Compare commits

..

16 Commits

Author SHA1 Message Date
2c8dcf9db7 issue #60
All checks were successful
Test CI / build (push) Successful in 17s
- 일정 등록 및 조회 컴포넌트 설계 및 구현 중
2025-12-06 00:19:25 +09:00
geonhee-min
4a8e761b3d issue #60
All checks were successful
Test CI / build (push) Successful in 18s
- 날짜 선택 및 해당 날짜 일정 조회 화면 구현 중
2025-12-05 17:10:58 +09:00
0c8e0893c7 issue #60
All checks were successful
Test CI / build (push) Successful in 17s
- 일정 등록 및 조회 컴포넌트 설계 및 구현 중
2025-12-05 00:05:33 +09:00
geonhee-min
7df60fe004 issue #60
All checks were successful
Test CI / build (push) Successful in 17s
- 캘린더 ui 구현 중
2025-12-04 17:00:02 +09:00
daab622638 issue #59
All checks were successful
Test CI / build (push) Successful in 17s
- 일정 메인 화면 구현 중
2025-12-03 22:51:13 +09:00
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
1a0cc9376f issue #49
All checks were successful
Test CI / build (push) Successful in 16s
- 홈 화면 라우팅 및 기본 파일 생성
2025-12-02 22:40:04 +09:00
b730945d34 issue #37
- 비밀번호 초기화 화면 기능 구현
2025-12-02 22:39:47 +09:00
17e27fca70 issue #36
- 화면 구현 완료
2025-12-02 22:39:31 +09:00
geonhee-min
af3fa26f3b issue #37
All checks were successful
Test CI / build (push) Successful in 16s
- 기능 구현 1차 완료(동작 확인 필요)
2025-12-02 16:50:24 +09:00
geonhee-min
eec883ac32 issue #36
- 화면 구현 완료
2025-12-02 16:49:54 +09:00
46 changed files with 2064 additions and 467 deletions

View File

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

View File

@@ -18,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"registries": {
"@reui": "https://reui.io/r/{name}.json"
}
}

8
package-lock.json generated
View File

