Compare commits
2 Commits
49ca9b9ae3
...
af3fa26f3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af3fa26f3b | ||
|
|
eec883ac32 |
@@ -18,5 +18,7 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"registries": {}
|
"registries": {
|
||||||
|
"@reui": "https://reui.io/r/{name}.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -54,7 +54,7 @@
|
|||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
@@ -6376,9 +6376,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.3.1",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||||
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
|
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import './App.css';
|
import './App.css';
|
||||||
import SignUpPage from './ui/page/signup/SignUpPage';
|
import SignUpPage from './ui/page/account/signup/SignUpPage';
|
||||||
import Layout from './layouts/Layout';
|
import Layout from './layouts/Layout';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
import { PageRouting } from './const/PageRouting';
|
import { PageRouting } from './const/PageRouting';
|
||||||
import LoginPage from './ui/page/login/LoginPage';
|
import LoginPage from './ui/page/account/login/LoginPage';
|
||||||
import ResetPasswordPage from './ui/page/resetPassword/ResetPasswordPage';
|
import ResetPasswordPage from './ui/page/account/resetPassword/ResetPasswordPage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { authData } = useAuthStore();
|
const { authData } = useAuthStore();
|
||||||
|
|||||||
408
src/components/ui/stepper.tsx
Normal file
408
src/components/ui/stepper.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -3,4 +3,19 @@ import * as z from 'zod';
|
|||||||
export const ResetPasswordSchema = z.object({
|
export const ResetPasswordSchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
.email()
|
.email()
|
||||||
|
, code: z
|
||||||
|
.string()
|
||||||
|
.length(8)
|
||||||
|
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$%^]).*$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||||
|
, password: z
|
||||||
|
.string()
|
||||||
|
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
||||||
|
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
||||||
|
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$%^]).*$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||||
|
, passwordConfirm: z
|
||||||
|
.string()
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.passwordConfirm, {
|
||||||
|
path: ["passwordConfirm"],
|
||||||
|
error: "비밀번호가 일치하지 않습니다."
|
||||||
});
|
});
|
||||||
@@ -11,7 +11,7 @@ export const SignUpSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
||||||
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
||||||
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$]).*$/, "영문 소문자로 시작하고 숫자와 특수문자(!@#$)를 포함해야 합니다.")
|
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$%^]).*$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||||
, name: z
|
, name: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "이름을 입력해주시십시오.")
|
.min(1, "이름을 입력해주시십시오.")
|
||||||
|
|||||||
4
src/data/request/account/ResetPasswordRequest.ts
Normal file
4
src/data/request/account/ResetPasswordRequest.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export class ResetPasswordRequest {
|
||||||
|
email!: string;
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
3
src/data/request/account/SendResetPasswordCodeRequest.ts
Normal file
3
src/data/request/account/SendResetPasswordCodeRequest.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export class SendResetPasswordCodeRequest {
|
||||||
|
email!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export class VerifyResetPasswordCodeRequest {
|
||||||
|
email!: string;
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -3,3 +3,6 @@ export * from './account/SendVerificationCodeRequest';
|
|||||||
export * from './account/VerifyCodeRequest';
|
export * from './account/VerifyCodeRequest';
|
||||||
export * from './account/SignupRequest';
|
export * from './account/SignupRequest';
|
||||||
export * from './account/LoginRequest';
|
export * from './account/LoginRequest';
|
||||||
|
export * from './account/SendResetPasswordCodeRequest';
|
||||||
|
export * from './account/VerifyResetPasswordCodeRequest';
|
||||||
|
export * from './account/ResetPasswordRequest';
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export class BaseResponse {
|
export class BaseResponse {
|
||||||
|
success!: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { BaseResponse } from "../BaseResponse";
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
export class LoginResponse extends BaseResponse {
|
export class LoginResponse extends BaseResponse {
|
||||||
success!: boolean;
|
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
}
|
}
|
||||||
5
src/data/response/account/ResetPasswordResponse.ts
Normal file
5
src/data/response/account/ResetPasswordResponse.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class ResetPasswordResponse extends BaseResponse {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class SendResetPasswordCodeResponse extends BaseResponse {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BaseResponse } from "../BaseResponse";
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
export class SendVerificationCodeResponse extends BaseResponse {
|
export class SendVerificationCodeResponse extends BaseResponse {
|
||||||
success!: boolean;
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { BaseResponse } from "../BaseResponse";
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
export class SignupResponse extends BaseResponse {
|
export class SignupResponse extends BaseResponse {
|
||||||
success!: boolean;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class VerifyResetPasswordCodeResponse extends BaseResponse {
|
||||||
|
verified!: boolean;
|
||||||
|
}
|
||||||
@@ -3,3 +3,6 @@ export * from './account/SendVerificationCodeResponse';
|
|||||||
export * from './account/VerifyCodeResponse';
|
export * from './account/VerifyCodeResponse';
|
||||||
export * from './account/SignupResponse';
|
export * from './account/SignupResponse';
|
||||||
export * from './account/LoginResponse';
|
export * from './account/LoginResponse';
|
||||||
|
export * from './account/SendResetPasswordCodeResponse';
|
||||||
|
export * from './account/VerifyResetPasswordCodeResponse';
|
||||||
|
export * from './account/ResetPasswordResponse';
|
||||||
@@ -3,14 +3,20 @@ import {
|
|||||||
SendVerificationCodeRequest,
|
SendVerificationCodeRequest,
|
||||||
VerifyCodeRequest,
|
VerifyCodeRequest,
|
||||||
SignupRequest,
|
SignupRequest,
|
||||||
LoginRequest
|
LoginRequest,
|
||||||
|
SendResetPasswordCodeRequest,
|
||||||
|
VerifyResetPasswordCodeRequest,
|
||||||
|
ResetPasswordRequest
|
||||||
} from "@/data/request";
|
} from "@/data/request";
|
||||||
import {
|
import {
|
||||||
CheckDuplicationResponse,
|
CheckDuplicationResponse,
|
||||||
SendVerificationCodeResponse,
|
SendVerificationCodeResponse,
|
||||||
VerifyCodeResponse,
|
VerifyCodeResponse,
|
||||||
SignupResponse,
|
SignupResponse,
|
||||||
LoginResponse
|
LoginResponse,
|
||||||
|
SendResetPasswordCodeResponse,
|
||||||
|
VerifyResetPasswordCodeResponse,
|
||||||
|
ResetPasswordResponse
|
||||||
} from "@/data/response";
|
} from "@/data/response";
|
||||||
import { BaseNetwork } from "./BaseNetwork";
|
import { BaseNetwork } from "./BaseNetwork";
|
||||||
|
|
||||||
@@ -67,4 +73,25 @@ export class AccountNetwork extends BaseNetwork {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendResetPasswordCode(data: SendResetPasswordCodeRequest) {
|
||||||
|
return await this.post<SendResetPasswordCodeResponse>(
|
||||||
|
'/send-reset-password-code',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyResetPasswordCode(data: VerifyResetPasswordCodeRequest) {
|
||||||
|
return await this.post<VerifyResetPasswordCodeResponse>(
|
||||||
|
'/verify-reset-password-code',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(data: ResetPasswordRequest) {
|
||||||
|
return await this.post<ResetPasswordResponse>(
|
||||||
|
'/reset-password',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
415
src/ui/page/account/resetPassword/ResetPasswordPage.tsx
Normal file
415
src/ui/page/account/resetPassword/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
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 { 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(4);
|
||||||
|
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;
|
||||||
|
if (!Validator.validatePasswordFormat(code)) {
|
||||||
|
resetPasswordForm.setError('code', {
|
||||||
|
type: 'pattern',
|
||||||
|
message: '올바른 코드 형식이 아닙니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
email: email,
|
||||||
|
code: code
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await accountNetwork.verifyResetPasswordCode(data);
|
||||||
|
const resData = response.data;
|
||||||
|
|
||||||
|
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 handleThirdStepButton = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (email.length === 0) return;
|
||||||
|
handleClickFirstStepButton();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<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={handleThirdStepButton}
|
||||||
|
>
|
||||||
|
비밀번호 변경
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,5 +3,20 @@ export class Validator {
|
|||||||
if (typeof value !== 'string') return false;
|
if (typeof value !== 'string') return false;
|
||||||
const email = value.trim();
|
const email = value.trim();
|
||||||
return /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(email);
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user