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
93 changes: 93 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Moyeoit - A platform for IT professionals. Next.js 15 application with React 19, TypeScript, and Tailwind CSS 4.

## Development Commands

```bash
# Development
pnpm dev # Start dev server with Turbopack
pnpm dev:mock-ssr # Dev server with MSW mock API

# Code Quality
pnpm lint # Run ESLint
pnpm lint:fix # ESLint with auto-fix
pnpm format # Format with Prettier
pnpm type-check # TypeScript type checking

# Build & Production
pnpm build # Production build
pnpm start # Start production server

# Testing
pnpm test # Run Vitest tests
pnpm storybook # Start Storybook on port 6006
```

## Architecture

### Directory Structure

- **`src/app/`** - Next.js App Router with route groups `(root)/(routes)/`
- **`src/components/`** - Atomic design: `atoms/` -> `molecules/` -> `(pages)/`
- **`src/features/`** - Feature-Sliced Architecture by domain (clubs, review, like, oauth, user, etc.)
- **`src/shared/`** - Cross-cutting concerns: configs, hooks, providers, utils, types
- **`src/mocks/`** - MSW handlers and mock data

### Key Patterns

**Component Variants**: Use Class Variance Authority (CVA) for type-safe styling variants
```typescript
const buttonVariants = cva([...], {
variants: { variant: {...}, size: {...} },
defaultVariants: { variant: 'solid', size: 'small' }
})
```

**Styling**: Tailwind CSS with `cn()` utility (clsx + tailwind-merge) from `@/shared/utils/cn`

**Routing**: Centralized path constants in `src/shared/configs/appPath.ts`

**Data Fetching**: TanStack React Query with Axios; MSW for API mocking

**Forms**: React Hook Form + Zod validation with `@hookform/resolvers`

**UI Components**: Radix UI primitives wrapped via shadcn/ui (New York style)

## Code Conventions

### Commit Messages (Conventional Commits)
- `feat`: New feature
- `fix`: Bug fix
- `style`: CSS related
- `refactor`: Code refactoring
- `docs`: Documentation
- `chore`: Miscellaneous changes
- `test`: Test code

### Pre-commit Hooks
Husky runs: lint -> format -> build before each commit

## Environment Variables

