Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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"
]
}
}
20 changes: 15 additions & 5 deletions auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,26 @@ 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, backendUrl);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

try {
// Google uses GET, Kakao uses POST
const res = await fetch(backendUrl, {
method: 'POST',
method,
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:`, error);
throw new Error('Backend verification failed');
}
const { data } = await res.json();
console.log(data);
console.log(`[${provider}] Success data:`, data);

return {
userId: data.userId,
Expand Down Expand Up @@ -148,6 +153,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
49 changes: 22 additions & 27 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,41 @@
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>}>
<BannerCarouselContainer />
</Suspense>
<Suspense fallback={<div>Loading...</div>}>
<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>Loading...</div>}>
<RecommendProductContainer
endpoint="/products/special-sale"
heading="특가 상품"
/>
</Suspense>

<Suspense fallback={<div>Loading...</div>}>
<RecommendProductContainer
endpoint="/products/recommend"
heading="추천 상품"
/>
</Suspense>

<Suspense fallback={<div>Loading...</div>}>
<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} />;
}
66 changes: 66 additions & 0 deletions src/components/banner-carousel/carousel-with-dots.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'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 function CarouselWithDots({ advertisements }: CarouselWithDotsProps) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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">
<Carousel
setApi={setApi}
opts={{
loop: true,
}}
plugins={[
Autoplay({
delay: 3000,
stopOnInteraction: false,
}),
]}
>
<CarouselContent className="m-0 max-h-125 w-full">
{advertisements.map((item) => (
<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
/>
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>
);
}
98 changes: 1 addition & 97 deletions src/components/banner-carousel/index.tsx
Original file line number Diff line number Diff line change
@@ -1,97 +1 @@
'use client';

import { useState } from 'react';
import Link from 'next/link'; // Link 태그 추가
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';

export function CarouselWithDots() {
const [api, setApi] = useState<CarouselApi>();
const { current, count, scrollTo } = useCarouselDots(api);

const bannerContent = [
{
title: '“프롬프트 엔지니어링 (aka 구걸)”',
desc: '"제발 한 번만 돌아가게 해줘"라고 정중하게 부탁해서 얻어낸 코드입니다. AI는 예의 바른 사람을 좋아하거든요.',
handle: '@bk-git-hub',
link: 'https://github.com/bk-git-hub',
},
{
title: '“버그는 죽지 않는다, 다만 숨을 뿐”',
desc: '버그는 우리와 함께 살아가는 동반자입니다. 죽이려 하지 마세요. 그냥 다른 곳으로 옮기세요.',
handle: '@Seoje1405',
link: 'https://github.com/Seoje1405',
},
{
title: '“할루시네이션도 하나의 기능입니다”',
desc: 'AI가 존재하지 않는 라이브러리를 사용했다고요? 그건 저희가 앞으로 만들겠다는 미래지향적 비전입니다.',
handle: '@bk-git-hub',
link: 'https://github.com/bk-git-hub',
},
{
title: '“커밋 메시지 ‘fix’는 기도의 한 종류”',
desc: '아무것도 안 고쳤지만 제발 이번엔 배포가 성공하길 바라는 개발자의 간절한 염원입니다.',
handle: 'Seoje1405',
link: 'https://github.com/Seoje1405',
},

{
title: '“완벽한 코드는 배포하지 않은 코드”',
desc: '세상에 돌아가는 쓰레기는 있어도 완벽한 코드는 없습니다. 돌아가면 일단 퇴근하십시오.',
handle: '@bk-git-hub',
link: 'https://github.com/bk-git-hub',
},
];

return (
<div className="w-screen max-w-200">
<Carousel
setApi={setApi}
opts={{
loop: true,
}}
plugins={[
Autoplay({
delay: 3000,
stopOnInteraction: false,
}),
]}
>
<CarouselContent className="m-0 max-h-125 w-full">
{bannerContent.map((item, i) => (
<CarouselItem key={i} className="p-0">
<Link
href={item.link}
target="_blank"
className="block h-full w-full transition-transform outline-none active:scale-95"
>
<div className="bg-card text-card-foreground hover:bg-accent flex aspect-380/275 h-full w-full flex-col items-center justify-center border-4 p-6 text-center transition-colors">
<span className="mb-2 text-xs font-bold tracking-widest uppercase opacity-60">
{item.handle}
</span>
<h3 className="mb-4 text-2xl font-black break-keep italic underline decoration-yellow-400 underline-offset-4">
{item.title}
</h3>
<p className="text-lg font-medium break-keep opacity-90">
{item.desc}
</p>
<span className="mt-4 text-[10px] font-bold opacity-40 group-hover:opacity-100">
CLICK TO VISIT GITHUB
</span>
</div>
</Link>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<DotNavigation count={count} current={current} onDotClick={scrollTo} />
</div>
);
}
export { default } from './banner-carousel-container';
2 changes: 1 addition & 1 deletion src/components/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function LoginForm() {
const CLIENT_ID = process.env.NEXT_PUBLIC_AUTH_KAKAO_ID;
authUrl = `https://kauth.kakao.com/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${callbackUrl}&response_type=code`;
} else if (provider === 'google') {
const CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
const CLIENT_ID = process.env.NEXT_PUBLIC_AUTH_GOOGLE_ID;
const SCOPE = 'openid email profile';
authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${callbackUrl}&response_type=code&scope=${SCOPE}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Expand Down
45 changes: 45 additions & 0 deletions src/components/recommend-brand/recommended-brand-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client';

import { useState } from 'react';
import { BrandWithProducts } from '@/types/domain/brand';
import RecommendedBrandHeader from './recommended-brand-header';
import RecommendedBrandGridCard from './recommended-brand-grid-card';

interface RecommendedBrandClientProps {
brands: BrandWithProducts[];
}

export default function RecommendedBrandClient({
brands,
}: RecommendedBrandClientProps) {
const [selectedIndex, setSelectedIndex] = useState<number>(0);

const currentBrand = brands[selectedIndex];
const currentProducts = currentBrand?.products || [];

return (
<div className="flex w-full flex-col p-5">
<RecommendedBrandHeader
brands={brands}
onClick={setSelectedIndex}
selectedIndex={selectedIndex}
/>

<div className="mt-4">
<div className="mt-2 w-full">
{currentProducts.length > 0 ? (
<ul className="grid w-full grid-cols-2 justify-items-center gap-4">
{currentProducts.map((product) => (
<li key={product.id}>
<RecommendedBrandGridCard product={product} />
</li>
))}
</ul>
) : (
<p>No products available for this brand.</p>
)}
Comment on lines +42 to +44
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

하드코딩된 영어 문구 — i18n 고려 필요.

한국어 서비스 기준으로 "No products available for this brand." 문구가 영어입니다. 한국어로 변경하거나 i18n 처리가 필요합니다.

🤖 Prompt for AI Agents
In `@src/components/recommend-brand/recommended-brand-client.tsx` around lines 38
- 40, The hardcoded English message in the RecommendedBrandClient JSX should be
replaced with an i18n lookup; locate the component in
recommended-brand-client.tsx and replace the string "No products available for
this brand." with a call to your translation method (e.g., using
useTranslation() -> t('recommendedBrand.noProducts') or
i18n.t('recommendedBrand.noProducts')), and add the corresponding translation
key/value to your locale files (Korean entry at minimum) so the UI shows the
localized text.

</div>
</div>
</div>
);
}
Loading