티스토리 뷰

💥 비밀번호 초기화

export interface SignupProps {
    email: string
    password: string
}

function ResetPassword() {
    const navigate = useNavigate()
    const showAlert = useAlert()
    const [resetRequested, setResetRequested] = useState(false)

    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm<SignupProps>()

    const onSubmit = (data: SignupProps) => {
        if (resetRequested) {
            // 초기화
            resetPassword(data).then(() => {
                showAlert("비밀번호가 초기화되었습니다.")
                navigate('/login')
            })
        } else {
            // 요청
            resetRequest(data).then(() => {
                setResetRequested(true)
            })
        }
    }

    return (
        <>
            <Title size="large">비밀번호 초기화</Title>
            <SignupStyle>
                <form onSubmit={handleSubmit(onSubmit)}>
                    <fieldset>
                        <InputText
                            placeholder="이메일"
                            inputType="email"
                            {...register("email", { required: true })}
                        />
                        {errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
                    </fieldset>
                    {resetRequested && (
                        <fieldset>
                        <InputText
                            placeholder="비밀번호"
                            inputType="password"
                            {...register("password", { required: true })}
                        />
                        {errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
                    </fieldset>
                    )}
                    <fieldset>
                        <Button type="submit" size="medium" scheme="primary">
                            {resetRequested ? "비밀번호 초기화" : "초기화 요청"}
                        </Button>
                    </fieldset>
                    <div className="info">
                        <Link to="/reset">비밀번호 초기화</Link>
                    </div>
                </form>
            </SignupStyle>
        </>
    )
}

export default ResetPassword

 - ResetPassword.tsx

 

비밀번호 초기화 요청은 이전에 했던 Signup.tsx와 많이 다르지않다.

그래서 복사 붙여넣기를 한 후, 로직을 조금만 변경하는 식으로 했다.

회원가입에서 비밀번호 초기화 링크를 누르면, 비밀번호 초기화 페이지로 이동한다.

이메일 부분에 가입한 이메일을 적었을 때, 존재하는 이메일이라면 비밀번호를 초기화할 수 있는 input이 나타나게 된다.

그 다음에, 바꾸고 싶은 비밀번호를 적은 후, 비밀번호 초기화 버튼을 누르면

showAlert을 통해, "비밀번호가 초기화되었습니다." 라는 알림창이 뜨게 되고,

navigate를 통해 login 페이지로 이동한다.

현재, login 페이지는 만들어놓지 않았기 때문에, 에러페이지로 보인다.

하지만 주소창을 보면 /login 페이지로 잘 이동한 걸 볼 수 있다👏👏

💥 로그인

우선, 백엔드에서 로그인에 성공을 하게되면 쿠키에 토큰을 담아서 프론트쪽으로 전달해줬었는데,

body에도 같이 담아서 전달하는 것으로 수정이 됐다.

그래서 cookie에만 담았던 token을 스프레드 문법으로 status와 body에도 담아서 프론트로 전달하는 로직으로 변경이 됐다.

 

1. Login.tsx

import { 생략 }

export interface SignupProps {
    email: string
    password: string
}

function Login() {
    const navigate = useNavigate()
    const showAlert = useAlert()

    const { isloggedIn, storeLogin, storeLogout } = useAuthStore()

    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm<SignupProps>()

    const onSubmit = (data: SignupProps) => {
        login(data).then((res) => {
            // 상태 변화
            storeLogin(res.token)

            showAlert("로그인이 완료되었습니다.")
            navigate("/")
        }, (error) => {
            showAlert("로그인이 실패했습니다.")
        })
    }

    return (
        <>
            <Title size="large">로그인</Title>
            <SignupStyle>
                <form onSubmit={handleSubmit(onSubmit)}>
                    <fieldset>
                        <InputText
                            placeholder="이메일"
                            inputType="email"
                            {...register("email", { required: true })}
                        />
                        {errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
                    </fieldset>
                    <fieldset>
                        <InputText
                            placeholder="비밀번호"
                            inputType="password"
                            {...register("password", { required: true })}
                        />
                        {errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
                    </fieldset>
                    <fieldset>
                        <Button type="submit" size="medium" scheme="primary">
                            로그인
                        </Button>
                    </fieldset>
                    <div className="info">
                        <Link to="/reset">비밀번호 초기화</Link>
                    </div>
                </form>
            </SignupStyle>
        </>
    )
}

export default Login

 

2. authStore.ts

import { create } from "zustand"

interface StoreState {
    isloggedIn: boolean;
    storeLogin: (token: string) => void;
    storeLogout: () => void;
}

export const getToken = () => {
    const token = localStorage.getItem("token")
    return token
}

const setToken = (token: string) => {
    localStorage.setItem("token", token)
}

export const removeToken = () => {
    localStorage.removeItem("token")
}

export const useAuthStore = create<StoreState>((set) => ({
    isloggedIn: getToken() ? true : false, // 초기값
    storeLogin: (token: string) => {
        set({ isloggedIn: true })
        setToken(token)
    },
    storeLogout: () => {
        set({ isloggedIn: false })
        removeToken()
    }
}))

 

로그인 화면은 이렇게 완성이 되었다.

이메일, 비밀번호 중 데이터 없이 비어있다면 아래에 빨간색으로 '이메일(비밀번호)을 입력해주세요.' 라는 글자가 뜨게된다.

그리고 로그인이 정상적으로 완료가 된다면, navigate를 통해 홈 화면으로 돌아가고,

로그인이 실패했을 경우엔 '로그인이 실패했습니다.' 라는 알림창이 뜨게된다.

또한, 백엔드에서 만들어놨던 token 역시 localStorage에 저장하는 로직도 짰다.

로그아웃을 하게되면 token 역시 remove 되게 만들었다.

💥 도서 목록 페이지

* 도서 목록 화면 요구 사항
  1) 도서(book)의 목록을 fetch 하여 화면에 렌더
  2) pagination을 구현
  3) 검색 결과가 없을 때, 결과 없음 화면 노출
  4) 카테고리 및 신간 필터 기능을 제공
  5) 목록의 view는 그리드 형태, 목록 형태로 변경 가능

 

