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
46 changes: 46 additions & 0 deletions docs/balance-game-tag-filter-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 밸런스게임 태그 검색/필터 요청서

## 목적

- 태그 필터를 드롭다운이 아닌 **검색 입력 + Enter 적용** 방식으로 변경.
- **다중 태그 필터** 지원 (입력 후 Enter로 태그 추가).
- 필터 적용 시 **입력된 모든 태그를 포함**하는 밸런스게임 목록만 반환.

## 변경 요약

- 프론트는 태그 입력 후 Enter 또는 "적용" 버튼 클릭 시 `tag` 쿼리로 요청
- 입력 길이 제한: **최대 40자**
- 태그 필터 해제 시 `tag` 파라미터 제거

## API 요청

### GET /api/v1/balance-game

- 기존 목록 API 유지
- Query Params:
- `page` (int, 1-based)
- `size` (int)
- `sort` (`latest` | `popular`)
- `status` (`active` | `closed`, optional)
- `tags` (string, optional) — `tag1,tag2,tag3` 형태의 comma-separated

### 태그 필터 동작

- `tags`가 전달되면 **모든 태그를 포함**한 투표만 반환
- 대소문자/공백 처리 정책은 백엔드에서 일관되게 적용
- `tags`가 비어있거나 누락되면 전체 반환

## 입력 제약 (프론트 기준)

- 태그 길이: 1~40자
- 다중 태그 필터 지원

## 응답

- 기존 밸런스게임 목록 응답 스키마 그대로

## 예시

```
GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
```
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.27.1",
"googleapis": "^164.1.0",
"hashids": "^2.3.0",
"lucide-react": "^0.475.0",
"next": "15.2.8",
"postcss": "^8.5.2",
Expand Down
86 changes: 86 additions & 0 deletions src/api/client/api-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig,
} from 'axios';

const shouldLog = process.env.NODE_ENV !== 'production';

const normalizeUrl = (config: InternalAxiosRequestConfig) => {
const base = config.baseURL ?? '';
const url = config.url ?? '';

if (!base) return url;
if (url.startsWith('http://') || url.startsWith('https://')) return url;

return `${base.replace(/\/$/, '')}/${url.replace(/^\//, '')}`;
};

const stringifyParams = (params: InternalAxiosRequestConfig['params']) => {
if (!params) return '';

try {
return JSON.stringify(params);
} catch {
return '';
}
};

const stringifyData = (data: unknown) => {
if (data === undefined) return '';

if (typeof data === 'string') return data;

try {
return JSON.stringify(data);
} catch {
return String(data);
}
};

