source: petify-frontend/src/components/ListingCard.vue@ 92e7c7a

Last change on this file since 92e7c7a was 92e7c7a, checked in by veronika-ils <ilioskaveronika@…>, 9 hours ago

Petify fullstack project

  • Property mode set to 100644
File size: 7.6 KB
Line 
1<template>
2 <article class="listing-card" @click="onCardClick" role="button" tabindex="0" @keydown.enter="onCardClick">
3 <div class="media">
4 <img class="image" :src="imageSrc" :alt="imageAlt" loading="lazy" @error="onImageError" />
5 <button class="favorite" type="button" @click.stop="toggleFavorite" :aria-pressed="isFavorited">
6 {{ isFavorited ? '♥' : '♡' }}
7 </button>
8 <div v-if="listing?.status" class="badge">{{ listing.status }}</div>
9 </div>
10
11 <div class="content">
12 <div class="top">
13 <h3 class="title" :title="titleText">{{ titleText }}</h3>
14 <div v-if="priceText" class="price">{{ priceText }}</div>
15 </div>
16
17 <p v-if="createdText" class="created">Posted {{ createdText }}</p>
18
19 <p class="meta">
20 <span v-if="listing?.petType">{{ listing.petType }}</span>
21 <span v-if="listing?.breed"> / {{ listing.breed }}</span>
22 <span v-if="ageText"> / {{ ageText }}</span>
23 </p>
24
25 <p v-if="locationText" class="location">{{ locationText }}</p>
26
27 <ul v-if="chips.length" class="chips" aria-label="Listing tags">
28 <li v-for="chip in chips" :key="chip" class="chip">{{ chip }}</li>
29 </ul>
30
31 <p v-if="listing?.description" class="description">
32 {{ listing.description }}
33 </p>
34
35 <div class="actions" @click.stop>
36 <button class="primary" type="button" @click="emit('view', listing)">
37 View
38 </button>
39 <button class="secondary" type="button" @click="emit('contact', listing)">
40 Contact
41 </button>
42 </div>
43 </div>
44 </article>
45</template>
46
47<script setup>
48import { computed, ref, watchEffect } from 'vue'
49
50const emit = defineEmits(['click', 'view', 'contact', 'favorite'])
51
52const props = defineProps({
53 listing: {
54 type: Object,
55 required: true,
56 },
57 favorited: {
58 type: Boolean,
59 default: false,
60 },
61 placeholderImage: {
62 type: String,
63 default:
64 "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='800' height='600'><rect width='100%25' height='100%25' fill='%23f2f3f5'/><text x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='Arial' font-size='24' fill='%238a8f98'>Petify</text></svg>",
65 },
66})
67
68const isFavorited = ref(false)
69watchEffect(() => {
70 isFavorited.value = !!props.favorited
71})
72
73const imageBroken = ref(false)
74
75const imageSrc = computed(() => {
76 const img =
77 props.listing?.imageUrl ||
78 props.listing?.image ||
79 props.listing?.photos?.[0] ||
80 props.listing?.images?.[0]
81 return !imageBroken.value && img ? img : props.placeholderImage
82})
83
84const imageAlt = computed(() => {
85 const name = props.listing?.name || props.listing?.title || 'Pet listing'
86 return `${name} photo`
87})
88
89const titleText = computed(() => props.listing?.title || props.listing?.name || props.listing?.animalName || 'Untitled listing')
90
91const ageText = computed(() => {
92 const age = props.listing?.age
93 const unit = props.listing?.ageUnit || 'years'
94 if (age === null || age === undefined || age === '') return ''
95 return `${age} ${unit}`
96})
97
98const locationText = computed(() => {
99 const parts = [props.listing?.city, props.listing?.state, props.listing?.country].filter(Boolean)
100 return parts.length ? parts.join(', ') : ''
101})
102
103const priceText = computed(() => {
104 const price = props.listing?.price ?? props.listing?.fee ?? props.listing?.adoptionFee
105 const currency = props.listing?.currency || 'USD'
106 if (price === null || price === undefined || price === '') return ''
107 const num = Number(price)
108 if (Number.isFinite(num)) {
109 try {
110 return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(num)
111 } catch {
112 return `$${num}`
113 }
114 }
115 return String(price)
116})
117
118const chips = computed(() => {
119 const out = []
120 if (props.listing?.gender) out.push(props.listing.gender)
121 if (props.listing?.size) out.push(props.listing.size)
122 if (props.listing?.vaccinated) out.push('Vaccinated')
123 if (props.listing?.neutered || props.listing?.spayed) out.push('Fixed')
124 if (Array.isArray(props.listing?.tags)) out.push(...props.listing.tags.slice(0, 4))
125
126 if (!out.length) {
127 if (props.listing?.animalName) out.push(props.listing.animalName)
128 if (props.listing?.ownerName) out.push(`By ${props.listing.ownerName}`)
129 if (props.listing?.status) out.push(String(props.listing.status))
130 }
131 return out.filter(Boolean)
132})
133
134const createdText = computed(() => {
135 const raw = props.listing?.createdAt || props.listing?.created_at
136 if (!raw) return ''
137 const date = new Date(raw)
138 if (Number.isNaN(date.getTime())) return ''
139 return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
140})
141
142function onImageError() {
143 imageBroken.value = true
144}
145
146function onCardClick() {
147 emit('click', props.listing)
148}
149
150function toggleFavorite() {
151 isFavorited.value = !isFavorited.value
152 emit('favorite', { listing: props.listing, favorited: isFavorited.value })
153}
154</script>
155
156<style scoped>
157.listing-card {
158 background: #ffffff;
159 border: 1px solid rgba(17, 24, 39, 0.08);
160 border-radius: 14px;
161 box-shadow: 0 10px 25px rgba(17, 24, 39, 0.06);
162 cursor: pointer;
163 display: grid;
164 grid-template-rows: auto 1fr;
165 overflow: hidden;
166 transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease;
167}
168
169.listing-card:hover {
170 border-color: rgba(249, 115, 22, 0.35);
171 box-shadow: 0 16px 35px rgba(17, 24, 39, 0.1);
172 transform: translateY(-2px);
173}
174
175.listing-card:focus {
176 outline: 3px solid rgba(249, 115, 22, 0.35);
177 outline-offset: 2px;
178}
179
180.media {
181 aspect-ratio: 4 / 3;
182 background: #f6f7f9;
183 position: relative;
184 width: 100%;
185}
186
187.image {
188 display: block;
189 height: 100%;
190 object-fit: cover;
191 width: 100%;
192}
193
194.badge {
195 background: rgba(249, 115, 22, 0.92);
196 border-radius: 999px;
197 color: #ffffff;
198 font-size: 12px;
199 left: 10px;
200 padding: 6px 10px;
201 position: absolute;
202 top: 10px;
203}
204
205.favorite {
206 align-items: center;
207 background: rgba(255, 255, 255, 0.9);
208 border: 1px solid rgba(255, 255, 255, 0.7);
209 border-radius: 999px;
210 display: grid;
211 font-size: 18px;
212 height: 38px;
213 line-height: 1;
214 place-items: center;
215 position: absolute;
216 right: 10px;
217 top: 10px;
218 width: 38px;
219}
220
221.favorite[aria-pressed='true'] {
222 border-color: rgba(249, 115, 22, 0.45);
223 color: #f97316;
224}
225
226.favorite:hover {
227 background: #ffffff;
228}
229
230.content {
231 display: grid;
232 gap: 8px;
233 padding: 12px 12px 14px;
234}
235
236.top {
237 align-items: flex-start;
238 display: flex;
239 gap: 10px;
240 justify-content: space-between;
241}
242
243.title {
244 color: #111827;
245 display: -webkit-box;
246 font-size: 16px;
247 font-weight: 650;
248 margin: 0;
249 overflow: hidden;
250 -webkit-box-orient: vertical;
251 -webkit-line-clamp: 2;
252}
253
254.price {
255 color: #111827;
256 font-weight: 700;
257 white-space: nowrap;
258}
259
260.created {
261 color: #6b7280;
262 font-size: 12px;
263 margin: 0;
264}
265
266.meta,
267.location,
268.description {
269 color: #4b5563;
270 font-size: 13px;
271 margin: 0;
272}
273
274.location {
275 color: #6b7280;
276}
277
278.description {
279 display: -webkit-box;
280 overflow: hidden;
281 -webkit-box-orient: vertical;
282 -webkit-line-clamp: 2;
283}
284
285.chips {
286 display: flex;
287 flex-wrap: wrap;
288 gap: 6px;
289 list-style: none;
290 margin: 0;
291 padding: 0;
292}
293
294.chip {
295 background: #f3f4f6;
296 border: 1px solid #eceef3;
297 border-radius: 999px;
298 color: #374151;
299 font-size: 12px;
300 padding: 5px 8px;
301}
302
303.actions {
304 display: flex;
305 gap: 8px;
306 margin-top: 2px;
307}
308
309.primary,
310.secondary {
311 border: 1px solid transparent;
312 border-radius: 10px;
313 flex: 1;
314 font-weight: 600;
315 height: 36px;
316}
317
318.primary {
319 background: #f97316;
320 color: #ffffff;
321}
322
323.primary:hover {
324 background: #ea580c;
325}
326
327.secondary {
328 background: #ffffff;
329 border-color: #e5e7eb;
330 color: #111827;
331}
332
333.secondary:hover {
334 background: #f9fafb;
335}
336</style>
Note: See TracBrowser for help on using the repository browser.