1. Books.tsx

import { 생략 }

function Books() {
    const { books, pagination, isEmpty } = useBooks()

    return (
        <>
            <Title size="large">도서 검색 결과</Title>
            <BooksStyle>
                <div className="filter">
                    <BooksFilter />
                    <BooksViewSwitcher />
                </div>
                {!isEmpty && <BooksList books={books} /> }
                {isEmpty && <BooksEmpty /> }
                {!isEmpty && <Pagination pagination={pagination}/> }
                
            </BooksStyle>
        </>
        
    )
}

const BooksStyle = styled.div`

    { 생략 } `

export default Books

 

2. BooksFilter.tsx

import { 생략 }

function BooksFilter() {
    const { category } = useCategory()
    const [searchParams, setSearchParams] = useSearchParams()

    const handleCategory = (category_id: number | null) => {
        const newSearchParams = new URLSearchParams(searchParams)

        if (category_id === null) {
            newSearchParams.delete(QUERYSTRING.CATEGORY_ID)
        } else {
            newSearchParams.set(QUERYSTRING.CATEGORY_ID, category_id.toString())
        }

        setSearchParams(newSearchParams)
    }

    const handleNews = () => {
        const newSearchParams = new URLSearchParams(searchParams)

        if (newSearchParams.get(QUERYSTRING.NEWS)) {
            newSearchParams.delete(QUERYSTRING.NEWS)
        } else {
            newSearchParams.set(QUERYSTRING.NEWS, "true")
        }

        setSearchParams(newSearchParams)
    }

    return (
        <BooksFilterStyle>
            <div className="category">
                {
                    category.map((item) => (
                        <Button
                            size="medium"
                            scheme={item.isActive ? "primary" : 'normal'}
                            key={item.category_id}
                            onClick={() => handleCategory(item.category_id)}
                        >
                            {item.category_name}
                        </Button>
                    ))
                }
            </div>
            <div className="new">
                <Button
                    size="medium"
                    scheme={searchParams.get('news') ? 'primary' : 'normal'}
                    onClick={() => handleNews()}
                >
                    신간
                </Button>
            </div>
        </BooksFilterStyle>
    )
}

const BooksFilterStyle = styled.div`

    { 생략 } `

export default BooksFilter

 

 

