From 041dd613d16adca81857dbc06b0b43fd61410547 Mon Sep 17 00:00:00 2001 From: geunu Date: Mon, 25 May 2026 21:26:05 +0900 Subject: [PATCH 01/41] [WEB-USER][UPDATE]: Enhance error and loading handling in web user app documentation - Updated the documentation to clarify the current implementation of error and loading handling in the Picake web user app. - Added detailed sections on loading processing, including the use of Skeleton components and React Query for managing loading states. - Improved structure and readability of the document by consolidating related information and providing clear examples for various loading scenarios. - Highlighted differences in error handling compared to other apps, ensuring a focused discussion on the web user app's unique approach. --- ...- \352\260\200\354\235\264\353\223\234.md" | 335 +++++++++++------- 1 file changed, 200 insertions(+), 135 deletions(-) diff --git "a/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" "b/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" index c92f7c13..866291a6 100644 --- "a/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" +++ "b/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" @@ -2,201 +2,266 @@ ## 개요 -이 문서는 Picake 웹 사용자 앱에서 에러와 로딩을 처리하는 방식에 대해 설명합니다. +이 문서는 Picake **웹 사용자 앱**(`apps/web-user`)에서 에러와 로딩을 처리하는 **현재 구현**을 설명합니다. -## 에러 처리 +> **다른 앱과의 차이**: `web-seller` / `web-admin`은 `ContentLoading`, `AuthInitializerProvider` 등 별도 패턴을 사용합니다. 사용자 앱은 **Skeleton + `isLoading`** 중심이며, 이 문서는 web-user만 다룹니다. -### 1. ErrorBoundary (UI 에러 처리) +--- -**용도**: React 컴포넌트에서 발생하는 UI 에러를 잡아서 처리 +## 로딩 처리 (요약) -**구현 위치**: `apps/web-user/src/common/components/providers/ErrorBoundaryProvider.tsx` +| 계층 | 수단 | 용도 | +|------|------|------| +| 1 | 루트 `Suspense` + `LoadingFallback` | `useSearchParams` 등으로 suspend 되는 구간 | +| 2 | React Query `isLoading` + **Skeleton** | 페이지·섹션·목록 **첫 로딩** (주력) | +| 3 | `isFetchingNextPage` + 인라인 스피너 | 무한 스크롤 **추가 로딩** | +| 4 | Mutation `isPending` | 버튼·폼 제출·좋아요 등 **액션 로딩** | +| 5 | `Toast` `showSpinner` | 지역 변경 등 짧은 **피드백** | -```typescript -export function ErrorBoundaryProvider({ children }: ErrorBoundaryProviderProps) { - const handleError = (error: Error, errorInfo: ErrorInfo) => { - // 에러 로깅 (추후 에러 모니터링 서비스 연동 가능) - console.error("ErrorBoundary caught an error:", error, errorInfo); - }; +**사용하지 않는 것**: `useSuspenseQuery`, Query `suspense: true`, `throwOnError: true`(주석 처리됨). - return ( - ( - - )} +--- + +## 로딩 처리 (상세) + +### 1. 전역 Suspense + LoadingFallback + +**구현 위치**: `apps/web-user/src/app/layout.tsx` + +```typescript + + + } > - {children} - - ); -} + {children} + + + ``` **특징**: -- `react-error-boundary` 라이브러리 사용 -- 에러 발생 시 `ErrorFallback` 컴포넌트로 대체 -- 에러 로깅 기능 포함 -- 재시도 기능 제공 -- 리액트 쿼리 throwOnError true설정 시, 비동기 에러 발생 시 예외(throw error)를 던지며 상위의 ErrorBoundary 컴포넌트에서 인식됩니다. +- `RootWrapperLayout` 안에서 `AuthProvider`, `Header`, `Alert`, `LoginBottomSheet` 등이 함께 마운트됩니다. +- API 데이터 로딩의 **주된 UI는 Suspense가 아니라** 아래 2번(Skeleton + `isLoading`)입니다. +- 루트 Suspense는 주로 Next.js 클라이언트 컴포넌트가 suspend 할 때(예: `useSearchParams` 사용 페이지) fallback을 보여줍니다. + +**LoadingFallback** (`common/components/fallbacks/LoadingFallback.tsx`): + +| variant | 설명 | +|---------|------| +| `overlay` (기본) | 전체 화면 오버레이 + `loading-spinner-large` | +| `corner` | 우측 하단 작은 스피너 (정의만 있음, **현재 미사용**) | -### 2. React Query onError (비동기 에러 처리) +스피너 스타일: `common/styles/globals.css`의 `.loading-spinner-large`, `.loading-spinner-small` -**용도**: API 호출에서 발생하는 비동기 에러를 처리 +### 2. React Query `isLoading` + Skeleton (주력) -**구현 위치**: `apps/web-user/src/features/auth/hooks/queries/useAuth.ts` +**용도**: `useQuery` / `useInfiniteQuery`의 **첫 fetch** 동안 레이아웃을 유지한 채 로딩 표시 + +**기본 컴포넌트** (`common/components/skeleton/`): + +- `Skeleton`, `SkeletonCircle`, `SkeletonText` — 블록 단위 +- `HomeSkeleton`, `ProductDetailSkeleton`, `StoreDetailSkeleton`, `AlarmSkeleton`, `ChatListSkeleton`, `MyReviewsSkeleton`, `RecentProductsSkeleton` — 화면별 조합 + +**페이지 예시** — 상품 상세: ```typescript -export function useLogin() { - const { showAlert } = useAlertStore(); - - return useMutation({ - mutationFn: authApi.login, - onSuccess: (data) => { - // 성공 처리 - }, - onError: (error) => { - showAlert({ - type: "error", - title: "오류", - message: getApiMessage.error(error), - }); - }, - }); +const { data, isLoading } = useProductDetail(productId); + +if (isLoading) { + return ; } ``` -**특징**: +**섹션 예시** — 홈 슬라이더 (`CakeListSlider`): + +- 부모에서 `isLoading`을 계산해 prop으로 전달 +- 로딩 중에는 카드 형태의 `Skeleton` 블록을 여러 개 렌더 + +**목록 예시** — 검색·좋아요·채팅 등: + +- `isLoading`일 때 섹션/페이지 스켈레톤 또는 빈 목록 대신 placeholder +- 데이터 없음·에러는 별도 분기 + +**선택 가이드**: + +| 상황 | 권장 | +|------|------| +| 전체 페이지 첫 진입 | 전용 `*Skeleton` early return | +| 목록·카드 일부 | `Skeleton` 또는 슬라이더 내 placeholder | +| 버튼 한 번 누름 | `isPending` (아래 4번) | -- 대부분의 API 에러는 `onError`에서 Alert으로 표시 +### 3. 무한 스크롤 추가 로딩 (`isFetchingNextPage`) -### 3. 비즈니스 로직이 있는 경우의 에러 처리 +**훅**: `common/hooks/useInfiniteScroll.ts` — `IntersectionObserver`로 `fetchNextPage` 호출 -**구현 위치**: `apps/web-user/src/app/auth/login/google/page.tsx` +**UI**: 전용 컴포넌트 없이, 목록 하단에 인라인 스피너 ```typescript -const handleGoogleCallback = async (code: string) => { - // 특별한 비즈니스 로직(휴대폰 인증 필요)이 있으므로 try-catch 유지 - try { - await googleLoginMutation.mutateAsync(code); - } catch (error: any) { - const { googleId, googleEmail, message } = error?.response?.data?.data || {}; - - if (message === "휴대폰 인증이 필요합니다.") { - setGoogleLoginData({ googleId, googleEmail }); - setShowPhoneVerification(true); - } else { - // 다른 오류의 경우 로그인 페이지로 이동 - router.push(PATHS.HOME); - } - } -}; +{isFetchingNextPage && ( +
+
+ 더 많은 상품을 불러오는 중... +
+)} ``` -**특징**: +**사용 예**: `SearchProductListSection`, `SearchStoreListSection`, `chat/page`, `mypage/recent`, 좋아요 목록, 주문 목록, 스토어 상품/리뷰 탭 등 -- 비즈니스 로직이 필요한 경우 컴포넌트 내부에서 `try-catch`로 처리 -- `mutateAsync`를 사용하여 에러를 직접 catch -- React Query의 `onError`에서 `throw error`로 에러를 다시 전파 +### 4. Mutation `isPending` (액션 로딩) -### 5. Axios 인터셉터를 통한 에러 처리 +**용도**: 로그인·회원가입·좋아요·결제·업로드·주문 변경 등 **사용자 액션** 중복 방지 및 버튼 상태 표시 -**구현 위치**: `apps/web-user/src/common/config/axios.config.ts` +**구현 위치**: `features/*/hooks/mutations/*`, 각 폼·카드·바텀시트 ```typescript -apiClient.interceptors.response.use( - (response: AxiosResponse) => { - return response; - }, - async (error: AxiosError) => { - const status = error.response?.status; - const message = error.response?.data?.data?.message; - - // 401 에러 처리 (토큰 갱신) - if (status === 401 && message?.includes("ACCESS_TOKEN_INVALID")) { - // 토큰 갱신 로직 - } +const { mutate, isPending } = useSomeMutation(); - // 그 외 에러는 그대로 전파 - return Promise.reject(error); - }, -); + ``` -**특징**: +**패턴**: -- 401 에러 시 자동 토큰 갱신 시도 -- 다른 에러는 그대로 전파 -- 자세한 내용은 [통합 인증 - 가이드](../common/통합 인증 - 가이드.md) 참고 +- `disabled={isPending}` 또는 `if (isPending) return` (낙관적 업데이트 시 `onError` 롤백) +- 여러 mutation 조합: `isAddingLike || isRemovingLike` 등 -## 로딩 처리 +### 5. 페이지 단위 Suspense (OAuth 콜백 등) -### 1. Suspense (UI 로딩 처리) +`useSearchParams`를 쓰는 페이지는 **페이지 내부**에 별도 `Suspense`를 둡니다. 루트 `LoadingFallback`과 별개로, 단순 텍스트 fallback을 쓰는 경우가 많습니다. -**용도**: React 컴포넌트의 로딩 상태를 처리 +**구현 위치**: -**구현 위치**: `apps/web-user/src/app/layout.tsx` +- `app/auth/login/google/page.tsx` +- `app/auth/login/kakao/page.tsx` +- `app/auth/register/google/page.tsx` +- `app/auth/register/kakao/page.tsx` ```typescript - - +export default function GoogleAuthCallbackPage() { + return ( } + fallback={ +
+

로딩 중...

+
+ } > - {children} +
- - -
-
+ ); +} ``` -**특징**: +콜백 본문에서는 `authApi.googleLogin` 등을 `try/catch`로 호출하며, 화면에는 "구글 로그인 처리 중..." 문구를 표시합니다. + +### 6. Toast 스피너 (부가 피드백) + +**구현 위치**: `common/components/headers/Header.tsx` + +지역 선택 후 상품 목록 refetch가 끝날 때까지 `Toast` + `showSpinner` ("위치 설정 중..")를 표시합니다. `useIsFetching({ queryKey: ["product", "list"] })`로 refetch 완료를 감지합니다. -- 전역 Suspense로 페이지 로딩 처리 -- `LoadingFallback` 컴포넌트로 로딩 UI 표시 -- `overlay` 방식으로 전체 화면 로딩 표시 +--- -### 2. React Query isPending (비동기 로딩 처리) +## QueryClient 기본값 -**용도**: API 호출 중 로딩 상태를 처리 +**구현 위치**: `apps/web-user/src/common/config/query-client.ts` -**구현 위치**: 각 폼 컴포넌트들 +- `retry: 0` (queries / mutations) +- `throwOnError: true` — **주석 처리**, ErrorBoundary와 Query 에러를 **연동하지 않음** +- API 로딩 UI는 훅의 `isLoading` / `isPending` / `isFetchingNextPage`로만 처리 + +--- + +## 에러 처리 + +### 1. ErrorBoundary (렌더링/UI 에러) + +**구현 위치**: `apps/web-user/src/common/components/providers/ErrorBoundaryProvider.tsx` + +- `react-error-boundary` + `ErrorFallback` +- `console.error` 로깅 (추후 모니터링 연동 가능) +- **React Query fetch 에러는 기본적으로 여기로 오지 않음** (`throwOnError` 미사용) + +### 2. React Query `onError` (API 에러 — Alert) + +**구현 위치**: `apps/web-user/src/features/auth/hooks/mutations/useAuthMutation.ts` 등 ```typescript -// 로그인 폼 예시 -export default function LoginForm() { - const loginMutation = useLogin(); +return useMutation({ + mutationFn: authApi.someAction, + onError: (error) => { + showAlert({ + type: "error", + title: "오류", + message: getApiMessage.error(error), + }); + }, +}); +``` - return ( - - ); +대부분의 mutation API 에러는 Alert으로 표시합니다. + +### 3. 비즈니스 분기 — `try/catch` + `authApi` + +**구현 위치**: `apps/web-user/src/app/auth/login/google/page.tsx` (카카오 동일 패턴) + +OAuth 콜백에서는 mutation 대신 **`authApi` 직접 호출 + `try/catch`** 로 분기합니다. + +```typescript +try { + const data = await authApi.googleLogin(code); + login(data.accessToken); + router.replace(PATHS.HOME); +} catch (error: unknown) { + const { googleId, googleEmail, message } = /* response 파싱 */; + + if (message === AUTH_ERROR_MESSAGES.PHONE_VERIFICATION_REQUIRED && googleId && googleEmail) { + router.replace(`${PATHS.AUTH.GOOGLE_REGISTER}?...`); + } else { + router.replace(PATHS.HOME); + showAlert({ type: "error", ... }); + } } ``` -**특징**: +휴대폰 미인증 등 **응답 메시지에 따른 라우팅**이 필요할 때 이 패턴을 사용합니다. -- 지역 컴포넌트에서 `isPending` 사용 -- 버튼 비활성화 및 텍스트 변경 -- 낙관적 업데이트를 위한 로딩 UI 제공 +### 4. Axios 인터셉터 -### 3. LoadingFallback 컴포넌트 +**구현 위치**: `apps/web-user/src/common/config/axios.config.ts` -**구현 위치**: `apps/web-user/src/common/components/fallbacks/LoadingFallback.tsx` +- `401` + `ACCESS_TOKEN_INVALID` → 토큰 갱신 시도 +- 그 외 → `Promise.reject`로 호출부(React Query / try-catch)에 전달 -**특징**: +자세한 내용: [통합 인증 - 가이드](../common/통합%20인증%20-%20가이드.md) + +### 5. Query `isError` / 빈 데이터 UI + +페이지·섹션에서 `!data` 또는 query `isError` 시 "불러오지 못했습니다" 등 **인라인 메시지**를 표시하는 경우가 있습니다. 전역 ErrorBoundary 대신 **지역 처리**가 일반적입니다. + +--- + +## 새 기능 추가 시 체크리스트 + +1. **첫 목록/상세 로딩** → `isLoading` + 기존 Skeleton 패턴 또는 새 `*Skeleton` 추가 +2. **무한 스크롤** → `useInfiniteScroll` + `isFetchingNextPage` 하단 스피너 +3. **버튼·제출** → mutation `isPending` +4. **`useSearchParams` 페이지** → 페이지 내부 `Suspense` boundary +5. **전체 화면 블로킹** → 꼭 필요할 때만 `LoadingFallback` (남용 지양) +6. **에러** → mutation `onError` + Alert, 분기 필요 시 `try/catch` + +--- -- `overlay` 방식: 전체 화면 로딩 -- `corner` 방식: 우측 하단 로딩 +## 참고: seller/admin과의 차이 -## 참고사항 +| 항목 | web-user | web-seller / web-admin | +|------|----------|-------------------------| +| 본문 로딩 | Skeleton | `ContentLoading` + `LoadingSpinner` | +| 앱 초기화 | `AuthProvider` (persist) | `AuthInitializerProvider` + `LoadingFallback` | +| 무한 스크롤 하단 | 인라인 스피너 | seller: `InfiniteScrollLoading` | +| 백그라운드 refetch | Header 등 지역 로직 | seller: `FetchStatusInline` | -1. **useQuerySuspense**: 리액트 쿼리(TanStack Query v5)의 기본 기능으로, 비동기 데이터를 불러오는 동안 로딩 표시를 해줍니다. 상위의 Suspense 컴포넌트에서 인식됩니다. -2. **throwOnError**: 리액트 쿼리(TanStack Query v5)의 기본 기능으로, 비동기 데이터를 불러오는 동안 에러 발생 시 예외(throw error)를 던집니다. 상위의 ErrorBoundary 컴포넌트에서 인식됩니다. +통일이 필요하면 팀 합의 후 공통 패키지 또는 컴포넌트 추출을 검토합니다. From 5035f979e43cbc35ed64b893717afbdb1bbfadef Mon Sep 17 00:00:00 2001 From: geunu Date: Tue, 26 May 2026 22:37:39 +0900 Subject: [PATCH 02/41] [WEB-ADMIN][UPDATE]: Revise Vercel deployment workflow and documentation - Updated the GitHub Actions workflow for staging deployments to utilize Vercel CLI for building and deploying applications. - Enhanced error handling and validation for project names and environments. - Removed the previous webhook URL setup in favor of using project IDs stored in GitHub secrets. - Added Discord notification functionality for deployment status updates. - Revised documentation to reflect changes in deployment strategy, emphasizing tag-based deployments and the new setup for Vercel tokens and project IDs. --- .github/workflows/deploy-staging-web.yml | 189 ++++++++++++++---- ...- \352\260\200\354\235\264\353\223\234.md" | 45 +++-- 2 files changed, 178 insertions(+), 56 deletions(-) diff --git a/.github/workflows/deploy-staging-web.yml b/.github/workflows/deploy-staging-web.yml index a1b42116..ed817b6f 100644 --- a/.github/workflows/deploy-staging-web.yml +++ b/.github/workflows/deploy-staging-web.yml @@ -8,28 +8,32 @@ on: - "web-seller/staging-*" - "web-admin/staging-*" -permissions: {} +permissions: + contents: read + +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} jobs: deploy-vercel: runs-on: ubuntu-latest steps: - # 태그에서 프로젝트명과 환경 추출 (예: web-user/staging-v1.0.0) - name: Extract project name and environment from tag id: extract-project run: | TAG_NAME=${GITHUB_REF#refs/tags/} - PROJECT_NAME=$(echo $TAG_NAME | cut -d'/' -f1) - ENV_NAME=$(echo $TAG_NAME | cut -d'/' -f2 | cut -d'-' -f1) - echo "project=$PROJECT_NAME" >> $GITHUB_OUTPUT - echo "environment=$ENV_NAME" >> $GITHUB_OUTPUT - echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT + PROJECT_NAME=$(echo "$TAG_NAME" | cut -d'/' -f1) + ENV_NAME=$(echo "$TAG_NAME" | cut -d'/' -f2 | cut -d'-' -f1) + echo "project=$PROJECT_NAME" >> "$GITHUB_OUTPUT" + echo "environment=$ENV_NAME" >> "$GITHUB_OUTPUT" + echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "app_dir=apps/$PROJECT_NAME" >> "$GITHUB_OUTPUT" echo "📦 Tag: $TAG_NAME" echo "📁 Project: $PROJECT_NAME" echo "🌍 Environment: $ENV_NAME" - # 프로젝트명과 환경 유효성 검증 - name: Validate project name and environment run: | PROJECT=${{ steps.extract-project.outputs.project }} @@ -37,66 +41,177 @@ jobs: if [[ "$PROJECT" != "web-user" && "$PROJECT" != "web-seller" && "$PROJECT" != "web-admin" ]]; then echo "❌ Invalid project name: $PROJECT" - echo "Valid project names: web-user, web-seller, web-admin" exit 1 fi if [[ "$ENV" != "staging" ]]; then echo "❌ Invalid environment: $ENV" - echo "Valid environment: staging" exit 1 fi echo "✅ Valid project: $PROJECT" echo "✅ Valid environment: $ENV" - # 프로젝트별 Vercel 웹훅 URL 설정 - - name: Set project-specific webhook URL - id: set-webhook + - name: Set Vercel project id + id: vercel-project run: | PROJECT=${{ steps.extract-project.outputs.project }} - case $PROJECT in + case "$PROJECT" in web-user) - echo "webhook_url=${{ secrets.VERCEL_WEBHOOK_URL_WEB_USER_STAGING }}" >> $GITHUB_OUTPUT + PROJECT_ID="${{ secrets.VERCEL_PROJECT_ID_WEB_USER_STAGING }}" ;; web-seller) - echo "webhook_url=${{ secrets.VERCEL_WEBHOOK_URL_WEB_SELLER_STAGING }}" >> $GITHUB_OUTPUT + PROJECT_ID="${{ secrets.VERCEL_PROJECT_ID_WEB_SELLER_STAGING }}" ;; web-admin) - echo "webhook_url=${{ secrets.VERCEL_WEBHOOK_URL_WEB_ADMIN_STAGING }}" >> $GITHUB_OUTPUT + PROJECT_ID="${{ secrets.VERCEL_PROJECT_ID_WEB_ADMIN_STAGING }}" ;; esac - # Vercel 웹훅을 통한 배포 트리거 - - name: Trigger Vercel deployment via webhook + if [ -z "$PROJECT_ID" ]; then + echo "❌ VERCEL_PROJECT_ID is not set for $PROJECT" + echo "Add VERCEL_PROJECT_ID_*_STAGING to GitHub repository secrets" + exit 1 + fi + + echo "id=$PROJECT_ID" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: yarn + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Install Vercel CLI + run: npm install -g vercel@latest + + - name: Pull Vercel environment + working-directory: ${{ steps.extract-project.outputs.app_dir }} + env: + VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} run: | - PROJECT=${{ steps.extract-project.outputs.project }} - TAG_NAME=${{ steps.extract-project.outputs.tag }} - WEBHOOK_URL=${{ steps.set-webhook.outputs.webhook_url }} + if [ -z "$VERCEL_TOKEN" ] || [ -z "$VERCEL_ORG_ID" ]; then + echo "❌ VERCEL_TOKEN and VERCEL_ORG_ID secrets are required" + exit 1 + fi + + vercel pull --yes --environment=production --token="$VERCEL_TOKEN" \ + 2>&1 | tee /tmp/vercel-pull.log + + - name: Build with Vercel + id: vercel-build + working-directory: ${{ steps.extract-project.outputs.app_dir }} + env: + VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} + run: | + set -o pipefail + vercel build --prod --token="$VERCEL_TOKEN" \ + 2>&1 | tee /tmp/vercel-build.log + echo "result=success" >> "$GITHUB_OUTPUT" - echo "🚀 Triggering deployment for $PROJECT (staging) via Vercel webhook..." - echo "📋 Tag: $TAG_NAME" - echo "🔗 Webhook URL: ${WEBHOOK_URL:0:50}..." # URL 일부만 표시 (보안) + - name: Deploy to Vercel + id: vercel-deploy + working-directory: ${{ steps.extract-project.outputs.app_dir }} + env: + VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} + run: | + set -o pipefail + vercel deploy --prebuilt --prod --yes --token="$VERCEL_TOKEN" \ + 2>&1 | tee /tmp/vercel-deploy.log - if [ -z "$WEBHOOK_URL" ]; then - echo "❌ Error: Webhook URL is not set for $PROJECT" - echo "Please set VERCEL_WEBHOOK_URL_${PROJECT^^}_STAGING secret in GitHub repository settings" + DEPLOY_URL=$(grep -Eo 'https://[a-zA-Z0-9./_-]+' /tmp/vercel-deploy.log | tail -n 1) + if [ -z "$DEPLOY_URL" ]; then + echo "❌ Could not parse deployment URL from Vercel CLI output" exit 1 fi - # Vercel 웹훅 호출 - echo "📤 Calling Vercel webhook..." - HTTP_STATUS=$(curl -s -o /tmp/vercel_response.txt -w "%{http_code}" \ + echo "url=$DEPLOY_URL" >> "$GITHUB_OUTPUT" + echo "✅ Deployed: $DEPLOY_URL" + + - name: Notify Discord + if: always() + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + PROJECT: ${{ steps.extract-project.outputs.project }} + TAG: ${{ steps.extract-project.outputs.tag }} + ENVIRONMENT: ${{ steps.extract-project.outputs.environment }} + DEPLOY_URL: ${{ steps.vercel-deploy.outputs.url }} + JOB_STATUS: ${{ job.status }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + echo "⚠️ DISCORD_WEBHOOK_URL is not set — skipping notification" + exit 0 + fi + + case "$JOB_STATUS" in + success) STATUS_LABEL="✅ 배포 성공"; COLOR=5763719 ;; + failure) STATUS_LABEL="❌ 배포 실패"; COLOR=15548997 ;; + cancelled) STATUS_LABEL="⚠️ 배포 취소"; COLOR=9807270 ;; + *) STATUS_LABEL="ℹ️ 배포 종료 ($JOB_STATUS)"; COLOR=3447003 ;; + esac + + LOG_SOURCE="/tmp/vercel-build.log" + if [ ! -s "$LOG_SOURCE" ]; then + LOG_SOURCE="/tmp/vercel-pull.log" + fi + if [ ! -s "$LOG_SOURCE" ]; then + LOG_SOURCE="/tmp/vercel-deploy.log" + fi + + LOG_SNIPPET="로그 파일 없음" + if [ -s "$LOG_SOURCE" ]; then + LOG_SNIPPET=$(tail -c 900 "$LOG_SOURCE" | sed 's/```/``\`/g') + fi + + DEPLOY_FIELD="${DEPLOY_URL:-배포 URL 없음 (빌드/배포 단계 실패)}" + if [ -n "$DEPLOY_URL" ]; then + DEPLOY_FIELD="[$DEPLOY_URL]($DEPLOY_URL)" + fi + + PAYLOAD=$(jq -n \ + --arg title "$STATUS_LABEL — $PROJECT (staging)" \ + --argjson color "$COLOR" \ + --arg project "$PROJECT" \ + --arg tag "$TAG" \ + --arg environment "$ENVIRONMENT" \ + --arg deploy "$DEPLOY_FIELD" \ + --arg run_url "$RUN_URL" \ + --arg log "$LOG_SNIPPET" \ + '{ + embeds: [{ + title: $title, + color: $color, + fields: [ + { name: "프로젝트", value: $project, inline: true }, + { name: "환경", value: $environment, inline: true }, + { name: "태그", value: ("`" + $tag + "`"), inline: false }, + { name: "배포 URL", value: $deploy, inline: false }, + { name: "GitHub Actions", value: ("[워크플로우 로그](" + $run_url + ")"), inline: false }, + { name: "Vercel 로그 (마지막 900자)", value: ("```\n" + $log + "\n```"), inline: false } + ], + timestamp: (now | strftime("%Y-%m-%dT%H:%M:%SZ")) + }] + }') + + HTTP_STATUS=$(curl -s -o /tmp/discord_response.txt -w "%{http_code}" \ -X POST \ -H "Content-Type: application/json" \ - "$WEBHOOK_URL") + -d "$PAYLOAD" \ + "$DISCORD_WEBHOOK_URL") if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 300 ]; then - echo "✅ Webhook triggered successfully (HTTP $HTTP_STATUS)" - cat /tmp/vercel_response.txt 2>/dev/null || echo "No response body" + echo "✅ Discord notification sent (HTTP $HTTP_STATUS)" else - echo "❌ Webhook call failed (HTTP $HTTP_STATUS)" - cat /tmp/vercel_response.txt - exit 1 + echo "⚠️ Discord notification failed (HTTP $HTTP_STATUS) — deploy result is unchanged" + cat /tmp/discord_response.txt fi diff --git "a/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" "b/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" index b336c5cd..a748f292 100644 --- "a/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" +++ "b/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" @@ -37,7 +37,7 @@ Picake 프로젝트의 web-user, web-seller, web-admin 애플리케이션을 Ver } ``` -이 설정은 웹훅을 통한 수동 배포만 사용한다는 의미입니다. +이 설정은 브랜치 push 시 Vercel 자동 배포를 막고, **태그 + GitHub Actions**로만 배포한다는 의미입니다. ### 2. Vercel 콘솔 설정 @@ -88,24 +88,31 @@ Picake 프로젝트의 web-user, web-seller, web-admin 애플리케이션을 Ver | web-seller | `VITE_PUBLIC_API_DOMAIN` | `https://api-staging.picakes.com` | | web-admin | `VITE_PUBLIC_API_DOMAIN` | `https://api-staging.picakes.com` | -#### 2.4 Deploy Hook 생성 (웹훅) +#### 2.4 Vercel 토큰 및 프로젝트 ID 확인 -1. Vercel 대시보드 → 프로젝트 설정 → Git → Deploy Hooks -2. Deploy Hook 생성 -3. 생성된 웹훅 URL 복사 (다음 단계에서 사용) +1. https://vercel.com/account/settings/tokens url직접 입력 -> 토큰 생성 및 깃허브 VERCEL_TOKEN secrets 설정 +2. Vercel → 팀 선택 → Settings → General → Team ID 복사 및 깃허브 VERCEL_ORG_ID secrets 설정 +3. Vercel → 팀 선택 → 각 프로젝트 -> Settings -> General -> Project ID 복사 및 깃허브 VERCEL_PROJECT_ID secrets 설정 -### 3. GitHub 환경변수 설정 +#### 2.5 Discord 웹훅 (배포 알림) -1. GitHub 저장소 → Settings → Secrets and variables → Actions -2. New repository secret 클릭 -3. 다음 Secrets 추가: - - `VERCEL_WEBHOOK_URL_WEB_USER_STAGING`: web-user 스테이징 환경 Vercel 웹훅 URL - - `VERCEL_WEBHOOK_URL_WEB_SELLER_STAGING`: web-seller 스테이징 환경 Vercel 웹훅 URL - - `VERCEL_WEBHOOK_URL_WEB_ADMIN_STAGING`: web-admin 스테이징 환경 Vercel 웹훅 URL +1. Discord 서버 → 채널 설정 → 연동 → 웹후크 만들기 +2. 웹훅 URL을 GitHub Secret `DISCORD_WEBHOOK_URL_WEB_FE`에 등록 -### 4. GitHub 워크플로 생성 (태그 기반) +| Secret | 설명 | +| -------------------------------------- | ------------------------------ | +| `VERCEL_TOKEN` | Vercel API 토큰 | +| `VERCEL_ORG_ID` | Vercel 팀/개인 Org ID | +| `VERCEL_PROJECT_ID_WEB_USER_STAGING` | web-user-staging 프로젝트 ID | +| `VERCEL_PROJECT_ID_WEB_SELLER_STAGING` | web-seller-staging 프로젝트 ID | +| `VERCEL_PROJECT_ID_WEB_ADMIN_STAGING` | web-admin-staging 프로젝트 ID | +| `DISCORD_WEBHOOK_URL_WEB_FE` | 배포 결과 Discord 알림 웹훅 | -`.github/workflows/deploy-staging-web.yml` 파일을 생성하여 태그 기반 배포 워크플로를 설정합니다. +> 이전 Deploy Hook용 `VERCEL_WEBHOOK_URL_*` 시크릿은 더 이상 사용하지 않습니다. + +### 4. GitHub 워크플로 (태그 기반 + Discord 알림) + +`.github/workflows/deploy-staging-web.yml`에서 태그 푸시 시 Vercel CLI로 빌드·배포하고, 성공/실패 시 Discord로 알립니다. **워크플로 트리거:** @@ -115,14 +122,14 @@ Picake 프로젝트의 web-user, web-seller, web-admin 애플리케이션을 Ver **워크플로 동작:** -1. 태그에서 프로젝트명과 환경 추출 -2. 프로젝트명과 환경 유효성 검증 -3. 프로젝트별 Vercel 웹훅 URL 가져오기 -4. Vercel 웹훅 호출하여 배포 트리거 +1. 태그에서 프로젝트명·환경 추출 및 검증 +2. 모노레포 의존성 설치 (`yarn install`) +3. `vercel pull` → `vercel build` → `vercel deploy` (배포 완료까지 대기) +4. Discord에 성공/실패, 배포 URL, GitHub Actions 로그 링크, Vercel 빌드 로그 일부 전송 자세한 워크플로 내용은 `.github/workflows/deploy-staging-web.yml` 파일을 참고하세요. -### 5. 도메인 구성 (선택사항) +### 4. 도메인 구성 (선택사항) 커스텀 도메인 설정은 [AWS Route53(도메인) - 가이드](<../aws/AWS%20Route53(도메인)%20-%20가이드.md>)를 참고하세요. From 82ec08cb379a6a8aaa103f817cdad16c625efefb Mon Sep 17 00:00:00 2001 From: geunu Date: Tue, 26 May 2026 22:40:42 +0900 Subject: [PATCH 03/41] [WEB-ADMIN][UPDATE]: Enhance staging deployment workflow with Yarn caching - Updated the GitHub Actions workflow for staging deployments to enable Corepack and cache Yarn dependencies. - Added steps to retrieve the Yarn cache directory and configure caching for improved build performance. - Removed the outdated cache configuration to prevent issues with Corepack and Yarn 1.x. --- .github/workflows/deploy-staging-web.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-staging-web.yml b/.github/workflows/deploy-staging-web.yml index ed817b6f..fdf56516 100644 --- a/.github/workflows/deploy-staging-web.yml +++ b/.github/workflows/deploy-staging-web.yml @@ -83,11 +83,23 @@ jobs: uses: actions/setup-node@v4 with: node-version: "20" - cache: yarn + # packageManager: yarn@4.x — setup-node의 cache: yarn은 Corepack 전 Yarn 1.x를 호출해 실패함 - name: Enable Corepack run: corepack enable + - name: Get Yarn cache directory + id: yarn-cache-dir + run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" + + - name: Cache Yarn dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.yarn-cache-dir.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: Install dependencies run: yarn install --immutable From 98333fab8673ebab229b32b2489a99c49d56d876 Mon Sep 17 00:00:00 2001 From: geunu Date: Tue, 26 May 2026 22:43:41 +0900 Subject: [PATCH 04/41] [WEB-ADMIN][UPDATE]: Add react-quill dependency to yarn.lock - Updated yarn.lock to include react-quill version 2.0.0 for enhanced text editing capabilities in the application. --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 2322f60a..9ef87956 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4054,6 +4054,7 @@ __metadata: react: "npm:^18.2.0" react-dom: "npm:^18.2.0" react-error-boundary: "npm:^6.0.0" + react-quill: "npm:^2.0.0" react-router-dom: "npm:^7.9.4" tailwind-merge: "npm:^2.6.0" tailwindcss: "npm:^3.4.18" From 1cd1b53dcda71e39e542f65239bdef7e2e2a02b9 Mon Sep 17 00:00:00 2001 From: geunu Date: Tue, 26 May 2026 22:46:28 +0900 Subject: [PATCH 05/41] [WEB-ADMIN][UPDATE]: Refine Vercel deployment workflow for staging - Adjusted the GitHub Actions workflow to execute Vercel CLI from the repository root to avoid path issues. - Cleaned up previous Vercel environment files before pulling the latest configuration. - Updated project.json to skip the install step during the build process, enhancing deployment efficiency. --- .github/workflows/deploy-staging-web.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-staging-web.yml b/.github/workflows/deploy-staging-web.yml index fdf56516..3a988f89 100644 --- a/.github/workflows/deploy-staging-web.yml +++ b/.github/workflows/deploy-staging-web.yml @@ -106,8 +106,9 @@ jobs: - name: Install Vercel CLI run: npm install -g vercel@latest + # 모노레포: apps/에서 vercel CLI를 실행하면 rootDirectory가 중복되어 + # apps/web-user/apps/web-user 경로가 되며 "spawn sh ENOENT"가 발생함 → 저장소 루트에서 실행 - name: Pull Vercel environment - working-directory: ${{ steps.extract-project.outputs.app_dir }} env: VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} run: | @@ -116,12 +117,17 @@ jobs: exit 1 fi + rm -rf apps/*/.vercel .vercel + vercel pull --yes --environment=production --token="$VERCEL_TOKEN" \ 2>&1 | tee /tmp/vercel-pull.log + # 의존성은 위에서 루트 yarn install 완료 — vercel build의 install 단계 스킵 + jq '.installCommand = "true"' .vercel/project.json > .vercel/project.json.tmp + mv .vercel/project.json.tmp .vercel/project.json + - name: Build with Vercel id: vercel-build - working-directory: ${{ steps.extract-project.outputs.app_dir }} env: VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} run: | @@ -132,7 +138,6 @@ jobs: - name: Deploy to Vercel id: vercel-deploy - working-directory: ${{ steps.extract-project.outputs.app_dir }} env: VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} run: | From 07897cc45174f081a9f20eaab3dcd9198c89782a Mon Sep 17 00:00:00 2001 From: geunu Date: Tue, 26 May 2026 22:52:44 +0900 Subject: [PATCH 06/41] [WEB-ADMIN][UPDATE]: Update Discord webhook URL in staging deployment workflow - Changed the Discord webhook URL reference in the GitHub Actions workflow from DISCORD_WEBHOOK_URL to DISCORD_WEBHOOK_URL_WEB_FE for improved clarity and consistency. - Updated notification message to reflect the new environment variable name, ensuring accurate logging during deployment notifications. --- .github/workflows/deploy-staging-web.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-staging-web.yml b/.github/workflows/deploy-staging-web.yml index 3a988f89..fae4260f 100644 --- a/.github/workflows/deploy-staging-web.yml +++ b/.github/workflows/deploy-staging-web.yml @@ -157,7 +157,7 @@ jobs: - name: Notify Discord if: always() env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL_WEB_FE }} PROJECT: ${{ steps.extract-project.outputs.project }} TAG: ${{ steps.extract-project.outputs.tag }} ENVIRONMENT: ${{ steps.extract-project.outputs.environment }} @@ -166,7 +166,7 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "⚠️ DISCORD_WEBHOOK_URL is not set — skipping notification" + echo "⚠️ DISCORD_WEBHOOK_URL_WEB_FE is not set — skipping notification" exit 0 fi From 3697bd06c9c4495a67b50236235943bd8632b093 Mon Sep 17 00:00:00 2001 From: geunu Date: Tue, 26 May 2026 23:04:19 +0900 Subject: [PATCH 07/41] [WEB-ADMIN][UPDATE]: Revise Vercel project setup documentation - Removed outdated sections regarding project creation and environment variable setup for Vercel deployments. - Streamlined the documentation to focus on essential steps for configuring Vercel projects and environment variables. - Updated the GitHub workflow section to reflect the new structure and clarify the deployment process, including Discord webhook integration. --- ...- \352\260\200\354\235\264\353\223\234.md" | 44 +++---------------- 1 file changed, 5 insertions(+), 39 deletions(-) diff --git "a/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" "b/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" index a748f292..3b33654a 100644 --- "a/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" +++ "b/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" @@ -54,50 +54,18 @@ Picake 프로젝트의 web-user, web-seller, web-admin 애플리케이션을 Ver | web-admin-staging | staging | `staging` | Production | | web-admin-production | production | `main` | Production | -**중요 사항:** - -- staging과 production 환경 모두 별도의 Vercel 프로젝트로 구성됩니다 -- 각 프로젝트는 바라보는 브랜치만 다릅니다 (staging 브랜치 또는 main 브랜치) -- 모든 프로젝트는 Production 타입으로 배포됩니다 - #### 2.2 프로젝트 생성 -1. Vercel 대시보드에서 새 프로젝트 생성 (총 6개) -2. GitHub 저장소 연결 - - web-user-staging: `staging` 브랜치 연결 - - web-user-production: `main` 브랜치 연결 - - web-seller-staging: `staging` 브랜치 연결 - - web-seller-production: `main` 브랜치 연결 - - web-admin-staging: `staging` 브랜치 연결 - - web-admin-production: `main` 브랜치 연결 -3. 빌드 설정: - - Framework: Next.js (web-user) / Vite (web-seller, web-admin) - - Build Command: `next build` (web-user) / `yarn build` (web-seller, web-admin) - - Install Command: `yarn install` - - Root Directory: `apps/web-user` 또는 `apps/web-seller` 또는 `apps/web-admin` - - Output Directory: `.next` (web-user) / `dist` (web-seller) / `dist` (web-admin) +1. 이전에 만들었던 프로젝트 설정 확인 #### 2.3 환경변수 설정 1. Vercel 대시보드 → 프로젝트 설정 → Environment Variables 2. 필요한 환경변수 추가 -| 프로젝트 | 환경변수 | staging 예시 | -| ---------- | ------------------------ | --------------------------------- | -| web-user | (프로젝트별 설정) | — | -| web-seller | `VITE_PUBLIC_API_DOMAIN` | `https://api-staging.picakes.com` | -| web-admin | `VITE_PUBLIC_API_DOMAIN` | `https://api-staging.picakes.com` | - -#### 2.4 Vercel 토큰 및 프로젝트 ID 확인 - -1. https://vercel.com/account/settings/tokens url직접 입력 -> 토큰 생성 및 깃허브 VERCEL_TOKEN secrets 설정 -2. Vercel → 팀 선택 → Settings → General → Team ID 복사 및 깃허브 VERCEL_ORG_ID secrets 설정 -3. Vercel → 팀 선택 → 각 프로젝트 -> Settings -> General -> Project ID 복사 및 깃허브 VERCEL_PROJECT_ID secrets 설정 - -#### 2.5 Discord 웹훅 (배포 알림) - -1. Discord 서버 → 채널 설정 → 연동 → 웹후크 만들기 -2. 웹훅 URL을 GitHub Secret `DISCORD_WEBHOOK_URL_WEB_FE`에 등록 +- https://vercel.com/account/settings/tokens url직접 입력 -> 토큰 생성 및 깃허브 VERCEL_TOKEN secrets 설정 +- Vercel → 팀 선택 → Settings → General → Team ID 복사 및 깃허브 VERCEL_ORG_ID secrets 설정 +- Vercel → 팀 선택 → 각 프로젝트 -> Settings -> General -> Project ID 복사 및 깃허브 VERCEL_PROJECT_ID secrets 설정 | Secret | 설명 | | -------------------------------------- | ------------------------------ | @@ -108,9 +76,7 @@ Picake 프로젝트의 web-user, web-seller, web-admin 애플리케이션을 Ver | `VERCEL_PROJECT_ID_WEB_ADMIN_STAGING` | web-admin-staging 프로젝트 ID | | `DISCORD_WEBHOOK_URL_WEB_FE` | 배포 결과 Discord 알림 웹훅 | -> 이전 Deploy Hook용 `VERCEL_WEBHOOK_URL_*` 시크릿은 더 이상 사용하지 않습니다. - -### 4. GitHub 워크플로 (태그 기반 + Discord 알림) +### 3. GitHub 워크플로 (태그 기반 + Discord 알림) `.github/workflows/deploy-staging-web.yml`에서 태그 푸시 시 Vercel CLI로 빌드·배포하고, 성공/실패 시 Discord로 알립니다. From fda7f91af55c598cb75c40a2022beecfa53d7439 Mon Sep 17 00:00:00 2001 From: jangchanwoo Date: Tue, 9 Jun 2026 13:51:45 +0900 Subject: [PATCH 08/41] [WEB-USER][UPDATE]: Add category images to CategoryList Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/images/contents/category1.png | Bin 0 -> 2358 bytes .../public/images/contents/category2.png | Bin 0 -> 6846 bytes .../public/images/contents/category3.png | Bin 0 -> 2758 bytes .../public/images/contents/category4.png | Bin 0 -> 4624 bytes .../public/images/contents/category5.png | Bin 0 -> 2523 bytes .../components/categories/CategoryList.tsx | 13 +++++-------- 6 files changed, 5 insertions(+), 8 deletions(-) create mode 100644 apps/web-user/public/images/contents/category1.png create mode 100644 apps/web-user/public/images/contents/category2.png create mode 100644 apps/web-user/public/images/contents/category3.png create mode 100644 apps/web-user/public/images/contents/category4.png create mode 100644 apps/web-user/public/images/contents/category5.png diff --git a/apps/web-user/public/images/contents/category1.png b/apps/web-user/public/images/contents/category1.png new file mode 100644 index 0000000000000000000000000000000000000000..5b83aa8160950b64b9e17ede032bc2165d94e9ab GIT binary patch literal 2358 zcmV-63CZ?}P)9ml^_<2dmn&I|&?faDW4cOoX(DJ&*|?9;clw1O$qhaWZx` zV|N$ztDd&U;~q~>Pd}>5zmGJY?w;;(Pk;OOsH(0ifr_J4r;dXQ9{Ry5gz^CK3Isg; zffw$8x?Zox9NN~eE`;csccmUdi8gBbb?LArM5BueBTz9^zqg9MzorGbs@K-^CJ*`0 z(*L84Ks~^GttsZ0Tc{A$3u`QLuePqw^qO8-A7gz*(AJl$sXf30EcrXOTeOwJ8Zkec zTh$BiX^UAcQOxlOv4a5j#PZ5EEW|2d2ZG*&AKbE1%y`f)&E57thy> zbt)4xD~#B-Mc`6}bw|)q$``m4VbzO{hoE*_FRjCA$`iOyVJjL#XGh9Sv2b|{1yY!> z#_a~dUF28pw4F9&a6?;f69piVGi0S||M_h#>V2GFQ3)M>hm0zA46RfJqKjPrmeTh(>nx&zT83h^HUw#Z@qAXz957R#tI06Ja??%8bmCs+oml zQ8OnKac2^iMa_+w#Z8?Rn?xy$sUDcDG5Lz>bW)cp(ieC3Yg9eRiBf*7VxCGE=}0B4 zDW^K$GGha3BW*c%>KLo`dpDu{_rVm06dPh;<%4MsnMzobEKxNf*C-WDS0_`iPm@ed z=ZYxL$aZd!(>NTw1C8^%@6fJ?-~#ks`sHjyhHG?L#3Q$At&wmE6HWWw(4egd{u zTWF)Nc?) z*;*^W8Hv<*i?!WaU+x1>KLwurEnKeJ_h*3`uTiYUT5YX#p+Ek0_&&^6Ujx4W2JqS& zn2EfIu<^JZUz_X{x_NSZYdq0LX^n=D|x%PC7wK2BR z1y3q!wExfEO?C`f=r1;tiy9p>>Z;2mrd%77JXzSKaT-xakNfZ^@uJZ-`i$Ir8vgnb zEaK*91Hs(zit0Vv(0@JH#?yZwHDp=8+yegh1o-sN!2UDfn?KM&i0)~H)cngLi=}ey zCG#{(Gum)MSZ%-8XZfwUK`}5bFJAyH?K=H~<86QVEl|6lv)u3MBx?bwn8tM%L0I%@ zMyp4z*qE|%@Nt)a{^g{o5wz{`AK?s2tq!~6;~0CP=H@d@-T#dLjNGz2)Q2@Z7M8|Q zI&Z{Ta+RqaYdDk@yEeebIV4agEmrk9lu>zLs!mDLl?kR%DG`Z(o|w@jyqPl#u7Xkj>^qAMZdPn!n%moR7P18;}BB3 zA`x&WE9X#Qj4fAKS%b68T|g-)a>XdwVjEvB?*inB)R*?kmrxc|^W>pSsAh&lY z@Z|5vh41_bB~s6QSX-tPSXftKVI75qbrce|(v$Ns@vyLN(q>oeCS7UfOl90c2rVqs zV=!)^)UK{8HZvbHZqZ^_>>^rI?TYVJo)q>FoE=vY%7&L4+ryhq2y09^z#TBvLY+>0Pd^%S81TGxRYRT*p{Il$BvDN; z6+vvr&E^sRFk2Z(l#3m+ia^F|6)UW`+-MeVwTljQ)BW66Pz>?bN{oN}v}mjS+PB=W zTKWxMLF2W^@fwX4)>tjX4vK;%XP~k63%lDhh6G*t0`Lv}8!k;b(L^f7TdVo>j3c9o zA0DFY&e6XSAVUizQ5zqmuVIQ_dLbj4sSf&TXV*CSeH{ zH)ApoHT;?kU~w}dBT$e3J&B~-6-3&M!Yjr8$!sTObaLGP@{O<01Hsxf4k zB#k52u@vdVO^XCqK^~)mJ35}eo~Nipt`<9tj=O89$-C#)ddW(4BYD#H)Y<=C!JNay zs;fQL9W3L0AsU4>c@{1#VPdZ@H{EYU&_zG#zmvDyjs4Fk5oZM!q6y#MD$4D~o&qeu z8G$Vj^ypMz5k~?`T&qx$RZ0w0qboQzAZpk3(mL#;tF5y|)~N+;*(7L$wZcXy z)v03-T3oqSvL$y-GDJh}EMdn+F^92U*ip14Q&+Y%ZOzy8nuq-6mv5V~uay5tFE#Jw{6-tefHU)ni>Nq~CS(*%I2e cTjV7E4?X}`<*T&`vj6}907*qoM6N<$g3G{pK>z>% literal 0 HcmV?d00001 diff --git a/apps/web-user/public/images/contents/category2.png b/apps/web-user/public/images/contents/category2.png new file mode 100644 index 0000000000000000000000000000000000000000..ea6604afc52573d0b72a570f511eb7ab9c6312ec GIT binary patch literal 6846 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 literal 0 HcmV?d00001 diff --git a/apps/web-user/public/images/contents/category3.png b/apps/web-user/public/images/contents/category3.png new file mode 100644 index 0000000000000000000000000000000000000000..f4e692afd36b61b63ac35defe83cd60b3bfe15d3 GIT binary patch literal 2758 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 M07*qoM6N<$f}A-;)&Kwi literal 0 HcmV?d00001 diff --git a/apps/web-user/public/images/contents/category4.png b/apps/web-user/public/images/contents/category4.png new file mode 100644 index 0000000000000000000000000000000000000000..5081b75eb2a33ec883ac3651eb15ceb4385878b6 GIT binary patch literal 4624 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 lVpRyAvHPqJJ?<76$IpO0 Date: Fri, 19 Jun 2026 14:19:12 +0900 Subject: [PATCH 09/41] =?UTF-8?q?[WEB-USER][QA]=20=ED=83=AD=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=EB=A7=81,=20=ED=9B=84=EA=B8=B0=20=EC=97=86?= =?UTF-8?q?=EC=9D=84=EB=95=8C=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=A4=91?= =?UTF-8?q?=EC=95=99=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/product/[productId]/page.tsx | 1 + .../common/components/reviews/ReviewList.tsx | 2 +- .../src/common/components/tabs/Tabs.tsx | 34 +++++++++++++++++-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/web-user/src/app/product/[productId]/page.tsx b/apps/web-user/src/app/product/[productId]/page.tsx index 295ccffc..94671032 100644 --- a/apps/web-user/src/app/product/[productId]/page.tsx +++ b/apps/web-user/src/app/product/[productId]/page.tsx @@ -99,6 +99,7 @@ export default function ProductDetailPage({ params }: ProductDetailPageProps) { {/* 탭 영역 */} )) ) : ( -