export const attachApiLogger = (instance: AxiosInstance, label: string) => {
if (!shouldLog) return;

instance.interceptors.request.use((config) => {
const method = (config.method || 'get').toUpperCase();
const url = normalizeUrl(config);
const params = stringifyParams(config.params);

console.log(
`[API ${label}] ${method} ${url}${params ? ` params=${params}` : ''}`,
);

return config;
});

instance.interceptors.response.use(
(response) => {
const method = (response.config.method || 'get').toUpperCase();
const url = normalizeUrl(response.config);
const data = stringifyData(response.data);

console.log(`[API ${label}] ${method} ${url} -> ${response.status}`);
if (data) {
console.log(`[API ${label}] response=${data}`);
}

return response;
},
(error: AxiosError) => {
const config = error.config;
const method = config?.method?.toUpperCase() || 'UNKNOWN';
const url = config ? normalizeUrl(config) : 'unknown';
const status = error.response?.status;
const data = stringifyData(error.response?.data);

console.log(
`[API ${label}] ${method} ${url} -> ERROR${status ? ` ${status}` : ''}`,
);
if (data) {
console.log(`[API ${label}] response=${data}`);
}
Comment on lines +44 to +81
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

응답/파라미터 로그에 민감정보 노출 위험

비프로덕션이라도 팀 공용 로그에 PII/토큰이 기록될 수 있습니다. 본문/파라미터 로그는 명시적 플래그로 제한하거나 마스킹을 권장합니다.

✅ 로그 본문 opt-in 예시
-const shouldLog = process.env.NODE_ENV !== 'production';
+const shouldLog = process.env.NODE_ENV !== 'production';
+const shouldLogBody = process.env.NEXT_PUBLIC_API_LOG_BODY === 'true';

@@
-      if (data) {
+      if (shouldLogBody && data) {
         console.log(`[API ${label}] response=${data}`);
       }
@@
-      if (data) {
+      if (shouldLogBody && data) {
         console.log(`[API ${label}] response=${data}`);
       }
🤖 Prompt for AI Agents
In `@src/api/client/api-logger.ts` around lines 44 - 81, The request/response
logging in instance.interceptors.request.use and
instance.interceptors.response.use currently prints params/body via
stringifyParams and stringifyData which can expose PII/tokens; make logging of
params and response body opt-in via a configurable boolean (e.g.,
enableDetailedApiLogging or per-client option) and, when disabled, either omit
those fields or replace them using a masking function (e.g., maskSensitiveData)
before calling console.log; update usages around the Axios instance creation and
the label-aware logs so that logs still include method/url/status but only
include params/data when the opt-in flag is true or after masking.


return Promise.reject(error);
},
);
};
3 changes: 3 additions & 0 deletions src/api/client/axios.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { attachApiLogger } from './api-logger';
import { getServerCookie } from '../../utils/server-cookie';

// * server-side axios 인스턴스
Expand All @@ -13,6 +14,8 @@ export const axiosServerInstance = axios.create({
withCredentials: true,
});

attachApiLogger(axiosServerInstance, 'server-json');

const onRequestServer = async (config: InternalAxiosRequestConfig) => {
const accessToken = await getServerCookie('accessToken');

Expand Down
4 changes: 4 additions & 0 deletions src/api/client/axios.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios';
import { ApiError, isApiError } from './api-error';
import { attachApiLogger } from './api-logger';
import { getCookie, setCookie } from './cookie';

// * client-side axios 인스턴스
Expand All @@ -23,6 +24,9 @@ export const axiosInstanceForMultipart = axios.create({
},
});

attachApiLogger(axiosInstance, 'client-json');
attachApiLogger(axiosInstanceForMultipart, 'client-multipart');

const onRequestClient = (config: InternalAxiosRequestConfig) => {
const accessToken = getCookie('accessToken');

Expand Down
3 changes: 3 additions & 0 deletions src/api/client/axiosV2.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { attachApiLogger } from './api-logger';
import { getServerCookie } from '../../utils/server-cookie';

// * server-side axios 인스턴스
Expand All @@ -13,6 +14,8 @@ export const axiosServerInstanceV2 = axios.create({
withCredentials: true,
});

attachApiLogger(axiosServerInstanceV2, 'server-v2-json');

const onRequestServer = async (config: InternalAxiosRequestConfig) => {
const accessToken = await getServerCookie('accessToken');

Expand Down
4 changes: 4 additions & 0 deletions src/api/client/axiosV2.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios';
import { ApiError, isApiError } from './api-error';
import { attachApiLogger } from './api-logger';
import { getCookie, setCookie } from './cookie';

// * client-side axios 인스턴스 - openapi에 사용될 용도 (openapi로 전환 완료하면 axiosInstance로 변경)
Expand All @@ -23,6 +24,9 @@ export const axiosInstanceForMultipartV2 = axios.create({
},
});

attachApiLogger(axiosInstanceV2, 'client-v2-json');
attachApiLogger(axiosInstanceForMultipartV2, 'client-v2-multipart');

const onRequestClient = (config: InternalAxiosRequestConfig) => {
const accessToken = getCookie('accessToken');

Expand Down
1 change: 1 addition & 0 deletions src/app/(service)/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default async function Home({
<FeedbackLink />
<StartStudyButton />
<HomeContent activeTab={activeTab} />
<div className="h-[400px]" aria-hidden />
</div>
);
}
11 changes: 0 additions & 11 deletions src/app/(service)/insights/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,6 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
{category.name}
</Link>
))}
<Link
href="/insights/weekly"
className="font-designer-15r relative px-300 pb-200 text-[#535862] transition-colors hover:text-[#6366f1]"
>
<span className="flex items-center gap-100">
위클리
<span className="animate-pulse rounded-full bg-gradient-to-r from-[#6366f1] to-[#8b5cf6] px-100 py-25 text-[10px] font-bold text-white">
NEW
</span>
</span>
</Link>
</div>

{/* 아티클 목록 */}
Expand Down
12 changes: 0 additions & 12 deletions src/app/(service)/insights/weekly/[id]/page.tsx

This file was deleted.

5 changes: 0 additions & 5 deletions src/app/(service)/insights/weekly/page.tsx

This file was deleted.

31 changes: 24 additions & 7 deletions src/components/card/voting-card.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { MessageCircle, Users } from 'lucide-react';
import Link from 'next/link';
import React from 'react';
import { cn } from '@/components/ui/(shadcn)/lib/utils';
import UserAvatar from '@/components/ui/avatar';
Expand All @@ -10,12 +9,23 @@ import VoteTimer from '../voting/vote-timer';
interface VotingCardProps {
voting: BalanceGame;
onClick?: () => void;
onTagClick?: (tag: string) => void;
}

export default function VotingCard({ voting, onClick }: VotingCardProps) {
export default function VotingCard({
voting,
onClick,
onTagClick,
}: VotingCardProps) {
const topOption = voting.options.reduce((prev, current) =>
prev.percentage > current.percentage ? prev : current,
);
const isActive = voting.isActive ?? true;

const authorImage =
typeof voting.author.profileImage === 'string'
? voting.author.profileImage
: voting.author.profileImage?.resizedImages?.[0]?.resizedImageUrl;

// myVote can be null or number (optionId)
const hasVoted = voting.myVote !== undefined && voting.myVote !== null;
Expand All @@ -27,6 +37,7 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) {
hasVoted
? 'ring-border-brand shadow-2'
: 'ring-border-subtle hover:ring-border-brand hover:shadow-2',
!isActive && 'bg-background-default',
)}
onClick={
onClick
Expand All @@ -48,7 +59,7 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) {
<div>
<UserAvatar
size={32}
image={voting.author.profileImage || undefined}
image={authorImage || undefined}
className="relative z-10"
/>
</div>
Expand All @@ -73,12 +84,18 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) {
{voting.tags && Array.isArray(voting.tags) && voting.tags.length > 0 && (
<div className="mb-200 flex flex-wrap gap-100">
{voting.tags.map((tag, index) => (
<span
<button
key={tag || index}
className="rounded-100 bg-fill-neutral-subtle-default font-designer-12r text-text-subtle px-150 py-50"
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onTagClick?.(tag);
}}
className="rounded-100 bg-fill-neutral-subtle-default font-designer-12r text-text-subtle hover:ring-fill-brand-default-default px-150 py-50 ring-1 ring-transparent transition-shadow ring-inset"
>
#{tag}
</span>
</button>
))}
</div>
)}
Expand Down Expand Up @@ -129,5 +146,5 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) {
return cardContent;
}

