diff --git a/src/@types/AuctionDetails.d.ts b/src/@types/AuctionDetails.d.ts index affd7b62..1e474fba 100644 --- a/src/@types/AuctionDetails.d.ts +++ b/src/@types/AuctionDetails.d.ts @@ -7,6 +7,11 @@ declare module 'AuctionDetails' { minPrice: number; isSeller: boolean; category: string; + sellerProfileImageUrl: string; + images: { + imageId: number; + imageUrl: string; + }[]; } export interface IAuctionDetails extends IAuctionDetailsBase { @@ -17,16 +22,12 @@ declare module 'AuctionDetails' { bidId: number | null; bidAmount: number; remainingBidCount: number; - imageUrls: string[]; + isCancelled: boolean; } export interface IPreAuctionDetails extends IAuctionDetailsBase { updatedAt: string; likeCount: number; isLiked: boolean; - images: { - imageId: number; - imageUrl: string; - }[]; } } diff --git a/src/assets/icons/in_box_edit_time.svg b/src/assets/icons/in_box_edit_time.svg new file mode 100644 index 00000000..5d65e331 --- /dev/null +++ b/src/assets/icons/in_box_edit_time.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/in_box_like.svg b/src/assets/icons/in_box_like.svg new file mode 100644 index 00000000..271ed786 --- /dev/null +++ b/src/assets/icons/in_box_like.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/like_heart.svg b/src/assets/icons/like_heart.svg new file mode 100644 index 00000000..f710fad7 --- /dev/null +++ b/src/assets/icons/like_heart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/modal_cancel.svg b/src/assets/icons/modal_cancel.svg new file mode 100644 index 00000000..39218231 --- /dev/null +++ b/src/assets/icons/modal_cancel.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/modal_edit.svg b/src/assets/icons/modal_edit.svg new file mode 100644 index 00000000..e91d57d2 --- /dev/null +++ b/src/assets/icons/modal_edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/modal_share.svg b/src/assets/icons/modal_share.svg new file mode 100644 index 00000000..15ac1d38 --- /dev/null +++ b/src/assets/icons/modal_share.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/my_participation_amount.svg b/src/assets/icons/my_participation_amount.svg new file mode 100644 index 00000000..cc4b37b0 --- /dev/null +++ b/src/assets/icons/my_participation_amount.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/bid/Bid.test.tsx b/src/components/bid/Bid.test.tsx index d9218199..2d2ad807 100644 --- a/src/components/bid/Bid.test.tsx +++ b/src/components/bid/Bid.test.tsx @@ -1,11 +1,11 @@ -import { RouterProvider, createMemoryRouter } from 'react-router-dom'; import { act, render, screen, waitFor } from '@testing-library/react'; +import { RouterProvider, createMemoryRouter } from 'react-router-dom'; import { describe, expect, test, vi } from 'vitest'; import Bid from '@/pages/Bid'; import { mockedUseNavigate } from '@/setupTests'; -import { useGetAuctionDetails } from '../details/queries'; import userEvent from '@testing-library/user-event'; +import { useGetAuctionDetails } from '../details/queries'; vi.mock('@/components/details/queries'); vi.mocked(useGetAuctionDetails).mockReturnValue({ @@ -14,7 +14,8 @@ vi.mocked(useGetAuctionDetails).mockReturnValue({ bidId: null, description: "서로 다른 출신과 개성을 가진 이들이 모여 밴드 결성까지의 과정을 보여준 ‘Harmony from Discord’부터 멤버들 간의 만남을 동경과 벅차오르는 감성으로 담아낸 ‘MANITO’까지. 성장 서사를 써내려가는 밴드 QWER이 두 번째 EP인 ‘Algorithm’s Blossom’을 선보인다. 이번 앨범에서는 QWER이라는 하나의 팀으로서 새롭게 운명을 개척해나가는 이야기를 ‘알고리즘이 피워낸 꽃’이라는 키워드를 통해 풀어내고자 한다.\n\n\"사랑과 상처, 그 모든 것을 끌어안고 피어나”\n\n무수히 파편적이고 혼란하지만 보여지는 것은 단편적인 곳, 다양한 혼잡함이 가지런히 질서를 이루는 곳. 그런 '알고리즘' 속에서 우리의 이야기를 피워낸다. ‘Algorithm’s Blossom'에서 QWER은 보편적이지 않은 공간에 심겨진 씨앗으로, 동시에 사랑과 상처를 양분삼아 돋아난 싹으로, 세상에 보인 적 없던 새로운 꽃의 모습으로 자신들의 성장과 여정을 그린다.", - imageUrls: ['/jgbI75.jpg'], + images: [{ imageId: 1, imageUrl: '/jgbI75.jpg' }], + isCancelled: false, isParticipated: false, isSeller: false, minPrice: 23000, @@ -26,7 +27,23 @@ vi.mocked(useGetAuctionDetails).mockReturnValue({ status: 'PROCEEDING', timeRemaining: 25816, category: 'ELECTRONICS', + sellerProfileImageUrl: '' }, + refetch: vi.fn().mockResolvedValue({ // refetch 추가 + data: { + bidAmount: 1000, + bidId: null, + description: 'Test auction', + imageUrls: ['test-image.jpg'], + isParticipated: false, + isSeller: false, + minPrice: 500, + productName: 'Test product', + participantCount: 5, + sellerProfileImageUrl: 'seller-image.jpg', + }, + error: null, + }), }); /** diff --git a/src/components/bid/BidFooter.tsx b/src/components/bid/BidFooter.tsx index de962a82..7ce08d19 100644 --- a/src/components/bid/BidFooter.tsx +++ b/src/components/bid/BidFooter.tsx @@ -1,46 +1,26 @@ -import Button from '../common/Button'; import { MAX_BID_COUNT } from '@/constants/bid'; +import Button from '../common/Button'; interface BidFooterProps { remain: number; check: boolean; isSubmitting: boolean; - handlePost: (e?: React.BaseSyntheticEvent | undefined) => Promise; - handlePatch: () => void; + handlePost: (e: React.BaseSyntheticEvent | undefined) => Promise; } -const BidFooter = ({ remain, check, isSubmitting, handlePost, handlePatch }: BidFooterProps) => { - if (remain === MAX_BID_COUNT) { - return ( - - ); - } - +const BidFooter = ({ remain, check, isSubmitting, handlePost }: BidFooterProps) => { + const flag = remain === MAX_BID_COUNT return ( - <> - - - + ); }; diff --git a/src/components/bid/BidMain.tsx b/src/components/bid/BidMain.tsx index 7c7ba4aa..57710011 100644 --- a/src/components/bid/BidMain.tsx +++ b/src/components/bid/BidMain.tsx @@ -13,16 +13,15 @@ import Layout from "../layout/Layout"; import { Input } from "../ui/input"; import BidCaution from "./BidCaution"; import BidFooter from "./BidFooter"; -import { usePatchBid, usePostBid } from "./queries"; +import { usePostBid } from "./queries"; const BidMain = ({ auctionId }: { auctionId: number }) => { const { auctionDetails } = useGetAuctionDetails(auctionId); const { mutate: postBid } = usePostBid(auctionId); - const { mutate: patchBid } = usePatchBid(auctionId); const [check, setCheck] = useState(false); const toggleCheckBox = () => setCheck((state) => !state); - const { imageUrls, productName, minPrice, participantCount, remainingBidCount, bidAmount, timeRemaining, isParticipated, bidId } = auctionDetails; + const { images, productName, minPrice, participantCount, remainingBidCount, bidAmount, timeRemaining, isParticipated } = auctionDetails; const BidSchema = getBidSchema(minPrice); type FormFields = z.infer; @@ -51,16 +50,12 @@ const BidMain = ({ auctionId }: { auctionId: number }) => { postBid(bidData); }; - const onPatchSubmit = () => { - if (bidId) patchBid(bidId); - }; - return ( <>
- + {isParticipated && ( @@ -92,7 +87,7 @@ const BidMain = ({ auctionId }: { auctionId: number }) => {
- + ); diff --git a/src/components/bid/EditBid.test.tsx b/src/components/bid/EditBid.test.tsx index e095280a..24374043 100644 --- a/src/components/bid/EditBid.test.tsx +++ b/src/components/bid/EditBid.test.tsx @@ -1,11 +1,11 @@ -import { RouterProvider, createMemoryRouter } from 'react-router-dom'; import { act, render, screen, waitFor } from '@testing-library/react'; +import { RouterProvider, createMemoryRouter } from 'react-router-dom'; import { describe, expect, test, vi } from 'vitest'; import Bid from '@/pages/Bid'; import { mockedUseNavigate } from '@/setupTests'; -import { useGetAuctionDetails } from '../details/queries'; import userEvent from '@testing-library/user-event'; +import { useGetAuctionDetails } from '../details/queries'; vi.mock('@/components/details/queries'); vi.mocked(useGetAuctionDetails).mockReturnValue({ @@ -14,7 +14,8 @@ vi.mocked(useGetAuctionDetails).mockReturnValue({ bidId: null, description: "서로 다른 출신과 개성을 가진 이들이 모여 밴드 결성까지의 과정을 보여준 ‘Harmony from Discord’부터 멤버들 간의 만남을 동경과 벅차오르는 감성으로 담아낸 ‘MANITO’까지. 성장 서사를 써내려가는 밴드 QWER이 두 번째 EP인 ‘Algorithm’s Blossom’을 선보인다. 이번 앨범에서는 QWER이라는 하나의 팀으로서 새롭게 운명을 개척해나가는 이야기를 ‘알고리즘이 피워낸 꽃’이라는 키워드를 통해 풀어내고자 한다.\n\n\"사랑과 상처, 그 모든 것을 끌어안고 피어나”\n\n무수히 파편적이고 혼란하지만 보여지는 것은 단편적인 곳, 다양한 혼잡함이 가지런히 질서를 이루는 곳. 그런 '알고리즘' 속에서 우리의 이야기를 피워낸다. ‘Algorithm’s Blossom'에서 QWER은 보편적이지 않은 공간에 심겨진 씨앗으로, 동시에 사랑과 상처를 양분삼아 돋아난 싹으로, 세상에 보인 적 없던 새로운 꽃의 모습으로 자신들의 성장과 여정을 그린다.", - imageUrls: ['/jgbI75.jpg'], + images: [{ imageId: 1, imageUrl: '/jgbI75.jpg' }], + isCancelled: false, isParticipated: false, isSeller: false, minPrice: 23000, @@ -26,7 +27,23 @@ vi.mocked(useGetAuctionDetails).mockReturnValue({ status: 'PROCEEDING', timeRemaining: 25816, category: 'ELECTRONICS', + sellerProfileImageUrl: '' }, + refetch: vi.fn().mockResolvedValue({ // refetch 추가 + data: { + bidAmount: 1000, + bidId: null, + description: 'Test auction', + imageUrls: ['test-image.jpg'], + isParticipated: false, + isSeller: false, + minPrice: 500, + productName: 'Test product', + participantCount: 5, + sellerProfileImageUrl: 'seller-image.jpg', + }, + error: null, + }), }); const router = createMemoryRouter( [ diff --git a/src/components/bid/queries.ts b/src/components/bid/queries.ts index cfbd0aaf..c7a18419 100644 --- a/src/components/bid/queries.ts +++ b/src/components/bid/queries.ts @@ -1,10 +1,10 @@ import { UseMutateFunction, useMutation } from '@tanstack/react-query'; -import { API_END_POINT } from '@/constants/api'; import { httpClient } from '@/api/axios'; -import { toast } from 'sonner'; -import { useNavigate } from 'react-router-dom'; +import { API_END_POINT } from '@/constants/api'; import { IBidPostData } from 'Bid'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; export const usePostBid = ( auctionId: number @@ -26,24 +26,3 @@ export const usePostBid = ( return { mutate }; }; - -export const usePatchBid = ( - auctionId: number -): { - mutate: UseMutateFunction; -} => { - const patchBid = async (bidId: number) => { - await httpClient.patch(`${API_END_POINT.BID}/${bidId}/cancel`); - }; - - const navigate = useNavigate(); - const { mutate } = useMutation({ - mutationFn: patchBid, - onSuccess: () => { - toast.success('입찰 수정!'); - navigate(`/auctions/auction/${auctionId}`); - }, - }); - - return { mutate }; -}; diff --git a/src/components/bidderList/BidderListMain.tsx b/src/components/bidderList/BidderListMain.tsx index 16adc288..34f7307e 100644 --- a/src/components/bidderList/BidderListMain.tsx +++ b/src/components/bidderList/BidderListMain.tsx @@ -2,6 +2,7 @@ import { BIDDER_LIST_PRICE_FILTER } from "@/constants/filter"; import { formatCurrencyWithWon } from "@/utils/formatCurrencyWithWon"; import type { IBidder } from "Bid"; import { useState } from "react"; +import { useNavigate } from "react-router-dom"; import Button from "../common/Button"; import AuctionItem from "../common/item/AuctionItem"; import { useGetAuctionDetails } from "../details/queries"; @@ -10,6 +11,7 @@ import { useGetBidderList } from "./queries"; const BidderListMain = ({ auctionId }: { auctionId: number }) => { const [filterState, setFilterState] = useState(BIDDER_LIST_PRICE_FILTER.HIGH); + const navigate = useNavigate() const handleFilterState = () => setFilterState((prev) => (prev.name === BIDDER_LIST_PRICE_FILTER.HIGH.name ? BIDDER_LIST_PRICE_FILTER.LOW : BIDDER_LIST_PRICE_FILTER.HIGH)); @@ -19,14 +21,14 @@ const BidderListMain = ({ auctionId }: { auctionId: number }) => { const { bidderList } = useGetBidderList(auctionId); const filteredBidderList = filterState.sort === 'desc' ? bidderList : bidderList.sort((a, b) => a.bidAmount - b.bidAmount) - const { imageUrls, productName, minPrice, participantCount } = auctionDetails; + const { images, productName, minPrice, participantCount } = auctionDetails; return ( <>
- +
@@ -51,7 +53,7 @@ const BidderListMain = ({ auctionId }: { auctionId: number }) => {
- diff --git a/src/components/common/Button.tsx b/src/components/common/Button.tsx index b09f4a11..e0fc9c72 100644 --- a/src/components/common/Button.tsx +++ b/src/components/common/Button.tsx @@ -25,7 +25,7 @@ const Button = ({ ariaLabel = '', loading = false, }: ButtonProps) => { - const baseClasses = 'focus:outline-none rounded transition-colors active:bg-black active:text-white box-border'; + const baseClasses = 'focus:outline-none rounded-lg transition-colors active:bg-black active:text-white box-border'; const colorClasses = classNames({ 'bg-black text-white border border-black': color === 'black', 'bg-white text-black border border-black': color === 'white', // 동일한 border 유지 diff --git a/src/components/common/ConfirmModal.tsx b/src/components/common/ConfirmModal.tsx new file mode 100644 index 00000000..1226f9e1 --- /dev/null +++ b/src/components/common/ConfirmModal.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from "react"; +import Button from "./Button"; + +interface ConfirmModalProps { + close: () => void + title: string; + description: string + children: ReactNode +} + +const ConfirmModal = ({ close, title, description, children }: ConfirmModalProps) => { + return ( +
+
+
e.stopPropagation()} className='flex flex-col w-2/5 gap-3 p-8 bg-white rounded-lg sm:text-body1 text-body2'> +

{title}

+

{description}

+
+ + {children} +
+
+
+
+ ); +} + +export default ConfirmModal; diff --git a/src/components/common/CustomCarousel.tsx b/src/components/common/CustomCarousel.tsx index d0e30338..d94e82fb 100644 --- a/src/components/common/CustomCarousel.tsx +++ b/src/components/common/CustomCarousel.tsx @@ -2,13 +2,16 @@ import { Carousel, CarouselContent, CarouselNext, CarouselPrevious } from '../ui import { ReactNode } from 'react'; -const CustomCarousel = ({ contentStyle, length, children }: { contentStyle?: string; length: number; children: ReactNode }) => { +interface CustomCarouselProps { contentStyle?: string; length: number; children: ReactNode; loop?: boolean } + +const CustomCarousel = ({ contentStyle, length, children, loop = false }: CustomCarouselProps) => { return ( diff --git a/src/components/common/atomic/LikeCount.tsx b/src/components/common/atomic/LikeCount.tsx index 6e6a049f..2bb0e601 100644 --- a/src/components/common/atomic/LikeCount.tsx +++ b/src/components/common/atomic/LikeCount.tsx @@ -7,9 +7,9 @@ const LikeCount = ({ count }: { count: number }) => { className="flex items-center text-xs sm:text-body2 text-gray2" > 좋아요 - - {`좋아요 `} - {count}명 + + 좋아요 + {count} 명
); diff --git a/src/components/common/atomic/MinPrice.tsx b/src/components/common/atomic/MinPrice.tsx index 905ec58a..d3a570bb 100644 --- a/src/components/common/atomic/MinPrice.tsx +++ b/src/components/common/atomic/MinPrice.tsx @@ -1,16 +1,17 @@ -import { formatCurrencyWithWon } from '@/utils/formatCurrencyWithWon'; import PriceIcon from '@/assets/icons/price.svg'; +import { formatCurrencyWithWon } from '@/utils/formatCurrencyWithWon'; const MinPrice = ({ price }: { price: number }) => { const formatted = formatCurrencyWithWon(price); + return (
시작가 - - 시작가 {formatted} + + 시작가 {formatted}
); diff --git a/src/components/common/atomic/ParticipantCount.tsx b/src/components/common/atomic/ParticipantCount.tsx index 1f65c46f..442cc553 100644 --- a/src/components/common/atomic/ParticipantCount.tsx +++ b/src/components/common/atomic/ParticipantCount.tsx @@ -7,8 +7,8 @@ const ParticipantCount = ({ count }: { count: number }) => { className="flex items-center text-xs sm:text-body2 text-gray2" > 참여자 - - 참여자 {count}명 + + 참여자 {count} 명 ); diff --git a/src/components/common/atomic/TimeLabel.tsx b/src/components/common/atomic/TimeLabel.tsx index 1be8e76c..338fec88 100644 --- a/src/components/common/atomic/TimeLabel.tsx +++ b/src/components/common/atomic/TimeLabel.tsx @@ -1,14 +1,27 @@ import { getTimeColor } from '@/utils/getTimeColor'; const TimeLabel = ({ time }: { time: number }) => { - const formattedTime = Math.ceil(time / 3600); - const color = getTimeColor(formattedTime); + let formattedTime = time / 3600; + const color = getTimeColor(Math.ceil(time / 3600)); + + let remainingTime; + if (formattedTime >= 1) remainingTime = `${Math.ceil(formattedTime)}시간 남음` + else { + formattedTime *= 60; + if (formattedTime >= 1) remainingTime = `${Math.ceil(formattedTime)}분 남음` + else { + formattedTime *= 60; + if (formattedTime >= 1) remainingTime = '1분 미만' + else remainingTime = '경매 종료' + } + } + return (
- {formattedTime === 0 ?

경매 종료

: `${formattedTime}시간 남음`} +

{remainingTime}

); }; diff --git a/src/components/common/item/AuctionItem.tsx b/src/components/common/item/AuctionItem.tsx index b9354510..f4dc2800 100644 --- a/src/components/common/item/AuctionItem.tsx +++ b/src/components/common/item/AuctionItem.tsx @@ -23,7 +23,7 @@ const AuctionItem = ({ label, axis, children }: AuctionItemProps) => { const Image = ({ src, time = undefined }: { src: string; time?: number }) => { return (
- 이미지 + 이미지 {time && }
); diff --git a/src/components/common/route/PrivateRoute.tsx b/src/components/common/route/PrivateRoute.tsx index 08051abe..9622a1d9 100644 --- a/src/components/common/route/PrivateRoute.tsx +++ b/src/components/common/route/PrivateRoute.tsx @@ -2,13 +2,11 @@ import { isLoggedIn } from '@/store/authSlice'; import { ReactNode } from 'react'; import { useSelector } from 'react-redux'; import { Navigate } from 'react-router-dom'; -import { toast } from 'sonner'; const PrivateRoute = ({ children }: { children: ReactNode }) => { const isLogin = useSelector(isLoggedIn); if (!isLogin) { - toast.warning('로그인이 필요한 서비스입니다.'); return ; } diff --git a/src/components/common/route/PublicRoute.tsx b/src/components/common/route/PublicRoute.tsx index b919c85a..5939a3e6 100644 --- a/src/components/common/route/PublicRoute.tsx +++ b/src/components/common/route/PublicRoute.tsx @@ -1,15 +1,12 @@ import { isLoggedIn } from '@/store/authSlice'; import { ReactNode } from 'react'; import { useSelector } from 'react-redux'; -import { Navigate, useLocation } from 'react-router-dom'; -import { toast } from 'sonner'; +import { Navigate } from 'react-router-dom'; const PublicRoute = ({ children }: { children: ReactNode }) => { const isLogin = useSelector(isLoggedIn); - const { search } = useLocation(); if (isLogin) { - if (search === '') toast.warning('이미 로그인된 사용자입니다.'); return ; } diff --git a/src/components/details/AuctionDetailsFooter.tsx b/src/components/details/AuctionDetailsFooter.tsx new file mode 100644 index 00000000..04dcc3cd --- /dev/null +++ b/src/components/details/AuctionDetailsFooter.tsx @@ -0,0 +1,104 @@ + +import Button from "@/components/common/Button"; +import { useCancelBid } from "@/components/details/queries"; +import { MAX_BID_COUNT } from "@/constants/bid"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import ConfirmModal from "../common/ConfirmModal"; +import Layout from "../layout/Layout"; + +interface AuctionDetailsFooterProps { + bidId: number | null; + auctionId: number + isCancelled: boolean + status: string + remainingBidCount: number + isSeller: boolean +} + +const AuctionDetailsFooter = ({ isSeller, bidId, auctionId, isCancelled, status, remainingBidCount }: AuctionDetailsFooterProps) => { + const navigate = useNavigate(); + const [confirm, setConfirm] = useState(false) + const toggleConfirm = () => setConfirm((prev) => !prev) + const { mutate: cancelBid } = useCancelBid() + const remainFlag = remainingBidCount === MAX_BID_COUNT + const clickBid = () => navigate(`/auctions/bid/${auctionId}`) + const clickCancel = () => cancelBid(bidId || 0) + + if (status !== 'PROCEEDING') { + return ( + + + + ); + } + + if (isCancelled) { + return ( + + + + ); + } + + if (isSeller) { + return ( + + + + ); + } + + return ( + <> + + {remainFlag + ? + + : + <> + + + + } + + { + confirm && + + + + } + + ); +} + +export default AuctionDetailsFooter; diff --git a/src/components/details/BuyersFooter.tsx b/src/components/details/BuyersFooter.tsx deleted file mode 100644 index db157c87..00000000 --- a/src/components/details/BuyersFooter.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import Button from "@/components/common/Button"; -import { AiOutlineHeart, AiFillHeart } from "react-icons/ai"; -import { useLikeAuctionItem, useCancelBid } from "@/components/details/queries"; - -interface BuyersFooterProps { - auctionId: number; - bidId?: number; - isSeller: boolean; - status: string; - likeCount?: number; - isParticipated: boolean; - remainingBidCount?: number; -} - -const BuyersFooter = ({ - auctionId, - bidId = 0, - isSeller, - status, - likeCount = 0, - isParticipated, - remainingBidCount, -}: BuyersFooterProps) => { - const navigate = useNavigate(); - const { mutate: likeAuctionItem } = useLikeAuctionItem(); - const { mutate: cancelBid } = useCancelBid(); // Call the hook here - - const [currentLikeCount, setCurrentLikeCount] = useState(likeCount); - const [isLiked, setIsLiked] = useState(isParticipated); - - const onMoveToBidHandler = () => { - navigate(`/auctions/bid/${auctionId}`); - }; - - const onToggleNotificationHandler = () => { - likeAuctionItem(auctionId); - if (!isLiked) { - setCurrentLikeCount((prev) => prev + 1); - setIsLiked(true); - } else { - setCurrentLikeCount((prev) => (prev > 0 ? prev - 1 : 0)); - setIsLiked(false); - } - }; - - const onCancelBidHandler = () => { - cancelBid(bidId); - navigate("/"); - }; - - const HeartIcon = isLiked ? AiFillHeart : AiOutlineHeart; - const heartColor = isLiked ? "text-red-500" : "text-gray-500"; - - if (isSeller) return null; - - if (status === "PENDING") { - return ( -
- - {`${currentLikeCount}명`} - -
- ); - } - - if (status === "PROCEEDING" && !isParticipated) { - return ( -
- -
- ); - } - - if ( - status === "PROCEEDING" && - isParticipated && - remainingBidCount && - remainingBidCount > 0 - ) { - return ( -
- - -
- ); - } - - if (status === "PROCEEDING" && isParticipated && remainingBidCount === 0) { - return ( -
- - -
- ); - } - - if (status === "ENDED") { - return ( -
종료된 경매
- ); - } - - return null; -}; - -export default BuyersFooter; diff --git a/src/components/details/ConfirmationModal.tsx b/src/components/details/ConfirmationModal.tsx deleted file mode 100644 index 6ae63d4b..00000000 --- a/src/components/details/ConfirmationModal.tsx +++ /dev/null @@ -1,35 +0,0 @@ -interface ConfirmationModalProps { - message: string; - onConfirm: () => void; - onCancel: () => void; -} - -const ConfirmationModal = ({ message, onConfirm, onCancel }: ConfirmationModalProps) => { - return ( -
-
-
-

{message}

-
- - -
-
-
- ); -}; - -export default ConfirmationModal; diff --git a/src/components/details/DetailsBasic.tsx b/src/components/details/DetailsBasic.tsx new file mode 100644 index 00000000..28459230 --- /dev/null +++ b/src/components/details/DetailsBasic.tsx @@ -0,0 +1,28 @@ +import ProfileDefaultImage from '@/assets/icons/profile.svg'; +import { CATEGORIES } from '@/constants/categories'; +import MinPrice from '../common/atomic/MinPrice'; + +interface DetailsBasicProps { + profileImg: string, nickname: string, productName: string, category: string, minPrice: number +} + +const DetailsBasic = ({ profileImg, nickname, productName, category, minPrice }: DetailsBasicProps) => { + return ( +
+
+ 판매자 프로필 +

+ {nickname} +

+
+

+ {productName} +

+ {CATEGORIES[category].value} + +
+ + ); +} + +export default DetailsBasic; diff --git a/src/components/details/ImageItem.tsx b/src/components/details/ImageItem.tsx deleted file mode 100644 index 56f701c8..00000000 --- a/src/components/details/ImageItem.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { CarouselItem } from '../ui/carousel'; -interface ImageItemProps { - url: string; - productName: string; -} - -const ImageItem = ({ url, productName }: ImageItemProps) => { - return ( - - {productName} - - ); -}; -export default ImageItem; diff --git a/src/components/details/ImageList.tsx b/src/components/details/ImageList.tsx deleted file mode 100644 index 8936849d..00000000 --- a/src/components/details/ImageList.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import useImageUrls from '@/hooks/useImageUrls'; -import CustomCarousel from '../common/CustomCarousel'; -import ImageItem from './ImageItem'; - -interface ImageListProps { - images: string[] | { imageId?: number; imageUrl: string }[]; - productName: string; - productId: number; -} - -const ImageList = ({ images, productName, productId }: ImageListProps) => { - const imageUrls = useImageUrls(images); - const { length } = imageUrls; - - return ( - - {imageUrls.map((img) => ( - - ))} - - ); -}; - -export default ImageList; diff --git a/src/components/details/PreAuctionDetailsFooter.tsx b/src/components/details/PreAuctionDetailsFooter.tsx new file mode 100644 index 00000000..bbecc6a1 --- /dev/null +++ b/src/components/details/PreAuctionDetailsFooter.tsx @@ -0,0 +1,42 @@ +import HeartOffIcon from '@/assets/icons/heart_off.svg'; +import HeartOnIcon from '@/assets/icons/like_heart.svg'; +import Button from '../common/Button'; +import Layout from '../layout/Layout'; +import { useConvertAuction, useLikeAuctionItem } from './queries'; + +interface PreAuctionDetailsFooterProps { + likeCount: number + preAuctionId: number + isSeller: boolean +} + +const PreAuctionDetailsFooter = ({ likeCount, preAuctionId, isSeller }: PreAuctionDetailsFooterProps) => { + const { mutate: likeAuctionItem } = useLikeAuctionItem(); + const { mutate: convertToAuction, isPending } = useConvertAuction(); + const HeartIcon = likeCount ? HeartOnIcon : HeartOffIcon; + + return ( + +
+ 하트 아이콘 + {`${likeCount} 명`} +
+ {isSeller + ? + + : + } +
+ ); +} + +export default PreAuctionDetailsFooter; diff --git a/src/components/details/ProgressBar.tsx b/src/components/details/ProgressBar.tsx index 1e17c783..69bb0f9d 100644 --- a/src/components/details/ProgressBar.tsx +++ b/src/components/details/ProgressBar.tsx @@ -1,27 +1,23 @@ /* eslint-disable prettier/prettier */ -import { useState, useEffect } from 'react'; +import { QueryObserverResult, RefetchOptions } from '@tanstack/react-query'; +import type { IAuctionDetails } from 'AuctionDetails'; +import { useEffect, useState } from 'react'; -interface ProgressBarProps { - initialTimeRemaining: number | ''; - totalTime: number; -} +const totalTime = 24 * 60 ** 2 -const ProgressBar: React.FC = ({ +const ProgressBar = ({ initialTimeRemaining, - totalTime, -}) => { - const [timeRemaining, setTimeRemaining] = useState( - typeof initialTimeRemaining === 'number' ? initialTimeRemaining : 0 - ); + refetch +}: { initialTimeRemaining: number; refetch: (options?: RefetchOptions | undefined) => Promise> }) => { + const [timeRemaining, setTimeRemaining] = useState(initialTimeRemaining); useEffect(() => { - setTimeRemaining( - typeof initialTimeRemaining === 'number' ? initialTimeRemaining : 0 - ); + setTimeRemaining(initialTimeRemaining); const interval = setInterval(() => { setTimeRemaining((prevTime) => { if (prevTime <= 1) { + refetch() clearInterval(interval); return 0; } @@ -32,7 +28,7 @@ const ProgressBar: React.FC = ({ return () => clearInterval(interval); }, [initialTimeRemaining]); - const progressBarWidth = (timeRemaining / totalTime) * 100; + const progressBarWidth = (timeRemaining / totalTime) * 100 const hours = Math.floor(timeRemaining / 3600); const minutes = Math.floor((timeRemaining % 3600) / 60); @@ -41,21 +37,20 @@ const ProgressBar: React.FC = ({ .toString() .padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - const isHalfTime = progressBarWidth <= 50; - const progressBarColor = isHalfTime ? 'bg-red-500' : 'bg-green-500'; + const progressBarColor = hours < 1 ? 'bg-timeColor1' : (hours <= 16 ? 'bg-timeColor2' : 'bg-timeColor3') + const textColor = hours < 1 ? 'text-timeColor1' : (hours <= 16 ? 'text-timeColor2' : 'text-timeColor3') + return ( -
+
- {formattedTime} + {timeRemaining !== 0 ? formattedTime : '경매 종료'}
-
+
diff --git a/src/components/details/SellersFooter.tsx b/src/components/details/SellersFooter.tsx index 026cf7b8..e389854e 100644 --- a/src/components/details/SellersFooter.tsx +++ b/src/components/details/SellersFooter.tsx @@ -1,52 +1,56 @@ /* eslint-disable prettier/prettier */ -import React from 'react'; +import HeartOffIcon from '@/assets/icons/heart_off.svg'; +import HeartOnIcon from '@/assets/icons/like_heart.svg'; import Button from '@/components/common/Button'; -import { AiOutlineHeart } from 'react-icons/ai'; -import { useConvertToAuction } from './queries'; -import { useNavigate } from 'react-router-dom'; +import React from 'react'; +import Layout from '../layout/Layout'; +import { useConvertAuction } from './queries'; interface SellersFooterProps { - isSeller: boolean; likeCount?: number; status?: string; auctionId: number; } const SellersFooter: React.FC = ({ - isSeller, status, likeCount = 0, auctionId, }) => { - const { mutate: convertToAuction } = useConvertToAuction(); - const navigate = useNavigate(); - const onConvertClickHandler = () => { - convertToAuction(auctionId); - navigate('/'); - }; + const { mutate: convertToAuction, isPending } = useConvertAuction(); + const onConvertClickHandler = () => convertToAuction(auctionId) - if (isSeller && status === 'PROCEEDING') { + const HeartIcon = likeCount ? HeartOnIcon : HeartOffIcon; + const heartColor = likeCount ? "text-redNotice" : "text-gray2"; + + if (status === 'PROCEEDING') { return ( -
- 내가 올린 경매 -
+ + + ); } - if (isSeller && status === 'PENDING') { + if (status === 'PENDING') { return ( -
- - {`${likeCount}명`} + +
+ 하트 아이콘 + {`${likeCount} 명`} +
-
+ ); } diff --git a/src/components/details/SuccessModal.tsx b/src/components/details/SuccessModal.tsx deleted file mode 100644 index a8670a6b..00000000 --- a/src/components/details/SuccessModal.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// SuccessModal.tsx -import React from 'react'; - -interface SuccessModalProps { - message: string; - onClose: () => void; -} - -const SuccessModal: React.FC = ({ message, onClose }) => { - return ( -
-
-
-

{message}

-
- -
-
-
- ); -}; - -export default SuccessModal; diff --git a/src/components/details/queries.ts b/src/components/details/queries.ts index 403b6dab..fca69ef6 100644 --- a/src/components/details/queries.ts +++ b/src/components/details/queries.ts @@ -1,49 +1,41 @@ -import { UseMutateFunction, useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; +import { QueryObserverResult, RefetchOptions, UseMutateFunction, useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; import type { IAuctionDetails, IPreAuctionDetails } from 'AuctionDetails'; import { httpClient } from '@/api/axios'; import { API_END_POINT } from '@/constants/api'; import { queryKeys } from '@/constants/queryKeys'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; -export const useConvertToAuction = (): { +export const useConvertAuction = (): { mutate: UseMutateFunction; + isPending: boolean; } => { - const queryClient = useQueryClient(); + const navigate = useNavigate(); - const { mutate } = useMutation({ - mutationFn: async (productId: number) => { - const response = await httpClient.post(`${API_END_POINT.AUCTIONS}/start`, productId); + const convertAuction = async (productId: number) => { + const response = await httpClient.post(`${API_END_POINT.AUCTIONS}/start`, productId); - return response.data; - }, - onSuccess: (_, productId) => { - // 경매로 전환될 시에도 몇몇 데이터를 그대로 사용하기 때문에 필요할 수 있겠다는 판단으로 넣음 - // 불필요시 제거 - queryClient.invalidateQueries({ - queryKey: [queryKeys.PRE_AUCTIONS, productId], - }); - queryClient.invalidateQueries({ - queryKey: [queryKeys.PRE_AUCTIONS], - }); - queryClient.invalidateQueries({ - queryKey: [queryKeys.PRE_AUCTION_LIST], - }); - queryClient.invalidateQueries({ - queryKey: [queryKeys.AUCTION_LIST], - }); + return response.data; + }; + + const { mutate, isPending } = useMutation({ + mutationFn: convertAuction, + onSuccess: (data) => { + navigate(`/auctions/auction/${data.auctionId}`, { replace: true }); + toast.success('경매로 전환되었습니다.'); }, }); - return { mutate }; + return { mutate, isPending }; }; export const useLikeAuctionItem = (): { mutate: UseMutateFunction; } => { const likeAuctionItem = async (auctionId: number) => { - const response = await httpClient.post(`${API_END_POINT.PRE_AUCTION}/${auctionId}/likes`); - - return response.data; + await httpClient.post(`${API_END_POINT.PRE_AUCTION}/${auctionId}/likes`); + return; }; const queryClient = useQueryClient(); @@ -68,17 +60,14 @@ export const useCancelBid = (): { const queryClient = useQueryClient(); const cancelBid = async (bidId: number) => { - const response = await httpClient.patch(`${API_END_POINT.BID}/${bidId}/cancel`); - - return response.data; + await httpClient.patch(`${API_END_POINT.BID}/${bidId}/cancel`); + return; }; const { mutate } = useMutation({ mutationFn: cancelBid, - onSuccess: (_, bidId) => { - queryClient.invalidateQueries({ - queryKey: [queryKeys.BIDDER_LIST, bidId], - }); + onSuccess: () => { + toast.success('입찰이 취소되었습니다.'); queryClient.invalidateQueries({ queryKey: [queryKeys.AUCTION_DETAILS], }); @@ -88,21 +77,23 @@ export const useCancelBid = (): { return { mutate }; }; -export const useGetAuctionDetails = (auctionId: number) => { +export const useGetAuctionDetails = ( + auctionId: number +): { auctionDetails: IAuctionDetails; refetch: (options?: RefetchOptions | undefined) => Promise> } => { const getAuctionDetails = async (): Promise => { const response = await httpClient.get(`${API_END_POINT.AUCTIONS}/${auctionId}`); - return response.data; }; - const { data: auctionDetails } = useSuspenseQuery({ + const { data: auctionDetails, refetch } = useSuspenseQuery({ queryKey: [queryKeys.AUCTION_DETAILS, auctionId], queryFn: getAuctionDetails, }); return { auctionDetails, + refetch, }; }; @@ -143,26 +134,22 @@ export const useGetPreAuctionDetailsWithSuspense = (preAuctionId: number) => { export const useDeletePreAuction = (): { mutate: UseMutateFunction; + isPending: boolean; } => { - const queryClient = useQueryClient(); + const navigate = useNavigate(); const deletePreAuction = async (preAuctionId: number) => { - const response = await httpClient.delete(`${API_END_POINT.PRE_AUCTION}/${preAuctionId}`); - - return response.data; + await httpClient.delete(`${API_END_POINT.PRE_AUCTION}/${preAuctionId}`); + return; }; - const { mutate } = useMutation({ + const { mutate, isPending } = useMutation({ mutationFn: deletePreAuction, - onSuccess: (_, preAuctionId) => { - queryClient.invalidateQueries({ - queryKey: [queryKeys.PRE_AUCTION_DETAILS, preAuctionId], - }); - queryClient.invalidateQueries({ - queryKey: [queryKeys.PRE_AUCTION_LIST], - }); + onSuccess: () => { + navigate('/'); + toast.success('사전 경매가 삭제되었습니다.'); }, }); - return { mutate }; + return { mutate, isPending }; }; diff --git a/src/components/heart/queries.ts b/src/components/heart/queries.ts index 91de23fc..b7a1c27d 100644 --- a/src/components/heart/queries.ts +++ b/src/components/heart/queries.ts @@ -35,6 +35,9 @@ export const useDeletePreAuctionHeart = (): { queryClient.invalidateQueries({ queryKey: [queryKeys.PRE_AUCTION_HEART_LIST], }); + queryClient.invalidateQueries({ + queryKey: [queryKeys.PRE_AUCTION_DETAILS], + }); toast.success('좋아요 취소되었습니다.'); }, }); diff --git a/src/components/layout/GlobalLayout.tsx b/src/components/layout/GlobalLayout.tsx index 0c268da6..ca3b0265 100644 --- a/src/components/layout/GlobalLayout.tsx +++ b/src/components/layout/GlobalLayout.tsx @@ -1,24 +1,17 @@ import { useEffect, useState } from 'react'; import { API_END_POINT } from '@/constants/api'; -import { Outlet } from 'react-router-dom'; -import type { IRealTimeNotification } from 'Notification'; import { useSSE } from '@/hooks/useSSE'; +import type { IRealTimeNotification } from 'Notification'; +import { Outlet } from 'react-router-dom'; import Popup from '../common/Popup'; import RealTimeNotification from './RealTimeNotification'; -import { useReadNotification } from '../notification/queries'; const GlobalLayout = () => { const { state: notifications, setState: setNotifications } = useSSE(`${API_END_POINT.REALTIME_NOTIFICATIONS}`); - const [currentNotification, setCurrentNotification] = useState(null); - const { mutate: readNotification } = useReadNotification(); - - const closePopup = () => { - if (currentNotification && !currentNotification.auctionId) readNotification(currentNotification.notificationId); - setCurrentNotification(null); - }; + const closePopup = () => setCurrentNotification(null) useEffect(() => { const showNextNotification = () => { diff --git a/src/components/login/queries.ts b/src/components/login/queries.ts index 3ad1ea9d..e797872c 100644 --- a/src/components/login/queries.ts +++ b/src/components/login/queries.ts @@ -5,10 +5,9 @@ import { API_END_POINT } from '@/constants/api'; import type { User } from '@/@types/user'; import { httpClient } from '@/api/axios'; import { storeLogin } from '@/store/authSlice'; -import { toast } from 'sonner'; -import { useDispatch } from 'react-redux'; -import { useEffect } from 'react'; import { useMutation } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; export const postSignup = async (data: User) => { const response = await httpClient.post(API_END_POINT.SIGNUP, { ...data }); @@ -35,7 +34,6 @@ export const refreshToken = async () => { if (newAccessToken) { setToken(newAccessToken); - toast.success('로그인 되었습니다.'); } return newAccessToken; diff --git a/src/components/notification/NotificationItem.tsx b/src/components/notification/NotificationItem.tsx index 3fe8d342..df9c8ea2 100644 --- a/src/components/notification/NotificationItem.tsx +++ b/src/components/notification/NotificationItem.tsx @@ -3,32 +3,23 @@ import { NOTIFICATION_CONTENTS } from '@/constants/notification'; import { getTimeAgo } from '@/utils/getTimeAgo'; import type { INotification } from 'Notification'; import { useNavigate } from 'react-router-dom'; +import { useDeleteNotification, useReadNotification } from './queries'; -interface INotificationItem { - item: INotification; - handleDelete: (id: number) => void; - handleRead: (id: number) => void; -} - -const NotificationItem = ({ - item, - handleDelete, - handleRead, -}: INotificationItem) => { +const NotificationItem = ({ item }: { item: INotification }) => { const navigate = useNavigate(); const { notificationId, isRead, imageUrl, message, createdAt, type, auctionId } = item; + const { mutate: deleteNotification } = useDeleteNotification(); + const { mutate: readNotification } = useReadNotification(); const time = getTimeAgo(createdAt); const handleClick = () => { - if (NOTIFICATION_CONTENTS[type]?.link && auctionId) { - navigate(NOTIFICATION_CONTENTS[type].link!(auctionId)); - handleRead(notificationId); - } + readNotification(notificationId) + if (NOTIFICATION_CONTENTS[type]?.link && auctionId) navigate(NOTIFICATION_CONTENTS[type].link!(auctionId)) }; return (
-
+

{message} @@ -39,7 +30,7 @@ const NotificationItem = ({

{`알림
-
diff --git a/src/components/profile/ProfileImageUploader.tsx b/src/components/profile/ProfileImageUploader.tsx index e3d0f47c..ec8b5791 100644 --- a/src/components/profile/ProfileImageUploader.tsx +++ b/src/components/profile/ProfileImageUploader.tsx @@ -59,7 +59,7 @@ const ProfileImageUploader = ({ file, setFile, image, setImage }: ImageUploaderP type="file" id="사진" className="hidden" - accept="image/*" + accept='image/jpeg, image/png, image/webp' onChange={handleImage} // 다수의 파일을 받지 않음 aria-label="프로필 사진 업로드 인풋" role="button" diff --git a/src/components/register/ImageUploader.tsx b/src/components/register/ImageUploader.tsx index fb8d0788..51db1143 100644 --- a/src/components/register/ImageUploader.tsx +++ b/src/components/register/ImageUploader.tsx @@ -18,11 +18,11 @@ const ImageUploader = ({ images, setImages }: ImageUploaderProps) => { return (
- + {images.map((image: string, index: number) => (
handleDragStart(index)} onDragOver={(e) => { @@ -54,7 +54,7 @@ const ImageUploader = ({ images, setImages }: ImageUploaderProps) => { type='file' id='사진' className='hidden' - accept='image/*' + accept='image/jpeg, image/png, image/webp' multiple onChange={handleImage} aria-label='사진 업로드 인풋' diff --git a/src/hooks/useImageUploader.ts b/src/hooks/useImageUploader.ts index dd6e56be..d4625ce0 100644 --- a/src/hooks/useImageUploader.ts +++ b/src/hooks/useImageUploader.ts @@ -1,4 +1,5 @@ import { ChangeEvent, useRef } from 'react'; +import { toast } from 'sonner'; export const useImageUploader = (state: string[], setState: (images: string[]) => void) => { const fileInputRef = useRef(null); @@ -29,6 +30,13 @@ export const useImageUploader = (state: string[], setState: (images: string[]) = if (!e.target.files) return; const curFiles = Array.from(e.target.files); + const maxSize = 10 * 1024 * 1024; + for (let file of curFiles) { + if (file.size > maxSize) { + toast.error('파일 크기는 10MB를 초과할 수 없습니다.'); + return; + } + } addImages(curFiles); }; diff --git a/src/hooks/useImageUrls.ts b/src/hooks/useImageUrls.ts deleted file mode 100644 index fd00278e..00000000 --- a/src/hooks/useImageUrls.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useMemo } from 'react'; - -interface ImageObject { - imageId?: number; - imageUrl: string; -} - -type ImageData = string[] | ImageObject[]; - -interface NormalizedImage { - key: number | string; - imageUrl: string; -} - -const useImageUrls = (images: ImageData): NormalizedImage[] => { - return useMemo(() => { - if (images.length === 0) return []; - - if (typeof images[0] === 'string') { - // 이미지가 ID의 배열인 경우 (auctionDetails) - return (images as string[]).map((id, idx) => ({ - key: idx + 1, - imageUrl: id, - })); - } - // 이미지가 Object인 경우 (PreAuctionDetails) - return (images as ImageObject[]).map((image, idx) => ({ - key: image.imageId || idx + 1, - imageUrl: image.imageUrl, - })); - }, [images]); -}; - -export default useImageUrls; diff --git a/src/hooks/useProfileImageUploader.ts b/src/hooks/useProfileImageUploader.ts index 7c20ce97..994e91fb 100644 --- a/src/hooks/useProfileImageUploader.ts +++ b/src/hooks/useProfileImageUploader.ts @@ -1,4 +1,5 @@ import { ChangeEvent, Dispatch, SetStateAction, useRef } from 'react'; +import { toast } from 'sonner'; export const useProfileImageUploader = ( _state: string | null, @@ -38,6 +39,13 @@ export const useProfileImageUploader = ( if (!e.target.files || e.target.files.length === 0) return; const newFile = e.target.files[0]; + + const maxSize = 10 * 1024 * 1024; + if (newFile.size > maxSize) { + toast.error('파일 크기는 10MB를 초과할 수 없습니다.'); + return; + } + addImage(newFile); }; @@ -58,4 +66,4 @@ export const useProfileImageUploader = ( handleImage, handleBoxClick, }; -}; \ No newline at end of file +}; diff --git a/src/mocks/data/auctionDetailsData.ts b/src/mocks/data/auctionDetailsData.ts index f97ec231..56c28511 100644 --- a/src/mocks/data/auctionDetailsData.ts +++ b/src/mocks/data/auctionDetailsData.ts @@ -1,6 +1,6 @@ -import { IAuctionDetails } from 'AuctionDetails'; import jordanBlackImage from '@/assets/images/jordan_black.jpeg'; import jordanRedImage from '@/assets/images/jordan_red.jpeg'; +import { IAuctionDetails } from 'AuctionDetails'; export const auctionDetailsData: IAuctionDetails[] = [ { @@ -8,7 +8,8 @@ export const auctionDetailsData: IAuctionDetails[] = [ bidId: null, description: "서로 다른 출신과 개성을 가진 이들이 모여 밴드 결성까지의 과정을 보여준 ‘Harmony from Discord’부터 멤버들 간의 만남을 동경과 벅차오르는 감성으로 담아낸 ‘MANITO’까지. 성장 서사를 써내려가는 밴드 QWER이 두 번째 EP인 ‘Algorithm’s Blossom’을 선보인다. 이번 앨범에서는 QWER이라는 하나의 팀으로서 새롭게 운명을 개척해나가는 이야기를 ‘알고리즘이 피워낸 꽃’이라는 키워드를 통해 풀어내고자 한다.\n\n\"사랑과 상처, 그 모든 것을 끌어안고 피어나”\n\n무수히 파편적이고 혼란하지만 보여지는 것은 단편적인 곳, 다양한 혼잡함이 가지런히 질서를 이루는 곳. 그런 '알고리즘' 속에서 우리의 이야기를 피워낸다. ‘Algorithm’s Blossom'에서 QWER은 보편적이지 않은 공간에 심겨진 씨앗으로, 동시에 사랑과 상처를 양분삼아 돋아난 싹으로, 세상에 보인 적 없던 새로운 꽃의 모습으로 자신들의 성장과 여정을 그린다.", - imageUrls: [jordanRedImage], + images: [{ imageId: 1, imageUrl: jordanRedImage }], + isCancelled: false, isParticipated: false, isSeller: false, minPrice: 23000, @@ -20,13 +21,15 @@ export const auctionDetailsData: IAuctionDetails[] = [ status: 'PROCEEDING', timeRemaining: 25816, category: 'ELECTRONICS', + sellerProfileImageUrl: '', }, { bidAmount: 0, bidId: null, description: "서로 다른 출신과 개성을 가진 이들이 모여 밴드 결성까지의 과정을 보여준 ‘Harmony from Discord’부터 멤버들 간의 만남을 동경과 벅차오르는 감성으로 담아낸 ‘MANITO’까지. 성장 서사를 써내려가는 밴드 QWER이 두 번째 EP인 ‘Algorithm’s Blossom’을 선보인다. 이번 앨범에서는 QWER이라는 하나의 팀으로서 새롭게 운명을 개척해나가는 이야기를 ‘알고리즘이 피워낸 꽃’이라는 키워드를 통해 풀어내고자 한다.\n\n\"사랑과 상처, 그 모든 것을 끌어안고 피어나”\n\n무수히 파편적이고 혼란하지만 보여지는 것은 단편적인 곳, 다양한 혼잡함이 가지런히 질서를 이루는 곳. 그런 '알고리즘' 속에서 우리의 이야기를 피워낸다. ‘Algorithm’s Blossom'에서 QWER은 보편적이지 않은 공간에 심겨진 씨앗으로, 동시에 사랑과 상처를 양분삼아 돋아난 싹으로, 세상에 보인 적 없던 새로운 꽃의 모습으로 자신들의 성장과 여정을 그린다.", - imageUrls: [jordanBlackImage], + images: [{ imageId: 2, imageUrl: jordanBlackImage }], + isCancelled: false, isParticipated: false, isSeller: false, minPrice: 23000, @@ -38,5 +41,6 @@ export const auctionDetailsData: IAuctionDetails[] = [ status: 'PROCEEDING', timeRemaining: 25816, category: 'ELECTRONICS', + sellerProfileImageUrl: '', }, ]; diff --git a/src/pages/AuctionDetails.tsx b/src/pages/AuctionDetails.tsx index 909b6133..ef408fe3 100644 --- a/src/pages/AuctionDetails.tsx +++ b/src/pages/AuctionDetails.tsx @@ -1,184 +1,78 @@ -/* eslint-disable prettier/prettier */ -import { useState } from 'react'; -import { LoaderFunction, useLoaderData, useNavigate } from 'react-router-dom'; -import BuyersFooter from '@/components/details/BuyersFooter'; -import { CiCoins1 } from 'react-icons/ci'; -import Layout from '@/components/layout/Layout'; +import { LoaderFunction, useLoaderData } from 'react-router-dom'; + +import ParticipantAmount from '@/assets/icons/my_participation_amount.svg'; import Participants from '@/assets/icons/participants.svg'; -import Price from '@/assets/icons/price.svg'; +import CustomCarousel from '@/components/common/CustomCarousel'; +import AuctionDetailsFooter from '@/components/details/AuctionDetailsFooter'; +import DetailsBasic from '@/components/details/DetailsBasic'; import ProgressBar from '@/components/details/ProgressBar'; -import SellersFooter from '@/components/details/SellersFooter'; import { useGetAuctionDetails } from '@/components/details/queries'; +import Layout from '@/components/layout/Layout'; +import { CarouselItem } from '@/components/ui/carousel'; import { formatCurrencyWithWon } from '@/utils/formatCurrencyWithWon'; -import ImageList from '@/components/details/ImageList'; -import LocalAPIAsyncBoundary from '@/components/common/boundary/LocalAPIAsyncBoundary'; const AuctionDetails = () => { const auctionId = useLoaderData() as number; - const { auctionDetails } = useGetAuctionDetails(auctionId); - if (!auctionDetails) { - throw new Error('해당 물품을 찾을 수 없습니다.'); - } - const [isMenuOpen, setIsMenuOpen] = useState(false); - const [isTimerFixed, _setIsTimerFixed] = useState(false); - const [isPreAuction, _setIsPreAuction] = useState(false); - const [_interestCount, _setInterestCount] = useState(1); - - const totalTime = 24 * 60 * 60; - - const navigate = useNavigate(); - const handleBackClick = () => { - navigate('/'); - }; - - const toggleMenu = () => { - setIsMenuOpen(!isMenuOpen); - }; - - const closeMenu = () => { - setIsMenuOpen(false); - }; + const { auctionDetails, refetch } = useGetAuctionDetails(auctionId); + const { images, productName, timeRemaining, sellerNickname, minPrice, bidAmount, isParticipated, bidId, remainingBidCount, status, description, isSeller, participantCount, category, sellerProfileImageUrl, isCancelled } = auctionDetails return ( - {/* 메인 컨텐츠가 스크롤 가능하도록 수정 */} -
- - {/* 상품 이미지 영역 */} -
- - - - {/* 타이머 및 프로그레스 바 */} - {auctionDetails && ( -
- -
- )} + +
+
+ + {images.map((img) => ( + + {`${productName}${img.imageId}`} + + ))} + +
+ - {/* 경매 정보 영역 */} -
- {/* 경매 아이템 제목 & 시작가 */} - {auctionDetails && ( -
-
-
-

- {auctionDetails?.sellerNickname || ''} -

-
-

- {auctionDetails?.productName || ''} -

-

- - - Price - - 시작가 - - {formatCurrencyWithWon(auctionDetails?.minPrice || 0)} - - -

-
- )} - {/* 나의 참여 금액 & 경매 참여인원 */} -
-
-
-
- - 나의 참여 금액 -
-

- {auctionDetails?.isParticipated - ? `${formatCurrencyWithWon(auctionDetails?.bidAmount || 0)}원` - : '참여 전'} -

-
-
-
-
- Participants -

참여 인원

-
-

- {auctionDetails?.participantCount - ? `${auctionDetails?.participantCount}명` - : '0명'} -

-
+
+
+
+ 나의 참여 금액 + 나의 참여 금액
+

+ {isParticipated + ? `${formatCurrencyWithWon(bidAmount)}` + : (isCancelled ? '참여 취소' : '참여 전')} +

-
- - {/* 상품 설명 */} -
-

{auctionDetails?.description || ''}

-
- - {/* 화면 하단에 고정된 Footer */} - - {auctionDetails && auctionDetails.isSeller ? ( - - ) : ( - - )} - - {/* 백드롭 */} - {isMenuOpen && ( - <> -
- {/* 메뉴 (아코디언) */} -
- - +
+
+ 참여 인원 + 참여 인원 +
+

+ {`${participantCount} 명`} +

- - )} -
+
+

+ {description} +

+
+ + ); }; diff --git a/src/pages/Heart.tsx b/src/pages/Heart.tsx index 0832646b..ea956392 100644 --- a/src/pages/Heart.tsx +++ b/src/pages/Heart.tsx @@ -22,7 +22,7 @@ const Heart = () => { - diff --git a/src/pages/Notification.tsx b/src/pages/Notification.tsx index 8de6cf54..920e8586 100644 --- a/src/pages/Notification.tsx +++ b/src/pages/Notification.tsx @@ -1,4 +1,4 @@ -import { useDeleteNotification, useGetNotificationsWithSuspense, useReadNotification } from '@/components/notification/queries'; +import { useGetNotificationsWithSuspense } from '@/components/notification/queries'; import EmptyBoundary from '@/components/common/boundary/EmptyBoundary'; import NotificationItem from '@/components/notification/NotificationItem'; @@ -6,17 +6,12 @@ import type { INotification } from 'Notification'; const Notification = () => { const { notifications } = useGetNotificationsWithSuspense(); - const { mutate: deleteNotification } = useDeleteNotification(); - const { mutate: readNotification } = useReadNotification(); - - const clickDelete = (id: number) => deleteNotification(id); - const clickRead = (id: number) => readNotification(id); return (
{notifications.map((item: INotification) => ( - + ))}
diff --git a/src/pages/PreAuctionDetails.tsx b/src/pages/PreAuctionDetails.tsx index 060e23c0..09a261e2 100644 --- a/src/pages/PreAuctionDetails.tsx +++ b/src/pages/PreAuctionDetails.tsx @@ -1,166 +1,116 @@ +import DeleteIcon from '@/assets/icons/modal_cancel.svg'; +import EditIcon from '@/assets/icons/modal_edit.svg'; import { useDeletePreAuction, useGetPreAuctionDetailsWithSuspense } from '@/components/details/queries'; import { LoaderFunction, useLoaderData, useNavigate } from 'react-router-dom'; -import Price from '@/assets/icons/price.svg'; -import LocalAPIAsyncBoundary from '@/components/common/boundary/LocalAPIAsyncBoundary'; -import BuyersFooter from '@/components/details/BuyersFooter'; -import ConfirmationModal from '@/components/details/ConfirmationModal'; -import ImageList from '@/components/details/ImageList'; -import SellersFooter from '@/components/details/SellersFooter'; -import SuccessModal from '@/components/details/SuccessModal'; +import BoxEditIcon from '@/assets/icons/in_box_edit_time.svg'; +import BoxLikeIcon from '@/assets/icons/in_box_like.svg'; +import Button from '@/components/common/Button'; +import ConfirmModal from '@/components/common/ConfirmModal'; +import CustomCarousel from '@/components/common/CustomCarousel'; +import DetailsBasic from '@/components/details/DetailsBasic'; +import PreAuctionDetailsFooter from '@/components/details/PreAuctionDetailsFooter'; import Layout from '@/components/layout/Layout'; -import { formatCurrencyWithWon } from '@/utils/formatCurrencyWithWon'; -/* eslint-disable prettier/prettier */ +import { CarouselItem } from '@/components/ui/carousel'; +import { getTimeAgo } from '@/utils/getTimeAgo'; import { useState } from 'react'; -const PreAuction = () => { +const PreAuctionDetails = () => { + const navigate = useNavigate(); const preAuctionId = useLoaderData() as number; const { preAuctionDetails } = useGetPreAuctionDetailsWithSuspense(preAuctionId); - + const { images, productName, productId, likeCount, sellerNickname, minPrice, isSeller, description, category, sellerProfileImageUrl, updatedAt } = preAuctionDetails const [isMenuOpen, setIsMenuOpen] = useState(false); const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); - const [isDeleteSuccessOpen, setIsDeleteSuccessOpen] = useState(false); - - const navigate = useNavigate(); - const { mutate: deletePreAuction } = useDeletePreAuction(); - - const toggleMenu = () => setIsMenuOpen(!isMenuOpen); - const closeMenu = () => setIsMenuOpen(false); - - // Delete button click handler - const onDeleteButtonClickHandler = () => { - setIsDeleteConfirmOpen(true); - closeMenu(); - }; - - const onEditButtonClickHandler = () => { - navigate(`/auctions/pre-auction/edit/${preAuctionDetails.productId}`); - }; + const { mutate: deletePreAuction, isPending } = useDeletePreAuction(); - const handleConfirmDelete = () => { - deletePreAuction(preAuctionId, { - onSuccess: () => { - setIsDeleteConfirmOpen(false); - setIsDeleteSuccessOpen(true); - }, - }); - }; + const updatedTime = getTimeAgo(updatedAt) - const handleCloseSuccessModal = () => { - setIsDeleteSuccessOpen(false); - navigate('/'); - }; + const toggleMenu = () => setIsMenuOpen((prev) => !prev); + const toggleConfirm = () => setIsDeleteConfirmOpen((prev) => !prev) + const clickDelete = () => { + toggleMenu() + toggleConfirm() + } + const clickEdit = () => navigate(`/auctions/pre-auction/edit/${productId}`); + const confirmDelete = () => deletePreAuction(preAuctionId); return ( -
- -
- - - -
-
- {preAuctionDetails && ( -
-
-
-

- {preAuctionDetails?.sellerNickname || ''} -

-
-

- {preAuctionDetails.productName} -

-

- - - Price - - 시작가 - - {formatCurrencyWithWon(preAuctionDetails.minPrice)}원 - - -

+ +
+ + {images.map((img) => ( + + {`${productName}${img.imageId}`} + + ))} + + + +
+
+
+ 수정 시간 + 수정 시간 +
+

+ {updatedTime} +

+
+
+
+ 좋아요 + 좋아요
- )} -
-
-

{preAuctionDetails?.description}

-
- - - {preAuctionDetails.isSeller ? ( - - ) : ( - - )} - - {isMenuOpen && ( - <> -
-
-
+
+

+ {description} +

+
+ + + { + isMenuOpen && ( +
+
e.stopPropagation()} className='absolute flex flex-col w-1/5 bg-white rounded-lg sm:text-body1 text-body2 top-3 right-3'> + -
- - )} -
- {isDeleteConfirmOpen && ( - setIsDeleteConfirmOpen(false)} - /> - )} - {isDeleteSuccessOpen && ( - - )} - - ); -}; +
) + } + { + isDeleteConfirmOpen && + + + + } + ) + +} -export default PreAuction; +export default PreAuctionDetails; export const loader: LoaderFunction = async ({ params }) => { const { preAuctionId } = params; diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 34b80502..9772f8ac 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -63,7 +63,10 @@ const Register = () => { const finalButton = caution === 'REGISTER' ? '바로 등록하기' : '사전 등록하기'; const toggleCheckBox = () => setCheck((state) => !state); - const clickBack = () => (caution === '' ? navigate(-1) : setCaution('')); + const clickBack = () => { + (caution === '' ? navigate(-1) : setCaution('')); + toggleCheckBox() + } const handleProceed = (proceedType: 'PRE_REGISTER' | 'REGISTER') => { handleSubmit(() => setCaution(proceedType))(); }; diff --git a/src/provider/queryProvider.tsx b/src/provider/queryProvider.tsx index 73f73943..d30bbe6f 100644 --- a/src/provider/queryProvider.tsx +++ b/src/provider/queryProvider.tsx @@ -25,8 +25,8 @@ const ReactQueryProvider = ({ showDevTools = false, children }: ReactQueryProvid throwOnError: false, onError: (error) => { if (error instanceof AxiosError) { - const { title } = getErrorByCode(error) - toast.error(title); + const { description } = getErrorByCode(error) + toast.error(description); } }, }, diff --git a/src/utils/dataURLToFile.ts b/src/utils/dataURLToFile.ts index 0f939888..29d1b3df 100644 --- a/src/utils/dataURLToFile.ts +++ b/src/utils/dataURLToFile.ts @@ -1,3 +1,5 @@ +import { toast } from 'sonner'; + export const dataURLtoFile = (dataURL: string): File => { // DataURL에서 Base64와 MIME 타입 추출 const arr = dataURL.split(','); @@ -18,11 +20,15 @@ export const dataURLtoFile = (dataURL: string): File => { const mimeToExtensionMap: { [key: string]: string } = { 'image/jpeg': 'jpg', 'image/png': 'png', + 'image/webp': 'webp', }; // MIME 타입이 지도된 확장자가 있는 경우에만 파일 생성 const extension = mimeToExtensionMap[mime]; - if (!extension) throw new Error(`${mime}은 지원되지 않는 MIME 타입입니다.`); + if (!extension) { + toast.error(`${mime}은 지원되지 않는 MIME 타입입니다.`); + throw new Error(`${mime}은 지원되지 않는 MIME 타입입니다.`); + } const fileName = `image.${extension}`; // idx를 사용한 파일 이름