Skip to content

Commit deb5923

Browse files
authored
feat: 모바일카드 컴포넌트 구현 (#135)
* refactor: card 컴포넌트 수정 * feat: 북마크 추가 * refactor: card 컴포넌트 수정 * feat: 모바일 카드 구현
1 parent 63d8d18 commit deb5923

25 files changed

+586
-341
lines changed

.example.env

Lines changed: 0 additions & 11 deletions
This file was deleted.

CLAUDE.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pnpm storybook # Start Storybook on port 6006
4141
### Key Patterns
4242

4343
**Component Variants**: Use Class Variance Authority (CVA) for type-safe styling variants
44+
4445
```typescript
4546
const buttonVariants = cva([...], {
4647
variants: { variant: {...}, size: {...} },
@@ -61,6 +62,7 @@ const buttonVariants = cva([...], {
6162
## Code Conventions
6263

6364
### Commit Messages (Conventional Commits)
65+
6466
- `feat`: New feature
6567
- `fix`: Bug fix
6668
- `style`: CSS related
@@ -70,6 +72,7 @@ const buttonVariants = cva([...], {
7072
- `test`: Test code
7173

7274
### Pre-commit Hooks
75+
7376
Husky runs: lint -> format -> build before each commit
7477

7578
## Environment Variables
@@ -82,12 +85,12 @@ NEXT_PUBLIC_GA_ID # Google Analytics
8285

8386
## Tech Stack Quick Reference
8487

85-
| Category | Technology |
86-
|----------|------------|
87-
| Framework | Next.js 15 (App Router, Turbopack) |
88-
| UI | Radix UI + shadcn/ui + Tailwind CSS 4 |
89-
| State | TanStack React Query v5 |
90-
| Forms | React Hook Form + Zod |
91-
| Testing | Vitest + Storybook 9 + Playwright |
92-
| Mocking | MSW 2 |
93-
| Monitoring | Sentry |
88+
| Category | Technology |
89+
| ---------- | ------------------------------------- |
90+
| Framework | Next.js 15 (App Router, Turbopack) |
91+
| UI | Radix UI + shadcn/ui + Tailwind CSS 4 |
92+
| State | TanStack React Query v5 |
93+
| Forms | React Hook Form + Zod |
94+
| Testing | Vitest + Storybook 9 + Playwright |
95+
| Mocking | MSW 2 |
96+
| Monitoring | Sentry |

public/mockServiceWorker.js

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
* - Please do NOT modify this file.
88
*/
99

10-
const PACKAGE_VERSION = '2.10.4'
11-
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
10+
const PACKAGE_VERSION = '2.12.2'
11+
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
1212
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
1313
const activeClientIds = new Set()
1414

@@ -71,11 +71,6 @@ addEventListener('message', async function (event) {
7171
break
7272
}
7373

74-
case 'MOCK_DEACTIVATE': {
75-
activeClientIds.delete(clientId)
76-
break
77-
}
78-
7974
case 'CLIENT_CLOSED': {
8075
activeClientIds.delete(clientId)
8176

@@ -94,6 +89,8 @@ addEventListener('message', async function (event) {
9489
})
9590

9691
addEventListener('fetch', function (event) {
92+
const requestInterceptedAt = Date.now()
93+
9794
// Bypass navigation requests.
9895
if (event.request.mode === 'navigate') {
9996
return
@@ -110,23 +107,29 @@ addEventListener('fetch', function (event) {
110107

111108
// Bypass all requests when there are no active clients.
112109
// Prevents the self-unregistered worked from handling requests
113-
// after it's been deleted (still remains active until the next reload).
110+
// after it's been terminated (still remains active until the next reload).
114111
if (activeClientIds.size === 0) {
115112
return
116113
}
117114

118115
const requestId = crypto.randomUUID()
119-
event.respondWith(handleRequest(event, requestId))
116+
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
120117
})
121118

122119
/**
123120
* @param {FetchEvent} event
124121
* @param {string} requestId
122+
* @param {number} requestInterceptedAt
125123
*/
126-
async function handleRequest(event, requestId) {
124+
async function handleRequest(event, requestId, requestInterceptedAt) {
127125
const client = await resolveMainClient(event)
128126
const requestCloneForEvents = event.request.clone()
129-
const response = await getResponse(event, client, requestId)
127+
const response = await getResponse(
128+
event,
129+
client,
130+
requestId,
131+
requestInterceptedAt,
132+
)
130133

131134
// Send back the response clone for the "response:*" life-cycle events.
132135
// Ensure MSW is active and ready to handle the message, otherwise
@@ -202,9 +205,10 @@ async function resolveMainClient(event) {
202205
* @param {FetchEvent} event
203206
* @param {Client | undefined} client
204207
* @param {string} requestId
208+
* @param {number} requestInterceptedAt
205209
* @returns {Promise<Response>}
206210
*/
207-
async function getResponse(event, client, requestId) {
211+
async function getResponse(event, client, requestId, requestInterceptedAt) {
208212
// Clone the request because it might've been already used
209213
// (i.e. its body has been read and sent to the client).
210214
const requestClone = event.request.clone()
@@ -255,6 +259,7 @@ async function getResponse(event, client, requestId) {
255259
type: 'REQUEST',
256260
payload: {
257261
id: requestId,
262+
interceptedAt: requestInterceptedAt,
258263
...serializedRequest,
259264
},
260265
},
Lines changed: 5 additions & 0 deletions
Loading
Lines changed: 5 additions & 0 deletions
Loading
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 4 additions & 0 deletions
Loading

src/assets/icons/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@ export { default as BasicReviewIcon } from './basicReview.svg'
1717
export { default as PremiumReviewIcon } from './premiumReview.svg'
1818
export { default as BellIcon } from './bell-icon.svg'
1919
export { default as ThumbsUpIcon } from './thumbs-up-icon.svg'
20+
export { default as BookmarkFilledIcon } from './bookmark-filled-icon.svg'
21+
export { default as BookmarkEmptyIcon } from './bookmark-empty-icon.svg'
22+
export { default as BookmarkMobileFilledIcon } from './bookmark-mobile-filled.svg'
23+
export { default as BookmarkMobileEmptyIcon } from './bookmark-mobile-empty.svg'

src/components/(pages)/club/explore/Explore.tsx

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import * as React from 'react'
44
import Image from 'next/image'
55
import { useRouter } from 'next/navigation'
66
import { SideBar, type SideOption } from '@/components/atoms/sideBar/Sidebar'
7-
import { Card } from '@/components/molecules/card'
7+
import CardOverlay from '@/components/molecules/card/CardOverlay'
88
import {
99
MultiDropDown,
1010
type Group,
1111
} from '@/components/molecules/multiDropDown/MultiDropDown'
1212
import { Tab, type TabOption } from '@/components/molecules/tab/Tab'
13+
import { useToggleClubSubscription } from '@/features/clubs/mutations'
1314
import { useExploreClubs } from '@/features/explore/queries'
15+
import { useUserSubscribes } from '@/features/subscribe/queries'
16+
import useMediaQuery from '@/shared/hooks/useMediaQuery'
1417
import useQueryState from '@/shared/hooks/useQueryState'
1518

1619
const CATEGORY_OPTIONS: SideOption[] = [
@@ -81,6 +84,7 @@ const TARGET_OPTIONS: Group[] = [
8184
]
8285

8386
export function Explore() {
87+
const { isDesktop } = useMediaQuery()
8488
const router = useRouter()
8589
const [field, setField] = useQueryState('field')
8690
const [sort, setSort] = useQueryState('sort')
@@ -185,9 +189,22 @@ export function Explore() {
185189
error: queryError,
186190
} = useExploreClubs(queryParams)
187191

192+
const { data: subscribesData } = useUserSubscribes()
193+
const toggleSubscription = useToggleClubSubscription()
194+
188195
const clubs = clubsData?.content || []
196+
const subscribes = subscribesData?.data?.content || []
197+
const subscribedClubIds = new Set(subscribes.map((s) => s.clubId))
189198
const error = queryError ? '동아리를 불러오는데 실패했습니다.' : null
190199

200+
const handleBookmarkClick = React.useCallback(
201+
(e: React.MouseEvent<HTMLButtonElement>, clubId: number) => {
202+
e.stopPropagation()
203+
toggleSubscription.mutate(clubId)
204+
},
205+
[toggleSubscription],
206+
)
207+
191208
const fieldLabel = React.useMemo(() => {
192209
return currentField === 'all' ? '전체' : currentField
193210
}, [currentField])
@@ -289,57 +306,37 @@ export function Explore() {
289306
</div>
290307

291308
{/* 카드 그리드 */}
292-
<div className="grid grid-cols-[repeat(3,17.625rem)] gap-x-4 gap-y-12">
309+
<div
310+
className={`grid ${isDesktop ? 'grid-cols-3 gap-8 pt-0 pb-12' : 'grid-cols-1 gap-4'}`}
311+
>
293312
{clubs.map((club) => (
294-
<Card
313+
<CardOverlay
295314
key={club.clubId}
296-
size="col3Desktop"
297-
orientation="vertical"
298-
border={true}
299-
gap="12px"
300-
className="group cursor-pointer relative"
301-
onClick={() => router.push(`/club/${club.clubId}`)}
302-
>
303-
<Card.Image
304-
logoUrl={club.logoUrl || '/images/default.svg'}
305-
alt={club.clubName}
306-
interactive
307-
className="transition-transform duration-300 ease-out"
308-
/>
309-
<Card.Content className="px-[6px]">
310-
<Card.Title className="">{club.clubName}</Card.Title>
311-
<Card.Description>
312-
{club.description}
313-
</Card.Description>
314-
<Card.Meta part={club.categories.join(' · ')} />
315-
</Card.Content>
316-
{club.isRecruiting && (
317-
<div className="w-[61px] h-[29px] absolute top-[16px] left-[16px] bg-white text-grey-color-5 typo-caption-sb rounded-[73px] border border-light-color-3 z-10 px-3 py-1.5 text-center flex items-center justify-center leading-none">
318-
모집중
319-
</div>
320-
)}
321-
</Card>
315+
club={club}
316+
isSubscribed={subscribedClubIds.has(club.clubId)}
317+
onBookmarkClick={handleBookmarkClick}
318+
/>
322319
))}
323320
</div>
324321
</div>
325-
</div>
326322

327-
{/* 빈 상태 표시 */}
328-
{clubs.length === 0 && (
329-
<div className="text-center py-20">
330-
<div className="w-16 h-16 bg-light-color-2 rounded-full flex items-center justify-center mx-auto mb-4"></div>
331-
<h3 className="text-lg font-semibold mb-2">
332-
{currentField === 'all'
333-
? '동아리를 찾을 수 없습니다'
334-
: `${fieldLabel} 카테고리의 동아리가 없습니다`}
335-
</h3>
336-
<p className="text-grey-color-2">
337-
{currentField === 'all'
338-
? '다른 필터를 시도해보세요.'
339-
: '다른 카테고리를 선택해보세요.'}
340-
</p>
341-
</div>
342-
)}
323+
{/* 빈 상태 표시 */}
324+
{clubs.length === 0 && (
325+
<div className="text-center py-20">
326+
<div className="w-16 h-16 bg-light-color-2 rounded-full flex items-center justify-center mx-auto mb-4"></div>
327+
<h3 className="text-lg font-semibold mb-2">
328+
{currentField === 'all'
329+
? '동아리를 찾을 수 없습니다'
330+
: `${fieldLabel} 카테고리의 동아리가 없습니다`}
331+
</h3>
332+
<p className="text-grey-color-2">
333+
{currentField === 'all'
334+
? '다른 필터를 시도해보세요.'
335+
: '다른 카테고리를 선택해보세요.'}
336+
</p>
337+
</div>
338+
)}
339+
</div>
343340
</div>
344341
</>
345342
)}

src/components/molecules/card/Card.stories.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,7 @@ const HorizontalRender = (args: React.ComponentProps<typeof Card>) => (
7272
<Card.Content>
7373
<Card.Title>{title}</Card.Title>
7474
<Card.Description>{description}</Card.Description>
75-
<Card.Meta kind="지원편" clubName="모여잇" clubYear="2024" part="FE" />
76-
<Card.Stats likes={20} comments={5} />
75+
<Card.Meta part="기획 · 개발 · 디자인" />
7776
</Card.Content>
7877
</Card>
7978
)
@@ -97,9 +96,9 @@ export const HomeReviewDesktop: Story = {
9796
}
9897

9998
export const Col4Phone: Story = {
100-
name: 'Vertical / col4Phone',
101-
args: { size: 'col4Phone', orientation: 'vertical', border: true },
102-
render: VerticalRender,
99+
name: 'Horizontal / col4Phone',
100+
args: { size: 'col4Phone', orientation: 'horizontal', border: true },
101+
render: HorizontalRender,
103102
}
104103

105104
export const HomeReviewPhone: Story = {

0 commit comments

Comments
 (0)