return <Link href={`/insights/weekly/${voting.id}`}>{cardContent}</Link>;
return cardContent;
}
5 changes: 4 additions & 1 deletion src/components/discussion/comment-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ export default function CommentList({
'avatar' in comment.author
? comment.author.avatar
: 'profileImage' in comment.author
? (comment.author as any).profileImage
? typeof (comment.author as any).profileImage === 'string'
? (comment.author as any).profileImage
: (comment.author as any).profileImage?.resizedImages?.[0]
?.resizedImageUrl
: undefined;

return (
Expand Down
29 changes: 25 additions & 4 deletions src/components/home/tab-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import {
History,
} from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { getCookie } from '@/api/client/cookie';
import { cn } from '@/components/ui/(shadcn)/lib/utils';
import Button from '@/components/ui/button';
import { useAuth } from '@/hooks/common/use-auth';
import { useScrollToHomeContent } from '@/hooks/use-scroll-to-home-content';

interface TabNavigationProps {
activeTab: string;
Expand Down Expand Up @@ -53,22 +56,40 @@ export default function TabNavigation({ activeTab }: TabNavigationProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated } = useAuth();
const hasMemberId = !!getCookie('memberId');
const canViewHistory = isAuthenticated && hasMemberId;
const [canViewHistory, setCanViewHistory] = useState(false);
const visibleTabs = canViewHistory
? TABS
: TABS.filter((tab) => tab.id !== 'history');

useEffect(() => {
const hasMemberId = !!getCookie('memberId');
setCanViewHistory(isAuthenticated && hasMemberId);
}, [isAuthenticated]);

const scrollToHomeContent = useScrollToHomeContent();

const handleTabChange = (tabId: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', tabId);
router.push(`/home?${params.toString()}`);
router.push(`/home?${params.toString()}`, { scroll: false });
requestAnimationFrame(scrollToHomeContent);
};

const handleStudyTutorial = () => {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', 'study');
params.set('tutorial', 'study');
router.push(`/home?${params.toString()}`, { scroll: false });
requestAnimationFrame(scrollToHomeContent);
};

return (
<div className="mb-500 flex flex-col gap-300">
<div id="home-content-anchor" className="mb-500 flex flex-col gap-300">
<div className="flex items-center justify-between">
<h1 className="font-bold-h3 text-text-strong">제로원 홈</h1>
<Button size="small" color="outlined" onClick={handleStudyTutorial}>
스터디 튜토리얼 보기
</Button>
</div>

<nav className="border-border-subtle flex gap-100 border-b">
Expand Down
Loading