From 50be0a60b41669bb63ccdd2fd9ceb40ed01c7ae6 Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sat, 30 May 2026 11:58:56 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=90=9B=20vite=EA=B0=80=20alias=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20src=EB=A5=BC=20@?= =?UTF-8?q?=EC=9D=98=20=EB=A3=A8=ED=8A=B8=EB=A1=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tsconfig.app.json | 2 +- tsconfig.node.json | 5 ++++- vite.config.ts | 6 ++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/tsconfig.app.json b/tsconfig.app.json index 144e3bc..560824b 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -22,7 +22,7 @@ "noUncheckedSideEffectImports": true, "paths": { - "@/*": ["./*"] + "@/*": ["./src/*"] } }, "include": ["src"] diff --git a/tsconfig.node.json b/tsconfig.node.json index b2e9f3d..3584b92 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -18,5 +18,8 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts"], + "paths": { + "@/*": ["./src/*"] + } } diff --git a/vite.config.ts b/vite.config.ts index 1551971..5af65d2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import babel from '@rolldown/plugin-babel'; import tailwindcss from '@tailwindcss/vite'; import react, { reactCompilerPreset } from '@vitejs/plugin-react'; +import path from 'path'; import { defineConfig } from 'vite'; // https://vite.dev/config/ @@ -10,4 +11,9 @@ export default defineConfig({ react(), babel({ presets: [reactCompilerPreset()] }), ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, }); From 1dff24700905dd4ff3b71ad7c6eab52c749f512f Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sat, 30 May 2026 11:59:24 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9B=20tailwindcss=EA=B0=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EC=98=A4=ED=83=80=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 583a621..e1caca4 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + Vite + React + TS From d83c639ded17cfe7fc5ee7a1175f1541983018f9 Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sat, 30 May 2026 12:00:06 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20shadcn/ui=EB=A1=9C=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C,=20input,=20label,=20tab=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=9E=84=ED=8F=AC=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- {lib => src/lib}/utils.ts | 0 src/widgets/common/ui/card.tsx | 103 ++++++++++++++++++++++++++++++++ src/widgets/common/ui/input.tsx | 19 ++++++ src/widgets/common/ui/label.tsx | 24 ++++++++ src/widgets/common/ui/tabs.tsx | 88 +++++++++++++++++++++++++++ 5 files changed, 234 insertions(+) rename {lib => src/lib}/utils.ts (100%) create mode 100644 src/widgets/common/ui/card.tsx create mode 100644 src/widgets/common/ui/input.tsx create mode 100644 src/widgets/common/ui/label.tsx create mode 100644 src/widgets/common/ui/tabs.tsx diff --git a/lib/utils.ts b/src/lib/utils.ts similarity index 100% rename from lib/utils.ts rename to src/lib/utils.ts diff --git a/src/widgets/common/ui/card.tsx b/src/widgets/common/ui/card.tsx new file mode 100644 index 0000000..f7801e2 --- /dev/null +++ b/src/widgets/common/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Card({ + className, + size = 'default', + ...props +}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) { + return ( +
img:first-child]:pt-0 has-data-[slot=card-footer]:pb-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl', + className + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +}; diff --git a/src/widgets/common/ui/input.tsx b/src/widgets/common/ui/input.tsx new file mode 100644 index 0000000..6d4e927 --- /dev/null +++ b/src/widgets/common/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ); +} + +export { Input }; diff --git a/src/widgets/common/ui/label.tsx b/src/widgets/common/ui/label.tsx new file mode 100644 index 0000000..645c558 --- /dev/null +++ b/src/widgets/common/ui/label.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { Label as LabelPrimitive } from 'radix-ui'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/src/widgets/common/ui/tabs.tsx b/src/widgets/common/ui/tabs.tsx new file mode 100644 index 0000000..657faaa --- /dev/null +++ b/src/widgets/common/ui/tabs.tsx @@ -0,0 +1,88 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { Tabs as TabsPrimitive } from 'radix-ui'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Tabs({ + className, + orientation = 'horizontal', + ...props +}: React.ComponentProps) { + return ( + + ); +} + +const tabsListVariants = cva( + 'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none', + { + variants: { + variant: { + default: 'bg-muted', + line: 'gap-1 bg-transparent', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +function TabsList({ + className, + variant = 'default', + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ); +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }; From 8b5e5abc73587d1c1090ae04a9d3e417c492f47c Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sat, 30 May 2026 12:00:24 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=A8=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=8F=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20UI=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/agreements.ts | 20 + src/entities/input.ts | 11 + .../auth/presenter/authFormPresentation.ts | 123 +++++++ .../auth/presenter/authInputPresentation.ts | 147 ++++++++ src/pages/sign-in.tsx | 9 +- src/pages/sign-up.tsx | 9 +- src/widgets/auth/index.ts | 2 + src/widgets/auth/ui/sign-in-form.tsx | 147 ++++++++ src/widgets/auth/ui/sign-up-form.tsx | 347 ++++++++++++++++++ 9 files changed, 803 insertions(+), 12 deletions(-) create mode 100644 src/entities/agreements.ts create mode 100644 src/entities/input.ts create mode 100644 src/feature/auth/presenter/authFormPresentation.ts create mode 100644 src/feature/auth/presenter/authInputPresentation.ts create mode 100644 src/widgets/auth/index.ts create mode 100644 src/widgets/auth/ui/sign-in-form.tsx create mode 100644 src/widgets/auth/ui/sign-up-form.tsx diff --git a/src/entities/agreements.ts b/src/entities/agreements.ts new file mode 100644 index 0000000..6257371 --- /dev/null +++ b/src/entities/agreements.ts @@ -0,0 +1,20 @@ +export type Agreement = { + id: string; + label: string; + required: boolean; + checked: boolean; +}; + +export const TERMS: Omit[] = [ + { id: 'terms-of-service', label: '이용약관 동의 (필수)', required: true }, + { + id: 'privacy-policy', + label: '개인정보 수집 및 이용 동의 (필수)', + required: true, + }, + { + id: 'marketing', + label: '마케팅 정보 수신 동의 (선택)', + required: false, + }, +]; diff --git a/src/entities/input.ts b/src/entities/input.ts new file mode 100644 index 0000000..9141aac --- /dev/null +++ b/src/entities/input.ts @@ -0,0 +1,11 @@ +export type Input = { + value: T; + isError: boolean; + onChange: (value: T) => void; +}; + +export type InputForForm = Input; + +export type InputWithDetailedError = Input & { + detailedError: E; +}; diff --git a/src/feature/auth/presenter/authFormPresentation.ts b/src/feature/auth/presenter/authFormPresentation.ts new file mode 100644 index 0000000..a0e2755 --- /dev/null +++ b/src/feature/auth/presenter/authFormPresentation.ts @@ -0,0 +1,123 @@ +import type { Agreement } from '@/entities/agreements'; +import type { + Input, + InputForForm, + InputWithDetailedError, +} from '@/entities/input'; +import type { AuthInputPresentation } from '@/feature/auth/presenter/authInputPresentation'; + +type InitialFormState = { + email?: string; + mail?: string; + username?: string; + password?: string; + code?: string; +}; + +type AuthFormPresentation = { + useValidator({ + initialState, + authInputPresentation, + }: { + initialState?: InitialFormState; + authInputPresentation: AuthInputPresentation; + }): { + inputStates: { + mail: Input; + username: Input; + password: InputWithDetailedError< + string, + { + englishError: boolean; + numberError: boolean; + specialCharError: boolean; + lengthError: boolean; + patternError: boolean; + } + >; + passwordConfirm: Input; + newPassword: InputWithDetailedError< + string, + { + englishError: boolean; + numberError: boolean; + specialCharError: boolean; + lengthError: boolean; + patternError: boolean; + } + >; + newPasswordConfirm: Input; + code: Input; + emailVerifySuccessCode: Input; + agreements: Input; + }; + formStates: { + mail: InputForForm; + username: InputForForm; + password: InputForForm; + newPassword: Input; + code: InputForForm; + emailVerifySuccessCode: InputForForm; + agreements: InputForForm; + }; + }; +}; + +export const authFormPresentation: AuthFormPresentation = { + useValidator: ({ initialState, authInputPresentation }) => { + const initialStateForInput = { + mail: initialState?.mail, + username: initialState?.username, + password: initialState?.password, + code: initialState?.code, + }; + + const { + mail, + username, + password, + passwordConfirm, + newPassword, + newPasswordConfirm, + code, + emailVerifySuccessCode, + agreements, + } = authInputPresentation.useValidator({ + initialState: initialStateForInput, + }); + + return { + inputStates: { + mail, + username, + password, + passwordConfirm, + newPassword, + newPasswordConfirm, + code, + emailVerifySuccessCode, + agreements, + }, + formStates: { + mail, + username, + password, + newPassword: { + ...newPassword, + isError: newPassword.isError || newPassword.value === password.value, + }, + code, + emailVerifySuccessCode: { + ...emailVerifySuccessCode, + isError: emailVerifySuccessCode.value.length === 0, + }, + agreements: { + ...agreements, + isError: agreements.value + .filter((agreement) => agreement.required) + .some((agreement) => !agreement.checked), + }, + }, + }; + }, +}; diff --git a/src/feature/auth/presenter/authInputPresentation.ts b/src/feature/auth/presenter/authInputPresentation.ts new file mode 100644 index 0000000..a94d5ce --- /dev/null +++ b/src/feature/auth/presenter/authInputPresentation.ts @@ -0,0 +1,147 @@ +import { useState } from 'react'; + +import type { Agreement } from '@/entities/agreements'; +import { TERMS } from '@/entities/agreements'; +import type { Input, InputWithDetailedError } from '@/entities/input'; + +type InitialInputState = { + mail?: string; + username?: string; + password?: string; + code?: string; +}; + +export type AuthInputPresentation = { + useValidator({ initialState }: { initialState?: InitialInputState }): { + mail: Input; + username: Input; + password: InputWithDetailedError< + string, + { + englishError: boolean; + numberError: boolean; + specialCharError: boolean; + lengthError: boolean; + patternError: boolean; + } + >; + passwordConfirm: Input; + newPassword: InputWithDetailedError< + string, + { + englishError: boolean; + numberError: boolean; + specialCharError: boolean; + lengthError: boolean; + patternError: boolean; + } + >; + newPasswordConfirm: Input; + code: Input; + emailVerifySuccessCode: Input; + agreements: Input; + }; +}; + +const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; +const PASSWORD_REGEX = /^.{8,64}$/; +const PASSWORD_DETAIL_REGEX = { + ENGLISH_REGEX: /(?=.*[A-Z])(?=.*[a-z])/, + NUMBER_REGEX: /\d/, + SPECIAL_CHAR_REGEX: /[@#$!^*]/, + LENGTH_REGEX: /^.{8,64}$/, + INVALID_PATTERN_REGEX: + /(012|123|234|345|456|567|678|789|890|abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz)|([a-zA-Z0-9])\2{2,}/i, +}; +const CODE_REGEX = /^\d{6}$/; +const USERNAME_REGEX = /^([가-힣]{2,6}|[A-Za-z]{2,20})$/; + +export const authInputPresentation: AuthInputPresentation = { + useValidator: ({ initialState = {} }) => { + const [mail, setMail] = useState( + initialState.mail !== undefined ? initialState.mail : '' + ); + const [username, setUsername] = useState( + initialState.username !== undefined ? initialState.username : '' + ); + const [password, setPassword] = useState( + initialState.password !== undefined ? initialState.password : '' + ); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [newPasswordConfirm, setNewPasswordConfirm] = useState(''); + const [code, setCode] = useState( + initialState.code !== undefined ? initialState.code : '' + ); + const [emailVerifySuccessCode, setEmailVerifySuccessCode] = useState(''); + const [agreements, setAgreements] = useState( + TERMS.map((item) => ({ ...item, checked: false })) + ); + + return { + mail: { + isError: !EMAIL_REGEX.test(mail), + value: mail, + onChange: setMail, + }, + password: { + isError: !PASSWORD_REGEX.test(password), + value: password, + detailedError: { + englishError: !PASSWORD_DETAIL_REGEX.ENGLISH_REGEX.test(password), + numberError: !PASSWORD_DETAIL_REGEX.NUMBER_REGEX.test(password), + specialCharError: + !PASSWORD_DETAIL_REGEX.SPECIAL_CHAR_REGEX.test(password), + lengthError: !PASSWORD_DETAIL_REGEX.LENGTH_REGEX.test(password), + patternError: + PASSWORD_DETAIL_REGEX.INVALID_PATTERN_REGEX.test(password), + }, + onChange: setPassword, + }, + passwordConfirm: { + isError: password !== passwordConfirm, + value: passwordConfirm, + onChange: setPasswordConfirm, + }, + newPassword: { + isError: !PASSWORD_REGEX.test(newPassword), + value: newPassword, + detailedError: { + englishError: !PASSWORD_DETAIL_REGEX.ENGLISH_REGEX.test(newPassword), + numberError: !PASSWORD_DETAIL_REGEX.NUMBER_REGEX.test(newPassword), + specialCharError: + !PASSWORD_DETAIL_REGEX.SPECIAL_CHAR_REGEX.test(newPassword), + lengthError: !PASSWORD_DETAIL_REGEX.LENGTH_REGEX.test(newPassword), + patternError: + PASSWORD_DETAIL_REGEX.INVALID_PATTERN_REGEX.test(newPassword), + }, + onChange: setNewPassword, + }, + newPasswordConfirm: { + isError: newPassword !== newPasswordConfirm, + value: newPasswordConfirm, + onChange: setNewPasswordConfirm, + }, + code: { + isError: !CODE_REGEX.test(code), + value: code, + onChange: setCode, + }, + username: { + isError: !USERNAME_REGEX.test(username), + value: username, + onChange: setUsername, + }, + emailVerifySuccessCode: { + isError: false, + value: emailVerifySuccessCode, + onChange: setEmailVerifySuccessCode, + }, + agreements: { + isError: false, + value: agreements, + onChange: setAgreements, + }, + }; + }, +}; diff --git a/src/pages/sign-in.tsx b/src/pages/sign-in.tsx index 6b3811e..5a1f104 100644 --- a/src/pages/sign-in.tsx +++ b/src/pages/sign-in.tsx @@ -1,12 +1,9 @@ -import { useRouteNavigation } from '../routes/use-route-navigation'; +import { SignInForm } from '@/widgets/auth'; export const SignInPage = () => { - const { toMain } = useRouteNavigation(); - return ( -
-

로그인 페이지입니다.

- +
+
); }; diff --git a/src/pages/sign-up.tsx b/src/pages/sign-up.tsx index 4c09be7..4f3ae2d 100644 --- a/src/pages/sign-up.tsx +++ b/src/pages/sign-up.tsx @@ -1,12 +1,9 @@ -import { useRouteNavigation } from '../routes/use-route-navigation'; +import { SignUpForm } from '@/widgets/auth'; export const SignUpPage = () => { - const { toMain } = useRouteNavigation(); - return ( -
-

회원가입 페이지입니다.

- +
+
); }; diff --git a/src/widgets/auth/index.ts b/src/widgets/auth/index.ts new file mode 100644 index 0000000..82207ea --- /dev/null +++ b/src/widgets/auth/index.ts @@ -0,0 +1,2 @@ +export { SignInForm } from './ui/sign-in-form'; +export { SignUpForm } from './ui/sign-up-form'; diff --git a/src/widgets/auth/ui/sign-in-form.tsx b/src/widgets/auth/ui/sign-in-form.tsx new file mode 100644 index 0000000..7f53074 --- /dev/null +++ b/src/widgets/auth/ui/sign-in-form.tsx @@ -0,0 +1,147 @@ +import { Eye, EyeOff } from 'lucide-react'; +import { useState } from 'react'; + +import { authFormPresentation } from '@/feature/auth/presenter/authFormPresentation'; +import { authInputPresentation } from '@/feature/auth/presenter/authInputPresentation'; +import { useRouteNavigation } from '@/routes/use-route-navigation'; +import { Button } from '@/widgets/common/ui/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/widgets/common/ui/card'; +import { Input } from '@/widgets/common/ui/input'; +import { Label } from '@/widgets/common/ui/label'; +import { Tabs, TabsList, TabsTrigger } from '@/widgets/common/ui/tabs'; + +type Role = 'OWNER' | 'REVIEWER'; + +export function SignInForm() { + const { toSignUp } = useRouteNavigation(); + const [role, setRole] = useState('REVIEWER'); + const [showPassword, setShowPassword] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [serverError, setServerError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const { inputStates, formStates } = authFormPresentation.useValidator({ + authInputPresentation, + }); + + const { mail, password } = inputStates; + + const handleSubmit = (e: { preventDefault: () => void }) => { + e.preventDefault(); + setSubmitted(true); + setServerError(''); + if (formStates.mail.isError || password.isError) { + return; + } + setIsLoading(true); + // TODO: call signIn usecase + setIsLoading(false); + }; + + return ( + + + + 로그인 + + + +
+ setRole(v as Role)}> + + + 손님 + + + 사장님 + + + + +
+ {/* 이메일 */} +
+ + mail.onChange(e.target.value)} + aria-invalid={submitted && mail.isError} + autoComplete="email" + /> + {submitted && formStates.mail.isError && ( +

+ 올바른 이메일을 입력해 주세요. +

+ )} +
+ + {/* 비밀번호 */} +
+ +
+ password.onChange(e.target.value)} + aria-invalid={submitted && password.isError} + autoComplete="current-password" + className="pr-10" + /> + +
+ {submitted && password.isError && ( +

+ 비밀번호를 입력해 주세요. +

+ )} +
+ + {serverError && ( +

+ {serverError} +

+ )} + + +
+ +

