diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index fcbc43858..000000000 --- a/.cursorrules +++ /dev/null @@ -1,530 +0,0 @@ -# .cursorrules - -> **디프만 18기 3팀 — 위시리스트 소비 결정 서비스** -> 이 문서는 Cursor AI가 프로젝트 작업 시 반드시 따라야 할 규칙 및 컨텍스트를 정의합니다. -> 팀원 전원이 공유하는 AI 협업 규약이며, 모든 코드 생성/리팩토링은 이 규약을 따릅니다. - ---- - -## 🎯 프로젝트 컨텍스트 - -### 한 줄 정의 -**쌓인 위시리스트에서 먼저 살 것을 골라주는 소비 결정 서비스** - -### 타겟 유저 -위시리스트와 장바구니는 가득하지만, 선택 피로로 구매를 계속 미루는 패션·라이프스타일 중심의 **20~30대 모바일 쇼핑 사용자**. - -### 핵심 기능 -- **링크 기반 상품 저장** — 여러 쇼핑몰 링크를 한곳에 모음 -- **1:1 토너먼트 비교** — 두 개씩 비교해 최종 1순위 결정 (WOW 포인트) -- **AI 소비 메이트** — 질문/공감/리마인드 (WOW 포인트) -- **보류함 & 재판단 알림** — 방치된 위시 재방문 유도 -- **커머스 직접 연동** — 결정된 상품 즉시 구매 - -### 플랫폼 전략 -**RN(Expo) 앱이 WebView로 Next.js 웹앱을 감싸는 구조.** -→ 실제 UI/비즈니스 로직은 `apps/web`에 집중. 네이티브 기능만 `apps/app`에서 처리. - ---- - -## 💻 기술 스택 - -### 공통 -- **패키지 매니저**: pnpm 10.17.0 -- **모노레포**: Turborepo -- **언어**: TypeScript 5.9.2 -- **배포**: Vercel (web), Expo (app) - -### apps/web (Next.js) -- **프레임워크**: Next.js 16 (App Router) -- **React**: 19.2.0 -- **상태관리**: Zustand -- **스타일링**: Tailwind CSS v4 (루트 공통 설정, 각 앱에서 상속) -- **API 통신**: TanStack Query (@tanstack/react-query) -- **스키마 검증**: Zod -- **API 모킹**: MSW (필요 시) -- **아이콘**: `@/assets/icons` (fill/outline SVG) — Lucide React 사용 안 함 - -### apps/app (React Native) -- **프레임워크**: React Native + Expo -- **라우팅**: Expo Router -- **주요 역할**: WebView로 `apps/web` 렌더링 - -### CI/CD -- **GitHub Actions**: PR 단위 빌드 검사 (`lint` → `check-types` → `build`) -- **필수 통과 조건**: `pnpm install --frozen-lockfile` - ---- - -## 📁 프로젝트 구조 - -### 모노레포 루트 -``` -18th-team3-client/ -├── apps/ -│ ├── app/ # React Native + Expo (WebView 래퍼) -│ └── web/ # Next.js 16 (메인 서비스) -├── packages/ -│ ├── core/ # @piki/core — 웹뷰 통신용 type/hook/util -│ └── typescript-config/ # @piki/typescript-config -├── prettier.config.mjs # 루트 Prettier (공통) -├── eslint.config.mjs # 루트 ESLint (공통) -├── .prettierignore -└── turbo.json -``` - -### apps/web 내부 (페이지별 폴더 구조) -``` -apps/web/src/ -├── app/ # Next.js App Router -│ ├── layout.tsx -│ ├── page.tsx # 홈 -│ ├── providers.tsx # TanStack Query Provider -│ ├── fonts/ -│ ├── tournament/ # 토너먼트 (예시) -│ │ ├── page.tsx -│ │ ├── _common/ # tournament/* 하위 라우트 간 공유 -│ │ │ ├── _components/ -│ │ │ ├── _hooks/ -│ │ │ ├── _consts/ -│ │ │ └── _types/ -│ │ └── create/ -│ │ ├── page.tsx -│ │ ├── _common/ # create/* 하위 라우트 간 공유 -│ │ │ ├── _components/ -│ │ │ ├── _hooks/ -│ │ │ └── _consts/ -│ │ └── by-wish/ -│ │ ├── page.tsx -│ │ └── _components/ # by-wish 전용 (아직 레벨업 전) -│ ├── wishlist/ -│ │ ├── page.tsx -│ │ └── _components/ # wishlist 전용 -│ └── ... -├── components/ -│ └── common/ # app/ 밖 — top-level 라우트 간 공유 + 범용 UI -│ └── {component-name}/ # 폴더명: kebab-case -│ ├── index.tsx # 컴포넌트 본체 (default export) -│ ├── {componentName}.style.ts # cva variants (스타일 분리 시) -│ └── {componentName}.const.ts # 상수/타입 (필요 시) -├── apis/ # API 호출 함수 (HTTP 메서드 prefix 컨벤션) -├── hooks/ # 커스텀 훅 -├── utils/ # 공통 유틸리티 함수 -├── types/ # 공통 타입 정의 (T suffix) -├── assets/ # 정적 리소스 (SVG, 이미지) -├── styles/ # globals.css (Tailwind import) -└── consts/ # 상수 -``` - -**Colocation 배치 (한 단계씩 레벨업):** - -코드는 **가장 가까운 사용처**에 둔다. 재사용 범위가 넓어질 때만 **부모 라우트로 한 단계** 끌어올린다. 처음부터 `components/common/`에 두지 않는다. - -| 재사용 범위 | 배치 위치 | -| --- | --- | -| 단일 `page.tsx` 전용 | 해당 라우트 폴더의 `_components/` (또는 `_hooks/`, `_apis/`, `_consts/`, `_types/`) | -| **같은 부모 아래 2개 이상** 하위 라우트에서 공유 | **부모 라우트**의 `_common/_components/` 등으로 끌어올림 | -| **`app/` top-level 라우트 간** 공유 (tournament ↔ wishlist 등) | `components/common/{component-name}/` — **App Router 밖**으로 이동 | -| **2개 이상 top-level 라우트 또는 앱 전역**에서 쓰는 API/훅/유틸 | `src/apis/`, `hooks/`, `utils/`, `consts/`, `types/` | - -**API 함수 배치 예시:** -- 단일 페이지 전용 (홈의 토너먼트 리스트 조회 등) → `app/home/_apis/getTournamentList.ts` -- 2개 이상 페이지에서 공유 (유저 정보 조회, 이미지로 위시 담기 등) → `src/apis/` - -``` -예) by-wish/page 전용 → app/tournament/create/by-wish/_components/ - create/page + by-wish/page 에서 공유 → app/tournament/create/_common/_components/ (↑ app 내 한 단계) - tournament/* 전체에서 공유 → app/tournament/_common/_components/ (↑ app 내 한 단계) - tournament + wishlist 등 top-level 공유 → components/common/{name}/ (app/ 밖으로) - Button, Dialog 등 범용 UI → components/common/button/ -``` - -- **한 라우트 전용** → `_components/` 등을 라우트 폴더에 직접 둔다 -- **app/ 내부 레벨업(형제 라우트 간 공유)** → 부모에 `_common/`을 만들고 그 안에 `_components/`, `_hooks/`, `_consts/`, `_types/` 배치 -- **`app/` top-level 라우트를 넘나들면** `_common/`을 더 이상 쌓지 않고 `src/components/common/`(또는 `hooks/`, `utils/` 등)으로 이동 -- `app/_common/`, `app/_components/` ❌ — top-level 간 공유는 App Router 밖에서 관리 -- 필요한 폴더만 생성 — 빈 폴더는 만들지 않음 - -**`components/common` (App Router 밖):** -- `app/` **top-level 라우트 간** 공유되거나, **앱 전역 범용 UI**는 여기로 이동 -- `{component-name}/` **kebab-case 폴더** 안에 둔다 — 바로 아래 `.tsx` 금지 -- import는 폴더까지만 — `@/components/common/{component-name}` - -**컴포넌트 폴더 내부 파일명:** - -| 파일 종류 | 규칙 | 예시 | -| --- | --- | --- | -| **대표 컴포넌트** | `index.tsx` (default export) | `button/index.tsx` | -| **보조 컴포넌트** | PascalCase | `UserProfile.tsx`, `ButtonLink.tsx` | -| **스타일 (cva)** | camelCase + `.style.ts` | `button.style.ts`, `stateChip.style.ts` | -| **상수/타입** | camelCase + `.const.ts` | `userProfile.const.ts` | - -- **폴더명**: kebab-case (`button/`, `wish-card/`, `state-chip/`) - -```tsx -// ✅ Good -import Button from '@/components/common/button'; -import StateChip from '@/components/common/state-chip'; -import { stateChipStyles } from '@/components/common/state-chip/stateChip.style'; - -// ❌ Bad — common 바로 아래 파일, PascalCase 폴더, 중복 경로 -import Button from '@/components/common/Button'; -import Button from '@/components/common/Button/Button'; -``` - -### Path Alias -`@/*` → `apps/web/src/*` - ---- - -## 📝 네이밍 컨벤션 - -| 대상 | 규칙 | 예시 | -|---|---|---| -| **폴더** | kebab-case | `wish-card/`, `state-chip/` | -| **공통 컴포넌트 본체** | `index.tsx` | `components/common/button/index.tsx` | -| **보조 컴포넌트 파일** | PascalCase | `UserProfile.tsx`, `ButtonLink.tsx` | -| **스타일/상수 파일** | camelCase | `button.style.ts`, `userProfile.const.ts` | -| **일반 파일** (훅, 유틸) | camelCase | `useAuth.ts`, `formatDate.ts` | -| **타입** | T suffix | `UserT`, `ProductT` | -| **API 함수** | HTTP 메서드 prefix | `getUser`, `postWishlist`, `patchProfile`, `deleteItem` | -| **API 요청/응답 타입** | 함수명 + `RequestT`/`ResponseT` | `PostWishRequestT`, `PostWishResponseT` | -| **API 훅** | `use` + 함수명 | `usePostWish`, `usePatchItem`, `useGetWishlist` | -| **공통 객체 타입** | `src/types/` 에 위치, T suffix | `WishT`, `ItemT`, `UserT` | - ---- - -## 🌐 API 컨벤션 - -- **요청/응답 타입**: 함수명을 PascalCase로 한 뒤 `RequestT` / `ResponseT` 접미 - - `postWish` → `PostWishRequestT`, `PostWishResponseT` -- **API 훅**: `use` + 함수명 - - `postWish` → `usePostWish`, `patchItem` → `usePatchItem` -- **API endpoint**: `src/consts/api.ts` 에 상수로 모아서 관리 -- **공통 객체 타입**(wish/item/tournament 등 도메인 모델): `src/types/.ts` - - 예: `src/types/wish.ts` 에 `WishT`, `src/types/item.ts` 에 `ItemT` -- **API 함수 위치**: - - 단일 페이지 전용 → `app//_apis/` - - 2개 이상 페이지에서 공유 → `src/apis/` - ---- - -## 🎨 코딩 컨벤션 - -### 컴포넌트 -- **`function` 키워드 + default export** - -```tsx -function MyComponent({ children }: MyComponentProps) { - return
{children}
; -} - -export default MyComponent; -``` - -### 유틸 함수 -- **화살표 함수** 사용 - -```ts -const formatDate = (date: Date) => date.toISOString(); -``` - -### 타입 선언 -- **`type` 사용** (interface 대신) -- **T suffix** (컨벤션상 타입 선언 시) -- Props 타입명: `{ComponentName}Props` - -```ts -type UserT = { - id: number; - name: string; -}; - -type MyComponentProps = { - children: React.ReactNode; -}; -``` - -### Props 네이밍 -- **내부 핸들러**: `handle-` (예: `handleClick`, `handleSubmit`) -- **외부에서 받는 props**: `on-` (예: `onClick`, `onSubmit`) - -```tsx -function Button({ onClick }: ButtonProps) { - const handleClick = () => { - // 내부 로직 - onClick?.(); - }; - return ; -} -``` - -### API 훅 반환값 네이밍 (TanStack Query) -훅 내부에서 의미 있는 이름으로 rename 후 반환 — 호출하는 쪽에서 매번 `data: xxx` 처럼 rename하지 않도록. - -- **Query**: `data` → `{도메인}Data` -- **Mutation**: - - `mutate` → `{HTTP 메서드 prefix + 도메인}Mutation` - - `isPending` → `is{HTTP 메서드 prefix + 도메인}Pending` - -```ts -// ✅ Query — data를 도메인명 + Data로 -export const useGetWishlist = () => { - const { data: wishlistData } = useQuery({ queryKey: ['wishlists'], queryFn: getWishlist }); - return { wishlistData }; -}; -// 사용처: const { wishlistData } = useGetWishlist(); - -// ✅ Mutation — mutate/isPending에 API 함수명 + Mutation/Pending 접미 -export const usePostGuestLogin = () => { - const { mutate: postGuestLoginMutation, isPending: isPostGuestLoginPending } = useMutation({ - mutationFn: postGuestLogin, - }); - return { postGuestLoginMutation, isPostGuestLoginPending }; -}; -// 사용처: const { postGuestLoginMutation } = usePostGuestLogin(); -``` - -### 기타 -- **세미콜론**: 사용 (`semi: true`) -- **따옴표**: `singleQuote: true` (`'홑따옴표'`) -- **printWidth**: 100 -- **trailingComma**: `'es5'` -- **arrowParens**: `'avoid'` (인자 1개 시 괄호 생략) - ---- - -## 🧩 RSC / UI / Next 가이드 - -- **`page.tsx`는 가능하면 RSC** — `'use client'`는 상호작용이 필요한 자식 컴포넌트로 내림 -- **고정 width 지양** — 모바일은 `w-full + px-5` 패턴, 상한은 `max-w-*`로 -- **Semantic tag** — 컨테이너는 `
`, 타이틀은 `

`/`

` -- **클릭 요소엔 `cursor-pointer`** (Tailwind v4는 자동 적용 X, 공통 `Button`은 cva에서 처리됨) -- **Next 기능 우선** — ``, ``, `next/font` 등. `router.push`는 부수 작업(Dialog 닫기, API 후 처리)이 있을 때만 - ---- - -## 📦 Import 규칙 - -### 정렬 (자동화됨 — `@trivago/prettier-plugin-sort-imports`) -``` -1. 외부 라이브러리 () -2. 절대경로 (^@/) -3. 상대경로 (^[./]) -``` - -### 예시 -```tsx -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; - -import { apiClient } from '@/apis/apiClient'; -import { UserT } from '@/types/user'; - -import { formatDate } from './utils'; -import styles from './Page.module.css'; -``` - -### Prettier 옵션 -- `importOrderSeparation: true` (그룹 간 빈 줄) -- `importOrderSortSpecifiers: true` (그룹 내 알파벳 정렬) - ---- - -## 🚨 ESLint 주요 규칙 - -- `no-console`: warn/error만 허용 (`console.log` 금지) -- `no-nested-ternary`: error (중첩 삼항 금지) -- `@typescript-eslint/consistent-type-imports`: error (`import type` 강제) -- `@typescript-eslint/no-explicit-any`: error (`any` 금지) -- `unused-imports/no-unused-imports`: error -- `_` prefix 변수/인자는 unused 허용 - ---- - -## 🔀 Git 전략 - -### 브랜치 구조 -``` -main ← dev ← {type}/{issue-number}-{description} -``` - -### 브랜치 네이밍 -- **패턴**: `{type}/{issue-number}-{description}` -- **예시**: `feat/1-login-page`, `chore/3-web-setup`, `fix/5-auth-bug` -- **타입**: `feat`, `fix`, `chore`, `docs`, `refactor`, `style`, `test` - -### 커밋 메시지 -- **형식**: `{type}: {한글 설명}` -- **예시**: - - `feat: 로그인 페이지 구현` - - `chore: Prettier 설정 추가` - - `fix: 토큰 만료 처리 수정` - -### 머지 방식 -- **Squash Merge** 사용 -- PR 제목이 그대로 dev의 커밋 메시지가 됨 → PR 제목 신중히 작성 - -### PR 컨벤션 -- **base 브랜치**: `dev` (main 아님) -- **템플릿**: -```markdown -## 작업 내용 -[내용 정리] - -## 스크린샷 - -## 연관 이슈 -closes #이슈번호 -``` - -### 코드 리뷰 — PN 룰 -- **P1** (Request changes): 꼭 반영 — 중대한 오류 가능성 -- **P2** (Request changes): 적극 고려 — 수용 or 토론 -- **P3** (Comment): 웬만하면 반영 — 미반영 시 사유 설명 -- **P4** (Approve): 반영해도/안해도 OK — 고민 정도 -- **P5** (Approve): 사소한 의견 — 무시 가능 - -### 리뷰 규칙 -- **랜덤 1명 승인** 시 머지 가능 -- 리뷰 자동 배정 워크플로우 사용 - ---- - -## 🌐 API 통신 — Response Schema 규약 - -### 기본 원칙 -1. **HTTP Status Code는 REST 의미대로 사용** (200, 201, 400, 401, 403, 404, 500) -2. **성공/실패 Body 구조 통일** -3. **`fetch`는 4xx/5xx에서 자동 throw 안 함** → 반드시 `response.ok` 확인 -4. **`success` 필드 사용 안 함** (`response.ok`와 중복) - -### 응답 구조 -```json -{ - "status": 200, - "data": {}, - "detail": "요청이 정상적으로 처리되었습니다.", - "code": "COMMON_SUCCESS" -} -``` - -| 필드 | 설명 | -|---|---| -| `status` | HTTP Status Code와 동일 | -| `data` | 실제 비즈니스 응답 데이터 (실패 시 `null`) | -| `detail` | 응답 메시지 (사용자 표시용) | -| `code` | 서버 정의 Enum 코드 (세부 분기용) | - -### 검증 오류 응답 (400) -```json -{ - "status": 400, - "data": null, - "detail": "입력값이 올바르지 않습니다.", - "code": "INVALID_INPUT", - "errors": [ - { "field": "url", "reason": "URL 형식이 올바르지 않습니다." } - ] -} -``` - -### 페이지 응답 구조 -```json -{ - "status": 200, - "data": [ { "wishId": 1, "title": "셔츠" } ], - "detail": "요청이 정상적으로 처리되었습니다.", - "code": "COMMON_SUCCESS", - "pageInfo": { "nextCursor": "abc123", "hasNext": true } -} -``` - -### 프론트엔드 fetch 처리 표준 -```ts -export async function request(url: string, options?: RequestInit) { - const response = await fetch(url, options); - - let body; - try { - body = await response.json(); - } catch { - throw new Error('서버 응답을 해석할 수 없습니다.'); - } - - if (response.ok) { - return body.data; - } - - throw new Error(body.detail || '요청 처리 중 오류가 발생했습니다.'); -} -``` - -### 검증 오류 세부 처리 -```ts -if (response.status === 400 && body.errors) { - return body.errors; -} -``` - -### 분기 처리 (세부 에러) -```ts -if (body.code === 'WISH_NOT_FOUND') { - // 특정 에러 처리 -} -``` - -### HTTP Status 사용 기준 -| 상황 | HTTP Status | -|---|---| -| 조회 성공 | 200 OK | -| 생성 성공 | 201 Created | -| 잘못된 요청 | 400 Bad Request | -| 인증 실패 | 401 Unauthorized | -| 권한 없음 | 403 Forbidden | -| 리소스 없음 | 404 Not Found | -| 서버 오류 | 500 Internal Server Error | - ---- - -## 🤖 Cursor AI 작업 지침 - -### 코드 생성 시 -- **위 컨벤션을 반드시 준수** -- 신규 컴포넌트: `function` 키워드 + PascalCase 파일명 + default export -- 신규 훅: 화살표 함수 + camelCase 파일명 -- 신규 타입: `type` 키워드 + T suffix -- API 함수: HTTP 메서드 prefix (`getUser`, `postWishlist` 등) -- 경로는 `@/*` 절대경로 우선, 같은 디렉토리는 상대경로 - -### 커밋 메시지 제안 시 -- `{type}: {한글 설명}` 형식 사용 -- type: feat, fix, chore, docs, refactor, style, test - -### PR 작성 시 -- 제목: `{type}: {한글 설명}` 형식 -- 본문에 `closes #이슈번호` 포함 -- base 브랜치는 `dev` - -### 리뷰 코멘트 작성 시 -- PN 태그 (P1~P5) 사용 -- 이유 명확히 설명 - -### API 관련 코드 생성 시 -- HTTP status 기반 분기 (`response.ok`) -- body 구조: `{ status, data, detail, code }` 가정 -- fetch 래퍼 함수 활용 패턴 - - -### 추천 패턴 -- ✅ TanStack Query로 API 호출 래핑 -- ✅ Zod로 응답 검증 -- ✅ Zustand로 전역 상태 -- ✅ Tailwind 클래스 사용 (CSS Module 지양) -- ✅ Error Boundary / Suspense는 필요한 페이지만 선택 적용 - -### 의심스러울 때 -- 기존 코드 패턴 확인 -- 팀 컨벤션 우선 (이 문서) -- `CLAUDE.md` 참고 (동일 내용) -- 판단 어려우면 사용자에게 확인 요청 diff --git a/CLAUDE.md b/CLAUDE.md index 4c862bc12..a1aa4f287 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,8 @@ apps/web/src/ │ └── {component-name}/ # 폴더명: kebab-case │ ├── index.tsx # 컴포넌트 본체 (default export) │ ├── {componentName}.style.ts # cva variants (스타일 분리 시) -│ └── {componentName}.const.ts # 상수/타입 (필요 시) +│ ├── {componentName}.const.ts # 상수 (필요 시) +│ └── {componentName}.types.ts # 타입 (필요 시) ├── apis/ # API 호출 함수 (HTTP 메서드 prefix 컨벤션) ├── hooks/ # 커스텀 훅 ├── utils/ # 공통 유틸리티 함수 @@ -170,7 +171,8 @@ apps/web/src/ | **대표 컴포넌트** | `index.tsx` (default export) | `button/index.tsx` | | **보조 컴포넌트** | PascalCase | `UserProfile.tsx`, `ButtonLink.tsx` | | **스타일 (cva)** | camelCase + `.style.ts` | `button.style.ts`, `stateChip.style.ts` | -| **상수/타입** | camelCase + `.const.ts` | `userProfile.const.ts` | +| **상수** | camelCase + `.const.ts` | `joinErrorDialog.const.ts` | +| **타입** | camelCase + `.types.ts` | `userProfile.types.ts` | - **폴더명**: kebab-case (`button/`, `wish-card/`, `state-chip/`) @@ -198,7 +200,7 @@ import Button from '@/components/common/Button/Button'; | **폴더** | kebab-case | `wish-card/`, `state-chip/` | | **공통 컴포넌트 본체** | `index.tsx` | `components/common/button/index.tsx` | | **보조 컴포넌트 파일** | PascalCase | `UserProfile.tsx`, `ButtonLink.tsx` | -| **스타일/상수 파일** | camelCase | `button.style.ts`, `userProfile.const.ts` | +| **스타일/상수/타입 파일** | camelCase | `button.style.ts`, `userProfile.types.ts` | | **일반 파일** (훅, 유틸) | camelCase | `useAuth.ts`, `formatDate.ts` | | **타입** | T suffix | `UserT`, `ProductT` | | **API 함수** | HTTP 메서드 prefix | `getUser`, `postWishlist`, `patchProfile`, `deleteItem` | diff --git a/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx b/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx index afce91b38..e7647a244 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx @@ -12,14 +12,12 @@ import { useQueryAction } from '@/hooks/useQueryAction'; import { useSSEFallback } from '@/hooks/useSSEFallback'; import { hasParsingItems } from '@/utils/item'; -import { type JoinConfirmPayloadT, consumeJoinConfirmFor } from '../../../join/_utils/joinSession'; import { useGetTournament } from '../../_common/_hooks/useGetTournament'; import { PREV_ITEM_COUNT_KEY } from '../_consts/tournamentItemBasket'; import { useCountdown } from '../_hooks/useCountdown'; import { usePostTournamentStart } from '../_hooks/usePostTournamentStart'; -import { hasSentInvite } from '../_utils/inviteSentSession'; +import { hasSentInvite } from '@/utils/inviteSentSession'; import DepositClosedDialog from './deposit-closed-dialog/DepositClosedDialog'; -import MemberJoinConfirmDialog from './member-join-confirm-dialog/MemberJoinConfirmDialog'; import OwnerStartedDialog from './owner-started-dialog/OwnerStartedDialog'; import ParticipantPanel from './participant-panel/ParticipantPanel'; import TournamentHeader from './tournament-header/TournamentHeader'; @@ -112,9 +110,6 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { (pending?.participants ?? []).map(p => [p.userId, p.profileImage]) ); - const [confirmPayload, setConfirmPayload] = useState(() => - consumeJoinConfirmFor(tournamentId) - ); const isParticipant = !tournamentData.isOwner; // 참여자는 주최자가 ROOT 를 시작한 후(ownerStarted=true) 부터 본인 CLONE 시작 가능. const isWaitingForOwnerStart = isParticipant && pending?.ownerStarted === false; @@ -182,8 +177,6 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { if (!open) setHasDismissedOwnerStarted(true); }; - const handleCloseConfirm = () => setConfirmPayload(null); - return (
@@ -262,21 +255,6 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { onConfirm={() => setIsWelcomeOpen(false)} /> )} - - {confirmPayload && ( - { - if (!open) handleCloseConfirm(); - }} - nickname={confirmPayload.nickname} - profileType={confirmPayload.profileType} - tournamentName={confirmPayload.tournamentName} - itemCount={confirmPayload.itemCount} - participantCount={confirmPayload.participantCount} - onConfirm={handleCloseConfirm} - /> - )}
); } diff --git a/apps/web/src/app/tournament/[id]/create/_components/deposit-countdown/DepositCountdown.tsx b/apps/web/src/app/tournament/[id]/create/_components/deposit-countdown/DepositCountdown.tsx index 014bfe2f2..9e5aa98c3 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/deposit-countdown/DepositCountdown.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/deposit-countdown/DepositCountdown.tsx @@ -13,7 +13,7 @@ function DepositCountdown({ deadline, showLabel = true }: DepositCountdownProps) const { remaining } = useCountdown(deadline); return ( -
+

{remaining ?? '--:--:--'} diff --git a/apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriendsDialog.tsx b/apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriendsDialog.tsx index d1a459eb2..fcd3302af 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriendsDialog.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriendsDialog.tsx @@ -13,7 +13,7 @@ import { parseServerLocalDateTime } from '@/utils/formatDate'; import { share } from '@/utils/share'; import { usePatchInviteExpiry } from '../../_hooks/usePatchInviteExpiry'; -import { markInviteSent } from '../../_utils/inviteSentSession'; +import { markInviteSent } from '@/utils/inviteSentSession'; import InviteExpiresPicker from './InviteExpiresPicker'; type InviteFriendsDialogProps = { diff --git a/apps/web/src/app/tournament/[id]/create/_components/member-join-confirm-dialog/MemberJoinConfirmDialog.tsx b/apps/web/src/app/tournament/[id]/create/_components/member-join-confirm-dialog/MemberJoinConfirmDialog.tsx deleted file mode 100644 index c1107eaa2..000000000 --- a/apps/web/src/app/tournament/[id]/create/_components/member-join-confirm-dialog/MemberJoinConfirmDialog.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client'; - -import Button from '@/components/button'; -import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/drawer'; -import { PROFILE_SVG, type ProfileTypeT } from '@/components/user-profile-group/userProfile.const'; - -type MemberJoinConfirmDialogProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - nickname: string; - profileType: ProfileTypeT; - tournamentName: string; - itemCount: number; - participantCount: number; - onConfirm: () => void; -}; - -function MemberJoinConfirmDialog({ - open, - onOpenChange, - nickname, - profileType, - tournamentName, - itemCount, - participantCount, - onConfirm, -}: MemberJoinConfirmDialogProps) { - const ProfileSvg = PROFILE_SVG[profileType]; - - return ( - - -

-
- -
- {nickname} - - 이 프로필로 참여할게요. - -
-
- -
-

{tournamentName}

-

- 후보 {itemCount}개 · 참여 {participantCount}명 -

-
- - -
- - - ); -} - -export default MemberJoinConfirmDialog; diff --git a/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantChip.tsx b/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantChip.tsx index 2d2ed6c33..577bb8075 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantChip.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantChip.tsx @@ -1,5 +1,5 @@ import UserProfile from '@/components/user-profile-group/UserProfile'; -import type { UserT } from '@/components/user-profile-group/userProfile.const'; +import type { UserT } from '@/components/user-profile-group/userProfile.types'; import { useGetMe } from '@/hooks/useGetMe'; type ParticipantChipProps = { diff --git a/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantPanel.tsx b/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantPanel.tsx index e2c6591fe..7f131884a 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantPanel.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantPanel.tsx @@ -5,7 +5,7 @@ import { useState } from 'react'; import { AddIconOutline, ChevronDownIconOutline, ChevronUpIconOutline } from '@/assets/icons'; import UserProfileGroup from '@/components/user-profile-group'; -import type { UserT } from '@/components/user-profile-group/userProfile.const'; +import type { UserT } from '@/components/user-profile-group/userProfile.types'; import { ROUTES } from '@/consts/route'; import { Z_INDEX } from '@/consts/zIndex'; import { cn } from '@/utils/cn'; @@ -54,9 +54,7 @@ function ParticipantPanel({ const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); const hasFriends = participants.length > 1; - const profileImageUrls = participants.flatMap(({ user }) => - user.imageUrl ? [user.imageUrl] : [] - ); + const profileImageUrls = participants.map(({ user }) => user.imageUrl); const inviteUrl = inviteCode ? buildInviteUrl(tournamentId, inviteCode) : ''; const handleToggleExpand = () => setIsExpanded(prev => !prev); diff --git a/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentStart.ts b/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentStart.ts index d3da21cc9..9eee1012b 100644 --- a/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentStart.ts +++ b/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentStart.ts @@ -8,6 +8,7 @@ import { ROUTES } from '@/consts/route'; import { logAnalyticsEvent } from '@/utils/analytics'; import { getApiErrorCode, getApiErrorStatus, isGlobalNetError } from '@/utils/apiError'; import { getApiErrorMessage } from '@/utils/getApiErrorMessage'; +import { clearInviteSent } from '@/utils/inviteSentSession'; import { postTournamentStart } from '../_apis/postTournamentStart'; @@ -21,6 +22,7 @@ export const usePostTournamentStart = (tournamentId: number) => { // - 주최자: ROOT ID (요청 tournamentId 와 동일) // - 참여자: 새로 생성된 CLONE ID (이후 본인 인스턴스로 진행) onSuccess: ({ tournamentId: nextTournamentId }) => { + clearInviteSent(tournamentId); logAnalyticsEvent(ANALYTICS_EVENT.TOURNAMENT_START, { tournament_id: nextTournamentId, source_tournament_id: tournamentId, diff --git a/apps/web/src/app/tournament/[id]/result/_components/ResultGuestBanner.tsx b/apps/web/src/app/tournament/[id]/result/_components/ResultGuestBanner.tsx index 9ee7bc852..ad4425a21 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/ResultGuestBanner.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/ResultGuestBanner.tsx @@ -3,9 +3,7 @@ import Link from 'next/link'; import { useEffect } from 'react'; -import ResultGuestBannerGroupIllustration from '@/assets/images/result-guest-banner-group.svg'; -import UserProfileGreenIcon from '@/assets/images/user-profile-green.svg'; -import UserProfileYellowIcon from '@/assets/images/user-profile-yellow.svg'; +import ResultGuestBannerIllustration from '@/assets/images/result-guest-banner-illustration.svg'; import { ANALYTICS_EVENT } from '@/consts/analytics'; import { ROUTES } from '@/consts/route'; import { logAnalyticsEvent } from '@/utils/analytics'; @@ -31,39 +29,9 @@ function ResultGuestBanner() {

가입하고 토너먼트 주최하기

- {/* 노란 이모지 — 그룹 일러스트 뒤에 위치 */} - - {/* 그룹 일러스트 (블루 카드 + 하트 + 스파클) */} - - {/* 초록 이모지 — 그룹 일러스트 앞에 위치 */} - ); diff --git a/apps/web/src/app/tournament/[id]/result/_components/plate-share-dialog/PlateShareDialog.tsx b/apps/web/src/app/tournament/[id]/result/_components/plate-share-dialog/PlateShareDialog.tsx index 8341ce39d..afee97391 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/plate-share-dialog/PlateShareDialog.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/plate-share-dialog/PlateShareDialog.tsx @@ -7,6 +7,7 @@ import Button from '@/components/button'; import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/drawer'; import Spinner from '@/components/spinner'; import { ANALYTICS_EVENT } from '@/consts/analytics'; +import { ROUTES } from '@/consts/route'; import { logAnalyticsEvent } from '@/utils/analytics'; import { share } from '@/utils/share'; @@ -21,8 +22,9 @@ type PlateShareDialogProps = { }; const buildPlayLinkUrl = (tournamentId: number) => { - if (typeof window === 'undefined') return `/play/${tournamentId}`; - return `${window.location.origin}/play/${tournamentId}`; + const path = ROUTES.PLAY_FROM_LINK(tournamentId); + if (typeof window === 'undefined') return path; + return `${window.location.origin}${path}`; }; function PlateShareDialog({ diff --git a/apps/web/src/app/tournament/join/_utils/joinSession.ts b/apps/web/src/app/tournament/join/_utils/joinSession.ts deleted file mode 100644 index 32fbddd6d..000000000 --- a/apps/web/src/app/tournament/join/_utils/joinSession.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ProfileTypeT } from '@/components/user-profile-group/userProfile.const'; - -const CONFIRM_KEY = 'piki:joinConfirm'; - -export type JoinConfirmPayloadT = { - tournamentId: number; - nickname: string; - profileType: ProfileTypeT; - tournamentName: string; - itemCount: number; - participantCount: number; -}; - -const writeJson = (key: string, value: unknown) => { - if (typeof window === 'undefined') return; - sessionStorage.setItem(key, JSON.stringify(value)); -}; - -const readJson = (key: string): T | null => { - if (typeof window === 'undefined') return null; - const raw = sessionStorage.getItem(key); - if (!raw) return null; - try { - return JSON.parse(raw) as T; - } catch { - return null; - } -}; - -export const consumeJoinConfirmFor = (tournamentId: number): JoinConfirmPayloadT | null => { - const payload = readJson(CONFIRM_KEY); - if (!payload || payload.tournamentId !== tournamentId) return null; - sessionStorage.removeItem(CONFIRM_KEY); - return payload; -}; diff --git a/apps/web/src/assets/images/result-guest-banner-group.svg b/apps/web/src/assets/images/result-guest-banner-group.svg deleted file mode 100644 index 1fb3d95ba..000000000 --- a/apps/web/src/assets/images/result-guest-banner-group.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/src/assets/images/result-guest-banner-illustration.svg b/apps/web/src/assets/images/result-guest-banner-illustration.svg new file mode 100644 index 000000000..4ad87c2df --- /dev/null +++ b/apps/web/src/assets/images/result-guest-banner-illustration.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/images/user-profile-blue.svg b/apps/web/src/assets/images/user-profile-blue.svg deleted file mode 100644 index 04964aff9..000000000 --- a/apps/web/src/assets/images/user-profile-blue.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/web/src/assets/images/user-profile-green.svg b/apps/web/src/assets/images/user-profile-green.svg deleted file mode 100644 index 1911c594c..000000000 --- a/apps/web/src/assets/images/user-profile-green.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/web/src/assets/images/user-profile-yellow.svg b/apps/web/src/assets/images/user-profile-yellow.svg deleted file mode 100644 index 130c1145e..000000000 --- a/apps/web/src/assets/images/user-profile-yellow.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/web/src/components/tournament-card/FriendListDialog.tsx b/apps/web/src/components/tournament-card/FriendListDialog.tsx index e28ac0998..a6a7594cc 100644 --- a/apps/web/src/components/tournament-card/FriendListDialog.tsx +++ b/apps/web/src/components/tournament-card/FriendListDialog.tsx @@ -7,13 +7,13 @@ import { ProfileCircledFilledIconOutline } from '@/assets/icons'; import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/dialog'; import Spinner from '@/components/spinner'; import UserProfile from '@/components/user-profile-group/UserProfile'; -import type { ProfileTypeT, UserT } from '@/components/user-profile-group/userProfile.const'; +import type { UserT } from '@/components/user-profile-group/userProfile.types'; import { useGetMe } from '@/hooks/useGetMe'; type FriendListItemT = { userId: string; nickname: string; - profileImage?: string; + profileImage: string; /** 본인 여부 — true 면 우측에 "나" 배지 표시 */ isMe?: boolean; }; @@ -25,18 +25,10 @@ type FriendListDialogProps = { tournamentId: number; }; -/** 닉네임 → 안정적인 profileType(blue/yellow) 매핑. 이미지가 없을 때만 사용. */ -const pickProfileType = (seed: string): ProfileTypeT => { - let hash = 0; - for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0; - return Math.abs(hash) % 2 === 0 ? 'blue' : 'yellow'; -}; - const toUser = (friend: FriendListItemT): UserT => ({ id: friend.userId, name: friend.nickname, - profileType: pickProfileType(friend.userId || friend.nickname), - ...(friend.profileImage ? { imageUrl: friend.profileImage } : {}), + imageUrl: friend.profileImage, }); function FriendListDialog({ open, onOpenChange, tournamentId }: FriendListDialogProps) { diff --git a/apps/web/src/components/user-profile-group/UserProfile.tsx b/apps/web/src/components/user-profile-group/UserProfile.tsx index 2b07b0bd3..87cddad95 100644 --- a/apps/web/src/components/user-profile-group/UserProfile.tsx +++ b/apps/web/src/components/user-profile-group/UserProfile.tsx @@ -2,7 +2,7 @@ import Image from 'next/image'; import { cn } from '@/utils/cn'; -import { PROFILE_SVG, type UserT } from './userProfile.const'; +import type { UserT } from './userProfile.types'; type UserProfileProps = { user: UserT; @@ -10,36 +10,20 @@ type UserProfileProps = { }; function UserProfile({ user, className }: UserProfileProps) { - const SvgComponent = PROFILE_SVG[user.profileType ?? 'blue']; - - if (user.imageUrl) { - return ( - - {`${user.name} - - ); - } - return ( - + {`${user.name} ); } diff --git a/apps/web/src/components/user-profile-group/userProfile.const.ts b/apps/web/src/components/user-profile-group/userProfile.const.ts deleted file mode 100644 index b111b4f11..000000000 --- a/apps/web/src/components/user-profile-group/userProfile.const.ts +++ /dev/null @@ -1,17 +0,0 @@ -import UserProfileBlue from '@/assets/images/user-profile-blue.svg'; -import UserProfileYellow from '@/assets/images/user-profile-yellow.svg'; - -export type ProfileTypeT = 'blue' | 'yellow'; - -export type UserT = { - id: string | number; - name: string; - /** imageUrl 없을 때 기본 SVG 프로필 색상 */ - profileType?: ProfileTypeT; - imageUrl?: string; -}; - -export const PROFILE_SVG: Record = { - blue: UserProfileBlue, - yellow: UserProfileYellow, -}; diff --git a/apps/web/src/components/user-profile-group/userProfile.types.ts b/apps/web/src/components/user-profile-group/userProfile.types.ts new file mode 100644 index 000000000..9e8016fec --- /dev/null +++ b/apps/web/src/components/user-profile-group/userProfile.types.ts @@ -0,0 +1,5 @@ +export type UserT = { + id: string; + name: string; + imageUrl: string; +}; diff --git a/apps/web/src/consts/api.ts b/apps/web/src/consts/api.ts index fc022f068..9f5c2b794 100644 --- a/apps/web/src/consts/api.ts +++ b/apps/web/src/consts/api.ts @@ -1,7 +1,6 @@ export const ENDPOINTS = { /** 유저 */ USER: '/api/v1/users/me', - USER_PROFILE_IMAGE: '/api/v1/users/me/profile-image', USER_NICKNAME_CHECK: '/api/v1/users/nickname/check', /** 인증 */ diff --git a/apps/web/src/hooks/useDeleteTournament.ts b/apps/web/src/hooks/useDeleteTournament.ts index bb62b23eb..291d05d9b 100644 --- a/apps/web/src/hooks/useDeleteTournament.ts +++ b/apps/web/src/hooks/useDeleteTournament.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner'; import { deleteTournament } from '@/apis/deleteTournament'; import { QUERY_KEYS } from '@/consts/queryKeys'; +import { clearInviteSent } from '@/utils/inviteSentSession'; export const useDeleteTournament = (tournamentId: number) => { const queryClient = useQueryClient(); @@ -10,6 +11,7 @@ export const useDeleteTournament = (tournamentId: number) => { const { mutate: deleteTournamentMutation, isPending: isDeleteTournamentPending } = useMutation({ mutationFn: () => deleteTournament(tournamentId), onSuccess: () => { + clearInviteSent(tournamentId); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.TOURNAMENT.LIST.ALL }); queryClient.invalidateQueries({ queryKey: ['tournament', tournamentId] }); toast.success('토너먼트를 삭제했어요.'); diff --git a/apps/web/src/hooks/useInstagramStoryShare.ts b/apps/web/src/hooks/useInstagramStoryShare.ts index 604408b20..b472cff4a 100644 --- a/apps/web/src/hooks/useInstagramStoryShare.ts +++ b/apps/web/src/hooks/useInstagramStoryShare.ts @@ -12,7 +12,7 @@ const RESPONSE_TIMEOUT_MS = 15_000; /** `blocked` 는 앱 버전 게이트에 막혀 전송조차 안 된 경우 */ /** 'blocked' 앱 버전 게이트에 막힘 · 'busy' 이미 공유 진행 중 */ -export type InstagramStoryShareResultT = ShareInstagramStoryStatusT | 'blocked' | 'busy'; +type InstagramStoryShareResultT = ShareInstagramStoryStatusT | 'blocked' | 'busy'; type PendingRequestT = { resolve: (status: ShareInstagramStoryStatusT) => void; diff --git a/apps/web/src/app/tournament/[id]/create/_utils/inviteSentSession.ts b/apps/web/src/utils/inviteSentSession.ts similarity index 82% rename from apps/web/src/app/tournament/[id]/create/_utils/inviteSentSession.ts rename to apps/web/src/utils/inviteSentSession.ts index 7765e14a4..7ea780bf6 100644 --- a/apps/web/src/app/tournament/[id]/create/_utils/inviteSentSession.ts +++ b/apps/web/src/utils/inviteSentSession.ts @@ -28,3 +28,12 @@ export const markInviteSent = (tournamentId: number): void => { /* private mode 등 — 무시 */ } }; + +export const clearInviteSent = (tournamentId: number): void => { + if (typeof window === 'undefined') return; + try { + window.localStorage.removeItem(getStorageKey(tournamentId)); + } catch { + /* private mode 등 — 무시 */ + } +}; diff --git a/apps/web/src/utils/scrollRestoration.ts b/apps/web/src/utils/scrollRestoration.ts index cc3d22cbe..51c387c81 100644 --- a/apps/web/src/utils/scrollRestoration.ts +++ b/apps/web/src/utils/scrollRestoration.ts @@ -12,7 +12,7 @@ export const SCROLL_NAMESPACE = { ARCHIVE_TOURNAMENT: 'archiveTournament', } as const; -export type ScrollAnchorT = { +type ScrollAnchorT = { anchorId: number; offset: number; };