Skip to content

Commit 134cd8b

Browse files
authored
Merge pull request #97 from YAPP-Github/feature/PRODUCT-51
2 parents 120b7e1 + ac40571 commit 134cd8b

File tree

28 files changed

+751
-5
lines changed

28 files changed

+751
-5
lines changed

src/app/(store)/_api/shop/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from "./shop.api";
2+
export * from "./shop.queries";
3+
export * from "./shop.types";
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { http } from "@/lib/api";
2+
3+
import {
4+
type StoreCheersResponse,
5+
type StoreDetailResponse,
6+
type StoreImagesResponse,
7+
} from "./shop.types";
8+
9+
export const STORE_ERROR_CODES = {
10+
NOT_FOUND: "ST0012",
11+
} as const;
12+
13+
/**
14+
* 가게 상세 조회 API
15+
* @params storeId 조회할 가게 ID
16+
* @returns 가게 상세 정보
17+
*/
18+
export const getStoreDetail = async (
19+
storeId: string
20+
): Promise<StoreDetailResponse> => {
21+
return await http.get(`api/shops/${storeId}`).json<StoreDetailResponse>();
22+
};
23+
24+
/**
25+
* 가게별 응원 조회 API
26+
* @params storeId 조회할 가게 ID
27+
* @params size 조회할 응원 개수
28+
* @returns 가게별 응원 정보
29+
*/
30+
export const getStoreCheers = async (
31+
storeId: string,
32+
size: number
33+
): Promise<StoreCheersResponse> => {
34+
return await http
35+
.get(`api/shops/${storeId}/cheers`, {
36+
searchParams: {
37+
size,
38+
},
39+
})
40+
.json<StoreCheersResponse>();
41+
};
42+
43+
// /api/shops/{storeId}/images
44+
45+
/**
46+
* 가게별 이미지 조회 API
47+
* @params storeId 조회할 가게 ID
48+
* @returns 가게별 이미지 정보
49+
*/
50+
export const getStoreImages = async (
51+
storeId: string
52+
): Promise<StoreImagesResponse> => {
53+
return await http
54+
.get(`api/shops/${storeId}/images`)
55+
.json<StoreImagesResponse>();
56+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { queryOptions } from "@tanstack/react-query";
2+
3+
import { getStoreCheers, getStoreDetail, getStoreImages } from "./shop.api";
4+
5+
export const storeQueryKeys = {
6+
all: ["store"] as const,
7+
detail: (storeId: string) => [...storeQueryKeys.all, storeId] as const,
8+
cheers: (storeId: string, size: number) =>
9+
[...storeQueryKeys.all, storeId, "cheers", size] as const,
10+
images: (storeId: string) =>
11+
[...storeQueryKeys.all, storeId, "images"] as const,
12+
};
13+
14+
export const storeDetailQueryOptions = (storeId: string) =>
15+
queryOptions({
16+
queryKey: storeQueryKeys.detail(storeId),
17+
queryFn: () => getStoreDetail(storeId),
18+
});
19+
20+
export const storeCheersQueryOptions = (storeId: string, size: number) =>
21+
queryOptions({
22+
queryKey: storeQueryKeys.cheers(storeId, size),
23+
queryFn: () => getStoreCheers(storeId, size),
24+
});
25+
26+
export const storeImagesQueryOptions = (storeId: string) =>
27+
queryOptions({
28+
queryKey: storeQueryKeys.images(storeId),
29+
queryFn: () => getStoreImages(storeId),
30+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
type StoreDetail = {
2+
id: number;
3+
kakaoId: string;
4+
name: string;
5+
district: string;
6+
neighborhood: string;
7+
category: string;
8+
placeUrl: string;
9+
};
10+
11+
export type StoreDetailResponse = StoreDetail;
12+
13+
type StoreCheers = {
14+
id: number;
15+
memberId: number;
16+
memberNickname: string;
17+
description: string;
18+
};
19+
20+
export type StoreCheersResponse = { cheers: StoreCheers[] };
21+
22+
export type StoreImagesResponse = {
23+
imageUrls: string[];
24+
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { style } from "@vanilla-extract/css";
2+
3+
import { colors, radius, semantic } from "@/styles";
4+
5+
export const storeCheersContainer = style({
6+
paddingTop: "2.4rem",
7+
paddingBottom: "4rem",
8+
});
9+
10+
export const cheerCardProfileImage = style({
11+
width: "2.8rem",
12+
height: "2.8rem",
13+
borderRadius: "50%",
14+
});
15+
16+
export const cheerCardDivider = style({
17+
width: "0.2rem",
18+
height: "auto",
19+
alignSelf: "stretch",
20+
flexShrink: 0,
21+
backgroundColor: colors.coolNeutral[97],
22+
marginLeft: "1.4rem",
23+
marginRight: "2.1rem",
24+
});
25+
26+
export const cheerCardContent = style({
27+
width: "100%",
28+
height: "100%",
29+
padding: "1.6rem",
30+
backgroundColor: semantic.background.grayLight,
31+
borderRadius: radius[160],
32+
});
33+
34+
export const cheerCardContentText = style({
35+
selectors: {
36+
"&[data-long-text='true'][data-expanded='false']": {
37+
display: "-webkit-box",
38+
WebkitLineClamp: 4,
39+
WebkitBoxOrient: "vertical",
40+
overflow: "hidden",
41+
textOverflow: "ellipsis",
42+
},
43+
},
44+
});
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"use client";
2+
3+
import { Suspense } from "@suspensive/react";
4+
import { useSuspenseQuery } from "@tanstack/react-query";
5+
import Link from "next/link";
6+
import { useState } from "react";
7+
8+
import { storeCheersQueryOptions } from "@/app/(store)/_api/shop";
9+
import { Button } from "@/components/ui/Button";
10+
import { Skeleton } from "@/components/ui/Skeleton";
11+
import { Spacer } from "@/components/ui/Spacer";
12+
import { HStack, VStack } from "@/components/ui/Stack";
13+
import { Text } from "@/components/ui/Text";
14+
import { TextButton } from "@/components/ui/TextButton";
15+
16+
import * as styles from "./StoreCheers.css";
17+
18+
export const StoreCheers = ({ storeId }: { storeId: string }) => {
19+
return (
20+
<VStack gap={16} className={styles.storeCheersContainer}>
21+
<Text as='h3' typo='title2Sb' color='text.normal'>
22+
가게에 담긴 응원
23+
</Text>
24+
25+
<Suspense clientOnly fallback={<CheerCardSkeleton />}>
26+
<CheerContent storeId={storeId} />
27+
</Suspense>
28+
</VStack>
29+
);
30+
};
31+
32+
const CHEERS_SIZE = 50;
33+
34+
const CheerContent = ({ storeId }: { storeId: string }) => {
35+
const {
36+
data: { cheers },
37+
} = useSuspenseQuery(storeCheersQueryOptions(storeId, CHEERS_SIZE));
38+
39+
const [visibleCount, setVisibleCount] = useState(3);
40+
41+
const ITEMS_PER_PAGE = 3;
42+
const totalItems = cheers.length;
43+
const isAllVisible = visibleCount >= totalItems;
44+
const shouldShowToggleButton = totalItems > ITEMS_PER_PAGE;
45+
46+
const handleToggle = () => {
47+
if (isAllVisible) {
48+
// 접기: 처음 3개만 보여주기
49+
setVisibleCount(ITEMS_PER_PAGE);
50+
} else {
51+
// 더보기: 3개씩 추가
52+
setVisibleCount(prev => Math.min(prev + ITEMS_PER_PAGE, totalItems));
53+
}
54+
};
55+
56+
const visibleCards = cheers.slice(0, visibleCount);
57+
58+
return (
59+
<VStack>
60+
<VStack gap={24}>
61+
{visibleCards.map(card => (
62+
<CheerCard
63+
key={card.id}
64+
author={card.memberNickname}
65+
content={card.description}
66+
/>
67+
))}
68+
</VStack>
69+
{shouldShowToggleButton && (
70+
<>
71+
<Spacer size={20} />
72+
<Button
73+
variant='assistive'
74+
size='large'
75+
fullWidth
76+
onClick={handleToggle}
77+
>
78+
{isAllVisible ? "접기" : "더보기"}
79+
</Button>
80+
</>
81+
)}
82+
83+
<Spacer size={shouldShowToggleButton ? 12 : 20} />
84+
85+
{/*
86+
TODO: 가게 응원하기 버튼 클릭 시 가게 응원 페이지로 이동
87+
추후 가게 응원 등록 작업할 때 연결 예정
88+
*/}
89+
<Link href={""}>
90+
<Button variant='primary' size='large' fullWidth>
91+
가게 응원하기
92+
</Button>
93+
</Link>
94+
</VStack>
95+
);
96+
};
97+
98+
const CheerCard = ({
99+
author,
100+
content,
101+
}: {
102+
author: string;
103+
content: string;
104+
}) => {
105+
const [isExpanded, setIsExpanded] = useState(false);
106+
107+
const isLongText = content.length > 50;
108+
109+
return (
110+
<VStack gap={12}>
111+
<HStack align='center' gap={8}>
112+
<span className={styles.cheerCardProfileImage}>아이콘</span>
113+
<Text as='span' typo='body1Sb' color='text.normal'>
114+
{author}
115+
</Text>
116+
</HStack>
117+
118+
<HStack align='stretch'>
119+
<hr className={styles.cheerCardDivider} />
120+
<VStack className={styles.cheerCardContent} gap={4} align='start'>
121+
<Text
122+
as='p'
123+
typo='body2Rg'
124+
color='text.normal'
125+
className={styles.cheerCardContentText}
126+
data-expanded={isExpanded}
127+
data-long-text={isLongText}
128+
>
129+
{content}
130+
</Text>
131+
{isLongText && (
132+
<TextButton
133+
size='small'
134+
variant='assistive'
135+
onClick={() => setIsExpanded(!isExpanded)}
136+
>
137+
{isExpanded ? "접기" : "더보기"}
138+
</TextButton>
139+
)}
140+
</VStack>
141+
</HStack>
142+
</VStack>
143+
);
144+
};
145+
146+
const CheerCardSkeleton = () => {
147+
return (
148+
<VStack gap={12}>
149+
<Skeleton width='30%' height={28} radius={12} />
150+
<Skeleton width='100%' height={54} radius={12} />
151+
</VStack>
152+
);
153+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { StoreCheers } from "./StoreCheers";
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"use client";
2+
3+
import { useQuery } from "@tanstack/react-query";
4+
import { useRouter } from "next/navigation";
5+
import { useParams } from "next/navigation";
6+
7+
import { storeDetailQueryOptions } from "@/app/(store)/_api/shop";
8+
import ChevronLeftIcon from "@/assets/chevron-left.svg";
9+
import ShareIcon from "@/assets/share-24.svg";
10+
import { GNB } from "@/components/ui/GNB";
11+
import { semantic } from "@/styles";
12+
13+
export const StoreDetailGNB = () => {
14+
const router = useRouter();
15+
const { storeId } = useParams<{ storeId: string }>();
16+
17+
const { data: store } = useQuery(storeDetailQueryOptions(storeId));
18+
19+
const handleClickBack = () => {
20+
router.back();
21+
};
22+
23+
const handleClickShare = () => {
24+
navigator.share({
25+
title: store?.name,
26+
text: store?.name,
27+
url: window.location.href,
28+
});
29+
};
30+
31+
return (
32+
<GNB
33+
leftAddon={
34+
<button
35+
style={{ display: "flex", alignItems: "center" }}
36+
onClick={handleClickBack}
37+
>
38+
<ChevronLeftIcon width={24} height={24} />
39+
</button>
40+
}
41+
rightAddon={
42+
<button onClick={handleClickShare}>
43+
<ShareIcon width={24} height={24} color={semantic.icon.black} />
44+
</button>
45+
}
46+
/>
47+
);
48+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./StoreDetailGNB";

0 commit comments

Comments
 (0)