```bash
NEXT_PUBLIC_API_ADDRESS # Backend API URL
NEXT_PUBLIC_API_MOCKING # MSW toggle (enabled/disabled)
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 |
112 changes: 0 additions & 112 deletions src/app/(root)/(routes)/review/new/[kind]/[type]/page.tsx

This file was deleted.

170 changes: 56 additions & 114 deletions src/app/(root)/(routes)/review/new/[kind]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,132 +1,74 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { DocumentPencilIcon, DocumentDiamondIcon } from '@/assets/icons'
'use client'

const validKinds = ['paper', 'interview', 'activity'] as const
import { Suspense, useEffect, useState } from 'react'
import { notFound, redirect } from 'next/navigation'
import {
FormFactory,
isValidFormKind,
type FormKind,
} from '@/components/pages/review/new/forms'
import AppPath from '@/shared/configs/appPath'
import { useAuth } from '@/shared/providers/auth-provider'

export async function generateMetadata({
params,
}: {
interface PageProps {
params: Promise<{ kind: string }>
}): Promise<Metadata> {
const { kind } = await params
const isValidKind = (validKinds as readonly string[]).includes(kind)
}

if (!isValidKind) {
return {
title: '페이지를 찾을 수 없습니다',
}
}
export default function Page({ params }: PageProps) {
const { user, isLoading } = useAuth()
const [formKind, setFormKind] = useState<FormKind | null>(null)

const getKindDisplayName = (kind: string) => {
switch (kind) {
case 'paper':
return '서류'
case 'interview':
return '인터뷰/면접'
case 'activity':
return '활동'
default:
return kind
}
}
useEffect(() => {
async function loadParams() {
const { kind } = await params

const kindName = getKindDisplayName(kind)
if (!isValidFormKind(kind)) {
notFound()
}

return {
title: `${kindName} 후기 작성`,
description: `IT 동아리 ${kindName} 후기를 작성해보세요. 일반 후기와 프리미엄 후기 중 선택하여 경험을 공유하고 다른 분들에게 도움을 주세요.`,
keywords: [`IT 동아리 ${kindName} 후기`, '동아리 후기 작성', '경험 공유'],
openGraph: {
title: `${kindName} 후기 작성 | 모여잇`,
description: `IT 동아리 ${kindName} 후기를 작성해보세요. 일반 후기와 프리미엄 후기 중 선택하여 경험을 공유하고 다른 분들에게 도움을 주세요.`,
},
}
}
setFormKind(kind as FormKind)
}

export default async function Page({
params,
}: {
params: Promise<{ kind: string }>
}) {
const { kind } = await params
const isValidKind = (validKinds as readonly string[]).includes(kind)
if (!isValidKind) {
notFound()
}
loadParams()
}, [params])

const getKindDisplayName = (kind: string) => {
switch (kind) {
case 'paper':
return '서류'
case 'interview':
return '인터뷰/면접'
case 'activity':
return '활동'
default:
return kind
useEffect(() => {
if (!isLoading && !user) {
redirect(AppPath.login())
}
}
}, [user, isLoading])

return (
<main className="w-full h-full">
<div className="max-w-[530px] h-full mx-auto flex flex-col items-center justify-center">
{/* 제목 */}
<div className="text-center mb-8">
<h2 className="typo-title-1 text-black-color mb-8">
{getKindDisplayName(kind)} 후기 작성
</h2>
if (isLoading || !user) {
return (
<main className="">
<div className="max-w-[800px] mx-auto pt-20">
<div className="text-center">
<p className="typo-body-2-r text-grey-color-4">로딩 중...</p>
</div>
</div>
</main>
)
}

{/* 카드 컨테이너 */}
<div className="p-6 rounded-2xl bg-white-color flex flex-col gap-8 items-center w-[530px] shadow-sm">
<p className="typo-body-2-sb text-grey-color-4">
작성하실 후기 스타일을 선택해주세요
</p>
<div className="w-full flex flex-col gap-4">
{/* 일반 후기 카드 */}
<Link href={`/review/new/${kind}/normal`}>
<div className="w-full p-6 border border-gray-200 rounded-xl cursor-pointer group">
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
<DocumentPencilIcon role="img" aria-label="일반 후기" />
</div>
<div className="flex-1">
<h3 className="typo-body-3-b text-black-color mb-1">
일반 후기
</h3>
<p className="typo-button-m text-grey-color-3">
짧고 간단한 3분 후기 작성하기
</p>
</div>
</div>
</div>
</Link>
{/* 프리미엄 후기 카드 */}
<Link href={`/review/new/${kind}/premium`}>
<div className="w-full p-6 border border-gray-200 rounded-xl cursor-pointer group">
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
<DocumentDiamondIcon
role="img"
aria-label="프리미엄 후기"
/>
</div>
<div className="flex-1">
<h3 className="typo-body-3-b text-black-color mb-1">
프리미엄 후기
</h3>
<p className="typo-button-m text-grey-color-3">
가이드 따라 상세 후기 작성하고 기프티콘 혜택 받기
</p>
</div>
</div>
</div>
</Link>
if (!formKind) {
return (
<main className="">
<div className="max-w-[800px] mx-auto pt-20">
<div className="text-center">
<p className="typo-body-2-r text-grey-color-4">로딩 중...</p>
</div>
</div>
</div>
</main>
)
}

return (
<main className="">
<Suspense>
<div className="max-w-[800px] mx-auto pt-20 pb-20">
<FormFactory kind={formKind} />
</div>
</Suspense>
</main>
)
}
Loading