Compare commits

...

6 Commits

Author SHA1 Message Date
geonhee-min
ea7861b63a issue #
All checks were successful
Test CI / build (push) Successful in 17s
- 로그인 화면, 회원가입 화면, 비밀번호 초기화 화면 모바일 ui 대비 작업
2025-12-03 17:06:20 +09:00
geonhee-min
069f58075b issue #52
- 사이드바 Sheet 스타일로 변경
- 로그아웃 버튼 구현
2025-12-03 17:05:48 +09:00
geonhee-min
edef4273c0 issue #48
- 홈 화면 및 전체적 ui 수정
2025-12-03 17:05:19 +09:00
geonhee-min
e3091494b1 issue #32
- 새로고침 시 로그인 해제 오류 해결 및 자동로그인 기능 구현
2025-12-03 12:59:08 +09:00
geonhee-min
3859099074 issue #32
- 로그인 토스트 동작 오류 개선
2025-12-03 11:20:33 +09:00
geonhee-min
54c84dbc87 issue #
Enter키 동작 구현
2025-12-03 10:13:37 +09:00
18 changed files with 408 additions and 233 deletions

View File

@@ -1 +1 @@
VITE_API_URL=http://localhost:8080
VITE_API_URL=http://localhost:8088

View File

@@ -7,19 +7,41 @@ import { PageRouting } from './const/PageRouting';
import LoginPage from './ui/page/account/login/LoginPage';
import ResetPasswordPage from './ui/page/account/resetPassword/ResetPasswordPage';
import { HomePage } from './ui/page/home/HomePage';
import type { AuthData } from './data/AuthData';
import { useEffect } from 'react';
import { ScheduleMainPage } from './ui/page/schedule/ScheduleMainPage';
function App() {
const { authData } = useAuthStore();
const { authData, login } = useAuthStore();
useEffect(() => {
const autoLogin = localStorage.getItem('autoLogin') === 'true';
if (autoLogin) {
const stored = localStorage.getItem('auth-storage');
if (stored) {
const storedAuthData = JSON.parse(stored).state as AuthData;
login(storedAuthData);
}
}
}, []);
return (
<Router>
<Routes>
<Route element={<Layout />}>
<Route element={<LoginPage />} path={PageRouting["LOGIN"].path} />
<Route element={<SignUpPage />} path={PageRouting["SIGN_UP"].path} />
<Route element={<ResetPasswordPage />} path={PageRouting["RESET_PASSWORD"].path} />
{!authData ? <Route element={<Navigate to={PageRouting["LOGIN"].path} />} path="*" /> : null}
<Route element={<HomePage />} path={PageRouting["HOME"].path} />
{
!authData
? <>
<Route element={<LoginPage />} path={PageRouting["LOGIN"].path} />
<Route element={<SignUpPage />} path={PageRouting["SIGN_UP"].path} />
<Route element={<ResetPasswordPage />} path={PageRouting["RESET_PASSWORD"].path} />
<Route element={<Navigate to={PageRouting["LOGIN"].path} />} path="*" />
</>
: <>
<Route element={<Navigate to={PageRouting["HOME"].path} />} path="*" />
<Route element={<HomePage />} path={PageRouting["HOME"].path} />
<Route element={<ScheduleMainPage />} path={PageRouting["SCHEDULES"].path} />
</>
}
</Route>
</Routes>
</Router>

View File

@@ -89,9 +89,7 @@ function SidebarProvider({
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
const toggleSidebar = () => setOpen((open) => !open);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@@ -157,13 +155,15 @@ function Sidebar({
collapsible = "offcanvas",
className,
children,
forceSheet = false,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
collapsible?: "offcanvas" | "icon" | "none",
forceSheet?: boolean
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, open, setOpen } = useSidebar()
if (collapsible === "none") {
return (
@@ -180,14 +180,14 @@ function Sidebar({
)
}
if (isMobile) {
if (isMobile || forceSheet) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<Sheet open={open} onOpenChange={setOpen} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
className="bg-sidebar text-sidebar-foreground rounded-br-2xl rounded-tr-2xl w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,

View File

@@ -1,11 +1,20 @@
import { Validator } from '@/util/Validator';
import * as z from 'zod';
export const LoginSchema = z.object({
id: z
.string()
.refine((val) => {
if (val.includes('@')) {
return Validator.isEmail(val);;
}
return true;
}, {
message: "이메일 형식이 올바르지 않습니다."
})
, password: z
.string()
.min(8, "비밀번호는 8-12 자리여야 합니다.")
.max(12, "비밀번호는 8-12 자리여야 합니다.")
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$]).*$/, "비밀번호는 영소문자로 시작하여 숫자, 특수문자(!@#$)를 한 개 이상 포함하여야 합니다.")
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "비밀번호는 영소문자로 시작하여 숫자, 특수문자(!@#$)를 한 개 이상 포함하여야 합니다.")
});

View File

@@ -6,12 +6,12 @@ export const ResetPasswordSchema = z.object({
, code: z
.string()
.length(8)
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$%^]).*$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
, password: z
.string()
.min(8, "비밀번호는 8-12 자리여야 합니다.")
.max(12, "비밀번호는 8-12 자리여야 합니다.")
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$%^]).*$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
, passwordConfirm: z
.string()
})