@@ -54,7 +54,7 @@
"react-router-dom": "^7.9.5",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.1.12",
"zustand": "^5.0.8"
@@ -6376,9 +6376,9 @@
}
},
"node_modules/tailwind-merge": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"license": "MIT",
"funding": {
"type": "github",

View File

@@ -57,7 +57,7 @@
"react-router-dom": "^7.9.5",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.1.12",
"zustand": "^5.0.8"

View File

@@ -1,23 +1,47 @@
import './App.css';
import SignUpPage from './ui/page/signup/SignUpPage';
import SignUpPage from './ui/page/account/signup/SignUpPage';
import Layout from './layouts/Layout';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/authStore';
import { PageRouting } from './const/PageRouting';
import LoginPage from './ui/page/login/LoginPage';
import ResetPasswordPage from './ui/page/resetPassword/ResetPasswordPage';
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 />}>
{
!authData
? <>
<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={<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

@@ -10,6 +10,7 @@ import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { format } from "date-fns"
function Calendar({
className,
@@ -36,8 +37,28 @@ function Calendar({
)}
captionLayout={captionLayout}
formatters={{
formatCaption: (month) => format(month, "yyyy년 MM월"),
formatWeekdayName: (weekday) => {
switch(weekday.getDay()) {
case 0:
return '일';
case 1:
return '월';
case 2:
return '화';
case 3:
return '수';
case 4:
return '목';
case 5:
return '금';
case 6:
return '토';
}
return '';
},
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
date.toLocaleString("", { month: "short" }),
...formatters,
}}
classNames={{

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

@@ -0,0 +1,408 @@
'use client';
import * as React from 'react';
import { createContext, useContext } from 'react';
import { cn } from '@/lib/utils';
// Types
type StepperOrientation = 'horizontal' | 'vertical';
type StepState = 'active' | 'completed' | 'inactive' | 'loading';
type StepIndicators = {
active?: React.ReactNode;
completed?: React.ReactNode;
inactive?: React.ReactNode;
loading?: React.ReactNode;
};
interface StepperContextValue {
activeStep: number;
setActiveStep: (step: number) => void;
stepsCount: number;
orientation: StepperOrientation;
registerTrigger: (node: HTMLButtonElement | null) => void;
triggerNodes: HTMLButtonElement[];
focusNext: (currentIdx: number) => void;
focusPrev: (currentIdx: number) => void;
focusFirst: () => void;
focusLast: () => void;
indicators: StepIndicators;
}
interface StepItemContextValue {
step: number;
state: StepState;
isDisabled: boolean;
isLoading: boolean;
}
const StepperContext = createContext<StepperContextValue | undefined>(undefined);
const StepItemContext = createContext<StepItemContextValue | undefined>(undefined);
function useStepper() {
const ctx = useContext(StepperContext);
if (!ctx) throw new Error('useStepper must be used within a Stepper');
return ctx;
}
function useStepItem() {
const ctx = useContext(StepItemContext);
if (!ctx) throw new Error('useStepItem must be used within a StepperItem');
return ctx;
}
interface StepperProps extends React.HTMLAttributes<HTMLDivElement> {
defaultValue?: number;
value?: number;
onValueChange?: (value: number) => void;
orientation?: StepperOrientation;
indicators?: StepIndicators;
}
function Stepper({
defaultValue = 1,
value,
onValueChange,
orientation = 'horizontal',
className,
children,
indicators = {},
...props
}: StepperProps) {
const [activeStep, setActiveStep] = React.useState(defaultValue);
const [triggerNodes, setTriggerNodes] = React.useState<HTMLButtonElement[]>([]);
// Register/unregister triggers
const registerTrigger = React.useCallback((node: HTMLButtonElement | null) => {
setTriggerNodes((prev) => {
if (node && !prev.includes(node)) {
return [...prev, node];
} else if (!node && prev.includes(node!)) {
return prev.filter((n) => n !== node);
} else {
return prev;
}
});
}, []);
const handleSetActiveStep = React.useCallback(
(step: number) => {
if (value === undefined) {
setActiveStep(step);
}
onValueChange?.(step);
},
[value, onValueChange],
);
const currentStep = value ?? activeStep;
// Keyboard navigation logic
const focusTrigger = (idx: number) => {
if (triggerNodes[idx]) triggerNodes[idx].focus();
};
const focusNext = (currentIdx: number) => focusTrigger((currentIdx + 1) % triggerNodes.length);
const focusPrev = (currentIdx: number) => focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length);
const focusFirst = () => focusTrigger(0);
const focusLast = () => focusTrigger(triggerNodes.length - 1);
// Context value
const contextValue = React.useMemo<StepperContextValue>(
() => ({
activeStep: currentStep,
setActiveStep: handleSetActiveStep,
stepsCount: React.Children.toArray(children).filter(
(child): child is React.ReactElement =>
React.isValidElement(child) && (child.type as { displayName?: string }).displayName === 'StepperItem',
).length,
orientation,
registerTrigger,
focusNext,
focusPrev,
focusFirst,
focusLast,
triggerNodes,
indicators,
}),
[currentStep, handleSetActiveStep, children, orientation, registerTrigger, triggerNodes],
);
return (
<StepperContext.Provider value={contextValue}>
<div
role="tablist"
aria-orientation={orientation}
data-slot="stepper"
className={cn('w-full', className)}
data-orientation={orientation}
{...props}
>
{children}
</div>
</StepperContext.Provider>
);
}
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number;
completed?: boolean;
disabled?: boolean;
loading?: boolean;
}
function StepperItem({
step,
completed = false,
disabled = false,
loading = false,
className,
children,
...props
}: StepperItemProps) {
const { activeStep } = useStepper();
const state: StepState = completed || step < activeStep ? 'completed' : activeStep === step ? 'active' : 'inactive';
const isLoading = loading && step === activeStep;
return (
<StepItemContext.Provider value={{ step, state, isDisabled: disabled, isLoading }}>
<div
data-slot="stepper-item"
className={cn(
'group/step flex items-center justify-center group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col not-last:flex-1',
className,
)}
data-state={state}
{...(isLoading ? { 'data-loading': true } : {})}
{...props}
>
{children}
</div>
</StepItemContext.Provider>
);
}
interface StepperTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
function StepperTrigger({ asChild = false, className, children, tabIndex, ...props }: StepperTriggerProps) {
const { state, isLoading } = useStepItem();
const stepperCtx = useStepper();
const { setActiveStep, activeStep, registerTrigger, triggerNodes, focusNext, focusPrev, focusFirst, focusLast } =
stepperCtx;
const { step, isDisabled } = useStepItem();
const isSelected = activeStep === step;
const id = `stepper-tab-${step}`;
const panelId = `stepper-panel-${step}`;
// Register this trigger for keyboard navigation
const btnRef = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (btnRef.current) {
registerTrigger(btnRef.current);
}
}, [btnRef.current]);
// Find our index among triggers for navigation
const myIdx = React.useMemo(
() => triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
[triggerNodes, btnRef.current],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
if (myIdx !== -1 && focusNext) focusNext(myIdx);
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
if (myIdx !== -1 && focusPrev) focusPrev(myIdx);
break;
case 'Home':
e.preventDefault();
if (focusFirst) focusFirst();
break;
case 'End':
e.preventDefault();
if (focusLast) focusLast();
break;
case 'Enter':
case ' ':
e.preventDefault();
setActiveStep(step);
break;
}
};
if (asChild) {
return (
<span data-slot="stepper-trigger" data-state={state} className={className}>
{children}
</span>
);
}
return (
<button
ref={btnRef}
role="tab"
id={id}
aria-selected={isSelected}
aria-controls={panelId}
tabIndex={typeof tabIndex === 'number' ? tabIndex : isSelected ? 0 : -1}
data-slot="stepper-trigger"
data-state={state}
data-loading={isLoading}
className={cn(
'cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 inline-flex items-center gap-3 rounded-full outline-none focus-visible:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-60',
className,
)}
onClick={() => setActiveStep(step)}
onKeyDown={handleKeyDown}
disabled={isDisabled}
{...props}
>
{children}
</button>
);
}
function StepperIndicator({ children, className }: React.ComponentProps<'div'>) {
const { state, isLoading } = useStepItem();
const { indicators } = useStepper();
return (
<div
data-slot="stepper-indicator"
data-state={state}
className={cn(
'relative flex items-center overflow-hidden justify-center size-6 shrink-0 border-background rounded-full text-xs',
className,
)}
>
<div className="absolute">
{indicators &&
((isLoading && indicators.loading) ||
(state === 'completed' && indicators.completed) ||
(state === 'active' && indicators.active) ||
(state === 'inactive' && indicators.inactive))
? (isLoading && indicators.loading) ||
(state === 'completed' && indicators.completed) ||
(state === 'active' && indicators.active) ||
(state === 'inactive' && indicators.inactive)
: children}
</div>
</div>
);
}
function StepperSeparator({ className }: React.ComponentProps<'div'>) {
const { state } = useStepItem();
return (
<div
data-slot="stepper-separator"
data-state={state}
className={cn(
'm-0.5 rounded-full bg-muted group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5 group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1',
className,
)}
/>
);
}
function StepperTitle({ children, className }: React.ComponentProps<'h3'>) {
const { state } = useStepItem();
return (
<h3 data-slot="stepper-title" data-state={state} className={cn('text-sm font-medium leading-none', className)}>
{children}
</h3>
);
}
function StepperDescription({ children, className }: React.ComponentProps<'div'>) {
const { state } = useStepItem();
return (
<div data-slot="stepper-description" data-state={state} className={cn('text-sm text-muted-foreground', className)}>
{children}
</div>
);
}
function StepperNav({ children, className }: React.ComponentProps<'nav'>) {
const { activeStep, orientation } = useStepper();
return (
<nav
data-slot="stepper-nav"
data-state={activeStep}
data-orientation={orientation}
className={cn(
'group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col',
className,
)}
>
{children}
</nav>
);
}
function StepperPanel({ children, className }: React.ComponentProps<'div'>) {
const { activeStep } = useStepper();
return (
<div data-slot="stepper-panel" data-state={activeStep} className={cn('w-full', className)}>
{children}
</div>
);
}
interface StepperContentProps extends React.ComponentProps<'div'> {
value: number;
forceMount?: boolean;
}
function StepperContent({ value, forceMount, children, className }: StepperContentProps) {
const { activeStep } = useStepper();
const isActive = value === activeStep;
if (!forceMount && !isActive) {
return null;
}
return (
<div
data-slot="stepper-content"
data-state={activeStep}
className={cn('w-full', className, !isActive && forceMount && 'hidden')}
hidden={!isActive && forceMount}
>
{children}
</div>
);
}
export {
useStepper,
useStepItem,
Stepper,
StepperItem,
StepperTrigger,
StepperIndicator,
StepperSeparator,
StepperTitle,
StepperDescription,
StepperPanel,
StepperContent,
StepperNav,
type StepperProps,
type StepperItemProps,
type StepperTriggerProps,
type StepperContentProps,
};

99
src/const/ColorPalette.ts Normal file
View File

@@ -0,0 +1,99 @@
export type ColorPaletteType = {
index: number;
style: string;
main: boolean;
}
export const ColorPalette: Record<any, ColorPaletteType> = {
Black: {
index: 0,
style: '#000000',
main: true
},
White: {
index: 1,
style: '#FFFFFF',
main: true
},
PeachCream: {
index: 2,
style: '#FFDAB9',
main: false
},
CoralPink: {
index: 3,
style: '#F08080',
main: true
},
MintIcing: {
index: 4,
style: '#C1E1C1',
main: false
},
Vanilla: {
index: 5,
style: '#FFFACD',
main: true
},
Wheat: {
index: 6,
style: '#F5DEB3',
main: false
},
AliceBlue: {
index: 7,
style: '#F0F8FF',
main: true
},
Lavender: {
index: 8,
style: '#E6E6FA',
main: false
},
LightAqua: {
index: 9,
style: '#A8E6CF',
main: true
},
CloudWhite: {
index: 10,
style: '#F0F8FF',
main: false
},
LightGray: {
index: 11,
style: '#D3D3D3',
main: true
},
LightKhakki: {
index: 12,
style: '#F0F8E6',
main: false
},
DustyRose: {
index: 13,
style: '#D8BFD8',
main: true
},
CreamBeige: {
index: 14,
style: '#FAF0E6',
main: true,
},
Oatmeal: {
index: 15,
style: '#FDF5E6',
main: false
},
CharcoalLight: {
index: 16,
style: '#A9A9A9',
main: true
},
Custom: {
index: 17,
style: 'transparent',
main: false
},
}

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

@@ -3,4 +3,19 @@ import * as z from 'zod';
export const ResetPasswordSchema = z.object({
email: z
.email()
, code: z
.string()
.length(8)
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
, password: z
.string()
.min(8, "비밀번호는 8-12 자리여야 합니다.")
.max(12, "비밀번호는 8-12 자리여야 합니다.")
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
, passwordConfirm: z
.string()
})
.refine((data) => data.password === data.passwordConfirm, {
path: ["passwordConfirm"],
error: "비밀번호가 일치하지 않습니다."
});

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

@@ -0,0 +1,4 @@
export class ResetPasswordRequest {
email!: string;
password!: string;
}

View File

@@ -0,0 +1,3 @@
export class SendResetPasswordCodeRequest {
email!: string;
}

View File

@@ -0,0 +1,4 @@
export class VerifyResetPasswordCodeRequest {
email!: string;
code!: string;
}

View File

@@ -3,3 +3,6 @@ export * from './account/SendVerificationCodeRequest';
export * from './account/VerifyCodeRequest';
export * from './account/SignupRequest';
export * from './account/LoginRequest';
export * from './account/SendResetPasswordCodeRequest';
export * from './account/VerifyResetPasswordCodeRequest';
export * from './account/ResetPasswordRequest';

View File

@@ -1,4 +1,5 @@
export class BaseResponse {
success!: boolean;
message?: string;
error?: string;
}

View File

@@ -1,7 +1,6 @@
import { BaseResponse } from "../BaseResponse";
export class LoginResponse extends BaseResponse {
success!: boolean;
accessToken?: string;
refreshToken?: string;
}

View File

@@ -0,0 +1,5 @@
import { BaseResponse } from "../BaseResponse";
export class ResetPasswordResponse extends BaseResponse {
}

View File

@@ -0,0 +1,5 @@
import { BaseResponse } from "../BaseResponse";
export class SendResetPasswordCodeResponse extends BaseResponse {
}

View File

@@ -1,5 +1,5 @@
import { BaseResponse } from "../BaseResponse";
export class SendVerificationCodeResponse extends BaseResponse {
success!: boolean;
}

View File

@@ -1,6 +1,5 @@
import { BaseResponse } from "../BaseResponse";
export class SignupResponse extends BaseResponse {
success!: boolean;
}

View File

@@ -0,0 +1,5 @@
import { BaseResponse } from "../BaseResponse";
export class VerifyResetPasswordCodeResponse extends BaseResponse {
verified!: boolean;
}

View File

@@ -3,3 +3,6 @@ export * from './account/SendVerificationCodeResponse';
export * from './account/VerifyCodeResponse';
export * from './account/SignupResponse';
export * from './account/LoginResponse';
export * from './account/SendResetPasswordCodeResponse';
export * from './account/VerifyResetPasswordCodeResponse';
export * from './account/ResetPasswordResponse';

67
src/hooks/use-palette.ts Normal file
View File

@@ -0,0 +1,67 @@
import { ColorPalette, type ColorPaletteType } from "@/const/ColorPalette";
export function usePalette() {
const ColorPaletteType = typeof ColorPalette;
const getPaletteNameList = () => {
return Object.keys(ColorPalette);
}
const getMainPaletteList = () => {
const paletteKeys = Object.keys(ColorPalette);
let paletteList: ColorPaletteType[] = [];
paletteKeys.forEach((paletteKey) => {
const key = paletteKey as keyof typeof ColorPalette;
const palette: ColorPaletteType = ColorPalette[key];
if (palette.main) {
paletteList.push(palette);
}
});
paletteList = paletteList.sort((a, b) => a.index - b.index);
return paletteList;
}
const getExtraPaletteList = () => {
const paletteKeys = Object.keys(ColorPalette);
let paletteList: ColorPaletteType[] = [];
paletteKeys.forEach((paletteKey) => {
const key = paletteKey as keyof typeof ColorPalette;
const palette: ColorPaletteType = ColorPalette[key];
if (!palette.main) {
paletteList.push(palette);
}
});
paletteList = paletteList.sort((a, b) => a.index - b.index);
return paletteList;
}
const getAllPaletteList = [...getMainPaletteList(), ...getExtraPaletteList()].sort((a, b) => a.index - b.index);
const getPaletteByKey = (key: keyof typeof ColorPalette) => {
return ColorPalette[key];
}
const getCustomColor = (style: string) => {
return {
style: `#${style}`,
main: false
} as ColorPaletteType;
}
const getStyle = (palette: ColorPaletteType) => {
return palette.style;
}
return {
ColorPaletteType,
getPaletteNameList,
getMainPaletteList,
getExtraPaletteList,
getAllPaletteList,
getPaletteByKey,
getCustomColor,
getStyle
}
}

27
src/hooks/use-viewport.ts Normal file
View File

@@ -0,0 +1,27 @@
import { useState, useEffect } from 'react';
const useViewport = () => {
const [width, setWidth] = useState(
typeof window !== 'undefined' ? window.innerWidth : 0
);
const [height, setHeight] = useState(
typeof window !== 'undefined' ? window.innerHeight : 0
);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
setHeight(window.innerHeight);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}
}, []);
return { width, height };
}
export default useViewport;

