| [92e7c7a] | 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>
|
|---|
| 48 | import { computed, ref, watchEffect } from 'vue'
|
|---|
| 49 |
|
|---|
| 50 | const emit = defineEmits(['click', 'view', 'contact', 'favorite'])
|
|---|
| 51 |
|
|---|
| 52 | const 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 |
|
|---|
| 68 | const isFavorited = ref(false)
|
|---|
| 69 | watchEffect(() => {
|
|---|
| 70 | isFavorited.value = !!props.favorited
|
|---|
| 71 | })
|
|---|
| 72 |
|
|---|
| 73 | const imageBroken = ref(false)
|
|---|
| 74 |
|
|---|
| 75 | const 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 |
|
|---|
| 84 | const imageAlt = computed(() => {
|
|---|
| 85 | const name = props.listing?.name || props.listing?.title || 'Pet listing'
|
|---|
| 86 | return `${name} photo`
|
|---|
| 87 | })
|
|---|
| 88 |
|
|---|
| 89 | const titleText = computed(() => props.listing?.title || props.listing?.name || props.listing?.animalName || 'Untitled listing')
|
|---|
| 90 |
|
|---|
| 91 | const 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 |
|
|---|
| 98 | const locationText = computed(() => {
|
|---|
| 99 | const parts = [props.listing?.city, props.listing?.state, props.listing?.country].filter(Boolean)
|
|---|
| 100 | return parts.length ? parts.join(', ') : ''
|
|---|
| 101 | })
|
|---|
| 102 |
|
|---|
| 103 | const 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 |
|
|---|
| 118 | const 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 |
|
|---|
| 134 | const 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 |
|
|---|
| 142 | function onImageError() {
|
|---|
| 143 | imageBroken.value = true
|
|---|
| 144 | }
|
|---|
| 145 |
|
|---|
| 146 | function onCardClick() {
|
|---|
| 147 | emit('click', props.listing)
|
|---|
| 148 | }
|
|---|
| 149 |
|
|---|
| 150 | function 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>
|
|---|