Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"allow": [
"Bash(gh pr view:*)",
"Bash(gh api:*)",
"Bash(npm run lint)"
"Bash(npm run lint)",
"mcp__ide__getDiagnostics"
]
}
}
88 changes: 81 additions & 7 deletions auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,61 @@ import { TokenRefreshReqDto, TokenRefreshResDto } from '@/types/domain/auth';
import { ApiResponse } from '@/types/common';

const TOKEN_REFRESH_BUFFER = 60 * 1000;
const REDACTED = '[REDACTED]';
const SENSITIVE_TOKEN_KEYS = new Set([
'accessToken',
'refreshToken',
'access_token',
'refresh_token',
'id_token',
'token',
'email',
'password',
'username',
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function maskSensitiveUrl(url: string): string {
try {
const parsedUrl = new URL(url);
if (parsedUrl.searchParams.has('code')) {
parsedUrl.searchParams.set('code', REDACTED);
}
return parsedUrl.toString();
} catch {
return url.replace(/([?&]code=)[^&]*/i, `$1${REDACTED}`);
}
}

function maskTokenField(value: unknown): string {
if (typeof value !== 'string') return REDACTED;
if (value.length <= 10) return REDACTED;
return `${value.slice(0, 4)}...${value.slice(-4)}`;
}

function sanitizeLogData(value: unknown, seen = new WeakSet<object>): unknown {
if (Array.isArray(value)) {
return value.map((entry) => sanitizeLogData(entry, seen));
}
Comment on lines +40 to +43
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

배열에 대한 순환 참조 검사 누락.

Array.isArray 분기가 seen WeakSet 검사보다 먼저 실행되어, 순환 참조를 가진 배열은 무한 재귀에 빠질 수 있습니다. 실제 로그 데이터에서 발생 확률은 낮지만 방어 코드의 일관성 측면에서 보완할 수 있습니다.

🛡️ 제안: 배열도 순환 참조 검사 추가
 function sanitizeLogData(value: unknown, seen = new WeakSet<object>): unknown {
+  if (value && typeof value === 'object') {
+    if (seen.has(value as object)) {
+      return '[Circular]';
+    }
+    seen.add(value as object);
+  }
+
   if (Array.isArray(value)) {
     return value.map((entry) => sanitizeLogData(entry, seen));
   }
 
-  if (value && typeof value === 'object') {
-    if (seen.has(value as object)) {
-      return '[Circular]';
-    }
-    seen.add(value as object);
-
+  if (value && typeof value === 'object') {
     return Object.entries(value as Record<string, unknown>).reduce(
🤖 Prompt for AI Agents
In `@auth.ts` around lines 40 - 43, The Array branch in sanitizeLogData runs
before checking for circular references, so arrays that reference themselves can
cause infinite recursion; modify sanitizeLogData to first detect objects (typeof
value === 'object' && value !== null), then if seen.has(value) return a sentinel
like "[Circular]", otherwise seen.add(value) and only after that handle
Array.isArray(value) by mapping entries via sanitizeLogData(entry, seen); keep
the same seen WeakSet for nested object handling and do not add non-object
primitives to seen.


if (value && typeof value === 'object') {
if (seen.has(value as object)) {
return '[Circular]';
}
seen.add(value as object);

return Object.entries(value as Record<string, unknown>).reduce(
(acc, [key, entryValue]) => {
acc[key] = SENSITIVE_TOKEN_KEYS.has(key)
? maskTokenField(entryValue)
: sanitizeLogData(entryValue, seen);
return acc;
},
{} as Record<string, unknown>,
);
}

return value;
}

async function refreshAccessToken(token: JWT): Promise<JWT | null> {
try {
Expand Down Expand Up @@ -58,21 +113,35 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
if (!parsed.success) return null;
const { provider, code } = parsed.data;

const backendUrl = `${process.env.BACKEND_API_URL}/auth/oauth/${provider}?code=${code}`;
console.log(backendUrl);
const backendUrl = `${process.env.BACKEND_API_URL}/auth/oauth/${provider}?code=${encodeURIComponent(code)}`;
const method = provider === 'google' ? 'GET' : 'POST';
console.log(
`[${provider}] OAuth Request:`,
method,
maskSensitiveUrl(backendUrl),
);

try {
// Google uses GET, Kakao uses POST
const res = await fetch(backendUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method,
...(method === 'POST' && {
headers: { 'Content-Type': 'application/json' },
}),
});

console.log(`[${provider}] Response status:`, res.status);

if (!res.ok) {
const error = await res.json();
console.log(error);
console.error(
`[${provider}] Backend error:`,
sanitizeLogData(error),
);
throw new Error('Backend verification failed');
}
const { data } = await res.json();
console.log(data);
console.log(`[${provider}] Success data:`, sanitizeLogData(data));

return {
userId: data.userId,
Expand Down Expand Up @@ -115,7 +184,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({

if (!res.ok) throw new Error('Invalid credentials');
const { data } = await res.json();
console.log(data);
console.log('Credentials login success:', sanitizeLogData(data));

return {
userId: data.userId,
Expand Down Expand Up @@ -148,6 +217,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
};
}

// Only attempt refresh if we have a refresh token (user is logged in)
if (!token.refreshToken) {
return token;
}

if (Date.now() < (token.accessTokenExpires ?? 0) - TOKEN_REFRESH_BUFFER) {
return token;
}
Expand Down
4 changes: 4 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: 'example.com', // TODO: 실제 API 이미지 호스트로 교체 필요
},
{
protocol: 'https',
hostname: 'ongil-bucket.s3.ap-northeast-2.amazonaws.com', // 광고 이미지 S3 버킷
},
{
protocol: 'http',
hostname: 'img1.kakaocdn.net', // 카카오 프로필 이미지 호스팅
Expand Down
72 changes: 43 additions & 29 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,60 @@
import { CarouselWithDots } from '@/components/banner-carousel';
import {
RecommendedCategoryCard,
RecommendCarousel,
RecommendCarouselItem,
RecommendedProductCard,
} from '@/components/recommend-carousel';
import { MAIN_CATEGORIES } from '@/mocks/categories';
import BannerCarouselContainer from '@/components/banner-carousel';
import MainHeader from '@/components/layout/main-header';
import MainNavBar from '@/components/layout/main-nav-bar';

import { BRANDS, MOCK_PRODUCTS } from '@/mocks/brands-and-products';
import RecommendedBrandContainer from '@/components/recommend-brand/recommended-brand-container';
import RecommendCategoryContainer from '@/components/recommend-carousel/recommend-category-container';
import { Suspense } from 'react';
// import { api } from '@/lib/api-client';
// import { Advertisement } from '@/types/domain/etc';
// import { Category } from '@/types/domain/category';
import RecommendProductContainer from '@/components/recommend-carousel/recommend-product-container';

export default async function Home() {
const p1 = MOCK_PRODUCTS.slice(0, 6);
const p2 = MOCK_PRODUCTS.slice(6, 12);
const p3 = MOCK_PRODUCTS.slice(12, 18);
// TODO: 카테고리 데이터를 UI에 연결
// const categories = await api.get<Category[]>('/categories');

return (
<div className="flex flex-col items-center">
<MainHeader />

<CarouselWithDots />
<Suspense fallback={<div>Loading...</div>}>
<Suspense
fallback={
<div className="h-[420px] w-full animate-pulse bg-gray-100 md:h-[520px]" />
}
>
<BannerCarouselContainer />
</Suspense>
<Suspense
fallback={
<div className="h-[120px] w-full animate-pulse bg-gray-50 px-5 py-4" />
}
>
<RecommendCategoryContainer />
</Suspense>
<RecommendCarousel heading="추천 상품">
{MOCK_PRODUCTS.map((product) => (
<RecommendCarouselItem key={product.id}>
<RecommendedProductCard productInfo={product} />
</RecommendCarouselItem>
))}
</RecommendCarousel>

<RecommendedBrandContainer brands={BRANDS} productLists={[p1, p2, p3]} />
<Suspense
fallback={
<div className="h-[360px] w-full animate-pulse bg-gray-50 px-5 py-4" />
}
>
<RecommendProductContainer
endpoint="/products/special-sale"
heading="특가 상품"
/>
</Suspense>

<Suspense
fallback={
<div className="h-[360px] w-full animate-pulse bg-gray-50 px-5 py-4" />
}
>
<RecommendProductContainer
endpoint="/products/recommend"
heading="추천 상품"
/>
</Suspense>

<Suspense
fallback={
<div className="h-[460px] w-full animate-pulse bg-gray-50 px-5 py-4" />
}
>
<RecommendedBrandContainer />
</Suspense>

<MainNavBar />
</div>
Expand Down
9 changes: 9 additions & 0 deletions src/components/banner-carousel/banner-carousel-container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { api } from '@/lib/api-client';
import { Advertisement } from '@/types/domain/advertisement';
import CarouselWithDots from './carousel-with-dots';

export default async function BannerCarouselContainer() {
const advertisements = await api.get<Advertisement[]>('/advertisements/home');

return <CarouselWithDots advertisements={advertisements} />;
}
76 changes: 76 additions & 0 deletions src/components/banner-carousel/carousel-with-dots.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client';

import { useState } from 'react';
import Image from 'next/image';
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from '@/components/ui/carousel';
import { useCarouselDots } from './use-carousel-dots';
import { DotNavigation } from './dot-navigation';
import Autoplay from 'embla-carousel-autoplay';
import { Advertisement } from '@/types/domain/advertisement';

interface CarouselWithDotsProps {
advertisements: Advertisement[];
}

export default function CarouselWithDots({
advertisements,
}: CarouselWithDotsProps) {
if (advertisements.length === 0) return null;

const [api, setApi] = useState<CarouselApi>();
const { current, count, scrollTo } = useCarouselDots(api);
Comment on lines +20 to +26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

React Rules of Hooks 위반 — early return이 useState보다 앞에 위치

Line 23의 early return이 Line 25의 useState 호출보다 먼저 실행됩니다. advertisements가 비어있을 때 hooks가 호출되지 않으므로, 배열이 비어있는 상태 ↔ 비어있지 않은 상태 간 전환 시 React가 "Rendered fewer hooks than expected" 에러를 발생시킵니다.

early return을 hooks 호출 이후로 이동시켜야 합니다.

제안
 export default function CarouselWithDots({
   advertisements,
 }: CarouselWithDotsProps) {
-  if (advertisements.length === 0) return null;
-
   const [api, setApi] = useState<CarouselApi>();
   const { current, count, scrollTo } = useCarouselDots(api);
 
+  if (advertisements.length === 0) return null;
+
   return (

Based on learnings: "ALWAYS use export default function for components (Named functions)." — 함수 선언 패턴은 준수하고 있으나, hooks 규칙이 우선입니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export default function CarouselWithDots({
advertisements,
}: CarouselWithDotsProps) {
if (advertisements.length === 0) return null;
const [api, setApi] = useState<CarouselApi>();
const { current, count, scrollTo } = useCarouselDots(api);
export default function CarouselWithDots({
advertisements,
}: CarouselWithDotsProps) {
const [api, setApi] = useState<CarouselApi>();
const { current, count, scrollTo } = useCarouselDots(api);
if (advertisements.length === 0) return null;
return (
🤖 Prompt for AI Agents
In `@src/components/banner-carousel/carousel-with-dots.tsx` around lines 20 - 26,
The early return in CarouselWithDots runs before hooks and breaks the Rules of
Hooks; move the guard that checks advertisements.length so that the hooks are
always called first: keep the useState<CarouselApi>() and the
useCarouselDots(api) calls (references: CarouselWithDots, useState,
useCarouselDots, api, setApi) at the top of the component, then after those
hooks return null if advertisements.length === 0 (or render an empty
placeholder) so hooks are invoked consistently across renders.


return (
<div
className="w-screen max-w-200"
role="region"
aria-roledescription="carousel"
aria-label="배너 광고"
>
<Carousel
setApi={setApi}
opts={{
loop: true,
}}
plugins={[
Autoplay({
delay: 3000,
stopOnInteraction: false,
}),
]}
>
<CarouselContent className="m-0 max-h-125 w-full">
{advertisements.map((item, index) => (
<CarouselItem key={item.id} className="p-0">
<div className="relative aspect-380/275 h-full w-full overflow-hidden">
<Image
src={item.imageUrl}
alt={item.title}
fill
className="object-cover"
priority={index === 0}
sizes="(max-width: 800px) 100vw, 800px"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-6 text-white">
<h3 className="mb-2 text-2xl font-black break-keep">
{item.title}
</h3>
<p className="text-lg font-medium break-keep opacity-90">
{item.description}
</p>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<DotNavigation count={count} current={current} onDotClick={scrollTo} />
</div>
);
}
Loading