diff --git a/.eslintrc.json b/.eslintrc.json index 05eaf54b..406073bf 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -22,6 +22,8 @@ "linebreak-style": "off", "react/jsx-props-no-spreading": "off", "react/require-default-props": "off", + "react/jsx-one-expression-per-line": "off", + "implicit-arrow-linebreak": "off", "no-restricted-imports": [ "error", { diff --git a/src/App.tsx b/src/App.tsx index cf024e1d..b4955d8e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import GoogleLogin from 'pages/Auth/OAuth/GoogleLogin'; import MyPage from 'pages/MyPage'; import NotFoundPage from 'pages/Search/components/NotFoundPage'; import FollowProfile from 'pages/Follow/components/FollowProfile'; +import ShopDetail from 'pages/ShopDetail'; export default function App(): JSX.Element { return ( @@ -46,6 +47,7 @@ export default function App(): JSX.Element { } /> } /> + } /> } /> } /> } /> diff --git a/src/api/review/entity.ts b/src/api/review/entity.ts index b6ba81f8..8b6eb4fe 100644 --- a/src/api/review/entity.ts +++ b/src/api/review/entity.ts @@ -1,6 +1,56 @@ export interface ReviewParams { - placeId : string; - content : string; - rate : number; - reviewImages : File[]; + placeId: string; + content: string; + rate: number; + reviewImages: File[]; +} + +export interface ShopReviewsResponse { + content: { + content: string; + createdAt: string; + id: number; + rate: number; + reviewImages: { + imageUrl: string; + originalName: string; + }[]; + shopPlaceId: string; + userReviewResponse: { + account: string; + id: number; + nickname: string; + profileImage: { + id: number; + originalName: string; + path: string; + url: string; + }; + }; + }[]; + empty: boolean; + first: boolean; + last: boolean; + number: number; + numberOfElements: number; + pageable: { + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + unpaged: boolean; + }; + size: number; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + totalElements: number; + totalPages: number; } diff --git a/src/api/review/index.ts b/src/api/review/index.ts index 4f0acf99..6a1f2ec0 100644 --- a/src/api/review/index.ts +++ b/src/api/review/index.ts @@ -1,5 +1,5 @@ -import { ReviewParams } from './entity'; import reviewApi from './reviewApiClient'; +import type { ReviewParams, ShopReviewsResponse } from './entity'; export const postReview = (params: ReviewParams) => { const formData = new FormData(); @@ -17,3 +17,7 @@ export const postReview = (params: ReviewParams) => { }; export const deleteReview = (reviewId: string) => reviewApi.delete(`/${reviewId}`); + +export const fetchFollowerReview = (placeId: string) => reviewApi.get(`/followers/shop/${placeId}`); + +export const fetchMyReview = (placeId: string) => reviewApi.get(`/shop/${placeId}`); diff --git a/src/api/shop/entity.ts b/src/api/shop/entity.ts index 325193da..0cec4722 100644 --- a/src/api/shop/entity.ts +++ b/src/api/shop/entity.ts @@ -17,11 +17,11 @@ export interface FilterShopsResponse { export type FilterShopsListResponse = FilterShopsResponse[]; export interface FetchTrendingsResponse { - trendings: string[] + trendings: string[]; } export interface SearchQueryParams { - searchText : string; + searchText: string; } export interface ShopsParams { @@ -43,10 +43,33 @@ export interface Shop { formattedAddress: string; lat: number; lng: number; - openNow: boolean + openNow: boolean; totalRating: number | null; ratingCount: number | null; photoToken: string; dist: number; + category: string; // 추후 카테고리 확인 필요 +} + +type Period = { + close: { day: number; time: number }; + open: { day: number; time: number }; +} | null; + +export interface FetchShopResponse { + shopId: number; + placeId: string; + name: string; + formattedAddress: string; + lat: number; + lng: number; + formattedPhoneNumber: string; + openNow: boolean; + totalRating: number; + ratingCount: number; category: string; + todayPeriod: [number, number]; + periods: Period[]; + scrap: number | null; + photos: string[]; } diff --git a/src/api/shop/index.ts b/src/api/shop/index.ts index b1bc12f7..afc61db0 100644 --- a/src/api/shop/index.ts +++ b/src/api/shop/index.ts @@ -1,14 +1,14 @@ 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 = (shopId: string) => shopApi.get(`/shops/${shopId}`); -export const getfilterShops = (params: FilterShopsParams, location: Coords) => { +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}`; const requestBody = { lat: location.lat, diff --git a/src/assets/svg/shop/book-mark.svg b/src/assets/svg/shop/book-mark.svg new file mode 100644 index 00000000..1c043dff --- /dev/null +++ b/src/assets/svg/shop/book-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/shop/info.svg b/src/assets/svg/shop/info.svg new file mode 100644 index 00000000..8e18f582 --- /dev/null +++ b/src/assets/svg/shop/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/shop/next-arrow.svg b/src/assets/svg/shop/next-arrow.svg new file mode 100644 index 00000000..eaf43d05 --- /dev/null +++ b/src/assets/svg/shop/next-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/shop/not-found.svg b/src/assets/svg/shop/not-found.svg new file mode 100644 index 00000000..e6b2be4b --- /dev/null +++ b/src/assets/svg/shop/not-found.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/svg/shop/prev-arrow.svg b/src/assets/svg/shop/prev-arrow.svg new file mode 100644 index 00000000..7871fb8e --- /dev/null +++ b/src/assets/svg/shop/prev-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/StarRating/StarRating.module.scss b/src/components/StarRating/StarRating.module.scss index 92acc4c5..77cfe3ba 100644 --- a/src/components/StarRating/StarRating.module.scss +++ b/src/components/StarRating/StarRating.module.scss @@ -3,7 +3,6 @@ align-items: center; width: 160px; height: 32px; - margin-bottom: 16px; } .wrapper { diff --git a/src/components/StarRating/StarRatingPreview.tsx b/src/components/StarRating/StarRatingPreview.tsx new file mode 100644 index 00000000..1b5c3803 --- /dev/null +++ b/src/components/StarRating/StarRatingPreview.tsx @@ -0,0 +1,23 @@ +import { ReactComponent as Star } from 'assets/svg/post/star.svg'; +import styles from './StarRating.module.scss'; + +interface Props { + rate: number; +} + +function StarRatingPreview({ rate }: Props) { + return ( +
+ {[1, 2, 3, 4, 5].map((num) => ( + = num ? '#ff7f23' : '#eee'} + /> + ))} +
+ ); +} + +export default StarRatingPreview; diff --git a/src/components/editor/TextEditor/TextEditor.module.scss b/src/components/editor/TextEditor/TextEditor.module.scss index d597522c..103434c4 100644 --- a/src/components/editor/TextEditor/TextEditor.module.scss +++ b/src/components/editor/TextEditor/TextEditor.module.scss @@ -91,6 +91,7 @@ } &__contents { + padding-bottom: 16px; border-bottom: 0.5px solid #c4c4c4; } diff --git a/src/pages/Auth/Login/index.tsx b/src/pages/Auth/Login/index.tsx index dc8243a6..99e44939 100644 --- a/src/pages/Auth/Login/index.tsx +++ b/src/pages/Auth/Login/index.tsx @@ -81,8 +81,8 @@ export default function Login(): JSX.Element { }, }); - const [errorMsg, setErroMsg] = useState(''); - const submitLogin = useLoginRequest({ onError: setErroMsg }); + const [errorMsg, setErrorMsg] = useState(''); + const submitLogin = useLoginRequest({ onError: setErrorMsg }); return (
diff --git a/src/pages/Home/components/Map/components/MarkerHtml/index.tsx b/src/pages/Home/components/Map/components/MarkerHtml/index.tsx index 70c2c9f1..ab2140a6 100644 --- a/src/pages/Home/components/Map/components/MarkerHtml/index.tsx +++ b/src/pages/Home/components/Map/components/MarkerHtml/index.tsx @@ -19,19 +19,21 @@ export function MarkerHtml(src:string | null, name:string) { `; } -export function ClickedMarkerHtml(src:string, name:string) { +export function ClickedMarkerHtml(src:string, name:string, id: string) { return ` -
- -
-
- 사진 -
- + `; } diff --git a/src/pages/Home/components/Map/hooks/useFilterShops.ts b/src/pages/Home/components/Map/hooks/useFilterShops.ts index 3cfb931b..50721685 100644 --- a/src/pages/Home/components/Map/hooks/useFilterShops.ts +++ b/src/pages/Home/components/Map/hooks/useFilterShops.ts @@ -1,6 +1,6 @@ import useGeolocation from 'utils/hooks/useGeolocation'; import { useQuery } from 'react-query'; -import { getfilterShops } from 'api/shop'; +import { getFilterShops } from 'api/shop'; import { FilterShopsParams } from 'api/shop/entity'; import { useAuth } from 'store/auth'; @@ -19,7 +19,7 @@ const useFilterShops = ({ }; const { isLoading, isError, data, refetch, - } = useQuery('filterShops', () => getfilterShops(params, { + } = useQuery('filterShops', () => getFilterShops(params, { lat: location?.lat, lng: location?.lng, }), { diff --git a/src/pages/Home/components/Map/hooks/useMarker.ts b/src/pages/Home/components/Map/hooks/useMarker.ts index 63cf71d1..a8051db6 100644 --- a/src/pages/Home/components/Map/hooks/useMarker.ts +++ b/src/pages/Home/components/Map/hooks/useMarker.ts @@ -41,7 +41,7 @@ function useMarker({ map, filterShops }: MarkerProps) { }); marker.setIcon({ - content: ClickedMarkerHtml(shop.photo, shop.name), + content: ClickedMarkerHtml(shop.photo, shop.name, shop.placeId), }); setSelected(marker); if (map) { diff --git a/src/pages/ShopDetail/ShopDetail.module.scss b/src/pages/ShopDetail/ShopDetail.module.scss new file mode 100644 index 00000000..cfc55aed --- /dev/null +++ b/src/pages/ShopDetail/ShopDetail.module.scss @@ -0,0 +1,119 @@ +.header { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 80px; + z-index: 10; + background: #fff; +} + +.container { + width: 100%; + height: 100%; + position: absolute; + top: 80px; +} + +.line-divisor { + width: 1px; + height: 12px; + background: #c4c4c4; +} + +.shop-detail { + display: grid; + grid-template-areas: + "a a" + "b c" + "d ."; + grid-template-columns: 1fr 1fr; + gap: 18px; + width: 972px; + margin: 52px auto; + padding-bottom: 128px; +} + +.detail-main { + display: flex; + flex-direction: column; + grid-area: a; + + & > div:first-child { + padding-bottom: 16px; + border-bottom: 1px solid #c4c4c4; + } + + & > div:nth-child(2) { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 16px; + } + + & > div:last-child { + display: flex; + align-items: center; + color: #666; + gap: 4px; + margin: 12px 0; + font-size: 14px; + } + + &__rating { + display: flex; + gap: 8px; + align-items: flex-end; + + & > span { + color: #c4c4c4; + font-size: 18px; + } + } + + &__name { + display: flex; + align-items: center; + justify-content: space-between; + + & > h1 { + font-size: 36px; + font-weight: normal; + } + + & > button { + display: flex; + gap: 2px; + align-items: center; + justify-content: center; + background: #f8f8f8; + padding: 16px 24px; + border-radius: 4px; + font-size: 16px; + color: #666; + } + } + + &__info-name { + font-weight: 700; + color: #ff7f23; + } + + &__info { + display: flex; + gap: 8px; + align-items: center; + + & > span:first-child { + font-weight: 500; + } + } + + &__report { + display: flex; + gap: 4px; + color: #666; + font-size: 14px; + margin: 12px 0; + } +} diff --git a/src/pages/ShopDetail/components/ImageCarousel/ImageCarousel.module.scss b/src/pages/ShopDetail/components/ImageCarousel/ImageCarousel.module.scss new file mode 100644 index 00000000..f3594041 --- /dev/null +++ b/src/pages/ShopDetail/components/ImageCarousel/ImageCarousel.module.scss @@ -0,0 +1,64 @@ +img { + object-fit: cover; + width: 360px; + height: 400px; +} + +button { + background: none; + border: none; + cursor: pointer; +} + +.container { + width: 100vw; + min-width: 1196px; + position: relative; + overflow-x: hidden; +} + +.image-carousel { + padding-left: max(calc((100vw - 1080px) / 2), 68px); + max-width: 100vw; + width: 100vw; + display: flex; + flex-flow: nowrap; + justify-content: flex-start; + overflow-x: visible; + align-items: center; + transition: all 0.4s ease; + + & > li { + width: fit-content; + list-style: none; + } +} + +.back-drop { + &__left { + left: 0; + display: flex; + align-items: center; + justify-content: flex-end; + top: 0; + position: absolute; + background: #000; + opacity: 0.7; + width: calc((100vw - 1080px) / 2); + min-width: 68px; + height: 400px; + } + + &__right { + right: 0; + display: flex; + align-items: center; + top: 0; + position: absolute; + background: #000; + opacity: 0.7; + width: calc((100vw - 1080px) / 2); + min-width: 68px; + height: 400px; + } +} diff --git a/src/pages/ShopDetail/components/ImageCarousel/index.tsx b/src/pages/ShopDetail/components/ImageCarousel/index.tsx new file mode 100644 index 00000000..f2d7f873 --- /dev/null +++ b/src/pages/ShopDetail/components/ImageCarousel/index.tsx @@ -0,0 +1,52 @@ +import useCounter from 'utils/hooks/useCounter'; +import { ReactComponent as PrevIcon } from 'assets/svg/shop/prev-arrow.svg'; +import { ReactComponent as NextIcon } from 'assets/svg/shop/next-arrow.svg'; +import styles from './ImageCarousel.module.scss'; + +interface Props { + imageUrls: string[]; +} + +function ImageCarousel({ imageUrls }: Props) { + const { count: currentIndex, increment, decrement } = useCounter(0); + + const next = () => { + if (currentIndex === imageUrls.length - 3) return; + + increment(); + }; + + const prev = () => { + if (currentIndex === 0) return; + + decrement(); + }; + + return ( +
+
    + {imageUrls.map((url, index) => ( +
  • + {`${index}번 +
  • + ))} +
