Skip to content

Commit 41f1353

Browse files
authored
Merge pull request #144 from DeviceLife-Official/feature/141-combo_eval-api
feat: 조합평가 api 연동
2 parents 3ac81ed + 302de33 commit 41f1353

19 files changed

Lines changed: 543 additions & 102 deletions

src/apis/combo/deleteComboDevice.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { axiosInstance } from '@/apis/axios/axios';
22
import type { DeleteComboDeviceResponse } from '@/types/combo/combo';
33
import { useMutation, useQueryClient } from '@tanstack/react-query';
44
import { queryKey } from '@/constants/queryKey';
5+
import { usePollComboEvaluation } from '@/hooks/usePollComboEvaluation';
56

67
// 조합에서 기기 삭제
78
export const deleteComboDevice = async (
@@ -16,14 +17,17 @@ export const deleteComboDevice = async (
1617

1718
export const useDeleteComboDevice = () => {
1819
const queryClient = useQueryClient();
20+
const { poll } = usePollComboEvaluation();
1921

2022
return useMutation({
2123
mutationFn: ({ comboId, deviceId }: { comboId: number; deviceId: number }) =>
2224
deleteComboDevice(comboId, deviceId),
23-
onSuccess: () => {
25+
onSuccess: (_data, variables) => {
2426
// 조합 목록과 상세 정보 캐시 무효화
2527
queryClient.invalidateQueries({ queryKey: [queryKey.COMBOS] });
2628
queryClient.invalidateQueries({ queryKey: [queryKey.COMBO_DETAIL] });
29+
// 조합 평가 폴링 시작
30+
poll(variables.comboId);
2731
},
2832
});
2933
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { axiosInstance } from '@/apis/axios/axios';
2+
import type { GetComboEvaluationResponse, ComboEvaluationResult } from '@/types/combo/evaluation';
3+
import { useQuery } from '@tanstack/react-query';
4+
import { queryKey } from '@/constants/queryKey';
5+
6+
// 조합 평가 점수 조회 API
7+
export const getComboEvaluation = async (comboId: number): Promise<ComboEvaluationResult> => {
8+
const { data } = await axiosInstance.get<GetComboEvaluationResponse>(
9+
`/api/combos/${comboId}/evaluation`
10+
);
11+
return data.result!;
12+
};
13+
14+
// 조합 평가 조회 훅 (캐시 구독 + 초기 fetch)
15+
export const useComboEvaluation = (comboId: number | undefined) => {
16+
return useQuery({
17+
queryKey: [queryKey.COMBO_EVALUATION, comboId],
18+
queryFn: () => getComboEvaluation(comboId!),
19+
enabled: !!comboId,
20+
staleTime: Infinity, // 폴링이 setQueryData로 갱신하므로 자동 refetch 불필요
21+
retry: false, // 404(EVAL_4041)일 때 무한 재시도 방지
22+
});
23+
};
24+

src/apis/combo/postComboDevices.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
} from '@/types/combo/combo';
66
import { useMutation, useQueryClient } from '@tanstack/react-query';
77
import { queryKey } from '@/constants/queryKey';
8+
import { usePollComboEvaluation } from '@/hooks/usePollComboEvaluation';
89

910
// 조합에 기기 추가
1011
export const postComboDevice = async (
@@ -20,13 +21,16 @@ export const postComboDevice = async (
2021

2122
export const usePostComboDevice = () => {
2223
const queryClient = useQueryClient();
24+
const { poll } = usePollComboEvaluation();
2325

2426
return useMutation({
2527
mutationFn: ({ comboId, deviceId }: { comboId: number; deviceId: number }) =>
2628
postComboDevice(comboId, { deviceId }),
27-
onSuccess: () => {
29+
onSuccess: (_data, variables) => {
2830
// 조합 목록/상세 캐시 무효화
2931
queryClient.invalidateQueries({ queryKey: [queryKey.COMBOS] });
32+
// 조합 평가 폴링 시작
33+
poll(variables.comboId);
3034
},
3135
});
3236
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { CombinationName, CombinationStatus } from '@/constants/combination';
2+
import {
3+
COMBINATION_NAME_STYLE_MAP,
4+
COMBINATION_STATUS_STYLE_MAP,
5+
} from '@/constants/combination';
6+
import type { Grade } from '@/constants/evaluation/grade';
7+
8+
interface CombinationEvaluationCardProps {
9+
category: CombinationName;
10+
grade: Grade;
11+
description: string;
12+
tags: string[];
13+
}
14+
15+
// 등급 텍스트 색상 클래스 추출 (COMBINATION_STATUS_STYLE_MAP에서 색상만 추출)
16+
const getGradeTextColorClass = (grade: Grade): string => {
17+
const statusStyle = COMBINATION_STATUS_STYLE_MAP[grade as CombinationStatus];
18+
// 'font-caption-sm text-optimal' 형태에서 색상 부분만 추출
19+
return statusStyle.split(' ').find((cls) => cls.startsWith('text-')) ?? 'text-optimal';
20+
};
21+
22+
const CombinationEvaluationCard = ({
23+
category,
24+
grade,
25+
description,
26+
tags,
27+
}: CombinationEvaluationCardProps) => {
28+
const gradeTextColorClass = getGradeTextColorClass(grade);
29+
const tagStyleClass = COMBINATION_NAME_STYLE_MAP[category];
30+
31+
return (
32+
<div className="bg-white rounded-card px-42 py-30 flex flex-col gap-30">
33+
<div className="flex items-center gap-16">
34+
<p className="font-heading-4 text-black">{category}:</p>
35+
<p className={`font-heading-4 ${gradeTextColorClass}`}>{grade}</p>
36+
</div>
37+
<p className="font-body-3-r text-black leading-28">{description}</p>
38+
<div className="flex gap-8 -ml-4">
39+
{tags.map((tag) => (
40+
<span
41+
key={tag}
42+
className={`${tagStyleClass} font-body-2-sm px-12 py-8 rounded-full`}
43+
>
44+
{tag}
45+
</span>
46+
))}
47+
</div>
48+
</div>
49+
);
50+
};
51+
52+
export default CombinationEvaluationCard;

src/components/Combination/Stage1Section.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const Stage1Section = () => {
99
<p className="font-body-4-sm text-black text-center">
1010
나만의 기기 조합을 만들어보세요! <br /> 조합명을 입력하고, 조합 생성하기 버튼을 누르면
1111
<br />
12-
나만의 조합이 생성돼요!
12+
나만의 조합이 생성됩니다.
1313
</p>
1414
</div>
1515
<Stage1 className="w-236 h-60" />

src/components/Combination/Stage2Section.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const Stage2Section = () => {
1010
기기검색 창에서 원하는 기기들을 골라
1111
<br /> 내가 만든 조합에 담아보세요!
1212
<br /> 이때 한 조합에는 동일한 카테고리의 기기를 <br />
13-
하나씩만 담는 것을 추천해요!
13+
하나씩만 담는 것을 추천합니다.
1414
</p>
1515
</div>
1616
<Stage2 className="w-176 h-128" />

src/constants/combination.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export const COMBINATION_NAMES = ['연동성', '편의성', '라이프스타일'] as const;
2-
export const COMBINATION_STATUSES = ['최적', '보통', '미흡', '-'] as const;
2+
export const COMBINATION_STATUSES = ['최적', '양호', '보통', '미흡', '-'] as const;
33

44
export type CombinationName = (typeof COMBINATION_NAMES)[number];
55
export type CombinationStatus = (typeof COMBINATION_STATUSES)[number];
@@ -12,6 +12,7 @@ export const COMBINATION_NAME_STYLE_MAP: Record<CombinationName, string> = {
1212

1313
export const COMBINATION_STATUS_STYLE_MAP: Record<CombinationStatus, string> = {
1414
최적: 'font-caption-sm text-optimal',
15+
양호: 'font-caption-sm text-good',
1516
보통: 'font-caption-sm text-normal',
1617
미흡: 'font-caption-sm text-poor',
1718
'-': 'font-caption-sm text-optimal',
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { GRADE } from './grade';
2+
import type { Grade } from './grade';
3+
4+
// 연동성 카드 데이터 타입
5+
export type EvaluationCardData = {
6+
tags: string[];
7+
text: string;
8+
};
9+
10+
// 연동성 매핑 테이블
11+
export const CONNECTIVITY: Record<Grade, EvaluationCardData> = {
12+
[GRADE.BEST]: {
13+
tags: ['OS통합', '완벽 호환성'],
14+
text: '모든 기기가 하나의 OS 생태계로 완벽하게 통합되어 있습니다. 호환성 기준이 다수 충족된 최상의 상태입니다.',
15+
},
16+
[GRADE.GOOD]: {
17+
tags: ['안정적 연결', '품질 우수'],
18+
text: '주요 기기 간의 연결이 안정적입니다. 다만 조합에 따라 마우스 제스처나 오디오 코덱 등 세부적인 부가 기능 중 일부가 제한적으로 작동할 수 있습니다.',
19+
},
20+
[GRADE.NORMAL]: {
21+
tags: ['OS 혼합'],
22+
text: '기본적인 연결은 가능하나 서로 다른 OS가 섞여 있습니다. 키보드 레이아웃 불일치나 단축키 활용에 제약이 예상되어, 기기 간의 시너지를 확인해 볼 필요가 있습니다.',
23+
},
24+
[GRADE.POOR]: {
25+
tags: ['OS 고립', '연동성 부족'],
26+
text: '스마트워치와 스마트폰의 OS가 달라 핵심 기능을 쓸 수 없거나, 연결 대상이 없어 활용도가 떨어지는 기기가 포함되어 있습니다. 연동성이 매우 낮은 조합입니다.',
27+
},
28+
[GRADE.UNKNOWN]: {
29+
tags: ['-'],
30+
text: '-',
31+
},
32+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { GRADE } from './grade';
2+
import type { Grade } from './grade';
3+
import type { EvaluationCardData } from './connectivity';
4+
5+
// 편의성 매핑 테이블
6+
export const CONVENIENCE: Record<Grade, EvaluationCardData> = {
7+
[GRADE.BEST]: {
8+
tags: ['USB-C', '배터리 효율 우수'],
9+
text: '모든 기기가 USB-C로 통일되었으며 배터리 효율 또한 우수입니다. 단일 충전기로 노트북까지 완벽하게 커버 가능한 이상적인 환경입니다.',
10+
},
11+
[GRADE.GOOD]: {
12+
tags: ['안정적 충전', '멀티태스킹', '배터리 준수'],
13+
text: '전반적인 배터리 효율과 충전 속도가 안정적입니다. 무선 충전이나 고출력 PD 충전 등 핵심 편의 기능 중 일부가 포함되어 관리가 수월합니다.',
14+
},
15+
[GRADE.NORMAL]: {
16+
tags: ['단자 혼재', '충전 속도 확인', '포트 배분'],
17+
text: '사용에 큰 지장은 없으나, 기기에 따라 충전 단자가 다르거나 충전기 출력이 다소 낮을 수 있습니다. 동시 충전 시 포트 배분을 고려해야 합니다.',
18+
},
19+
[GRADE.POOR]: {
20+
tags: ['어댑터 필요', '단자 불일치', '충전 병목'],
21+
text: '노트북 충전을 위해 전용 어댑터가 필요하거나 충전 포트가 기기 수에 비해 부족합니다. 단자 혼재로 인해 케이블 관리가 번거로운 조합입니다.',
22+
},
23+
[GRADE.UNKNOWN]: {
24+
tags: ['-'],
25+
text: '-',
26+
},
27+
};

src/constants/evaluation/grade.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// 등급 상수 및 타입 정의
2+
3+
export const GRADE = {
4+
BEST: '최적',
5+
GOOD: '양호',
6+
NORMAL: '보통',
7+
POOR: '미흡',
8+
UNKNOWN: '-',
9+
} as const;
10+
11+
export type Grade = (typeof GRADE)[keyof typeof GRADE];
12+
13+
// API 응답 등급 문자열 → Grade 타입 변환
14+
export const toGrade = (raw: string): Grade => {
15+
const valid: string[] = [GRADE.BEST, GRADE.GOOD, GRADE.NORMAL, GRADE.POOR];
16+
return valid.includes(raw) ? (raw as Grade) : GRADE.UNKNOWN;
17+
};

0 commit comments

Comments
 (0)