View File

@@ -4,6 +4,11 @@ export const SignUpSchema = z.object({
accountId: z
.string()
.min(5, "아이디는 5 자리 이상이어야 합니다.")
.refine((val) => {
return /^[a-zA-z-_.]*$/.test(val);
}, {
message: "영문, 숫자, '- _ .' 를 제외한 문자를 사용할 수 없습니다."
})
, email: z
.string()
.min(5, "이메일을 입력해주십시오.")
@@ -11,7 +16,7 @@ export const SignUpSchema = z.object({
.string()
.min(8, "비밀번호는 8-12 자리여야 합니다.")
.max(12, "비밀번호는 8-12 자리여야 합니다.")
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$%^]).*$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
, name: z
.string()
.min(1, "이름을 입력해주시십시오.")

View File

@@ -120,7 +120,10 @@
}
html, body, #root {
height: 100%;
width: 100%;
height: 100%;
min-width: 1280px;
min-height: 720px;
}
/* Chrome, Safari, Edge */

View File

@@ -33,8 +33,9 @@ export default function Layout() {
<SideBar />
<div className="flex flex-col w-full h-full">
{ authData ? <Header /> : null}
{/* <Header /> */}
<Outlet />
<div className="w-full h-full p-2.5">
<Outlet />
</div>
</div>
</SidebarProvider>
</>

View File

@@ -7,7 +7,8 @@ import type {
InternalAxiosRequestConfig,
} from "axios";
import { useAuthStore } from '@/store/authStore';
import type { RefreshAccessTokenResponse } from '@/data/response/account/RefreshAccessTokenResponse';
import { RefreshAccessTokenResponse } from '@/data/response/account/RefreshAccessTokenResponse';
import type { AuthData } from '@/data/AuthData';
export class BaseNetwork {
protected instance: AxiosInstance;
@@ -98,30 +99,16 @@ export class BaseNetwork {
this.isRefreshing = true;
try {
const response = await this.get<RefreshAccessTokenResponse>(
'/account/refresh-access-token',
{
headers: {
Authorization: `Bearer ${refreshToken}`
}
}
)
await this.refreshToken();
const newAccessToken = response.data.accessToken;
const newRefreshToken = response.data.refreshToken;
useAuthStore.getState().login({
...authData,
accessToken: newAccessToken,
refreshToken: newRefreshToken
});
const newAccessToken = useAuthStore.getState().authData!.accessToken;
this.refreshQueue.forEach((cb) => cb(newAccessToken));
this.refreshQueue = [];
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${newAccessToken}`,
};
} as any;
return this.instance(originalRequest);
} catch (err) {
@@ -143,4 +130,39 @@ export class BaseNetwork {
protected async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig & { authPass?: boolean }) {
return await this.instance.post<T>(url, data, config);
}
public async refreshToken() {
const storedAuth = localStorage.getItem('auth-storage');
if (!storedAuth) {
localStorage.setItem('autoLogin', 'false');
throw new Error;
}
const authData: AuthData = JSON.parse(storedAuth).state;
if (!authData || !authData.refreshToken) {
localStorage.setItem('autoLogin', 'false');
throw new Error;
}
const result = await this.get<RefreshAccessTokenResponse>(
'/account/refresh-access-token',
{
headers: {
Authorization: `Bearer ${authData.refreshToken}`
}
}
);
if (!result.data.success) throw new Error;
const newAccessToken = result.data.accessToken;
const newRefreshToken = result.data.refreshToken;
useAuthStore.getState().login({
accessToken: newAccessToken,
refreshToken: newRefreshToken
});
}
}

View File

@@ -1,5 +1,6 @@
import type { AuthData } from '@/data/AuthData';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
interface AuthStoreProps {
authData: AuthData | undefined;
@@ -7,17 +8,23 @@ interface AuthStoreProps {
logout: () => void;
}
export const useAuthStore = create<AuthStoreProps>((set) => ({
authData: undefined,
login: (data: AuthData) => {
set({ authData: data });
Object.entries(data)
.forEach((entry) => {
localStorage.setItem(entry[0], entry[1]);
})
},
logout: () => {
set({ authData: undefined });
localStorage.clear();
}
}));
const storage = sessionStorage;
export const useAuthStore = create<AuthStoreProps>()(
persist(
(set) => ({
authData: undefined,
login: (data: AuthData) => {
set({ authData: data });
},
logout: () => {
localStorage.setItem('autoLogin', 'false');
localStorage.removeItem('auth-storage');
set({ authData: undefined });
}
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => storage)
}
)
);

View File

@@ -2,13 +2,53 @@ import { Label } from '@/components/ui/label';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Separator } from '@/components/ui/separator';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { useAuthStore } from '@/store/authStore';
import { LogOutIcon } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { PageRouting } from '@/const/PageRouting';
export default function Header() {
const navigate = useNavigate();
const { logout } = useAuthStore();
const handleClickLogoutButton = () => {
logout();
navigate(PageRouting["LOGIN"].path);
}
return (
<header className="flex shrink-0 items-center gap-2 border-b px-4 w-full h-12">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Label>{import.meta.env.BASE_URL}</Label>
<header className="w-full flex shrink-0 flex-row justify-between items-center border-b px-4 h-12">
<div className="flex flex-row gap-2 items-center">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Label>{import.meta.env.BASE_URL}</Label>
</div>
<div>
<Button
className={`
group flex items-center justify-start
pr-2 pl-2 border border-red-500 bg-white
transition-all duration-150
w-10 hover:w-25 hover:bg-red-500
overflow-hidden rounded-md
`}
type="button"
onClick={handleClickLogoutButton}
>
<LogOutIcon
className="text-red-500 transition-colors duration-150 group-hover:text-white"
/>
<span className="
text-red-500 group-hover:text-white
opacity-0 scale-1
transition-all duration-150
group-hover:opacity-100 group-hover:scale-100
">
</span>
</Button>
</div>
</header>
);
}

View File

@@ -8,7 +8,7 @@ import {
export default function SideBar() {
return (
<Sidebar>
<Sidebar forceSheet={true}>
</Sidebar>
);

View File

@@ -4,7 +4,7 @@ import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { PageRouting } from '@/const/PageRouting';
@@ -15,10 +15,15 @@ import { LoginRequest } from '@/data/request/account/LoginRequest';
import { AccountNetwork } from '@/network/AccountNetwork';
import { toast } from 'sonner';
import { useAuthStore } from '@/store/authStore';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { useIsMobile } from '@/hooks/use-mobile';
export default function LoginPage() {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [autoLogin, setAutoLogin] = useState<boolean>(false);
const { login } = useAuthStore();
const isMobile = useIsMobile();
const navigate = useNavigate();
const accountNetwork = new AccountNetwork();
const loginForm = useForm<z.infer<typeof LoginSchema>>({
@@ -30,6 +35,10 @@ export default function LoginPage() {
});
const { id, password } = { id: loginForm.watch('id'), password: loginForm.watch('password') };
useEffect(() => {
localStorage.setItem('autoLogin', `${autoLogin}`)
}, [autoLogin]);
const moveToSignUpPage = useCallback(() => {
navigate(PageRouting["SIGN_UP"].path);
}, []);
@@ -53,39 +62,27 @@ export default function LoginPage() {
const loginPromise = accountNetwork.login(data);
toast.promise<{ message?: string }>(
() => new Promise(async (resolve, reject) => {
try {
loginPromise.then((res) => {
if (res.data.success) {
resolve({message: ''});
} else {
reject(res.data.message);
}
})
} catch (err) {
reject ("서버 에러 발생");
}
}),
toast.promise(
loginPromise,
{
loading: "로그인 중입니다.",
success: "로그인이 완료되었습니다.",
error: (err) => `${err}`
success: (res) => {
setIsLoading(false);
if (res.data.success) {
const data = {
accessToken: res.data.accessToken!,
refreshToken: res.data.refreshToken!
};
login({...data});
moveToHomePage();
return "로그인 성공";
} else {
throw new Error(res.data.message);
}
},
error: (err: Error) => err.message || "에러 발생"
}
);
loginPromise
.then((res) => {
if (res.data.success) {
const data = {
accessToken: res.data.accessToken!,
refreshToken: res.data.refreshToken!
}
login({ ...data });
moveToHomePage();
}
})
.finally(() => setIsLoading(false));
}
const TextSeparator = ({ text }: { text: string }) => {
@@ -98,9 +95,16 @@ export default function LoginPage() {
)
}
const handleEnterKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!(e.key === 'Enter')) return;
const result = await loginForm.trigger();
if (!result) return;
await reqLogin();
}
return (
<div className="w-full h-full flex flex-col justify-center items-center">
<Card className="w-md pl-2 pr-2">
<Card className={isMobile ? "w-full pl-2 pr-2" : "w-md pl-2 pr-2"}>
<CardHeader>
</CardHeader>
@@ -117,6 +121,8 @@ export default function LoginPage() {
type="text"
id="form-login-id"
aria-invalid={fieldState.invalid}
tabIndex={1}
onKeyDown={handleEnterKeyDown}
/>
<FieldError errors={[fieldState.error]} />
</Field>
@@ -134,6 +140,7 @@ export default function LoginPage() {
className="p-0 bg-transparent hover:bg-transparent h-fit w-fit text-xs text-gray-400 hover:text-gray-500 cursor-pointer"
onClick={moveToResetPasswordPage}
type="button"
tabIndex={3}
>
?
</Button>
@@ -143,13 +150,28 @@ export default function LoginPage() {
type="password"
id="form-login-password"
aria-invalid={fieldState.invalid}
tabIndex={2}
onKeyDown={handleEnterKeyDown}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
>
</Controller>
<div className="flex flex-row gap-2 mt-2">
<Checkbox
className={[
"data-[state=checked]:bg-indigo-500 data-[state=checked]:text-white"
, "data-[state=checked]:outline-none data-[state=checked]:border-0"
].join(' ')}
id="auto-login"
checked={autoLogin}
onCheckedChange={(value) => setAutoLogin(value === true)}
/>
<Label htmlFor="auto-login"> </Label>
</div>
</form>
</CardContent>
<CardFooter
className="w-full flex flex-col items-center gap-5"

View File

@@ -4,7 +4,7 @@ import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { useState, useCallback } from 'react';
import React, { useState, useCallback } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { PageRouting } from '@/const/PageRouting';
@@ -122,7 +122,7 @@ export default function ResetPasswordPage() {
}
}
const handleThirdStepButton = async () => {
const handleClickThirdStepButton = async () => {
if (isLoading) return;
const passwordValid = await resetPasswordForm.trigger('password');
if (!passwordValid) return;
@@ -159,6 +159,19 @@ export default function ResetPasswordPage() {
}
const handleEnterKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
if (currentStep === 1) {
await handleClickFirstStepButton();
return;
}
if (currentStep === 3) {
await handleClickThirdStepButton();
}
}
}
return (
<Stepper
value={currentStep}
@@ -211,13 +224,7 @@ export default function ResetPasswordPage() {
type="email"
id="reset-password-email"
aria-invalid={fieldState.invalid}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (email.length === 0) return;
handleClickFirstStepButton();
}
}}
onKeyDown={handleEnterKeyDown}
/>
<FieldError className="font-[12px]" errors={[fieldState.error]} />
</>
@@ -275,6 +282,7 @@ export default function ResetPasswordPage() {
type={ showPassword ? "text" : "password" }
id="reset-password-password"
aria-invalid={fieldState.invalid}
onKeyDown={handleEnterKeyDown}
className="pr-10"
/>
<button
@@ -306,6 +314,7 @@ export default function ResetPasswordPage() {
id="reset-password-password-confirm"
className="pr-10"
aria-invalid={fieldState.invalid}
onKeyDown={handleEnterKeyDown}
/>
<button
type="button"
@@ -380,7 +389,7 @@ export default function ResetPasswordPage() {
(password.trim().length < 1)
&& (passwordConfirm.trim().length < 1)
}
onClick={handleThirdStepButton}
onClick={handleClickThirdStepButton}
>
</Button>

View File

@@ -18,6 +18,8 @@ import { AccountNetwork } from '@/network/AccountNetwork';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import { PageRouting } from '@/const/PageRouting';
import { useIsMobile } from '@/hooks/use-mobile';
import { ScrollArea } from '@/components/ui/scroll-area';
export default function SignUpPage() {
const [emailVerificationModalOpen, setEmailVerificationModalOpen] = useState<boolean>(false);
@@ -28,6 +30,7 @@ export default function SignUpPage() {
const accountNetwork = new AccountNetwork();
const navigate = useNavigate();
const isMobile = useIsMobile();
const signUpForm = useForm<z.infer<typeof SignUpSchema>>({
resolver: zodResolver(SignUpSchema),
@@ -119,145 +122,150 @@ export default function SignUpPage() {
}
return (
<div className="w-full h-full flex flex-col justify-center items-center">
<Card className="w-md pl-2 pr-2">
<CardHeader></CardHeader>
<CardContent>
<form id="form-signup">
<FieldGroup>
<Controller
name="accountId"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-account-id"></FieldLabel>
<div id="accountId-group" className="w-full flex flex-row justify-between gap-2.5">
<div className={"w-full h-full flex flex-col justify-center items-center"}>
<Card className={isMobile ? "w-full pl-2 pr-2" : "w-md pl-2 pr-2"}>
<CardHeader></CardHeader>
<ScrollArea className="h-72 [&>div>div:last-child]:hidden">
<CardContent>
<form id="form-signup">
<FieldGroup>
<Controller
name="accountId"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-account-id"></FieldLabel>
<div id="accountId-group" className="w-full flex flex-row justify-between gap-2.5">
<Input
{...field}
id="form-signup-account-id"
aria-invalid={fieldState.invalid}
onInput={handleOnChangeAccountId}
/>
<Button
type="button"
onClick={() => handleDuplicationCheckButtonClick('accountId')}
className="bg-indigo-500 hover:bg-indigo-400"
>
</Button>
</div>
{ isCheckedAccountIdDuplication && <p className="text-green-500 text-sm font-normal"> </p> }
<FieldError errors={[fieldState.error]}/>
</Field>
)}
/>
<Controller
name="name"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-name"></FieldLabel>
<Input
{...field}
id="form-signup-account-id"
id="form-signup-name"
aria-invalid={fieldState.invalid}
onInput={handleOnChangeAccountId}
/>
<Button
type="button"
onClick={() => handleDuplicationCheckButtonClick('accountId')}
className="bg-indigo-500 hover:bg-indigo-400"
>
</Button>
</div>
{ isCheckedAccountIdDuplication && <p className="text-green-500 text-sm font-normal"> </p> }
<FieldError errors={[fieldState.error]}/>
</Field>
)}
/>
<Controller
name="name"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-name"></FieldLabel>
<Input
{...field}
id="form-signup-name"
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="nickname"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-nickname"></FieldLabel>
<Input
{...field}
id="form-signup-nickname"
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="email"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-email"></FieldLabel>
<div id="email-group" className="w-full flex flex-row justify-between gap-2.5">
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="nickname"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-nickname"></FieldLabel>
<Input
{...field}
id="form-signup-email"
id="form-signup-nickname"
aria-invalid={fieldState.invalid}
placeholder="example@domain.com"
type="email"
onInput={handleOnChangeEmail}
/>
<Button
type="button"
onClick={() => handleDuplicationCheckButtonClick('email')}
className="bg-indigo-500 hover:bg-indigo-400"
>
</Button>
</div>
{ isCheckedEmailDuplication && <p className="text-green-500 text-sm font-normal"> </p> }
<FieldError errors={[fieldState.error]}/>
</Field>
)}
/>
<Controller
name="password"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-password"></FieldLabel>
<Input
{...field}
id="form-signup-password"
aria-invalid={fieldState.invalid}
type="password"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="passwordConfirm"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-password-confirm"> </FieldLabel>
<Input
{...field}
id="form-signup-password-confirm"
aria-invalid={fieldState.invalid}
type="password"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</FieldGroup>
</form>
</CardContent>
<CardFooter>
<EmailVerificationModal
trigger={
<Button type="button" onClick={handleOnSignUpButtonClick} className="0">
</Button>
}
email={duplicationCheckedEmail}
open={emailVerificationModalOpen} // ✅ 부모 상태 연결
setOpen={setEmailVerificationModalOpen} // ✅ 부모 상태 변경 함수 전달
onVerifySuccess={signup} // ✅ 인증 성공 시 signup 호출
/>
</CardFooter>
</Card>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="email"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-email"></FieldLabel>
<div id="email-group" className="w-full flex flex-row justify-between gap-2.5">
<Input
{...field}
id="form-signup-email"
aria-invalid={fieldState.invalid}
placeholder="example@domain.com"
type="email"
onInput={handleOnChangeEmail}
/>
<Button
type="button"
onClick={() => handleDuplicationCheckButtonClick('email')}
className="bg-indigo-500 hover:bg-indigo-400"
>
</Button>
</div>
{ isCheckedEmailDuplication && <p className="text-green-500 text-sm font-normal"> </p> }
<FieldError errors={[fieldState.error]}/>
</Field>
)}
/>
<Controller
name="password"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-password"></FieldLabel>
<Input
{...field}
id="form-signup-password"
aria-invalid={fieldState.invalid}
type="password"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="passwordConfirm"
control={signUpForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-signup-password-confirm"> </FieldLabel>
<Input
{...field}
id="form-signup-password-confirm"
aria-invalid={fieldState.invalid}
type="password"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</FieldGroup>
</form>
</CardContent>
</ScrollArea>
<CardFooter>
<EmailVerificationModal
trigger={
<Button type="button" onClick={handleOnSignUpButtonClick} className="0">
</Button>
}
email={duplicationCheckedEmail}
open={emailVerificationModalOpen} // ✅ 부모 상태 연결
setOpen={setEmailVerificationModalOpen} // ✅ 부모 상태 변경 함수 전달
onVerifySuccess={signup} // ✅ 인증 성공 시 signup 호출
/>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -1,6 +1,6 @@
export const HomePage = () => {
return (
<div>
<div className="w-full h-full flex flex-column">
HomePage
</div>
)

View File

@@ -0,0 +1,26 @@
import { Calendar } from "@/components/ui/calendar";
import { DayButton } from "react-day-picker";
export function ScheduleMainPage() {
return (
<div className="w-full h-full flex flex-col justify-start items-center">
<Calendar
mode="single"
className="rounded-lg w-full h-full max-h-10/12 border"
components={{
Weeks: (props) => (
<tbody {...props} className={props.className}></tbody>
),
Week: (props) => (
<tr {...props} className={props.className + " h-1/10"}></tr>
),
Day: (props) => (
<td {...props} className={props.className + " h-1"}></td>
)
}}
>
</Calendar>
</div>
)
}

View File

@@ -7,6 +7,7 @@ export class Validator {
static validatePasswordFormat = (password: string): boolean => {
if (password.length < 8) return false;
if (password.includes(' ')) return false;
const alphabets = 'abcdefghijklmnopqrstuvwxyz';
const numbers = '0123456789';