The catalog price shown next to a part (e.g. '€979.95') stayed stuck at its initial search-result value even after 'Compare prices' found and persisted a cheaper offer server-side (updatePriceIfCheaper already saves it to the database, but the locally held search result was never updated) — so the UI showed a stale, higher price right above a correctly-labeled 'cheapest' offer, which was confusing (e.g. '€979.95' next to 'Cdiscount: €741.51 (cheapest)'). comparePrices() now also updates part.price locally when the cheapest offer found beats it (or no catalog price was known), mirroring what the backend already persisted, so the display is consistent without needing to re-run the search.
199 lines
6.5 KiB
Vue
199 lines
6.5 KiB
Vue
<script setup lang="ts">
|
|
import { reactive, ref, watch } from 'vue'
|
|
import type { MerchantOffer, ScrapedPart } from '~/pages/types/UITypes'
|
|
|
|
const emit = defineEmits<{
|
|
import: [part: ScrapedPart, offer: MerchantOffer | null]
|
|
}>()
|
|
|
|
function faviconUrl(source: string): string {
|
|
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(source)}&sz=32`
|
|
}
|
|
|
|
const query = ref('')
|
|
const results = ref<ScrapedPart[]>([])
|
|
const isLoading = ref(false)
|
|
const hasSearched = ref(false)
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
// Per-part price comparison state, keyed by scraped part id.
|
|
const comparing = reactive<Record<number, boolean>>({})
|
|
const offers = reactive<Record<number, MerchantOffer[]>>({})
|
|
const compareErrors = reactive<Record<number, boolean>>({})
|
|
|
|
async function search() {
|
|
if (!query.value.trim()) {
|
|
results.value = []
|
|
hasSearched.value = false
|
|
return
|
|
}
|
|
|
|
isLoading.value = true
|
|
try {
|
|
const response = await fetch(`/scraped-parts?q=${encodeURIComponent(query.value.trim())}`, {
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
if (response.ok) {
|
|
results.value = await response.json()
|
|
} else {
|
|
results.value = []
|
|
}
|
|
} finally {
|
|
isLoading.value = false
|
|
hasSearched.value = true
|
|
}
|
|
}
|
|
|
|
watch(query, () => {
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(search, 400)
|
|
})
|
|
|
|
async function comparePrices(part: ScrapedPart) {
|
|
comparing[part.id] = true
|
|
compareErrors[part.id] = false
|
|
try {
|
|
const response = await fetch(`/scraped-parts/compare?id=${part.id}`, {
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
if (!response.ok) {
|
|
compareErrors[part.id] = true
|
|
return
|
|
}
|
|
const data = await response.json()
|
|
offers[part.id] = data.offers ?? []
|
|
if (offers[part.id].length === 0) {
|
|
compareErrors[part.id] = true
|
|
return
|
|
}
|
|
|
|
// The backend already persists the cheapest offer found as this
|
|
// part's new best-known price when it beats the catalog price (see
|
|
// ScrapedPartsController#compare / updatePriceIfCheaper). Mirror that
|
|
// here on the locally held result so the displayed catalog price
|
|
// (e.g. "€979.95") reflects it immediately, instead of staying stuck
|
|
// at whatever price the initial search returned until the user
|
|
// re-runs the search from scratch.
|
|
const cheapest = offers[part.id][0]
|
|
if (part.price === null || cheapest.price < part.price) {
|
|
part.price = cheapest.price
|
|
}
|
|
} catch {
|
|
compareErrors[part.id] = true
|
|
} finally {
|
|
comparing[part.id] = false
|
|
}
|
|
}
|
|
|
|
function cheapestOffer(part: ScrapedPart): MerchantOffer | null {
|
|
return offers[part.id]?.[0] ?? null
|
|
}
|
|
|
|
function merchantDomain(merchant: MerchantOffer['merchant']): string {
|
|
const domains: Record<MerchantOffer['merchant'], string> = {
|
|
'Amazon': 'amazon.fr',
|
|
'LDLC': 'ldlc.com',
|
|
'Grosbill': 'grosbill.com',
|
|
'TopAchat': 'topachat.com',
|
|
'Materiel.net': 'materiel.net',
|
|
'Cdiscount': 'cdiscount.com',
|
|
'PCComponentes': 'pccomponentes.com',
|
|
}
|
|
return domains[merchant]
|
|
}
|
|
|
|
function importPart(part: ScrapedPart) {
|
|
emit('import', part, cheapestOffer(part))
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
|
<label class="block text-gray-700 text-sm font-bold mb-2">
|
|
Search a new part (pc-builder.io)
|
|
</label>
|
|
<input
|
|
v-model="query"
|
|
type="text"
|
|
placeholder="e.g. Ryzen 5 7600X, RTX 4070..."
|
|
class="w-full border-gray-200 focus:border-indigo-600 focus:ring focus:ring-indigo-500/40 px-3 py-2 border rounded text-gray-700"
|
|
/>
|
|
|
|
<p v-if="isLoading" class="text-sm text-gray-500 mt-2">Searching…</p>
|
|
<p v-else-if="hasSearched && results.length === 0" class="text-sm text-gray-500 mt-2">
|
|
No matching part found.
|
|
</p>
|
|
<p v-else-if="results.length > 0" class="text-xs text-gray-500 mt-2">
|
|
{{ results.length }} result{{ results.length > 1 ? 's' : '' }} — scroll for more
|
|
</p>
|
|
|
|
<ul v-if="results.length > 0" class="mt-1 space-y-2 max-h-96 overflow-y-auto">
|
|
<li
|
|
v-for="part in results"
|
|
:key="part.id"
|
|
class="bg-white border border-gray-200 rounded p-2"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<img
|
|
v-if="part.imageUrl"
|
|
:src="part.imageUrl"
|
|
alt=""
|
|
class="w-10 h-10 object-contain shrink-0"
|
|
/>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm text-gray-800 truncate" :title="part.name">{{ part.name }}</p>
|
|
<p class="text-xs text-gray-500">
|
|
{{ part.type.name }}
|
|
<span v-if="part.price !== null"> · €{{ part.price.toFixed(2) }}</span>
|
|
<span v-else class="text-gray-400"> · No price yet</span>
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="px-3 py-1 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 transition shrink-0"
|
|
@click="importPart(part)"
|
|
>
|
|
Import
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-2 pl-13 flex items-center gap-2 flex-wrap">
|
|
<button
|
|
v-if="!offers[part.id]"
|
|
type="button"
|
|
:disabled="comparing[part.id]"
|
|
class="text-xs px-2 py-1 border border-gray-300 rounded text-gray-600 hover:bg-gray-100 transition disabled:opacity-50"
|
|
@click="comparePrices(part)"
|
|
>
|
|
{{ comparing[part.id] ? 'Checking prices…' : 'Compare prices' }}
|
|
</button>
|
|
|
|
<template v-else-if="cheapestOffer(part)">
|
|
<img
|
|
:src="faviconUrl(merchantDomain(cheapestOffer(part)!.merchant))"
|
|
alt=""
|
|
class="w-4 h-4 shrink-0"
|
|
/>
|
|
<a
|
|
:href="cheapestOffer(part)!.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-xs text-indigo-600 hover:underline"
|
|
>
|
|
{{ cheapestOffer(part)!.merchant }}: €{{ cheapestOffer(part)!.price.toFixed(2) }}
|
|
(cheapest)
|
|
</a>
|
|
</template>
|
|
|
|
<span v-if="compareErrors[part.id] && part.price !== null" class="text-xs text-gray-400">
|
|
No live price found, using catalog price €{{ part.price.toFixed(2) }}
|
|
</span>
|
|
<span v-else-if="compareErrors[part.id]" class="text-xs text-gray-400">
|
|
No price found on any merchant for this part.
|
|
</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|