diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 5435f546..389faf0b 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -16,10 +16,10 @@ jobs: - name: Enable Corepack run: corepack enable - - name: Use Node.js 20.9.0 + - name: Use Node.js 22 uses: actions/setup-node@v4 with: - node-version: "20.9.0" + node-version: "22" cache: "yarn" cache-dependency-path: "yarn.lock" @@ -32,11 +32,6 @@ jobs: key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-nextjs- - - name: Install system dependencies for Sharp - run: | - sudo apt-get update - sudo apt-get install -y libvips-dev - - name: Install dependencies run: yarn install --immutable @@ -50,14 +45,16 @@ jobs: run: yarn build env: AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + INTERNAL_API_SECRET: ${{ secrets.INTERNAL_API_SECRET }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} - KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }} + NEXT_PUBLIC_KAKAO_APP_KEY: ${{ secrets.NEXT_PUBLIC_KAKAO_APP_KEY }} KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }} + KAKAO_REST_KEY: ${{ secrets.KAKAO_REST_KEY }} NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }} - DATABASE_URL: ${{secrets.DATABASE_URL}} - DIRECT_URL: ${{secrets.DIRECT_URL}} NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} - SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} diff --git a/README.md b/README.md index 3d0da58b..8e64feb9 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,95 @@ -# Perscent ๐ŸŒธ +# ChickChick ๐ŸŒธ -**Perscent**๋Š” ํ–ฅ์ˆ˜ ์• ํ˜ธ๊ฐ€๋“ค์„ ์œ„ํ•œ ์ข…ํ•ฉ ์ปค๋ฎค๋‹ˆํ‹ฐ ํ”Œ๋žซํผ์ž…๋‹ˆ๋‹ค. ํ–ฅ์ˆ˜ ๋ฆฌ๋ทฐ, ์ถ”์ฒœ, ์ปฌ๋ ‰์…˜ ๊ด€๋ฆฌ ๋ฐ ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +ํ–ฅ์ˆ˜ ์ •๋ณด ์กฐํšŒ, ๋ฆฌ๋ทฐ, ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋Š” ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜. +Next.js 15 App Router์™€ Hono๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌํ˜„ํ•œ ํ’€์Šคํƒ ํ”„๋กœ์ ํŠธ. -## โœจ ์ฃผ์š” ๊ธฐ๋Šฅ +--- -### ๐Ÿ” ํ–ฅ์ˆ˜ ํƒ์ƒ‰ ๋ฐ ๊ฒ€์ƒ‰ +## ๐Ÿ–ผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ + + + + + + +
๋ฏธ๋ฆฌ๋ณด๊ธฐ 1๋ฏธ๋ฆฌ๋ณด๊ธฐ 2
+ +--- + +## โœจ ๊ตฌํ˜„๋œ ๊ธฐ๋Šฅ + +### ์‚ฌ์šฉ์ž + +- Google, Kakao ์†Œ์…œ ๋กœ๊ทธ์ธ (NextAuth.js v5) +- ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ๋ฐ ํ™œ๋™ ๋‚ด์—ญ + +### ํ–ฅ์ˆ˜ - ๋ธŒ๋žœ๋“œ๋ณ„ ํ–ฅ์ˆ˜ ๋ชฉ๋ก ์กฐํšŒ - ํ–ฅ์ˆ˜ ์ƒ์„ธ ์ •๋ณด (๋…ธํŠธ, ์–ด์ฝ”๋“œ, ์ง€์†์„ฑ, ์‹ค๋ผ์ฃผ ๋“ฑ) -- ๊ณ ๊ธ‰ ํ•„ํ„ฐ๋ง ๋ฐ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ +- ํ–ฅ์ˆ˜ ๊ฒ€์ƒ‰ ๋ฐ ๋‹ค์ค‘ ํ•„ํ„ฐ๋ง -### ๐Ÿ“ ๋ฆฌ๋ทฐ ์‹œ์Šคํ…œ +### ๋ฆฌ๋ทฐ -- ํ–ฅ์ˆ˜ ์‚ฌ์šฉ ํ›„๊ธฐ ์ž‘์„ฑ -- 5์  ํ‰์  ์‹œ์Šคํ…œ -- ์ง€์†์„ฑ, ์‹ค๋ผ์ฃผ, ๊ณ„์ ˆ๊ฐ, ๊ฐ€๊ฒฉ ๋Œ€๋น„ ๋งŒ์กฑ๋„ ํ‰๊ฐ€ -- ์„ฑ๋ณ„ ํ”„๋กœํ•„ ๋ฐ ์‚ฌ์šฉ ์‹œ๊ฐ„๋Œ€ ์ •๋ณด +- 5์  ํ‰์  ๋ฐ ์„ธ๋ถ€ ํ‰๊ฐ€ (์ง€์†์„ฑ, ์‹ค๋ผ์ฃผ, ๊ณ„์ ˆ๊ฐ, ๊ฐ€๊ฒฉ ๋Œ€๋น„ ๋งŒ์กฑ๋„) +- ์„ฑ๋ณ„ ๋ฐ ์‚ฌ์šฉ ์‹œ๊ฐ„๋Œ€ ์ •๋ณด ํฌํ•จ +- Tiptap ๊ธฐ๋ฐ˜ ์—๋””ํ„ฐ -### ๐Ÿ‘ฅ ์ปค๋ฎค๋‹ˆํ‹ฐ +### ์ปค๋ฎค๋‹ˆํ‹ฐ -- ์ž์œ ๊ฒŒ์‹œํŒ, ์งˆ๋ฌธ, ์ถ”์ฒœ ๊ฒŒ์‹œํŒ -- ํ–ฅ์ˆ˜ ๊ด€๋ จ ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ ๋ฐ ๋Œ“๊ธ€ -- ๊ฒŒ์‹œ๊ธ€ ์ข‹์•„์š” ๋ฐ ๋ถ๋งˆํฌ ๊ธฐ๋Šฅ +- ๊ฒŒ์‹œํŒ (์ž์œ /์งˆ๋ฌธ/์ถ”์ฒœ) +- ๋Œ“๊ธ€ ๋ฐ ์ข‹์•„์š” +- ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ (Supabase Storage) -### ๐Ÿ“š ๊ฐœ์ธ ์ปฌ๋ ‰์…˜ +### ๊ฐœ์ธ ํ™œ๋™ -- ๋ณด์œ  ํ–ฅ์ˆ˜ ์ปฌ๋ ‰์…˜ ๊ด€๋ฆฌ -- ๋ถ๋งˆํฌ ๊ธฐ๋Šฅ -- ์‚ฌ์šฉ์ž ํ™œ๋™ ๊ธฐ๋ก +- ํ–ฅ์ˆ˜/๊ฒŒ์‹œ๊ธ€ ๋ถ๋งˆํฌ +- ๊ฐœ์ธ ํ–ฅ์ˆ˜ ์ปฌ๋ ‰์…˜ ๊ด€๋ฆฌ +- ์ž„์‹œ์ €์žฅ -### ๐Ÿ” ์†Œ์…œ ๋กœ๊ทธ์ธ - -- Google, Naver, Kakao ๋กœ๊ทธ์ธ ์ง€์› -- NextAuth.js ๊ธฐ๋ฐ˜ ์ธ์ฆ ์‹œ์Šคํ…œ +--- ## ๐Ÿ›  ๊ธฐ์ˆ  ์Šคํƒ -### Frontend +**Frontend** +Next.js 15 (App Router) ยท TypeScript ยท Tailwind CSS ยท Zustand ยท TanStack Query ยท React Hook Form ยท Zod ยท Tiptap + +**Backend** +Hono ยท Prisma ยท PostgreSQL ยท NextAuth.js v5 ยท Supabase Storage -- **Framework**: Next.js 15 (App Router) -- **Language**: TypeScript -- **Styling**: Tailwind CSS -- **UI Components**: Custom components with Tailwind -- **State Management**: Zustand -- **Forms**: React Hook Form + Zod validation -- **Rich Text Editor**: CKEditor 5 +**Infra** +Vercel ยท Yarn -### Backend +--- -- **API Framework**: Hono -- **Database**: PostgreSQL -- **ORM**: Prisma -- **Authentication**: NextAuth.js -- **File Storage**: Supabase +## ๐ŸŽฏ ์ฃผ์š” ๊ธฐ์ˆ ์  ์„ ํƒ -### Development Tools +### Hono on Next.js -- **Package Manager**: Yarn -- **Linting**: ESLint -- **Component Development**: Storybook -- **API Documentation**: OpenAPI/Swagger +Next.js API Routes ๋Œ€์‹  Hono๋ฅผ ๋„์ž…ํ–ˆ์Šต๋‹ˆ๋‹ค. App Router์˜ ํด๋” ๊ธฐ๋ฐ˜ ๋ผ์šฐํŒ… ๋ฐฉ์‹์€ +์—”๋“œํฌ์ธํŠธ๊ฐ€ ๋Š˜์–ด๋‚ ์ˆ˜๋ก ํŒŒ์ผ๊ณผ ํด๋”๊ฐ€ ๋ถ„์‚ฐ๋˜์–ด ์ „์ฒด API ๊ตฌ์กฐ๋ฅผ ํ•œ๋ˆˆ์— ํŒŒ์•…ํ•˜๊ธฐ ์–ด๋ ต๋‹ค๋Š” +ํ•œ๊ณ„๋ฅผ ๋А๊ผˆ์Šต๋‹ˆ๋‹ค. ์บ์น˜์˜ฌ ์„ธ๊ทธ๋จผํŠธ(`/api/[[...route]]`)๋ฅผ ํ™œ์šฉํ•ด Hono๋กœ ๋ชจ๋“  ์š”์ฒญ์„ +์ค‘์•™์—์„œ ๋ผ์šฐํŒ…ํ•˜๋„๋ก ๊ตฌ์„ฑํ•˜์—ฌ API ๊ตฌ์กฐ์˜ ์‘์ง‘๋„๋ฅผ ๋†’์˜€์Šต๋‹ˆ๋‹ค. -## ๐Ÿ“ฆ ์„ค์น˜ ๋ฐ ์‹คํ–‰ +### ์ƒํƒœ ๊ด€๋ฆฌ ๋ถ„๋ฆฌ -### ํ•„์ˆ˜ ์š”๊ตฌ์‚ฌํ•ญ +- **Zustand**: ์„ธ์…˜ ์ •๋ณด, UI ์ƒํƒœ(๋ชจ๋‹ฌ, ํ† ๊ธ€ ๋“ฑ) +- **TanStack Query**: ์„œ๋ฒ„ ์ƒํƒœ (API ์‘๋‹ต ์บ์‹ฑ ๋ฐ ์žฌ๊ฒ€์ฆ) -- Node.js 18+ +์„œ๋ฒ„ ์ƒํƒœ์™€ ํด๋ผ์ด์–ธํŠธ ์ƒํƒœ์˜ ์ฑ…์ž„์„ ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌํ•˜์—ฌ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +--- + +## ๐Ÿ“ฆ ๋กœ์ปฌ ์‹คํ–‰ + +### ์š”๊ตฌ์‚ฌํ•ญ + +- Node.js 22+ - PostgreSQL - Yarn +### ์„ค์น˜ + ### 1. ์ €์žฅ์†Œ ํด๋ก  ```bash @@ -87,13 +108,10 @@ yarn install `.env.local` ํŒŒ์ผ์„ ์ƒ์„ฑํ•˜๊ณ  ๋‹ค์Œ ๋ณ€์ˆ˜๋“ค์„ ์„ค์ •ํ•˜์„ธ์š”: ```env -# Database -DATABASE_URL="postgresql://..." -DIRECT_URL="postgresql://..." - -# NextAuth.js -NEXTAUTH_SECRET="your-secret" -NEXTAUTH_URL="http://localhost:3000" +# Supabase +NEXT_PUBLIC_SUPABASE_URL="your-supabase-url" +NEXT_PUBLIC_SUPABASE_ANON_KEY="your-supabase-anon-key" +SUPABASE_SERVICE_ROLE_KEY="your-supabase-service-role-key" # OAuth Providers GOOGLE_CLIENT_ID="your-google-client-id" @@ -102,129 +120,68 @@ NAVER_CLIENT_ID="your-naver-client-id" NAVER_CLIENT_SECRET="your-naver-client-secret" KAKAO_CLIENT_ID="your-kakao-client-id" KAKAO_CLIENT_SECRET="your-kakao-client-secret" - -# Supabase -NEXT_PUBLIC_SUPABASE_URL="your-supabase-url" -NEXT_PUBLIC_SUPABASE_ANON_KEY="your-supabase-anon-key" -``` - -### 4. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • - -```bash -# Prisma ์Šคํ‚ค๋งˆ ์ƒ์„ฑ -npx prisma generate - -# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ -npx prisma migrate deploy -``` - -### 5. ๊ฐœ๋ฐœ ์„œ๋ฒ„ ์‹คํ–‰ - -```bash -yarn dev +NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY="your-kakao-javascript-key" +KAKAO_REST_KEY="your-kakao-rest-key" + +# Auth (NextAuth.js v5) +AUTH_SECRET="your-auth-secret" +AUTH_TRUST_HOST=true +AUTH_URL="http://localhost:3000" + +# Internal +# NextAuth๊ฐ€ OAuth ๋กœ๊ทธ์ธ ์‹œ ์œ ์ € ๋™๊ธฐํ™”๋ฅผ ์œ„ํ•ด ๋‚ด๋ถ€ API(/api/v1/auth/sync)๋ฅผ ํ˜ธ์ถœํ•  ๋•Œ +# ์™ธ๋ถ€ ์š”์ฒญ๊ณผ ๊ตฌ๋ถ„ํ•˜๊ธฐ ์œ„ํ•ด x-internal-secret ํ—ค๋”์— ์‹ค์–ด ๋ณด๋‚ด๋Š” ์‹œํฌ๋ฆฟ ํ‚ค์ž…๋‹ˆ๋‹ค. +INTERNAL_API_SECRET="your-internal-api-secret" ``` -์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋˜๋ฉด [http://localhost:3000](http://localhost:3000)์—์„œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. - -## ๐Ÿ“ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์Šคํฌ๋ฆฝํŠธ - -```bash -# ๊ฐœ๋ฐœ ์„œ๋ฒ„ ์‹คํ–‰ -yarn dev - -# ํ”„๋กœ๋•์…˜ ๋นŒ๋“œ -yarn build - -# ํ”„๋กœ๋•์…˜ ์„œ๋ฒ„ ์‹คํ–‰ -yarn start +--- -# Storybook ์‹คํ–‰ -yarn storybook - -# Prisma Zod ์Šคํ‚ค๋งˆ ์ƒ์„ฑ -yarn zod -``` - -## ๐Ÿ— ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ +## ๐Ÿ—‚ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ``` src/ -โ”œโ”€โ”€ app/ # Next.js App Router -โ”‚ โ”œโ”€โ”€ (main)/ # ๋ฉ”์ธ ํŽ˜์ด์ง€ ๊ทธ๋ฃน -โ”‚ โ”œโ”€โ”€ api/ # API ๋ผ์šฐํŠธ -โ”‚ โ”œโ”€โ”€ brand/ # ๋ธŒ๋žœ๋“œ ํŽ˜์ด์ง€ -โ”‚ โ”œโ”€โ”€ community/ # ์ปค๋ฎค๋‹ˆํ‹ฐ ํŽ˜์ด์ง€ -โ”‚ โ”œโ”€โ”€ perfumes/ # ํ–ฅ์ˆ˜ ์ƒ์„ธ ํŽ˜์ด์ง€ -โ”‚ โ””โ”€โ”€ user/ # ์‚ฌ์šฉ์ž ํŽ˜์ด์ง€ -โ”œโ”€โ”€ components/ # ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ปดํฌ๋„ŒํŠธ -โ”‚ โ”œโ”€โ”€ commons/ # ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ -โ”‚ โ”œโ”€โ”€ domains/ # ๋„๋ฉ”์ธ๋ณ„ ์ปดํฌ๋„ŒํŠธ -โ”‚ โ””โ”€โ”€ modal/ # ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ -โ”œโ”€โ”€ lib/ # ์œ ํ‹ธ๋ฆฌํ‹ฐ ๋ฐ ์„ค์ • -โ”‚ โ”œโ”€โ”€ hono/ # Hono API ์„ค์ • -โ”‚ โ”œโ”€โ”€ prisma/ # Prisma ํด๋ผ์ด์–ธํŠธ -โ”‚ โ”œโ”€โ”€ queries/ # ๋ฐ์ดํ„ฐ ์กฐํšŒ ํ•จ์ˆ˜ -โ”‚ โ”œโ”€โ”€ stores/ # Zustand ์Šคํ† ์–ด -โ”‚ โ””โ”€โ”€ utils/ # ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜ -โ””โ”€โ”€ stories/ # Storybook ์Šคํ† ๋ฆฌ +โ”œโ”€โ”€ app/ # Next.js App Router +โ”‚ โ”œโ”€โ”€ (main)/ # ๋ฉ”์ธ ํŽ˜์ด์ง€ +โ”‚ โ”œโ”€โ”€ api/[[...route]]/ # Hono ์บ์น˜์˜ฌ API ๋ผ์šฐํŠธ +โ”‚ โ”œโ”€โ”€ api/auth/ # NextAuth.js ์ธ์ฆ ๋ผ์šฐํŠธ +โ”‚ โ”œโ”€โ”€ api-doc/ # OpenAPI ๋ฌธ์„œ ํŽ˜์ด์ง€ +โ”‚ โ”œโ”€โ”€ brand/[name]/ # ๋ธŒ๋žœ๋“œ ์ƒ์„ธ ํŽ˜์ด์ง€ +โ”‚ โ”œโ”€โ”€ community/ # ์ปค๋ฎค๋‹ˆํ‹ฐ ๋ชฉ๋ก +โ”‚ โ”œโ”€โ”€ community/post/ # ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑยท์ƒ์„ธยท์ˆ˜์ • +โ”‚ โ”œโ”€โ”€ perfumes/ # ํ–ฅ์ˆ˜ ๋ชฉ๋ก ๋ฐ ์ƒ์„ธ +โ”‚ โ””โ”€โ”€ user/[id]/ # ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ยทํ™œ๋™ยท๋ถ๋งˆํฌยท์ปฌ๋ ‰์…˜ +โ”œโ”€โ”€ components/ # UI ์ปดํฌ๋„ŒํŠธ +โ”‚ โ”œโ”€โ”€ commons/ # ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ (๋ฒ„ํŠผ, ์นด๋“œ, ํƒญ ๋“ฑ) +โ”‚ โ”œโ”€โ”€ domains/ # ๋„๋ฉ”์ธ๋ณ„ ์ปดํฌ๋„ŒํŠธ +โ”‚ โ”‚ โ”œโ”€โ”€ brandDetail/ +โ”‚ โ”‚ โ”œโ”€โ”€ community/ +โ”‚ โ”‚ โ”œโ”€โ”€ main/ +โ”‚ โ”‚ โ”œโ”€โ”€ perfumeDetail/ +โ”‚ โ”‚ โ”œโ”€โ”€ perfumes/ +โ”‚ โ”‚ โ”œโ”€โ”€ post/ +โ”‚ โ”‚ โ”œโ”€โ”€ postDetail/ +โ”‚ โ”‚ โ””โ”€โ”€ user/ +โ”‚ โ””โ”€โ”€ modal/ # ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ +โ”œโ”€โ”€ server/ # ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ์ฝ”๋“œ +โ”‚ โ”œโ”€โ”€ hono/ # Hono API ๋ ˆ์ด์–ด +โ”‚ โ”‚ โ”œโ”€โ”€ middleware/ # ์ธ์ฆ ๋ฏธ๋“ค์›จ์–ด +โ”‚ โ”‚ โ”œโ”€โ”€ repositories/ # DB ์ ‘๊ทผ ๋ ˆ์ด์–ด +โ”‚ โ”‚ โ”œโ”€โ”€ routes/v1/ # ํ•ธ๋“ค๋Ÿฌ (๋„๋ฉ”์ธ๋ณ„ ๋ผ์šฐํŠธ) +โ”‚ โ”‚ โ”œโ”€โ”€ schemas/ # Zod ์š”์ฒญ/์‘๋‹ต ์Šคํ‚ค๋งˆ +โ”‚ โ”‚ โ”œโ”€โ”€ services/ # ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง +โ”‚ โ”‚ โ””โ”€โ”€ utils/ # ์„œ๋ฒ„ ์œ ํ‹ธ๋ฆฌํ‹ฐ +โ”‚ โ”œโ”€โ”€ database/ # ์„œ๋ฒ„ ์•ก์…˜ ๋ฐ ์„ธ์…˜ ์กฐํšŒ +โ”‚ โ”œโ”€โ”€ prisma/ # Prisma ํด๋ผ์ด์–ธํŠธ +โ”‚ โ””โ”€โ”€ supabase/ # Supabase ํด๋ผ์ด์–ธํŠธ +โ”œโ”€โ”€ client/ # ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ ์ฝ”๋“œ +โ”‚ โ”œโ”€โ”€ hooks/query/ # TanStack Query ํ›… +โ”‚ โ”œโ”€โ”€ stores/ # Zustand ์Šคํ† ์–ด +โ”‚ โ”œโ”€โ”€ tiptap/ # Tiptap ์—๋””ํ„ฐ ์„ค์ • +โ”‚ โ””โ”€โ”€ utils/ # ํด๋ผ์ด์–ธํŠธ ์œ ํ‹ธ๋ฆฌํ‹ฐ +โ””โ”€โ”€ shared/ # ์„œ๋ฒ„ยทํด๋ผ์ด์–ธํŠธ ๊ณต์šฉ + โ”œโ”€โ”€ constants/ # ๊ณตํ†ต ์ƒ์ˆ˜ + โ”œโ”€โ”€ types/ # ๊ณตํ†ต ํƒ€์ž… ์ •์˜ + โ””โ”€โ”€ utils/ # ๊ณตํ†ต ์œ ํ‹ธ๋ฆฌํ‹ฐ ``` -## ๐ŸŽจ ์ปดํฌ๋„ŒํŠธ ์‹œ์Šคํ…œ - -ํ”„๋กœ์ ํŠธ๋Š” ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ปดํฌ๋„ŒํŠธ ์‹œ์Šคํ…œ์„ ๊ฐ–์ถ”๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค: - -- **Commons**: ๋ฒ„ํŠผ, ์ž…๋ ฅ, ์นด๋“œ ๋“ฑ ๊ธฐ๋ณธ UI ์ปดํฌ๋„ŒํŠธ -- **Domains**: ํŠน์ • ๋„๋ฉ”์ธ(ํ–ฅ์ˆ˜, ์ปค๋ฎค๋‹ˆํ‹ฐ, ์‚ฌ์šฉ์ž)์— ํŠนํ™”๋œ ์ปดํฌ๋„ŒํŠธ -- **Modal**: ๋กœ๊ทธ์ธ, ๋ฆฌ๋ทฐ ๋“ฑ ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ - -๋ชจ๋“  ์ปดํฌ๋„ŒํŠธ๋Š” Storybook์œผ๋กœ ๋ฌธ์„œํ™”๋˜์–ด ์žˆ์œผ๋ฉฐ, `yarn storybook`์œผ๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. - -## ๐Ÿ“Š ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ - -์ฃผ์š” ์—”ํ‹ฐํ‹ฐ: - -- **User**: ์‚ฌ์šฉ์ž ์ •๋ณด -- **Perfume**: ํ–ฅ์ˆ˜ ์ •๋ณด -- **Brand**: ๋ธŒ๋žœ๋“œ ์ •๋ณด -- **Review**: ํ–ฅ์ˆ˜ ๋ฆฌ๋ทฐ -- **Post**: ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ฒŒ์‹œ๊ธ€ -- **Comment**: ๋Œ“๊ธ€ -- **Bookmark**: ๋ถ๋งˆํฌ (ํ–ฅ์ˆ˜/๊ฒŒ์‹œ๊ธ€) -- **Collection**: ์‚ฌ์šฉ์ž ํ–ฅ์ˆ˜ ์ปฌ๋ ‰์…˜ - -์ž์„ธํ•œ ์Šคํ‚ค๋งˆ๋Š” `prisma/schema.prisma` ํŒŒ์ผ์„ ์ฐธ์กฐํ•˜์„ธ์š”. - -## ๐Ÿ”ง API ๋ฌธ์„œ - -๊ฐœ๋ฐœ ๋ชจ๋“œ์—์„œ API ๋ฌธ์„œ๋Š” ๋‹ค์Œ URL์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค: - -- OpenAPI ์ŠคํŽ™: `http://localhost:3000/api/doc` - -API๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—”๋“œํฌ์ธํŠธ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค: - -- `/api/v1/perfumes` - ํ–ฅ์ˆ˜ ๊ด€๋ จ API -- `/api/v1/community` - ์ปค๋ฎค๋‹ˆํ‹ฐ API -- `/api/v1/reviews` - ๋ฆฌ๋ทฐ API -- `/api/v1/users` - ์‚ฌ์šฉ์ž API -- `/api/v1/search` - ๊ฒ€์ƒ‰ API - -## ๐Ÿ“ธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ - -![๋ฉ”์ธ ํŽ˜์ด์ง€](./public/images/Preview.png) - -## ๐Ÿ’ก ๊ธฐ์ˆ ์  ๋„์ „ ํฌ์ธํŠธ - - โ€ข App Router ์ „ํ™˜: ํŽ˜์ด์ง€ ๋ผ์šฐํŒ… ๋ฐ ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ ๊ธฐ๋ฐ˜ ์„ฑ๋Šฅ ๊ฐœ์„ . - โ€ข ์ƒํƒœ ๊ด€๋ฆฌ ์ „๋žต: Zustand + React Query ๊ธฐ๋ฐ˜ ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ. - โ€ข ์ปดํฌ๋„ŒํŠธ ๋ฌธ์„œํ™”: Storybook์œผ๋กœ UI ํ…Œ์ŠคํŠธ ๋ฐ ๊ด€๋ฆฌ. - -## ๐Ÿ“† ๊ฐœ๋ฐœ ๋กœ๋“œ๋งต - - โ€ข ํ–ฅ์ˆ˜ ์ถ”์ฒœ ์•Œ๊ณ ๋ฆฌ์ฆ˜ ์ถ”๊ฐ€ - โ€ข ๋‹ค๊ตญ์–ด ์ง€์› (i18n) - โ€ข ๋ชจ๋ฐ”์ผ ์ตœ์ ํ™” ๊ฐœ์„  - -## ๐Ÿค ํŒ€ ํ˜‘์—… & ์ฝ”๋“œ ์ปจ๋ฒค์…˜ - - โ€ข GitHub Actions + PR ๋ฆฌ๋ทฐ ํ”„๋กœ์„ธ์Šค. - โ€ข ESLint + Prettier๋ฅผ ํ†ตํ•œ ์ผ๊ด€์„ฑ ์œ ์ง€. +--- diff --git a/next.config.ts b/next.config.ts index 9708020c..b25d0376 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,8 +11,6 @@ const nextConfig: NextConfig = { serverExternalPackages: ["jsdom"], webpack: (config, { dev }) => { if (!dev) { - config.resolve.alias["@hono/swagger-ui"] = false; - config.resolve.alias["swagger-ui-dist"] = false; config.resolve.alias["swagger-ui-react"] = false; @@ -83,6 +81,16 @@ const nextConfig: NextConfig = { hostname: "images.unsplash.com", pathname: "/**", }, + { + protocol: "https", + hostname: "ssl.pstatic.net", + pathname: "/**", + }, + { + protocol: "http", + hostname: "img1.kakaocdn.net", + pathname: "/**", + }, ], }, compiler: { @@ -126,7 +134,7 @@ const nextConfig: NextConfig = { }, { key: "Permissions-Policy", - value: "camera=(), microphone=(), geolocation=()", + value: "camera=(), microphone=(), geolocation=(self)", }, ], }, diff --git a/package.json b/package.json index 6e8c4da1..71b58c8f 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "perscent", + "name": "chickchick", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", - "build": "prisma generate && next build", + "build": "next build", "build:analyze": "cross-env ANALYZE=true prisma generate && cross-env ANALYZE=true next build", "start": "next start", "lint": "next lint", @@ -13,34 +13,34 @@ "test:run": "vitest run", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "zod": "zod-prisma-types" + "zod": "prisma generate" }, "scarfSettings": { "enabled": false }, "dependencies": { - "@asteasolutions/zod-to-openapi": "^7.3.4", "@auth/supabase-adapter": "^1.8.0", - "@ckeditor/ckeditor5-react": "^9.5.0", - "@hono/node-server": "^1.14.4", - "@hono/swagger-ui": "^0.5.2", "@hono/zod-openapi": "^0.19.9", "@hono/zod-validator": "^0.4.3", "@hookform/resolvers": "^5.1.1", "@prisma/client": "6.19.0", "@supabase/supabase-js": "^2.46.2", "@tanstack/react-query": "^5.87.4", - "@tanstack/react-query-devtools": "^5.87.4", + "@tiptap/core": "^2.11.7", + "@tiptap/extension-image": "^2.11.7", + "@tiptap/extension-link": "^2.11.7", + "@tiptap/extension-text-align": "^2.11.7", + "@tiptap/extension-underline": "^2.11.7", + "@tiptap/pm": "^2.11.7", + "@tiptap/react": "^2.11.7", + "@tiptap/starter-kit": "^2.11.7", "chart.js": "^4.4.9", "chartjs-plugin-datalabels": "^2.2.0", - "ckeditor5": "^45.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.13", - "dompurify": "^3.3.1", "hono": "^4.8.3", - "hono-openapi": "^0.4.8", - "jsdom": "^28.1.0", + "image-size": "^1.1.1", "lucide-react": "^0.462.0", "next": "15.0.5", "next-auth": "^5.0.0-beta.25", @@ -49,16 +49,13 @@ "react-dom": "18", "react-dropzone": "^14.3.8", "react-hook-form": "^7.53.2", - "react-naver-maps": "^0.1.4", - "sharp": "^0.33.5", - "swagger-ui-react": "^5.25.3", + "sanitize-html": "^2.17.2", "swiper": "^11.2.7", "tailwind-merge": "^2.5.5", "tailwind-scrollbar-hide": "^2.0.0", "tailwindcss-animate": "^1.0.7", "uuid": "^11.1.0", "zod": "^3.25.67", - "zod-openapi": "^4.2.4", "zustand": "^5.0.1" }, "devDependencies": { @@ -72,12 +69,12 @@ "@storybook/react": "^8.5.8", "@storybook/test": "^8.5.8", "@svgr/webpack": "^8.1.0", - "@types/dompurify": "^3.2.0", - "@types/jsdom": "^27.0.0", + "@tanstack/react-query-devtools": "^5.87.4", "@types/navermaps": "^3.9.1", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/sanitize-html": "^2.16.1", "@types/swagger-ui-react": "^5.18.0", "@vitejs/plugin-react": "^5.1.1", "@vitest/ui": "^4.0.9", @@ -88,16 +85,13 @@ "openapi-types": "^12.1.3", "postcss": "^8", "prisma": "6.19.0", + "prisma-zod-generator": "1.32.1", "storybook": "^8.5.8", + "swagger-ui-react": "^5.25.3", "tailwindcss": "^3.4.1", "ts-node": "^10.9.2", "typescript": "^5", - "vitest": "^4.0.9", - "zod-prisma-types": "^3.2.4" - }, - "resolutions": { - "sharp": "0.33.5", - "jsdom": "25.0.1" + "vitest": "^4.0.9" }, "browserslist": [ "chrome >= 90", diff --git a/prisma/migrations/20260410083613_add_user_oauth_accounts/migration.sql b/prisma/migrations/20260410083613_add_user_oauth_accounts/migration.sql new file mode 100644 index 00000000..9fc49aed --- /dev/null +++ b/prisma/migrations/20260410083613_add_user_oauth_accounts/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "user_oauth_accounts" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "provider" TEXT NOT NULL, + "provider_account_id" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_oauth_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_oauth_accounts_provider_provider_account_id_key" ON "user_oauth_accounts"("provider", "provider_account_id"); + +-- AddForeignKey +ALTER TABLE "user_oauth_accounts" ADD CONSTRAINT "user_oauth_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260423004852_add_pending_oauth_link/migration.sql b/prisma/migrations/20260423004852_add_pending_oauth_link/migration.sql new file mode 100644 index 00000000..d91c31ca --- /dev/null +++ b/prisma/migrations/20260423004852_add_pending_oauth_link/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "pending_oauth_links" ( + "id" UUID NOT NULL, + "token" UUID NOT NULL, + "provider" TEXT NOT NULL, + "provider_account_id" TEXT NOT NULL, + "target_email" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "pending_oauth_links_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "pending_oauth_links_token_key" ON "pending_oauth_links"("token"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3a0e7f3e..db371c96 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,11 +3,14 @@ generator client { } generator zod { - provider = "zod-prisma-types" - output = "./zod" - createInputTypes = "false" - createModelSchema = "true" - useMultipleFiles = "true" + provider = "prisma-zod-generator" + output = "./zod" + pureModels = true + pureModelsLean = true + zodImportTarget = "v3" + dateTimeStrategy = "coerce" + useMultipleFiles = true + optionalFieldBehavior = "nullable" } datasource db { @@ -90,40 +93,65 @@ model Perfume { } model User { - id String @id @default(uuid()) @db.Uuid + id String @id @default(uuid()) @db.Uuid name String - authId String @unique @map("auth_id") @db.Uuid - email String? @unique - nickname String @unique + authId String @unique @map("auth_id") @db.Uuid + email String? @unique + nickname String @unique age Int? - gender Gender @default(UNKNOWN) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) - isActive Boolean @default(true) @map("is_active") - role String? @default("user") - imageUrl String? @map("image_url") - deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) - consecutiveLoginDays Int @default(0) @map("consecutive_login_days") - lastLoginDate DateTime? @map("last_login_date") @db.Timestamptz(6) - totalPoints Int @default(0) @map("total_points") - comments Comment[] @relation("UserToComments") - perfumeBookmarks PerfumeBookmark[] @relation("UserToPerfumeBookmarks") - perfumeLikes PerfumeLike[] @relation("UserToPerfumeLikes") - pointHistories PointHistory[] @relation("UserToPointHistories") - postBookmarks PostBookmark[] @relation("UserToPostBookmarks") - postDrafts PostDraft[] @relation("UserToPostDrafts") - postLikes PostLike[] @relation("UserToPostLikes") - posts Post[] @relation("UserToPosts") + gender Gender @default(UNKNOWN) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + isActive Boolean @default(true) @map("is_active") + role String? @default("user") + imageUrl String? @map("image_url") + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + consecutiveLoginDays Int @default(0) @map("consecutive_login_days") + lastLoginDate DateTime? @map("last_login_date") @db.Timestamptz(6) + totalPoints Int @default(0) @map("total_points") + comments Comment[] @relation("UserToComments") + oauthAccounts UserOAuthAccount[] @relation("UserToOAuthAccounts") + perfumeBookmarks PerfumeBookmark[] @relation("UserToPerfumeBookmarks") + perfumeLikes PerfumeLike[] @relation("UserToPerfumeLikes") + pointHistories PointHistory[] @relation("UserToPointHistories") + postBookmarks PostBookmark[] @relation("UserToPostBookmarks") + postDrafts PostDraft[] @relation("UserToPostDrafts") + postLikes PostLike[] @relation("UserToPostLikes") + posts Post[] @relation("UserToPosts") recentPerfumeViews RecentPerfumeView[] recentPostViews RecentPostView[] - reviewLikes ReviewLike[] @relation("UserToReviewLikes") - reviews Review[] @relation("UserToReviews") - activities UserActivity[] @relation("UserToActivities") - collections UserCollection[] @relation("UserToCollections") + reviewLikes ReviewLike[] @relation("UserToReviewLikes") + reviews Review[] @relation("UserToReviews") + activities UserActivity[] @relation("UserToActivities") + collections UserCollection[] @relation("UserToCollections") @@map("users") } +model UserOAuthAccount { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + provider String + providerAccountId String @map("provider_account_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User @relation("UserToOAuthAccounts", fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@map("user_oauth_accounts") +} + +model PendingOAuthLink { + id String @id @default(uuid()) @db.Uuid + token String @unique @default(uuid()) @db.Uuid + provider String + providerAccountId String @map("provider_account_id") + targetEmail String @map("target_email") + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("pending_oauth_links") +} + model PerfumeLike { id String @id @default(uuid()) @db.Uuid userId String @map("user_id") @db.Uuid diff --git a/public/images/docs/image.png b/public/images/docs/image.png new file mode 100644 index 00000000..62725ba9 Binary files /dev/null and b/public/images/docs/image.png differ diff --git a/public/images/docs/image2.png b/public/images/docs/image2.png new file mode 100644 index 00000000..1322c58c Binary files /dev/null and b/public/images/docs/image2.png differ diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index f48b929c..b84e9db3 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -3,6 +3,8 @@ import { MainContent } from "@/components/domains/main/mainContent/MainContent"; import { MainLogo } from "@/components/domains/main/MainLogo"; import { MainSearchBar } from "@/components/domains/main/MainSearchBar"; +export const dynamic = "force-dynamic"; + export default function Home() { return ( <> diff --git a/src/app/api-doc/layout.tsx b/src/app/api-doc/layout.tsx index dd030a7d..59a9448e 100644 --- a/src/app/api-doc/layout.tsx +++ b/src/app/api-doc/layout.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { notFound } from "next/navigation"; export default function ApiDocLayout({ children, @@ -6,7 +7,7 @@ export default function ApiDocLayout({ children: React.ReactNode; }) { if (process.env.NODE_ENV === "production") { - return null; + notFound(); } return <>{children}; } diff --git a/src/app/api-doc/page.tsx b/src/app/api-doc/page.tsx index bd56fdda..39bc851d 100644 --- a/src/app/api-doc/page.tsx +++ b/src/app/api-doc/page.tsx @@ -1,4 +1,5 @@ import SwaggerUI from "swagger-ui-react"; +import "swagger-ui-react/swagger-ui.css"; export default function Page() { return ( diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts index 020c8650..81a88d11 100644 --- a/src/app/api/[[...route]]/route.ts +++ b/src/app/api/[[...route]]/route.ts @@ -1,20 +1,8 @@ -import app from "@/lib/hono/app"; +import app from "@/server/hono/app"; import { handle } from "hono/vercel"; // export const runtime = "edge"; -if (process.env.NODE_ENV !== "production") { - try { - const { swaggerUI } = await import("@hono/swagger-ui"); - app.get("/swagger-ui", swaggerUI({ url: "/api/doc" })); - } catch (_) { - console.warn( - "Swagger UI dependencies not found in production environment.", - _, - ); - } -} - export const GET = handle(app); export const POST = handle(app); export const PUT = handle(app); diff --git a/src/app/api/brands/[name]/route.ts b/src/app/api/brands/[name]/route.ts deleted file mode 100644 index 528f2a8f..00000000 --- a/src/app/api/brands/[name]/route.ts +++ /dev/null @@ -1,212 +0,0 @@ -// app/api/brands/[name]/route.ts -import { Store } from "@/components/domains/brandDetail/map"; -import { NextRequest, NextResponse } from "next/server"; - -interface NaverLocalItem { - title: string; - address: string; - roadAddress: string; - telephone: string; - mapx: string; - mapy: string; - category: string; - link: string; -} - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ name: string }> } -) { - try { - // Next.js 15: params๋ฅผ await - const { name } = await params; - - const searchParams = request.nextUrl.searchParams; - const x = searchParams.get("x"); - const y = searchParams.get("y"); - - if (!name) { - return NextResponse.json( - { error: "Brand name is required" }, - { status: 400 } - ); - } - - if (!process.env.NAVER_CLIENT_ID || !process.env.NAVER_CLIENT_SECRET) { - return NextResponse.json( - { - error: "Server configuration error", - details: "Naver API credentials are missing in .env.local", - }, - { status: 500 } - ); - } - - // API URL ๊ตฌ์„ฑ - const apiUrl = `https://openapi.naver.com/v1/search/local.json?query=${encodeURIComponent( - name - )}&display=20&sort=random`; - - const response = await fetch(apiUrl, { - method: "GET", - headers: { - "X-Naver-Client-Id": process.env.NAVER_CLIENT_ID!, - "X-Naver-Client-Secret": process.env.NAVER_CLIENT_SECRET!, - }, - }); - - // ์—๋Ÿฌ ์‘๋‹ต ์ƒ์„ธ ์ฒ˜๋ฆฌ - if (!response.ok) { - const errorBody = await response.text(); - - // ๋„ค์ด๋ฒ„ API ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ํŒŒ์‹ฑ ์‹œ๋„ - let errorMessage = `Naver API error: ${response.status}`; - let errorDetails = errorBody; - - try { - const errorJson = JSON.parse(errorBody); - errorMessage = errorJson.errorMessage || errorMessage; - errorDetails = errorJson.errorCode - ? `${errorJson.errorCode}: ${errorJson.errorMessage}` - : errorBody; - } catch { - // JSON ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ ์›๋ณธ ํ…์ŠคํŠธ ์‚ฌ์šฉ - } - - // ์ƒํƒœ ์ฝ”๋“œ๋ณ„ ๊ตฌ์ฒด์ ์ธ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ - if (response.status === 400) { - return NextResponse.json( - { - error: "Bad Request", - details: "Invalid request parameters", - naverError: errorDetails, - }, - { status: 400 } - ); - } else if (response.status === 401) { - return NextResponse.json( - { - error: "Authentication Failed", - details: - "Invalid Naver API credentials. Please check your Client ID and Secret in .env.local", - naverError: errorDetails, - }, - { status: 401 } - ); - } else if (response.status === 403) { - return NextResponse.json( - { - error: "Forbidden", - details: - "API access denied. Check if the Search API is enabled in your Naver app", - naverError: errorDetails, - }, - { status: 403 } - ); - } else if (response.status === 429) { - return NextResponse.json( - { - error: "Rate Limit Exceeded", - details: "Too many requests. Please try again later", - naverError: errorDetails, - }, - { status: 429 } - ); - } else { - return NextResponse.json( - { - error: errorMessage, - details: errorDetails, - status: response.status, - }, - { status: response.status } - ); - } - } - - // ์„ฑ๊ณต ์‘๋‹ต ์ฒ˜๋ฆฌ - const data = await response.json(); - - // ๋ฐ์ดํ„ฐ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ - if (!data.items || data.items.length === 0) { - return NextResponse.json({ - success: true, - stores: [], - total: 0, - message: `No stores found for "${name}"`, - }); - } - - // ๋ฐ์ดํ„ฐ ์ •์ œ - const stores = data.items.map((item: NaverLocalItem, index: number) => { - console.log(` - Item ${index + 1}:`, item.title?.substring(0, 30)); - return { - name: item.title?.replace(/<[^>]*>/g, "") || "Unknown", - address: item.address || "", - roadAddress: item.roadAddress || "", - telephone: item.telephone || "", - x: item.mapx || "0", - y: item.mapy || "0", - category: item.category || "", - link: item.link || "", - }; - }); - - // ๊ฑฐ๋ฆฌ ๊ณ„์‚ฐ - if (x && y) { - const userLat = parseFloat(y); - const userLng = parseFloat(x); - - stores.forEach((store: Store) => { - const storeLat = parseInt(store.y) / 10000000; - const storeLng = parseInt(store.x) / 10000000; - - const R = 6371; // km - const dLat = ((storeLat - userLat) * Math.PI) / 180; - const dLng = ((storeLng - userLng) * Math.PI) / 180; - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos((userLat * Math.PI) / 180) * - Math.cos((storeLat * Math.PI) / 180) * - Math.sin(dLng / 2) * - Math.sin(dLng / 2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - store.distance = Math.round(R * c * 1000); // meters - }); - - stores.sort( - (a: Store, b: Store) => (a.distance || 0) - (b.distance || 0) - ); - } - - return NextResponse.json({ - success: true, - stores, - total: data.total, - }); - } catch (error) { - console.error("=== API Route Error ==="); - console.error( - "Error type:", - error instanceof Error ? error.constructor.name : typeof error - ); - console.error( - "Error message:", - error instanceof Error ? error.message : String(error) - ); - console.error( - "Error stack:", - error instanceof Error ? error.stack : "No stack" - ); - console.error("======================="); - - return NextResponse.json( - { - error: "Internal server error", - message: error instanceof Error ? error.message : "Unknown error", - type: error instanceof Error ? error.constructor.name : typeof error, - }, - { status: 500 } - ); - } -} diff --git a/src/app/brand/[name]/page.tsx b/src/app/brand/[name]/page.tsx index 0b9fdf17..bdb4bef2 100644 --- a/src/app/brand/[name]/page.tsx +++ b/src/app/brand/[name]/page.tsx @@ -1,16 +1,15 @@ import { cache } from "react"; import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; -import getQueryClient from "@/lib/utils/getQueryClient"; -import { brandApi } from "@/lib/utils/api/brands.api"; -import { perfumeApi } from "@/lib/utils/api/perfumes.api"; -import { BrandDetailImage } from "@/components/domains/brandDetail/image"; -import { BrandDetailInfo } from "@/components/domains/brandDetail/info"; +import getQueryClient from "@/client/utils/getQueryClient"; +import { brandApi } from "@/client/utils/api/brands.api"; +import { perfumeApi } from "@/client/utils/api/perfumes.api"; +import { BrandDetailTitle } from "@/components/domains/brandDetail/title"; import { PageClient } from "@/components/domains/brandDetail/perfumes/PageClient"; import { BrandDetailSearchBar } from "@/components/domains/brandDetail/searchBar"; -import { notFound } from "next/navigation"; -import { BrandMap } from "@/components/domains/brandDetail/map"; -import { generateSeo } from "@/lib/utils/generateSeo"; +import BrandDetailTabs from "@/components/domains/brandDetail/Tabs"; +import { generateSeo } from "@/shared/utils/generateSeo"; type Props = { params: Promise<{ name: string }>; @@ -85,24 +84,26 @@ export default async function BrandDetailPage({ ]); const brandUrl = brandData.brandUrl; - const brandStores = `https://map.naver.com/p/search/${brandData.nameKo}`; const brandImages = { - src: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?auto=format&fit=crop&w=1200&h=400&q=80", - alt: "1", + src: + brandData.imageUrl ?? + "https://images.unsplash.com/photo-1506744038136-46273834b3fb?auto=format&fit=crop&w=1200&h=400&q=80", + alt: brandData.nameKo ?? brandData.nameEn, }; return (
- + - -
diff --git a/src/app/brand/layout.tsx b/src/app/brand/layout.tsx new file mode 100644 index 00000000..4678f33c --- /dev/null +++ b/src/app/brand/layout.tsx @@ -0,0 +1,17 @@ +import Script from "next/script"; + +export default function BrandLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + {children} +