+ +
+ +
+
+ +
+
+ ); +} + +export default ImageCarousel; diff --git a/src/pages/ShopDetail/components/Map/Map.module.scss b/src/pages/ShopDetail/components/Map/Map.module.scss new file mode 100644 index 00000000..f0cb6c67 --- /dev/null +++ b/src/pages/ShopDetail/components/Map/Map.module.scss @@ -0,0 +1,11 @@ +.container { + display: flex; + width: 100%; + flex-direction: column; + gap: 32px; +} + +.map { + width: 486px; + height: 320px; +} diff --git a/src/pages/ShopDetail/components/Map/index.tsx b/src/pages/ShopDetail/components/Map/index.tsx new file mode 100644 index 00000000..8634a62e --- /dev/null +++ b/src/pages/ShopDetail/components/Map/index.tsx @@ -0,0 +1,26 @@ +import useNaverMap from 'pages/Home/components/Map/hooks/useNaverMap'; +import SectionHeader from '../SectionHeader'; +import styles from './Map.module.scss'; + +interface Props { + formattedAddress: string; + latitude: number; + longitude: number; +} + +function Map({ formattedAddress, latitude, longitude }: Props) { + useNaverMap(latitude, longitude); + + return ( +
+ {} }} + /> +
+
+ ); +} + +export default Map; diff --git a/src/pages/ShopDetail/components/NotFoundDescription/NotFoundDescription.module.scss b/src/pages/ShopDetail/components/NotFoundDescription/NotFoundDescription.module.scss new file mode 100644 index 00000000..19d9a065 --- /dev/null +++ b/src/pages/ShopDetail/components/NotFoundDescription/NotFoundDescription.module.scss @@ -0,0 +1,13 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + margin: auto 0; + + & > div { + font-size: 14px; + color: #222; + } +} diff --git a/src/pages/ShopDetail/components/NotFoundDescription/index.tsx b/src/pages/ShopDetail/components/NotFoundDescription/index.tsx new file mode 100644 index 00000000..28da55ad --- /dev/null +++ b/src/pages/ShopDetail/components/NotFoundDescription/index.tsx @@ -0,0 +1,14 @@ +import { ReactComponent as NotFoundIcon } from 'assets/svg/shop/not-found.svg'; +import styles from './NotFoundDescription.module.scss'; + +function NotFoundDescription() { + return ( +
+ +
아직 등록된 리뷰가 없습니다.
+
친구에게 추천해볼까요?
+
+ ); +} + +export default NotFoundDescription; diff --git a/src/pages/ShopDetail/components/ReviewList/FriendReviewList.tsx b/src/pages/ShopDetail/components/ReviewList/FriendReviewList.tsx new file mode 100644 index 00000000..1012d0f7 --- /dev/null +++ b/src/pages/ShopDetail/components/ReviewList/FriendReviewList.tsx @@ -0,0 +1,56 @@ +import { fetchFollowerReview } from 'api/review'; +import { useQuery } from 'react-query'; +import styles from './ReviewList.module.scss'; +import SectionHeader from '../SectionHeader'; +import NotFoundDescription from '../NotFoundDescription'; +// import mockReviews from './mock'; + +interface Props { + placeId: string; +} + +function FriendReviewList({ placeId }: Props) { + const { data } = useQuery('followerReviews', () => fetchFollowerReview(placeId)); + + if (data) { + const { content, totalElements, size } = data.data; + + return ( +
+ size ? { content: '전체보기', onClick: () => {} } : undefined} + /> + + {content.length ? ( +
    + {content.slice(0, size).map(({ id, content: reviewContent, userReviewResponse }) => ( +
  • + {`${userReviewResponse.nickname}의 +
    +

    {userReviewResponse.nickname}

    +
    {reviewContent}
    +
    +
  • + ))} +
+ ) : ( + + )} +
+ ); + } + + return ( +
+ + +
+ ); +} + +export default FriendReviewList; diff --git a/src/pages/ShopDetail/components/ReviewList/MyReviewList.tsx b/src/pages/ShopDetail/components/ReviewList/MyReviewList.tsx new file mode 100644 index 00000000..f65ea32c --- /dev/null +++ b/src/pages/ShopDetail/components/ReviewList/MyReviewList.tsx @@ -0,0 +1,58 @@ +import { fetchMyReview } from 'api/review'; +import { useQuery } from 'react-query'; +import formatISODate from 'utils/ts/formatISODate'; +import styles from './ReviewList.module.scss'; +import SectionHeader from '../SectionHeader'; +import NotFoundDescription from '../NotFoundDescription'; +// import mockReviews from './mock'; + +interface Props { + placeId: string; +} + +function MyReviewList({ placeId }: Props) { + const { data } = useQuery('myReviews', () => fetchMyReview(placeId)); + + if (data) { + const { content } = data.data; + + return ( +
+ + {content.length ? ( +
    + {content.map(({ id, content: reviewContent, userReviewResponse }) => ( +
  • + {`${userReviewResponse.nickname}의 +
    +
    {reviewContent}
    +
    +
  • + ))} +
+ ) : ( + + )} +
+ ); + } + + return ( +
+ + +
+ ); +} + +export default MyReviewList; diff --git a/src/pages/ShopDetail/components/ReviewList/ReviewList.module.scss b/src/pages/ShopDetail/components/ReviewList/ReviewList.module.scss new file mode 100644 index 00000000..4ddac768 --- /dev/null +++ b/src/pages/ShopDetail/components/ReviewList/ReviewList.module.scss @@ -0,0 +1,56 @@ +.review-list { + display: flex; + flex-direction: column; + gap: 32px; + width: 100%; + + &__main { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + align-items: flex-end; + list-style: none; + + & > li { + padding-left: 32px; + width: calc(100% - 32px); + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + + & > img { + width: 46px; + height: 46px; + min-width: 46px; + min-height: 46px; + border-radius: 100%; + } + } + + &--follower-content { + width: 100%; + display: flex; + flex-direction: column; + padding: 12px; + background: #f9f9f9; + border-radius: 4px; + gap: 4px; + + & > h3 { + font-weight: normal; + font-size: 18px; + } + } + + &--my-content { + width: 100%; + display: flex; + padding: 12px; + background: #fff; + border-radius: 4px; + border: 1px solid #c4c4c4; + } + } +} diff --git a/src/pages/ShopDetail/components/ReviewList/mock.ts b/src/pages/ShopDetail/components/ReviewList/mock.ts new file mode 100644 index 00000000..89c41cab --- /dev/null +++ b/src/pages/ShopDetail/components/ReviewList/mock.ts @@ -0,0 +1,67 @@ +const reviews = [ + { + content: + '맛있어요! 다음에 또 오고 싶어요~~~!맛있어요! 다음에 또 오고 싶어요~~~!맛있어요! 다음에 또 오고 싶어요~~~!맛있어요! 다음에 또 오고 싶어요~~~!맛있어요! 다음에 또 오고 싶어요~~~!맛있어요! 다음에 또 오고 싶어요~~~!맛있어요! 다음에 또 오고 싶어요~~~!', + createdAt: '2023-12-23T06:41:43.493Z', + id: 1, + rate: 4.0, + reviewImages: [], + shopPlaceId: '', + userReviewResponse: { + account: '모름', + id: 1, + nickname: '쩝쩝이', + profileImage: { + id: 1, + originalName: '김민재', + path: '모름', + url: 'https://fastly.picsum.photos/id/104/200/200.jpg?hmac=3XxEVXVjwoI45-6sum_iMwNZ52GT-SJacVWr4fh4hqI', + }, + }, + }, + { + content: '전 여기 별로였어요. 아니 왜 이렇게 맛이 없지 우에엑~', + createdAt: '2023-12-24T06:41:43.493Z', + id: 2, + rate: 3.0, + reviewImages: [], + shopPlaceId: '', + userReviewResponse: { + account: '모름', + id: 2, + nickname: '짭짭이', + profileImage: null, + }, + }, +]; + +const mockReviews = { + content: [...reviews, ...reviews], + pageable: { + sort: { + empty: false, + sorted: true, + unsorted: false, + }, + offset: 0, + pageNumber: 0, + pageSize: 3, + paged: true, + unpaged: false, + }, + last: true, + totalPages: 2, + totalElements: 4, + size: 3, + number: 0, + sort: { + empty: false, + sorted: true, + unsorted: false, + }, + first: true, + numberOfElements: 0, + empty: true, +}; + +export default mockReviews; diff --git a/src/pages/ShopDetail/components/SectionHeader/SectionHeader.module.scss b/src/pages/ShopDetail/components/SectionHeader/SectionHeader.module.scss new file mode 100644 index 00000000..0ba04343 --- /dev/null +++ b/src/pages/ShopDetail/components/SectionHeader/SectionHeader.module.scss @@ -0,0 +1,24 @@ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + + &__title { + font-size: 24px; + font-weight: normal; + } + + &__description { + color: #979797; + font-size: 18px; + } + + &__button { + color: #666; + font-size: 14px; + padding: 8px 16px; + border: none; + border-radius: 4px; + background: #f9f9f9; + } +} diff --git a/src/pages/ShopDetail/components/SectionHeader/index.tsx b/src/pages/ShopDetail/components/SectionHeader/index.tsx new file mode 100644 index 00000000..a1f8da08 --- /dev/null +++ b/src/pages/ShopDetail/components/SectionHeader/index.tsx @@ -0,0 +1,28 @@ +import styles from './SectionHeader.module.scss'; + +interface Props { + title: string; + description: string; + button?: { + content: string; + onClick: React.ReactEventHandler; + }; +} + +function SectionHeader({ title, description, button }: Props) { + return ( +
+
+

{title}

+
{description}
+
+ {button && ( + + )} +
+ ); +} + +export default SectionHeader; diff --git a/src/pages/ShopDetail/index.tsx b/src/pages/ShopDetail/index.tsx new file mode 100644 index 00000000..9ee369c1 --- /dev/null +++ b/src/pages/ShopDetail/index.tsx @@ -0,0 +1,108 @@ +import { useParams } from 'react-router-dom'; +import { useQuery } from 'react-query'; +import AuthTopNavigation from 'components/Auth/AuthTopNavigation'; +import { fetchShop } from 'api/shop'; +import StarRatingPreview from 'components/StarRating/StarRatingPreview'; +import { ReactComponent as BookMarkIcon } from 'assets/svg/shop/book-mark.svg'; +import { ReactComponent as InfoIcon } from 'assets/svg/shop/info.svg'; +import styles from './ShopDetail.module.scss'; +import ImageCarousel from './components/ImageCarousel'; +import FriendReviewList from './components/ReviewList/FriendReviewList'; +import MyReviewList from './components/ReviewList/MyReviewList'; +import Map from './components/Map'; +// import mock from './mock'; + +const formatPeriod = (period: [number, number]) => + period.map((time) => `${time.toString().slice(0, 2)}:${time.toString().slice(2)}`).join('~'); + +function ShopDetail() { + const { placeId } = useParams(); + + const { data } = useQuery('shopDetail', () => fetchShop(placeId as string)); + + if (data) { + const { + // shopId, + // placeId, + // periods, + // scrap, + // openNow, + // category, + name, + formattedAddress, + lat, + lng, + formattedPhoneNumber, + totalRating, + todayPeriod, + photos, + } = data.data; + + return ( + <> +
+ +
+ +
+ + +
+
+
+
+ + {/* totalRating이 -1인 경우는 아예 별점이 없는 경우를 의미합니다. */} + {totalRating === -1 ? '0.0' : totalRating.toFixed(1)} +
+
+

{name}

+ +
+
+ +
+
기본 정보
+
+ 영업시간 +
+ {formatPeriod(todayPeriod)} +
+
+ 전화번호 +
+ {formattedPhoneNumber} +
+
+ 주소 +
+ {formattedAddress} +
+
+ +
+ + + + +
+
+ + ); + } + + return ( + <> + +
스켈레톤
+ + ); +} + +export default ShopDetail; diff --git a/src/pages/ShopDetail/mock.ts b/src/pages/ShopDetail/mock.ts new file mode 100644 index 00000000..20550e23 --- /dev/null +++ b/src/pages/ShopDetail/mock.ts @@ -0,0 +1,92 @@ +const mockShopDetail = { + shopId: 44, + placeId: 'ChIJlT9G2NChfDURJ76xQh0V3XE', + name: '그릴640', + formattedAddress: '대한민국 서울특별시 강남구 역삼동 735-25', + lat: 37.4986651, + lng: 127.0340201, + formattedPhoneNumber: '02-554-0640', + openNow: true, + totalRating: -1, + ratingCount: 1, + category: 'restaurant', + todayPeriod: [1130, 2230], + periods: [ + null, + { + open: { + day: 1, + time: 1130, + }, + close: { + day: 1, + time: 2230, + }, + }, + { + open: { + day: 2, + time: 1130, + }, + close: { + day: 2, + time: 2230, + }, + }, + { + open: { + day: 3, + time: 1130, + }, + close: { + day: 3, + time: 2230, + }, + }, + { + open: { + day: 4, + time: 1130, + }, + close: { + day: 4, + time: 2230, + }, + }, + { + open: { + day: 5, + time: 1130, + }, + close: { + day: 5, + time: 2230, + }, + }, + { + open: { + day: 6, + time: 1730, + }, + close: { + day: 6, + time: 2200, + }, + }, + ], + scrap: null, + photos: [ + 'https://maps.googleapis.com/maps/api/place/photo?photo_reference=AcJnMuEUuJjFebTP_6i_dX5FFK22fx5obHOdpJshw6cWqoEFbnORl5tJf8PNQBGv0rwIceXmvHyQeyAUpB_V5NWONyiT1xLi2oj4mVlQ6xBw0JCVD3HXYIaax5Vr5S-X8hWrkVgQtXvUbSqBRsuF-rKnhaHujFuYvGm-bE742SNQ40CAbkVa&key=AIzaSyDxxUkBkujFvAJbrpndyIcyNMY2Nie5w28&maxwidth=400&maxheight=400', + 'https://maps.googleapis.com/maps/api/place/photo?photo_reference=AcJnMuEJAm119UE9O3UthoqqpY91DxMjLXjUT3BnjiqSPUs48NFSIi1NTy2XR-CiYhejBqKGUlr23mO0YaCpukfn5fH9gHLBnSSQCRzW2S2jA7xGU_e_76BcJR2m1pUWftfW2C19nnxlX7B_dp5U2RgKpzIkt7ecvtSUf3JaYdHNQwWE2HUD&key=AIzaSyDxxUkBkujFvAJbrpndyIcyNMY2Nie5w28&maxwidth=400&maxheight=400', + 'https://maps.googleapis.com/maps/api/place/photo?photo_reference=AcJnMuHI4wyh_QlDbBwEsQXICxo8QIKNJA_1RXcbwfN1ahxygyPgOJDS1rYem2wKWLcwWUgiVKrpQUGVHpt3daVykQ8VGC5Hj57TFB95YKM8Se8bSjYMeqtPI_HxtVtMeP4SxSH7CV83Knl_fGOd3gUuYp2asaoNVaejjNMxKPMXQffocVTb&key=AIzaSyDxxUkBkujFvAJbrpndyIcyNMY2Nie5w28&maxwidth=400&maxheight=400', + 'https://maps.googleapis.com/maps/api/place/photo?photo_reference=AcJnMuGt5DiRTUpJ4-6PsFX1pIfFkIJMkkqPM7ujl7ddHE2t3ye4qiCkGswpdDBB2Wr29if0e8hyyPGX38jQGBiVIFi1uRIYH4VJs8bgxdDpDUNgeaJhe03NPhiaBARQP0Nf2NZDBpCl259aZ1EDs5jyOFpn6Zw_vtCbPQ_Y--GpC1s804nn&key=AIzaSyDxxUkBkujFvAJbrpndyIcyNMY2Nie5w28&maxwidth=400&maxheight=400', + 'https://maps.googleapis.com/maps/api/place/photo?photo_reference=AcJnMuHGL2PPVRD8gn3Y0EBaUZbyCIFzPQ61Ou450IJv1uq-pJ-80ocYgIcuMzUe5sCWiU08BC3fQ3BDY5w_NgdvTvJ3WoSRlNJFb06rZYYmh-dUm9yR_rzwj3Jrailztu9ETCcQsC_s4kxWvxE_E9TWFv9WPGLUvuoDwrnoQkESXZfyn4tr&key=AIzaSyDxxUkBkujFvAJbrpndyIcyNMY2Nie5w28&maxwidth=400&maxheight=400', + 'https://maps.googleapis.com/maps/api/place/photo?photo_reference=AcJnMuEWNdGP9xy-7KihBpjXXZ1HgTa3Nls983NuZsncHQBFSdUO6KMiclMEowG9p7SpyXTKaJ5eZYnsxUcV4Jh1O-0LRCdZGQwq1KfwatNqdm2kWmosB1bXDC_Zs9SSZVB4LLiNQbeXZ-9MpM2IaleGpcsUqVbtd6Ylcmb6Pm0oG6h7ozd7&key=AIzaSyDxxUkBkujFvAJbrpndyIcyNMY2Nie5w28&maxwidth=400&maxheight=400', + 'https://maps.googleapis.com/maps/api/place/photo?photo_reference=AcJnMuEMdeMz9boLNPThj3YlBjz4t_VIQ0AzviMBzIPRj04jPK5v4Co2q7D3-2VdET1-QxmeXqqNM5ERUTORTxaYW-yhv9Lysk5kEaJx6K72b7Kout0lj3bKi1zC5Im_kW_eJKNierxINofA8fKnTmIItDsSdLbsXAX7IbkT1Bmr2lRhG5hQ&key=AIzaSyDxxUkBkujFvAJbrpndyIcyNMY2Nie5w28&maxwidth=400&maxheight=400', + 'https://maps.googleapis.com/maps/api/place/photo?photo_reference=AcJnMuFtfZwe0netS1LLSK4-8BDMFgeHIMFdv4EzH24HX8La2HM7A93LF4UU0ySJXFpGTXgV9KvuGwqFaewzxM7M_rIz2MoYwZyAbt1N49FxUnZ0S5GFZf2fusyhQ4eYFwBsCsfkIl63EV5B9oUgPPseZ9BOi0a7dUUCA_uqf7hYfmxHmp-4&key=AIzaSyDxxUkBkujFvAJbrpndyIcyNMY2Nie5w28&maxwidth=400&maxheight=400', + 'https://maps.googleapis.com/maps/api/place/photo?photo_reference=AcJnMuE1Xu8bgSWXtxX1U2nhKmOgda5lj6S7mccVJhfclHoeRyj5-cf7gzrVisYDDWKhlfbYtkqN0fSlcy9lvc1nAYiLt8LJW0M7bldnvaJ95Z5Qsn2klMMeFxMGHbD5xpkxuWDOMSR0mBUsfAyUqlfUdFudKRHdlFRuBSO6i_l-D9hC5BHV&key=AIzaSyDxxUkBkujFvAJbrpndyIcyNMY2Nie5w28&maxwidth=400&maxheight=400', + 'https://maps.googleapis.com/maps/api/place/photo?photo_reference=AcJnMuHATveG7xqy_nig4liBXOvTPR6qGSbr94zLLq-G4P8sPv3cJmYHpPIO_QgeNLNbVg7t4cAmgOm1SiTo9Ag3ZUQmlEV5fpiZoM4h40zLguKn0NMqLrVmhSidc_u7BCC3A4XtruJmgso3rcbUdJOSZCns_B1CYXSow8FD2ev2nWvD94h9&key=AIzaSyDxxUkBkujFvAJbrpndyIcyNMY2Nie5w28&maxwidth=400&maxheight=400', + ], +}; + +export default mockShopDetail; diff --git a/src/utils/hooks/useCounter.ts b/src/utils/hooks/useCounter.ts new file mode 100644 index 00000000..368e4835 --- /dev/null +++ b/src/utils/hooks/useCounter.ts @@ -0,0 +1,24 @@ +import { Dispatch, SetStateAction, useState } from 'react'; + +interface ReturnType { + count: number; + increment: VoidFunction; + decrement: VoidFunction; + setCount: Dispatch>; +} + +function useCounter(initialValue?: number): ReturnType { + const [count, setCount] = useState(initialValue || 0); + + const increment = () => setCount((x) => x + 1); + const decrement = () => setCount((x) => x - 1); + + return { + count, + increment, + decrement, + setCount, + }; +} + +export default useCounter; diff --git a/src/utils/ts/formatISODate.ts b/src/utils/ts/formatISODate.ts new file mode 100644 index 00000000..4ff4dadd --- /dev/null +++ b/src/utils/ts/formatISODate.ts @@ -0,0 +1,10 @@ +function formatISODate(isoDateString: string): string { + const date = new Date(isoDateString); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + + return `${year}.${month}.${day}`; +} + +export default formatISODate;