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 = () => { profile - Lv.1 기본 너구리 - -

- 손님 -

+ + Lv.1 + + + + 인턴 너구리 + + + + 손님 + + + + +
- - -
); }; @@ -72,23 +85,30 @@ const UserProfileSectionContent = () => { profile - 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 ( + + + + + ); +};