feat: listing detail page with trust score ring, signal breakdown, seller panel
Replaces the Coming Soon placeholder. Clicking Details on any card opens a full trust breakdown view: - SVG score ring with composite score and colour-coded verdict label - Auto-generated verdict text (identifies worst signals in plain English) - Signal table with mini-bars: Feedback Volume/Ratio, Account Age, Price vs Market, Category History — pending state shown for unresolved - Red flag badges (hard vs soft) above the score ring - Photo carousel with prev/next controls and img-error skip - Seller panel (feedback count/ratio, account age, pending enrichment note) - Block seller inline form wired to POST /api/blocklist - Triple Red pulsing border easter egg carried over from card - Not-found state for direct URL access (store cleared on refresh) - Responsive: single-column layout on ≤640px - ListingCard: adds Details RouterLink to price column - search store: adds getListing(id) lookup helper
This commit is contained in:
parent
29922ede47
commit
c90061733c
3 changed files with 827 additions and 30 deletions
|
|
@ -174,6 +174,12 @@
|
|||
<span v-if="marketPrice" class="card__market-price" title="Median market price">
|
||||
market ~{{ formattedMarket }}
|
||||
</span>
|
||||
<RouterLink
|
||||
:to="`/listing/${listing.platform_listing_id}`"
|
||||
class="card__detail-link"
|
||||
:aria-label="`View trust breakdown for: ${listing.title}`"
|
||||
@click.stop
|
||||
>Details</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -181,6 +187,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Listing, TrustScore, Seller } from '../stores/search'
|
||||
import { useSearchStore } from '../stores/search'
|
||||
import { useBlocklistStore } from '../stores/blocklist'
|
||||
|
|
@ -733,6 +740,16 @@ const formattedMarket = computed(() => {
|
|||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.card__detail-link {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--app-primary);
|
||||
text-decoration: none;
|
||||
margin-top: var(--space-1);
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.card__detail-link:hover { opacity: 0.75; }
|
||||
|
||||
/* ── Triple Red easter egg ──────────────────────────────────────────────── */
|
||||
/* Fires when: (new_account | account_under_30d) + suspicious_price + hard flag */
|
||||
.listing-card--triple-red {
|
||||
|
|
|
|||
|
|
@ -312,6 +312,10 @@ export const useSearchStore = defineStore('search', () => {
|
|||
}
|
||||
}
|
||||
|
||||
function getListing(platformListingId: string): Listing | undefined {
|
||||
return results.value.find(l => l.platform_listing_id === platformListingId)
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
results,
|
||||
|
|
@ -330,5 +334,6 @@ export const useSearchStore = defineStore('search', () => {
|
|||
closeUpdates,
|
||||
clearResults,
|
||||
populateFromLLM,
|
||||
getListing,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,55 +1,830 @@
|
|||
<template>
|
||||
<div class="listing-view">
|
||||
<div class="placeholder">
|
||||
<span class="placeholder__icon" aria-hidden="true">🎯</span>
|
||||
<h1 class="placeholder__title">Listing Detail</h1>
|
||||
<p class="placeholder__body">Coming soon — full listing detail view with trust score breakdown, photo analysis, and seller history.</p>
|
||||
<RouterLink to="/" class="placeholder__back">← Back to Search</RouterLink>
|
||||
|
||||
<!-- Not found — store was cleared (page refresh or direct URL) -->
|
||||
<div v-if="!listing" class="lv-empty">
|
||||
<span class="lv-empty__icon" aria-hidden="true">🎯</span>
|
||||
<h1 class="lv-empty__title">Listing not found</h1>
|
||||
<p class="lv-empty__body">
|
||||
Search results are held in session memory.
|
||||
Return to search and click a listing to view its trust breakdown.
|
||||
</p>
|
||||
<RouterLink to="/" class="lv-empty__back">← Back to Search</RouterLink>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Back link -->
|
||||
<RouterLink to="/" class="lv-back">← Back to results</RouterLink>
|
||||
|
||||
<div class="lv-layout" :class="{ 'lv-layout--triple-red': tripleRed }">
|
||||
|
||||
<!-- Photo carousel -->
|
||||
<section class="lv-photos" aria-label="Listing photos">
|
||||
<div v-if="listing.photo_urls.length" class="lv-carousel">
|
||||
<img
|
||||
:src="listing.photo_urls[photoIdx]"
|
||||
:alt="`Photo ${photoIdx + 1} of ${listing.photo_urls.length}: ${listing.title}`"
|
||||
class="lv-carousel__img"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<div v-if="listing.photo_urls.length > 1" class="lv-carousel__controls">
|
||||
<button
|
||||
class="lv-carousel__btn"
|
||||
aria-label="Previous photo"
|
||||
:disabled="photoIdx === 0"
|
||||
@click="photoIdx--"
|
||||
>‹</button>
|
||||
<span class="lv-carousel__counter">{{ photoIdx + 1 }} / {{ listing.photo_urls.length }}</span>
|
||||
<button
|
||||
class="lv-carousel__btn"
|
||||
aria-label="Next photo"
|
||||
:disabled="photoIdx === listing.photo_urls.length - 1"
|
||||
@click="photoIdx++"
|
||||
>›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="lv-carousel lv-carousel--empty" aria-hidden="true">📷</div>
|
||||
</section>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="lv-content">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="lv-header">
|
||||
<h1 class="lv-title">{{ listing.title }}</h1>
|
||||
<div class="lv-price-row">
|
||||
<span class="lv-price">{{ formattedPrice }}</span>
|
||||
<span v-if="store.marketPrice" class="lv-market">
|
||||
market ~{{ formattedMarket }}
|
||||
</span>
|
||||
<span v-if="isSteal" class="lv-steal-badge">🎯 Potential steal</span>
|
||||
</div>
|
||||
<div class="lv-badges">
|
||||
<span class="lv-badge">{{ conditionLabel }}</span>
|
||||
<span class="lv-badge">{{ formatLabel }}</span>
|
||||
<span v-if="listing.category_name" class="lv-badge">{{ listing.category_name }}</span>
|
||||
<span v-if="isAuction && listing.ends_at" class="lv-badge lv-badge--auction">
|
||||
⏱ Ends {{ auctionEnds }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Red flags -->
|
||||
<div v-if="redFlags.length" class="lv-flags" role="list" aria-label="Risk flags">
|
||||
<span
|
||||
v-for="flag in redFlags"
|
||||
:key="flag"
|
||||
class="lv-flag"
|
||||
:class="hardFlags.has(flag) ? 'lv-flag--hard' : 'lv-flag--soft'"
|
||||
role="listitem"
|
||||
>{{ flagLabel(flag) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Trust score: ring + verdict + signal table -->
|
||||
<section class="lv-trust" aria-labelledby="trust-heading">
|
||||
<h2 id="trust-heading" class="lv-section-heading">Trust Score</h2>
|
||||
|
||||
<div class="lv-ring-row">
|
||||
<!-- SVG ring -->
|
||||
<div class="lv-ring" :class="ringClass" role="img" :aria-label="`Trust score: ${scoreDisplay} out of 100`">
|
||||
<svg width="88" height="88" viewBox="0 0 88 88" aria-hidden="true">
|
||||
<circle cx="44" cy="44" r="36" fill="none" stroke="var(--ring-track)" stroke-width="8"/>
|
||||
<circle
|
||||
v-if="trust && trust.composite_score != null"
|
||||
cx="44" cy="44" r="36"
|
||||
fill="none"
|
||||
:stroke="ringColor"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
:stroke-dasharray="`${ringFill} 226.2`"
|
||||
transform="rotate(-90 44 44)"
|
||||
/>
|
||||
</svg>
|
||||
<div class="lv-ring__center">
|
||||
<span class="lv-ring__score" :style="{ color: ringColor }">{{ scoreDisplay }}</span>
|
||||
<span class="lv-ring__denom">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verdict -->
|
||||
<div class="lv-verdict">
|
||||
<p class="lv-verdict__label" :style="{ color: ringColor }">{{ verdictLabel }}</p>
|
||||
<p class="lv-verdict__text">{{ verdictText }}</p>
|
||||
<p v-if="trust?.score_is_partial" class="lv-verdict__partial">
|
||||
↻ Some signals are still loading — run again to update
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signal table -->
|
||||
<table class="lv-signals" aria-label="Trust signal breakdown">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="lv-signals__col-name">Signal</th>
|
||||
<th scope="col" class="lv-signals__col-bar" aria-hidden="true"></th>
|
||||
<th scope="col" class="lv-signals__col-score">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="sig in signals" :key="sig.key" class="lv-signals__row">
|
||||
<td class="lv-signals__name">{{ sig.label }}</td>
|
||||
<td class="lv-signals__bar" aria-hidden="true">
|
||||
<div class="lv-mini-bar">
|
||||
<div
|
||||
class="lv-mini-bar__fill"
|
||||
:class="sig.pending ? 'lv-mini-bar__fill--pending' : barClass(sig.score)"
|
||||
:style="{ width: sig.pending ? '8%' : `${(sig.score / 20) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="lv-signals__score">
|
||||
<span v-if="sig.pending" class="lv-sig-pending">↻ pending</span>
|
||||
<span v-else>{{ sig.score }} / 20</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Seller panel -->
|
||||
<section v-if="seller" class="lv-seller" aria-label="Seller information">
|
||||
<h2 class="lv-section-heading">Seller</h2>
|
||||
<div class="lv-seller__panel">
|
||||
<div class="lv-seller__avatar" aria-hidden="true">👤</div>
|
||||
<div class="lv-seller__info">
|
||||
<p class="lv-seller__name">{{ seller.username }}</p>
|
||||
<p class="lv-seller__stats">
|
||||
{{ seller.feedback_count.toLocaleString() }} feedback
|
||||
· {{ (seller.feedback_ratio * 100).toFixed(1) }}% positive
|
||||
</p>
|
||||
<p class="lv-seller__age" v-if="seller.account_age_days != null">
|
||||
Account age: {{ accountAgeLabel }}
|
||||
</p>
|
||||
<p class="lv-seller__age lv-seller__age--unknown" v-else>
|
||||
Account age: pending enrichment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="lv-actions">
|
||||
<a
|
||||
:href="listing.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="lv-btn-primary"
|
||||
>↗ View on eBay</a>
|
||||
<button
|
||||
v-if="seller"
|
||||
class="lv-btn-secondary"
|
||||
type="button"
|
||||
@click="blockingOpen = !blockingOpen"
|
||||
>⊘ Block seller</button>
|
||||
</div>
|
||||
|
||||
<!-- Block seller inline form -->
|
||||
<div v-if="blockingOpen && seller" class="lv-block-form" role="dialog" aria-label="Block seller">
|
||||
<p class="lv-block-form__title">Block <strong>{{ seller.username }}</strong>?</p>
|
||||
<input
|
||||
v-model="blockReason"
|
||||
class="lv-block-form__input"
|
||||
placeholder="Reason (optional)"
|
||||
aria-label="Reason for blocking seller (optional)"
|
||||
maxlength="200"
|
||||
@keydown.enter="onBlock"
|
||||
@keydown.escape="blockingOpen = false"
|
||||
/>
|
||||
<div class="lv-block-form__btns">
|
||||
<button class="lv-block-form__confirm" type="button" @click="onBlock">Block</button>
|
||||
<button class="lv-block-form__cancel" type="button" @click="blockingOpen = false; blockReason = ''">Cancel</button>
|
||||
</div>
|
||||
<p v-if="blockError" class="lv-block-form__error" role="alert">{{ blockError }}</p>
|
||||
</div>
|
||||
|
||||
</div><!-- /lv-content -->
|
||||
</div><!-- /lv-layout -->
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import { useSearchStore } from '../stores/search'
|
||||
|
||||
const RING_CIRCUMFERENCE = 226.2 // 2π × r=36
|
||||
|
||||
const route = useRoute()
|
||||
const store = useSearchStore()
|
||||
const id = route.params.id as string
|
||||
|
||||
const listing = computed(() => store.getListing(id))
|
||||
const trust = computed(() => store.trustScores.get(id) ?? null)
|
||||
const seller = computed(() => {
|
||||
if (!listing.value) return null
|
||||
return store.sellers.get(listing.value.seller_platform_id) ?? null
|
||||
})
|
||||
|
||||
// ── Photo carousel ───────────────────────────────────────────────────────────
|
||||
|
||||
const photoIdx = ref(0)
|
||||
|
||||
function onImgError() {
|
||||
// Skip broken photos by advancing to next; if at end, go back
|
||||
const max = (listing.value?.photo_urls.length ?? 1) - 1
|
||||
if (photoIdx.value < max) photoIdx.value++
|
||||
else if (photoIdx.value > 0) photoIdx.value--
|
||||
}
|
||||
|
||||
// ── Price / format helpers ───────────────────────────────────────────────────
|
||||
|
||||
const formattedPrice = computed(() => {
|
||||
if (!listing.value) return ''
|
||||
const sym = listing.value.currency === 'USD' ? '$' : listing.value.currency + ' '
|
||||
return `${sym}${listing.value.price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||
})
|
||||
|
||||
const formattedMarket = computed(() =>
|
||||
store.marketPrice
|
||||
? `$${store.marketPrice.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
|
||||
: ''
|
||||
)
|
||||
|
||||
const isSteal = computed(() => {
|
||||
const s = trust.value?.composite_score
|
||||
if (!s || s < 80 || !store.marketPrice || !listing.value) return false
|
||||
return listing.value.price < store.marketPrice * 0.8
|
||||
})
|
||||
|
||||
const isAuction = computed(() => listing.value?.buying_format === 'auction')
|
||||
|
||||
const auctionEnds = computed(() => {
|
||||
const end = listing.value?.ends_at
|
||||
if (!end) return ''
|
||||
return new Date(end).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
})
|
||||
|
||||
const conditionLabel = computed(() => {
|
||||
const c = listing.value?.condition ?? ''
|
||||
const map: Record<string, string> = {
|
||||
new: 'New',
|
||||
open_box: 'Open Box',
|
||||
used_excellent:'Used – Excellent',
|
||||
used_good: 'Used – Good',
|
||||
used_fair: 'Used – Fair',
|
||||
for_parts: 'For Parts',
|
||||
}
|
||||
return map[c] ?? c
|
||||
})
|
||||
|
||||
const formatLabel = computed(() => {
|
||||
const f = listing.value?.buying_format ?? 'fixed_price'
|
||||
if (f === 'auction') return 'Auction'
|
||||
if (f === 'best_offer') return 'Best Offer'
|
||||
return 'Fixed Price'
|
||||
})
|
||||
|
||||
// ── Red flags ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FLAG_LABELS: Record<string, string> = {
|
||||
new_account: '✗ New account',
|
||||
account_under_30_days: '⚠ Account <30d',
|
||||
low_feedback_count: '⚠ Low feedback',
|
||||
suspicious_price: '✗ Suspicious price',
|
||||
duplicate_photo: '✗ Duplicate photo',
|
||||
established_bad_actor: '✗ Known bad actor',
|
||||
zero_feedback: '✗ No feedback',
|
||||
marketing_photo: '✗ Marketing photo',
|
||||
scratch_dent_mentioned: '⚠ Damage mentioned',
|
||||
long_on_market: '⚠ Long on market',
|
||||
significant_price_drop: '⚠ Price dropped',
|
||||
}
|
||||
|
||||
const HARD_FLAGS = new Set([
|
||||
'new_account', 'established_bad_actor', 'zero_feedback', 'suspicious_price', 'duplicate_photo',
|
||||
])
|
||||
|
||||
const hardFlags = HARD_FLAGS
|
||||
|
||||
function flagLabel(flag: string): string {
|
||||
return FLAG_LABELS[flag] ?? `⚠ ${flag}`
|
||||
}
|
||||
|
||||
const redFlags = computed<string[]>(() => {
|
||||
try { return JSON.parse(trust.value?.red_flags_json ?? '[]') } catch { return [] }
|
||||
})
|
||||
|
||||
// ── Score ring ───────────────────────────────────────────────────────────────
|
||||
|
||||
const scoreDisplay = computed(() => trust.value?.composite_score ?? '?')
|
||||
|
||||
const ringColor = computed(() => {
|
||||
const s = trust.value?.composite_score
|
||||
if (s == null) return 'var(--color-text-muted)'
|
||||
if (s >= 80) return 'var(--trust-high)'
|
||||
if (s >= 50) return 'var(--trust-mid)'
|
||||
return 'var(--trust-low)'
|
||||
})
|
||||
|
||||
const ringClass = computed(() => {
|
||||
const s = trust.value?.composite_score
|
||||
if (s == null) return 'lv-ring--unknown'
|
||||
if (s >= 80) return 'lv-ring--high'
|
||||
if (s >= 50) return 'lv-ring--mid'
|
||||
return 'lv-ring--low'
|
||||
})
|
||||
|
||||
const ringFill = computed(() => {
|
||||
const s = trust.value?.composite_score
|
||||
if (s == null) return 0
|
||||
return (s / 100) * RING_CIRCUMFERENCE
|
||||
})
|
||||
|
||||
// ── Verdict ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const verdictLabel = computed(() => {
|
||||
const s = trust.value?.composite_score
|
||||
if (s == null) return 'Unscored'
|
||||
if (s >= 80) return 'Looks trustworthy'
|
||||
if (s >= 50) return 'Moderate risk'
|
||||
return 'High risk'
|
||||
})
|
||||
|
||||
const verdictText = computed(() => {
|
||||
const s = trust.value?.composite_score
|
||||
const f = new Set(redFlags.value)
|
||||
const sel = seller.value
|
||||
|
||||
if (s == null) return 'No trust data available for this listing.'
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (f.has('established_bad_actor')) return 'This seller is on your blocklist. Do not proceed.'
|
||||
if (f.has('zero_feedback')) parts.push('seller has no feedback history')
|
||||
if (f.has('new_account')) parts.push('account is less than a week old')
|
||||
else if (f.has('account_under_30_days') && sel)
|
||||
parts.push(`account is only ${sel.account_age_days} days old`)
|
||||
if (f.has('suspicious_price')) parts.push('price is suspiciously below market')
|
||||
else if (trust.value?.price_vs_market_score === 0 && store.marketPrice)
|
||||
parts.push('price is above the market median')
|
||||
if (f.has('duplicate_photo')) parts.push('photo appears in other listings')
|
||||
if (f.has('scratch_dent_mentioned')) parts.push('title mentions damage or wear')
|
||||
if (f.has('long_on_market')) parts.push('listing has been sitting for a while')
|
||||
if (f.has('significant_price_drop')) parts.push('price has dropped significantly since first seen')
|
||||
|
||||
if (s >= 80 && parts.length === 0)
|
||||
return 'Strong seller history and clean signals across the board.'
|
||||
if (parts.length === 0)
|
||||
return 'No specific red flags, but some signals are weak or pending.'
|
||||
|
||||
const list = parts.map((p, i) => (i === 0 ? p[0].toUpperCase() + p.slice(1) : p))
|
||||
return list.join(', ') + '.'
|
||||
})
|
||||
|
||||
// ── Signal table ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface Signal { key: string; label: string; score: number; pending: boolean }
|
||||
|
||||
const signals = computed<Signal[]>(() => {
|
||||
const t = trust.value
|
||||
const sel = seller.value
|
||||
return [
|
||||
{
|
||||
key: 'feedback_count', label: 'Feedback Volume',
|
||||
score: t?.feedback_count_score ?? 0,
|
||||
pending: false,
|
||||
},
|
||||
{
|
||||
key: 'feedback_ratio', label: 'Feedback Ratio',
|
||||
score: t?.feedback_ratio_score ?? 0,
|
||||
pending: false,
|
||||
},
|
||||
{
|
||||
key: 'account_age', label: 'Account Age',
|
||||
score: t?.account_age_score ?? 0,
|
||||
pending: sel?.account_age_days == null,
|
||||
},
|
||||
{
|
||||
key: 'price_vs_market', label: 'Price vs Market',
|
||||
score: t?.price_vs_market_score ?? 0,
|
||||
pending: store.marketPrice == null,
|
||||
},
|
||||
{
|
||||
key: 'category_history', label: 'Category History',
|
||||
score: t?.category_history_score ?? 0,
|
||||
pending: !sel || sel.category_history_json === '{}',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function barClass(score: number): string {
|
||||
if (score >= 16) return 'lv-mini-bar__fill--high'
|
||||
if (score >= 8) return 'lv-mini-bar__fill--mid'
|
||||
return 'lv-mini-bar__fill--low'
|
||||
}
|
||||
|
||||
// ── Triple Red easter egg ────────────────────────────────────────────────────
|
||||
|
||||
const tripleRed = computed(() => {
|
||||
const f = new Set(redFlags.value)
|
||||
const hasAccountFlag = f.has('new_account') || f.has('account_under_30_days')
|
||||
const hasPriceFlag = f.has('suspicious_price')
|
||||
const hasThirdFlag = f.has('duplicate_photo') || f.has('established_bad_actor') ||
|
||||
f.has('zero_feedback') || f.has('scratch_dent_mentioned')
|
||||
return hasAccountFlag && hasPriceFlag && hasThirdFlag
|
||||
})
|
||||
|
||||
// ── Seller account age label ─────────────────────────────────────────────────
|
||||
|
||||
const accountAgeLabel = computed(() => {
|
||||
const d = seller.value?.account_age_days
|
||||
if (d == null) return 'unknown'
|
||||
if (d < 30) return `${d} days`
|
||||
if (d < 365) return `${Math.floor(d / 30)} months`
|
||||
const y = Math.floor(d / 365)
|
||||
const m = Math.floor((d % 365) / 30)
|
||||
return m > 0 ? `${y}y ${m}mo` : `${y} year${y !== 1 ? 's' : ''}`
|
||||
})
|
||||
|
||||
// ── Block seller ─────────────────────────────────────────────────────────────
|
||||
|
||||
const blockingOpen = ref(false)
|
||||
const blockReason = ref('')
|
||||
const blockError = ref('')
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE ?? ''
|
||||
|
||||
async function onBlock() {
|
||||
if (!seller.value) return
|
||||
blockError.value = ''
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/blocklist`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
platform: seller.value.platform,
|
||||
platform_seller_id: seller.value.platform_seller_id,
|
||||
username: seller.value.username,
|
||||
reason: blockReason.value.trim() || null,
|
||||
source: 'manual',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
blockError.value = body.detail ?? `Error ${res.status}`
|
||||
return
|
||||
}
|
||||
blockingOpen.value = false
|
||||
blockReason.value = ''
|
||||
} catch {
|
||||
blockError.value = 'Network error — try again'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.listing-view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60dvh;
|
||||
padding: var(--space-8);
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
/* ── Empty / not-found ── */
|
||||
.lv-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
padding: var(--space-16) var(--space-8);
|
||||
}
|
||||
.lv-empty__icon { font-size: 3rem; }
|
||||
.lv-empty__title { font-family: var(--font-display); font-size: 1.5rem; color: var(--app-primary); }
|
||||
.lv-empty__body { color: var(--color-text-muted); line-height: 1.6; max-width: 400px; }
|
||||
.lv-empty__back { color: var(--app-primary); font-weight: 600; text-decoration: none; }
|
||||
.lv-empty__back:hover { opacity: 0.75; }
|
||||
|
||||
.placeholder__icon { font-size: 3rem; }
|
||||
|
||||
.placeholder__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.placeholder__body {
|
||||
/* ── Back link ── */
|
||||
.lv-back {
|
||||
display: inline-block;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.placeholder__back {
|
||||
color: var(--app-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: opacity 150ms ease;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: var(--space-4);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
.lv-back:hover { color: var(--app-primary); }
|
||||
|
||||
/* ── Layout ── */
|
||||
.lv-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: var(--space-6);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.placeholder__back:hover { opacity: 0.75; }
|
||||
/* Triple Red: pulsing red glow around the whole card */
|
||||
.lv-layout--triple-red {
|
||||
border-radius: var(--radius-lg);
|
||||
animation: triple-red-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes triple-red-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(248,81,73,0); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(248,81,73,0.25); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lv-layout--triple-red { animation: none; box-shadow: 0 0 0 2px rgba(248,81,73,0.4); }
|
||||
}
|
||||
|
||||
/* ── Photo carousel ── */
|
||||
.lv-photos { position: sticky; top: var(--space-4); }
|
||||
|
||||
.lv-carousel {
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.lv-carousel--empty {
|
||||
height: 220px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.lv-carousel__img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: contain;
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
.lv-carousel__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.lv-carousel__btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition);
|
||||
line-height: 1;
|
||||
}
|
||||
.lv-carousel__btn:hover:not(:disabled) { color: var(--app-primary); }
|
||||
.lv-carousel__btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.lv-carousel__counter { font-size: 0.75rem; color: var(--color-text-muted); font-family: var(--font-mono); }
|
||||
|
||||
/* ── Content column ── */
|
||||
.lv-content { display: flex; flex-direction: column; gap: var(--space-5); min-width: 0; }
|
||||
|
||||
/* ── Header ── */
|
||||
.lv-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text);
|
||||
margin-bottom: var(--space-2);
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
.lv-price-row { display: flex; align-items: baseline; gap: var(--space-3); flex-wrap: wrap; margin-bottom: var(--space-2); }
|
||||
.lv-price {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--app-primary);
|
||||
}
|
||||
.lv-market { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
.lv-steal-badge {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(63,185,80,0.15);
|
||||
color: var(--trust-high);
|
||||
border: 1px solid rgba(63,185,80,0.3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.lv-badges { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.lv-badge {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.lv-badge--auction { color: var(--color-warning); border-color: rgba(210,153,34,0.3); background: rgba(210,153,34,0.1); }
|
||||
|
||||
/* ── Red flags ── */
|
||||
.lv-flags { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.lv-flag {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
}
|
||||
.lv-flag--hard { background: rgba(248,81,73,0.12); color: var(--trust-low); border: 1px solid rgba(248,81,73,0.3); }
|
||||
.lv-flag--soft { background: rgba(210,153,34,0.1); color: var(--trust-mid); border: 1px solid rgba(210,153,34,0.3); }
|
||||
|
||||
/* ── Section heading ── */
|
||||
.lv-section-heading {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* ── Trust section ── */
|
||||
.lv-trust {
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── Ring row ── */
|
||||
.lv-ring-row { display: flex; align-items: center; gap: var(--space-5); margin-bottom: var(--space-4); }
|
||||
|
||||
.lv-ring {
|
||||
position: relative;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lv-ring--high { --ring-track: rgba(63,185,80,0.12); }
|
||||
.lv-ring--mid { --ring-track: rgba(210,153,34,0.12); }
|
||||
.lv-ring--low { --ring-track: rgba(248,81,73,0.12); }
|
||||
.lv-ring--unknown { --ring-track: var(--color-surface-raised); }
|
||||
|
||||
.lv-ring__center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.lv-ring__score {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.lv-ring__denom { font-size: 0.55rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.1rem; }
|
||||
|
||||
/* ── Verdict ── */
|
||||
.lv-verdict { flex: 1; }
|
||||
.lv-verdict__label { font-size: 0.9rem; font-weight: 700; margin-bottom: var(--space-1); }
|
||||
.lv-verdict__text { font-size: 0.8rem; color: var(--color-text-muted); line-height: 1.55; }
|
||||
.lv-verdict__partial { font-size: 0.72rem; color: var(--color-info); margin-top: var(--space-2); }
|
||||
|
||||
/* ── Signal table ── */
|
||||
.lv-signals { width: 100%; border-collapse: collapse; }
|
||||
.lv-signals th {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
padding: 0 var(--space-2) var(--space-2);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.lv-signals__col-score { text-align: right; }
|
||||
.lv-signals__row td { padding: var(--space-2) var(--space-2); border-bottom: 1px solid var(--color-border-light); }
|
||||
.lv-signals__row:last-child td { border-bottom: none; }
|
||||
.lv-signals__name { font-size: 0.82rem; color: var(--color-text); }
|
||||
.lv-signals__bar { width: 80px; }
|
||||
.lv-signals__score { font-family: var(--font-mono); font-size: 0.78rem; color: var(--color-text-muted); text-align: right; white-space: nowrap; }
|
||||
|
||||
.lv-mini-bar { height: 4px; background: var(--color-surface-raised); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.lv-mini-bar__fill { height: 100%; border-radius: var(--radius-full); transition: width 0.4s ease; }
|
||||
.lv-mini-bar__fill--high { background: var(--trust-high); }
|
||||
.lv-mini-bar__fill--mid { background: var(--trust-mid); }
|
||||
.lv-mini-bar__fill--low { background: var(--trust-low); }
|
||||
.lv-mini-bar__fill--pending { background: var(--color-border); }
|
||||
|
||||
.lv-sig-pending { color: var(--color-info); font-size: 0.72rem; }
|
||||
|
||||
/* ── Seller ── */
|
||||
.lv-seller__panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
.lv-seller__avatar {
|
||||
width: 40px; height: 40px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lv-seller__name { font-weight: 700; font-size: 0.9rem; color: var(--color-text); }
|
||||
.lv-seller__stats { font-size: 0.75rem; color: var(--color-text-muted); margin-top: var(--space-1); }
|
||||
.lv-seller__age { font-size: 0.72rem; color: var(--color-info); margin-top: 0.2rem; }
|
||||
.lv-seller__age--unknown { color: var(--color-text-muted); }
|
||||
|
||||
/* ── Actions ── */
|
||||
.lv-actions { display: flex; gap: var(--space-3); }
|
||||
|
||||
.lv-btn-primary {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--app-primary);
|
||||
color: var(--color-text-inverse);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.lv-btn-primary:hover { background: var(--app-primary-hover); }
|
||||
|
||||
.lv-btn-secondary {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition), color var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
.lv-btn-secondary:hover { border-color: var(--trust-low); color: var(--trust-low); }
|
||||
|
||||
/* ── Block seller form ── */
|
||||
.lv-block-form {
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.lv-block-form__title { font-size: 0.85rem; }
|
||||
.lv-block-form__input {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.lv-block-form__input:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
|
||||
.lv-block-form__btns { display: flex; gap: var(--space-2); }
|
||||
.lv-block-form__confirm {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--trust-low);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.lv-block-form__cancel {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.lv-block-form__error { font-size: 0.78rem; color: var(--trust-low); }
|
||||
|
||||
/* ── Responsive: single column on narrow ── */
|
||||
@media (max-width: 640px) {
|
||||
.lv-layout { grid-template-columns: 1fr; }
|
||||
.lv-photos { position: static; }
|
||||
.lv-carousel__img { aspect-ratio: 4/3; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue