1// listing_item_template.js 2// A helper class to separate template rendering responsibilities from the controller 3 4export default class ListingItemTemplate { 5 constructor(options = {}) { 6 this.currentUserSlug = options.currentUserSlug || ''; 7 this.userId = options.userId || null; 8 this.currentUserId = options.currentUserId || null; 9 this.collection = options.collection || ''; 10 this.inIos = options.inIos || false; 11 this.editBoldSvgPath = options.editBoldSvgPath || '/assets/components/edit-bold.svg'; 12 this.fxRate = options.fxRate || 1; 13 this.fxCurrencySymbol = options.fxCurrencySymbol || '£'; 14 this.imageSearch = options.imageSearch || false; 15 this.plpExperiment = options.plpExperiment || false; 16 this.plpSortExperimentItemListName = options.plpSortExperimentItemListName || ''; 17 } 18 19 renderItem(result) { 20 const listingPath = this._buildListingPath(result.getRaw('slug')); 21 return ` 22 <div class="item-card2" id="${result.getRaw('id')}" 23 data-action="click->app-search--listing-filters#clickThroughTracking click->app-search--listing-filters#redirectWithQueryParams"> 24 25 <a href="${listingPath}" class="item-link" target="_blank" 26 data-controller="google-tag-manager--enhanced-ecommerce" 27 data-google-tag-manager--enhanced-ecommerce-item-name="${result.getRaw('item_name')}" 28 data-google-tag-manager--enhanced-ecommerce-item-uid="${result.getRaw('public_uid')}" 29 data-google-tag-manager--enhanced-ecommerce-item-categories="${result.getRaw('tag_list').join(', ')}" 30 data-google-tag-manager--enhanced-ecommerce-item-brand="${result.getRaw('designer_brand')}" 31 data-google-tag-manager--enhanced-ecommerce-item-colour="${result.getRaw('colour_category')}" 32 data-google-tag-manager--enhanced-ecommerce-item-price="${result.getRaw('listing_view_price')}" 33 data-google-tag-manager--enhanced-ecommerce-item-list-name="${this.plpSortExperimentItemListName}" 34 data-action="google-tag-manager--enhanced-ecommerce#productClicks"> 35 36 ${this.renderImageContainer(result)} 37 38 <div class="item-details position-relative"> 39 <div class="managed-tag-wrapper">${this.renderItemBadge(result)}</div> 40 <p class="font-weight-bold text-hurr-black mb-0 text-uppercase">${result.getRaw('designer_brand')}</p> 41 <p class="mb-0 text-hurr-black pb-1">${result.getRaw('unit_sizes_available')}</p> 42 ${this.renderPricing(result)} 43 </div> 44 </a> 45 </div> 46 `; 47 } 48 49 renderImageContainer(result) { 50 return ` 51 <div class="mx-auto image-wrapper position-relative" data-controller="item-card plp-wishlist" 52 data-action="mouseenter->item-card#showSecondImage mouseleave->item-card#showMainImage"> 53 54 <div class="image" style="background-image: url(${result.getRaw('cl_image_url')}); opacity: 1;" data-item-card-target="firstImage"></div> 55 <div class="image" style="background-image: url(${result.getRaw('second_image_url')}); opacity: 0;" data-item-card-target="secondImage"></div> 56 57 <div class="overlay-buttons position-absolute d-flex w-100 p-2 justify-content-between"> 58 ${this.renderEditButton(result)} 59 ${this.renderBuyNowBadge(result)} 60 ${this.renderWishlistButton(result)} 61 62 </div> 63 </div> 64 `; 65 } 66 67 renderEditButton(result) { 68 if (!this.currentUserId || this.currentUserId !== this.userId) { 69 return ''; 70 } 71 72 const slug = result.getRaw('slug'); 73 return ` 74 <a href="/listings/${slug}/modals/edit" class="action-button text-decoration-none" 75 data-turbo-stream="true" data-turbo-method="GET"> 76 <img src="${this.editBoldSvgPath}" alt="Edit" class="edit-icon" /> 77 </a> 78 `; 79 } 80 81 renderItemBadge(result) { 82 if (result.getRaw('exclusive') === 'true') { 83 return `<span class="exclusive-tag managed-item-tag-2" data-toggle="tooltip" data-placement="top" data-html="true" data-original-title="This item is available <br> exclusively to rent on <span class='hurr-font-logo'>HURR</span>">Exclusive</span>`;
84 } else if (result.getRaw('managed_item?') === 'true') { 85 return `<span class="managed-item-tag-2" data-toggle="tooltip" data-placement="top" data-html="true" data-original-title="These items are managed<br>by our in-house team.">Managed</span>`; 86 } else if (result.getRaw('top_lender?') === 'true') { 87 return `<span class="managed-item-tag-2" data-toggle="tooltip" data-placement="top" data-html="true" data-original-title="Top Lenders are amongst our<br>best performing members.">Top Lender</span>`; 88 } 89 return ''; 90 } 91 92 renderPricing(result) { 93 const rrp = parseInt(result.getRaw('rrp'), 10); 94 const rrpInCurrency = parseInt(rrp * this.fxRate); 95 if (this.collection === 'resale') { 96 let copy = `${this.renderPurchaseAvailabilityLabel(result)}<p class="mb-0 d-inline"><s>${this.fxCurrencySymbol}${rrpInCurrency.toLocaleString()}</s></p>`; 97 if (!this.isMakeAnOfferResult(result)) { 98 const purchasePrice = parseFloat(result.getRaw('lowest_unit_purchase_price')); 99 const discount = Math.round(100 - (purchasePrice * 100) / rrp); 100 if (discount >= 10 && purchasePrice < rrp) { 101 copy += `<p class="d-inline text-danger pl-2">${discount}% off</p>`; 102 } 103 } 104 return copy; 105 } 106 const fontWeight = this.collection === 'resale' ? '' : 'font-weight-bold'; 107 const isResaleOnly = result.getRaw('resale_only') === 'true'; 108 const rentFromHtml = isResaleOnly 109 ? '' 110 : (() => { 111 const viewPriceInCurrency = parseInt(result.getRaw('listing_view_price'), 10) * this.fxRate; 112 return `<p class="mb-0 text-hurr-black ${fontWeight}">Rent from ${this.fxCurrencySymbol}${Math.round(viewPriceInCurrency).toLocaleString()}</p>`; 113 })(); 114 return `${rentFromHtml}${this.renderPurchaseAvailabilityLabel(result)}<p class="mb-0 pb-3"><s>${this.fxCurrencySymbol}${rrpInCurrency.toLocaleString()}</s></p>`; 115 } 116 117 isMakeAnOfferResult(result) { 118 return result.getRaw('purchase_availability_label') === 'MAKE AN OFFER'; 119 } 120 121 renderPurchaseAvailabilityLabel(result) { 122 if (!result.getRaw('purchase_availability_label')) return ''; 123 let purchase_availability_label = ''; 124 if (this.availableToBuyNow(result)) { 125 const purchasePrice = parseFloat(result.getRaw('lowest_unit_purchase_price')); 126 const purchasePriceInCurrency = parseInt(purchasePrice * this.fxRate); 127 128 purchase_availability_label = 'Buy Now ' + this.fxCurrencySymbol + purchasePriceInCurrency.toLocaleString(); 129 } else { 130 purchase_availability_label = 'Make an offer'; 131 } 132 const fontWeight = this.collection === 'resale' ? 'font-weight-bold' : ''; 133 return `<p class="mb-0 text-hurr-black ${fontWeight}">${purchase_availability_label}</p>`; 134 } 135 136 availableToBuyNow(result) { 137 return ( 138 result.getRaw('available_to_buy_now') === 'true' && result.getRaw('has_available_for_buy_now_units?') === 'true' 139 ); 140 } 141 142 renderWishlistButton(result) { 143 const listingId = result.getRaw('id'); 144 const userSlugs = JSON.parse(result.getRaw('wishers_slugs')); 145 const wishlisted = userSlugs.includes(this.currentUserSlug); 146 const heartCount = userSlugs.length >= 2 ? userSlugs.length : ''; 147 const filled = 148 'https://res.cloudinary.com/dcaptnlz3/image/asset/filled-heart-icon-a8423184c157efec16d266396a600a9f.svg'; 149 const empty = 150 'https://res.cloudinary.com/dcaptnlz3/image/asset/empty-heart-icon-d56bf5bf6cbfd87ebc3a94c9c7f06461.svg'; 151 152 if (!this.currentUserSlug) { 153 return ` 154 <a href="#" class="heart-button ml-auto text-decoration-none" data-toggle="modal" data-target="#signup-modal""> 155 <span class="heart-counter text-black">${heartCount}</span> 156 <img src="${filled}" alt="Filled" class="heart-icon filled ${wishlisted ? '' : 'd-none'}" /> 157 <img src="${empty}" alt="Empty" class="heart-icon empty ${wishlisted ? 'd-none' : ''}" /> 158 </a> 159 `; 160 } 161 162 return ` 163 <a href="#" class="heart-button ml-auto text-decoration-none" 164 data-listing-id="${listingId}" 165 data-action="click->plp-wishlist#toggleWishlist">
166 <span class="heart-counter text-black">${heartCount}</span> 167 <img src="${filled}" alt="Filled" class="heart-icon filled ${wishlisted ? '' : 'd-none'}" /> 168 <img src="${empty}" alt="Empty" class="heart-icon empty ${wishlisted ? 'd-none' : ''}" /> 169 </a> 170 `; 171 } 172 173 renderBuyNowBadge(result) { 174 if (this.currentUserId != null && 175 this.userId != null && 176 this.currentUserId == this.userId) { 177 return ''; 178 } 179 180 const listingAvailableToBuyNow = result.getRaw('available_to_buy_now'); 181 if (listingAvailableToBuyNow == 'true') { 182 return ` 183 <div class="buy-now-badge"> 184 <span class="buy-now-badge-text">BUY NOW</span> 185 </div> 186 `; 187 } else { 188 return ''; 189 } 190 } 191 192 _buildListingPath(slug) { 193 const params = []; 194 if (this.collection === 'resale') params.push('buy_now=true'); 195 if (this.imageSearch) params.push('ref=image_search'); 196 if (this.plpExperiment) params.push('plp_experiment=1'); 197 return params.length ? `/listings/${slug}?${params.join('&')}` : `/listings/${slug}`; 198 } 199};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.