diff --git a/.gitignore b/.gitignore index cd57444..961bbe7 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ next-env.d.ts # local env files .env.local /.vscode -/docs \ No newline at end of file +/docs +/.github/copilot-instructions.md \ No newline at end of file diff --git a/src/app/api/user/nickname/route.ts b/src/app/api/user/nickname/route.ts new file mode 100644 index 0000000..a8b62d6 --- /dev/null +++ b/src/app/api/user/nickname/route.ts @@ -0,0 +1,50 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import { createClient as createServerClient } from "@/shared/utils/supabase/server"; + +export async function PATCH(request: Request) { + try { + const supabase = createServerClient(cookies()); + const { data: userData, error: userError } = await supabase.auth.getUser(); + + if (userError || !userData.user) { + return NextResponse.json({ status: "error", data: null, error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { nickname } = body; + + // 닉네임 검증 + if (!nickname || typeof nickname !== "string") { + return NextResponse.json({ status: "error", data: null, error: "닉네임을 입력해주세요." }, { status: 400 }); + } + + if (nickname.length < 2 || nickname.length > 20) { + return NextResponse.json( + { status: "error", data: null, error: "닉네임은 2자 이상 20자 이하로 입력해주세요." }, + { status: 400 }, + ); + } + + // 사용자 메타데이터 업데이트 + const { data, error } = await supabase.auth.updateUser({ + data: { + nickname, + }, + }); + + if (error) { + console.error("Nickname update error:", error); + return NextResponse.json({ status: "error", data: null, error: error.message }, { status: 500 }); + } + + return NextResponse.json( + { status: "success", data: { nickname: data.user.user_metadata.nickname } }, + { status: 200 }, + ); + } catch (error) { + console.error("Nickname API error:", error); + return NextResponse.json({ status: "error", data: null, error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/setting/page.tsx b/src/app/setting/page.tsx index afa321f..304b171 100644 --- a/src/app/setting/page.tsx +++ b/src/app/setting/page.tsx @@ -1,3 +1,14 @@ -export default function SettingsPage() { - return
Settings Page
; +import { ProfileSection, UserInfoSection } from "@/features"; + +export default function SettingPage() { + return ( +
+

프로필 설정

+ +
+ + +
+
+ ); } diff --git a/src/features/index.ts b/src/features/index.ts index 2483cb0..c33a01f 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -1,4 +1,5 @@ export * from "./home"; export * from "./login"; +export * from "./setting"; export * from "./signup"; export * from "./wallet"; diff --git a/src/features/setting/apis/change-nickname.api.ts b/src/features/setting/apis/change-nickname.api.ts new file mode 100644 index 0000000..94f866e --- /dev/null +++ b/src/features/setting/apis/change-nickname.api.ts @@ -0,0 +1,26 @@ +import { APIResponse } from "@/shared"; + +export interface ChangeNicknameParams { + nickname: string; +} + +export type ChangeNicknameResponse = APIResponse<{ + nickname: string; +}>; + +export const changeNicknameAPI = async ({ nickname }: ChangeNicknameParams): Promise => { + const response = await fetch("/api/user/nickname", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ nickname }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "닉네임 변경에 실패했습니다."); + } + + return response.json(); +}; diff --git a/src/features/setting/apis/index.ts b/src/features/setting/apis/index.ts new file mode 100644 index 0000000..ab3fb33 --- /dev/null +++ b/src/features/setting/apis/index.ts @@ -0,0 +1 @@ +export * from "./change-nickname.api"; diff --git a/src/features/setting/components/common/index.ts b/src/features/setting/components/common/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/setting/components/features/EmailField.tsx b/src/features/setting/components/features/EmailField.tsx new file mode 100644 index 0000000..5170d38 --- /dev/null +++ b/src/features/setting/components/features/EmailField.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Label, Skeleton } from "@/shared"; + +import { useUserInfo } from "../../hooks"; + +export const EmailField = () => { + const { userEmail, isLoaded } = useUserInfo(); + + return ( +
+ +
+ {!isLoaded ? :

{userEmail}

} +
+
+ ); +}; diff --git a/src/features/setting/components/features/LastLoginField.tsx b/src/features/setting/components/features/LastLoginField.tsx new file mode 100644 index 0000000..21cfd04 --- /dev/null +++ b/src/features/setting/components/features/LastLoginField.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Label, Skeleton } from "@/shared"; + +import { useUserInfo } from "../../hooks"; +import { formattedDate } from "../../utils"; + +export const LastLoginField = () => { + const { lastUpdated, isLoaded } = useUserInfo(); + + const formattedLastUpdated = formattedDate(lastUpdated || ""); + + return ( +
+ +
+ {!isLoaded ? ( + + ) : ( +

{formattedLastUpdated}

+ )} +
+
+ ); +}; diff --git a/src/features/setting/components/features/NicknameField.tsx b/src/features/setting/components/features/NicknameField.tsx new file mode 100644 index 0000000..fde89f5 --- /dev/null +++ b/src/features/setting/components/features/NicknameField.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Button, Input, Label, Skeleton, Spinner } from "@/shared"; + +import { useChangeNickname, useUserInfo } from "../../hooks"; + +export const NicknameField = () => { + const { userNickname, isLoaded } = useUserInfo(); + + const { fieldState, inputValue, onChangeNickname, onClickEditButton, isPending } = useChangeNickname({ + userNickname, + }); + + return ( +
+ +
+ {fieldState === "edit" ? ( + + ) : ( +
+ {!isLoaded ? :

{userNickname}

} +
+ )} + +
+
+ ); +}; diff --git a/src/features/setting/components/features/index.ts b/src/features/setting/components/features/index.ts new file mode 100644 index 0000000..8971f9e --- /dev/null +++ b/src/features/setting/components/features/index.ts @@ -0,0 +1,3 @@ +export * from "./EmailField"; +export * from "./LastLoginField"; +export * from "./NicknameField"; diff --git a/src/features/setting/components/index.ts b/src/features/setting/components/index.ts new file mode 100644 index 0000000..0e84926 --- /dev/null +++ b/src/features/setting/components/index.ts @@ -0,0 +1 @@ +export * from "./features"; diff --git a/src/features/setting/hooks/index.ts b/src/features/setting/hooks/index.ts new file mode 100644 index 0000000..f54065d --- /dev/null +++ b/src/features/setting/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useUserInfo"; +export * from "./useChangeNickname"; diff --git a/src/features/setting/hooks/useChangeNickname.ts b/src/features/setting/hooks/useChangeNickname.ts new file mode 100644 index 0000000..a06a723 --- /dev/null +++ b/src/features/setting/hooks/useChangeNickname.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; + +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { createClient } from "@/shared/utils/supabase"; + +import { changeNicknameAPI } from "../apis"; + +type Props = { + userNickname: string; +}; + +export const useChangeNickname = ({ userNickname }: Props) => { + const [fieldState, setFieldState] = useState<"view" | "edit">("view"); + const [inputValue, setInputValue] = useState(userNickname); + const supabase = createClient(); + + // userNickname이 변경되면 inputValue 동기화 (세션 새로고침 후) + useEffect(() => { + setInputValue(userNickname); + }, [userNickname]); + + const onChangeNickname = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + }; + + const { mutate: changeNickname, isPending } = useMutation({ + mutationFn: () => changeNicknameAPI({ nickname: inputValue }), + onError: (error: Error) => { + toast.error(error.message); + setInputValue(userNickname); // 에러 시 원래 값으로 복구 + }, + onSuccess: async () => { + toast.success("닉네임이 변경되었습니다."); + setFieldState("view"); + + // Supabase 세션 강제 갱신 (AuthProvider가 감지하여 Zustand 스토어 업데이트) + await supabase.auth.refreshSession(); + }, + }); + + const onClickEditButton = () => { + if (fieldState === "edit") { + if (inputValue.trim() === userNickname) { + setFieldState("view"); + return; + } + changeNickname(); + } else { + setFieldState("edit"); + } + }; + + return { + fieldState, + inputValue, + onChangeNickname, + onClickEditButton, + isPending, + }; +}; diff --git a/src/features/setting/hooks/useUserInfo.ts b/src/features/setting/hooks/useUserInfo.ts new file mode 100644 index 0000000..0e0f572 --- /dev/null +++ b/src/features/setting/hooks/useUserInfo.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useGetSession, useIsSessionLoaded } from "@/shared"; + +export const useUserInfo = () => { + const session = useGetSession(); + const isLoaded = useIsSessionLoaded(); + + const userAvatarUrl = session?.user?.user_metadata?.avatar_url || ""; + const userNickname = session?.user?.user_metadata?.nickname || ""; + const userEmail = session?.user?.email || ""; + const lastUpdated = session?.user?.updated_at || ""; + + return { userAvatarUrl, userNickname, userEmail, lastUpdated, isLoaded }; +}; diff --git a/src/features/setting/index.ts b/src/features/setting/index.ts new file mode 100644 index 0000000..4aedf59 --- /dev/null +++ b/src/features/setting/index.ts @@ -0,0 +1 @@ +export * from "./ui"; diff --git a/src/features/setting/ui/ProfileSection.tsx b/src/features/setting/ui/ProfileSection.tsx new file mode 100644 index 0000000..d26d87d --- /dev/null +++ b/src/features/setting/ui/ProfileSection.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Avatar, AvatarFallback, AvatarImage, Button } from "@/shared"; +import { Camera, UserRound } from "lucide-react"; + +import { useUserInfo } from "../hooks"; + +export const ProfileSection = () => { + const { userAvatarUrl } = useUserInfo(); + + return ( +
+
+ + + + + + + +
+

