Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions .example.env

This file was deleted.

21 changes: 12 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pnpm storybook # Start Storybook on port 6006
### Key Patterns

**Component Variants**: Use Class Variance Authority (CVA) for type-safe styling variants

```typescript
const buttonVariants = cva([...], {
variants: { variant: {...}, size: {...} },
Expand All @@ -61,6 +62,7 @@ const buttonVariants = cva([...], {
## Code Conventions

### Commit Messages (Conventional Commits)

- `feat`: New feature
- `fix`: Bug fix
- `style`: CSS related
Expand All @@ -70,6 +72,7 @@ const buttonVariants = cva([...], {
- `test`: Test code

### Pre-commit Hooks

Husky runs: lint -> format -> build before each commit

## Environment Variables
Expand All @@ -82,12 +85,12 @@ NEXT_PUBLIC_GA_ID # Google Analytics

## Tech Stack Quick Reference

| Category | Technology |
|----------|------------|
| Framework | Next.js 15 (App Router, Turbopack) |
| UI | Radix UI + shadcn/ui + Tailwind CSS 4 |
| State | TanStack React Query v5 |
| Forms | React Hook Form + Zod |
| Testing | Vitest + Storybook 9 + Playwright |
| Mocking | MSW 2 |
| Monitoring | Sentry |
| Category | Technology |
| ---------- | ------------------------------------- |
| Framework | Next.js 15 (App Router, Turbopack) |
| UI | Radix UI + shadcn/ui + Tailwind CSS 4 |
| State | TanStack React Query v5 |
| Forms | React Hook Form + Zod |
| Testing | Vitest + Storybook 9 + Playwright |
| Mocking | MSW 2 |
| Monitoring | Sentry |
29 changes: 17 additions & 12 deletions public/mockServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* - Please do NOT modify this file.
*/

const PACKAGE_VERSION = '2.10.4'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
const PACKAGE_VERSION = '2.12.2'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

Expand Down Expand Up @@ -71,11 +71,6 @@ addEventListener('message', async function (event) {
break
}

case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}

case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)

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

addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()

// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
Expand All @@ -110,23 +107,29 @@ addEventListener('fetch', function (event) {

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

const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})

/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId) {
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(event, client, requestId)
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)

// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
Expand Down Expand Up @@ -202,9 +205,10 @@ async function resolveMainClient(event) {
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId) {
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
Expand Down Expand Up @@ -255,6 +259,7 @@ async function getResponse(event, client, requestId) {
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
Expand Down
5 changes: 5 additions & 0 deletions src/assets/icons/bookmark-empty-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/assets/icons/bookmark-filled-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/assets/icons/bookmark-mobile-empty.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/assets/icons/bookmark-mobile-filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/assets/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ export { default as BasicReviewIcon } from './basicReview.svg'
export { default as PremiumReviewIcon } from './premiumReview.svg'
export { default as BellIcon } from './bell-icon.svg'
export { default as ThumbsUpIcon } from './thumbs-up-icon.svg'
export { default as BookmarkFilledIcon } from './bookmark-filled-icon.svg'
export { default as BookmarkEmptyIcon } from './bookmark-empty-icon.svg'
export { default as BookmarkMobileFilledIcon } from './bookmark-mobile-filled.svg'
export { default as BookmarkMobileEmptyIcon } from './bookmark-mobile-empty.svg'
89 changes: 43 additions & 46 deletions src/components/(pages)/club/explore/Explore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import * as React from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { SideBar, type SideOption } from '@/components/atoms/sideBar/Sidebar'
import { Card } from '@/components/molecules/card'
import CardOverlay from '@/components/molecules/card/CardOverlay'
import {
MultiDropDown,
type Group,
} from '@/components/molecules/multiDropDown/MultiDropDown'
import { Tab, type TabOption } from '@/components/molecules/tab/Tab'
import { useToggleClubSubscription } from '@/features/clubs/mutations'
import { useExploreClubs } from '@/features/explore/queries'
import { useUserSubscribes } from '@/features/subscribe/queries'
import useMediaQuery from '@/shared/hooks/useMediaQuery'
import useQueryState from '@/shared/hooks/useQueryState'

const CATEGORY_OPTIONS: SideOption[] = [
Expand Down Expand Up @@ -81,6 +84,7 @@ const TARGET_OPTIONS: Group[] = [
]

export function Explore() {
const { isDesktop } = useMediaQuery()
const router = useRouter()
const [field, setField] = useQueryState('field')
const [sort, setSort] = useQueryState('sort')
Expand Down Expand Up @@ -185,9 +189,22 @@ export function Explore() {
error: queryError,
} = useExploreClubs(queryParams)

const { data: subscribesData } = useUserSubscribes()
const toggleSubscription = useToggleClubSubscription()

const clubs = clubsData?.content || []
const subscribes = subscribesData?.data?.content || []

Choose a reason for hiding this comment

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

high

useExploreClubs에서 반환되는 clubsDataclubsData.content로 접근하는 반면, useUserSubscribes에서 반환되는 subscribesDatasubscribesData.data.content로 접근하고 있습니다. API 응답 데이터 구조에 일관성이 부족해 보입니다. 가능하다면 API 응답 형식을 { content: [...] } 또는 { data: { content: [...] } } 중 하나로 통일하여 클라이언트 측에서 데이터를 다루기 용이하게 만드는 것을 고려해보세요. 이는 코드의 예측 가능성을 높이고 잠재적인 버그를 줄일 수 있습니다.

const subscribedClubIds = new Set(subscribes.map((s) => s.clubId))
const error = queryError ? '동아리를 불러오는데 실패했습니다.' : null

const handleBookmarkClick = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>, clubId: number) => {
e.stopPropagation()
toggleSubscription.mutate(clubId)
},
[toggleSubscription],
)

const fieldLabel = React.useMemo(() => {
return currentField === 'all' ? '전체' : currentField
}, [currentField])
Expand Down Expand Up @@ -289,57 +306,37 @@ export function Explore() {
</div>

{/* 카드 그리드 */}
<div className="grid grid-cols-[repeat(3,17.625rem)] gap-x-4 gap-y-12">
<div
className={`grid ${isDesktop ? 'grid-cols-3 gap-8 pt-0 pb-12' : 'grid-cols-1 gap-4'}`}
>
{clubs.map((club) => (
<Card
<CardOverlay
key={club.clubId}
size="col3Desktop"
orientation="vertical"
border={true}
gap="12px"
className="group cursor-pointer relative"
onClick={() => router.push(`/club/${club.clubId}`)}
>
<Card.Image
logoUrl={club.logoUrl || '/images/default.svg'}
alt={club.clubName}
interactive
className="transition-transform duration-300 ease-out"
/>
<Card.Content className="px-[6px]">
<Card.Title className="">{club.clubName}</Card.Title>
<Card.Description>
{club.description}
</Card.Description>
<Card.Meta part={club.categories.join(' · ')} />
</Card.Content>
{club.isRecruiting && (
<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">
모집중
</div>
)}
</Card>
club={club}
isSubscribed={subscribedClubIds.has(club.clubId)}
onBookmarkClick={handleBookmarkClick}
/>
))}
</div>
</div>
</div>

{/* 빈 상태 표시 */}
{clubs.length === 0 && (
<div className="text-center py-20">
<div className="w-16 h-16 bg-light-color-2 rounded-full flex items-center justify-center mx-auto mb-4"></div>
<h3 className="text-lg font-semibold mb-2">
{currentField === 'all'
? '동아리를 찾을 수 없습니다'
: `${fieldLabel} 카테고리의 동아리가 없습니다`}
</h3>
<p className="text-grey-color-2">
{currentField === 'all'
? '다른 필터를 시도해보세요.'
: '다른 카테고리를 선택해보세요.'}
</p>
</div>
)}
{/* 빈 상태 표시 */}
{clubs.length === 0 && (
<div className="text-center py-20">
<div className="w-16 h-16 bg-light-color-2 rounded-full flex items-center justify-center mx-auto mb-4"></div>
<h3 className="text-lg font-semibold mb-2">
{currentField === 'all'
? '동아리를 찾을 수 없습니다'
: `${fieldLabel} 카테고리의 동아리가 없습니다`}
</h3>
<p className="text-grey-color-2">
{currentField === 'all'
? '다른 필터를 시도해보세요.'
: '다른 카테고리를 선택해보세요.'}
</p>
</div>
)}
</div>
</div>
</>
)}
Expand Down
9 changes: 4 additions & 5 deletions src/components/molecules/card/Card.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,7 @@ const HorizontalRender = (args: React.ComponentProps<typeof Card>) => (
<Card.Content>
<Card.Title>{title}</Card.Title>
<Card.Description>{description}</Card.Description>
<Card.Meta kind="지원편" clubName="모여잇" clubYear="2024" part="FE" />
<Card.Stats likes={20} comments={5} />
<Card.Meta part="기획 · 개발 · 디자인" />
</Card.Content>
</Card>
)
Expand All @@ -97,9 +96,9 @@ export const HomeReviewDesktop: Story = {
}

export const Col4Phone: Story = {
name: 'Vertical / col4Phone',
args: { size: 'col4Phone', orientation: 'vertical', border: true },
render: VerticalRender,
name: 'Horizontal / col4Phone',
args: { size: 'col4Phone', orientation: 'horizontal', border: true },
render: HorizontalRender,
}

export const HomeReviewPhone: Story = {
Expand Down
Loading