feat(community): TrustFeedbackButtons + useTrustFeedback -- trust signal UI on ListingCard [MIT]
Files: web/src/composables/useTrustFeedback.ts, web/src/components/TrustFeedbackButtons.vue, web/src/components/ListingCard.vue - useTrustFeedback composable: FeedbackState machine (idle/sending/confirmed/disputed), fail-soft fetch, always confirms regardless of network outcome - TrustFeedbackButtons.vue: "This score looks right / This score is wrong" button pair with calm "Thanks, noted." confirmation; uses --trust-high/--trust-low theme CSS vars; aria-live="polite", aria-busy, focus-visible, prefers-reduced-motion, no countdown timers - ListingCard.vue: TrustFeedbackButtons slotted after trust badge inside .card__score-col
This commit is contained in:
parent
303b4bfb6f
commit
5006a03603
3 changed files with 126 additions and 0 deletions
|
|
@ -128,6 +128,12 @@
|
||||||
>⚑</button>
|
>⚑</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Trust feedback: calm "looks right / wrong" signal buttons -->
|
||||||
|
<TrustFeedbackButtons
|
||||||
|
:seller-id="`ebay::${listing.seller_platform_id}`"
|
||||||
|
:trust="trust"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Price -->
|
<!-- Price -->
|
||||||
<div class="card__price-wrap">
|
<div class="card__price-wrap">
|
||||||
<span
|
<span
|
||||||
|
|
@ -152,6 +158,7 @@ import { computed, ref } from 'vue'
|
||||||
import type { Listing, TrustScore, Seller } from '../stores/search'
|
import type { Listing, TrustScore, Seller } from '../stores/search'
|
||||||
import { useSearchStore } from '../stores/search'
|
import { useSearchStore } from '../stores/search'
|
||||||
import { useBlocklistStore } from '../stores/blocklist'
|
import { useBlocklistStore } from '../stores/blocklist'
|
||||||
|
import TrustFeedbackButtons from './TrustFeedbackButtons.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
listing: Listing
|
listing: Listing
|
||||||
|
|
|
||||||
91
web/src/components/TrustFeedbackButtons.vue
Normal file
91
web/src/components/TrustFeedbackButtons.vue
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<!-- web/src/components/TrustFeedbackButtons.vue -->
|
||||||
|
<!-- MIT -- component layer -->
|
||||||
|
<template>
|
||||||
|
<div class="trust-feedback" v-if="trust">
|
||||||
|
<template v-if="state === 'idle' || state === 'sending'">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="trust-feedback__btn trust-feedback__btn--confirm"
|
||||||
|
:disabled="state === 'sending'"
|
||||||
|
:aria-busy="state === 'sending'"
|
||||||
|
@click="submitFeedback(true)"
|
||||||
|
>
|
||||||
|
This score looks right
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="trust-feedback__btn trust-feedback__btn--dispute"
|
||||||
|
:disabled="state === 'sending'"
|
||||||
|
:aria-busy="state === 'sending'"
|
||||||
|
@click="submitFeedback(false)"
|
||||||
|
>
|
||||||
|
This score is wrong
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Confirmation -- persistent, no countdown, no urgency -->
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="trust-feedback__confirmation"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Thanks, noted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TrustScore } from '../stores/search'
|
||||||
|
import { useTrustFeedback } from '../composables/useTrustFeedback'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sellerId: string
|
||||||
|
trust: TrustScore | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { state, submitFeedback } = useTrustFeedback(props.sellerId)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.trust-feedback {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trust-feedback__btn {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.trust-feedback__btn { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.trust-feedback__btn:hover:not(:disabled),
|
||||||
|
.trust-feedback__btn:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trust-feedback__btn:disabled { cursor: default; }
|
||||||
|
.trust-feedback__btn--confirm { border-color: var(--trust-high, #3fb950); }
|
||||||
|
.trust-feedback__btn--dispute { border-color: var(--trust-low, #f85149); }
|
||||||
|
|
||||||
|
.trust-feedback__confirmation {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
web/src/composables/useTrustFeedback.ts
Normal file
28
web/src/composables/useTrustFeedback.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// web/src/composables/useTrustFeedback.ts
|
||||||
|
// MIT -- component layer; the API call routes to a BSL endpoint.
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export type FeedbackState = 'idle' | 'sending' | 'confirmed' | 'disputed'
|
||||||
|
|
||||||
|
export function useTrustFeedback(sellerId: string) {
|
||||||
|
const state = ref<FeedbackState>('idle')
|
||||||
|
|
||||||
|
async function submitFeedback(confirmed: boolean): Promise<void> {
|
||||||
|
if (state.value !== 'idle') return
|
||||||
|
state.value = 'sending'
|
||||||
|
try {
|
||||||
|
await fetch('/api/community/signal', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ seller_id: sellerId, confirmed }),
|
||||||
|
})
|
||||||
|
// Always confirm regardless of response -- fail-soft contract.
|
||||||
|
} catch {
|
||||||
|
// Network unreachable -- still confirm to the user. Signal is best-effort.
|
||||||
|
} finally {
|
||||||
|
state.value = confirmed ? 'confirmed' : 'disputed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state, submitFeedback }
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue