diff --git a/src/api/review/entity.ts b/src/api/review/entity.ts index b6ba81f8..1259d425 100644 --- a/src/api/review/entity.ts +++ b/src/api/review/entity.ts @@ -1,3 +1,69 @@ +export interface PostReviewParams { + content:string; + placeId:string; + rate:number; + reviewImages?:Array; +} + +export interface LatestReviewResponse { + lastDate:null | string; +} + +export interface FetchParams { + placeId:string; + sort:string; +} + +export interface GetReviewResponse { + content:Review[]; + empty:boolean; + first:boolean; + last:boolean; + number:boolean; + numberOfElements:number; + pageable:Pagable; + size:number; + sort:Sort; + totalElements:number; + totalPages:number; +} + +interface Review { + content:string; + createdAt:string; + id:number; + rate:number; + reviewImages:ReviewImage[]; + shopPlaceId:string; + userReviewResponse:UserReviewResponse; +} + +interface Pagable { + offset:number; + pageNumber:number; + pageSize:number; + paged:boolean; + sort:Sort; + unpaged:boolean; +} + +interface ReviewImage { + imageUrl:string; + originalname:string; +} + +interface UserReviewResponse { + account:string; + id:number; + nickname:string; +} + +interface Sort { + empty:boolean; + sorted:boolean; + unsorted:boolean; +} + export interface ReviewParams { placeId : string; content : string; diff --git a/src/api/review/index.ts b/src/api/review/index.ts index 4f0acf99..6f9273e3 100644 --- a/src/api/review/index.ts +++ b/src/api/review/index.ts @@ -1,5 +1,27 @@ -import { ReviewParams } from './entity'; import reviewApi from './reviewApiClient'; +import { + GetReviewResponse, LatestReviewResponse, FetchParams, ReviewParams, +} from './entity'; + +export const getMyReview = async (params:FetchParams) => { + const { data } = await reviewApi.get(`/shop/${params.placeId}?sort=${params.sort}`); + return data; +}; + +export const getFollowersReview = async (params:FetchParams) => { + const { data } = await reviewApi.get(`/followers/shop/${params.placeId}?sort=${params.sort}`); + return data; +}; + +export const latestFollowerReview = async (placeId:string) => { + const { data } = await reviewApi.get(`/followers/last-date/shop/${placeId}`); + return data; +}; + +export const latestMyReview = async (placeId:string) => { + const { data } = await reviewApi.get(`/last-date/shop/${placeId}`); + return data; +}; export const postReview = (params: ReviewParams) => { const formData = new FormData(); diff --git a/src/api/scrap/entity.ts b/src/api/scrap/entity.ts new file mode 100644 index 00000000..dc706a19 --- /dev/null +++ b/src/api/scrap/entity.ts @@ -0,0 +1,58 @@ +export interface PostScrapParams { + directoryId:number; + placeId:string; +} + +export interface PostScrapResponse { + createdAt:string; + directory:{ + createdAt:string; + id:number; + name:string; + scrapCount:number; + updatedAt:string; + } + id:number; + shopId:number; + updatedAt:string; +} + +export interface GetScrapResponse { + content: Content[]; + empty:boolean; + first:boolean; + last:boolean; + number:number; + numberOfElements:number; + pagable: Pagable; + size:number; + sort: Sort; + totalElements:number; + totalPages:number; +} + +interface Content { + address:string; + category:string; + name:string; + photo:string; + placeId:string; + ratingCount:number; + scrapId:number; + totalRating:number; +} + +interface Pagable { + offset:number; + pageNumber:number; + pageSize:number; + paged:boolean; + sort: Sort[]; + unpaged:boolean; +} + +interface Sort { + empty:boolean; + sorted:boolean; + unsorted:boolean; +} diff --git a/src/api/scrap/index.ts b/src/api/scrap/index.ts new file mode 100644 index 00000000..e8561e8e --- /dev/null +++ b/src/api/scrap/index.ts @@ -0,0 +1,14 @@ +import { PostScrapParams, GetScrapResponse, PostScrapResponse } from './entity'; +import scrapApi from './scrapApiClient'; + +export const postScrap = async (params:PostScrapParams) => { + const { data } = await scrapApi.post('/scraps', params); + return data; +}; + +export const deleteScrap = async (scrapId:number) => scrapApi.delete(`/scraps/${scrapId}`); + +export const getScrap = async () => { + const { data } = await scrapApi.get('/scraps'); + return data; +}; diff --git a/src/api/scrap/scrapApiClient.ts b/src/api/scrap/scrapApiClient.ts new file mode 100644 index 00000000..6d49e636 --- /dev/null +++ b/src/api/scrap/scrapApiClient.ts @@ -0,0 +1,18 @@ +import axios from 'axios'; +import { API_PATH } from 'config/constants'; + +const scrapApi = axios.create({ + baseURL: `${API_PATH}`, + timeout: 2000, +}); + +scrapApi.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 scrapApi; diff --git a/src/api/shop/entity.ts b/src/api/shop/entity.ts index 325193da..f8d1a579 100644 --- a/src/api/shop/entity.ts +++ b/src/api/shop/entity.ts @@ -33,6 +33,24 @@ export interface Coords { lng: number | undefined; } +export interface FetchShopResponse { + businessDay:string[]; + category:string; + formattedAddress:string; + formattedPhoneNumber:string; + lat:number; + lng:number; + name:string; + openNow:boolean; + photos:string[]; + placeId:string; + ratingCount:number; + scrap:number; + shopId:number; + todayBusinessHour:string; + totalRating:number; +} + export interface FetchShopsResponse { shopQueryResponseList: Shop[]; } diff --git a/src/api/shop/index.ts b/src/api/shop/index.ts index b1bc12f7..7021c84e 100644 --- a/src/api/shop/index.ts +++ b/src/api/shop/index.ts @@ -1,12 +1,15 @@ import { Coords, FetchShopsResponse, FetchTrendingsResponse, FilterShopsParams, - FilterShopsListResponse, ShopsParams, + FilterShopsListResponse, ShopsParams, FetchShopResponse, } from './entity'; import shopApi from './shopApiClient'; export const fetchTrendings = () => shopApi.get('/trending'); -export const fetchShop = (shopId: string) => shopApi.get(`/shop?place_id=${shopId}`); +export const fetchShop = async (placeId: string) => { + const { data } = await shopApi.get(`/shops/${placeId}`); + return data; +}; export const getfilterShops = (params: FilterShopsParams, location: Coords) => { const url = `/shops/maps?options_friend=${params.options_friend}&options_nearby=${params.options_nearby}&options_scrap=${params.options_scrap}`; diff --git a/src/assets/svg/pin/bookmark.svg b/src/assets/svg/pin/bookmark.svg new file mode 100644 index 00000000..9bb120b6 --- /dev/null +++ b/src/assets/svg/pin/bookmark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/svg/pin/bookmark_activated.svg b/src/assets/svg/pin/bookmark_activated.svg new file mode 100644 index 00000000..ff8459af --- /dev/null +++ b/src/assets/svg/pin/bookmark_activated.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/svg/pin/empty.svg b/src/assets/svg/pin/empty.svg new file mode 100644 index 00000000..33f328bd --- /dev/null +++ b/src/assets/svg/pin/empty.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/svg/pin/follower_empty.svg b/src/assets/svg/pin/follower_empty.svg new file mode 100644 index 00000000..f251037a --- /dev/null +++ b/src/assets/svg/pin/follower_empty.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/svg/pin/pencil.svg b/src/assets/svg/pin/pencil.svg new file mode 100644 index 00000000..a123a109 --- /dev/null +++ b/src/assets/svg/pin/pencil.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/svg/pin/report.svg b/src/assets/svg/pin/report.svg new file mode 100644 index 00000000..42592dfc --- /dev/null +++ b/src/assets/svg/pin/report.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/svg/pin/star.svg b/src/assets/svg/pin/star.svg new file mode 100644 index 00000000..ace9eda5 --- /dev/null +++ b/src/assets/svg/pin/star.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/svg/pin/switch-horizontal.svg b/src/assets/svg/pin/switch-horizontal.svg new file mode 100644 index 00000000..06be0844 --- /dev/null +++ b/src/assets/svg/pin/switch-horizontal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/common/SideNavigation/SideNavigation.module.scss b/src/components/common/SideNavigation/SideNavigation.module.scss index 67eeca8c..53b287f9 100644 --- a/src/components/common/SideNavigation/SideNavigation.module.scss +++ b/src/components/common/SideNavigation/SideNavigation.module.scss @@ -144,6 +144,7 @@ z-index: 5; background-color: #ffffff; display: flex; + align-items: center; flex-direction: column; animation: slide-in 0.3s ease-out normal forwards; diff --git a/src/components/common/SideNavigation/index.tsx b/src/components/common/SideNavigation/index.tsx index 6dedd175..5f01fcf0 100644 --- a/src/components/common/SideNavigation/index.tsx +++ b/src/components/common/SideNavigation/index.tsx @@ -8,10 +8,16 @@ import cn from 'utils/ts/classNames'; import useBooleanState from 'utils/hooks/useBooleanState'; import { Link, useLocation } from 'react-router-dom'; import { useFilterFriend, useFilterNearby, useFilterScrap } from 'store/filter'; +import Pin from 'pages/Pin'; +import { useEffect } from 'react'; import styles from './SideNavigation.module.scss'; import SpriteSvg from '../SpriteSvg'; -export default function SideNavigation(): JSX.Element { +interface Props { + selected:naver.maps.Marker | undefined; + placeId:string | ''; +} +export default function SideNavigation({ selected, placeId }:Props): JSX.Element { const auth = useAuth(); const clearAuth = useClearAuth(); const location = useLocation(); @@ -56,6 +62,9 @@ export default function SideNavigation(): JSX.Element { }, ]; + useEffect(() => { + if (selected) setVisible(true); + }, [selected, setVisible]); return (
+ {(selected && placeId !== '') && } ); diff --git a/src/layout/DefaultLayout/index.tsx b/src/layout/DefaultLayout/index.tsx index dcc7f80a..614fa9f0 100644 --- a/src/layout/DefaultLayout/index.tsx +++ b/src/layout/DefaultLayout/index.tsx @@ -1,22 +1,16 @@ import { Outlet, useLocation } from 'react-router-dom'; -import BottomNavigation from 'components/common/BottomNavigation'; -import SideNavigation from 'components/common/SideNavigation'; import Home from 'pages/Home'; -import useMediaQuery from 'utils/hooks/useMediaQuery'; import cn from 'utils/ts/classNames'; import styles from './DefaultLayout.module.scss'; export default function DefaultLayout(): JSX.Element { - const { isMobile } = useMediaQuery(); const location = useLocation(); return ( <> - {!isMobile && }
- {isMobile && } ); } diff --git a/src/pages/Home/components/Map/hooks/useMarker.ts b/src/pages/Home/components/Map/hooks/useMarker.ts index 63cf71d1..aa61558f 100644 --- a/src/pages/Home/components/Map/hooks/useMarker.ts +++ b/src/pages/Home/components/Map/hooks/useMarker.ts @@ -17,8 +17,8 @@ function useMarker({ map, filterShops }: MarkerProps) { useEffect(() => { if (!map || !filterShops) return; // 사용량 제한으로, 현재는 목업 데이터로 마커를 찍고 있음 - const newMarkers = (filterShops ?? []).map((shop, index) => { - // const newMarkers = (MARKER ?? []).map((shop, index) => { + // const newMarkers = (filterShops ?? []).map((shop, index) => { + const newMarkers = (MARKER ?? []).map((shop, index) => { const lat = shop?.geometry?.location?.lat; const lng = shop?.geometry?.location?.lng; if (!lat || !lng) return; diff --git a/src/pages/Home/components/Map/hooks/usePlaceId.ts b/src/pages/Home/components/Map/hooks/usePlaceId.ts new file mode 100644 index 00000000..96901a06 --- /dev/null +++ b/src/pages/Home/components/Map/hooks/usePlaceId.ts @@ -0,0 +1,32 @@ +import { fetchShops } from 'api/shop'; +import { useEffect, useState } from 'react'; + +interface PlaceIdParam { + title: string | undefined; + lat: number | undefined; + lng: number | undefined; +} +const usePlaceId = ({ title, lat, lng }:PlaceIdParam) => { + const [placeId, setPlaceId] = useState(''); + + useEffect(() => { + const getPlceId = async () => { + if (title && lat && lng) { + const shops = await fetchShops( + { + keyword: title, + location: { lat, lng }, + }, + ); + if (shops.data.shopQueryResponseList.length !== 0) { + setPlaceId(shops.data?.shopQueryResponseList[0].placeId || ''); + } + } + }; + getPlceId(); + }, [lat, lng, title]); + + return { placeId }; +}; + +export default usePlaceId; diff --git a/src/pages/Home/components/Map/index.tsx b/src/pages/Home/components/Map/index.tsx index f4ca6a5d..2a8e9bb0 100644 --- a/src/pages/Home/components/Map/index.tsx +++ b/src/pages/Home/components/Map/index.tsx @@ -3,12 +3,15 @@ import useMediaQuery from 'utils/hooks/useMediaQuery'; import { useEffect } from 'react'; import { useFilterFriend, useFilterNearby, useFilterScrap } from 'store/filter'; import { useLocation } from 'store/location'; +import SideNavigation from 'components/common/SideNavigation'; +import BottomNavigation from 'components/common/BottomNavigation'; +import Pin from 'pages/Pin'; import styles from './Map.module.scss'; import MobileOptions from './components/MobileOptions'; import useNaverMap from './hooks/useNaverMap'; import useMarker from './hooks/useMarker'; import useFilterShops from './hooks/useFilterShops'; -import Pin from '../Pin'; +import usePlaceId from './hooks/usePlaceId'; import useCluster from './hooks/useCluster'; export default function Map(): JSX.Element { @@ -25,6 +28,11 @@ export default function Map(): JSX.Element { }); const { markerArray, selected } = useMarker({ map, filterShops }); + const { placeId } = usePlaceId({ + title: selected?.getTitle(), + lat: selected?.getPosition().y, + lng: selected?.getPosition().x, + }); useEffect(() => { refetch(); @@ -35,8 +43,11 @@ export default function Map(): JSX.Element { return ( <> {isMobile && } - {selected && } -
+ {(isMobile && selected) && } + {!isMobile && } +
+ {isMobile && } +
{ cluster && null, getMap: () => null }} />} ); diff --git a/src/pages/Home/components/Pin/index.tsx b/src/pages/Home/components/Pin/index.tsx deleted file mode 100644 index 32fd1502..00000000 --- a/src/pages/Home/components/Pin/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -interface PinProps { - selected : naver.maps.Marker; -} - -export default function Pin({ selected } : PinProps): JSX.Element { - return ( -
- {selected.getTitle()} -
- ); -} diff --git a/src/pages/Home/static/marker.ts b/src/pages/Home/static/marker.ts index facc396d..434ac4f6 100644 --- a/src/pages/Home/static/marker.ts +++ b/src/pages/Home/static/marker.ts @@ -6,7 +6,7 @@ const MARKER = [ lng: 127.0243059, }, }, - name: '삼촌네고깃집2', + name: '삼촌네고깃집', photo: 'null', placeId: 'nmap-119FC9E2-1E6F-4920-8734-0D82644CFFBF', }, @@ -17,7 +17,7 @@ const MARKER = [ lng: 127.0360738, }, }, - name: '멕시카나 치킨2', + name: '멕시카나 치킨', photo: 'null', placeId: 'nmarker-3DC43A25-00E4-485E-B823-C87666B8B4A0', }, @@ -28,10 +28,21 @@ const MARKER = [ lng: 127.041588, }, }, - name: '아라미즈2', + name: '아라미즈', photo: 'null', placeId: 'nmarker-B8FB8547-EFB9-416B-BA20-02749D239706', }, + { + geometry: { + location: { + lat: 37.51700039999999, + lng: 126.9078048, + }, + }, + name: '신전떡볶이 영등포점', + photo: 'null', + placeId: 'ChIJe9073fyefDUR4FggnKorNT4', + }, ]; export default MARKER; diff --git a/src/pages/MyPage/components/BookMark/BookMark.module.scss b/src/pages/MyPage/components/BookMark/BookMark.module.scss index edd905ac..409e4ef0 100644 --- a/src/pages/MyPage/components/BookMark/BookMark.module.scss +++ b/src/pages/MyPage/components/BookMark/BookMark.module.scss @@ -45,7 +45,7 @@ &--type { display: flex; - align-items: end; + align-items: flex-end; font-size: 14px; font-weight: 400; color: #979797; diff --git a/src/pages/MyPage/components/Information/Information.module.scss b/src/pages/MyPage/components/Information/Information.module.scss index afc1b310..2c252b47 100644 --- a/src/pages/MyPage/components/Information/Information.module.scss +++ b/src/pages/MyPage/components/Information/Information.module.scss @@ -11,7 +11,7 @@ .user { display: flex; align-items: center; - justify-content: start; + justify-content: flex-start; &__image { width: 46px; diff --git a/src/pages/MyPage/components/MobileBoard/MobileBoard.module.scss b/src/pages/MyPage/components/MobileBoard/MobileBoard.module.scss index 4633993e..fc758b02 100644 --- a/src/pages/MyPage/components/MobileBoard/MobileBoard.module.scss +++ b/src/pages/MyPage/components/MobileBoard/MobileBoard.module.scss @@ -51,7 +51,7 @@ .store { display: flex; margin-bottom: 8px; - align-items: end; + align-items: flex-end; gap: 4px; &__name { diff --git a/src/pages/MyPage/components/MyPost/MyPost.module.scss b/src/pages/MyPage/components/MyPost/MyPost.module.scss index 5f412bf2..5c071121 100644 --- a/src/pages/MyPage/components/MyPost/MyPost.module.scss +++ b/src/pages/MyPage/components/MyPost/MyPost.module.scss @@ -4,7 +4,7 @@ display: flex; width: 98%; gap: 17px; - justify-content: end; + justify-content: flex-end; margin-top: 25px; @include media.media-breakpoint-down(mobile) { diff --git a/src/pages/MyPage/index.tsx b/src/pages/MyPage/index.tsx index f7578e1e..6911f49d 100644 --- a/src/pages/MyPage/index.tsx +++ b/src/pages/MyPage/index.tsx @@ -31,7 +31,7 @@ export default function MyPage() { }; return ( <> - {!isMobile && } + {!isMobile && } {profile && (
diff --git a/src/pages/Pin/Pin.module.scss b/src/pages/Pin/Pin.module.scss new file mode 100644 index 00000000..64c38460 --- /dev/null +++ b/src/pages/Pin/Pin.module.scss @@ -0,0 +1,354 @@ +@use "src/utils/styles/mediaQuery" as media; + +.template { + width: 550px; + justify-content: flex-end; + align-items: center; + background-color: #ffffff; + overflow-x: hidden; + overflow-y: scroll; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + border-radius: 20px; + background-color: #c4c4c4; + } + + &::-webkit-scrollbar-track { + background-color: #eeeeee; + } + + @include media.media-breakpoint-down(mobile) { + width: 100%; + height: 100vh; + position: fixed; + z-index: 10; + } +} + +.carousel { + position: relative; + overflow: hidden; + display: flex; + width: 550px; + height: 362px; + background-color: #f5f5f5; + + @include media.media-breakpoint-down(mobile) { + width: 100vw; + } + + + &__left { + position: absolute; + width: 50px; + height: 100%; + z-index: 999; + border: none; + background-color: transparent; + color: #ffffff; + cursor: pointer; + + &:hover { + transition: 0.3s; + background-color: rgba(0 0 0 / 10%); + } + } + + &__right { + position: absolute; + width: 50px; + height: 100%; + z-index: 999; + border: none; + right: 0; + background-color: transparent; + color: #ffffff; + cursor: pointer; + + &:hover { + transition: 0.3s; + background-color: rgba(0 0 0 / 5%); + } + } + + &__container { + display: flex; + align-items: center; + top: 0; + left: 0; + height: 100%; + transition: 0.5s; + + &--photo { + width: 550px; + height: 362px; + object-fit: contain; + + @include media.media-breakpoint-down(mobile) { + width: 100vw; + } + } + } + + &__index { + width: 100%; + display: flex; + justify-content: center; + position: absolute; + bottom: 22px; + z-index: 999; + + &--dot { + width: 8px; + margin-right: 3px; + height: 8px; + background-color: #ffffff; + opacity: 0.8; + border: none; + border-radius: 10px; + cursor: pointer; + + &--active { + background-color: #ff7f23; + } + } + } +} + +.shop { + display: flex; + height: 90px; + flex-direction: column; + position: relative; + padding: 16px; + + &__title { + display: flex; + align-items: flex-end; + + &--name { + font-size: 28px; + font-weight: 700; + } + + &--category { + margin-left: 8px; + font-size: 14px; + font-weight: 400; + color: #666666; + } + } + + &__detail { + display: flex; + margin-top: 4px; + color: #666666; + position: sticky; + top: 0; + + &--rate { + display: flex; + align-items: center; + margin-right: 2px; + font-size: 16px; + font-weight: 500; + } + + &--latest { + font-weight: 400; + } + } + + &__bookmark { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + font-size: 14px; + font-weight: 400; + color: #666666; + right: 16px; + border: none; + background-color: transparent; + cursor: pointer; + } +} + +.comment { + width: 100%; + max-height: calc(100% - 116px); + padding-bottom: 30px; + display: flex; + flex-direction: column; + + &__empty { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-bottom: 70px; + + &--text { + white-space: pre-wrap; + text-align: center; + margin-top: 24px; + font-size: 16px; + font-weight: 400; + color: #666666; + } + } + + &__container { + display: flex; + align-items: flex-end; + margin-bottom: 8px; + } + + &__mode { + display: flex; + justify-content: space-around; + border-bottom: 1px solid #eeeeee; + box-shadow: 2px 3px 12px -4px rgba(0 0 0 / 10%); + } + + &__button { + font-size: 18px; + font-weight: 500; + border: none; + border-bottom: 1px solid #eeeeee; + color: #222222; + background-color: #ffffff; + padding-bottom: 12px; + cursor: pointer; + + &--active { + color: #ff7f23; + border-bottom: 1px solid #ff7f23; + } + } + + &__list { + margin: 0 16px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 0; + } + + &--switch { + display: flex; + margin-bottom: 16px; + align-items: center; + font-size: 14px; + font-weight: 600; + color: #666666; + border: none; + background-color: #ffffff; + cursor: pointer; + margin-top: 16px; + z-index: 999; + } + } + + &__item { + display: flex; + align-items: flex-start; + border-bottom: 0.5px solid #eeeeee; + + &--profile { + display: flex; + align-items: center; + justify-content: center; + width: 50px; + height: 50px; + border: 1px solid #eeeeee; + border-radius: 50px; + background-color: #ffffff; + overflow: hidden; + } + } + + &__info { + width: 100%; + margin-left: 16px; + display: flex; + flex-direction: column; + + &--nickname { + font-size: 18px; + font-weight: 700; + margin-right: 5px; + color: #222222; + } + + &--id { + font-size: 14px; + font-weight: 400; + color: #666666; + } + + &--content { + font-size: 18px; + font-weight: 400; + color: #222222; + } + + &--evaluation { + position: relative; + display: flex; + align-items: center; + font-size: 16px; + font-weight: 400; + color: #666666; + margin: 10px 0; + } + } + + &__report { + position: absolute; + right: 16px; + display: flex; + align-items: center; + font-size: 11px; + font-weight: 400; + color: #c4c4c4; + border: none; + background-color: #ffffff; + cursor: pointer; + } + + &__write { + position: absolute; + bottom: 40px; + transform: translateX(-50%); + left: 275px; + width: 157px; + height: 39px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 20px; + border: 1px solid #c4c4c4; + background-color: #ffffff; + box-shadow: 2px 3px 12px 1px rgba(0 0 0 / 10%); + text-decoration: none; + color: #666666; + font-size: 14px; + font-weight: 500; + cursor: pointer; + + &--icon { + margin-right: 8px; + } + } +} + +.disabled { + opacity: 0; +} diff --git a/src/pages/Pin/components/Carousel.tsx b/src/pages/Pin/components/Carousel.tsx new file mode 100644 index 00000000..6ddd4d6d --- /dev/null +++ b/src/pages/Pin/components/Carousel.tsx @@ -0,0 +1,62 @@ +import { useState, useRef, useEffect } from 'react'; +import cn from 'utils/ts/classNames'; +import useMediaQuery from 'utils/hooks/useMediaQuery'; +import styles from '../Pin.module.scss'; + +interface Props { + images:string[]; +} + +export default function Carousel({ images }:Props) { + const { isMobile } = useMediaQuery(); + const [count, setCount] = useState(0); + + const carouselRef = useRef(null); + const rightClick = () => { + setCount(count + 1 < images.length + ? count + 1 : images.length - 1); + }; + + useEffect(() => { + if (carouselRef.current && !isMobile) { + carouselRef.current.style.transform = `translateX(${(count) * -550}px)`; + } + + if (carouselRef.current && isMobile) { + carouselRef.current.style.transform = `translateX(${(count) * -window.innerWidth}px)`; + } + }, [count, isMobile]); + + return ( +
+ +
+ {images && images.map((item:string, index:number) => ( +
+ +
+ ))} +
+
+ {images && images.map((item:string, index:number) => ( + + ))} +
+
+ +
+ ); +} diff --git a/src/pages/Pin/components/Scrap.tsx b/src/pages/Pin/components/Scrap.tsx new file mode 100644 index 00000000..09e4de65 --- /dev/null +++ b/src/pages/Pin/components/Scrap.tsx @@ -0,0 +1,47 @@ +import { ReactComponent as BookMark } from 'assets/svg/pin/bookmark.svg'; +import { ReactComponent as BookMarkActivated } from 'assets/svg/pin/bookmark_activated.svg'; +import { useQuery, useQueryClient } from 'react-query'; +import { getScrap, deleteScrap, postScrap } from 'api/scrap'; +import { useEffect, useState } from 'react'; +import styles from '../Pin.module.scss'; + +interface Props { + placeId:string; +} +export default function Scrap({ placeId }:Props) { + const queryClient = useQueryClient(); + const scrapList = useQuery('scrapList', () => getScrap()); + const [scrapId, setScrapId] = useState(-1); + + useEffect(() => { + if (scrapList.status === 'success') { + setScrapId(scrapList.data.content.find((v) => v.placeId === placeId)?.scrapId || -1); + } + }, [placeId, scrapList.data, scrapList.status]); + + const handleScrap = async () => { + if (scrapId === -1) { + postScrap({ directoryId: 0, placeId }) + .then((response) => { + setScrapId(response.id); + queryClient.removeQueries('scrapList'); + }); + } else { + await deleteScrap(scrapId); + queryClient.removeQueries('scrapList'); + setScrapId(-1); + } + }; + return ( + + ); +} diff --git a/src/pages/Pin/hooks/usePinQueries.ts b/src/pages/Pin/hooks/usePinQueries.ts new file mode 100644 index 00000000..a2cd19bd --- /dev/null +++ b/src/pages/Pin/hooks/usePinQueries.ts @@ -0,0 +1,39 @@ +import { + getFollowersReview, getMyReview, latestFollowerReview, latestMyReview, +} from 'api/review'; +import { fetchShop } from 'api/shop'; +import { useEffect, useState } from 'react'; +import { useQueries } from 'react-query'; + +interface QueryParam { + placeId:string, + sortType:string +} +const usePinQueries = ({ placeId, sortType }:QueryParam) => { + const [rateValue, setRateValue] = useState ('0'); + const queries = useQueries([ + { queryKey: ['pinInfo', placeId], queryFn: async () => fetchShop(placeId) }, + { queryKey: ['latestMy', placeId], queryFn: async () => latestMyReview(placeId) }, + { queryKey: ['latestFollower', placeId], queryFn: async () => latestFollowerReview(placeId) }, + { queryKey: ['myReview', placeId, sortType], queryFn: async () => getMyReview({ placeId, sort: sortType }) }, + { queryKey: ['followerReview', placeId, sortType], queryFn: async () => getFollowersReview({ placeId, sort: sortType }) }, + ]); + + useEffect(() => { + if (queries[0].data?.totalRating + && queries[0].data?.ratingCount) { + setRateValue(Math.floor((queries[0].data?.totalRating || 0) + / (queries[0].data?.ratingCount || 1)).toFixed(1)); + } + }, [queries]); + return { + pinInfo: queries[0], + latestMyReview: queries[1], + latestFollowerReview: queries[2], + myReviews: queries[3], + followersReviews: queries[4], + rateValue, + }; +}; + +export default usePinQueries; diff --git a/src/pages/Pin/index.tsx b/src/pages/Pin/index.tsx new file mode 100644 index 00000000..c12fb5a7 --- /dev/null +++ b/src/pages/Pin/index.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import { ReactComponent as Star } from 'assets/svg/pin/star.svg'; +import { ReactComponent as Switch } from 'assets/svg/pin/switch-horizontal.svg'; +import { ReactComponent as Report } from 'assets/svg/pin/report.svg'; +import { ReactComponent as Pencil } from 'assets/svg/pin/pencil.svg'; +import { ReactComponent as Empty } from 'assets/svg/pin/empty.svg'; +import { ReactComponent as FollowersEmpty } from 'assets/svg/pin/follower_empty.svg'; +import cn from 'utils/ts/classNames'; +import { Link } from 'react-router-dom'; +import styles from './Pin.module.scss'; +import Carousel from './components/Carousel'; +import Scrap from './components/Scrap'; +import usePinQueries from './hooks/usePinQueries'; + +interface Props { + placeId:string; +} + +export default function Pin({ placeId }:Props) { + const [sortType, setSortType] = useState('createdAt'); + const [mode, setMode] = useState(1); + const { + pinInfo, latestMyReview, latestFollowerReview, + myReviews, followersReviews, rateValue, + } = usePinQueries({ placeId, sortType }); + + const handleSortButton = () => { + if (sortType === 'createdAt') setSortType('rate'); + else setSortType('createdAt'); + }; + + return ( +
+
+ +
+
+ + {pinInfo.data?.name} + + {pinInfo.data?.category} +
+
+
+ + {rateValue} +
+
+ 마지막 리뷰 + {' '} + {(latestMyReview.data?.lastDate?.replaceAll('-', '/') || '') + > (latestFollowerReview.data?.lastDate?.replaceAll('-', '/') || '') + ? latestMyReview.data?.lastDate?.replaceAll('-', '/') + : latestFollowerReview.data?.lastDate?.replaceAll('-', '/')} +
+
+ +
+
+
+ + +
+
+ + {mode === 1 && (myReviews.data?.content.length !== 0 + ? myReviews.data?.content.map((item) => ( +
+
+
+
+ {item.userReviewResponse.nickname} +
+
+ {item.userReviewResponse.account} +
+
+
+ {item.content} +
+
+ {`${item.createdAt.slice(3).replaceAll('-', '/')}|`} + + {item.rate.toFixed(1)} +
+
+
+ )) : ( +
+ +
+ 오늘 다녀오셨나요? + {'\n'} + 리뷰를 한번 작성해보아요! +
+
+ ))} + {mode === 2 && (followersReviews.data?.content.length !== 0 + ? followersReviews.data?.content.map((item) => ( +
+
프로필
+
+
+
+ {item.userReviewResponse.nickname} +
+
+ {item.userReviewResponse.account} +
+
+
+ {item.content} +
+
+ {`${item.createdAt.slice(3).replaceAll('-', '/')}|`} + + {item.rate.toFixed(1)} + +
+
+
+ )) : ( +
+
+ 작성한 리뷰가 없어요. +
+ +
+ ))} +
+ + + 리뷰 작성하기 + +
+
+
+ ); +} diff --git a/src/pages/Search/components/SearchBar/SearchInput.tsx b/src/pages/Search/components/SearchBar/SearchInput.tsx index d216d8dc..4c2dbd2a 100644 --- a/src/pages/Search/components/SearchBar/SearchInput.tsx +++ b/src/pages/Search/components/SearchBar/SearchInput.tsx @@ -5,13 +5,11 @@ import { useNavigate } from 'react-router-dom'; // import RelatedSearches from '../RelatedSearches'; interface Props { - text: string, - onChange: (e: React.ChangeEvent) => void + text: string; + onChange: (e: React.ChangeEvent) => void; onSubmit: (event: React.FormEvent) => void; } -export default function SearchInput({ - text, onChange, onSubmit, -}: Props) { +export default function SearchInput({ text, onChange, onSubmit }: Props) { const { isFetching, data: shops, refetch } = useFetchShops(text ?? ''); const navigate = useNavigate(); @@ -36,7 +34,11 @@ export default function SearchInput({ }; return ( -