diff --git a/apps/web/app/(auth)/profile/edit/page.tsx b/apps/web/app/(auth)/profile/edit/page.tsx
index 1814bf03..f8040614 100644
--- a/apps/web/app/(auth)/profile/edit/page.tsx
+++ b/apps/web/app/(auth)/profile/edit/page.tsx
@@ -1,5 +1,7 @@
+import { UserProfileEditView } from "@/src/domains/user/presentation/ui/views/user-profile-edit-view";
+
const ProfileEditPage = () => {
- return
ProfileEditPage
;
+ return ;
};
export default ProfileEditPage;
diff --git a/apps/web/public/images/intern-nuguri.png b/apps/web/public/images/intern-nuguri.png
new file mode 100644
index 00000000..a1e2a015
Binary files /dev/null and b/apps/web/public/images/intern-nuguri.png differ
diff --git a/apps/web/src/domains/user/domain/entities/user.entity.ts b/apps/web/src/domains/user/domain/entities/user.entity.ts
index 393ef485..e29deb76 100644
--- a/apps/web/src/domains/user/domain/entities/user.entity.ts
+++ b/apps/web/src/domains/user/domain/entities/user.entity.ts
@@ -23,6 +23,8 @@ export interface UserProfile {
getNickname(): string;
getEmail(): string | undefined;
getProfileImageUrl(): string | undefined;
+ getHeight(): number | undefined;
+ getWeight(): number | undefined;
getCreatedAt(): string | undefined;
getUpdatedAt(): string | undefined;
@@ -59,6 +61,8 @@ export class UserProfileEntity implements UserProfile {
private readonly _nickname: string;
private readonly _email?: string;
private readonly _profileImageUrl?: string;
+ private readonly _height?: number;
+ private readonly _weight?: number;
private readonly _createdAt?: string;
private readonly _updatedAt?: string;
@@ -67,6 +71,8 @@ export class UserProfileEntity implements UserProfile {
nickname: string;
email?: string;
profileImageUrl?: string;
+ height?: number;
+ weight?: number;
createdAt?: string;
updatedAt?: string;
}) {
@@ -123,6 +129,8 @@ export class UserProfileEntity implements UserProfile {
this._nickname = params.nickname;
this._email = params.email;
this._profileImageUrl = params.profileImageUrl;
+ this._height = params.height;
+ this._weight = params.weight;
this._createdAt = params.createdAt;
this._updatedAt = params.updatedAt;
}
@@ -144,6 +152,14 @@ export class UserProfileEntity implements UserProfile {
return this._profileImageUrl;
}
+ getHeight(): number | undefined {
+ return this._height;
+ }
+
+ getWeight(): number | undefined {
+ return this._weight;
+ }
+
getCreatedAt(): string | undefined {
return this._createdAt;
}
@@ -279,6 +295,8 @@ export class UserProfileEntity implements UserProfile {
nickname: this._nickname,
email: this._email,
profileImageUrl: this._profileImageUrl,
+ height: this._height,
+ weight: this._weight,
createdAt: this._createdAt,
updatedAt: this._updatedAt,
};
diff --git a/apps/web/src/domains/user/presentation/schemas/profile-edit.schema.ts b/apps/web/src/domains/user/presentation/schemas/profile-edit.schema.ts
new file mode 100644
index 00000000..3b309850
--- /dev/null
+++ b/apps/web/src/domains/user/presentation/schemas/profile-edit.schema.ts
@@ -0,0 +1,27 @@
+import { z } from "zod";
+
+export const profileEditSchema = z.object({
+ nickname: z
+ .string()
+ .min(2, "닉네임은 최소 2자 이상이어야 합니다")
+ .max(10, "닉네임은 최대 10자까지 가능합니다")
+ .regex(
+ /^[a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ]+$/,
+ "닉네임은 영문, 한글, 숫자만 사용할 수 있습니다",
+ )
+ .refine((val) => !val.includes(" "), {
+ message: "닉네임에 공백을 포함할 수 없습니다",
+ }),
+ height: z
+ .number()
+ .min(130, "키는 최소 130cm 이상이어야 합니다")
+ .max(220, "키는 최대 220cm까지 입력 가능합니다")
+ .optional(),
+ weight: z
+ .number()
+ .min(30, "몸무게는 최소 30kg 이상이어야 합니다")
+ .max(250, "몸무게는 최대 250kg까지 입력 가능합니다")
+ .optional(),
+});
+
+export type ProfileEditFormData = z.infer;
diff --git a/apps/web/src/domains/user/presentation/shared/adapters/user.adapter.ts b/apps/web/src/domains/user/presentation/shared/adapters/user.adapter.ts
index d3903ce5..e6bfda65 100644
--- a/apps/web/src/domains/user/presentation/shared/adapters/user.adapter.ts
+++ b/apps/web/src/domains/user/presentation/shared/adapters/user.adapter.ts
@@ -1,15 +1,6 @@
import type { UserProfile } from "../../../domain/entities/user.entity";
import type { UserProfileItem } from "../types/user.type";
-type UserProfileData = {
- id: number;
- nickname: string;
- email?: string;
- profileImageUrl?: string;
- createdAt?: string;
- updatedAt?: string;
-};
-
export const UserAdapter = {
/**
* Transform UserProfile entity to UI item
@@ -25,6 +16,8 @@ export const UserAdapter = {
nickname: profile.getNickname(),
email: profile.getEmail(),
profileImageUrl: profile.getProfileImageUrl(),
+ height: profile.getHeight(),
+ weight: profile.getWeight(),
createdAt: profile.getCreatedAt(),
updatedAt: profile.getUpdatedAt(),
};
@@ -170,7 +163,7 @@ export const UserAdapter = {
* @param changes - Partial profile data with proposed changes
* @returns True if profile can be updated with these changes
*/
- canEdit(profile: UserProfile, changes: Partial): boolean {
+ canEdit(profile: UserProfile, changes: Partial): boolean {
if (
"canUpdateProfile" in profile &&
typeof profile.canUpdateProfile === "function"
diff --git a/apps/web/src/domains/user/presentation/shared/types/user.type.ts b/apps/web/src/domains/user/presentation/shared/types/user.type.ts
index 74188c34..b3416f30 100644
--- a/apps/web/src/domains/user/presentation/shared/types/user.type.ts
+++ b/apps/web/src/domains/user/presentation/shared/types/user.type.ts
@@ -3,6 +3,8 @@ export interface UserProfileItem {
nickname: string;
email?: string;
profileImageUrl?: string;
+ height?: number;
+ weight?: number;
createdAt?: string;
updatedAt?: string;
}
diff --git a/apps/web/src/domains/user/presentation/shared/ui/components/user-points-balance/index.css.ts b/apps/web/src/domains/user/presentation/shared/ui/components/user-points-balance/index.css.ts
index 353c145c..b9e34b26 100644
--- a/apps/web/src/domains/user/presentation/shared/ui/components/user-points-balance/index.css.ts
+++ b/apps/web/src/domains/user/presentation/shared/ui/components/user-points-balance/index.css.ts
@@ -3,6 +3,7 @@ import { style } from "@vanilla-extract/css";
export const container = style({
position: "relative",
height: 190,
+ boxSizing: "border-box",
});
export const imageWrapper = style({
diff --git a/apps/web/src/domains/user/presentation/shared/ui/components/user-points-balance/index.tsx b/apps/web/src/domains/user/presentation/shared/ui/components/user-points-balance/index.tsx
index 39623dbd..e0c4410e 100644
--- a/apps/web/src/domains/user/presentation/shared/ui/components/user-points-balance/index.tsx
+++ b/apps/web/src/domains/user/presentation/shared/ui/components/user-points-balance/index.tsx
@@ -17,7 +17,7 @@ interface UserPointsBalanceProps {
export const UserPointsBalance = ({ balance }: UserPointsBalanceProps) => {
return (
-
+
diff --git a/apps/web/src/domains/user/presentation/shared/ui/sections/user-points-history-section/index.css.ts b/apps/web/src/domains/user/presentation/shared/ui/sections/user-points-history-section/index.css.ts
new file mode 100644
index 00000000..09290ccb
--- /dev/null
+++ b/apps/web/src/domains/user/presentation/shared/ui/sections/user-points-history-section/index.css.ts
@@ -0,0 +1,6 @@
+import { vars } from "@nugudi/themes";
+import { style } from "@vanilla-extract/css";
+
+export const container = style({
+ backgroundColor: vars.colors.$static.light.color.white,
+});
diff --git a/apps/web/src/domains/user/presentation/shared/ui/sections/user-points-history-section/index.tsx b/apps/web/src/domains/user/presentation/shared/ui/sections/user-points-history-section/index.tsx
index 326dac8e..6f662616 100644
--- a/apps/web/src/domains/user/presentation/shared/ui/sections/user-points-history-section/index.tsx
+++ b/apps/web/src/domains/user/presentation/shared/ui/sections/user-points-history-section/index.tsx
@@ -5,13 +5,14 @@ import {
type DailyPointData,
UserPointsHistoryList,
} from "../../components/user-points-history-list";
+import * as styles from "./index.css";
export const UserPointsHistorySection = () => {
// TODO: Replace with actual data from API
const pointsData = usePointsData();
return (
-
+
);
diff --git a/apps/web/src/domains/user/presentation/shared/ui/sections/user-profile-section/index.css.ts b/apps/web/src/domains/user/presentation/shared/ui/sections/user-profile-section/index.css.ts
index 32401873..8f173220 100644
--- a/apps/web/src/domains/user/presentation/shared/ui/sections/user-profile-section/index.css.ts
+++ b/apps/web/src/domains/user/presentation/shared/ui/sections/user-profile-section/index.css.ts
@@ -4,13 +4,14 @@ import { style } from "@vanilla-extract/css";
export const container = style({
width: "100%",
gap: "16px",
- padding: "16px",
+ paddingLeft: vars.box.spacing[4],
+ paddingRight: vars.box.spacing[4],
boxSizing: "border-box",
alignItems: "center",
});
export const profileImage = style({
- objectFit: "cover",
+ objectFit: "contain",
});
export const infoWrapper = style({
@@ -26,18 +27,24 @@ export const levelText = style({
color: vars.colors.$scale.main[500],
});
-export const nameText = style({
- ...classes.typography.title.t3,
- fontWeight: vars.typography.fontWeight[600],
- color: vars.colors.$scale.zinc[600],
-});
-
export const nameSuffix = style({
...classes.typography.body.b4,
});
export const editButton = style({
+ position: "relative",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
color: vars.colors.$scale.zinc[600],
+ cursor: "pointer",
+ transition: "color 0.2s ease",
+ "::before": {
+ content: '""',
+ position: "absolute",
+ inset: "-12px",
+ zIndex: 0,
+ },
":hover": {
color: vars.colors.$scale.main[500],
},
diff --git a/apps/web/src/domains/user/presentation/shared/ui/sections/user-profile-section/index.tsx b/apps/web/src/domains/user/presentation/shared/ui/sections/user-profile-section/index.tsx
index c2794b75..5df0e99b 100644
--- a/apps/web/src/domains/user/presentation/shared/ui/sections/user-profile-section/index.tsx
+++ b/apps/web/src/domains/user/presentation/shared/ui/sections/user-profile-section/index.tsx
@@ -1,7 +1,8 @@
"use client";
import { PencilIcon } from "@nugudi/assets-icons";
-import { Flex } from "@nugudi/react-components-layout";
+import { Badge } from "@nugudi/react-components-badge";
+import { Body, Flex, HStack, Title } from "@nugudi/react-components-layout";
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
@@ -24,12 +25,17 @@ export const UserProfileSection = () => {
const UserProfileSectionSkeleton = () => {
return (
-
+
-
-
+
+
+
+
+
+
+
+
-
);
};
@@ -40,23 +46,30 @@ const UserProfileSectionError = () => {
- Lv.1 기본 너구리
-
-
- 손님 님
-
+
+ Lv.1
+
+
+
+ 인턴 너구리
+
+
+
+ 손님
+
+
+
+
+
-
-
-
);
};
@@ -72,23 +85,30 @@ const UserProfileSectionContent = () => {
- Lv.1 기본 너구리
-
-
- {nickname} 님
-
+
+ Lv.1
+
+
+
+ 인턴 너구리
+
+
+
+ {nickname}
+
+
+
+
+
-
-
-
);
};
diff --git a/apps/web/src/domains/user/presentation/shared/ui/views/user-points-view/index.css.ts b/apps/web/src/domains/user/presentation/shared/ui/views/user-points-view/index.css.ts
index b7556fd2..30a99bf8 100644
--- a/apps/web/src/domains/user/presentation/shared/ui/views/user-points-view/index.css.ts
+++ b/apps/web/src/domains/user/presentation/shared/ui/views/user-points-view/index.css.ts
@@ -3,6 +3,7 @@ import { style } from "@vanilla-extract/css";
export const container = style({
boxSizing: "border-box",
+ backgroundColor: vars.colors.$static.light.zinc[50],
});
export const content = style({
diff --git a/apps/web/src/domains/user/presentation/shared/ui/views/user-points-view/index.tsx b/apps/web/src/domains/user/presentation/shared/ui/views/user-points-view/index.tsx
index e9f39f67..d134f372 100644
--- a/apps/web/src/domains/user/presentation/shared/ui/views/user-points-view/index.tsx
+++ b/apps/web/src/domains/user/presentation/shared/ui/views/user-points-view/index.tsx
@@ -15,10 +15,8 @@ export const UserPointsView = () => {
w="full"
className={styles.container}
>
-
-
-
-
+
+
);
diff --git a/apps/web/src/domains/user/presentation/shared/ui/views/user-profile-view/index.tsx b/apps/web/src/domains/user/presentation/shared/ui/views/user-profile-view/index.tsx
index f73149d6..0f7f95e1 100644
--- a/apps/web/src/domains/user/presentation/shared/ui/views/user-profile-view/index.tsx
+++ b/apps/web/src/domains/user/presentation/shared/ui/views/user-profile-view/index.tsx
@@ -1,5 +1,5 @@
import { AppHeader } from "@core/ui/components/app-header";
-import { Flex } from "@nugudi/react-components-layout";
+import { Flex, VStack } from "@nugudi/react-components-layout";
import { UserProfileLogoutButton } from "../../components/user-profile-logout-button";
import { UserProfileMenuSection } from "../../sections/user-profile-menu-section";
import { UserProfilePointSection } from "../../sections/user-profile-point-section";
@@ -10,8 +10,10 @@ export const UserProfileView = () => {
return (
-
-
+
+
+
+
diff --git a/apps/web/src/domains/user/presentation/ui/components/user-basic-info-form-component/index.tsx b/apps/web/src/domains/user/presentation/ui/components/user-basic-info-form-component/index.tsx
new file mode 100644
index 00000000..bcf04778
--- /dev/null
+++ b/apps/web/src/domains/user/presentation/ui/components/user-basic-info-form-component/index.tsx
@@ -0,0 +1,102 @@
+import { Button } from "@nugudi/react-components-button";
+import { Input } from "@nugudi/react-components-input";
+import { Box, Flex, Title, VStack } from "@nugudi/react-components-layout";
+import type { FieldErrors, UseFormRegister } from "react-hook-form";
+import type { ProfileEditFormData } from "../../../schemas/profile-edit.schema";
+
+interface UserBasicInfoFormComponentProps {
+ register: UseFormRegister;
+ errors: FieldErrors;
+ onCheckNickname: () => void;
+ onNicknameChange: (e: React.ChangeEvent) => void;
+ isCheckingNickname: boolean;
+}
+
+export const UserBasicInfoFormComponent = ({
+ register,
+ errors,
+ onCheckNickname,
+ onNicknameChange,
+ isCheckingNickname,
+}: UserBasicInfoFormComponentProps) => {
+ return (
+
+
+
+
+ );
+};
+
+function UserBasicInfoTitle() {
+ return 기본 정보;
+}
+
+type UserNicknameFieldProps = Pick<
+ UserBasicInfoFormComponentProps,
+ | "register"
+ | "errors"
+ | "onCheckNickname"
+ | "onNicknameChange"
+ | "isCheckingNickname"
+>;
+
+function UserNicknameField({
+ register,
+ errors,
+ onCheckNickname,
+ onNicknameChange,
+ isCheckingNickname,
+}: UserNicknameFieldProps) {
+ return (
+
+
+ {
+ register("nickname").onChange(e);
+ onNicknameChange(e);
+ }}
+ placeholder="정조이"
+ isError={!!errors.nickname}
+ errorMessage={errors.nickname?.message}
+ />
+
+
+
+ );
+}
+
+interface NicknameDuplicateCheckButtonProps
+ extends Pick<
+ UserBasicInfoFormComponentProps,
+ "onCheckNickname" | "isCheckingNickname"
+ > {}
+
+function NicknameDuplicateCheckButton({
+ onCheckNickname,
+ isCheckingNickname,
+}: NicknameDuplicateCheckButtonProps) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/domains/user/presentation/ui/components/user-physical-info-form-component/index.tsx b/apps/web/src/domains/user/presentation/ui/components/user-physical-info-form-component/index.tsx
new file mode 100644
index 00000000..9f12f1bb
--- /dev/null
+++ b/apps/web/src/domains/user/presentation/ui/components/user-physical-info-form-component/index.tsx
@@ -0,0 +1,144 @@
+import { Input } from "@nugudi/react-components-input";
+import { Body, HStack, Title, VStack } from "@nugudi/react-components-layout";
+import type { FieldErrors, UseFormRegister } from "react-hook-form";
+import type { ProfileEditFormData } from "../../../schemas/profile-edit.schema";
+
+interface UserPhysicalInfoFormComponentProps {
+ register: UseFormRegister;
+ errors: FieldErrors;
+ defaultHeight?: number;
+ defaultWeight?: number;
+}
+
+export const UserPhysicalInfoFormComponent = ({
+ register,
+ errors,
+ defaultHeight,
+ defaultWeight,
+}: UserPhysicalInfoFormComponentProps) => {
+ return (
+
+
+
+
+ );
+};
+
+function UserPhysicalInfoHeader() {
+ return (
+
+ 신체 정보
+
+
+ );
+}
+
+function UserPhysicalInfoDescription() {
+ return (
+
+ 체형별 맞춤 서비스를 위해 필요하며 다른 사람에게 공개되지 않습니다
+
+ );
+}
+
+interface UserPhysicalInfoInputFieldsProps
+ extends Pick<
+ UserPhysicalInfoFormComponentProps,
+ "register" | "errors" | "defaultHeight" | "defaultWeight"
+ > {}
+
+function UserPhysicalInfoInputFields({
+ register,
+ errors,
+ defaultHeight,
+ defaultWeight,
+}: UserPhysicalInfoInputFieldsProps) {
+ return (
+
+
+
+
+ );
+}
+
+interface UserHeightInputProps
+ extends Pick<
+ UserPhysicalInfoFormComponentProps,
+ "register" | "errors" | "defaultHeight"
+ > {}
+
+function UserHeightInput({
+ register,
+ errors,
+ defaultHeight,
+}: UserHeightInputProps) {
+ return (
+ {
+ if (!v || v === "") return undefined;
+ const n = Number(v);
+ return Number.isNaN(n) ? undefined : n;
+ },
+ })}
+ defaultValue={defaultHeight}
+ type="number"
+ placeholder="163"
+ isError={!!errors.height}
+ rightIcon={}
+ />
+ );
+}
+
+interface UserWeightInputProps
+ extends Pick<
+ UserPhysicalInfoFormComponentProps,
+ "register" | "errors" | "defaultWeight"
+ > {}
+
+function UserWeightInput({ register, errors }: UserWeightInputProps) {
+ return (
+ {
+ if (!v || v === "") return undefined;
+ const n = Number(v);
+ return Number.isNaN(n) ? undefined : n;
+ },
+ })}
+ type="number"
+ placeholder="80"
+ isError={!!errors.weight}
+ rightIcon={}
+ />
+ );
+}
+
+interface PhysicalUnitTextProps {
+ unit: string;
+}
+
+function PhysicalUnitText({ unit }: PhysicalUnitTextProps) {
+ return (
+
+ {unit}
+
+ );
+}
diff --git a/apps/web/src/domains/user/presentation/ui/sections/user-profile-edit-form-section-mock/index.tsx b/apps/web/src/domains/user/presentation/ui/sections/user-profile-edit-form-section-mock/index.tsx
new file mode 100644
index 00000000..965d6100
--- /dev/null
+++ b/apps/web/src/domains/user/presentation/ui/sections/user-profile-edit-form-section-mock/index.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Button } from "@nugudi/react-components-button";
+import { VStack } from "@nugudi/react-components-layout";
+import { useRouter } from "next/navigation";
+import { useForm } from "react-hook-form";
+import {
+ type ProfileEditFormData,
+ profileEditSchema,
+} from "../../../schemas/profile-edit.schema";
+import { UserBasicInfoFormComponent } from "../../components/user-basic-info-form-component";
+import { UserPhysicalInfoFormComponent } from "../../components/user-physical-info-form-component";
+
+const MOCK_PROFILE = {
+ nickname: "정조이",
+ height: 163,
+ weight: 40,
+};
+
+export const UserProfileEditFormSectionMock = () => {
+ const router = useRouter();
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(profileEditSchema),
+ defaultValues: MOCK_PROFILE,
+ });
+
+ const onSubmit = (data: ProfileEditFormData) => {
+ console.log("폼 제출:", data);
+ router.push("/profile");
+ };
+
+ return (
+
+
+ console.log("닉네임 중복 체크")}
+ onNicknameChange={() => {}}
+ isCheckingNickname={false}
+ />
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/domains/user/presentation/ui/views/user-profile-edit-view/index.tsx b/apps/web/src/domains/user/presentation/ui/views/user-profile-edit-view/index.tsx
new file mode 100644
index 00000000..514a4f8e
--- /dev/null
+++ b/apps/web/src/domains/user/presentation/ui/views/user-profile-edit-view/index.tsx
@@ -0,0 +1,12 @@
+import { VStack } from "@nugudi/react-components-layout";
+import { NavBar } from "@/src/core/ui";
+import { UserProfileEditFormSectionMock } from "../../sections/user-profile-edit-form-section-mock";
+
+export const UserProfileEditView = () => {
+ return (
+
+
+
+
+ );
+};