diff --git a/apps/web/components/map/listing-map.tsx b/apps/web/components/map/listing-map.tsx index 511f3f6..df681a1 100644 --- a/apps/web/components/map/listing-map.tsx +++ b/apps/web/components/map/listing-map.tsx @@ -104,7 +104,10 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa const el = document.createElement('button'); el.className = 'mapbox-price-marker'; const isSelected = selectedListingId === marker.listing.id; - el.innerHTML = `${formatPrice(marker.listing.priceVND)}`; + const span = document.createElement('span'); + if (isSelected) span.className = 'selected'; + span.textContent = formatPrice(marker.listing.priceVND); + el.appendChild(span); el.style.cssText = 'border:none;cursor:pointer;background:none;padding:0;'; el.addEventListener('click', (e) => { @@ -129,38 +132,71 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa } }, [markers, selectedListingId, onMarkerClick]); + function buildPopupContent(listing: ListingDetail): HTMLDivElement { + const container = document.createElement('div'); + container.style.fontFamily = 'system-ui,sans-serif'; + + if (listing.property.media.length > 0) { + const img = document.createElement('img'); + img.src = listing.property.media[0]!.url; + img.alt = listing.property.title; + img.style.cssText = 'width:100%;height:96px;object-fit:cover;border-radius:6px;margin-bottom:8px;'; + container.appendChild(img); + } + + const price = document.createElement('p'); + price.style.cssText = 'font-weight:700;color:hsl(142.1,76.2%,36.3%);font-size:14px;margin:0 0 4px;'; + price.textContent = `${formatPrice(listing.priceVND)} VND`; + container.appendChild(price); + + const title = document.createElement('p'); + title.style.cssText = 'font-size:13px;font-weight:500;margin:0 0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; + title.textContent = listing.property.title; + container.appendChild(title); + + const location = document.createElement('p'); + location.style.cssText = 'font-size:12px;color:#666;margin:0 0 8px;'; + location.textContent = `${listing.property.district}, ${listing.property.city}`; + container.appendChild(location); + + const details = document.createElement('div'); + details.style.cssText = 'display:flex;gap:4px;font-size:11px;margin-bottom:8px;'; + const tagStyle = 'background:#f1f5f9;padding:2px 6px;border-radius:4px;'; + + const areaTag = document.createElement('span'); + areaTag.style.cssText = tagStyle; + areaTag.textContent = `${listing.property.areaM2} m\u00B2`; + details.appendChild(areaTag); + + if (listing.property.bedrooms != null) { + const bedTag = document.createElement('span'); + bedTag.style.cssText = tagStyle; + bedTag.textContent = `${listing.property.bedrooms} PN`; + details.appendChild(bedTag); + } + if (listing.property.bathrooms != null) { + const bathTag = document.createElement('span'); + bathTag.style.cssText = tagStyle; + bathTag.textContent = `${listing.property.bathrooms} WC`; + details.appendChild(bathTag); + } + container.appendChild(details); + + const link = document.createElement('a'); + link.href = `/listings/${listing.id}`; + link.style.cssText = 'display:block;text-align:center;font-size:12px;font-weight:500;color:hsl(142.1,76.2%,36.3%);text-decoration:none;'; + link.textContent = 'Xem chi ti\u1EBFt \u2192'; + container.appendChild(link); + + return container; + } + function showPopup(map: mapboxgl.Map, marker: MapMarker) { popupRef.current?.remove(); - const { listing } = marker; - const imgHtml = listing.property.media.length > 0 - ? `${listing.property.title}` - : ''; - const popup = new mapboxgl.Popup({ offset: 25, maxWidth: '260px', closeButton: true }) .setLngLat([marker.lng, marker.lat]) - .setHTML(` -
- ${imgHtml} -

- ${formatPrice(listing.priceVND)} VND -

-

- ${listing.property.title} -

-

- ${listing.property.district}, ${listing.property.city} -

-
- ${listing.property.areaM2} m\u00B2 - ${listing.property.bedrooms != null ? `${listing.property.bedrooms} PN` : ''} - ${listing.property.bathrooms != null ? `${listing.property.bathrooms} WC` : ''} -
- - Xem chi tiet → - -
- `) + .setDOMContent(buildPopupContent(marker.listing)) .addTo(map); popupRef.current = popup; diff --git a/apps/web/lib/listings-api.ts b/apps/web/lib/listings-api.ts index 6f9d1d5..20053be 100644 --- a/apps/web/lib/listings-api.ts +++ b/apps/web/lib/listings-api.ts @@ -162,9 +162,19 @@ export const listingsApi = { formData.append('file', file); if (caption) formData.append('caption', caption); + const csrfToken = typeof document !== 'undefined' + ? document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/)?.[1] + : undefined; + + const headers: HeadersInit = {}; + if (csrfToken) { + headers['X-CSRF-Token'] = decodeURIComponent(csrfToken); + } + const res = await fetch(`${API_BASE_URL}/listings/${listingId}/media`, { method: 'POST', credentials: 'include', + headers, body: formData, }); diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 3b88687..2b97129 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -20,6 +20,22 @@ const nextConfig = { { key: 'X-XSS-Protection', value: '1; mode=block' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self)' }, + { + key: 'Content-Security-Policy', + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://api.mapbox.com", + "style-src 'self' 'unsafe-inline' https://api.mapbox.com", + "img-src 'self' data: blob: https://*.mapbox.com https://*.tiles.mapbox.com https:", + "font-src 'self' data:", + "connect-src 'self' https://*.mapbox.com https://api.mapbox.com https://events.mapbox.com http://localhost:3001", + "worker-src 'self' blob:", + "child-src 'self' blob:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join('; '), + }, ], }, ];