+ 계정이 없으신가요?{' '} + +

+
+
+
+ ); +} diff --git a/src/widgets/auth/ui/sign-up-form.tsx b/src/widgets/auth/ui/sign-up-form.tsx new file mode 100644 index 0000000..f505803 --- /dev/null +++ b/src/widgets/auth/ui/sign-up-form.tsx @@ -0,0 +1,347 @@ +import { Check, Eye, EyeOff, X } from 'lucide-react'; +import { useState } from 'react'; + +import { authFormPresentation } from '@/feature/auth/presenter/authFormPresentation'; +import { authInputPresentation } from '@/feature/auth/presenter/authInputPresentation'; +import { useRouteNavigation } from '@/routes/use-route-navigation'; +import { Button } from '@/widgets/common/ui/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/widgets/common/ui/card'; +import { Input } from '@/widgets/common/ui/input'; +import { Label } from '@/widgets/common/ui/label'; +import { Tabs, TabsList, TabsTrigger } from '@/widgets/common/ui/tabs'; + +type Role = 'OWNER' | 'REVIEWER'; + +export function SignUpForm() { + const { toSignIn } = useRouteNavigation(); + const [role, setRole] = useState('REVIEWER'); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [codeSent, setCodeSent] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [serverError, setServerError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const { inputStates, formStates } = authFormPresentation.useValidator({ + authInputPresentation, + }); + + const { + mail, + username, + password, + passwordConfirm, + code, + emailVerifySuccessCode, + } = inputStates; + + const isEmailVerified = !formStates.emailVerifySuccessCode.isError; + + const handleSendCode = () => { + if (formStates.mail.isError) { + setSubmitted(true); + return; + } + setCodeSent(true); + // TODO: API call to send verification code + }; + + const handleVerifyCode = () => { + if (code.isError) { + return; + } + emailVerifySuccessCode.onChange(code.value); + }; + + const handleSubmit = (e: { preventDefault: () => void }) => { + e.preventDefault(); + setSubmitted(true); + setServerError(''); + + const isValid = + !username.isError && + !formStates.mail.isError && + isEmailVerified && + !password.isError && + !passwordConfirm.isError; + + if (!isValid) { + return; + } + setIsLoading(true); + // TODO: call signUp usecase + setIsLoading(false); + }; + + const { detailedError } = password; + const showPasswordRequirements = password.value.length > 0; + + return ( + + + + 회원가입 + + + +
+ setRole(v as Role)}> + + + 손님 + + + 사장님 + + + + +
+ {/* 실명 */} +
+ + username.onChange(e.target.value)} + aria-invalid={submitted && username.isError} + autoComplete="name" + /> + {submitted && username.isError && ( +

+ 한글 2~6자 또는 영문 2~20자로 입력해 주세요. +

+ )} +
+ + {/* 이메일 + 인증 코드 발송 */} +
+ +
+ { + mail.onChange(e.target.value); + setCodeSent(false); + emailVerifySuccessCode.onChange(''); + }} + aria-invalid={submitted && mail.isError} + autoComplete="email" + className="flex-1" + /> + +
+ {submitted && formStates.mail.isError && ( +

+ 올바른 이메일을 입력해 주세요. +

+ )} + {isEmailVerified && ( +

+ + 이메일 인증이 완료되었습니다. +

+ )} +
+ + {/* 인증 코드 입력 (발송 후, 미인증 시) */} + {codeSent && !isEmailVerified && ( +
+ +
+ code.onChange(e.target.value)} + aria-invalid={submitted && code.isError} + maxLength={6} + className="flex-1" + /> + +
+ {submitted && code.isError && ( +

+ 6자리 숫자 인증 코드를 입력해 주세요. +

+ )} +
+ )} + + {/* 비밀번호 */} +
+ +
+ password.onChange(e.target.value)} + aria-invalid={submitted && password.isError} + autoComplete="new-password" + className="pr-10" + /> + +
+ {submitted && password.isError && ( +

+ 비밀번호 필수 조건을 만족해 주세요. +

+ )} + + {/* 비밀번호 요구사항 */} + {showPasswordRequirements && ( +
+
+

+ 필수 조건 +

+ +
+
+

+ 권고 조건 +

+ + + + +
+
+ )} +
+ + {/* 비밀번호 확인 */} +
+ +
+ passwordConfirm.onChange(e.target.value)} + aria-invalid={submitted && passwordConfirm.isError} + autoComplete="new-password" + className="pr-10" + /> + +
+ {submitted && passwordConfirm.isError && ( +

+ 비밀번호가 일치하지 않습니다. +

+ )} +
+ + {serverError && ( +

+ {serverError} +

+ )} + + +
+ +

