before claude
BIN
webui/dist/assets/fa-brands-400-D1LuMI3I.ttf
vendored
Normal file
BIN
webui/dist/assets/fa-brands-400-D_cYUPeE.woff2
vendored
Normal file
BIN
webui/dist/assets/fa-regular-400-BjRzuEpd.woff2
vendored
Normal file
BIN
webui/dist/assets/fa-regular-400-DZaxPHgR.ttf
vendored
Normal file
BIN
webui/dist/assets/fa-solid-900-CTAAxXor.woff2
vendored
Normal file
BIN
webui/dist/assets/fa-solid-900-D0aA9rwL.ttf
vendored
Normal file
BIN
webui/dist/assets/fa-v4compatibility-C9RhG_FT.woff2
vendored
Normal file
BIN
webui/dist/assets/fa-v4compatibility-CCth-dXg.ttf
vendored
Normal file
5
webui/dist/assets/index-BURbFjJa.css
vendored
Normal file
18
webui/dist/assets/index-ZvFbjZEA.js
vendored
Normal file
5
webui/dist/favicon.svg
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#3c3836" />
|
||||
<circle cx="32" cy="32" r="18" fill="#fe8019" />
|
||||
<path d="M18 34c6-6 22-6 28 0" fill="none" stroke="#282828" stroke-width="4" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 281 B |
14
webui/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PriceWatch Web UI</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script type="module" crossorigin src="/assets/index-ZvFbjZEA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BURbFjJa.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 181 KiB |
144
webui/src/components/CardActions.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
productId: number
|
||||
compareIds: number[]
|
||||
showSecondary?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
(e: 'compare'): void
|
||||
(e: 'edit'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'open'): void
|
||||
}>()
|
||||
|
||||
function handleRefresh(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
function handleCompare(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('compare')
|
||||
}
|
||||
|
||||
function handleEdit(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('edit')
|
||||
}
|
||||
|
||||
function handleDelete(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
function handleOpen(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('open')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="card-actions__btn card-actions__btn--primary"
|
||||
title="Rafraichir"
|
||||
aria-label="Rafraichir le produit"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="fa-solid fa-rotate"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="card-actions__btn"
|
||||
title="Modifier"
|
||||
aria-label="Modifier le produit"
|
||||
@click="handleEdit"
|
||||
>
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="card-actions__btn"
|
||||
title="Supprimer"
|
||||
aria-label="Supprimer le produit"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="card-actions__btn"
|
||||
title="Ouvrir"
|
||||
aria-label="Ouvrir dans un nouvel onglet"
|
||||
@click="handleOpen"
|
||||
>
|
||||
<i class="fa-solid fa-up-right-from-square"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="props.showSecondary"
|
||||
class="card-actions__btn"
|
||||
:class="{ 'card-actions__btn--active': compareIds.includes(productId) }"
|
||||
title="Comparer"
|
||||
aria-label="Comparer le produit"
|
||||
@click="handleCompare"
|
||||
>
|
||||
<i class="fa-solid" :class="compareIds.includes(productId) ? 'fa-square-check' : 'fa-code-compare'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 0 2px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.card-actions__btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-actions__btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-actions__btn--primary {
|
||||
background: rgba(254, 128, 25, 0.18);
|
||||
color: var(--accent);
|
||||
border-color: rgba(254, 128, 25, 0.4);
|
||||
}
|
||||
|
||||
.card-actions__btn--primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 14px rgba(254, 128, 25, 0.25);
|
||||
}
|
||||
|
||||
.card-actions__btn--active {
|
||||
background: rgba(184, 187, 38, 0.2);
|
||||
color: var(--success);
|
||||
border-color: rgba(184, 187, 38, 0.45);
|
||||
}
|
||||
|
||||
.card-actions__btn--active:hover {
|
||||
background: var(--success);
|
||||
}
|
||||
</style>
|
||||
@@ -78,11 +78,9 @@ const yBounds = computed(() => {
|
||||
const values = validPoints.value.map((item) => item.v);
|
||||
const rawMin = Math.min(...values);
|
||||
const rawMax = Math.max(...values);
|
||||
const delta = Math.max(rawMax - rawMin, 1);
|
||||
const pad = delta * 0.05;
|
||||
return {
|
||||
min: rawMin - pad,
|
||||
max: rawMax + pad,
|
||||
min: rawMin,
|
||||
max: rawMax,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -132,21 +130,14 @@ const chartPoints = computed(() => {
|
||||
const hasPoints = computed(() => chartPoints.value.length > 0);
|
||||
|
||||
const linePoints = computed(() => {
|
||||
if (!chartPoints.value.length) {
|
||||
if (chartPoints.value.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
if (chartPoints.value.length === 1) {
|
||||
const point = chartPoints.value[0];
|
||||
const endX = margins.left + chartDimensions.value.width;
|
||||
return [
|
||||
{ x: margins.left, y: point.y },
|
||||
{ x: endX, y: point.y },
|
||||
];
|
||||
}
|
||||
return chartPoints.value;
|
||||
});
|
||||
|
||||
const polylinePoints = computed(() => linePoints.value.map((point) => `${point.x},${point.y}`).join(" "));
|
||||
const showLine = computed(() => linePoints.value.length > 1);
|
||||
|
||||
const yTickValues = computed(() => {
|
||||
const count = Math.max(2, props.yTicks);
|
||||
@@ -256,6 +247,7 @@ const placeholderLabel = computed(() => "");
|
||||
</text>
|
||||
</g>
|
||||
<polyline
|
||||
v-if="showLine"
|
||||
:points="polylinePoints"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
|
||||
319
webui/src/components/PriceBlock.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
price: number | null
|
||||
currency: string
|
||||
msrp?: number | null
|
||||
discountAmount?: number | null
|
||||
discountPercent?: number | null
|
||||
discountText?: string | null
|
||||
deltaLabel?: string | null
|
||||
deltaLabelTitle?: string | null
|
||||
stockStatus: string
|
||||
stockText?: string | null
|
||||
inStock?: boolean | null
|
||||
reference?: string | null
|
||||
url?: string | null
|
||||
ratingValue?: number | null
|
||||
ratingCount?: number | null
|
||||
amazonChoice?: boolean | null
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const formatPrice = (value: number | null, currency: string): string => {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) {
|
||||
return '—'
|
||||
}
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: currency || 'EUR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
const formatShortPrice = (value: number | null, currency: string): string => {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) {
|
||||
return '—'
|
||||
}
|
||||
const numeric = Number(value)
|
||||
const rounded = Math.round(numeric * 100) / 100
|
||||
const centsValue = Math.round(rounded * 100) - Math.floor(rounded) * 100
|
||||
const hasCents = centsValue !== 0
|
||||
if ((currency || 'EUR') === 'EUR') {
|
||||
const euros = Math.floor(rounded)
|
||||
const cents = String(Math.abs(centsValue)).padStart(2, '0')
|
||||
return hasCents ? `${euros}€${cents}` : `${euros}€`
|
||||
}
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: currency || 'EUR',
|
||||
minimumFractionDigits: hasCents ? 2 : 0,
|
||||
maximumFractionDigits: hasCents ? 2 : 0,
|
||||
}).format(rounded)
|
||||
}
|
||||
|
||||
type PriceParts = {
|
||||
euros: string
|
||||
cents: string | null
|
||||
symbol: string
|
||||
raw: string | null
|
||||
}
|
||||
|
||||
const formatPriceParts = (value: number | null, currency: string): PriceParts => {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) {
|
||||
return { euros: '', cents: null, symbol: '', raw: '—' }
|
||||
}
|
||||
const numeric = Number(value)
|
||||
const rounded = Math.round(numeric * 100) / 100
|
||||
const centsValue = Math.round(rounded * 100) - Math.floor(rounded) * 100
|
||||
const hasCents = centsValue !== 0
|
||||
if ((currency || 'EUR') !== 'EUR') {
|
||||
return { euros: '', cents: null, symbol: '', raw: formatShortPrice(rounded, currency) }
|
||||
}
|
||||
return {
|
||||
euros: String(Math.floor(rounded)),
|
||||
cents: hasCents ? String(Math.abs(centsValue)).padStart(2, '0') : null,
|
||||
symbol: '€',
|
||||
raw: null,
|
||||
}
|
||||
}
|
||||
|
||||
const formattedPrice = computed(() => formatPriceParts(props.price, props.currency))
|
||||
const formattedMsrp = computed(() => (props.msrp ? formatPriceParts(props.msrp, props.currency) : null))
|
||||
|
||||
const discountDisplay = computed(() => {
|
||||
if (props.discountText?.trim()) {
|
||||
return props.discountText.trim()
|
||||
}
|
||||
if (props.discountAmount === null || props.discountAmount === undefined ||
|
||||
props.discountPercent === null || props.discountPercent === undefined) {
|
||||
return null
|
||||
}
|
||||
const amount = formatShortPrice(props.discountAmount, props.currency)
|
||||
const percent = Math.round(props.discountPercent)
|
||||
return `-${percent}% (${amount})`
|
||||
})
|
||||
|
||||
const stockLabel = computed(() => {
|
||||
if (props.stockText?.trim()) {
|
||||
return props.stockText.trim()
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
in_stock: 'En stock',
|
||||
out_of_stock: 'Rupture',
|
||||
unknown: 'Inconnu',
|
||||
error: 'Erreur',
|
||||
}
|
||||
return map[props.stockStatus] || props.stockStatus
|
||||
})
|
||||
|
||||
const stockClass = computed(() => {
|
||||
if (props.inStock === true) return 'text-[var(--success)]'
|
||||
if (props.inStock === false) return 'text-[var(--danger)]'
|
||||
if (props.stockStatus === 'in_stock') return 'text-[var(--success)]'
|
||||
if (props.stockStatus === 'out_of_stock') return 'text-[var(--danger)]'
|
||||
return 'text-[var(--muted)]'
|
||||
})
|
||||
|
||||
const deltaDisplay = computed(() => {
|
||||
if (props.deltaLabel && props.deltaLabel.trim()) {
|
||||
return props.deltaLabel
|
||||
}
|
||||
return '—'
|
||||
})
|
||||
|
||||
const deltaTitle = computed(() => {
|
||||
if (props.deltaLabelTitle && props.deltaLabelTitle.trim()) {
|
||||
return props.deltaLabelTitle
|
||||
}
|
||||
return 'Evol.'
|
||||
})
|
||||
|
||||
const ratingDisplay = computed(() => {
|
||||
if (props.ratingValue === null || props.ratingValue === undefined) {
|
||||
return '—'
|
||||
}
|
||||
const value = props.ratingValue.toFixed(1).replace('.', ',')
|
||||
if (props.ratingCount === null || props.ratingCount === undefined) {
|
||||
return value
|
||||
}
|
||||
const count = new Intl.NumberFormat('fr-FR').format(props.ratingCount)
|
||||
return `${value} (${count})`
|
||||
})
|
||||
|
||||
const amazonChoiceDisplay = computed(() => {
|
||||
if (props.amazonChoice === true) return 'Oui'
|
||||
if (props.amazonChoice === false) return '—'
|
||||
return '—'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="price-block" :class="{ 'price-block--compact': compact }">
|
||||
<div class="price-block__row price-block__row--main">
|
||||
<span class="price-block__label">Actuel</span>
|
||||
<span class="price-block__current">
|
||||
<template v-if="formattedPrice.raw">{{ formattedPrice.raw }}</template>
|
||||
<template v-else>
|
||||
<span class="price-block__euros">{{ formattedPrice.euros }}</span>
|
||||
<sup class="price-block__currency">{{ formattedPrice.symbol }}</sup>
|
||||
<span v-if="formattedPrice.cents" class="price-block__cents">{{ formattedPrice.cents }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__row">
|
||||
<span class="price-block__label">Prix conseillé</span>
|
||||
<span class="price-block__msrp">
|
||||
<template v-if="!formattedMsrp">—</template>
|
||||
<template v-else-if="formattedMsrp.raw">{{ formattedMsrp.raw }}</template>
|
||||
<template v-else>
|
||||
<span class="price-block__euros">{{ formattedMsrp.euros }}</span>
|
||||
<sup class="price-block__currency">{{ formattedMsrp.symbol }}</sup>
|
||||
<span v-if="formattedMsrp.cents" class="price-block__cents">{{ formattedMsrp.cents }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__row">
|
||||
<span class="price-block__label">{{ deltaTitle }}</span>
|
||||
<span>{{ deltaDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="discountDisplay" class="price-block__row price-block__discount">
|
||||
<span class="price-block__label">Réduction</span>
|
||||
<span>{{ discountDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__row price-block__stock" :class="stockClass">
|
||||
<span class="price-block__label">Stock</span>
|
||||
<span>{{ stockLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__row">
|
||||
<span class="price-block__label">Note</span>
|
||||
<span>{{ ratingDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__row">
|
||||
<span class="price-block__label">Choix Amazon</span>
|
||||
<span>{{ amazonChoiceDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div class="price-block__meta">
|
||||
<span v-if="reference" class="price-block__ref">Ref: {{ reference }}</span>
|
||||
<a
|
||||
v-if="url"
|
||||
class="price-block__link"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@click.stop
|
||||
>
|
||||
Lien produit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.price-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 100px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.price-block--compact {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.price-block__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.price-block__row--main {
|
||||
font-size: 0.8rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.price-block__label {
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
font-size: 0.6rem;
|
||||
min-width: 82px;
|
||||
}
|
||||
|
||||
.price-block__current {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.price-block__euros {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.price-block__currency {
|
||||
font-size: 0.65em;
|
||||
vertical-align: super;
|
||||
margin-left: 1px;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.price-block__cents {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.price-block__msrp {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.price-block__discount {
|
||||
font-weight: 600;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.price-block__stock {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.price-block__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 6px;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.price-block__ref {
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.price-block__link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.price-block__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -119,7 +119,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const popupStyle = computed(() => ({
|
||||
position: "fixed",
|
||||
position: "fixed" as const,
|
||||
top: `${props.position.top}px`,
|
||||
left: `${props.position.left}px`,
|
||||
width: "280px",
|
||||
|
||||
600
webui/src/components/ProductCard.vue
Normal file
@@ -0,0 +1,600 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import MiniLineChart from './MiniLineChart.vue'
|
||||
import PriceBlock from './PriceBlock.vue'
|
||||
import CardActions from './CardActions.vue'
|
||||
|
||||
interface HistoryPoint {
|
||||
t: number
|
||||
v: number
|
||||
}
|
||||
|
||||
interface HistorySnapshot {
|
||||
points: HistoryPoint[]
|
||||
min: number | null
|
||||
max: number | null
|
||||
delta: number | null
|
||||
trendIcon: string
|
||||
trendLabel: string
|
||||
trendDeltaLabel: string
|
||||
trendColor: string
|
||||
lastTimestamp: number | null
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: number
|
||||
storeId: string
|
||||
title: string
|
||||
url: string
|
||||
price: number | null
|
||||
currency: string
|
||||
msrp: number | null
|
||||
stockStatus: string
|
||||
stockText?: string | null
|
||||
inStock?: boolean | null
|
||||
updatedAt: string
|
||||
delta: number
|
||||
discountAmount: number | null
|
||||
discountPercent: number | null
|
||||
discountText?: string | null
|
||||
imageWebp?: string
|
||||
imageJpg?: string
|
||||
reference?: string
|
||||
category?: string
|
||||
type?: string
|
||||
notes?: string
|
||||
analysis?: string
|
||||
ratingValue?: number | null
|
||||
ratingCount?: number | null
|
||||
amazonChoice?: boolean | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
product: Product
|
||||
historyData: HistorySnapshot | null
|
||||
compareIds: number[]
|
||||
storeLogo?: string
|
||||
storeLabel: string
|
||||
storeInitials: string
|
||||
chartPeriodLabel: string
|
||||
imageMode: string
|
||||
placeholderImage: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'refresh'): void
|
||||
(e: 'compare'): void
|
||||
(e: 'edit'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'open'): void
|
||||
(e: 'hover', event: MouseEvent | FocusEvent): void
|
||||
(e: 'leave'): void
|
||||
}>()
|
||||
|
||||
const formatShortPrice = (value: number | null, currency: string): string => {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) {
|
||||
return '—'
|
||||
}
|
||||
const numeric = Number(value)
|
||||
const rounded = Math.round(numeric * 100) / 100
|
||||
const centsValue = Math.round(rounded * 100) - Math.floor(rounded) * 100
|
||||
const hasCents = centsValue !== 0
|
||||
if ((currency || 'EUR') === 'EUR') {
|
||||
const euros = Math.floor(rounded)
|
||||
const cents = String(Math.abs(centsValue)).padStart(2, '0')
|
||||
return hasCents ? `${euros}€${cents}` : `${euros}€`
|
||||
}
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: currency || 'EUR',
|
||||
minimumFractionDigits: hasCents ? 2 : 0,
|
||||
maximumFractionDigits: hasCents ? 2 : 0,
|
||||
}).format(rounded)
|
||||
}
|
||||
|
||||
const formatHistoryDateLabel = (value: number | string): string => {
|
||||
let timestamp: number | null = null
|
||||
if (typeof value === 'number') {
|
||||
timestamp = value
|
||||
} else if (typeof value === 'string') {
|
||||
const parsed = Date.parse(value)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
timestamp = parsed
|
||||
}
|
||||
}
|
||||
if (timestamp === null) {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
const diffMs = Math.max(0, Date.now() - timestamp)
|
||||
const hours = Math.max(0, Math.round(diffMs / 3_600_000))
|
||||
if (hours < 36) {
|
||||
return hours === 0 ? '0h' : `-${hours}h`
|
||||
}
|
||||
const days = Math.max(1, Math.round(hours / 24))
|
||||
return `-${days}j`
|
||||
}
|
||||
|
||||
const formatRelativeTimeAgo = (timestamp: number | null): string => {
|
||||
if (timestamp === null || !Number.isFinite(timestamp)) {
|
||||
return 'a l instant'
|
||||
}
|
||||
const diff = Date.now() - timestamp
|
||||
if (diff < 60_000) {
|
||||
return 'a l instant'
|
||||
}
|
||||
const minutes = Math.floor(diff / 60_000)
|
||||
if (minutes < 60) {
|
||||
return `il y a ${minutes} min`
|
||||
}
|
||||
const hours = Math.floor(diff / 3_600_000)
|
||||
if (hours < 24) {
|
||||
return `il y a ${hours} h`
|
||||
}
|
||||
const days = Math.floor(diff / 86_400_000)
|
||||
return `il y a ${days} j`
|
||||
}
|
||||
|
||||
const cardClasses = computed(() => ({
|
||||
'product-card': true,
|
||||
'product-card--accent': props.product.delta < 0,
|
||||
}))
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
return props.product.imageJpg || props.product.imageWebp || props.placeholderImage
|
||||
})
|
||||
|
||||
const hasImage = computed(() => {
|
||||
return Boolean(props.product.imageWebp || props.product.imageJpg)
|
||||
})
|
||||
|
||||
const lastUpdateLabel = computed(() => {
|
||||
const timestamp = props.historyData?.lastTimestamp
|
||||
if (timestamp) {
|
||||
return formatRelativeTimeAgo(timestamp)
|
||||
}
|
||||
if (props.product.updatedAt) {
|
||||
const parsed = Date.parse(props.product.updatedAt)
|
||||
return Number.isNaN(parsed) ? props.product.updatedAt : formatRelativeTimeAgo(parsed)
|
||||
}
|
||||
return '—'
|
||||
})
|
||||
|
||||
const historyDeltaLabel = computed(() => {
|
||||
const value = props.historyData?.delta
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) {
|
||||
return '—'
|
||||
}
|
||||
const numeric = Number(value)
|
||||
const sign = numeric >= 0 ? '+' : ''
|
||||
return `${sign}${numeric.toFixed(1)}%`
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
emit('click')
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
|
||||
function handleHover(event: MouseEvent | FocusEvent) {
|
||||
emit('hover', event)
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
emit('leave')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
:class="cardClasses"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleClick"
|
||||
@keydown="handleKeydown"
|
||||
@mouseenter="handleHover"
|
||||
@mouseleave="handleLeave"
|
||||
@focusin="handleHover"
|
||||
@focusout="handleLeave"
|
||||
>
|
||||
<div class="product-card__top">
|
||||
<div class="product-card__identity">
|
||||
<div class="product-card__store-icon">
|
||||
<img v-if="storeLogo" :src="storeLogo" alt="" />
|
||||
<span v-else class="product-card__store-initials">{{ storeInitials }}</span>
|
||||
</div>
|
||||
<div class="product-card__identity-text">
|
||||
<h3 class="product-card__title" :title="product.title">
|
||||
{{ product.title }}
|
||||
</h3>
|
||||
<div class="product-card__store-name">{{ storeLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-card__reference">
|
||||
{{ product.reference || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-card__layout">
|
||||
<!-- Colonne gauche -->
|
||||
<div class="product-card__left">
|
||||
<div class="product-card__thumbnail">
|
||||
<picture v-if="hasImage">
|
||||
<source v-if="product.imageWebp" :srcset="product.imageWebp" type="image/webp" />
|
||||
<source v-if="product.imageJpg" :srcset="product.imageJpg" type="image/jpeg" />
|
||||
<img
|
||||
:src="imageUrl"
|
||||
class="product-card__image product-card__image--contain"
|
||||
alt="Image produit"
|
||||
loading="lazy"
|
||||
/>
|
||||
</picture>
|
||||
<img
|
||||
v-else
|
||||
:src="placeholderImage"
|
||||
class="product-card__image product-card__image--contain"
|
||||
alt="Image indisponible"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone centrale -->
|
||||
<div class="product-card__main">
|
||||
<PriceBlock
|
||||
:price="product.price"
|
||||
:currency="product.currency"
|
||||
:msrp="product.msrp"
|
||||
:discount-amount="product.discountAmount"
|
||||
:discount-percent="product.discountPercent"
|
||||
:discount-text="product.discountText"
|
||||
:delta-label="historyDeltaLabel"
|
||||
:delta-label-title="`Evol. ${chartPeriodLabel}`"
|
||||
:stock-status="product.stockStatus"
|
||||
:stock-text="product.stockText"
|
||||
:in-stock="product.inStock"
|
||||
:reference="product.reference"
|
||||
:url="product.url"
|
||||
:rating-value="product.ratingValue"
|
||||
:rating-count="product.ratingCount"
|
||||
:amazon-choice="product.amazonChoice"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zone basse -->
|
||||
<div class="product-card__history-zone">
|
||||
<div class="product-card__chart-container" :style="{ color: historyData?.trendColor || 'var(--muted)' }">
|
||||
<MiniLineChart
|
||||
v-if="historyData && historyData.points.length > 0"
|
||||
:points="historyData.points"
|
||||
:height="140"
|
||||
:formatY="(value: number) => formatShortPrice(value, product.currency)"
|
||||
:formatX="formatHistoryDateLabel"
|
||||
:yTicks="3"
|
||||
:xTicks="3"
|
||||
/>
|
||||
<div v-else class="product-card__no-history">
|
||||
Pas d'historique
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-card__history-stats">
|
||||
<div class="product-card__stat">
|
||||
<span class="product-card__stat-label">Min</span>
|
||||
<span class="product-card__stat-value">
|
||||
{{ historyData && historyData.min !== null ? formatShortPrice(historyData.min, product.currency) : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="product-card__stat">
|
||||
<span class="product-card__stat-label">Max</span>
|
||||
<span class="product-card__stat-value">
|
||||
{{ historyData && historyData.max !== null ? formatShortPrice(historyData.max, product.currency) : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="product-card__stat">
|
||||
<span class="product-card__stat-label">Tendance</span>
|
||||
<span
|
||||
class="product-card__stat-value product-card__trend"
|
||||
:style="{ color: historyData?.trendColor || 'var(--muted)' }"
|
||||
>
|
||||
{{ historyData?.trendIcon || '→' }} {{ historyData?.trendLabel || '—' }}
|
||||
<span class="product-card__trend-delta">{{ historyData?.trendDeltaLabel || '—' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="product-card__stat product-card__stat--update">
|
||||
<span class="product-card__stat-label">Dernier scrap</span>
|
||||
<span class="product-card__stat-value">
|
||||
{{ lastUpdateLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-card__footer">
|
||||
<div class="product-card__meta">
|
||||
<span>Categorie: {{ product.category || '—' }}</span>
|
||||
<span>Type: {{ product.type || '—' }}</span>
|
||||
</div>
|
||||
<CardActions
|
||||
:product-id="product.id"
|
||||
:compare-ids="compareIds"
|
||||
:show-secondary="false"
|
||||
@refresh="emit('refresh')"
|
||||
@compare="emit('compare')"
|
||||
@edit="emit('edit')"
|
||||
@delete="emit('delete')"
|
||||
@open="emit('open')"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 14px 26px var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.product-card:hover,
|
||||
.product-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 16px 32px var(--shadow);
|
||||
}
|
||||
|
||||
.product-card--accent {
|
||||
border-color: rgba(254, 128, 25, 0.5);
|
||||
box-shadow: 0 10px 30px rgba(254, 128, 25, 0.15);
|
||||
}
|
||||
|
||||
/* Header: Identity */
|
||||
.product-card__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-card__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.product-card__reference {
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-card__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-card__identity {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.product-card__store-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--surface-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-card__store-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.product-card__store-initials {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.product-card__identity-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-card__title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.product-card__store-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Thumbnail */
|
||||
.product-card__thumbnail {
|
||||
width: 100%;
|
||||
height: var(--pw-card-media-height, 140px);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.04), rgba(0, 0, 0, 0.15));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.product-card__image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.product-card__image--contain {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.product-card__image--cover {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Main Zone */
|
||||
.product-card__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: var(--pw-card-media-height, 140px);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.product-card__main :deep(.price-block) {
|
||||
max-width: 260px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* History Zone */
|
||||
.product-card__history-zone {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 14px;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.product-card__chart-container {
|
||||
height: 140px;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-card__no-history {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.product-card__history-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.product-card__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.product-card__stat-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.product-card__stat-value {
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.product-card__trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.product-card__trend-delta {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.product-card__stat--update {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.product-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.product-card__meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.product-card__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.product-card__top {
|
||||
flex-direction: column;
|
||||
}
|
||||
.product-card__reference {
|
||||
text-align: left;
|
||||
}
|
||||
.product-card__footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.product-card__history-zone {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
200
webui/src/components/ProductSummary.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
notes?: string | null
|
||||
analysis?: string | null
|
||||
editable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:notes', value: string): void
|
||||
}>()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const editedNotes = ref(props.notes || '')
|
||||
|
||||
const hasContent = computed(() => {
|
||||
return Boolean(props.notes?.trim() || props.analysis?.trim())
|
||||
})
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (props.notes?.trim()) {
|
||||
return props.notes
|
||||
}
|
||||
if (props.analysis?.trim()) {
|
||||
return props.analysis
|
||||
}
|
||||
return 'Aucune note'
|
||||
})
|
||||
|
||||
function startEdit() {
|
||||
if (!props.editable) return
|
||||
editedNotes.value = props.notes || ''
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
emit('update:notes', editedNotes.value)
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editedNotes.value = props.notes || ''
|
||||
isEditing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-summary" :class="{ 'product-summary--empty': !hasContent }">
|
||||
<div class="product-summary__header">
|
||||
<span class="product-summary__label">Résumé</span>
|
||||
<button
|
||||
v-if="editable && !isEditing"
|
||||
class="product-summary__edit-btn"
|
||||
title="Modifier"
|
||||
@click="startEdit"
|
||||
>
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" class="product-summary__edit">
|
||||
<textarea
|
||||
v-model="editedNotes"
|
||||
class="product-summary__textarea"
|
||||
placeholder="Ajouter des notes..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="product-summary__edit-actions">
|
||||
<button class="product-summary__action-btn" @click="saveEdit">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button class="product-summary__action-btn product-summary__action-btn--cancel" @click="cancelEdit">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="product-summary__content">
|
||||
{{ displayText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-summary {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.product-summary--empty {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.product-summary__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.product-summary__label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.product-summary__edit-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.product-summary__edit-btn:hover {
|
||||
background: var(--accent);
|
||||
color: #1b1b1b;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.product-summary__content {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.product-summary__edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-summary__textarea {
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.product-summary__textarea:focus {
|
||||
outline: 2px solid rgba(254, 128, 25, 0.4);
|
||||
}
|
||||
|
||||
.product-summary__edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.product-summary__action-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: #1b1b1b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.product-summary__action-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.product-summary__action-btn--cancel {
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.product-summary__action-btn--cancel:hover {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,8 @@
|
||||
--pw-store-icon: 40px;
|
||||
--pw-card-height-factor: 1;
|
||||
--pw-card-mobile-height-factor: 1;
|
||||
--pw-card-media-height: 160px;
|
||||
--pw-card-media-height: 140px;
|
||||
--pw-card-columns: 3;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
@@ -138,14 +139,12 @@
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 16px 32px var(--shadow);
|
||||
box-shadow: 0 12px 24px var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: calc(470px * var(--pw-card-height-factor, 1));
|
||||
padding: 24px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.card-thumbnail {
|
||||
@@ -403,6 +402,25 @@
|
||||
box-shadow: 0 10px 30px rgba(254, 128, 25, 0.2);
|
||||
}
|
||||
|
||||
/* Stock status colors */
|
||||
.status-in_stock,
|
||||
.status-in-stock {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-out_of_stock,
|
||||
.status-out-of-stock {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-unknown {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.density-dense .card {
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -616,6 +634,36 @@
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.add-product-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-product-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.add-product-modal__body {
|
||||
padding: 16px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.add-product-modal__footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 24px 18px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--surface);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.image-toggle {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
@@ -636,6 +684,44 @@
|
||||
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.add-product-carousel {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(88px, 1fr);
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
.add-product-thumb {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
scroll-snap-align: start;
|
||||
cursor: pointer;
|
||||
transition: border 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.add-product-thumb:hover {
|
||||
border-color: rgba(254, 128, 25, 0.6);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.add-product-thumb.selected {
|
||||
border-color: rgba(254, 128, 25, 0.9);
|
||||
box-shadow: 0 6px 16px rgba(254, 128, 25, 0.2);
|
||||
}
|
||||
|
||||
.add-product-thumb__image {
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.log-status-panel {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -777,8 +863,8 @@
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 32px;
|
||||
grid-template-columns: repeat(var(--pw-card-columns, 3), minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -790,6 +876,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.product-grid {
|
||||
--pw-card-columns: min(var(--pw-card-columns, 3), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.product-grid {
|
||||
--pw-card-columns: min(var(--pw-card-columns, 3), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-header .toolbar-text {
|
||||
display: none;
|
||||
@@ -800,6 +898,7 @@
|
||||
}
|
||||
.product-grid {
|
||||
grid-template-columns: 1fr;
|
||||
--pw-card-columns: 1;
|
||||
}
|
||||
.card {
|
||||
min-height: calc(470px * var(--pw-card-mobile-height-factor, 1));
|
||||
|
||||
@@ -18,12 +18,23 @@ export interface Product {
|
||||
id: number
|
||||
source: string
|
||||
reference: string
|
||||
asin: string | null
|
||||
url: string
|
||||
title: string | null
|
||||
category: string | null
|
||||
type: string | null
|
||||
description: string | null
|
||||
currency: string | null
|
||||
msrp: number | null
|
||||
rating_value: number | null
|
||||
rating_count: number | null
|
||||
amazon_choice: boolean | null
|
||||
amazon_choice_label: string | null
|
||||
discount_text: string | null
|
||||
stock_text: string | null
|
||||
in_stock: boolean | null
|
||||
model_number: string | null
|
||||
model_name: string | null
|
||||
first_seen_at: string
|
||||
last_updated_at: string
|
||||
latest_price: number | null
|
||||
@@ -31,6 +42,8 @@ export interface Product {
|
||||
latest_stock_status: StockStatus | null
|
||||
latest_fetched_at: string | null
|
||||
images: string[]
|
||||
main_image: string | null
|
||||
gallery_images: string[]
|
||||
specs: Record<string, string>
|
||||
discount_amount: number | null
|
||||
discount_percent: number | null
|
||||
@@ -43,6 +56,7 @@ export interface ProductCreate {
|
||||
url: string
|
||||
title?: string | null
|
||||
category?: string | null
|
||||
type?: string | null
|
||||
description?: string | null
|
||||
currency?: string | null
|
||||
msrp?: number | null
|
||||
@@ -52,6 +66,7 @@ export interface ProductUpdate {
|
||||
url?: string | null
|
||||
title?: string | null
|
||||
category?: string | null
|
||||
type?: string | null
|
||||
description?: string | null
|
||||
currency?: string | null
|
||||
msrp?: number | null
|
||||
@@ -188,10 +203,23 @@ export interface ProductSnapshot {
|
||||
currency: string | null
|
||||
shipping_cost: number | null
|
||||
stock_status: StockStatus | null
|
||||
stock_text: string | null
|
||||
in_stock: boolean | null
|
||||
reference: string | null
|
||||
asin: string | null
|
||||
category: string | null
|
||||
type: string | null
|
||||
description: string | null
|
||||
rating_value: number | null
|
||||
rating_count: number | null
|
||||
amazon_choice: boolean | null
|
||||
amazon_choice_label: string | null
|
||||
discount_text: string | null
|
||||
model_number: string | null
|
||||
model_name: string | null
|
||||
images: string[]
|
||||
main_image: string | null
|
||||
gallery_images: string[]
|
||||
specs: Record<string, string>
|
||||
msrp: number | null
|
||||
debug: DebugInfo
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface FilterChip {
|
||||
// === Settings ===
|
||||
export interface AppSettings {
|
||||
cardRatio: number
|
||||
cardColumns: number
|
||||
imageHeight: number
|
||||
imageMode: ImageMode
|
||||
fontSize: number
|
||||
@@ -87,12 +88,12 @@ export interface ProductMeta {
|
||||
export type ProductMetaMap = Record<number, ProductMeta>
|
||||
|
||||
// === Scrape Log ===
|
||||
export type LogLevel = 'debug' | 'info' | 'success' | 'warning' | 'error'
|
||||
export type ScrapeLogLevel = 'debug' | 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
export interface ScrapeLogEntry {
|
||||
id: number
|
||||
time: string
|
||||
level: LogLevel
|
||||
level: ScrapeLogLevel
|
||||
text: string
|
||||
}
|
||||
|
||||
@@ -152,7 +153,7 @@ export type LogTab = 'frontend' | 'backend' | 'uvicorn'
|
||||
export interface FrontendLog {
|
||||
id: number
|
||||
time: string
|
||||
level: LogLevel
|
||||
level: ScrapeLogLevel
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -164,6 +165,9 @@ export interface CardRatioPreset {
|
||||
|
||||
// === Constants (exportés pour réutilisation) ===
|
||||
export const DEFAULT_CARD_RATIO = 1
|
||||
export const DEFAULT_CARD_COLUMNS = 3
|
||||
export const MIN_CARD_COLUMNS = 1
|
||||
export const MAX_CARD_COLUMNS = 6
|
||||
export const DEFAULT_IMAGE_HEIGHT = 160
|
||||
export const CARD_HISTORY_LIMIT = 12
|
||||
export const DEFAULT_LOG_DURATION = 2500
|
||||
@@ -178,7 +182,7 @@ export const CARD_RATIO_PRESETS: CardRatioPreset[] = [
|
||||
|
||||
export const IMAGE_MODES: ImageMode[] = ['contain', 'cover']
|
||||
|
||||
export const LOG_ICONS: Record<LogLevel, string> = {
|
||||
export const LOG_ICONS: Record<ScrapeLogLevel, string> = {
|
||||
debug: '🔍',
|
||||
info: 'ℹ️',
|
||||
success: '✅',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
|
||||