3. BooksViewSwitcher.tsx

import { 생략 }

const viewOptions = [
    {
        value: "list",
        icon: <FaList />,
    },
    {
        value: "grid",
        icon: <FaTh />,
    }
]

export type ViewMode = "grid" | "list"

function BooksViewSwitcher() {
    const [searchParams, setSearchParams] = useSearchParams()

    const handleSwitch = (value: ViewMode) => {
        const newSearchParams = new URLSearchParams(searchParams)

        newSearchParams.set(QUERYSTRING.VIEW, value)
        setSearchParams(newSearchParams)
    }

    useEffect(() => {
        if (!searchParams.get(QUERYSTRING.VIEW)) {
            handleSwitch("grid")
        }
    }, [])

    return (
        <BooksViewSwitcherStyle>
            {
                viewOptions.map((option) => (
                    <Button
                        size="medium"
                        scheme={searchParams.get(QUERYSTRING.VIEW) === option.value ? "primary" : "normal"}
                        key={option.value}
                        onClick={() => handleSwitch(option.value as ViewMode)}
                    >
                        {option.icon}
                    </Button>
                ))
            }
        </BooksViewSwitcherStyle>
    )
}

const BooksViewSwitcherStyle = styled.div`

   { 생략 } `

export default BooksViewSwitcher

 

4. BooksList.tsx

import { 생략 }

interface Props {
    books: Book[]
}

function BooksList({ books }: Props) {
    const [view, setView] = useState<ViewMode>('grid')

    const location = useLocation()

    useEffect(() => {
        const params = new URLSearchParams(location.search)
        if (params.get(QUERYSTRING.VIEW)) {
            setView(params.get(QUERYSTRING.VIEW) as ViewMode)
        }
    }, [location.search])

    return (
        <BooksListStyle view={view}>
            {books?.map((item) => (
                <BookItem key={item.id} book={item} view={view} />
            ))}
        </BooksListStyle>
    )
}

interface BooksListStyleProps {
    view: ViewMode
}

const BooksListStyle = styled.div<BooksListStyleProps>`

   { 생략 } `

export default BooksList

 

5. BookItem.tsx

import { 생략 }

interface Props {
    book: Book,
    view?: ViewMode
}

function BookItem({ book, view }: Props) {
    return (
        <BookItemStyle view={view}>
            <div className="img">
                <img src={getImgSrc(book.img)} alt={book.title} />
            </div>
            <div className="content">
                <h2 className="title">{book.title}</h2>
                <p className="summary">{book.summary}</p>
                <p className="author">{book.author}</p>
                <p className="price">{formatNumber(book.price)}원</p>
                <div className="likes">
                    <FaHeart />
                    <span>{book.likes}</span>
                </div>
            </div>
        </BookItemStyle>
    )
}

const BookItemStyle = styled.div<Pick<Props, "view">>`

    { 생략 } `
    
export default BookItem

 

6. BooksEmpty.tsx

import { 생략 }

function BooksEmpty() {
    return (
        <BooksEmptyStyle>
            <div className="icon">
                <FaSmileWink />
            </div>
            <Title size="large" color="secondary">
                검색 결과가 없습니다.
            </Title>
            <p>
                <Link to="/books">전체 검색 결과로 이동</Link>
            </p>
        </BooksEmptyStyle>
    )
}

const BooksEmptyStyle = styled.div`

    { 생략 } `

export default BooksEmpty

 

7. Pagination.tsx

import { 생략 }

interface Props {
    pagination: IPagination
}

function Pagination({ pagination }: Props) {
    const [searchParams, setSearchParams] = useSearchParams()

    const { totalCount, currentPage } = pagination

    const pages: number = Math.ceil(totalCount / LIMIT)

    const handleClickPage = (page: number) => {
        const newSearchParams = new URLSearchParams(searchParams)

        newSearchParams.set(QUERYSTRING.PAGE, page.toString())

        setSearchParams(newSearchParams)
    }

    return (
        <PaginationStyle>
            {
                pages > 0 && (
                    <ol>
                        {
                            Array(pages).fill(0).map((_, index) => (
                                <li>
                                    <Button
                                        key={index}
                                        size="small"
                                        scheme={index + 1 === currentPage ? "primary" : "normal"}
                                        onClick={() => handleClickPage(index + 1)}
                                    >
                                        {index + 1}
                                    </Button>
                                </li>
                            ))
                        }
                    </ol>
                )
            }
        </PaginationStyle>
    )
}