View File

@@ -120,7 +120,11 @@
}
html, body, #root {
width: 100%;
height: 100%;
min-width: 1280px;
min-height: 720px;
max-height: 1080px;
}
/* Chrome, Safari, Edge */
@@ -130,7 +134,19 @@ input[type="number"]::-webkit-outer-spin-button {
margin: 0;
}
.rdp-day {
aspect-ratio: unset;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
}
.rdp-week:not(:first-child) {
@apply border-t;
}
.rdp-day:not(:first-child) {
@apply border-l;
}

View File

@@ -1,5 +1,5 @@
import SideBar from "@/ui/component/SideBar";
import { Outlet } from "react-router-dom";
import { Outlet, useNavigate } from "react-router-dom";
import { SidebarProvider } from "@/components/ui/sidebar";
import Header from "@/ui/component/Header";
import { useAuthStore } from '@/store/authStore';
@@ -11,9 +11,22 @@ import {
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useState } from "react";
export default function Layout() {
const { authData } = useAuthStore();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const pathname = location.pathname;
const goTo = (path: string) => {
console.log(path);
console.log(pathname);
if (path === pathname) return;
navigate(path);
setOpen(false);
}
return (
<>
<Toaster
@@ -27,15 +40,17 @@ export default function Layout() {
}}
/>
<SidebarProvider
defaultOpen={false}
open={open}
onOpenChange={setOpen}
id="root"
>
<SideBar />
<SideBar goTo={goTo} />
<div className="flex flex-col w-full h-full">
{ authData ? <Header /> : null}
{/* <Header /> */}
<div className="w-full h-full p-2.5">
<Outlet />
</div>
</div>
</SidebarProvider>
</>
);