프로필 사진 변경

+
+ ); +}; diff --git a/src/features/setting/ui/UserInfoSection.tsx b/src/features/setting/ui/UserInfoSection.tsx new file mode 100644 index 0000000..6c07b00 --- /dev/null +++ b/src/features/setting/ui/UserInfoSection.tsx @@ -0,0 +1,11 @@ +import { EmailField, LastLoginField, NicknameField } from "../components"; + +export const UserInfoSection = () => { + return ( +
+ + + +
+ ); +}; diff --git a/src/features/setting/ui/index.ts b/src/features/setting/ui/index.ts new file mode 100644 index 0000000..1c91256 --- /dev/null +++ b/src/features/setting/ui/index.ts @@ -0,0 +1,2 @@ +export * from "./ProfileSection"; +export * from "./UserInfoSection"; diff --git a/src/features/setting/utils/formatted-date.ts b/src/features/setting/utils/formatted-date.ts new file mode 100644 index 0000000..df790dc --- /dev/null +++ b/src/features/setting/utils/formatted-date.ts @@ -0,0 +1,13 @@ +export const formattedDate = (dateString: string): string => { + const date = dateString ? new Date(dateString) : null; + + const year = date ? date.getFullYear() : ""; + const month = date ? String(date.getMonth() + 1).padStart(2, "0") : ""; + const day = date ? String(date.getDate()).padStart(2, "0") : ""; + + const Hour = date ? String(date.getHours()).padStart(2, "0") : ""; + const Minutes = date ? String(date.getMinutes()).padStart(2, "0") : ""; + const Seconds = date ? String(date.getSeconds()).padStart(2, "0") : ""; + + return `${year}년 ${month}월 ${day}일 ${Hour}:${Minutes}:${Seconds}`; +}; diff --git a/src/features/setting/utils/index.ts b/src/features/setting/utils/index.ts new file mode 100644 index 0000000..0c8d06d --- /dev/null +++ b/src/features/setting/utils/index.ts @@ -0,0 +1 @@ +export * from "./formatted-date"; diff --git a/src/shared/components/features/header/Header.tsx b/src/shared/components/features/header/Header.tsx index 3ae82d6..e2936d8 100644 --- a/src/shared/components/features/header/Header.tsx +++ b/src/shared/components/features/header/Header.tsx @@ -1,13 +1,10 @@ import Image from "next/image"; import Link from "next/link"; -import { UserRound, Wallet } from "lucide-react"; - import Logo from "@/shared/assets/logo.webp"; import { ROUTER_PATH } from "../../../constants"; import { LoginButton } from "../../common"; -import { Avatar, AvatarFallback, AvatarImage } from "../../ui"; export const Header = () => { return ( @@ -35,10 +32,10 @@ export const Header = () => { Portfolio - Settings + Setting diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts index 43ba4b7..4946e48 100644 --- a/src/shared/components/ui/index.ts +++ b/src/shared/components/ui/index.ts @@ -9,6 +9,7 @@ export * from "./input"; export * from "./label"; export * from "./select"; export * from "./separator"; +export * from "./skeleton"; export * from "./sonner"; export * from "./spinner"; export * from "./table"; diff --git a/src/shared/components/ui/skeleton.tsx b/src/shared/components/ui/skeleton.tsx new file mode 100644 index 0000000..c270195 --- /dev/null +++ b/src/shared/components/ui/skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from "../../utils"; + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +export { Skeleton }; diff --git a/src/shared/constants/router-path.ts b/src/shared/constants/router-path.ts index 2e248f6..eca7e92 100644 --- a/src/shared/constants/router-path.ts +++ b/src/shared/constants/router-path.ts @@ -6,6 +6,6 @@ export const ROUTER_PATH = { AUTO_TRADE: "/auto-trade", TRADE: "/trade", PORTFOLIO: "/portfolio", - SETTINGS: "/settings", + SETTING: "/setting", WALLET: "/wallet", }; diff --git a/src/shared/hooks/useSelectChange.ts b/src/shared/hooks/useSelectChange.ts index 797b20c..46c89f3 100644 --- a/src/shared/hooks/useSelectChange.ts +++ b/src/shared/hooks/useSelectChange.ts @@ -26,7 +26,7 @@ export const useSelectChange = () => { console.error("로그아웃 에러:", error); } } else if (value === "setting") { - router.push(ROUTER_PATH.SETTINGS); + router.push(ROUTER_PATH.SETTING); } }; diff --git a/src/shared/provider/AppProvider.tsx b/src/shared/provider/AppProvider.tsx index b8009be..0818d6a 100644 --- a/src/shared/provider/AppProvider.tsx +++ b/src/shared/provider/AppProvider.tsx @@ -10,7 +10,7 @@ export const AppProvider = ({ children }: Props) => { return ( {children} - + ); };