const PaginationStyle = styled.div`

    { 생략 } `

export default Pagination

 

8. useBooks.ts

import { 생략 }

export const useBooks = () => {
    const location = useLocation()

    const [books, setBooks] = useState<Book[]>([])

    const [pagination, setPagination] = useState<Pagination>
    ({
        totalCount: 0,
        currentPage: 1,
    })
    const [isEmpty, setIsEmpty] = useState(true)

    useEffect(() => {
        const params = new URLSearchParams(location.search)

        fetchBooks({
            category_id: params.get(QUERYSTRING.CATEGORY_ID) ? Number(params.get(QUERYSTRING.CATEGORY_ID)) : undefined,
            news: params.get(QUERYSTRING.NEWS) ? true : undefined,
            currentPage: params.get(QUERYSTRING.PAGE) ? Number(params.get(QUERYSTRING.PAGE)) : 1,
            limit: LIMIT,
        }).then(({ books, pagination }) => {
            setBooks(books)
            setPagination(pagination)
            setIsEmpty(books.length === 0)
        })
        
    }, [location.search])
    
    return { books, pagination, isEmpty }
}

 

💥 도서 목록 결과물

 

 - 리스트 형식

 

 - 그리드 형식

 

 - 페이지네이션 + 카테고리 클릭 시 Active한 디자인 + 클릭하는 category, news에 맞게 변화하는 주소창

 

엄청난 대장정을 통해 BookList를 완성시켰다..

사실 백엔드에서 덜 고쳤던 코드들이 꽤나 있었어서, 그 부분 때문에 굉장히 많은 애를 먹었다...

res.data는 되고있는데, 백엔드에서 전해주는 data의 형식과 프론트에서 원하는 data의 형식이 달라서 전달이 안 되는 문제..

신간은 news로 만들기로 했는데, 혼자 냅다 newBook 으로 만들어서 신간을 누를 때마다, 데이터가 원하는대로 전달되지 않는 문제 때문에 계속 서버가 다운됐고, 화면엔 빨간 글씨가 그득그득했다.....ㅋㅋㅋㅋ

처음엔 백엔드에 문제가 없겠거니 하고 말았는데, 1번째 오류부터 해서 2번째 오류까지 멘토님께서 직접 봐주셨는데,

백엔드에서 전달하는 데이터 형식과 프론트에서 원하는 데이터 형식이 달라서 생기는 문제라고 말씀해주셨다.

그제서야, 백엔드에서 덜 고쳤던 코드들이 생각났다... '아... 나 그 때 덜 했었는데 그거 때문인가?' 

정답이다^^.. 그거 때문이었다..ㅎ 그 때, 많이 바빴어서 강의를 하루 못 들었는데 그 날에 거의 로직들이 많이 바뀌었었던 것이었다...

물론 페이지네이션도 그 날에 했었다.. 그 부분도 안 해놓고 이제와서 아 왜 오류냐고 내가 뭘 잘못했는데~!~~~!~!~

이러면서 승질 내고 있었던 거다...ㅋㅋㅋ 앞으로 강의 절대 안 빠지고 잘 들어야겠다 ㅠㅠ..

다행히 멘토님께서 말씀해주신 덕분에 백엔드 때 덜 들었던 강의도 마저 들었고, 고쳐야할 로직들도 야무지게 고쳐놓았다.

그랬더니 드디어 오류가 잘 안 뜨기 시작하고, 강의대로 술술 잘 풀리면서 마무리를 하게되었다...

사실 지금 BookList 하는 것도 많이 밀린 건데, 오류 나면서부터 점점 더 밀리게 되었다 ㅠㅠ..

서버쪽 코드들도 잘 고쳐놨으니 이제 오류가 덜 뜨길 희망하면서 강의 진도를 얼른 나가야 할 것 같다..

만들고나니까 너무 뿌듯하다.. 서버도 클라이언트도 내가 만들었다는 것에 대해 큰 희열을 느끼는 중..🤔

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
글 보관함