Skip to content
12 changes: 12 additions & 0 deletions src/app/(store)/_api/shop/shop.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type StoreCheersResponse,
type StoreDetailResponse,
type StoreImagesResponse,
type StoreTagsResponse,
} from "./shop.types";

export const STORE_ERROR_CODES = {
Expand Down Expand Up @@ -69,3 +70,14 @@ export const getCheeredMember = async (): Promise<CheeredMemberResponse> => {
.get("api/shops/cheered-member")
.json<CheeredMemberResponse>();
};

/**
* 가게별 태그 조회 API
* @params storeId 조회할 가게 ID
* @returns 가게별 태그 정보
*/
export const getStoreTags = async (
storeId: number
): Promise<StoreTagsResponse> => {
return await http.get(`api/shops/${storeId}/tags`).json<StoreTagsResponse>();
};
8 changes: 8 additions & 0 deletions src/app/(store)/_api/shop/shop.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getStoreCheers,
getStoreDetail,
getStoreImages,
getStoreTags,
} from "./shop.api";

export const storeQueryKeys = {
Expand All @@ -14,6 +15,7 @@ export const storeQueryKeys = {
[...storeQueryKeys.all, storeId, "cheers", size] as const,
images: (storeId: number) =>
[...storeQueryKeys.all, storeId, "images"] as const,
tags: (storeId: number) => [...storeQueryKeys.all, storeId, "tags"] as const,
};

export const storeDetailQueryOptions = (storeId: number) =>
Expand Down Expand Up @@ -43,3 +45,9 @@ export const cheeredMemberQueryOptions = () =>
queryKey: cheeredMemberQueryKeys.all,
queryFn: () => getCheeredMember(),
});

export const storeTagsQueryOptions = (storeId: number) =>
queryOptions({
queryKey: storeQueryKeys.tags(storeId),
queryFn: () => getStoreTags(storeId),
});
5 changes: 5 additions & 0 deletions src/app/(store)/_api/shop/shop.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type StoreCheers = {
memberId: number;
memberNickname: string;
description: string;
tags: string[];
};