View File

@@ -3,14 +3,20 @@ import {
SendVerificationCodeRequest,
VerifyCodeRequest,
SignupRequest,
LoginRequest
LoginRequest,
SendResetPasswordCodeRequest,
VerifyResetPasswordCodeRequest,
ResetPasswordRequest
} from "@/data/request";
import {
CheckDuplicationResponse,
SendVerificationCodeResponse,
VerifyCodeResponse,
SignupResponse,
LoginResponse
LoginResponse,
SendResetPasswordCodeResponse,
VerifyResetPasswordCodeResponse,
ResetPasswordResponse
} from "@/data/response";
import { BaseNetwork } from "./BaseNetwork";
@@ -67,4 +73,25 @@ export class AccountNetwork extends BaseNetwork {
}
);
}
async sendResetPasswordCode(data: SendResetPasswordCodeRequest) {
return await this.post<SendResetPasswordCodeResponse>(
this.baseUrl + '/send-reset-password-code',
data
);
}
async verifyResetPasswordCode(data: VerifyResetPasswordCodeRequest) {
return await this.post<VerifyResetPasswordCodeResponse>(
this.baseUrl + '/verify-reset-password-code',
data
);
}
async resetPassword(data: ResetPasswordRequest) {
return await this.post<ResetPasswordResponse>(
this.baseUrl + '/reset-password',
data
);
}
}

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) => ({
const storage = sessionStorage;
export const useAuthStore = create<AuthStoreProps>()(
persist(
(set) => ({
authData: undefined,
login: (data: AuthData) => {
set({ authData: data });
Object.entries(data)
.forEach((entry) => {
localStorage.setItem(entry[0], entry[1]);
})
},
logout: () => {
localStorage.setItem('autoLogin', 'false');
localStorage.removeItem('auth-storage');
set({ authData: undefined });
localStorage.clear();
}
}));
}),
{
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">
<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

@@ -5,11 +5,20 @@ import {
SidebarFooter,
SidebarHeader
} from '@/components/ui/sidebar';
import { PageRouting } from '@/const/PageRouting';
interface SideBarProps {
goTo: (path: string) => void;
}
export default function SideBar({ goTo } : SideBarProps) {
export default function SideBar() {
return (
<Sidebar>
<Sidebar forceSheet={true}>
<SidebarHeader></SidebarHeader>
<SidebarContent className="flex flex-col p-4 cursor-default">
<div onClick={() => goTo(PageRouting["HOME"].path)}>Home</div>
<div onClick={() => goTo(PageRouting["SCHEDULES"].path)}>Schedules</div>
</SidebarContent>
</Sidebar>
);
}

View File

@@ -0,0 +1,190 @@
import { cn } from "@/lib/utils";
import { Calendar } from "@/components/ui/calendar";
import { useLayoutEffect, useRef, useState } from "react";
import { getDefaultClassNames } from "react-day-picker";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { isSameDay, getWeeksInMonth, getWeekOfMonth } from "date-fns";
import { SchedulePopover } from "../popover/SchedulePopover";
interface CustomCalendarProps {
data?: any;
}
export const CustomCalendar = ({ data }: CustomCalendarProps) => {
const [weekCount, setWeekCount] = useState(5);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [popoverOpen, setPopoverOpen] = useState(false);
const [popoverSide, setPopoverSide] = useState<'right' | 'left'>('right');
const [popoverAlign, setPopoverAlign] = useState<'start' | 'end'>('end');
const defaultClassNames = getDefaultClassNames();
const containerRef = useRef<HTMLDivElement>(null);
const updateWeekCount = () => {
if (containerRef === null) return;
if (!containerRef.current) return;
const weeks = containerRef.current.querySelectorAll('.rdp-week');
if (weeks?.length) setWeekCount(weeks.length);
}
useLayoutEffect(() => {
updateWeekCount();
}, []);
const handleOpenChange = (open: boolean) => {
setPopoverOpen(open);
if (!open) {
setTimeout(() => {
setSelectedDate(undefined);
}, 150);
}
}
const handleDaySelect = (date: Date | undefined) => {
if (!date) {
setPopoverOpen(false);
setTimeout(() => {
setSelectedDate(undefined);
}, 150);
return;
}
if (date) {
setSelectedDate(date);
const dayOfWeek = date.getDay();
if (0 <= dayOfWeek && dayOfWeek < 4) {
setPopoverSide('right');
} else {
setPopoverSide('left');
}
const options = { weekStartsOn: 0 as 0 };
const totalWeeks = getWeeksInMonth(date, options);
const currentWeekNumber = getWeekOfMonth(date, options);
const threshold = Math.ceil(totalWeeks / 2);
if (currentWeekNumber <= threshold) {
setPopoverAlign('start');
} else {
setPopoverAlign('end');
}
requestAnimationFrame(() => {
setPopoverOpen(true);
})
}
}
return (
<div
className="w-full h-full"
ref={containerRef}
>
<Popover
open={popoverOpen}
onOpenChange={handleOpenChange}
>
<Calendar
mode="single"
className="h-full w-full border rounded-lg"
selected={selectedDate}
onSelect={handleDaySelect}
onMonthChange={() => {
// month 바뀐 직후 DOM 변화가 생기므로 다음 프레임에서 계산
requestAnimationFrame(() => {
updateWeekCount();
});
}}
classNames={{
months: cn(
defaultClassNames.months,
"w-full h-full relative"
),
nav: cn(
defaultClassNames.nav,
"flex w-full item-center gap-1 justify-around absolute top-0 inset-x-0"
),
month: cn(
defaultClassNames.month,
"h-full w-full flex flex-col"
),
month_grid: cn(
defaultClassNames.month_grid,
"w-full h-full flex-1"
),
weeks: cn(
defaultClassNames.weeks,
"w-full h-full"
),
weekdays: cn(
defaultClassNames.weekdays,
"w-full"
),
week: cn(
defaultClassNames.week,
`w-full`
),
day: cn(
defaultClassNames.day,
`w-[calc(100%/7)] rounded-none`
),
day_button: cn(
defaultClassNames.day_button,
"h-full w-full flex p-2 justify-start items-start",
"hover:bg-transparent",
"data-[selected-single=true]:bg-transparent data-[selected-single=true]:text-black"
),
selected: cn(
defaultClassNames.selected,
"h-full border-0 fill-transparent"
),
today: cn(
defaultClassNames.today,
"h-full"
),
}}
styles={{
day: {
height: `calc(100%/${weekCount})`
},
}}
components={{
Day: ({ day, ...props }) => {
const date = day.date;
const isSelected = selectedDate && isSameDay(selectedDate, date);
return (
<td {...props}>
{ isSelected
? <PopoverTrigger asChild>
{props.children}
</PopoverTrigger>
: props.children
}
</td>
)
},
DayButton: ({ day, ...props}) => (
<button
{...props}
disabled={day.outside}
>
{props.children}
</button>
)
}}
/>
<SchedulePopover
date={selectedDate}
popoverSide={popoverSide}
popoverAlign={popoverAlign}
/>
</Popover>
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { PopoverContent } from "@/components/ui/popover"
import type { ColorPaletteType } from "@/const/ColorPalette"
import { usePalette } from "@/hooks/use-palette";
import { useState } from "react";
interface ColorPickPopoverProps {
setColor: (color: ColorPaletteType) => void;
}
export const ColorPickPopover = ({ setColor }: ColorPickPopoverProps) => {
const [seeMore, setSeeMore] = useState(false);
const {
getMainPaletteList,
getExtraPaletteList,
getCustomColor
} = usePalette();
const mainPaletteList = getMainPaletteList();
const extraPaletteList = getExtraPaletteList();
const getSlicedList = (paletteList: ColorPaletteType[], length: number) => {
const slicedList: ColorPaletteType[][] = [];
let index = 0;
while (index < paletteList.length) {
slicedList.push(paletteList.slice(index, index + length));
index += length;
}
return slicedList;
}
return (
<PopoverContent
className="flex flex-col gap-1.5 w-fit"
>
{getSlicedList(mainPaletteList, 5).map((list) => (
<div className="flex flex-row gap-2.5">
{list.map((palette) => (
<div
className="rounded-full w-5 h-5 border border-gray-300"
style={{ backgroundColor: `${palette.style}` }}
onClick={() => setColor(palette)}
/>
))}
</div>
))}
{
!seeMore
? <div className="w-full" onClick={() => setSeeMore(true)}> </div>
: <>
{getSlicedList(extraPaletteList, 5).map((list) => (
<div className="flex flex-row gap-2.5">
{list.map((palette) => (
<div
className="rounded-full w-5 h-5 border border-gray-300"
style={{
backgroundColor: `${palette.style !== 'transparent' && palette.style}`,
background: `${palette.style === 'transparent' && 'linear-gradient(135deg, black 50%, white 50%)' }`
}}
onClick={() => setColor(palette)}
/>
))}
</div>
))}
</>
}
{
seeMore
? <div className="w-full" onClick={() => setSeeMore(false)}></div>
: null
}
</PopoverContent>
)
}

View File

@@ -0,0 +1,75 @@
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { usePalette } from '@/hooks/use-palette';
import { type ColorPaletteType } from '@/const/ColorPalette';
import { ColorPickPopover } from './ColorPickPopover';
import { Input } from '@/components/ui/input';
interface ScheduleSheetProps {
date: Date | undefined;
popoverSide: 'left' | 'right';
popoverAlign: 'start' | 'end';
}
export const SchedulePopover = ({ date, popoverSide, popoverAlign }: ScheduleSheetProps) => {
const {
ColorPaletteType,
getPaletteNameList,
getMainPaletteList,
getAllPaletteList,
getCustomColor,
getPaletteByKey,
getStyle
} = usePalette();
const defaultColor = getPaletteByKey('Black');
const [scheduleColor, setScheduleColor] = useState(defaultColor);
const [colorPopoverOpen, setColorPopoverOpen] = useState(false);
const selectColor = (color: ColorPaletteType) => {
setScheduleColor(color);
setColorPopoverOpen(false);
}
return (
<PopoverContent
className="rounded-xl xl:w-[calc(100vw/4)] xl:max-w-[480px] min-w-[320px]"
align={popoverAlign} side={popoverSide}
>
<ScrollArea
className={
cn(
"[&>div>div:last-child]:hidden min-h-[125px] h-[calc(100vh/2)] p-2.5 w-full flex flex-col",
)
}
>
<div className="w-full flex flex-row justify-center items-center gap-4">
<Popover open={colorPopoverOpen} onOpenChange={setColorPopoverOpen}>
<PopoverTrigger asChild>
<div
className={cn(
'rounded-full w-5 h-5 border-2 border-gray-300',
)}
style={{
backgroundColor: `${scheduleColor.style !== 'transparent' && scheduleColor.style}`,
background: `${scheduleColor.style === 'transparent' && 'linear-gradient(135deg, black 50%, white 50%)' }`
}}
/>
</PopoverTrigger>
<ColorPickPopover
setColor={selectColor}
/>
</Popover>
<Input
placeholder="제목"
className="font-bold border-t-0 border-r-0 border-l-0 p-0 border-b-2 rounded-none shadow-none border-indigo-300 focus-visible:ring-0 focus-visible:border-b-indigo-500"
style={{
fontSize: '20px'
}}
/>
</div>
</ScrollArea>
</PopoverContent>
)
}

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>(localStorage.getItem('autoLogin') === 'true');
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,33 @@ 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}`
}
);
loginPromise
.then((res) => {
success: (res) => {
setIsLoading(false);
if (res.data.success) {
const data = {
accessToken: res.data.accessToken!,
refreshToken: res.data.refreshToken!
};
login({...data});
if (autoLogin) {
localStorage.setItem('auth-storage', JSON.stringify({ state: data }));
}
login({ ...data });
moveToHomePage();
return "로그인 성공";
} else {
throw new Error(res.data.message);
}
})
.finally(() => setIsLoading(false));
},
error: (err: Error) => {
setIsLoading(false);
return err.message || "에러 발생"
}
}
);
}
const TextSeparator = ({ text }: { text: string }) => {
@@ -98,9 +101,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 +127,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 +146,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 +156,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

@@ -0,0 +1,421 @@
import { Card, CardContent, CardHeader, CardFooter } from '@/components/ui/card';
import { ResetPasswordSchema } from '@/data/form';
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 React, { useState, useCallback } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { PageRouting } from '@/const/PageRouting';
import { Stepper, StepperContent, StepperIndicator, StepperItem, StepperNav, StepperPanel, StepperSeparator, StepperTrigger } from '@/components/ui/stepper';
import * as z from 'zod';
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
import { Validator } from '@/util/Validator';
import { Eye, EyeOff, LoaderCircleIcon, CircleCheckBigIcon } from 'lucide-react';
import { AccountNetwork } from '@/network/AccountNetwork';
import { Label } from '@/components/ui/label';
const steps = [1, 2, 3, 4];
export default function ResetPasswordPage() {
const [currentStep, setCurrentStep] = useState(1);
const [showPassword, setShowPassword] = useState(false);
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const accountNetwork = new AccountNetwork();
const resetPasswordForm = useForm<z.infer<typeof ResetPasswordSchema>>({
resolver: zodResolver(ResetPasswordSchema),
defaultValues: {
email: "",
code: "",
password: "",
passwordConfirm: ""
}
});
const { email, code, password, passwordConfirm } = resetPasswordForm.watch();
const moveToLoginPage = useCallback(() => {
navigate(PageRouting["LOGIN"].path);
}, []);
const handleClickFirstStepButton = async () => {
if (isLoading) return;
if (!email || email.trim().length === 0) {
resetPasswordForm.setError('email', {
type: 'manual',
message: '이메일을 입력해주십시오'
});
return;
}
const isEmailValid = await resetPasswordForm.trigger('email');
if (!isEmailValid) {
resetPasswordForm.setError('email', {
type: 'validate',
message: '이메일 형식이 올바르지 않습니다'
});
return;
}
setIsLoading(true);
try {
const response = await accountNetwork.sendResetPasswordCode({ email: email });
const resData = response.data;
if (!resData.success) {
resetPasswordForm.setError('email', {
message: '서버 오류로 코드 발송에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
});
return;
}
setCurrentStep(current => current + 1);
} catch (err) {
resetPasswordForm.setError('email', {
message: '서버 오류로 코드 발송에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
});
} finally {
setIsLoading(false);
}
}
const handleSecondStepOTPCompleted = async () => {
if (isLoading) return;
const codeValid = await resetPasswordForm.trigger('code');
if (!codeValid) {
return;
}
const data = {
email: email,
code: code
}
setIsLoading(true);
try {
const response = await accountNetwork.verifyResetPasswordCode(data);
const resData = response.data;
console.log(resData);
if (!resData.success || !resData.verified) {
resetPasswordForm.setError('code', {
type: 'value',
message: resData.error
});
return;
}
setCurrentStep(current => current + 1);
} catch (err) {
resetPasswordForm.setError('code', {
type: 'value',
message: '서버 오류로 코드 인증에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
});
} finally {
setIsLoading(false);
}
}
const handleClickThirdStepButton = async () => {
if (isLoading) return;
const passwordValid = await resetPasswordForm.trigger('password');
if (!passwordValid) return;
const passwordConfirmValid = await resetPasswordForm.trigger('passwordConfirm');
if (!passwordConfirmValid) return;
const data = {
email: email,
password: password
}
setIsLoading(true);
try {
const response = await accountNetwork.resetPassword(data);
const resData = response.data;
if (!resData.success) {
resetPasswordForm.setError('password', {
message: '서버 오류로 비밀번호 변경에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
});
return;
}
setCurrentStep(current => current + 1);
} catch (err) {
resetPasswordForm.setError('password', {
message: '서버 오류로 비밀번호 변경에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
});
} finally {
setIsLoading(false);
}
}
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}
onValueChange={setCurrentStep}
className="w-full h-full flex flex-col justify-center items-center"
indicators={{
loading: <LoaderCircleIcon className="size-3.5 animate-spin" />
}}
>
<Card className="w-md pl-2 pr-2">
<CardHeader>
</CardHeader>
<CardContent>
<StepperNav className="select-none">
{steps.map((step) => (
<StepperItem key={step} step={step} loading={isLoading}>
<StepperTrigger asChild>
<StepperIndicator
className={[
`transition-all duration-300`,
`bg-accent text-accent-foreground rounded-full text-xs data-[state=completed]:bg-indigo-500 data-[state=completed]:text-primary-foreground data-[state=active]:bg-indigo-300 data-[state=active]:text-primary-foreground`,
].join(' ')}
>
{step}
</StepperIndicator>
</StepperTrigger>
{
steps.length > step
&& <StepperSeparator
className="transition-all duration-300 group-data-[state=completed]/step:bg-indigo-500"
/>
}
</StepperItem>
))}
</StepperNav>
</CardContent>
<CardContent>
<StepperPanel>
<StepperContent value={1} key={1}>
<Field data-invalid={resetPasswordForm.formState.errors.email?.message ? true : false}>
<FieldLabel htmlFor="reseet-password-email"></FieldLabel>
<Controller
name="email"
control={resetPasswordForm.control}
render={({ field, fieldState }) => (
<>
<Input
{...field}
type="email"
id="reset-password-email"
aria-invalid={fieldState.invalid}
onKeyDown={handleEnterKeyDown}
/>
<FieldError className="font-[12px]" errors={[fieldState.error]} />
</>
)}
/>
</Field>
</StepperContent>
<StepperContent value={2} key={2}>
<Controller
name="code"
control={resetPasswordForm.control}
render={
({ field, fieldState }) => (
<div className="w-full flex flex-col justify-center gap-5">
<FieldLabel htmlFor="reset-password-code"> </FieldLabel>
<div className="flex flex-row justify-center items-center">
<InputOTP
maxLength={8}
inputMode="text"
id="reset-password-code"
onComplete={handleSecondStepOTPCompleted}
value={field.value}
onChange={(value) => field.onChange(value)}
onBlur={field.onBlur}
required
>
<InputOTPGroup
className="gap-2.5 *:data-[slot=input-otp-slot]:rounded-md *:data-[slot=input-otp-slot]:border"
>
{
[0, 1, 2, 3, 4, 5, 6, 7].map((idx) => (
<InputOTPSlot index={idx} />
))
}
</InputOTPGroup>
</InputOTP>
</div>
<FieldError errors={[fieldState.error]} />
</div>
)
}
/>
</StepperContent>
<StepperContent value={3} key={3}>
<div className="w-full flex flex-col gap-5 items-start">
<FieldLabel htmlFor="reset-password-password"> </FieldLabel>
<Controller
name="password"
control={resetPasswordForm.control}
render={({ field, fieldState }) => (
<>
<div className="relative w-full">
<Input
{...field}
type={ showPassword ? "text" : "password" }
id="reset-password-password"
aria-invalid={fieldState.invalid}
onKeyDown={handleEnterKeyDown}
className="pr-10"
/>
<button
type="button"
className="absolute top-1/2 right-2 -translate-y-1/2"
onClick={() => setShowPassword(prev => !prev)}
>
{
showPassword
? <EyeOff size={14} />
: <Eye size={14} />
}
</button>
</div>
<FieldError className="text-[12px]" errors={[fieldState.error]} />
</>
)}
/>
<FieldLabel htmlFor="reset-password-password-confirm"> </FieldLabel>
<Controller
name="passwordConfirm"
control={resetPasswordForm.control}
render={({ field, fieldState}) => (
<>
<div className="w-full relative">
<Input
{...field}
type={ showPasswordConfirm ? "text" : "password" }
id="reset-password-password-confirm"
className="pr-10"
aria-invalid={fieldState.invalid}
onKeyDown={handleEnterKeyDown}
/>
<button
type="button"
className="absolute top-1/2 right-2 -translate-y-1/2"
onClick={() => setShowPasswordConfirm(prev => !prev)}
>
{
showPasswordConfirm
? <EyeOff size={14} />
: <Eye size={14} />
}
</button>
</div>
<FieldError className="text-[12px]" errors={[fieldState.error]} />
</>
)}
/>
</div>
</StepperContent>
<StepperContent value={4} key={4}>
<div className="w-full flex flex-col justify-center items-center gap-5">
<CircleCheckBigIcon size={50} className="text-indigo-500" />
<Label className="font-extrabold text-xl"> </Label>
</div>
</StepperContent>
</StepperPanel>
</CardContent>
<CardFooter
className="w-full"
>
<StepperPanel className="w-full">
<StepperContent value={1}>
<div className="w-full flex flex-row items-center gap-5">
<Button
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
type="button"
form="form-reset-password"
disabled={
(email.trim().length < 1)
}
onClick={handleClickFirstStepButton}
>
</Button>
<Button
type="button"
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
onClick={moveToLoginPage}
>
</Button>
</div>
</StepperContent>
<StepperContent value={2} key={2}>
<div className="w-full flex flex-row justify-end items-center gap-5">
<Button
type="button"
className="bg-stone-300 text-white hover:bg-stone-400"
onClick={moveToLoginPage}
>
</Button>
</div>
</StepperContent>
<StepperContent value={3} key={3}>
<div className="w-full flex flex-row align-center gap-5">
<Button
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
type="button"
form="form-reset-password"
disabled={
(password.trim().length < 1)
&& (passwordConfirm.trim().length < 1)
}
onClick={handleClickThirdStepButton}
>
</Button>
<Button
type="button"
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
onClick={moveToLoginPage}
>
</Button>
</div>
</StepperContent>
<StepperContent value={4} key={4}>
<div className="w-full flex flex-row justify-end items-center gap-5">
<Button
type="button"
className="bg-stone-500 text-white hover:bg-stone-400"
onClick={moveToLoginPage}
>
</Button>
</div>
</StepperContent>
</StepperPanel>
</CardFooter>
</Card>
</Stepper>
)
}

View File

@@ -0,0 +1,285 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
CardHeader,
CardFooter
} from '@/components/ui/card';
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
import { SignUpSchema } from '@/data/form';
import { Controller, useForm } from 'react-hook-form';
import * as z from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import EmailVerificationModal from '@/ui/component/modal/EmailVerificationModal';
import { CheckDuplicationRequest, SignupRequest } from '@/data/request';
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);
const [isCheckedEmailDuplication, setIsCheckedEmailDuplication] = useState<boolean>(false);
const [isCheckedAccountIdDuplication, setIsCheckedAccountIdDuplication] = useState<boolean>(false);
const [duplicationCheckedEmail, setDuplicationCheckedEmail] = useState<string>("");
const [duplicationCheckedAccountId, setDuplicationCheckedAccountId] = useState<string>("");
const accountNetwork = new AccountNetwork();
const navigate = useNavigate();
const isMobile = useIsMobile();
const signUpForm = useForm<z.infer<typeof SignUpSchema>>({
resolver: zodResolver(SignUpSchema),
defaultValues: {
accountId: "",
email: "",
password: "",
passwordConfirm: "",
name: "",
nickname: "",
}
});
const goToLogin = useCallback(() => {
navigate(PageRouting["LOGIN"].path);
}, [navigate]);
const checkDuplication = async (type: 'email' | 'accountId', value: string) => {
const data: CheckDuplicationRequest = new CheckDuplicationRequest(type, value);
return await accountNetwork.checkDuplication(data);
}
const signup = async () => {
const { email, accountId, name, nickname, password } = signUpForm.getValues();
const data: SignupRequest = new SignupRequest(accountId, email, name, nickname, password);
const signupPromise = accountNetwork.signup(data);
toast.promise(
signupPromise,
{
loading: "회원가입 진행 중입니다.",
success: (res) => {
if (!res.data.success) return "회원가입에 실패하였습니다.\n잠시 후 다시 시도해주십시오.";
return <SuccessToast onClose={goToLogin} />
},
error: "회원가입에 실패하였습니다.\n잠시 후 다시 시도해주십시오.",
}
);
}
const handleOnChangeAccountId = (e: React.ChangeEvent<HTMLInputElement>) => {
setIsCheckedAccountIdDuplication(
e.currentTarget.value === duplicationCheckedAccountId
);
}
const handleOnChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
setIsCheckedEmailDuplication(
e.currentTarget.value === duplicationCheckedEmail
);
}
const handleDuplicationCheckButtonClick = async (type: 'email' | 'accountId') => {
const value = signUpForm.getValues(type);
const duplicatedMessage = type === 'email' ? '사용할 수 없는 이메일입니다.' : '사용할 수 없는 아이디입니다.';
if (!value) return;
const isDuplicated = (await checkDuplication(type, value)).data.isDuplicated;
if (isDuplicated) {
signUpForm.setError(type, { message: duplicatedMessage });
} else {
signUpForm.clearErrors(type);
if (type === 'email') {
setIsCheckedEmailDuplication(true);
setDuplicationCheckedEmail(value);
} else {
setIsCheckedAccountIdDuplication(true);
setDuplicationCheckedAccountId(value);
}
}
}
const handleOnSignUpButtonClick = () => {
if (!isCheckedAccountIdDuplication) {
signUpForm.setError("accountId", { message: "아이디 중복 확인이 필요합니다."});
return;
}
if (!isCheckedEmailDuplication) {
signUpForm.setError("email", { message: "이메일 중복 확인이 필요합니다." });
return;
}
setEmailVerificationModalOpen(true);
}
return (
<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-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">
<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>
);
}
function SuccessToast({ onClose }: { onClose: () => void }) {
useEffect(() => {
const timer = setTimeout(() => onClose(), 3000); // 3초 후 이동
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className="w-full flex flex-row justify-between items-center">
!
<button onClick={onClose}> </button>
</div>
);
}

View File

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

View File

@@ -1,76 +0,0 @@
import { Card, CardContent, CardHeader, CardFooter } from '@/components/ui/card';
import { ResetPasswordSchema } from '@/data/form';
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 { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { PageRouting } from '@/const/PageRouting';
import * as z from 'zod';
export default function ResetPasswordPage() {
const navigate = useNavigate();
const loginForm = useForm<z.infer<typeof ResetPasswordSchema>>({
resolver: zodResolver(ResetPasswordSchema),
defaultValues: {
email: ""
}
});
const moveToLoginPage = useCallback(() => {
navigate(PageRouting["LOGIN"].path);
}, []);
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-reset-password" className="w-full flex flex-col gap-2.5">
<Controller
name="email"
control={loginForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-reset-password-email"></FieldLabel>
<Input
{...field}
type="email"
id="form-reset-password-email"
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
>
</Controller>
</form>
</CardContent>
<CardFooter
className="w-full flex flex-row items-center gap-5"
>
<Button
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
type="submit"
form="form-reset-password"
disabled={
(loginForm.getValues("email").trim.length < 1)
}>
</Button>
<Button
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
onClick={moveToLoginPage}
>
</Button>
</CardFooter>
</Card>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { CustomCalendar } from "@/ui/component/calendar/CustomCalendar";
export function ScheduleMainPage() {
return (
<div
className="w-full h-full p-2"
>
<CustomCalendar />
</div>
)
}

View File

@@ -1,277 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
CardHeader,
CardFooter
} from '@/components/ui/card';
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
import { SignUpSchema } from '@/data/form';
import { Controller, useForm } from 'react-hook-form';
import * as z from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import EmailVerificationModal from '@/ui/component/modal/EmailVerificationModal';
import { CheckDuplicationRequest, SignupRequest } from '@/data/request';
import { AccountNetwork } from '@/network/AccountNetwork';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import { PageRouting } from '@/const/PageRouting';
export default function SignUpPage() {
const [emailVerificationModalOpen, setEmailVerificationModalOpen] = useState<boolean>(false);
const [isCheckedEmailDuplication, setIsCheckedEmailDuplication] = useState<boolean>(false);
const [isCheckedAccountIdDuplication, setIsCheckedAccountIdDuplication] = useState<boolean>(false);
const [duplicationCheckedEmail, setDuplicationCheckedEmail] = useState<string>("");
const [duplicationCheckedAccountId, setDuplicationCheckedAccountId] = useState<string>("");
const accountNetwork = new AccountNetwork();
const navigate = useNavigate();
const signUpForm = useForm<z.infer<typeof SignUpSchema>>({
resolver: zodResolver(SignUpSchema),
defaultValues: {
accountId: "",
email: "",
password: "",
passwordConfirm: "",
name: "",
nickname: "",
}
});
const goToLogin = useCallback(() => {
navigate(PageRouting["LOGIN"].path);
}, [navigate]);
const checkDuplication = async (type: 'email' | 'accountId', value: string) => {
const data: CheckDuplicationRequest = new CheckDuplicationRequest(type, value);
return await accountNetwork.checkDuplication(data);
}
const signup = async () => {
const { email, accountId, name, nickname, password } = signUpForm.getValues();
const data: SignupRequest = new SignupRequest(accountId, email, name, nickname, password);
const signupPromise = accountNetwork.signup(data);
toast.promise(
signupPromise,
{
loading: "회원가입 진행 중입니다.",
success: (res) => {
if (!res.data.success) return "회원가입에 실패하였습니다.\n잠시 후 다시 시도해주십시오.";
return <SuccessToast onClose={goToLogin} />
},
error: "회원가입에 실패하였습니다.\n잠시 후 다시 시도해주십시오.",
}
);
}
const handleOnChangeAccountId = (e: React.ChangeEvent<HTMLInputElement>) => {
setIsCheckedAccountIdDuplication(
e.currentTarget.value === duplicationCheckedAccountId
);
}
const handleOnChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
setIsCheckedEmailDuplication(
e.currentTarget.value === duplicationCheckedEmail
);
}
const handleDuplicationCheckButtonClick = async (type: 'email' | 'accountId') => {
const value = signUpForm.getValues(type);
const duplicatedMessage = type === 'email' ? '사용할 수 없는 이메일입니다.' : '사용할 수 없는 아이디입니다.';
if (!value) return;
const isDuplicated = (await checkDuplication(type, value)).data.isDuplicated;
if (isDuplicated) {
signUpForm.setError(type, { message: duplicatedMessage });
} else {
signUpForm.clearErrors(type);
if (type === 'email') {
setIsCheckedEmailDuplication(true);
setDuplicationCheckedEmail(value);
} else {
setIsCheckedAccountIdDuplication(true);
setDuplicationCheckedAccountId(value);
}
}
}
const handleOnSignUpButtonClick = () => {
if (!isCheckedAccountIdDuplication) {
signUpForm.setError("accountId", { message: "아이디 중복 확인이 필요합니다."});
return;
}
if (!isCheckedEmailDuplication) {
signUpForm.setError("email", { message: "이메일 중복 확인이 필요합니다." });
return;
}
setEmailVerificationModalOpen(true);
}
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">
<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-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">
<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>
<CardFooter>
<EmailVerificationModal
trigger={
<Button type="button" onClick={handleOnSignUpButtonClick} className="0">
</Button>
}
email={duplicationCheckedEmail}
open={emailVerificationModalOpen} // ✅ 부모 상태 연결
setOpen={setEmailVerificationModalOpen} // ✅ 부모 상태 변경 함수 전달
onVerifySuccess={signup} // ✅ 인증 성공 시 signup 호출
/>
</CardFooter>
</Card>
</div>
);
}
function SuccessToast({ onClose }: { onClose: () => void }) {
useEffect(() => {
const timer = setTimeout(() => onClose(), 3000); // 3초 후 이동
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className="w-full flex flex-row justify-between items-center">
!
<button onClick={onClose}> </button>
</div>
);
}

View File

@@ -3,5 +3,21 @@ export class Validator {
if (typeof value !== 'string') return false;
const email = value.trim();
return /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(email);
};
};
static validatePasswordFormat = (password: string): boolean => {
if (password.length < 8) return false;
if (password.includes(' ')) return false;
const alphabets = 'abcdefghijklmnopqrstuvwxyz';
const numbers = '0123456789';
const specials = '!@#$%^';
if (!alphabets.includes(password[0])) return false;
const hasNumber = [...numbers].some((char) => password.includes(char));
const hasSpecial = [...specials].some((char) => password.includes(char));
return hasNumber && hasSpecial;
}
}

View File

@@ -5,6 +5,7 @@ import path from 'path'
// https://vite.dev/config/
export default defineConfig({
server: {
host: '0.0.0.0',
port: 5185
},
plugins: [