Files
nuxt-claude/app/components/used-sales/MarketPriceAnalysis.vue
hyeonggil 0a7cba4f93
Some checks failed
ci / ci (22, ubuntu-latest) (push) Failing after 24m12s
♻️ refactor: AI 시세 분석을 네이버 쇼핑 최저가 조회로 교체
- server/api/ai/market-price: Anthropic 스트리밍 제거, 네이버 쇼핑 API 호출로 변경
  - 신품 기준 최저가 10건 조회 (가격 오름차순, 중고·렌탈 제외)
  - NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 환경변수 사용
- MarketPriceAnalysis: 스트리밍 텍스트 UI → 구조화된 가격 비교 UI
  - 신품 최저가 / 평균가 / 희망가 대비 비율 요약 카드
  - 상품 목록 (이미지, 상품명, 쇼핑몰, 가격, 네이버 링크)
- nuxt.config: naverClientId / naverClientSecret runtimeConfig 추가
2026-03-08 22:24:26 +09:00

229 lines
7.2 KiB
Vue

<script setup lang="ts">
import { PLATFORM_LABELS } from '~/types/used-sale'
import type { UsedSale } from '~/types/used-sale'
interface ShoppingItem {
title: string
link: string
image: string
lprice: string
hprice: string
mallName: string
productId: string
}
interface ShoppingResult {
total: number
items: ShoppingItem[]
}
const props = defineProps<{
open: boolean
sale: UsedSale | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const result = ref<ShoppingResult | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchPrices() {
if (!props.sale) return
result.value = null
loading.value = true
error.value = null
try {
result.value = await $fetch<ShoppingResult>('/api/ai/market-price', {
method: 'POST',
body: {
itemName: props.sale.item_name,
salePrice: props.sale.sale_price
}
})
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '조회 중 오류가 발생했습니다'
error.value = msg
} finally {
loading.value = false
}
}
// 모달이 열릴 때 자동 조회
watch(() => props.open, (isOpen) => {
if (isOpen && props.sale) fetchPrices()
})
// HTML 태그 제거 (네이버 응답에 <b> 태그 포함)
function stripHtml(html: string) {
return html.replace(/<[^>]*>/g, '')
}
function formatPrice(price: string | number) {
const n = typeof price === 'string' ? parseInt(price, 10) : price
return isNaN(n) ? '-' : n.toLocaleString('ko-KR') + '원'
}
// 신품 최저가 (lprice 기준)
const lowestPrice = computed(() => {
const prices = (result.value?.items ?? [])
.map(i => parseInt(i.lprice, 10))
.filter(p => p > 0)
return prices.length ? Math.min(...prices) : null
})
// 신품 평균가
const avgPrice = computed(() => {
const prices = (result.value?.items ?? [])
.map(i => parseInt(i.lprice, 10))
.filter(p => p > 0)
if (!prices.length) return null
return Math.round(prices.reduce((a, b) => a + b, 0) / prices.length)
})
// 희망 판매가와 신품 최저가 비교 (양수 = 신품보다 비쌈)
const priceComparison = computed(() => {
if (!lowestPrice.value || !props.sale) return null
const diff = props.sale.sale_price - lowestPrice.value
const ratio = (diff / lowestPrice.value) * 100
return { diff, ratio }
})
</script>
<template>
<UModal
:open="open"
title="네이버 쇼핑 최저가 조회"
description="신품 기준 최저가와 희망 판매가를 비교합니다"
:ui="{ body: 'space-y-4' }"
@update:open="emit('update:open', $event)"
>
<template #body>
<!-- 장비 정보 -->
<div v-if="sale" class="flex flex-wrap gap-2">
<UBadge color="neutral" variant="outline" size="lg">
<UIcon name="i-lucide-tent" class="mr-1" />
{{ sale.item_name }}
</UBadge>
<UBadge color="neutral" variant="outline" size="lg">
<UIcon name="i-lucide-store" class="mr-1" />
{{ PLATFORM_LABELS[sale.platform] }}
</UBadge>
<UBadge color="primary" variant="subtle" size="lg">
<UIcon name="i-lucide-tag" class="mr-1" />
희망가 {{ sale ? formatPrice(sale.sale_price) : '-' }}
</UBadge>
</div>
<!-- 로딩 스켈레톤 -->
<div v-if="loading" class="space-y-3">
<USkeleton class="h-20 w-full rounded-lg" />
<USkeleton class="h-14 w-full rounded-lg" />
<USkeleton class="h-14 w-full rounded-lg" />
<USkeleton class="h-14 w-full rounded-lg" />
</div>
<!-- 에러 -->
<UAlert
v-else-if="error"
color="error"
icon="i-lucide-alert-circle"
:description="error"
/>
<!-- 결과 -->
<template v-else-if="result">
<!-- 가격 요약 카드 -->
<div class="grid grid-cols-3 gap-3">
<UCard :ui="{ body: 'p-3 text-center' }">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">신품 최저가</p>
<p class="text-lg font-bold text-green-600 dark:text-green-400">
{{ lowestPrice ? formatPrice(lowestPrice) : '-' }}
</p>
</UCard>
<UCard :ui="{ body: 'p-3 text-center' }">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">신품 평균가</p>
<p class="text-lg font-bold">
{{ avgPrice ? formatPrice(avgPrice) : '-' }}
</p>
</UCard>
<UCard :ui="{ body: 'p-3 text-center' }">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">희망가 비교</p>
<template v-if="priceComparison">
<p
class="text-lg font-bold"
:class="priceComparison.ratio > 0 ? 'text-red-500 dark:text-red-400' : 'text-blue-500 dark:text-blue-400'"
>
{{ priceComparison.ratio > 0 ? '+' : '' }}{{ priceComparison.ratio.toFixed(0) }}%
</p>
<p class="text-xs text-gray-400">신품 최저가 대비</p>
</template>
<p v-else class="text-lg font-bold text-gray-400">-</p>
</UCard>
</div>
<!-- 상품 목록 -->
<div class="space-y-2 max-h-64 overflow-y-auto pr-1">
<a
v-for="item in result.items"
:key="item.productId"
:href="item.link"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<img
v-if="item.image"
:src="item.image"
:alt="stripHtml(item.title)"
class="w-12 h-12 object-contain rounded bg-white flex-shrink-0"
/>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ stripHtml(item.title) }}</p>
<p class="text-xs text-gray-500">{{ item.mallName }}</p>
</div>
<div class="text-right flex-shrink-0 flex items-center gap-1">
<p class="text-sm font-bold text-green-600 dark:text-green-400">
{{ formatPrice(item.lprice) }}
</p>
<UIcon name="i-lucide-external-link" class="text-gray-400 size-3" />
</div>
</a>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500 text-center">
네이버 쇼핑 검색 결과 {{ result.total.toLocaleString() }} 최저가 {{ result.items.length }}
</p>
</template>
<!-- 초기 대기 상태 -->
<div v-else class="flex items-center justify-center h-32 text-gray-400 dark:text-gray-600">
<UIcon name="i-lucide-search" class="text-2xl" />
</div>
</template>
<template #footer>
<div class="flex justify-between w-full">
<UButton
icon="i-lucide-refresh-cw"
color="neutral"
variant="outline"
label="다시 조회"
:disabled="loading"
@click="fetchPrices"
/>
<UButton
label="닫기"
color="neutral"
variant="ghost"
@click="emit('update:open', false)"
/>
</div>
</template>
</UModal>
</template>