From 041dd613d16adca81857dbc06b0b43fd61410547 Mon Sep 17 00:00:00 2001 From: geunu Date: Mon, 25 May 2026 21:26:05 +0900 Subject: [PATCH 1/8] [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 2/8] [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 3/8] [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 4/8] [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 5/8] [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 6/8] [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 7/8] [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 8/8] [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