-
-
-
-
+
`;
}
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) => (
+ -
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+
+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}
+
{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 }) => (
+ -
+
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+ );
+ }
+
+ 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;