export type StoreCheersResponse = { cheers: StoreCheers[] };
Expand All @@ -34,3 +35,7 @@ export type CheeredStore = {
export type CheeredMemberResponse = {
stores: CheeredStore[];
};

export type StoreTagsResponse = {
tags: string[];
};
Original file line number Diff line number Diff line change
@@ -1,36 +1,37 @@
import { style } from "@vanilla-extract/css";

import { colors, radius, semantic } from "@/styles";
import { radius } from "@/styles";

export const storeCheersContainer = style({
paddingTop: "2.4rem",
paddingBottom: "4rem",
});

export const cheerCardProfileImage = style({
width: "2.8rem",
height: "2.8rem",
export const cheerCard = style({
width: "100%",
borderRadius: radius[160],
overflow: "hidden",
});

export const cheerCardHeader = style({
display: "flex",
alignItems: "center",
gap: "0.8rem",
padding: "1.2rem 2rem",
});

export const cheerCardDivider = style({
width: "0.2rem",
height: "auto",
alignSelf: "stretch",
flexShrink: 0,
backgroundColor: colors.coolNeutral[97],
marginLeft: "1.4rem",
marginRight: "2.1rem",
export const cheerCardAvatar = style({
width: "2.4rem",
height: "2.4rem",
});

export const cheerCardContent = style({
width: "100%",
height: "100%",
padding: "1.6rem",
backgroundColor: semantic.background.grayLight,
borderRadius: radius[160],
padding: "2rem",
paddingBottom: "1.6rem",
});

export const cheerCardContentText = style({
width: "100%",
selectors: {
"&[data-long-text='true'][data-expanded='false']": {
display: "-webkit-box",
Expand All @@ -41,3 +42,17 @@ export const cheerCardContentText = style({
},
},
});

export const tag = style({
display: "flex",
alignItems: "center",
gap: "0.4rem",
padding: "0.6rem 0.8rem",
backgroundColor: "#ffffff",
borderRadius: "999px",
border: "1px solid #f0f0f0",
});
Comment on lines +46 to +54
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3:
옹! 뭔가 사용안하는 스타일인거 같아서 제거해도 될 거 같은 느낌!


export const tagIcon = style({
fontSize: "1.2rem",
});
148 changes: 104 additions & 44 deletions src/app/(store)/stores/[storeId]/_components/StoreCheers/StoreCheers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import { Suspense } from "@suspensive/react";
import { useSuspenseQuery } from "@tanstack/react-query";
import Link from "next/link";
import { slice } from "es-toolkit/compat";
import { motion } from "motion/react";
import Image from "next/image";
import { useState } from "react";

import { storeCheersQueryOptions } from "@/app/(store)/_api/shop";
Expand All @@ -11,10 +13,13 @@ import { Button } from "@/components/ui/Button";
import { Skeleton } from "@/components/ui/Skeleton";
import { Spacer } from "@/components/ui/Spacer";
import { HStack, VStack } from "@/components/ui/Stack";
import { Tag } from "@/components/ui/Tag";
import { Text } from "@/components/ui/Text";
import { TextButton } from "@/components/ui/TextButton";
import { ALL_TAGS } from "@/constants/tag.constants";

import * as styles from "./StoreCheers.css";
import { getContentBackgroundColor, getHeaderBackgroundColor } from "./utils";

export const StoreCheers = ({ storeId }: { storeId: number }) => {
return (
Expand All @@ -32,17 +37,21 @@ export const StoreCheers = ({ storeId }: { storeId: number }) => {

const CHEERS_SIZE = 50;

const ITEMS_PER_PAGE = 3;

const THEMES = ["yellow", "pink", "blue"] as const;
type Theme = (typeof THEMES)[number];

const CheerContent = ({ storeId }: { storeId: number }) => {
const {
data: { cheers },
} = useSuspenseQuery(storeCheersQueryOptions(storeId, CHEERS_SIZE));

const [visibleCount, setVisibleCount] = useState(3);

const ITEMS_PER_PAGE = 3;
const totalItems = cheers.length;
const isAllVisible = visibleCount >= totalItems;
const shouldShowToggleButton = totalItems > ITEMS_PER_PAGE;
const showToggleButton = totalItems > ITEMS_PER_PAGE;

const handleToggle = () => {
if (isAllVisible) {
Expand All @@ -59,16 +68,17 @@ const CheerContent = ({ storeId }: { storeId: number }) => {
return (
<VStack>
<VStack gap={24}>
{visibleCards.map(card => (
{visibleCards.map((card, index) => (
<CheerCard
key={card.id}
author={card.memberNickname}
content={card.description}
memberId={card.memberId}
tags={card.tags}
theme={THEMES[index % THEMES.length] as Theme}
/>
))}
</VStack>
{shouldShowToggleButton && (
{showToggleButton && (
<>
<Spacer size={20} />
<Button
Expand All @@ -82,64 +92,114 @@ const CheerContent = ({ storeId }: { storeId: number }) => {
</>
)}

<Spacer size={shouldShowToggleButton ? 12 : 20} />

<Link href={`/stores/register?storeId=${storeId}`}>
<Button variant='primary' size='large' fullWidth>
가게 응원하기
</Button>
</Link>
<Spacer size={showToggleButton ? 12 : 20} />
</VStack>
);
};

const CheerCard = ({
author,
content,
memberId,
tags,
theme,
}: {
author: string;
content: string;
memberId: number;
tags: string[];
theme: Theme;
}) => {
const [isExpanded, setIsExpanded] = useState(false);

const isLongText = content.length > 50;

const selectedTags = ALL_TAGS.filter(tag => tags.includes(tag.name));
const visibleTags = isExpanded ? selectedTags : slice(selectedTags, 0, 2);
const additionalTagsCount = Math.max(
0,
selectedTags.length - visibleTags.length
);
const showAdditionalTags = additionalTagsCount > 0 && !isExpanded;

return (
<VStack gap={12}>
<HStack align='center' gap={8}>
<Avatar memberId={memberId} className={styles.cheerCardProfileImage} />
<motion.div
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

motion까쥐 🤩🤩

className={styles.cheerCard}
onClick={() => setIsExpanded(!isExpanded)}
role='button'
tabIndex={0}
transition={{ duration: 0.3 }}
whileTap={{ scale: 0.99 }}
>
<div
className={styles.cheerCardHeader}
style={{
backgroundColor: getHeaderBackgroundColor(theme),
}}
>
<Avatar
// TODO: 추후 theme 지정 가능하게끔 수정
memberId={THEMES.findIndex(t => t === theme)}
className={styles.cheerCardAvatar}
/>
<Text as='span' typo='body1Sb' color='text.normal'>
{author}
</Text>
</HStack>

<HStack align='stretch'>
<hr className={styles.cheerCardDivider} />
<VStack className={styles.cheerCardContent} gap={4} align='start'>
<Text
as='p'
typo='body2Rg'
color='text.normal'
className={styles.cheerCardContentText}
data-expanded={isExpanded}
data-long-text={isLongText}
>
{content}
</Text>
{isLongText && (
<TextButton
size='small'
variant='assistive'
onClick={() => setIsExpanded(!isExpanded)}
</div>

<div
className={styles.cheerCardContent}
style={{
backgroundColor: getContentBackgroundColor(theme),
}}
>
<VStack gap={8} align='start'>
<VStack gap={4} align='start'>
<Text
as='p'
typo='body2Rg'
color='text.normal'
className={styles.cheerCardContentText}
data-expanded={isExpanded}
data-long-text={isLongText}
>
{isExpanded ? "접기" : "더보기"}
</TextButton>
)}
{content}
</Text>
{isLongText && (
<TextButton
size='small'
variant='assistive'
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "접기" : "더보기"}
</TextButton>
)}
Comment on lines +166 to +173
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

카드 전체 onClick과 “더보기/접기” 버튼 onClick이 중복 호출되어 토글이 무력화됨

부모 motion.div와 자식 TextButton이 모두 토글을 수행하여 버튼 클릭 시 두 번 토글(원상복귀)됩니다. 자식 클릭에서 이벤트 전파를 중단하고, 키보드 접근성도 함께 보강해주세요.

-    <motion.div
+    <motion.div
       className={styles.cheerCard}
-      onClick={() => setIsExpanded(!isExpanded)}
+      onClick={() => setIsExpanded(prev => !prev)}
       role='button'
       tabIndex={0}
+      aria-expanded={isExpanded}
+      onKeyDown={e => {
+        if (e.key === "Enter" || e.key === " ") {
+          e.preventDefault();
+          setIsExpanded(prev => !prev);
+        }
+      }}
       transition={{ duration: 0.3 }}
       whileTap={{ scale: 0.99 }}
     >
@@
-            <TextButton
+            <TextButton
               size='small'
               variant='assistive'
-              onClick={() => setIsExpanded(!isExpanded)}
+              onClick={e => {
+                e.stopPropagation();
+                setIsExpanded(prev => !prev);
+              }}
             >

Also applies to: 123-131

🤖 Prompt for AI Agents
In src/app/(store)/stores/[storeId]/_components/StoreCheers/StoreCheers.tsx
around lines 166-173 (and also apply the same fix at 123-131), the card wrapper
and the TextButton both toggle expansion so clicking the button triggers both
and cancels the toggle; modify the TextButton handlers to accept the event and
call event.stopPropagation() in onClick, and add an onKeyDown handler that
prevents default and stops propagation while toggling on Enter/Space so keyboard
users work correctly; ensure the toggle logic is invoked only once from the
button handler and keep the parent card click untouched.

</VStack>

<HStack gap={8} align='start' wrap='wrap'>
{visibleTags.map(tag => (
<Tag key={tag.name}>
<Image
src={tag.iconUrl}
alt={tag.label}
width={16}
height={16}
className={styles.tagIcon}
/>
<Text as='span' typo='caption1Sb' color='text.primary'>
{tag.label}
</Text>
</Tag>
))}

{showAdditionalTags && (
<Tag>
<Text as='span' typo='caption1Sb' color='text.primary'>
+{additionalTagsCount}
</Text>
</Tag>
)}
</HStack>
</VStack>
</HStack>
</VStack>
</div>
</motion.div>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type Theme = "yellow" | "pink" | "blue";
32 changes: 32 additions & 0 deletions src/app/(store)/stores/[storeId]/_components/StoreCheers/utils.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 배경 색상 지정하는 함수 꽤나 쓰이는거 같은데, 이것도 분리해서 함께 사용하면 좋을 거 같네유 ~ . ~

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { type Theme } from "./types";

export const getCheerCardTheme = (memberId: number) => {
return ["yellow", "pink", "blue"][memberId % 3];
};
Comment on lines +3 to +5
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Theme 반환값이 undefined가 될 수 있는 여지 제거 + 명시적 반환 타입 지정

memberId % 3가 음수이거나 정수가 아닐 경우 인덱싱 결과가 undefined가 될 수 있습니다. 또한 반환 타입이 암묵적이라 호출부 타입 안정성이 떨어집니다. 안전한 인덱스 계산과 Theme 반환 타입을 명시해 주세요.

-export const getCheerCardTheme = (memberId: number) => {
-  return ["yellow", "pink", "blue"][memberId % 3];
-};
+export const getCheerCardTheme = (memberId: number): Theme => {
+  const themes: Theme[] = ["yellow", "pink", "blue"];
+  const safeIndex = Math.abs(Math.trunc(memberId)) % themes.length;
+  return themes[safeIndex];
+};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getCheerCardTheme = (memberId: number) => {
return ["yellow", "pink", "blue"][memberId % 3];
};
export const getCheerCardTheme = (memberId: number): Theme => {
const themes: Theme[] = ["yellow", "pink", "blue"];
const safeIndex = Math.abs(Math.trunc(memberId)) % themes.length;
return themes[safeIndex];
};
🤖 Prompt for AI Agents
In src/app/(store)/stores/[storeId]/_components/StoreCheers/utils.ts around
lines 3 to 5, the function can return undefined for non-integer or negative
memberId and lacks an explicit return type; ensure you validate and normalize
memberId to a safe 0..2 index (e.g. coerce to a finite integer with Math.floor,
then compute ((index % 3) + 3) % 3 to handle negatives), use that index to pick
from the themes array, add an explicit return type Theme (import or declare the
Theme union if needed), and provide a fallback theme as a last-resort default so
the function never returns undefined.

💡 Verification agent

❓ Verification inconclusive

유틸이 실제로 사용되는지 확인 필요

이번 PR에서 getCheerCardTheme가 사용되지 않는 것으로 보입니다. 미사용 유틸은 제거하거나 실제 사용처(예: StoreCheers.tsx)로 연결해 주세요.

다음 스크립트로 사용 여부를 확인할 수 있습니다.


🏁 Script executed:

#!/bin/bash
# getCheerCardTheme 사용처 검색
rg -nP '\bgetCheerCardTheme\s*\(' -S --type ts --type tsx -C2

Length of output: 91


getCheerCardTheme의 실제 사용처를 glob 옵션으로 다시 확인해 보겠습니다.
다음 스크립트를 실행해 주세요.


🏁 Script executed:

#!/bin/bash
# getCheerCardTheme 사용처 검색 (glob)
rg -nP '\bgetCheerCardTheme\s*\(' -n --glob '*.ts' --glob '*.tsx' -C2

Length of output: 71


getCheerCardTheme 사용처 없음 – 제거 또는 연결 필요

현재 src/app/(store)/stores/[storeId]/_components/StoreCheers/utils.ts에서 정의된 getCheerCardTheme 함수가 코드베이스 내 어디에서도 호출되지 않습니다.
불필요한 dead code 방지를 위해 아래 중 하나를 선택해 주세요:

  • 유틸이 더 이상 필요하지 않다면 해당 함수 및 임포트를 삭제
  • 실제로 사용하려는 컴포넌트(예: StoreCheers.tsx)에서 호출하도록 연결

대상 위치
• src/app/(store)/stores/[storeId]/_components/StoreCheers/utils.ts:3-5 (export const getCheerCardTheme …)

🤖 Prompt for AI Agents
In src/app/(store)/stores/[storeId]/_components/StoreCheers/utils.ts lines 3-5,
the exported getCheerCardTheme function is not used; either remove it and any
related imports/exports to eliminate dead code, or wire it into the StoreCheers
component (e.g., import getCheerCardTheme in
src/app/(store)/stores/[storeId]/_components/StoreCheers/StoreCheers.tsx and
call getCheerCardTheme(memberId) to compute the card theme and apply it to the
card’s className/props where member cards are rendered). Ensure after the change
there are no unused exports or linter errors.


// 색상 테마별 배경색 헬퍼 함수
export const getHeaderBackgroundColor = (colorName: Theme) => {
switch (colorName) {
case "yellow":
return "#fceb9c";
case "pink":
return "#fabdb8";
case "blue":
return "#b2dfff";
default:
return "#fceb9c";
}
};

export const getContentBackgroundColor = (colorName: Theme) => {
switch (colorName) {
case "yellow":
return "#fef8dd";
case "pink":
return "#fde5e3";
case "blue":
return "#e0f2ff";
default:
return "#fef8dd";
}
};
Loading