+ 이미 계정이 있으신가요?{' '} + +

+
+
+
+ ); +} + +type RequirementItemProps = { + label: string; + met: boolean; +}; + +function RequirementItem({ label, met }: RequirementItemProps) { + return ( +
+ {met ? ( + + ) : ( + + )} + {label} +
+ ); +} From bc32891e6a61e633a61599ecfc6f490aa7ba6972 Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sat, 30 May 2026 12:13:24 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=A8=20api=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/apis/local-server/apis.ts | 36 ++++++++++++ .../api/apis/local-server/index.ts | 1 + .../api/apis/local-server/schemas.ts | 7 +++ src/infrastructure/api/client.ts | 55 +++++++++++++++++++ src/infrastructure/api/domain.ts | 36 ++++++++++++ src/infrastructure/api/index.ts | 2 + 6 files changed, 137 insertions(+) create mode 100644 src/infrastructure/api/apis/local-server/apis.ts create mode 100644 src/infrastructure/api/apis/local-server/index.ts create mode 100644 src/infrastructure/api/apis/local-server/schemas.ts create mode 100644 src/infrastructure/api/client.ts create mode 100644 src/infrastructure/api/domain.ts create mode 100644 src/infrastructure/api/index.ts diff --git a/src/infrastructure/api/apis/local-server/apis.ts b/src/infrastructure/api/apis/local-server/apis.ts new file mode 100644 index 0000000..68dd90a --- /dev/null +++ b/src/infrastructure/api/apis/local-server/apis.ts @@ -0,0 +1,36 @@ +import type { + ErrorResponse, + InternalCallParams, + ResponseNecessary, + SuccessResponse, +} from '../../domain'; +import type { TestRequest, TestResponse } from './schemas'; + +type GetApisProps = { + callWithToken: ( + p: InternalCallParams & { token: string } + ) => Promise; + callWithoutToken: ( + p: InternalCallParams & { token?: never } + ) => Promise; + callWithOptionalToken: ( + p: InternalCallParams & { token?: string } + ) => Promise; +}; + +type Api = (_: { + body: never; + token: string; + params: never; + query: never; +}) => Promise<{ status: number; data: unknown }>; + +export const getLocalServerApis = ({ callWithoutToken }: GetApisProps) => + ({ + 'GET /test': ({ body }: { body: TestRequest }) => + callWithoutToken>({ + method: 'GET', + path: 'test', + body, + }), + }) satisfies Record; diff --git a/src/infrastructure/api/apis/local-server/index.ts b/src/infrastructure/api/apis/local-server/index.ts new file mode 100644 index 0000000..d57b8aa --- /dev/null +++ b/src/infrastructure/api/apis/local-server/index.ts @@ -0,0 +1 @@ +export { getLocalServerApis } from './apis'; \ No newline at end of file diff --git a/src/infrastructure/api/apis/local-server/schemas.ts b/src/infrastructure/api/apis/local-server/schemas.ts new file mode 100644 index 0000000..f17b97b --- /dev/null +++ b/src/infrastructure/api/apis/local-server/schemas.ts @@ -0,0 +1,7 @@ +export type TestRequest = { + test: number; +}; + +export type TestResponse = { + message: string; +}; diff --git a/src/infrastructure/api/client.ts b/src/infrastructure/api/client.ts new file mode 100644 index 0000000..3d5f739 --- /dev/null +++ b/src/infrastructure/api/client.ts @@ -0,0 +1,55 @@ +import { getLocalServerApis } from './apis/local-server'; +import type { + ErrorResponse, + ExternalCallParams, + InternalCallParams, + ResponseNecessary, +} from './domain'; + +type ImplApiProps = { + externalCall(_: ExternalCallParams): Promise; +}; + +export const implApi = ({ externalCall }: ImplApiProps) => { + const internalCall = async (content: { + method: string; + path: string; + body?: Record; + token?: string; + }) => { + const response = await externalCall({ + method: content.method, + path: content.path, + body: content.body, + headers: { + 'content-type': 'application/json;charset=UTF-8', + ...(content.token !== undefined + ? { Authorization: `Bearer ${content.token}` } + : {}), + }, + credentials: 'include', + }); + + return response as R; + }; + + const callWithToken = ( + p: InternalCallParams & { token: string } + ) => internalCall(p); + + const callWithoutToken = ( + p: InternalCallParams & { token?: never } + ) => internalCall(p); + + const callWithOptionalToken = ( + p: InternalCallParams & { token?: string } + ) => internalCall(p); + + return getLocalServerApis({ + callWithToken, + callWithoutToken, + callWithOptionalToken, + }); +}; + +export type Apis = ReturnType; diff --git a/src/infrastructure/api/domain.ts b/src/infrastructure/api/domain.ts new file mode 100644 index 0000000..bc152e5 --- /dev/null +++ b/src/infrastructure/api/domain.ts @@ -0,0 +1,36 @@ +export type ResponseNecessary = { + status: number; + data: unknown; +}; + +// TODO: 200 이외의 코드가 내려왔을 때 대응 필요 +export type SuccessResponse = { + status: Status; + data: T; +}; + +export type ErrorResponse< + Status extends number = 400 | 401 | 403 | 404 | 409 | 500, +> = { + status: Status; + data: { + timestamp: string; + message: string; + code: string; + }; +}; + +export type ExternalCallParams = { + method: string; + path: string; + body?: Record; + headers?: Record; + credentials?: string; +}; + +export type InternalCallParams = { + method: string; + path: string; + body?: Record; + token?: string; +}; diff --git a/src/infrastructure/api/index.ts b/src/infrastructure/api/index.ts new file mode 100644 index 0000000..933b46b --- /dev/null +++ b/src/infrastructure/api/index.ts @@ -0,0 +1,2 @@ +export { type Apis, implApi } from './client'; +export type { ExternalCallParams } from './domain'; From d353ebf2c201eeaf3a0c7db8dabe3c04b4e6ba72 Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sat, 30 May 2026 15:49:47 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=8E=A8=20=EB=A7=88=EC=A7=80=EB=A7=89?= =?UTF-8?q?=20=EC=B6=9C=20=EC=B6=94=EA=B0=80=20=ED=8F=AC=EB=A7=A4=ED=8C=85?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/infrastructure/api/apis/local-server/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infrastructure/api/apis/local-server/index.ts b/src/infrastructure/api/apis/local-server/index.ts index d57b8aa..b14c9ba 100644 --- a/src/infrastructure/api/apis/local-server/index.ts +++ b/src/infrastructure/api/apis/local-server/index.ts @@ -1 +1 @@ -export { getLocalServerApis } from './apis'; \ No newline at end of file +export { getLocalServerApis } from './apis';