issue #37
All checks were successful
Test CI / build (push) Successful in 16s

- 기능 구현 1차 완료(동작 확인 필요)
This commit is contained in:
geonhee-min
2025-12-02 16:50:24 +09:00
parent eec883ac32
commit af3fa26f3b
24 changed files with 519 additions and 97 deletions

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,11 +1,11 @@
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';
function App() {
const { authData } = useAuthStore();

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,
};

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(/^[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: "비밀번호가 일치하지 않습니다."
});

View File

@@ -11,7 +11,7 @@ export const SignUpSchema = z.object({
.string()
.min(8, "비밀번호는 8-12 자리여야 합니다.")
.max(12, "비밀번호는 8-12 자리여야 합니다.")
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$]).*$/, "영소문자로 시작하고 숫자와 특수문자(!@#$)를 포함해야 합니다.")
.regex(/^[a-z](?=.*[0-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';

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>(
'/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
);
}
}

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

@@ -4,4 +4,19 @@ export class Validator {
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;
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;
}
}