{emptyMessage}

+

{emptyMessage}

)}
diff --git a/apps/web-user/src/common/components/tabs/Tabs.tsx b/apps/web-user/src/common/components/tabs/Tabs.tsx index 55573183..5b7de181 100644 --- a/apps/web-user/src/common/components/tabs/Tabs.tsx +++ b/apps/web-user/src/common/components/tabs/Tabs.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; export interface Tab { id: string; @@ -11,18 +11,46 @@ export interface Tab { export interface TabsProps { tabs: Tab[]; defaultTab?: string; + /** 탭 클릭 시 탭 바가 상단 헤더 바로 아래로 오도록 스크롤할지 여부 (기본 false) */ + scrollOnSelect?: boolean; + /** 스크롤 시 제외할 상단 고정 헤더 높이 (px, 기본 52 = 탭 바 sticky 기준) */ + scrollOffset?: number; } -export function Tabs({ tabs, defaultTab }: TabsProps) { +export function Tabs({ + tabs, + defaultTab, + scrollOnSelect = false, + scrollOffset = 52, +}: TabsProps) { const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id || ""); + // sticky 탭 바는 붙어있을 때 위치가 52px로 고정돼 위치 계산이 불가하므로, + // 바로 앞에 둔 비-sticky 앵커로 스크롤한다(scrollIntoView가 스크롤 컨테이너를 자동 탐색). + const anchorRef = useRef(null); + + const handleTabClick = (tabId: string) => { + setActiveTab(tabId); + + if (!scrollOnSelect) return; + + // 콘텐츠 스왑 후 레이아웃이 반영된 다음 프레임에 스크롤 + requestAnimationFrame(() => { + // scroll-margin-top(scrollOffset) 덕분에 헤더 높이만큼 띄워서 멈춘다 + anchorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + }; + return (
+ {/* 스크롤 기준 앵커 (sticky 아님: 탭 바의 실제 위치를 가리킴) + scrollMarginTop = 헤더 높이만큼 띄워서 멈추도록 */} +
{/* 탭 헤더 */}
{tabs.map((tab) => (

{product.name}

From 2d43986f399cb2c7cc3d45d1b2ddd4fe83c3d155 Mon Sep 17 00:00:00 2001 From: jangchanwoo Date: Fri, 19 Jun 2026 16:51:15 +0900 Subject: [PATCH 11/41] =?UTF-8?q?[WEB-USER][QA]=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EC=96=B4=20=EC=84=A4=EB=AA=85=20=EC=A4=84=EB=B0=94=EA=BF=88(\n?= =?UTF-8?q?)=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit whitespace-pre-line 추가로 description의 개행 문자가 줄바꿈으로 표시되도록 수정 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../store/components/sections/StoreDetailIntroSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web-user/src/features/store/components/sections/StoreDetailIntroSection.tsx b/apps/web-user/src/features/store/components/sections/StoreDetailIntroSection.tsx index 287cc74f..0c0855f0 100644 --- a/apps/web-user/src/features/store/components/sections/StoreDetailIntroSection.tsx +++ b/apps/web-user/src/features/store/components/sections/StoreDetailIntroSection.tsx @@ -164,7 +164,7 @@ export function StoreDetailIntroSection({ store }: StoreDetailIntroSectionProps) {/* 설명 */} {store.description && (
-

+

{displayDescription} {shouldTruncate && !isExpanded && (

- {product.salePrice.toLocaleString()}원 + {isProductOnSale(product.originalPrice, product.salePrice) ? ( +
+ + {product.originalPrice.toLocaleString()}원 + + {product.salePrice.toLocaleString()}원 +
+ ) : ( + {product.salePrice.toLocaleString()}원 + )}
diff --git a/apps/web-seller/src/features/product/components/sections/ProductCreationBasicInfoSection.tsx b/apps/web-seller/src/features/product/components/sections/ProductCreationBasicInfoSection.tsx index 5b23f6e3..8f977f45 100644 --- a/apps/web-seller/src/features/product/components/sections/ProductCreationBasicInfoSection.tsx +++ b/apps/web-seller/src/features/product/components/sections/ProductCreationBasicInfoSection.tsx @@ -29,6 +29,7 @@ export interface ProductCreationBasicInfoSectionProps { onChange: ( key: keyof ProductForm, ) => (e: React.ChangeEvent) => void; + onOriginalPriceChange: (value: number) => void; onSalePriceChange: (value: number) => void; disabled?: boolean; } @@ -44,6 +45,7 @@ export const ProductCreationBasicInfoSection: React.FC { @@ -92,6 +94,22 @@ export const ProductCreationBasicInfoSection: React.FC
+
+ + onOriginalPriceChange(v ?? 0)} + placeholder="" + min={0} + className={errors.originalPrice ? "border-destructive" : ""} + disabled={disabled} + /> + {errors.originalPrice && ( +

{errors.originalPrice}

+ )} +
diff --git "a/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" "b/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" index 866291a6..682ae036 100644 --- "a/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" +++ "b/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" @@ -10,13 +10,13 @@ ## 로딩 처리 (요약) -| 계층 | 수단 | 용도 | -|------|------|------| -| 1 | 루트 `Suspense` + `LoadingFallback` | `useSearchParams` 등으로 suspend 되는 구간 | -| 2 | React Query `isLoading` + **Skeleton** | 페이지·섹션·목록 **첫 로딩** (주력) | -| 3 | `isFetchingNextPage` + 인라인 스피너 | 무한 스크롤 **추가 로딩** | -| 4 | Mutation `isPending` | 버튼·폼 제출·좋아요 등 **액션 로딩** | -| 5 | `Toast` `showSpinner` | 지역 변경 등 짧은 **피드백** | +| 계층 | 수단 | 용도 | +| ---- | -------------------------------------- | ------------------------------------------ | +| 1 | 루트 `Suspense` + `LoadingFallback` | `useSearchParams` 등으로 suspend 되는 구간 | +| 2 | React Query `isLoading` + **Skeleton** | 페이지·섹션·목록 **첫 로딩** (주력) | +| 3 | `isFetchingNextPage` + 인라인 스피너 | 무한 스크롤 **추가 로딩** | +| 4 | Mutation `isPending` | 버튼·폼 제출·좋아요 등 **액션 로딩** | +| 5 | `Toast` `showSpinner` | 지역 변경 등 짧은 **피드백** | **사용하지 않는 것**: `useSuspenseQuery`, Query `suspense: true`, `throwOnError: true`(주석 처리됨). @@ -48,10 +48,10 @@ **LoadingFallback** (`common/components/fallbacks/LoadingFallback.tsx`): -| variant | 설명 | -|---------|------| -| `overlay` (기본) | 전체 화면 오버레이 + `loading-spinner-large` | -| `corner` | 우측 하단 작은 스피너 (정의만 있음, **현재 미사용**) | +| variant | 설명 | +| ---------------- | ---------------------------------------------------- | +| `overlay` (기본) | 전체 화면 오버레이 + `loading-spinner-large` | +| `corner` | 우측 하단 작은 스피너 (정의만 있음, **현재 미사용**) | 스피너 스타일: `common/styles/globals.css`의 `.loading-spinner-large`, `.loading-spinner-small` @@ -86,11 +86,11 @@ if (isLoading) { **선택 가이드**: -| 상황 | 권장 | -|------|------| -| 전체 페이지 첫 진입 | 전용 `*Skeleton` early return | -| 목록·카드 일부 | `Skeleton` 또는 슬라이더 내 placeholder | -| 버튼 한 번 누름 | `isPending` (아래 4번) | +| 상황 | 권장 | +| ------------------- | --------------------------------------- | +| 전체 페이지 첫 진입 | 전용 `*Skeleton` early return | +| 목록·카드 일부 | `Skeleton` 또는 슬라이더 내 placeholder | +| 버튼 한 번 누름 | `isPending` (아래 4번) | ### 3. 무한 스크롤 추가 로딩 (`isFetchingNextPage`) @@ -257,11 +257,11 @@ try { ## 참고: seller/admin과의 차이 -| 항목 | web-user | web-seller / web-admin | -|------|----------|-------------------------| -| 본문 로딩 | Skeleton | `ContentLoading` + `LoadingSpinner` | -| 앱 초기화 | `AuthProvider` (persist) | `AuthInitializerProvider` + `LoadingFallback` | -| 무한 스크롤 하단 | 인라인 스피너 | seller: `InfiniteScrollLoading` | -| 백그라운드 refetch | Header 등 지역 로직 | seller: `FetchStatusInline` | +| 항목 | web-user | web-seller / web-admin | +| ------------------ | ------------------------ | --------------------------------------------- | +| 본문 로딩 | Skeleton | `ContentLoading` + `LoadingSpinner` | +| 앱 초기화 | `AuthProvider` (persist) | `AuthInitializerProvider` + `LoadingFallback` | +| 무한 스크롤 하단 | 인라인 스피너 | seller: `InfiniteScrollLoading` | +| 백그라운드 refetch | Header 등 지역 로직 | seller: `FetchStatusInline` | 통일이 필요하면 팀 합의 후 공통 패키지 또는 컴포넌트 추출을 검토합니다. From 9a88252481949f60ee8b4abfed4c0bffc8462b3a Mon Sep 17 00:00:00 2001 From: geunu Date: Sun, 21 Jun 2026 11:18:06 +0900 Subject: [PATCH 17/41] =?UTF-8?q?[WEB-USER][UPDATE]:=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=20=EB=A1=9C=EB=94=A9=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=98=84=EC=9E=AC=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EB=A7=88=EC=BB=A4=20=EA=B0=B1=EC=8B=A0=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0.=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20?= =?UTF-8?q?=EB=A7=88=EC=BB=A4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EC=97=90=20=EC=A7=80=EB=8F=84=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84=20=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-user/src/app/map/page.tsx | 17 ++++++++++++++--- .../src/common/hooks/useUserLocation.ts | 9 ++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/web-user/src/app/map/page.tsx b/apps/web-user/src/app/map/page.tsx index a85ad19d..ce9e86a9 100644 --- a/apps/web-user/src/app/map/page.tsx +++ b/apps/web-user/src/app/map/page.tsx @@ -66,6 +66,7 @@ export default function MapPage() { const { location: userLocation, refresh: refreshUserLocation } = useUserLocation(); const [kakaoLoaded, setKakaoLoaded] = useState(false); + const [mapReady, setMapReady] = useState(false); const [selectedStore, setSelectedStore] = useState(null); const [listSortBy, setListSortBy] = useState("distance"); const [listFilter, setListFilter] = useState({}); @@ -435,11 +436,19 @@ export default function MapPage() { (center: { lat: number; lng: number }, suppressKakaoUnopenedMarkers: boolean) => { if (!window.kakao?.maps || !mapContainerRef.current || mapInstanceRef.current) return; window.kakao.maps.load(() => { + // userLocation 도착 전·후로 initializeMap이 중복 호출되면 load 콜백이 2번 실행될 수 있음 + if (mapInstanceRef.current) return; + const map = new window.kakao.maps.Map(mapContainerRef.current, { center: new window.kakao.maps.LatLng(center.lat, center.lng), level: 5, }); mapInstanceRef.current = map; + setMapReady(true); + + if (typeof map.relayout === "function") { + map.relayout(); + } if (suppressKakaoUnopenedMarkers) clearKakaoMarkers(); drawPlatformStoreMarkersRef.current(); @@ -453,6 +462,8 @@ export default function MapPage() { window.kakao.maps.event.addListener(map, "idle", () => { if (isCenteringFromClickRef.current) { isCenteringFromClickRef.current = false; + // panTo 직후 CustomOverlay가 보이지 않는 경우가 있어 이동 완료 시 현재위치 마커 재적용 + updateUserLocationMarker(userLocationRef.current ?? null); return; } drawPlatformStoreMarkersRef.current(); @@ -548,11 +559,11 @@ export default function MapPage() { usedUserLocationForCenterRef.current = true; }, [userLocation, searchQuery, pickupFilter, searchPlaces]); - // 현재위치 변경 시 지도 위 현재위치 마커(점) 갱신 + // 현재위치 변경 또는 지도 준비 완료 시 현재위치 마커(점) 갱신 useEffect(() => { - if (!mapInstanceRef.current) return; + if (!mapReady || !mapInstanceRef.current) return; updateUserLocationMarker(userLocation ?? null); - }, [userLocation, updateUserLocationMarker]); + }, [userLocation, updateUserLocationMarker, mapReady]); // 플랫폼 스토어 전체 조회 후 캐시. 지도 있으면 마커 그리기, 검색 모드가 아니면 미입점 검색 useEffect(() => { diff --git a/apps/web-user/src/common/hooks/useUserLocation.ts b/apps/web-user/src/common/hooks/useUserLocation.ts index c7f9785c..dfceaf53 100644 --- a/apps/web-user/src/common/hooks/useUserLocation.ts +++ b/apps/web-user/src/common/hooks/useUserLocation.ts @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { useUserCurrentLocationStore } from "@/apps/web-user/common/store/user-current-location.store"; import { isWebViewEnvironment } from "@/apps/web-user/common/utils/webview.bridge"; interface UserLocation { @@ -8,12 +9,18 @@ interface UserLocation { longitude: number; } +function readStoredLocation(): UserLocation | null { + const { latitude, longitude } = useUserCurrentLocationStore.getState(); + if (latitude == null || longitude == null) return null; + return { latitude, longitude }; +} + /** * 사용자의 현재 위치를 가져오는 훅 * 웹뷰 환경에서는 브릿지, 브라우저 환경에서는 Geolocation API 사용 */ export function useUserLocation() { - const [location, setLocation] = useState(null); + const [location, setLocation] = useState(() => readStoredLocation()); const handleLocation = useCallback((latitude: number, longitude: number) => { setLocation({ latitude, longitude }); From c70fbd48c7d255247b26c5bd6437848ab78b91b4 Mon Sep 17 00:00:00 2001 From: geunu Date: Sun, 21 Jun 2026 11:38:10 +0900 Subject: [PATCH 18/41] =?UTF-8?q?[WEB-USER][UPDATE]:=20=ED=98=84=EC=9E=AC?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=A7=88=EC=BB=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0.=20CustomOverlay=20=EB=8C=80=EC=8B=A0=20Mark?= =?UTF-8?q?er=EC=99=80=20MarkerImage=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EB=A7=88=EC=BB=A4=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9D=84=20=EC=B5=9C=EC=A0=81=ED=99=94?= =?UTF-8?q?=ED=95=98=EA=B3=A0,=20=EB=A7=88=EC=BB=A4=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-user/src/app/map/page.tsx | 48 ++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/apps/web-user/src/app/map/page.tsx b/apps/web-user/src/app/map/page.tsx index ce9e86a9..34736cdc 100644 --- a/apps/web-user/src/app/map/page.tsx +++ b/apps/web-user/src/app/map/page.tsx @@ -125,7 +125,8 @@ export default function MapPage() { const selectedMarkerRef = useRef(null); const isCenteringFromClickRef = useRef(false); // 마커 클릭으로 panTo 한 직후 idle에서 재처리 방지 const usedUserLocationForCenterRef = useRef(false); // 이미 현재위치로 중심 잡았는지 - const userLocationOverlayRef = useRef(null); + const userLocationMarkerRef = useRef(null); // 현재위치(파란 점) 마커 + const userLocationMarkerImageRef = useRef(null); // 현재위치 마커 이미지(1회 생성 후 재사용) const userLocationRef = useRef<{ latitude: number; longitude: number } | null>(null); // load 콜백에서 최신 위치 참조용 const searchQueryRef = useRef(searchQuery); const pickupFilterRef = useRef(pickupFilter); @@ -302,25 +303,42 @@ export default function MapPage() { return () => window.clearInterval(id); }, []); - /** 현재위치 오버레이(점) 표시/제거 */ + /** + * 현재위치(파란 점) 마커 표시/제거. + * CustomOverlay(HTML img)는 지도 생성 직후·panTo 도중 렌더되지 않는 카카오 타이밍 이슈가 있어 + * 스토어 마커와 동일하게 Marker + MarkerImage로 그린다. + */ const updateUserLocationMarker = useCallback( (location: { latitude: number; longitude: number } | null) => { const map = mapInstanceRef.current; if (!window.kakao?.maps || !map) return; - if (userLocationOverlayRef.current) { - userLocationOverlayRef.current.setMap(null); - userLocationOverlayRef.current = null; + if (!location) { + if (userLocationMarkerRef.current) { + userLocationMarkerRef.current.setMap(null); + userLocationMarkerRef.current = null; + } + return; } - if (!location) return; const position = new window.kakao.maps.LatLng(location.latitude, location.longitude); - userLocationOverlayRef.current = new window.kakao.maps.CustomOverlay({ - map, - position, - content: - '현재 위치', - yAnchor: 0.5, - xAnchor: 0.5, - }); + if (!userLocationMarkerImageRef.current) { + userLocationMarkerImageRef.current = new window.kakao.maps.MarkerImage( + "/images/contents/map-current-position.png", + new window.kakao.maps.Size(34, 34), + { offset: new window.kakao.maps.Point(17, 17) }, // 좌표가 점 중앙에 오도록 + ); + } + if (userLocationMarkerRef.current) { + userLocationMarkerRef.current.setPosition(position); + userLocationMarkerRef.current.setMap(map); + } else { + userLocationMarkerRef.current = new window.kakao.maps.Marker({ + map, + position, + image: userLocationMarkerImageRef.current, + clickable: false, + zIndex: 1, // 스토어 마커 위에 항상 보이도록 + }); + } }, [], ); @@ -462,8 +480,6 @@ export default function MapPage() { window.kakao.maps.event.addListener(map, "idle", () => { if (isCenteringFromClickRef.current) { isCenteringFromClickRef.current = false; - // panTo 직후 CustomOverlay가 보이지 않는 경우가 있어 이동 완료 시 현재위치 마커 재적용 - updateUserLocationMarker(userLocationRef.current ?? null); return; } drawPlatformStoreMarkersRef.current(); From 51627eed44322b7e86a37c2dcad78d11f7b8fb9c Mon Sep 17 00:00:00 2001 From: geunu Date: Sun, 21 Jun 2026 11:56:28 +0900 Subject: [PATCH 19/41] =?UTF-8?q?[WEB-SELLER][UPDATE]:=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=97=90=20=ED=95=84=ED=84=B0=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80.=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=20=EB=B0=8F=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=ED=95=98=EC=97=AC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B2=BD=ED=97=98=EC=9D=84=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/store/detail/products/List.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/web-seller/src/pages/store/detail/products/List.tsx b/apps/web-seller/src/pages/store/detail/products/List.tsx index 01dd7d3a..7b2994b1 100644 --- a/apps/web-seller/src/pages/store/detail/products/List.tsx +++ b/apps/web-seller/src/pages/store/detail/products/List.tsx @@ -62,6 +62,14 @@ export const StoreDetailProductListPage: React.FC = () => { setSizes([]); }, []); + const handleResetSizes = useCallback(() => { + setSizes([]); + }, []); + + const handleResetCategories = useCallback(() => { + setProductCategoryTypes([]); + }, []); + const hasActiveFilters = search.trim() !== "" || salesStatus !== undefined || @@ -117,13 +125,23 @@ export const StoreDetailProductListPage: React.FC = () => {
{/* 통계 및 정렬 */}
-
+
{totalItems}개의 상품
{hasActiveFilters && ( + )} + {sizes.length > 0 && ( + + )} + {productCategoryTypes.length > 0 && ( + )}
From e721227f33b24773c049393d50bda838b1bad8a0 Mon Sep 17 00:00:00 2001 From: geunu Date: Sun, 21 Jun 2026 12:04:56 +0900 Subject: [PATCH 20/41] =?UTF-8?q?[WEB-SELLER][UPDATE]:=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EC=83=9D=EC=84=B1=20=ED=8F=BC=EC=97=90=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=BC=80=EC=9D=B4=ED=81=AC=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=20=EB=B0=8F=20=EB=A7=9B=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80.=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=97=90?= =?UTF-8?q?=EA=B2=8C=20=EC=84=A0=ED=83=9D=20=EA=B0=80=EB=8A=A5=ED=95=9C=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=EC=9D=84=20=EC=A0=9C=EA=B3=B5=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=A0=9C=ED=92=88=20=EB=93=B1=EB=A1=9D=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=84=B8=EC=8A=A4=EB=A5=BC=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/forms/ProductCreationForm.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx b/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx index 6deb36f6..20aa0e37 100644 --- a/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx +++ b/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx @@ -13,6 +13,7 @@ import { CakeSizeOptionDto, CakeFlavorOptionDto, ProductCategoryType, + CakeSizeDisplayName, } from "@/apps/web-seller/features/product/types/product.dto"; import type { ProductForm } from "@/apps/web-seller/features/product/types/product.ui"; import { ProductCreationBasicInfoSection } from "@/apps/web-seller/features/product/components/sections/ProductCreationBasicInfoSection"; @@ -38,8 +39,22 @@ export const defaultForm: ProductForm = { salePrice: 0, salesStatus: EnableStatus.ENABLE, visibilityStatus: EnableStatus.ENABLE, - cakeSizeOptions: [], - cakeFlavorOptions: [], + cakeSizeOptions: [ + { + visible: EnableStatus.ENABLE, + displayName: CakeSizeDisplayName.DOSIRAK, + lengthCm: 0, + price: 0, + description: "1~2인용", + }, + ], + cakeFlavorOptions: [ + { + visible: EnableStatus.ENABLE, + displayName: "초콜릿", + price: 0, + }, + ], letteringVisible: EnableStatus.ENABLE, letteringRequired: OptionRequired.OPTIONAL, letteringMaxLength: 0, From 24a52daf37ebe33c94ece52c2267ce5975b5d1a0 Mon Sep 17 00:00:00 2001 From: geunu Date: Sun, 21 Jun 2026 13:01:19 +0900 Subject: [PATCH 21/41] =?UTF-8?q?[WEB-SELLER][UPDATE]:=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EC=83=9D=EC=84=B1=20=ED=8F=BC=EC=9D=98=20=EB=A0=88?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=B5=9C=EB=8C=80=20=EA=B8=80=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EB=A5=BC=200=EC=97=90=EC=84=9C=201=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=ED=95=98=EA=B3=A0,=20=EB=A0=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=ED=91=9C=EC=8B=9C=20=EC=97=AC=EB=B6=80=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=ED=95=84=EC=88=98=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B5=9C=EB=8C=80=20=EA=B8=80=EC=9E=90=20=EC=88=98?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EB=B6=80=EB=A1=9C=20=ED=91=9C=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=ED=96=88=EC=8A=B5?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EB=A1=9C=EC=A7=81=EB=8F=84=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=ED=95=98=EC=97=AC=20=EC=B5=9C=EB=8C=80=20?= =?UTF-8?q?=EA=B8=80=EC=9E=90=20=EC=88=98=EA=B0=80=201=20=EB=AF=B8?= =?UTF-8?q?=EB=A7=8C=EC=9D=BC=20=EA=B2=BD=EC=9A=B0=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/forms/ProductCreationForm.tsx | 10 +++- .../ProductCreationLetteringPolicySection.tsx | 56 ++++++++++--------- .../product/utils/validateProductForm.ts | 5 ++ 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx b/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx index 20aa0e37..0281af15 100644 --- a/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx +++ b/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx @@ -57,7 +57,7 @@ export const defaultForm: ProductForm = { ], letteringVisible: EnableStatus.ENABLE, letteringRequired: OptionRequired.OPTIONAL, - letteringMaxLength: 0, + letteringMaxLength: 1, imageUploadEnabled: EnableStatus.ENABLE, productCategoryTypes: [], searchTags: [], @@ -174,7 +174,13 @@ export const ProductCreationForm: React.FC = ({ }; const handleLetteringVisibleChange = (value: EnableStatus) => { - const next = { ...form, letteringVisible: value }; + const next = { + ...form, + letteringVisible: value, + ...(value === EnableStatus.ENABLE && form.letteringMaxLength < 1 + ? { letteringMaxLength: 1 } + : {}), + }; setForm(next); onChange?.(next); }; diff --git a/apps/web-seller/src/features/product/components/sections/ProductCreationLetteringPolicySection.tsx b/apps/web-seller/src/features/product/components/sections/ProductCreationLetteringPolicySection.tsx index 5717e183..757d3d25 100644 --- a/apps/web-seller/src/features/product/components/sections/ProductCreationLetteringPolicySection.tsx +++ b/apps/web-seller/src/features/product/components/sections/ProductCreationLetteringPolicySection.tsx @@ -50,33 +50,37 @@ export const ProductCreationLetteringPolicySection: React.FC< />
-
- onLetteringRequiredChange(value as OptionRequired)} - options={OPTION_REQUIRED_OPTIONS} - error={errors.letteringRequired} - required - /> -
+ {form.letteringVisible === EnableStatus.ENABLE && ( + <> +
+ onLetteringRequiredChange(value as OptionRequired)} + options={OPTION_REQUIRED_OPTIONS} + error={errors.letteringRequired} + required + /> +
-
- - onLetteringMaxLengthChange(v ?? 0)} - placeholder="" - min={0} - className={errors.letteringMaxLength ? "border-destructive" : ""} - disabled={disabled} - /> - {errors.letteringMaxLength && ( -

{errors.letteringMaxLength}

- )} -
+
+ + onLetteringMaxLengthChange(v ?? 0)} + placeholder="" + min={1} + className={errors.letteringMaxLength ? "border-destructive" : ""} + disabled={disabled} + /> + {errors.letteringMaxLength && ( +

{errors.letteringMaxLength}

+ )} +
+ + )}
Date: Sun, 21 Jun 2026 13:12:04 +0900 Subject: [PATCH 22/41] =?UTF-8?q?[WEB-SELLER][ADD]:=20=EC=A3=BC=EB=AC=B8?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=95=88=EB=82=B4=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=20=EB=B0=8F=20=EB=AA=A8=EB=8B=AC=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80.=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=99=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=95=88=EB=82=B4=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=A9=ED=95=98=EC=97=AC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EA=B2=BD=ED=97=98=EC=9D=84=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=B3=84=20=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=EB=A5=BC=20=ED=8F=AC=ED=95=A8=ED=95=9C=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=8F=84=20=EC=B6=94=EA=B0=80=ED=96=88=EC=8A=B5?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/OrderStatusGuideHelpButton.tsx | 32 +++++++ .../components/OrderStatusGuideModal.tsx | 87 +++++++++++++++++++ .../utils/order-status-seller-guide.util.ts | 51 +++++++++++ .../src/pages/store/detail/orders/Detail.tsx | 85 +++--------------- .../src/pages/store/detail/orders/List.tsx | 15 +++- 5 files changed, 197 insertions(+), 73 deletions(-) create mode 100644 apps/web-seller/src/features/order/components/OrderStatusGuideHelpButton.tsx create mode 100644 apps/web-seller/src/features/order/components/OrderStatusGuideModal.tsx diff --git a/apps/web-seller/src/features/order/components/OrderStatusGuideHelpButton.tsx b/apps/web-seller/src/features/order/components/OrderStatusGuideHelpButton.tsx new file mode 100644 index 00000000..5ca72d2b --- /dev/null +++ b/apps/web-seller/src/features/order/components/OrderStatusGuideHelpButton.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { CircleHelp } from "lucide-react"; +import { cn } from "@/apps/web-seller/common/utils/classname.util"; + +interface OrderStatusGuideHelpButtonProps { + onClick: () => void; + className?: string; + ariaLabel?: string; + title?: string; +} + +export const OrderStatusGuideHelpButton: React.FC = ({ + onClick, + className, + ariaLabel = "주문 상태 안내 보기", + title = "주문 상태 안내", +}) => { + return ( + + ); +}; diff --git a/apps/web-seller/src/features/order/components/OrderStatusGuideModal.tsx b/apps/web-seller/src/features/order/components/OrderStatusGuideModal.tsx new file mode 100644 index 00000000..62897da6 --- /dev/null +++ b/apps/web-seller/src/features/order/components/OrderStatusGuideModal.tsx @@ -0,0 +1,87 @@ +import React, { useEffect } from "react"; +import { BaseButton as Button } from "@/apps/web-seller/common/components/buttons/BaseButton"; +import { X } from "lucide-react"; +import type { OrderStatusGuideItem } from "@/apps/web-seller/features/order/utils/order-status-seller-guide.util"; + +interface OrderStatusGuideModalProps { + open: boolean; + onClose: () => void; + /** 스크린 리더용 (화면에는 표시하지 않음) */ + ariaLabel?: string; + items?: readonly OrderStatusGuideItem[]; + numberedLines?: readonly string[]; +} + +export const OrderStatusGuideModal: React.FC = ({ + open, + onClose, + ariaLabel = "주문 상태 안내", + items, + numberedLines, +}) => { + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ +
+ {items && items.length > 0 && ( +
    + {items.map((item) => ( +
  • +

    {item.label}

    +

    {item.description}

    +
  • + ))} +
+ )} + {numberedLines && numberedLines.length > 0 && ( +
    + {numberedLines.map((line, i) => ( +
  • + + {i + 1} + + {line} +
  • + ))} +
+ )} +
+
+
+ ); +}; diff --git a/apps/web-seller/src/features/order/utils/order-status-seller-guide.util.ts b/apps/web-seller/src/features/order/utils/order-status-seller-guide.util.ts index ed4a3d0d..615c2a18 100644 --- a/apps/web-seller/src/features/order/utils/order-status-seller-guide.util.ts +++ b/apps/web-seller/src/features/order/utils/order-status-seller-guide.util.ts @@ -3,6 +3,12 @@ * 입금 마감(픽업까지 남은 시간에 따라 최대 12h·6h·1h)·픽업 시각 도달 자동 전환 등은 백엔드 `order-automation`·`order-datetime.util` 규칙과 맞춥니다. */ import { OrderStatus } from "@/apps/web-seller/features/order/types/order.dto"; +import { getOrderStatusLabel } from "@/apps/web-seller/features/order/utils/order-status-ui.util"; + +export type OrderStatusGuideItem = { + label: string; + description: string; +}; /** 상태별 안내 문장 (상세 힌트·흐름 목록 공통) */ export const ORDER_STATUS_SELLER_FLOW_LINE_BY_STATUS: Record = { @@ -60,3 +66,48 @@ export function getOrderStatusSellerHintBody(status: OrderStatus): string { if (idx === -1) return full.trim(); return full.slice(idx + 2).trim(); } + +/** 주문 목록 등에서 노출하는 간단 상태 안내 */ +export const ORDER_STATUS_LIST_GUIDE_ITEMS: readonly OrderStatusGuideItem[] = [ + { + label: getOrderStatusLabel(OrderStatus.RESERVATION_REQUESTED), + description: + "고객이 주문 예약 신청을 완료한 상태예요. 판매자의 확인 전 단계입니다.", + }, + { + label: getOrderStatusLabel(OrderStatus.PAYMENT_PENDING), + description: "주문은 접수되었지만, 고객의 입금이 확인되지 않은 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.PAYMENT_COMPLETED), + description: "고객의 입금이 정상적으로 확인된 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.CONFIRMED), + description: "판매자가 주문 내용을 확인하고 제작/예약을 확정한 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.PICKUP_PENDING), + description: "케이크 제작이 완료되었거나 픽업 준비 중인 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.PICKUP_COMPLETED), + description: "고객이 케이크를 정상적으로 수령한 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.CANCEL_REFUND_PENDING), + description: "고객의 취소 요청이 접수되어 환불 진행을 기다리는 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.CANCEL_REFUND_COMPLETED), + description: "주문 취소 및 환불이 완료된 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.CANCEL_COMPLETED), + description: "주문이 최종 취소 처리된 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.NO_SHOW), + description: "픽업 예정 시간까지 고객이 방문하지 않은 상태예요.", + }, +]; diff --git a/apps/web-seller/src/pages/store/detail/orders/Detail.tsx b/apps/web-seller/src/pages/store/detail/orders/Detail.tsx index 3ab8f1de..f310a734 100644 --- a/apps/web-seller/src/pages/store/detail/orders/Detail.tsx +++ b/apps/web-seller/src/pages/store/detail/orders/Detail.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { useParams } from "react-router-dom"; import { useOrderDetail } from "@/apps/web-seller/features/order/hooks/queries/useOrderQuery"; import { useUpdateOrderStatus } from "@/apps/web-seller/features/order/hooks/mutations/useOrderMutation"; @@ -19,6 +19,8 @@ import { getOrderStatusSellerHintBody, ORDER_STATUS_FLOW_LINES_FOR_SELLER, } from "@/apps/web-seller/features/order/utils/order-status-seller-guide.util"; +import { OrderStatusGuideHelpButton } from "@/apps/web-seller/features/order/components/OrderStatusGuideHelpButton"; +import { OrderStatusGuideModal } from "@/apps/web-seller/features/order/components/OrderStatusGuideModal"; import { PaymentPendingCountdown } from "@/apps/web-seller/features/order/components/detail/PaymentPendingCountdown"; import { OrderStatusFlowStepper } from "@/apps/web-seller/features/order/components/detail/OrderStatusFlowStepper"; import { OrderDetailSpreadsheetView } from "@/apps/web-seller/features/order/components/detail/OrderDetailSpreadsheetView"; @@ -28,15 +30,12 @@ import { ORDER_DETAIL_PAGE_META, ORDER_DETAIL_PAGE_TITLE, ORDER_DETAIL_SHEET, - ORDER_DETAIL_SHEET_HEADER, - ORDER_DETAIL_SHEET_TITLE, ORDER_DETAIL_TD_BLOCK, } from "@/apps/web-seller/features/order/constants/order-detail-page.constant"; import { SheetSectionRow, SheetTable, } from "@/apps/web-seller/features/order/components/detail/OrderDetailSheetTable"; -import { CircleHelp, X } from "lucide-react"; import { cn } from "@/apps/web-seller/common/utils/classname.util"; import { ContentLoading } from "@/apps/web-seller/common/components/loading/ContentLoading"; @@ -63,15 +62,6 @@ export const StoreDetailOrderDetailPage: React.FC = () => { const [reasonText, setReasonText] = useState(""); const [flowGuideOpen, setFlowGuideOpen] = useState(false); - useEffect(() => { - if (!flowGuideOpen) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") setFlowGuideOpen(false); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [flowGuideOpen]); - if (!storeId || !orderId) { return (
@@ -193,15 +183,12 @@ export const StoreDetailOrderDetailPage: React.FC = () => { {getOrderStatusLabel(status)} - + />

{getOrderStatusSellerHintBody(status)} @@ -373,58 +360,12 @@ export const StoreDetailOrderDetailPage: React.FC = () => { onReferenceImageClick={(url) => setLightboxImage(url)} /> - {flowGuideOpen && ( -

- -
-
-
    - {ORDER_STATUS_FLOW_LINES_FOR_SELLER.map((line, i) => ( -
  • - - {i + 1} - - {line} -
  • - ))} -
-
-
-
- )} + setFlowGuideOpen(false)} + ariaLabel="상태별 상세 안내 · 전체 흐름" + numberedLines={ORDER_STATUS_FLOW_LINES_FOR_SELLER} + /> {lightboxImage && ( { const [endDate, setEndDate] = useState(""); const [pickupStartDate, setPickupStartDate] = useState(""); const [pickupEndDate, setPickupEndDate] = useState(""); + const [statusGuideOpen, setStatusGuideOpen] = useState(false); // 주문 번호 검색 debounce (과도한 API 호출 방지) const debouncedOrderNumber = useDebouncedValue(orderNumber, DEBOUNCE_DELAY_MS); @@ -147,7 +151,10 @@ export const StoreDetailOrderListPage: React.FC = () => { {/* 주문 상태 필터 */}
- + { - const raw = e.target.value; + // type="text"에서도 숫자 검증이 동작하도록 숫자만 추출해 파싱한다. + const raw = e.target.value.replace(/[^\d]/g, ""); const n = raw === "" ? undefined : Number.parseInt(raw, 10); onChange( updateRefundRuleAt(policy, index, { diff --git a/apps/web-user/src/features/product/components/modals/ProductFilterModal.tsx b/apps/web-user/src/features/product/components/modals/ProductFilterModal.tsx index 04689540..1f1fcc08 100644 --- a/apps/web-user/src/features/product/components/modals/ProductFilterModal.tsx +++ b/apps/web-user/src/features/product/components/modals/ProductFilterModal.tsx @@ -119,9 +119,10 @@ export function ProductFilterModal({ }} > setMinPriceInput(e.target.value)} + onChange={(e) => setMinPriceInput(e.target.value.replace(/[^\d]/g, ""))} placeholder="최소 가격" style={{ flex: 1, @@ -143,9 +144,10 @@ export function ProductFilterModal({ /> ~ setMaxPriceInput(e.target.value)} + onChange={(e) => setMaxPriceInput(e.target.value.replace(/[^\d]/g, ""))} placeholder="최대 가격" style={{ flex: 1, From ef5a49268fd0340d0713c22dba7764145fa27687 Mon Sep 17 00:00:00 2001 From: jangchanwoo Date: Mon, 22 Jun 2026 16:45:21 +0900 Subject: [PATCH 37/41] =?UTF-8?q?[WEB-USER][QA]:=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B3=B5=EC=9B=90=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메뉴 클릭 후 뒤로가기 시 클릭했던 스크롤 위치로 복원되도록 useScrollRestoration 훅을 추가했습니다. window가 아닌 RootWrapperLayout의 스크롤 컨테이너(scrollTop)를 sessionStorage에 저장/복원합니다. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web-user/src/app/mypage/page.tsx | 9 +++- .../components/layouts/RootWrapperLayout.tsx | 5 +- .../src/common/hooks/useScrollRestoration.ts | 54 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 apps/web-user/src/common/hooks/useScrollRestoration.ts diff --git a/apps/web-user/src/app/mypage/page.tsx b/apps/web-user/src/app/mypage/page.tsx index fca3f116..3847d184 100644 --- a/apps/web-user/src/app/mypage/page.tsx +++ b/apps/web-user/src/app/mypage/page.tsx @@ -15,6 +15,7 @@ import { ProfileEditBottomSheet } from "@/apps/web-user/features/mypage/componen import { useUpdateMypageProfile } from "@/apps/web-user/features/mypage/hooks/mutations/useUpdateMypageProfile"; import { Toast } from "@/apps/web-user/common/components/toast/Toast"; import { useLoginSheetStore } from "@/apps/web-user/common/store/login-sheet.store"; +import { useScrollRestoration } from "@/apps/web-user/common/hooks/useScrollRestoration"; function getLoginInfo(user: { googleId: string; @@ -59,6 +60,8 @@ const TERMS_MENU = [ export default function MypagePage() { const { isAuthenticated } = useAuthStore(); const hasHydrated = useAuthHasHydrated(); + // 메뉴 클릭 후 뒤로가기 시 클릭했던 스크롤 위치로 복원 (레이아웃이 정해지는 hydration 이후 활성화) + useScrollRestoration("/mypage", hasHydrated); const { data: user } = useMypageProfile(); const openLoginSheet = useLoginSheetStore((s) => s.openLoginSheet); const [isProfileEditOpen, setIsProfileEditOpen] = useState(false); @@ -70,7 +73,11 @@ export default function MypagePage() { .flatMap((p) => p.data) .filter( (o) => - o.orderStatus === OrderStatus.CONFIRMED || o.orderStatus === OrderStatus.PICKUP_PENDING, + o.orderStatus === OrderStatus.RESERVATION_REQUESTED || + o.orderStatus === OrderStatus.PAYMENT_PENDING || + o.orderStatus === OrderStatus.PAYMENT_COMPLETED || + o.orderStatus === OrderStatus.CONFIRMED || + o.orderStatus === OrderStatus.PICKUP_PENDING, ).length ?? 0; return ( diff --git a/apps/web-user/src/common/components/layouts/RootWrapperLayout.tsx b/apps/web-user/src/common/components/layouts/RootWrapperLayout.tsx index 615cc1f3..5d092a74 100644 --- a/apps/web-user/src/common/components/layouts/RootWrapperLayout.tsx +++ b/apps/web-user/src/common/components/layouts/RootWrapperLayout.tsx @@ -58,7 +58,10 @@ export default function RootWrapperLayout({ children }: RootWrapperLayoutProps) -
+
{children}
diff --git a/apps/web-user/src/common/hooks/useScrollRestoration.ts b/apps/web-user/src/common/hooks/useScrollRestoration.ts new file mode 100644 index 00000000..0400521a --- /dev/null +++ b/apps/web-user/src/common/hooks/useScrollRestoration.ts @@ -0,0 +1,54 @@ +import { useEffect, useLayoutEffect } from "react"; + +/** RootWrapperLayout의 실제 스크롤 영역 id (window가 아닌 내부 컨테이너가 스크롤됨) */ +const SCROLL_CONTAINER_ID = "root-scroll-container"; + +/** SSR 환경에서 useLayoutEffect 경고를 피하기 위한 분기 */ +const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + +/** + * 스크롤 위치를 sessionStorage에 저장하고 복원하는 훅. + * + * 이 앱은 window가 아니라 RootWrapperLayout의 overflow-y-auto 컨테이너가 스크롤되기 때문에 + * 브라우저/Next.js 기본 스크롤 복원이 동작하지 않는다. 그래서 직접 컨테이너의 scrollTop을 관리한다. + * + * @param key 페이지를 구분하는 저장 키 (보통 pathname) + * @param enabled 데이터 로딩 등으로 복원 시점을 늦추고 싶을 때 사용. false면 복원/저장하지 않음. + */ +export function useScrollRestoration(key: string, enabled = true) { + useIsomorphicLayoutEffect(() => { + if (!enabled) return; + + const container = document.getElementById(SCROLL_CONTAINER_ID); + if (!container) return; + + const storageKey = `scroll-position:${key}`; + const saved = Number(sessionStorage.getItem(storageKey)); + + // 복원: 데이터가 늦게 들어와 높이가 늘어날 수 있으므로 몇 프레임에 걸쳐 목표 위치를 맞춘다. + let rafId = 0; + if (saved > 0) { + let attempts = 0; + const restore = () => { + container.scrollTop = saved; + // 아직 콘텐츠 높이가 부족해 목표에 못 미치면 다음 프레임에 재시도 (최대 ~10프레임) + if (container.scrollTop < saved && attempts < 10) { + attempts += 1; + rafId = requestAnimationFrame(restore); + } + }; + rafId = requestAnimationFrame(restore); + } + + // 저장: 스크롤할 때마다 최신 위치를 기록 + const handleScroll = () => { + sessionStorage.setItem(storageKey, String(container.scrollTop)); + }; + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + cancelAnimationFrame(rafId); + container.removeEventListener("scroll", handleScroll); + }; + }, [key, enabled]); +} From 9322fbfc66c2b83a939140fa1fd27214d40b4cca Mon Sep 17 00:00:00 2001 From: jangchanwoo Date: Mon, 22 Jun 2026 16:49:42 +0900 Subject: [PATCH 38/41] =?UTF-8?q?[WEB-USER][QA]:=20=EB=A7=A4=EC=9E=A5=20?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A3=BC=EB=AC=B8=20=EC=B7=A8=EC=86=8C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A4=91=EB=B3=B5=20=EB=A0=8C=EB=8D=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StoreInquiryBottomSheet에 order.storePhoneNumber를 phoneNumber로 전달 (예정/지난 주문, 확정 주문 카드) - OrderCancelView의 중복된 주문 상품 렌더링 블록 제거 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mypage/order/components/PastOrderList.tsx | 1 + .../order/components/UpcomingOrderList.tsx | 1 + .../order/components/ConfirmedOrderCard.tsx | 1 + .../components/cancel/OrderCancelView.tsx | 33 ------------------- 4 files changed, 3 insertions(+), 33 deletions(-) diff --git a/apps/web-user/src/features/mypage/order/components/PastOrderList.tsx b/apps/web-user/src/features/mypage/order/components/PastOrderList.tsx index 8c51f609..49bb4aff 100644 --- a/apps/web-user/src/features/mypage/order/components/PastOrderList.tsx +++ b/apps/web-user/src/features/mypage/order/components/PastOrderList.tsx @@ -176,6 +176,7 @@ function PastOrderItem({ order, isLast }: { order: OrderResponse; isLast: boolea onClose={() => setIsInquirySheetOpen(false)} kakaoChannelUrl={null} instagramUrl={null} + phoneNumber={order.storePhoneNumber} />
); diff --git a/apps/web-user/src/features/mypage/order/components/UpcomingOrderList.tsx b/apps/web-user/src/features/mypage/order/components/UpcomingOrderList.tsx index ece6ee21..655660b3 100644 --- a/apps/web-user/src/features/mypage/order/components/UpcomingOrderList.tsx +++ b/apps/web-user/src/features/mypage/order/components/UpcomingOrderList.tsx @@ -168,6 +168,7 @@ function UpcomingOrderItem({ order, isLast }: { order: OrderResponse; isLast: bo onClose={() => setIsInquirySheetOpen(false)} kakaoChannelUrl={null} instagramUrl={null} + phoneNumber={order.storePhoneNumber} />
); diff --git a/apps/web-user/src/features/order/components/ConfirmedOrderCard.tsx b/apps/web-user/src/features/order/components/ConfirmedOrderCard.tsx index 7ddc7481..b03d2deb 100644 --- a/apps/web-user/src/features/order/components/ConfirmedOrderCard.tsx +++ b/apps/web-user/src/features/order/components/ConfirmedOrderCard.tsx @@ -60,6 +60,7 @@ export function ConfirmedOrderCard({ order }: { order: OrderResponse }) { onClose={() => setIsInquirySheetOpen(false)} kakaoChannelUrl={null} instagramUrl={null} + phoneNumber={order.storePhoneNumber} />, document.body, )} diff --git a/apps/web-user/src/features/order/components/cancel/OrderCancelView.tsx b/apps/web-user/src/features/order/components/cancel/OrderCancelView.tsx index bb4d4efa..d4829aba 100644 --- a/apps/web-user/src/features/order/components/cancel/OrderCancelView.tsx +++ b/apps/web-user/src/features/order/components/cancel/OrderCancelView.tsx @@ -149,39 +149,6 @@ export function OrderCancelView({ order }: OrderCancelViewProps) {
))} - {order.orderItems.map((item) => ( -
-
- {productImage ? ( - {order.productName} - ) : ( -
- )} -
-
-

{order.storeName}

-

{order.productName}

-

- {[ - item.sizeDisplayName && - `${item.sizeDisplayName}(+${item.sizePrice?.toLocaleString() ?? 0}원)`, - item.flavorDisplayName && - `${item.flavorDisplayName}(+${item.flavorPrice?.toLocaleString() ?? 0}원)`, - ] - .filter(Boolean) - .join(" / ")} -

-
-
- ))}
From 9c65753d4724b9fe4d30d5e2aa2c7d4e05bf0e94 Mon Sep 17 00:00:00 2001 From: jangchanwoo Date: Mon, 22 Jun 2026 17:00:14 +0900 Subject: [PATCH 39/41] =?UTF-8?q?[WEB-USER][QA]:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=205=EC=A2=85=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit category1~5.png 이미지를 교체했습니다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/images/contents/category1.png | Bin 2358 -> 2151 bytes .../public/images/contents/category2.png | Bin 6846 -> 2618 bytes .../public/images/contents/category3.png | Bin 2758 -> 2547 bytes .../public/images/contents/category4.png | Bin 4624 -> 3838 bytes .../public/images/contents/category5.png | Bin 2523 -> 2314 bytes 5 files changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/web-user/public/images/contents/category1.png b/apps/web-user/public/images/contents/category1.png index 5b83aa8160950b64b9e17ede032bc2165d94e9ab..8f1786eff385364a563f8baf39fffefd4624a9ac 100644 GIT binary patch delta 2063 zcmV+q2=Mo|66X+*Z+{2kNklCHL_jmXh9EcxFVk>Ymv}xREW|Gt2}Zq*&cPAKrz^4Ex1BAs_xN+ zz+#fs$eLugoN{uKtPUX@#})L5Q(iIsm?N7ZvmkWiEPH3wnQ=s5qXg|`#u-F``As`+ zjxwDYryzlIt$&Igx57P0b_m5)w?!~bRrFEhNN&QCs5LswKwrDF?AT-nNZ=N7U>KTl z+)DQtJ#1odMP)3qCf$o8$N{Y$tMbdB3+jhWHbvfRLlfFOmPFl8hqDk-{jkW+(mgr^ zO=uBTg_~InR)G{3zBk8oh2~5{v8sAGpVlG7p#itB%73i?IWu8ZK0OI{_)Lfoj~?yI zD(_|@tQ1L^VI!lWDz&g8YUbF;K-^Tqim2MKky6~m!m2`vMU|YY2hO=uNeikp%^mI@ z6jAlT_x2bPl1Nz9_Sy?UH}$0L+}NR*F2tiV$06iNokH@7IKL1}SXC@>H=*<>n%U-h@yFrlJhiM-^5=aCxv1 zAtZ-}VhO>e!-6|!ERaBtRaNjDgt8G@C9gs-xy;Xr%=s1>rTXhOaQ`1*bR5`h0WVLH z)sQK_UjTmmV)on5)A>HJFm%}&vwYPx{n~@3@ zvGIW`Y(MxK?@Xt^R(kptQbEFUUvM2o!=ipZ?H$_EHYJXTCx#o}tkEZm#pd{qXhLepPWfaw~gITv3@H zNPnbH-;3sXorn-uROZTsXxGq-uU0FBRo+@h8MyQl62mu~#q9{TK!v3J3vlr}_c3$k zOW?%&!MQv1HU5l(sj0E?F8r$E@>Ifq|Ao|i`x*6_&93ywS12`&tVL?Bmm*_vyLQ_9 zgkLDGp)7Qo8ELqB(Lc=o+H^hG3=Z~sZGV@rj{DJj3#Rbk9&qDo|DW^E?%ja_u&733B~#5*DxQO(0*VJ!%z zF)6~D$)d;Zf?yguSXlYwoOxKq2!A`i9s|ed7;?noo~QhU-LxH8JxX11*MR^2g*H~N ziFVE!jW_)ntw@Kgf)}J z;;9Y6G;Hx$GieX2rQ2)>rqE_hJMGxh5KLjQmD3Sc@~kTI zc?yYXc)#ftR;uG_!qxDs_kW2lVU;U?1fgoQdY|x{8gm>%)mV{2R|u=TwgsUo%=+!- z(Qh)F6j9Z|S1Vs()f)3H2xVus-`+bx(@(`~%THg8`UA(SNYrpF_79RV%mg z_T3hAaqlkhU;%ja2$^!_Jn-B1!RPFH6WH=RGKItM*n0OLH+#63DRjA3*%-KbMA{fQ zQf60wNV*z}=V%mBnMfYXj}4v}kd)z!#5AIj)Y{&enrWMVb4R3&y8Lf(W{ z!M((8J*#m)>6OY47JoJNh+E_w+Y9B~Svu^8ppyoPx(^9RBPxPvc!ffs*QxC5|$BhGsi|sQJK`jG9qqf*vLRsCX+EG^+LFvCG(P# z)5)neG9a_TLXWt5$8gQ$1Tqwr$#fK$D>zl2OFK6@+)k}0t$%*|kx7pAgYxbnxVsR` zo&)}{kcWFGdS`_;VHqi^`eGyF(A$gFNk{y`)rf?I=4E*JbGJ@W8Lb}cMGe-^U~s+H zCOZ&yJT)!3O%vodG@#zgxs>1Y?z}`T49>kgh~SvdNyn`-1`Eerft!w-dIlTE9D$pT z2)YtyS>QRRI)B_En|9n`Od$-qQFX`7QDub1Y?9TSk_wtzb0;b4#_ff>_xK(Z*IeT2 zmixhZ$(=^lU4U6rdSQDZp?Ste4a*|yn!#bi{V3@e^b98G~4Kt<800>D%PDHLkV1m}T6chje delta 2271 zcmV<52q5?85VjJKZ+{4}Nkl9ml^_<2dmn&I|&?faDW4cOoX(DJ&*|?9;clw1O$qhaWZx` zV|N$ztDd&U;~q~>Pd}>5zmGJY?w;;(Pk;OOsH(0ifr_J4r+ns=ohL5VhM`gQ5BB}Ai(3L{W4RKK^1zQ3jgxvJOJ^d=Aa z(9-{-jX*uXe61u&8m+4gXN>@7_XAA3q=R&h5IVL3_4jLed% znT2IhGk+%&ac2^iMa_+w#Z8?Rn?xy$sUDcDG5Lz>bW)cp(ieC3Yg9eRiBf*7VxCGE z=}0B4DW^K$GGha3BW*c%>KLo`dpDu{_rVm06dPh;<%4MsnMzobEKxNf*C-WDS0_`i zPm@ed=ZYxL$aZd!(>NTw1C8^%@6fJ?-~#eccP@ivhw9yF5Q2U9*_`L;P}w`9WZ z?S2BbR$FMJujB2627`&Nm|d`Sc$k+H+i}md*g=)yCtz%2VwQZi#Rj!Pp3h;XMmN%* zT`M)#V%b_Nz!{0uc#E~&T3_x1Pd^2o{4HFr+V^LH8n02T#aeBxbfG`~b@)EaS6>6Z z{(lDW+8daOyuUwd##n8U=q?d!nBMN9=*<;V9Xe?NUU zy=*~>v%TpN=-S=gm<8c|1&`|v06qR}?` zjNE$~{`wIt;^t@r!QAkQ>OI@ge?8d7(|;f}WLdx50{-^|`1H@f{xjg4KhQyl?rDY8 z{L3PXrE={h^E68{+HgWxZNJxN`K`G@F)%GJUjQxbI{ky=ZGZSJP`jYB-0$inYkvW$ zn8tM%L0I%@Myp4z*qE|%@Nt)a{^g{o5wz{`AK?s2tq!~6;~0CP=H@d@-T#dLjNGz2 z)Q2@Z7M8|QI&Z{Ta+RqaYdDk@yEeebIV4agEmrk9lu>zLs!mKAW6yp$5ydn{BCoAVrVT>(TSXqO!%w0e!D00Op*5iPV?&%72$o7F6@( zp-iY|hF%~23Z?Mm@5qJk{0Jpd&wW^1rW9CMS7Bitg@tt#61LKl^D*(Tux`?3SL`NT zY3592+(HN~EYxE#ZlTn!t}8Y(A2V*zVpr@UT2t+c{Yp(y%_N}aYhe%A6+6dg+7%DF zxVOQ$LW?vVJXeedU|hkJa(|)<3XZd`Ai~bi17U+E7&n*;wKT$t?b zN{oN}v}mjS+PB=WTKWxMLF2W^@fwX4)>tjX4vK;%XP~k63%lDhh6G*t0`Lv}8!k;b z(L^f7TdVo>j3c9oAAcUA?9S25X=Lzf>M797fn2V&kYH=l?9UUY88sufUDQ^qcyv@N zpjxKIE3Z!4ZDQXYQ?B)c8({1pp%#fcWtl{ZU9tse7kqj0Y|liZ8J`Ju$t9D}1xF?aqI$dA`sla8I0$G4M1DppFND6fi+<{2W@?KiOqHXO_c>F} z80w5J%uLR0q+}*x2^KeFG7vTVnhaoZGa@5ViA;tAGA9z2vpD9ag<@((q7s>n^JGq_ zPvREXhU*F{_I1(7CVfNyKAV)yXV$= z$x3x2dD8aO+5cU^oWsPbt3B2oEaQD48ih4^7A`DdVt=nMH{EYU&_zG#zmvDyjs4Fk z5oZM!q6y#MD$4D~o&qeu8G$Vj^ypMz5k~?`T&qx$RZ0w0qboQzAZpk3(mL#; ztF5y|)~N+;*(7L$wZcXy)v03-T3oqSvL$y-GDJh}EMdn+F^92U*ip14Q&+Y%ZOzy8 znuq-6msoF`v9FZ>NG~@Bqt{i2$_YD)vFM%_S7=KX($hcbs2SI?GI6edkU?dy^k`vg8*;*jmk}XoQ zwZTifURDifrrABcOnueeGxY$);iA!y{mfVO)!I{NI8hYMs(*(-m1E}dhR8UwPAfkEJs;HMM~TGlhnsL{eZ&m8Z>P02w;al3!P)tweXwAZjnxMC2(|PMXJA zR70pr8(Q>o(<8bPC{vY|Dl4k2NA={qDhu#r6KSC@NA;DN#F{FLss!*167{09=`2QG z@K_0rvlvaHV1NG6k#3{LoyDjk!A`T{NN2deQ{^&!vWI}CeoSiIrZrZCRZv6R9R8i;$=8MG-6HrGU-B-2vwO^(HlYtv4~Yt zUr-OL@KTcqR9RKm=rn{75o@ZOB}`U=R1{vDb5d2a)qkqkWW8)p7vL$vGIU~PZT#7q zSks=Khn_z36o<1%&)aHut%&tWk~KnDN~%T{>!n&Jgax@HiS<&&2r;7E(qc`bM5(?_ zm4R*UeA$F*lZLWwlljJM# z)Peatll6JU`YO0Sgve1PXI90&3a%Xi^qR4u42@Wm1+T$1pWX*<-vl1qQJ+81U-$FS zZeaHwV5fTAJEK0|3#;6{1>C!%U!Udk&w4-Gv42yA!%qGBd(3O|_i;;RmYgM)qe}~L z!58Y{tzQQ|xv4K~5gyv5FYW!3l2UCPwWxvEtF{B2tr}TxQpNQ@O;A+=t`++N6H0+)8z} zYghl{UMl(UvU{my^KbsI^xwR#Uz-%Ma(|;tj8utar3pNINGDV>Fk0vOqjn%-Bfa%L zfCL-YCs!(2zb5(ja~gGEHA}oIehyNwDx7rKUzTH_3EdJK^-gE852KZB&7{MKm5bVA z#^EOY5i7Tb$wzAs_Mv4M2+V%5Cm{jfp*~uB@OsE)$XgEk#kL>;2i843I8(%#D}NaV zBR0~<*aeW_{#|%tJop4c%o>+l5!-?klibBgflhmH=d?uQ6@bJ(r zu>mCbyn>;_CLnuVTKq6=`aXUSj%(t}eeA)*(PkO3Uh4P}P;3B?dv=Jft~e$u*8S?e zX3$;fD92{RJ`E|Rcfgat&Rr1Z2!F-O?VRH~bC=~czvIAe@oN&uiY0sZ!4rdv++}ef z_AC40iLpoi%`Tr6bCtaO`r78e>mi?1&WayvyUG5q!DDcF68o2tCynAxY_>U zMl7@ACPgh52Hd?}et|&R^A9`@SGWr!Ms1zfKJoW@_us(Px3rWljIw5^#v)}x|px}E- z;_Ba&*sI8d&->YLom9tD&W|B*y>BOrj*@catzyq%^gh+n(Rs05tQMlwU!Red8@d-u zxN1>$jcqVlU$ zDx1BWuGs3^*?y$uK5`hf;h^>73)=iJ-Ty_ckd!;)^k}?vD3xb2+NI48n_^X+%!svi zUo~q;$>mtD{7S)WPCN_CgvRB-eC%teBNB2s&ill7P$~OVt?~8m!7}acC(}vatQ`_^ z=Z-sBRF=w*ed_ViSnhtZCD!z3>y;hH zu`PGz0Pv$$w`@ zjXy(-qw{)Mj10_%2Rg(95o<#E4`>3mBlpoKfMY*_2(nU1JAeL-q(d?pt?OtCwj-D8 zpSTXn7V=WbT>Qol+twAEsBNPu*pA%qKL`BOk_e70$xB^Ix|>JZ3F9p^?ao*>BKOcY zdc%!>|C26dp;RtdlixBqmn1fECxAI)zKtd^YUK7yC8Kmp+(}(ZyZ6qpyLhzF#QWKm ziQKrkvrrroR zPvmAv?I$a8yHedv3C;}@J9M|5d15pAwQ+_;F1K_RkxGU|u6wCxv+n+!d7DwwuVvH* zXd+w%xVg*EoP@?5<&PXHUtMyM_rLz47ol-Dl8MrHpMP~#{gQ2?OiB0~12>OcHwMnN z@!9-G&8(8gi?ey8l3Wo>8eb(hpIlXNm)u7cn4w1AL#@7(@#9PP+UKUw85AZHdkG%b zWM;u}3Lh?_USJ7Ln2;YIDD@Q-BzGGdN42?2>fr@=BpG_U?qQ4~mdO;mK>i?W8Myvz zK7RYqynmDyOU#fX zy5xG(ZBs*7z}i`geR7*Ohg&_b!lG1S^^i^lqTYm&|~Vb;QIHpH!t1!gAFcNA$d6{(s_TM#5LkJvjY&Tv93#jo5z7Wc?y0 zH+n-=Ud%h6Ix22X7>Ay$pU~)KTgt!rxGSlZ!M2x^2+{a+KGJ!{WD$+8;FU->J(Eo| zK7v;wFLXOmPw-mQ9M)Ai6X|J8PZsJzEkwGFsw0%SqDm{OsZeL#owAqipTWENinQ0WEp$Y7un00000NkvXXu0mjf&mIJ} delta 6795 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 `FcHn@SmHTi= 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 delta 2674 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 diff --git a/apps/web-user/public/images/contents/category4.png b/apps/web-user/public/images/contents/category4.png index 5081b75eb2a33ec883ac3651eb15ceb4385878b6..3577981041c78cd12fcbdce8967b90bece9ad64e 100644 GIT binary patch delta 3763 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%0ACu6eTRE ze7IJ)r4n^XQ(fYOc3oIbw45tr}MSM8}Y1=WHCWQW`lbI@Q)IiC6N1EI(^fm|GikqU=c#yGKLou{Y*344@ux(Suw zCx37@&FIL^)cmS#|8UfYlYMbmv`%i8cRWxW5&!T9M4x>jPQ3Jil3YMXru<%YUheB0 zPYJ1LxAN$G{dy$lDHK@NIL{T^Z$6G5U}{miqLQj!?am1w>|(ZJfzH!B=)J5lp(s!J zJ^!w+FBKGt0P2ZH3#M2m>Y*+yyOj&LNq=?sS@Ai19%RD*!&TsSw%yM!m{9w(1BPvi zqlQmT5&f#SG_UJA*{3=goxR;L!BgC~o?)d4cuCB}6{170i0_twiGE;vfmo0teWD~- zlB^P}&dUo!ehy@URJ1f7+1dSsD2WM=R05~m6z2!1_Akr|4k#5hxqwQho76jmT7QAI zX+}HWX2R6zx*U=mOyBA!TGJtd8Q}X5iYe-nK&~7397bnU)dF%~H?dGFrY*d``sCw+ z)UdNpsr(SLi`mk~C`AGAscPk*3gyHg0v_xX%cvfF^i4)>S6wDjJ&{s*Qklzn)V%i@ zVJ79p1AlwuDAAQGg<&?e;!rIsm4C;hnsI4@WdyvlhYu~ahfgYyGuxYRi@P;7K15L% z<$3qGo>U3v1P!k%kP4;Rq`HF}^<-jM1~mZ41Bs9ADqIApB1BdSMQ3L|EmoqtAY4KX zmgIihw-VjmEh1opg-e!}QJ%QM${&-n1!8COeN-=VJYI%acP7cLL_bCA zE`XGB9}fDSNs;U<^~u?JV(mw^S!{sn0cH5D$Y%wKcK!UG?gCKzqi7&D8N41=AG=o^ z3S0SuRBw0{nt2hbTYvVu0NmTfk5cSmnSJVFF|l)0pXhj{GMwg)jJ=dWphDi|ge2k?W^o-FjV9fvUl{-@Ig%2EIngPIG4X z@2x|DnS_i`-M-d~Q05aI`}H~H@2p-GdtS*Xb)hB1Izd!Fg@1|7t};=1(p7?k_*uV* zSvGg&;7y^`A=L=1SNAp#L%VO-K@?JUEdhL;uFI23`I~8-Qf0M7TN}|Wo1EFs(-ndqjti%?2wF2J%RoJS%|Gt81 zRZ`XZBS1&0PJgB4NQKh3-med+;sV2)BTr&&)e`HYumIKWc(Aan`a!k!$UZoYawS1( zos;TQW#u4}A((dEA*vQ*>tDUq3i#V0BmW$Q*{?+*w>v_Xr6-bDooQB8>fQ#Z)q`jl zM}=C8k`yt6;croWiM6kYu=};BU_b@GYKqm$K^geyGJjMO>IESS%w+W-%>AQ3<|90* z_>xNI!E$eZF8iO^#cnOeNj$KE!os*LYIE-X);-crEi!Xwlk5Xm%&#=TEopw?JVx}b z_JYYSe1KvR8Y9cJbmhkKy9I!CWweK%xT6#%K<*oF5q<8wumQx$L2~)TGxZrthGKkl zC7~TW!+-S1k3<*8y}$Sn#e!JVd|1Rk{s{b^SXahhNuYX4f;pC$}3U>>jG!%ts6AsbnF5R$N zB=ynO-Kg)+`h9nFi7opgU^goMPprw&J;aPdRBxzVpQ5rBnI3eeaz4oSMU_~~e2x#x zRWvwLaX!r+_cP6dRByQR#q-T5_)#{`Y_RW(B)DI#kW^s7|BW>{-pfDzEHzL=us+td z;(sYx02ZDBpEdWBxPzd9T7dC$yEMO-7KlaJA3K<`c>lH@k#drOo6(v5Cg&kqAQtUd z8st_PRUlP-O%>1}w@RoIsX$dmne7AN2Sg2@yKum#R*6)gYUKm=0p&n$h-0`(fn~l- zOR7gIP?OFZA2c|?`;yO70|5{`|62)a)_*JK8LIs}Lj`gZ`DpqoV9d!V^->K`({xKC zsPaG+gCsB1v_uaFZjVSrAq5!~SmsOIpWa1RL~FjDoDX_|S5QY>|&B!AU*I=XO){Fvit60CjRg z2ckr%M1;%o1T1SbLsP`bHG1l-Xj#q2#z@|5X20wL pBwFWrR)|me@L3xTMO~5Q@PGLAOh+pJ9?t*(002ovPDHLkV1gm(%tHVG diff --git a/apps/web-user/public/images/contents/category5.png b/apps/web-user/public/images/contents/category5.png index d9f4aa45b5301442f1a9e81987f416b1827f6666..66ded500701593f0370d741fa3e81e5436e91c85 100644 GIT binary patch delta 2227 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 BMO**? delta 2437 zcmV;033~R565A7yZ+{6^NklYonM-G;adUHH zrAT{ry1V8g{{?ZHYa_L39RC3~R=Vp#Uw>n|xGU0_sp29NGrx;CLrNxPhZ3-PZ$U^D z1rQ*JA8(=k%uobDfCBjC`>}6*xpRKEd-7aSOSEfC595Pz?M00-Z2{0*RiuNxt| z_V6(PAqM(csZT+P9@Ox0>8LA2a{vY-z%bN!*n+XUiGp0mSDU!XfqdxV|LB3I9>Z#_ zBUabDU=Zwu)fPFb-Nqxmi7#$PSYHwJ@ayWJ2Y3v1x6R$6SqiJge6`ZTh4;~7S|y4Z zjSy|{;GtOG*nb6vuo8A6=)2&0cUg)VPET91ba%w%W)GOaLRj_k#iq7SrDLYSh+QrM zmnv*^1f8XPflCoqz3e;S)$ZZaZKyQm30$bKhQ`q4k#bvHzOn-aQkbyX?Rwq=$gkbW zogPT<4q9&q3P3{6kd>-~7xz%qM{s^cB{2NRNB9GOAAbrUSBv#ysXXt3hC$Z*W}dM( zH;Yv-hwp$ZpMVuL4En)8Q3rc>At!PXR*lfzc<%1GN{|+(f#u12dAa6HU9rx34|FqT z3oOZ@^5jqM9ns9L{+yYxlX&_uF1EmuhAzh4x3h}7nFwo>q|C@HsajfCD{AIsBJNVc zT2XUjX@7B3XT>^E@?)w4maNhFit6`Mw<^*%cMfV)9<+&4ezahfS{Ug_C9H0zR=#J3 z18OU6J9pt2s}6?mf^t6uOL0iCB`$A#w8$lwBz5Zu_zx>@i3y!ebaA8OL4~)ESY5zGu`vdf?$%e?Jgq--{oLt|RQ6Jco_B@>CVs*PVkVt(_d#Xg%WwG$1eVr<0*_*kvOxx-VZ zEmjTtluoEfqi4Amh` zC9JIQyRj;%3+2STJfLXm;7ET|Zf0=$l9Iydn8&piKVeOZt+Q}BcGOe`S$`B8Ote;R zNb#BkkJ(LDCJW0YH&A|zWhSf##=^xFD{{ra%wkQBv5dS83F`nSOm@Y5j=dq&Xph%b z;m^MT81=zOsAhcicff(%$17eKWwYWe;3uqWu&^$l{J>glmM|)XlCZp{@-neb9Kb9q z<|VAninE}{%N3j4h7I6x4u1ow=S<~-trKU15GX9DFM$hH5SBxZpwu4LiuKILJTSRV z9KgdBcX_$uD#Tb0UI#TrSM2hcqPhyMn8P{LTom?@kt;U24I9AW9A2R-p4=?k0}j|a zaTat*)5&$k@EAB?a-BG!{Ccr&Se)tvI9XW=wobe}$~{hk zPlP>m+fb%Eu9C22D8#f2?wMpe(8sW6SV9 z%4<)*JT2@Il$RY>$$u3WeJ+#@uQzw6SDg}8o3aP@p$w|fh7BnCT-4db_w1!HM_xJe z*4a97dgR#=bUb z7cN0%pnG@3Mt=*v%wwptpfa|qX#PxDqksL63!T}Snh7)**1?{c z$yEK9{@|R24CIB@THm-6`R=@3Clf!=h|G4*SZ+w~TIwm#^p0h)S@_0h-S6jl z<#ouPJ5bd1=E6O0iDT?6YPh$Hd%pvg91(4-F1-yYNIz7;p+_?y^0`y5Y6$#p*WA?2 z%+wZ3=#rz7k8Gx#F?2J!&@(yrASFu)OIUF;CIeCZkAKMkD{e+)Br1`~a6tM%{C1X& zxoM%8nvtkPX5&2R0~(XKq79bCP~JyyIXh8_OkD9S>P3GWq%?D`_rk$;YQ4D)8Iy~! zgc|nmg3#jHPWo^LRK=UU&^s$~6PB<^RRkF(Nm1lFmLeUv?vb!1$P<)zALHp8d5TKp zYO&K`+<)DKntX6>t(UA6JIRywr!N2R3g#RpRs-}{_hB7A6rx#JmuKO^5<2#}a=ZGS z2-x(K?t6K=-O&FGB~n>|h3J6m?iA&ALk|HMP#J+42zq=dz=&gkCEPS9$top=#=%P1 zX|TYt1)_GV&|6HBGeGO?l69h>T~2}quoX5yseXO~Mu6hV&5|v7ppzjQa;FY$j$%&3 zUf5aCl4 Date: Tue, 23 Jun 2026 09:30:40 +0900 Subject: [PATCH 40/41] =?UTF-8?q?[WEB-USER,=20BE][FEATURE]:=20=EC=9E=85?= =?UTF-8?q?=EC=A0=90=20=EC=9A=94=EC=B2=AD=ED=95=98=EA=B8=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80(DB=EC=97=90=EB=A7=8C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/apis/consumer/consumer-api.module.ts | 4 + .../store-entry-request.controller.ts | 71 +++++++++ .../migration.sql | 34 +++++ .../src/infra/database/prisma/schema.prisma | 44 ++++++ .../store-entry-request.constants.ts | 32 +++++ .../dto/store-entry-request.dto.ts | 136 ++++++++++++++++++ .../store-entry-request-read.service.ts | 28 ++++ .../store-entry-request-write.service.ts | 50 +++++++ .../store-entry-request.module.ts | 20 +++ .../store-entry-request.service.ts | 36 +++++ apps/web-user/src/app/map/page.tsx | 38 ++++- .../src/features/store/apis/store.api.ts | 19 +++ .../components/map/MapUnenteredStoreCard.tsx | 127 ++++++++++++++++ .../constants/storeQueryKeys.constant.ts | 2 + .../hooks/mutations/useRequestStoreEntry.ts | 28 ++++ .../queries/useStoreEntryRequestExists.ts | 15 ++ .../store/types/store-entry-request.type.ts | 17 +++ 17 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/apis/consumer/controllers/store-entry-request.controller.ts create mode 100644 apps/backend/src/infra/database/prisma/migrations/20260623002342_add_store_entry_request/migration.sql create mode 100644 apps/backend/src/modules/store-entry-request/constants/store-entry-request.constants.ts create mode 100644 apps/backend/src/modules/store-entry-request/dto/store-entry-request.dto.ts create mode 100644 apps/backend/src/modules/store-entry-request/services/store-entry-request-read.service.ts create mode 100644 apps/backend/src/modules/store-entry-request/services/store-entry-request-write.service.ts create mode 100644 apps/backend/src/modules/store-entry-request/store-entry-request.module.ts create mode 100644 apps/backend/src/modules/store-entry-request/store-entry-request.service.ts create mode 100644 apps/web-user/src/features/store/components/map/MapUnenteredStoreCard.tsx create mode 100644 apps/web-user/src/features/store/hooks/mutations/useRequestStoreEntry.ts create mode 100644 apps/web-user/src/features/store/hooks/queries/useStoreEntryRequestExists.ts create mode 100644 apps/web-user/src/features/store/types/store-entry-request.type.ts diff --git a/apps/backend/src/apis/consumer/consumer-api.module.ts b/apps/backend/src/apis/consumer/consumer-api.module.ts index 3edc57a6..58a33117 100644 --- a/apps/backend/src/apis/consumer/consumer-api.module.ts +++ b/apps/backend/src/apis/consumer/consumer-api.module.ts @@ -29,6 +29,8 @@ import { NoticeModule } from "@apps/backend/modules/notice/notice.module"; import { ConsumerNoticeController } from "@apps/backend/apis/consumer/controllers/notice.controller"; import { QnaModule } from "@apps/backend/modules/qna/qna.module"; import { ConsumerQnaController } from "@apps/backend/apis/consumer/controllers/qna.controller"; +import { StoreEntryRequestModule } from "@apps/backend/modules/store-entry-request/store-entry-request.module"; +import { ConsumerStoreEntryRequestController } from "@apps/backend/apis/consumer/controllers/store-entry-request.controller"; /** * Consumer(구매자) API 모듈 — 경로 prefix `consumer`, JWT aud `consumer` @@ -51,6 +53,7 @@ import { ConsumerQnaController } from "@apps/backend/apis/consumer/controllers/q TermsModule, NoticeModule, QnaModule, + StoreEntryRequestModule, // ChatModule, ], controllers: [ @@ -69,6 +72,7 @@ import { ConsumerQnaController } from "@apps/backend/apis/consumer/controllers/q ConsumerTermsController, ConsumerNoticeController, ConsumerQnaController, + ConsumerStoreEntryRequestController, // ConsumerChatController, ], }) diff --git a/apps/backend/src/apis/consumer/controllers/store-entry-request.controller.ts b/apps/backend/src/apis/consumer/controllers/store-entry-request.controller.ts new file mode 100644 index 00000000..a69cff6f --- /dev/null +++ b/apps/backend/src/apis/consumer/controllers/store-entry-request.controller.ts @@ -0,0 +1,71 @@ +import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query, Request } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiExtraModels } from "@nestjs/swagger"; +import { StoreEntryRequestService } from "@apps/backend/modules/store-entry-request/store-entry-request.service"; +import { Auth } from "@apps/backend/modules/auth/decorators/auth.decorator"; +import { SwaggerResponse } from "@apps/backend/common/decorators/swagger-response.decorator"; +import { SwaggerAuthResponses } from "@apps/backend/common/decorators/swagger-auth-responses.decorator"; +import { createMessageObject } from "@apps/backend/common/utils/message.util"; +import { JwtVerifiedPayload } from "@apps/backend/modules/auth/types/auth.types"; +import { AUDIENCE } from "@apps/backend/modules/auth/constants/auth.constants"; +import { + STORE_ENTRY_REQUEST_ERROR_MESSAGES, + STORE_ENTRY_REQUEST_SUCCESS_MESSAGES, +} from "@apps/backend/modules/store-entry-request/constants/store-entry-request.constants"; +import { + CreateStoreEntryRequestDto, + CreateStoreEntryRequestResponseDto, + StoreEntryRequestExistsQueryDto, + StoreEntryRequestExistsResponseDto, +} from "@apps/backend/modules/store-entry-request/dto/store-entry-request.dto"; + +/** + * 입점 요청 컨트롤러 (사용자용) + * 지도에서 미입점 스토어(카카오 장소)에 대한 입점 요청을 처리합니다. + */ +@ApiTags("입점 요청") +@ApiExtraModels(CreateStoreEntryRequestResponseDto, StoreEntryRequestExistsResponseDto) +@Controller(`${AUDIENCE.CONSUMER}/store-entry-requests`) +@Auth({ isPublic: false, audiences: ["consumer"] }) +export class ConsumerStoreEntryRequestController { + constructor(private readonly storeEntryRequestService: StoreEntryRequestService) {} + + /** + * 입점 요청 추가 API + */ + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: "(로그인 필요) 입점 요청 추가", + description: "미입점 스토어(카카오 장소)에 대한 입점 요청을 등록합니다. 동일 장소 중복 요청은 불가합니다.", + }) + @SwaggerResponse(201, { dataDto: CreateStoreEntryRequestResponseDto }) + @SwaggerAuthResponses() + @SwaggerResponse(409, { + dataExample: createMessageObject(STORE_ENTRY_REQUEST_ERROR_MESSAGES.ALREADY_EXISTS), + }) + async create( + @Body() dto: CreateStoreEntryRequestDto, + @Request() req: { user: JwtVerifiedPayload }, + ) { + await this.storeEntryRequestService.createForUser(req.user.sub, dto); + return { message: STORE_ENTRY_REQUEST_SUCCESS_MESSAGES.CREATED }; + } + + /** + * 입점 요청 존재 여부 조회 API (버튼 상태 표시용) + */ + @Get("exists") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "(로그인 필요) 입점 요청 존재 여부 조회", + description: "현재 로그인 사용자가 해당 카카오 장소에 이미 입점 요청했는지 여부를 반환합니다.", + }) + @SwaggerResponse(200, { dataDto: StoreEntryRequestExistsResponseDto }) + @SwaggerAuthResponses() + async exists( + @Query() query: StoreEntryRequestExistsQueryDto, + @Request() req: { user: JwtVerifiedPayload }, + ): Promise { + return await this.storeEntryRequestService.existsForUser(req.user.sub, query.kakaoPlaceId); + } +} diff --git a/apps/backend/src/infra/database/prisma/migrations/20260623002342_add_store_entry_request/migration.sql b/apps/backend/src/infra/database/prisma/migrations/20260623002342_add_store_entry_request/migration.sql new file mode 100644 index 00000000..a2dfcd3f --- /dev/null +++ b/apps/backend/src/infra/database/prisma/migrations/20260623002342_add_store_entry_request/migration.sql @@ -0,0 +1,34 @@ +-- CreateEnum +CREATE TYPE "StoreEntryRequestStatus" AS ENUM ('REQUESTED', 'REVIEWING', 'APPROVED', 'REJECTED', 'COMPLETED'); + +-- CreateTable +CREATE TABLE "store_entry_requests" ( + "id" TEXT NOT NULL, + "consumer_id" TEXT NOT NULL, + "kakao_place_id" TEXT NOT NULL, + "place_name" TEXT NOT NULL, + "address" TEXT, + "road_address" TEXT, + "phone" TEXT, + "category_name" TEXT, + "place_url" TEXT, + "latitude" DOUBLE PRECISION, + "longitude" DOUBLE PRECISION, + "status" "StoreEntryRequestStatus" NOT NULL DEFAULT 'REQUESTED', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "store_entry_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "store_entry_requests_kakao_place_id_idx" ON "store_entry_requests"("kakao_place_id"); + +-- CreateIndex +CREATE INDEX "store_entry_requests_status_idx" ON "store_entry_requests"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "store_entry_requests_consumer_id_kakao_place_id_key" ON "store_entry_requests"("consumer_id", "kakao_place_id"); + +-- AddForeignKey +ALTER TABLE "store_entry_requests" ADD CONSTRAINT "store_entry_requests_consumer_id_fkey" FOREIGN KEY ("consumer_id") REFERENCES "consumers"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/src/infra/database/prisma/schema.prisma b/apps/backend/src/infra/database/prisma/schema.prisma index e2bd124a..032a9441 100644 --- a/apps/backend/src/infra/database/prisma/schema.prisma +++ b/apps/backend/src/infra/database/prisma/schema.prisma @@ -130,6 +130,7 @@ model Consumer { fcmTokens ConsumerFcmToken[] notificationPreferences ConsumerNotificationPreference[] termsAgreements ConsumerTermsAgreement[] + storeEntryRequests StoreEntryRequest[] @@index([phone]) @@index([googleId]) @@ -137,6 +138,40 @@ model Consumer { @@map("consumers") } +/// 미입점 스토어(카카오 장소) 입점 요청 — 구매자가 지도에서 "이 가게도 입점했으면" 하고 요청 +/// 동일 장소를 여러 구매자가 요청할 수 있으나, 한 구매자는 같은 장소를 한 번만 요청 가능 +model StoreEntryRequest { + id String @id @default(cuid()) + + consumerId String @map("consumer_id") + + /// 카카오 로컬 장소 ID — 중복 요청 방지·동일 장소 수요 집계 기준 + kakaoPlaceId String @map("kakao_place_id") + + /// 요청 시점의 장소 스냅샷 (추후 어드민 검토·연락·실제 스토어 매칭용) + placeName String @map("place_name") + address String? + roadAddress String? @map("road_address") + phone String? + categoryName String? @map("category_name") + placeUrl String? @map("place_url") + latitude Float? + longitude Float? + + /// 처리 상태 — 어드민 워크플로우 확장 대비 + status StoreEntryRequestStatus @default(REQUESTED) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + consumer Consumer @relation(fields: [consumerId], references: [id], onDelete: Cascade) + + @@unique([consumerId, kakaoPlaceId]) + @@index([kakaoPlaceId]) + @@index([status]) + @@map("store_entry_requests") +} + /// 판매자 앱(web-seller) 전용 계정 model Seller { id String @id @default(cuid()) @@ -826,6 +861,15 @@ enum ProductCategoryType { PHOTO // 사진 } +/// 미입점 스토어 입점 요청 처리 상태 +enum StoreEntryRequestStatus { + REQUESTED // 요청됨 + REVIEWING // 검토중 + APPROVED // 승인됨 + REJECTED // 반려됨 + COMPLETED // 입점 완료 +} + /// 판매자 검증·온보딩 enum SellerVerificationStatus { REGISTERED // 가입만 완료 diff --git a/apps/backend/src/modules/store-entry-request/constants/store-entry-request.constants.ts b/apps/backend/src/modules/store-entry-request/constants/store-entry-request.constants.ts new file mode 100644 index 00000000..c14752e6 --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/constants/store-entry-request.constants.ts @@ -0,0 +1,32 @@ +/** + * 입점 요청 관련 에러 메시지 + */ +export const STORE_ENTRY_REQUEST_ERROR_MESSAGES = { + ALREADY_EXISTS: "이미 입점 요청한 가게입니다.", +} as const; + +/** + * 입점 요청 관련 성공 메시지 + */ +export const STORE_ENTRY_REQUEST_SUCCESS_MESSAGES = { + CREATED: "입점 요청이 접수되었습니다.", +} as const; + +/** + * 입점 요청 조회 시 공통으로 노출하는 필드 + */ +export const STORE_ENTRY_REQUEST_SELECT = { + id: true, + kakaoPlaceId: true, + placeName: true, + address: true, + roadAddress: true, + phone: true, + categoryName: true, + placeUrl: true, + latitude: true, + longitude: true, + status: true, + createdAt: true, + updatedAt: true, +} as const; diff --git a/apps/backend/src/modules/store-entry-request/dto/store-entry-request.dto.ts b/apps/backend/src/modules/store-entry-request/dto/store-entry-request.dto.ts new file mode 100644 index 00000000..f3fc7b30 --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/dto/store-entry-request.dto.ts @@ -0,0 +1,136 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsLatitude, IsLongitude, IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; +import { StoreEntryRequestStatus } from "@apps/backend/infra/database/prisma/generated/client"; +import { STORE_ENTRY_REQUEST_SUCCESS_MESSAGES } from "@apps/backend/modules/store-entry-request/constants/store-entry-request.constants"; + +/** + * 입점 요청 생성 요청 DTO + * 카카오 로컬(키워드 검색) 장소 정보를 스냅샷으로 저장한다. + */ +export class CreateStoreEntryRequestDto { + @ApiProperty({ description: "카카오 로컬 장소 ID", example: "26338954" }) + @IsString() + @IsNotEmpty() + @MaxLength(64) + kakaoPlaceId: string; + + @ApiProperty({ description: "장소명", example: "산세바스티안 미입점" }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + placeName: string; + + @ApiPropertyOptional({ description: "지번 주소", example: "서울 강남구 역삼동 123-45" }) + @IsOptional() + @IsString() + @MaxLength(500) + address?: string; + + @ApiPropertyOptional({ description: "도로명 주소", example: "서울 강남구 테헤란로 123" }) + @IsOptional() + @IsString() + @MaxLength(500) + roadAddress?: string; + + @ApiPropertyOptional({ description: "전화번호", example: "02-123-4567" }) + @IsOptional() + @IsString() + @MaxLength(50) + phone?: string; + + @ApiPropertyOptional({ description: "카카오 카테고리명", example: "음식점 > 카페 > 제과,베이커리" }) + @IsOptional() + @IsString() + @MaxLength(255) + categoryName?: string; + + @ApiPropertyOptional({ description: "카카오 장소 상세 URL", example: "http://place.map.kakao.com/26338954" }) + @IsOptional() + @IsString() + @MaxLength(2048) + placeUrl?: string; + + @ApiPropertyOptional({ description: "위도", example: 37.5012 }) + @IsOptional() + @IsLatitude() + latitude?: number; + + @ApiPropertyOptional({ description: "경도", example: 127.0396 }) + @IsOptional() + @IsLongitude() + longitude?: number; +} + +/** + * 입점 요청 존재 여부 조회 쿼리 DTO + */ +export class StoreEntryRequestExistsQueryDto { + @ApiProperty({ description: "카카오 로컬 장소 ID", example: "26338954" }) + @IsString() + @IsNotEmpty() + @MaxLength(64) + kakaoPlaceId: string; +} + +/** + * 입점 요청 생성 응답 DTO + */ +export class CreateStoreEntryRequestResponseDto { + @ApiProperty({ + description: "응답 메시지", + example: STORE_ENTRY_REQUEST_SUCCESS_MESSAGES.CREATED, + }) + message: string; +} + +/** + * 입점 요청 존재 여부 응답 DTO (현재 로그인 사용자 기준) + */ +export class StoreEntryRequestExistsResponseDto { + @ApiProperty({ description: "현재 사용자의 입점 요청 존재 여부", example: true }) + requested: boolean; +} + +/** + * 입점 요청 단건 응답 DTO + */ +export class StoreEntryRequestResponseDto { + @ApiProperty({ example: "clxxxxxxxxxxxxxxxx" }) + id: string; + + @ApiProperty({ example: "26338954" }) + kakaoPlaceId: string; + + @ApiProperty({ example: "산세바스티안 미입점" }) + placeName: string; + + @ApiPropertyOptional({ example: "서울 강남구 역삼동 123-45" }) + address: string | null; + + @ApiPropertyOptional({ example: "서울 강남구 테헤란로 123" }) + roadAddress: string | null; + + @ApiPropertyOptional({ example: "02-123-4567" }) + phone: string | null; + + @ApiPropertyOptional({ example: "음식점 > 카페 > 제과,베이커리" }) + categoryName: string | null; + + @ApiPropertyOptional({ example: "http://place.map.kakao.com/26338954" }) + placeUrl: string | null; + + @ApiPropertyOptional({ example: 37.5012 }) + latitude: number | null; + + @ApiPropertyOptional({ example: 127.0396 }) + longitude: number | null; + + @ApiProperty({ enum: StoreEntryRequestStatus, example: StoreEntryRequestStatus.REQUESTED }) + status: StoreEntryRequestStatus; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; +} diff --git a/apps/backend/src/modules/store-entry-request/services/store-entry-request-read.service.ts b/apps/backend/src/modules/store-entry-request/services/store-entry-request-read.service.ts new file mode 100644 index 00000000..bc1d6ef8 --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/services/store-entry-request-read.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "@apps/backend/infra/database/prisma.service"; +import { StoreEntryRequestExistsResponseDto } from "@apps/backend/modules/store-entry-request/dto/store-entry-request.dto"; + +/** + * 입점 요청 조회 서비스 + */ +@Injectable() +export class StoreEntryRequestReadService { + constructor(private readonly prisma: PrismaService) {} + + /** + * 현재 사용자가 해당 장소에 이미 입점 요청했는지 여부 (버튼 상태 표시용) + * @param consumerId 구매자 ID + * @param kakaoPlaceId 카카오 로컬 장소 ID + */ + async existsForUser( + consumerId: string, + kakaoPlaceId: string, + ): Promise { + const existing = await this.prisma.storeEntryRequest.findUnique({ + where: { consumerId_kakaoPlaceId: { consumerId, kakaoPlaceId } }, + select: { id: true }, + }); + + return { requested: existing !== null }; + } +} diff --git a/apps/backend/src/modules/store-entry-request/services/store-entry-request-write.service.ts b/apps/backend/src/modules/store-entry-request/services/store-entry-request-write.service.ts new file mode 100644 index 00000000..2f04d674 --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/services/store-entry-request-write.service.ts @@ -0,0 +1,50 @@ +import { Injectable, ConflictException } from "@nestjs/common"; +import { PrismaService } from "@apps/backend/infra/database/prisma.service"; +import { LoggerUtil } from "@apps/backend/common/utils/logger.util"; +import { CreateStoreEntryRequestDto } from "@apps/backend/modules/store-entry-request/dto/store-entry-request.dto"; +import { STORE_ENTRY_REQUEST_ERROR_MESSAGES } from "@apps/backend/modules/store-entry-request/constants/store-entry-request.constants"; + +/** + * 입점 요청 생성 서비스 + * 미입점(카카오 장소) 스토어에 대한 입점 요청을 저장합니다. + */ +@Injectable() +export class StoreEntryRequestWriteService { + constructor(private readonly prisma: PrismaService) {} + + /** + * 입점 요청 추가 (사용자용) + * 동일 사용자가 같은 장소를 중복 요청하는 것은 DB unique 제약으로 방지합니다. + * @param consumerId 구매자 ID + * @param dto 카카오 장소 정보 + */ + async createForUser(consumerId: string, dto: CreateStoreEntryRequestDto) { + try { + await this.prisma.storeEntryRequest.create({ + data: { + consumerId, + kakaoPlaceId: dto.kakaoPlaceId, + placeName: dto.placeName, + address: dto.address, + roadAddress: dto.roadAddress, + phone: dto.phone, + categoryName: dto.categoryName, + placeUrl: dto.placeUrl, + latitude: dto.latitude, + longitude: dto.longitude, + }, + }); + } catch (error: any) { + if (error?.code === "P2002") { + LoggerUtil.log( + `입점 요청 실패: 이미 요청 존재 - consumerId: ${consumerId}, kakaoPlaceId: ${dto.kakaoPlaceId}`, + ); + throw new ConflictException(STORE_ENTRY_REQUEST_ERROR_MESSAGES.ALREADY_EXISTS); + } + LoggerUtil.log( + `입점 요청 실패: 알 수 없는 에러 - consumerId: ${consumerId}, kakaoPlaceId: ${dto.kakaoPlaceId}, error: ${error?.message || String(error)}`, + ); + throw error; + } + } +} diff --git a/apps/backend/src/modules/store-entry-request/store-entry-request.module.ts b/apps/backend/src/modules/store-entry-request/store-entry-request.module.ts new file mode 100644 index 00000000..3fc3976f --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/store-entry-request.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { DatabaseModule } from "@apps/backend/infra/database/database.module"; +import { StoreEntryRequestService } from "@apps/backend/modules/store-entry-request/store-entry-request.service"; +import { StoreEntryRequestReadService } from "@apps/backend/modules/store-entry-request/services/store-entry-request-read.service"; +import { StoreEntryRequestWriteService } from "@apps/backend/modules/store-entry-request/services/store-entry-request-write.service"; + +/** + * 입점 요청 모듈 + * 미입점(카카오 장소) 스토어 입점 요청 기능을 제공합니다. + */ +@Module({ + imports: [DatabaseModule], + providers: [ + StoreEntryRequestService, + StoreEntryRequestReadService, + StoreEntryRequestWriteService, + ], + exports: [StoreEntryRequestService], +}) +export class StoreEntryRequestModule {} diff --git a/apps/backend/src/modules/store-entry-request/store-entry-request.service.ts b/apps/backend/src/modules/store-entry-request/store-entry-request.service.ts new file mode 100644 index 00000000..6e92de95 --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/store-entry-request.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from "@nestjs/common"; +import { StoreEntryRequestReadService } from "@apps/backend/modules/store-entry-request/services/store-entry-request-read.service"; +import { StoreEntryRequestWriteService } from "@apps/backend/modules/store-entry-request/services/store-entry-request-write.service"; +import { + CreateStoreEntryRequestDto, + StoreEntryRequestExistsResponseDto, +} from "@apps/backend/modules/store-entry-request/dto/store-entry-request.dto"; + +/** + * 입점 요청 서비스 + * 미입점(카카오 장소) 스토어 입점 요청 관련 기능을 통합 제공합니다. + */ +@Injectable() +export class StoreEntryRequestService { + constructor( + private readonly readService: StoreEntryRequestReadService, + private readonly writeService: StoreEntryRequestWriteService, + ) {} + + /** + * 입점 요청 추가 (사용자용) + */ + createForUser(consumerId: string, dto: CreateStoreEntryRequestDto) { + return this.writeService.createForUser(consumerId, dto); + } + + /** + * 현재 사용자의 입점 요청 존재 여부 (사용자용) + */ + existsForUser( + consumerId: string, + kakaoPlaceId: string, + ): Promise { + return this.readService.existsForUser(consumerId, kakaoPlaceId); + } +} diff --git a/apps/web-user/src/app/map/page.tsx b/apps/web-user/src/app/map/page.tsx index 34736cdc..155a0868 100644 --- a/apps/web-user/src/app/map/page.tsx +++ b/apps/web-user/src/app/map/page.tsx @@ -17,6 +17,10 @@ import { Icon } from "@/apps/web-user/common/components/icons"; import { storeApi } from "@/apps/web-user/features/store/apis/store.api"; import type { StoreInfo, StoreListFilter } from "@/apps/web-user/features/store/types/store.type"; import { MapStoreCard } from "@/apps/web-user/features/store/components/map/MapStoreCard"; +import { + MapUnenteredStoreCard, + type MapUnenteredStore, +} from "@/apps/web-user/features/store/components/map/MapUnenteredStoreCard"; import { MapStoreListSection } from "@/apps/web-user/features/store/components/map/MapStoreListSection"; import { MapTopSearchBar } from "@/apps/web-user/features/store/components/map/MapTopSearchBar"; import { MapPickupDateBottomSheet } from "@/apps/web-user/features/store/components/map/MapPickupDateBottomSheet"; @@ -68,6 +72,9 @@ export default function MapPage() { const [kakaoLoaded, setKakaoLoaded] = useState(false); const [mapReady, setMapReady] = useState(false); const [selectedStore, setSelectedStore] = useState(null); + const [selectedUnenteredStore, setSelectedUnenteredStore] = useState( + null, + ); const [listSortBy, setListSortBy] = useState("distance"); const [listFilter, setListFilter] = useState({}); const [pickupFilter, setPickupFilter] = useState(null); @@ -273,6 +280,7 @@ export default function MapPage() { map.panTo(position); } if (listSheetPanelOffsetRef.current > 0) closeListSheet(); + setSelectedUnenteredStore(null); setSelectedStore(store); }); @@ -391,6 +399,18 @@ export default function MapPage() { markersRef.current.push(marker); window.kakao.maps.event.addListener(marker, "click", () => { setSelectedStore(null); + setSelectedUnenteredStore({ + kakaoPlaceId: String(place.id ?? ""), + name: place.place_name ?? "", + address: place.address_name || undefined, + roadAddress: place.road_address_name || undefined, + phone: place.phone || undefined, + categoryName: place.category_name || undefined, + placeUrl: place.place_url || undefined, + latitude: lat, + longitude: lng, + }); + if (listSheetPanelOffsetRef.current > 0) closeListSheet(); if (markerImageRef.current) markersRef.current.forEach((m) => m.setImage(markerImageRef.current)); platformMarkersRef.current.forEach((m, i) => { @@ -422,7 +442,7 @@ export default function MapPage() { { location: centerLatLng, useMapBounds: true }, ); }, - [clearKakaoMarkers, isPlatformStoreDuplicate], + [clearKakaoMarkers, isPlatformStoreDuplicate, closeListSheet], ); /** URL 검색 또는 픽업 필터 시 미입점 마커 제거, 해제 시에만 키워드 검색 재실행 */ @@ -432,6 +452,7 @@ export default function MapPage() { const suppress = Boolean(searchQuery || pickupFilter); if (suppress) { clearKakaoMarkers(); + setSelectedUnenteredStore(null); } else if (searchStoresRef.current === null) { searchPlaces(mapInstanceRef.current.getCenter()); } @@ -503,6 +524,7 @@ export default function MapPage() { window.kakao.maps.event.addListener(map, "click", () => { setSelectedStore(null); + setSelectedUnenteredStore(null); if (listSheetPanelOffsetRef.current > 0) closeListSheet(); }); @@ -862,7 +884,19 @@ export default function MapPage() { )} - {!selectedStore && ( + {!selectedStore && selectedUnenteredStore && ( +
+ +
+ )} + + {!selectedStore && !selectedUnenteredStore && ( => { + const response = await consumerClient.post("/store-entry-requests", data); + return response.data.data; + }, + // 미입점 스토어 입점 요청 존재 여부 조회 + getEntryRequestExists: async ( + kakaoPlaceId: string, + ): Promise => { + const response = await consumerClient.get("/store-entry-requests/exists", { + params: { kakaoPlaceId }, + }); + return response.data.data; + }, }; diff --git a/apps/web-user/src/features/store/components/map/MapUnenteredStoreCard.tsx b/apps/web-user/src/features/store/components/map/MapUnenteredStoreCard.tsx new file mode 100644 index 00000000..39dc71f4 --- /dev/null +++ b/apps/web-user/src/features/store/components/map/MapUnenteredStoreCard.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useState } from "react"; +import { Toast } from "@/apps/web-user/common/components/toast/Toast"; +import { useAuthStore } from "@/apps/web-user/common/store/auth.store"; +import { useLoginSheetStore } from "@/apps/web-user/common/store/login-sheet.store"; +import { useRequestStoreEntry } from "@/apps/web-user/features/store/hooks/mutations/useRequestStoreEntry"; +import { useStoreEntryRequestExists } from "@/apps/web-user/features/store/hooks/queries/useStoreEntryRequestExists"; + +/** 지도에서 클릭한 미입점 스토어(카카오 장소) */ +export interface MapUnenteredStore { + kakaoPlaceId: string; + name: string; + address?: string; + roadAddress?: string; + phone?: string; + categoryName?: string; + placeUrl?: string; + latitude: number; + longitude: number; +} + +interface MapUnenteredStoreCardProps { + store: MapUnenteredStore; +} + +/** 미입점 스토어 마커 클릭 시 하단에 표시되는 입점 요청 카드 */ +export function MapUnenteredStoreCard({ store }: MapUnenteredStoreCardProps) { + const { isAuthenticated } = useAuthStore(); + const openLoginSheet = useLoginSheetStore((s) => s.openLoginSheet); + const { mutate: requestEntry, isPending } = useRequestStoreEntry(); + const { data: existsData } = useStoreEntryRequestExists(store.kakaoPlaceId, isAuthenticated); + + const [showToast, setShowToast] = useState(false); + const [requestedLocally, setRequestedLocally] = useState(false); + + const alreadyRequested = requestedLocally || (existsData?.requested ?? false); + const disabled = isPending || alreadyRequested; + + const handleRequestEntry = () => { + if (!isAuthenticated) { + openLoginSheet(); + return; + } + if (disabled) return; + requestEntry( + { + kakaoPlaceId: store.kakaoPlaceId, + placeName: store.name, + address: store.address, + roadAddress: store.roadAddress, + phone: store.phone, + categoryName: store.categoryName, + placeUrl: store.placeUrl, + latitude: store.latitude, + longitude: store.longitude, + }, + { + onSuccess: () => { + setRequestedLocally(true); + setShowToast(true); + }, + }, + ); + }; + + const buttonLabel = alreadyRequested ? "입점 요청 완료" : "입점 요청하기"; + + return ( + <> +
+
+

+ {store.name} +

+ + 미입점 + +
+ + +
+ + {showToast && ( + setShowToast(false)} + /> + )} + + ); +} diff --git a/apps/web-user/src/features/store/constants/storeQueryKeys.constant.ts b/apps/web-user/src/features/store/constants/storeQueryKeys.constant.ts index 27c93c3e..aedeabf9 100644 --- a/apps/web-user/src/features/store/constants/storeQueryKeys.constant.ts +++ b/apps/web-user/src/features/store/constants/storeQueryKeys.constant.ts @@ -5,4 +5,6 @@ export const storeQueryKeys = { detail: (storeId: string) => ["store", "detail", storeId] as const, list: (params: { search?: string; sortBy?: string } & StoreListFilter) => ["store", "list", params] as const, + entryRequestExists: (kakaoPlaceId: string) => + ["store", "entry-request", "exists", kakaoPlaceId] as const, } as const; diff --git a/apps/web-user/src/features/store/hooks/mutations/useRequestStoreEntry.ts b/apps/web-user/src/features/store/hooks/mutations/useRequestStoreEntry.ts new file mode 100644 index 00000000..bd58a97f --- /dev/null +++ b/apps/web-user/src/features/store/hooks/mutations/useRequestStoreEntry.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { storeApi } from "@/apps/web-user/features/store/apis/store.api"; +import { storeQueryKeys } from "@/apps/web-user/features/store/constants/storeQueryKeys.constant"; +import { useAlertStore } from "@/apps/web-user/common/store/alert.store"; +import getApiMessage from "@/apps/web-user/common/utils/getApiMessage"; +import type { CreateStoreEntryRequest } from "@/apps/web-user/features/store/types/store-entry-request.type"; + +/** 미입점 스토어 입점 요청 */ +export function useRequestStoreEntry() { + const queryClient = useQueryClient(); + const { showAlert } = useAlertStore(); + + return useMutation({ + mutationFn: (data: CreateStoreEntryRequest) => storeApi.createEntryRequest(data), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: storeQueryKeys.entryRequestExists(variables.kakaoPlaceId), + }); + }, + onError: (error) => { + showAlert({ + type: "error", + title: "오류", + message: getApiMessage.error(error), + }); + }, + }); +} diff --git a/apps/web-user/src/features/store/hooks/queries/useStoreEntryRequestExists.ts b/apps/web-user/src/features/store/hooks/queries/useStoreEntryRequestExists.ts new file mode 100644 index 00000000..2472e085 --- /dev/null +++ b/apps/web-user/src/features/store/hooks/queries/useStoreEntryRequestExists.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { storeApi } from "@/apps/web-user/features/store/apis/store.api"; +import { storeQueryKeys } from "@/apps/web-user/features/store/constants/storeQueryKeys.constant"; + +/** + * 현재 사용자의 특정 카카오 장소 입점 요청 존재 여부 조회. + * 로그인 상태이고 kakaoPlaceId가 있을 때만 조회한다. + */ +export function useStoreEntryRequestExists(kakaoPlaceId: string | null, enabled: boolean) { + return useQuery({ + queryKey: storeQueryKeys.entryRequestExists(kakaoPlaceId ?? ""), + queryFn: () => storeApi.getEntryRequestExists(kakaoPlaceId as string), + enabled: enabled && !!kakaoPlaceId, + }); +} diff --git a/apps/web-user/src/features/store/types/store-entry-request.type.ts b/apps/web-user/src/features/store/types/store-entry-request.type.ts new file mode 100644 index 00000000..4cddf475 --- /dev/null +++ b/apps/web-user/src/features/store/types/store-entry-request.type.ts @@ -0,0 +1,17 @@ +/** 미입점 스토어(카카오 장소) 입점 요청 생성 요청 (백엔드 CreateStoreEntryRequestDto와 동일) */ +export interface CreateStoreEntryRequest { + kakaoPlaceId: string; + placeName: string; + address?: string; + roadAddress?: string; + phone?: string; + categoryName?: string; + placeUrl?: string; + latitude?: number; + longitude?: number; +} + +/** 입점 요청 존재 여부 응답 (백엔드 StoreEntryRequestExistsResponseDto와 동일) */ +export interface StoreEntryRequestExistsResponse { + requested: boolean; +} From ffe90e206adf2b425bec17fc220c1dd6a1bb2498 Mon Sep 17 00:00:00 2001 From: jangchanwoo Date: Tue, 23 Jun 2026 09:52:35 +0900 Subject: [PATCH 41/41] =?UTF-8?q?[WEB-USER][QA]:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=92=A4?= =?UTF-8?q?=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EB=8F=99=EC=9E=91=EC=9D=84=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에는 항상 내 예약 목록(/mypage/order)으로 이동했으나, router.back()으로 변경하여 진입했던 이전 페이지로 돌아가도록 했습니다. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web-user/src/app/order/[orderId]/page.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/web-user/src/app/order/[orderId]/page.tsx b/apps/web-user/src/app/order/[orderId]/page.tsx index cbf886f7..b96c2580 100644 --- a/apps/web-user/src/app/order/[orderId]/page.tsx +++ b/apps/web-user/src/app/order/[orderId]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import Header from "@/apps/web-user/common/components/headers/Header"; import { Toast } from "@/apps/web-user/common/components/toast/Toast"; import { useOrderDetail } from "@/apps/web-user/features/order/hooks/queries/useOrderDetail"; @@ -10,10 +10,8 @@ import { PendingToast, usePendingToastStore, } from "@/apps/web-user/common/store/pending-toast.store"; -import { PATHS } from "@/apps/web-user/common/constants/paths.constant"; export default function OrderDetailPage() { - const router = useRouter(); const params = useParams<{ orderId: string }>(); const orderId = params?.orderId ?? ""; const { data: order, isLoading } = useOrderDetail(orderId); @@ -28,11 +26,7 @@ export default function OrderDetailPage() { return (
-
router.push(PATHS.MY_ORDERS)} - /> +
{isLoading ? (