diff --git a/src/App.tsx b/src/App.tsx index cdbf602a..53b7e3be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,7 +42,7 @@ export default function App(): JSX.Element { } /> } /> - } /> + } /> }> } /> diff --git a/src/api/index.ts b/src/api/index.ts deleted file mode 100644 index faa2ca1e..00000000 --- a/src/api/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * as user from './user'; diff --git a/src/api/review/entity.ts b/src/api/review/entity.ts new file mode 100644 index 00000000..b6ba81f8 --- /dev/null +++ b/src/api/review/entity.ts @@ -0,0 +1,6 @@ +export interface ReviewParams { + placeId : string; + content : string; + rate : number; + reviewImages : File[]; +} diff --git a/src/api/review/index.ts b/src/api/review/index.ts new file mode 100644 index 00000000..4f0acf99 --- /dev/null +++ b/src/api/review/index.ts @@ -0,0 +1,19 @@ +import { ReviewParams } from './entity'; +import reviewApi from './reviewApiClient'; + +export const postReview = (params: ReviewParams) => { + const formData = new FormData(); + formData.append('content', params.content); + formData.append('placeId', params.placeId); + formData.append('rate', String(params.rate)); + params.reviewImages.forEach((image) => { + formData.append('reviewImages', image); + }); + return reviewApi.post('/', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); +}; + +export const deleteReview = (reviewId: string) => reviewApi.delete(`/${reviewId}`); diff --git a/src/api/review/reviewApiClient.ts b/src/api/review/reviewApiClient.ts new file mode 100644 index 00000000..4ca26f59 --- /dev/null +++ b/src/api/review/reviewApiClient.ts @@ -0,0 +1,18 @@ +import axios from 'axios'; +import { API_PATH } from 'config/constants'; + +const reviewApi = axios.create({ + baseURL: `${API_PATH}/review`, + timeout: 2000, +}); + +reviewApi.interceptors.request.use( + (config) => { + const accessToken = sessionStorage.getItem('accessToken'); + // eslint-disable-next-line no-param-reassign + if (config.headers && accessToken) config.headers.Authorization = `Bearer ${accessToken}`; + return config; + }, +); + +export default reviewApi; diff --git a/src/api/search/entity.ts b/src/api/search/entity.ts index 4e6f0fbc..7faa2246 100644 --- a/src/api/search/entity.ts +++ b/src/api/search/entity.ts @@ -12,18 +12,21 @@ export interface ShopsParams { } export interface FetchShopsResponse { - content: Shop[]; + shopQueryResponseList: Shop[]; } -interface Shop { - address: string, - dist: 0, - placeId: string, - placeName: string, - score: number, - shopId: number, - x: string, - y: string, +export interface Shop { + placeId: string; + name: string; + formattedAddress: string; + lat: number; + lng: number; + openNow: boolean + totalRating: number | null; + ratingCount: number | null; + photoToken: string; + dist: number; + category: string; // 추후 카테고리 확인 필요 } export interface Coords { diff --git a/src/api/search/index.ts b/src/api/search/index.ts index a874644a..1c8cc742 100644 --- a/src/api/search/index.ts +++ b/src/api/search/index.ts @@ -5,4 +5,7 @@ export const fetchTrendings = () => searchApi.get('/tren export const fetchShop = (shopId: string) => searchApi.get(`/shop?place_id=${shopId}`); -export const fetchShops = (params: ShopsParams) => searchApi.post(`/shops?keyword=${params.keyword}&x=${params.location?.latitude}&y=${params.location?.longitude}`); +export const fetchShops = (params: ShopsParams) => searchApi.post(`/shops?keyword=${params.keyword}`, { + lat: params.location?.latitude, + lng: params.location?.longitude, +}); diff --git a/src/components/StarRating/index.tsx b/src/components/StarRating/index.tsx index 399898c0..2eea3316 100644 --- a/src/components/StarRating/index.tsx +++ b/src/components/StarRating/index.tsx @@ -1,11 +1,12 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import useBooleanState from 'utils/hooks/useBooleanState'; +import { useRate } from 'store/review'; import StarRateContext from './StarRateContext'; import EnterStarRateContainer from './EnterStarRateContainer'; import LeaveStarRateContainer from './LeaveStarRateContainer'; export default function StarRating({ onClick }: { onClick: () => void }) { - const [rating, setRating] = useState(0); + const [rating, setRating] = useRate(); const [entered, enter, leave] = useBooleanState(false); const value = useMemo(() => ({ @@ -15,7 +16,7 @@ export default function StarRating({ onClick }: { onClick: () => void }) { setRating(num); onClick?.(); }, - }), [onClick, enter, leave]); + }), [onClick, enter, leave, setRating]); return ( diff --git a/src/components/editor/TextEditor/AddImage/AddImage.module.scss b/src/components/editor/TextEditor/AddImage/AddImage.module.scss index d0384674..601e32f7 100644 --- a/src/components/editor/TextEditor/AddImage/AddImage.module.scss +++ b/src/components/editor/TextEditor/AddImage/AddImage.module.scss @@ -23,6 +23,7 @@ border-radius: 50%; background-color: transparent; z-index: 2; + top: -5px; } &__image { @@ -52,7 +53,7 @@ height: 340px; } - &--withImage { + &--with-image { line-height: 15px; height: 600px; width: 100%; diff --git a/src/components/editor/TextEditor/AddImage/ImageItem.tsx b/src/components/editor/TextEditor/AddImage/ImageItem.tsx index 2edfe616..5e6b3a04 100644 --- a/src/components/editor/TextEditor/AddImage/ImageItem.tsx +++ b/src/components/editor/TextEditor/AddImage/ImageItem.tsx @@ -4,10 +4,11 @@ import styles from './AddImage.module.scss'; interface Props { value: string, - onDelete: (value: string) => void, + index: number, + onDelete: (index: number) => void, } -export default function ImageItem({ value, onDelete }: Props) { +export default function ImageItem({ value, onDelete, index }: Props) { const imageRef = useRef(null); return ( @@ -16,7 +17,7 @@ export default function ImageItem({ value, onDelete }: Props) { type="button" aria-label="trash" className={styles.container__button} - onClick={() => onDelete(value)} + onClick={() => onDelete(index)} > diff --git a/src/components/editor/TextEditor/AddImage/index.tsx b/src/components/editor/TextEditor/AddImage/index.tsx index 4f1fa845..6d69ffc5 100644 --- a/src/components/editor/TextEditor/AddImage/index.tsx +++ b/src/components/editor/TextEditor/AddImage/index.tsx @@ -1,7 +1,6 @@ import { ReactComponent as Picture } from 'assets/svg/post/picture.svg'; -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import Wysiwyg, { WysiwygType } from 'components/editor/Wysiwyg'; -import useBooleanState from 'utils/hooks/useBooleanState'; import cn from 'utils/ts/classNames'; import styles from './AddImage.module.scss'; import useImageList from '../hooks/useImageList'; @@ -10,25 +9,20 @@ import ImageItem from './ImageItem'; function AddImage() { const { imageList, addImage, removeImage } = useImageList(); const wysiwygRef = useRef(null); - const [opened, active, inActive] = useBooleanState(false); - useEffect(() => { - if (imageList === null || imageList.length === 0) inActive(); - else active(); - }, [imageList, active, inActive]); return (
- { imageList?.map((value) => ( + {imageList.map((value, index) => (
- +
))}
0, })} > diff --git a/src/components/editor/TextEditor/hooks/useImageList.ts b/src/components/editor/TextEditor/hooks/useImageList.ts index 956eaf67..a7e9f4bf 100644 --- a/src/components/editor/TextEditor/hooks/useImageList.ts +++ b/src/components/editor/TextEditor/hooks/useImageList.ts @@ -1,39 +1,48 @@ -import { Dispatch, SetStateAction, useState } from 'react'; +import { useState } from 'react'; +import { useSetReview } from 'store/review'; +import makeToast from 'utils/ts/makeToast'; -interface ReturnType { - imageList: string[] | null; - setImageList: Dispatch>; - addImage: (event: React.ChangeEvent) => void; - removeImage: (value: string) => void; -} - -export default function useImageList(): ReturnType { - const [imageList, setImageList] = useState(null); +export default function useImageList() { + const [imageList, setImageList] = useState([]); + const setReview = useSetReview(); const addImage = (event: React.ChangeEvent) => { const { files } = event.target; if (files) { - const fileArray: string[] = []; const fileCount = files.length; - + const dataTransfer = new DataTransfer(); for (let i = 0; i < fileCount; i += 1) { + if (files[i].size > 1048576) { + makeToast('error', '최대 1MB 이미지만 업로드 가능합니다.'); + return; + } const reader = new FileReader(); reader.onload = (e) => { const result = e.target?.result; if (typeof result === 'string') { - fileArray.push(result); - if (fileArray.length === fileCount) { - setImageList((prev) => (prev ? [...prev, ...fileArray] : fileArray)); - } + setImageList((prev) => ([...prev, result])); } }; reader.readAsDataURL(files[i]); + dataTransfer.items.add(files[i]); + setReview((prev) => ({ + ...prev, + reviewImages: [...prev.reviewImages, files[i]], + })); } } }; - const removeImage = (value: string) => { - setImageList((prev) => prev?.filter((item) => item !== value) || null); + const removeImage = (index: number) => { + setImageList((prev) => { + const newList = [...prev]; + newList.splice(index, 1); + return newList; + }); + setReview((prev) => ({ + ...prev, + reviewImages: prev.reviewImages.filter((_, i) => i !== index), + })); }; return { diff --git a/src/components/editor/TextEditor/index.tsx b/src/components/editor/TextEditor/index.tsx index b6eac97f..a4b8bed5 100644 --- a/src/components/editor/TextEditor/index.tsx +++ b/src/components/editor/TextEditor/index.tsx @@ -1,4 +1,3 @@ -import { ReactComponent as Plus } from 'assets/svg/post/plus.svg'; import cn from 'utils/ts/classNames'; import PreviousButton from 'components/PreviousButton/PreviousButton'; import StarRating from 'components/StarRating'; @@ -8,11 +7,11 @@ import AddImage from './AddImage'; import styles from './TextEditor.module.scss'; interface Props { - shop: string | null; - getShopname: () => string | null; + shop: string; + onSubmit: () => void; } -export default function TextEditor({ shop, getShopname }: Props) { +export default function TextEditor({ shop, onSubmit }: Props) { const [actived, active] = useBooleanState(false); return ( @@ -30,6 +29,7 @@ export default function TextEditor({ shop, getShopname }: Props) {
diff --git a/src/components/editor/Wysiwyg/index.tsx b/src/components/editor/Wysiwyg/index.tsx index 1c8ad6ef..738a571e 100644 --- a/src/components/editor/Wysiwyg/index.tsx +++ b/src/components/editor/Wysiwyg/index.tsx @@ -4,6 +4,7 @@ import '@toast-ui/editor/dist/toastui-editor.css'; import './Wysiwyg.scss'; import fontSize from 'tui-editor-plugin-font-size'; import 'tui-editor-plugin-font-size/dist/tui-editor-plugin-font-size.css'; +import { useSetReview } from 'store/review'; export interface WysiwygType { addImg: () => void, @@ -15,6 +16,7 @@ export interface WysiwygType { const Wysiwyg = forwardRef((_props, ref) => { const editorRef = useRef(null); + const setReview = useSetReview(); useImperativeHandle(ref, () => ({ addImg() { editorRef.current?.getInstance().exec('addImage', { imageUrl: 'https://picsum.photos/200/300' }); @@ -48,6 +50,10 @@ const Wysiwyg = forwardRef((_props, ref) => { toolbarItems={[]} ref={editorRef} plugins={[fontSize]} + onChange={() => setReview((prev) => ({ + ...prev, + content: editorRef.current?.getInstance().getMarkdown() || '', + }))} /> ); diff --git a/src/pages/Post/index.tsx b/src/pages/Post/index.tsx index b65e50ef..451be2f7 100644 --- a/src/pages/Post/index.tsx +++ b/src/pages/Post/index.tsx @@ -1,17 +1,28 @@ import TextEditor from 'components/editor/TextEditor'; -import { useSearchParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useReview } from 'store/review'; +import { postReview } from 'api/review'; +import makeToast from 'utils/ts/makeToast'; import styles from './Post.module.scss'; export default function Post() { - const [searchParams] = useSearchParams(); - // 쿼리가 shop인 값을 가져오는 함수 - const getShopname = () => { - const shop = searchParams.get('shop'); - return shop; + const { name: shopName } = useParams(); + const navigate = useNavigate(); + const { placeId } = useLocation().state as { placeId: string }; + const review = useReview(); + const submitReview = () => { + postReview({ + placeId, + ...review, + }).then(() => { + navigate('/'); + makeToast('success', '리뷰가 등록되었습니다.'); + }); }; + return (
- +
); } diff --git a/src/pages/Search/components/SearchBar/RollingBanner.tsx b/src/pages/Search/components/SearchBar/RollingBanner.tsx index 2c53d575..579ed88a 100644 --- a/src/pages/Search/components/SearchBar/RollingBanner.tsx +++ b/src/pages/Search/components/SearchBar/RollingBanner.tsx @@ -12,21 +12,21 @@ export default function RollingBanner() {
  • {trendings && trendings.map((tag) => ( - + {`#${tag}`} ))}
  • {trendings && trendings.map((tag) => ( - + {`#${tag}`} ))}
  • {trendings && trendings.map((tag) => ( - + {`#${tag}`} ))} diff --git a/src/pages/Search/components/SearchBar/SearchBar.module.scss b/src/pages/Search/components/SearchBar/SearchBar.module.scss index ecb36de0..d3caf8af 100644 --- a/src/pages/Search/components/SearchBar/SearchBar.module.scss +++ b/src/pages/Search/components/SearchBar/SearchBar.module.scss @@ -65,6 +65,12 @@ padding-right: 14px; cursor: pointer; } + + &__submit { + border: none; + background: none; + cursor: pointer; + } } .search-rolling-banner { diff --git a/src/pages/Search/components/SearchBar/SearchInput.tsx b/src/pages/Search/components/SearchBar/SearchInput.tsx index ef0e979e..7e4a5a5d 100644 --- a/src/pages/Search/components/SearchBar/SearchInput.tsx +++ b/src/pages/Search/components/SearchBar/SearchInput.tsx @@ -18,7 +18,9 @@ export default function SearchInput({ value={text} onChange={onChange} /> - + ); } diff --git a/src/pages/Search/index.tsx b/src/pages/Search/index.tsx index 81d6bd2b..d09d055b 100644 --- a/src/pages/Search/index.tsx +++ b/src/pages/Search/index.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import styles from 'pages/Search/Search.module.scss'; import useMediaQuery from 'utils/hooks/useMediaQuery'; +import { useNavigate } from 'react-router-dom'; import Recommendation from './components/SearchBar/Recommendation'; import SearchInput from './components/SearchBar/SearchInput'; import RollingBanner from './components/SearchBar/RollingBanner'; @@ -23,17 +24,19 @@ export default function Search(): JSX.Element { const { text, handleChange } = useSearchForm(); const isSearching = useSearchingMode(); const { isMobile } = useMediaQuery(); - + const navigate = useNavigate(); return (
    {isMobile && } {isMobile ? !isSearching && : } - +
    navigate(`/search/${text}`)}> + + {isMobile ? !isSearching && : }
    diff --git a/src/pages/SearchDetails/SearchDetails.module.scss b/src/pages/SearchDetails/SearchDetails.module.scss index c0a050fe..0a68a61f 100644 --- a/src/pages/SearchDetails/SearchDetails.module.scss +++ b/src/pages/SearchDetails/SearchDetails.module.scss @@ -139,6 +139,8 @@ font-size: 16px; text-decoration: none; color: black; + text-align: start; + cursor: pointer; &:hover { background-color: #eeeeee; diff --git a/src/pages/SearchDetails/components/SearchItem.tsx b/src/pages/SearchDetails/components/SearchItem.tsx index 99de0a44..119dcfae 100644 --- a/src/pages/SearchDetails/components/SearchItem.tsx +++ b/src/pages/SearchDetails/components/SearchItem.tsx @@ -1,37 +1,57 @@ import styles from 'pages/SearchDetails/SearchDetails.module.scss'; -import { ReactComponent as MapIcon } from 'assets/svg/search/map.svg'; -import defaultImage from 'assets/images/search/default-image.png'; +import { useNavigate } from 'react-router-dom'; +import { Shop } from 'api/search/entity'; +import { getMockItem } from '../static/mockup'; interface Props { - shop: { - address: string, - placeName: string, - shopId: number, - }, + shop: Shop; } export default function SearchItem({ shop }: Props) { - const { placeName, address } = shop; + const { + name, formattedAddress, photoToken, placeId, dist, openNow, + } = shop; + const { + imageAlt, defaultImage, phoneNumber, image, + } = getMockItem(); + const navigate = useNavigate(); + return ( -
    - 가게 이미지 없음 +
    + ); } diff --git a/src/pages/SearchDetails/components/SearchItemPC.tsx b/src/pages/SearchDetails/components/SearchItemPC.tsx deleted file mode 100644 index 80fb62ff..00000000 --- a/src/pages/SearchDetails/components/SearchItemPC.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import styles from 'pages/SearchDetails/SearchDetails.module.scss'; -import { Link } from 'react-router-dom'; -import { getMockItem, SHOPS } from '../static/mockup'; - -interface Props { - shop: { - address: string, - placeName: string, - shopId: number, - }, -} - -export default function SearchItemPC({ shop }: Props) { - const { placeName, address } = shop; - const { - imageAlt, defaultImage, phoneNumber, image, - } = getMockItem(); - - return ( - -
    - {imageAlt} -
    - {imageAlt} - {imageAlt} - {imageAlt} -
    -
    -
    -
    -

    {placeName}

    -

    {address}

    -
    -
    - -
    -
    - - ); -} diff --git a/src/pages/SearchDetails/hooks/useFetchShops.ts b/src/pages/SearchDetails/hooks/useFetchShops.ts index 438efcf0..91ec13f7 100644 --- a/src/pages/SearchDetails/hooks/useFetchShops.ts +++ b/src/pages/SearchDetails/hooks/useFetchShops.ts @@ -14,7 +14,7 @@ const useFetchShops = (keyword: string) => { } = useQuery('shop', () => fetchShops(params as ShopsParams), { enabled: !!location }); const isFetching = isLoading || !(location); - const shops = data?.data.content; + const shops = data?.data.shopQueryResponseList; return { isFetching, isError, data: shops, refetch, diff --git a/src/pages/SearchDetails/index.tsx b/src/pages/SearchDetails/index.tsx index ca4758e5..01968fe0 100644 --- a/src/pages/SearchDetails/index.tsx +++ b/src/pages/SearchDetails/index.tsx @@ -4,7 +4,7 @@ import NavigationBar from 'pages/Search/components/NavigationBar'; import LoadingView from './components/LoadingView'; import useFetchShops from './hooks/useFetchShops'; import ControllBar from './components/ControllBar'; -import SearchItemPC from './components/SearchItemPC'; +import SearchItem from './components/SearchItem'; export default function SearchDetails() { const { keyword } = useParams(); @@ -19,7 +19,7 @@ export default function SearchDetails() { {isFetching ? : shops && shops.map((shop) => ( - + ))}
    diff --git a/src/store/review.ts b/src/store/review.ts new file mode 100644 index 00000000..66f51d31 --- /dev/null +++ b/src/store/review.ts @@ -0,0 +1,25 @@ +import { ReviewParams } from 'api/review/entity'; +import { atom, useAtomValue, useSetAtom } from 'jotai'; + +type ReviewAtom = Omit; + +const reviewAtom = atom({ + content: '', + rate: 0, + reviewImages: [], +}); + +export const useReview = () => useAtomValue(reviewAtom); + +export const useSetReview = () => useSetAtom(reviewAtom); + +export const useRate = () => { + const review = useReview(); + const setReview = useSetReview(); + + const setRate = (rate: number) => { + setReview({ ...review, rate }); + }; + + return [review.rate, setRate] as const; +};