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}
-
+
);
};