Overview
The property components provide a complete system for displaying real estate listings, including grid views, detailed property pages (desktop and mobile), maps, and contact forms.
Component List
PropertyGrid Filterable property listing grid
PropertyDesktop Desktop property detail view with zipper layout
PropertyMobile Mobile property detail view with drawer
PropertyMap Interactive Leaflet map component
ContactProperty Property inquiry contact form
FloatingContactBtn Floating contact button (desktop)
PropertyGrid
Displays a filterable, sortable grid of property listings with sidebar filters.
Location
src/components/properties/PropertyGrid.astro
Usage
---
import PropertyGrid from '../components/properties/PropertyGrid.astro' ;
import { getLangFromUrl } from '../i18n/utils' ;
const lang = getLangFromUrl ( Astro . url );
---
< PropertyGrid lang = { lang } />
Props
Language code for translations (es or en)
Features
Filtering System:
Location (Málaga, Marbella, Estepona, Benahavis, San Pedro)
Property type (Apartment, House, Attic, Plot, Commercial)
Minimum bedrooms (+1, +2, +3)
Minimum bathrooms (+1, +2, +3)
Sorting Options:
Price: High to Low / Low to High
Size: Large to Small / Small to Large
Layout:
Sidebar filters (sticky on desktop)
2-column grid on desktop, 1-column on mobile
Featured property cards with special styling
Pagination dots for visual feedback
Data Structure
const PROPERTIES = await PropertyService . getAll ( isEn ? 'en-GB' : 'es-ES' );
Each property card includes:
< a
class = "property-link"
href = { translatePath ( `/propiedades/ ${ normalizeSlug ( prop . type ) } - ${ normalizeSlug ( prop . location ) } / ${ prop . id } ` ) }
data-location = { prop . filterLocation || prop . location }
data-type = { prop . type }
data-beds = { prop . bedrooms }
data-baths = { prop . bathrooms }
data-price-raw = { prop . price_long }
data-size-raw = { prop . size }
>
<!-- Card content -->
</ a >
JavaScript Filtering
let filters = {
location: 'all' ,
type: 'all' ,
beds: 'any' ,
baths: 'any' ,
sort: 'none'
};
function updateGrid () {
cards . forEach (( card ) => {
const loc = card . getAttribute ( 'data-location' );
const type = card . getAttribute ( 'data-type' );
const beds = parseInt ( card . getAttribute ( 'data-beds' ));
const baths = parseInt ( card . getAttribute ( 'data-baths' ));
let visible = true ;
// Apply filters
if ( filters . location !== 'all' && loc !== filters . location ) visible = false ;
if ( filters . type !== 'all' && type !== filters . type ) visible = false ;
if ( filters . beds !== 'any' && beds < parseInt ( filters . beds )) visible = false ;
if ( filters . baths !== 'any' && baths < parseInt ( filters . baths )) visible = false ;
card . classList . toggle ( 'hidden' , ! visible );
});
sortGrid ();
}
PropertyDesktop
Desktop-optimized property detail view using a “zipper” layout where images and content alternate.
Location
src/components/properties/PropertyDesktop.astro
Usage
---
import PropertyDesktop from '../components/properties/PropertyDesktop.astro' ;
import { PropertyService } from '../services/api/properties' ;
const property = await PropertyService . getById ( Astro . params . id , 'es-ES' );
---
< PropertyDesktop property = { property } lang = "es" />
Props
Property data object from API
Language code for translations
Zipper Layout
The zipper layout alternates content blocks:
Row 1: [Hero Image (Left)] [Hero Content (Right)]
Row 2: [Narrative Text (Left)] [Image (Right)]
Row 3: [Image (Left)] [Specs (Right)]
Row 4+: [Alternating Gallery Images]
Each item is 50% width with sticky positioning:
.zipper-item {
width : 50 % ;
height : 100 vh ;
position : sticky ;
top : 0 ;
overflow : hidden ;
}
.pos-left {
margin-right : auto ;
margin-left : 0 ;
}
.pos-right {
margin-left : auto ;
margin-right : 0 ;
}
Content Blocks
Hero Content
Narrative Text
Specs
< section class = "content-block hero-content-block" >
< h1 > { title } </ h1 >
< p class = "subtitle" > { municipality } | { neighborhood } </ p >
< div class = "price-tag" > { property . price } </ div >
< div class = "quick-specs" >
< span >< strong > { property . bedrooms } </ strong > { t ( "property.bedrooms" ) } </ span >
< span >< strong > { property . bathrooms } </ strong > { t ( "property.bathrooms" ) } </ span >
< span >< strong > { property . built_m2 } </ strong > { t ( "property.built" ) } </ span >
</ div >
</ section >
< section class = "content-block narrative-content-block" >
< h2 > { t ( "property.description" ) } </ h2 >
< p class = "narrative-text" > { description_full || description } </ p >
< div class = "meta-tag" >
< span > { t ( "property.energy" ) } : { conservation_status } </ span >
</ div >
</ section >
< section class = "content-block specs-content-block" >
< h2 > { t ( "property.details" ) } </ h2 >
< ul class = "specs-list" >
< li >< strong > { t ( "property.type" ) } : </ strong > { property . type } </ li >
< li >< strong > { t ( "property.reference" ) } : </ strong > { property . reference } </ li >
< li >< strong > { t ( "property.built" ) } : </ strong > { property . built_m2 } m² </ li >
< li >< strong > Piscina: </ strong > { property . pool ? 'Privada' : 'No' } </ li >
< li >< strong > Garaje: </ strong > { property . garage ? 'Sí' : 'No' } </ li >
</ ul >
</ section >
PropertyMobile
Mobile-optimized property view with full-screen image gallery and bottom drawer for details.
Location
src/components/properties/PropertyMobile.astro
Usage
< PropertyMobile property = { property } lang = "es" />
Props
Features
Gallery Slider:
Full-screen horizontal scroll gallery
Snap-to-center scrolling
Tap left/right zones to navigate
Hide scrollbar for cleaner UI
gallerySlider . addEventListener ( 'click' , ( e ) => {
const clickX = e . clientX ;
const width = window . innerWidth ;
if ( clickX < width * 0.3 ) {
// Tap left 30% -> previous
gallerySlider . scrollBy ({ left: - slideWidth , behavior: 'smooth' });
} else if ( clickX > width * 0.7 ) {
// Tap right 30% -> next
gallerySlider . scrollBy ({ left: slideWidth , behavior: 'smooth' });
}
});
Details Drawer:
Slides up from bottom (75vh height)
Glassmorphism overlay background
Scrollable content area
Fixed header and footer
Contains specs, description, map, and contact form
.details-drawer {
position : fixed ;
width : 100 % ;
height : 100 % ;
z-index : 3000 ;
background : rgba ( 26 , 26 , 26 , 0.4 );
backdrop-filter : blur ( 5 px );
opacity : 0 ;
visibility : hidden ;
}
.details-drawer.open {
opacity : 1 ;
visibility : visible ;
}
.drawer-content {
height : 75 vh ;
background : rgba ( 255 , 255 , 255 , 0.95 );
border-radius : 20 px 20 px 0 0 ;
transform : translateY ( 100 % );
transition : transform 0.4 s cubic-bezier ( 0.16 , 1 , 0.3 , 1 );
}
Floating Info Button:
< div class = "floating-actions" >
< button id = "mobile-details-trigger" class = "icon-btn info-btn" >
< span class = "info-icon" > i </ span >
</ button >
</ div >
PropertyMap
Interactive map component using Leaflet to display property location.
Location
src/components/properties/PropertyMap.astro
Usage
< PropertyMap
coords = { [ 36.51 , - 4.88 ] }
title = "Property Location"
zoom = { 14 }
mapId = "property-map"
interactive = { true }
/>
Props
coords
[number, number]
default: "[36.51, -4.88]"
Latitude and longitude coordinates
title
string
default: "Ubicación"
Popup title text
Initial zoom level (1-20)
mapId
string
default: "property-map"
Unique ID for map container (required if multiple maps)
Enable/disable map interactions (dragging, zoom)
Implementation
const map = L . map ( mapId , {
scrollWheelZoom: false ,
dragging: interactive ,
touchZoom: interactive ? 'center' : false ,
doubleClickZoom: interactive ,
boxZoom: interactive ,
tap: interactive ,
keyboard: interactive
}). setView ( coords , zoom );
// CartoDB Voyager tiles (clean, neutral style)
L . tileLayer (
'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png' ,
{
attribution: '© OpenStreetMap © CARTO' ,
maxZoom: 20
}
). addTo ( map );
// Approximate location circle (privacy)
L . circle ( coords , {
color: '#333' ,
fillColor: '#333' ,
fillOpacity: 0.2 ,
radius: 500 // meters
}). addTo ( map ). bindPopup ( title );
The map uses a circle instead of a pin marker to show approximate location, protecting exact property address.
Property inquiry contact form with financing option.
Location
src/components/properties/ContactProperty.astro
Usage
< ContactProperty
propertyId = { property . id }
lang = "es"
/>
Props
Property ID to associate with lead
< form id = "property-contact-form" >
< input type = "hidden" name = "property_id" value = { propertyId } />
< input type = "text" name = "name" placeholder = "Nombre completo" required />
< input type = "email" name = "email" placeholder = "Email" required />
< input type = "tel" name = "phone" placeholder = "Teléfono" required />
< select name = "financing" required >
< option value = "" > ¿Necesita financiación? </ option >
< option value = "si" > Sí </ option >
< option value = "no" > No </ option >
</ select >
< textarea name = "message" rows = "4" >
Hola, me interesa esta propiedad y me gustaría hacer una visita.
</ textarea >
< button type = "submit" > Enviar Solicitud </ button >
</ form >
Submission
form . addEventListener ( 'submit' , async ( e ) => {
e . preventDefault ();
const formData = new FormData ( form );
const financing = formData . get ( 'financing' );
const originalMessage = formData . get ( 'message' );
const finalMessage = ` ${ originalMessage } \n\n Necesita financiación: ${ financing === 'si' ? 'Sí' : 'No' } ` ;
const leadData = {
name: formData . get ( 'name' ),
email: formData . get ( 'email' ),
phone: formData . get ( 'phone' ),
message: finalMessage ,
property_id: formData . get ( 'property_id' ),
source: 'web'
};
const { LeadService } = await import ( '../../services/api/leads' );
const response = await LeadService . submitPropertyLead ( leadData );
if ( response . success ) {
alert ( response . message );
form . reset ();
}
});
Floating contact button that scrolls to form or submits it (desktop only).
Location
src/components/properties/FloatingContactBtn.astro
Usage
Behavior
The button has two states:
1. Floating State (before form visible):
Fixed at bottom center of screen
Text: “Contactar”
Action: Scrolls to contact form
2. Snapped State (form visible):
Snaps into dock position within form
Text: “Enviar Solicitud”
Action: Submits the form
const handleScroll = () => {
const dockCenterY = dock . getBoundingClientRect (). top + dock . height / 2 ;
const fixedTargetY = window . innerHeight - 32 - btnHeight / 2 ;
if ( dockCenterY <= fixedTargetY ) {
wrapper . classList . add ( 'is-snapped' );
btn . textContent = 'Enviar Solicitud' ;
} else {
wrapper . classList . remove ( 'is-snapped' );
btn . textContent = 'Contactar' ;
}
};
This component is desktop-only. It’s hidden on screens below 900px width.
Complete Property Page Example
src/pages/propiedades/[...slug].astro
---
import PropertyDesktop from '../../components/properties/PropertyDesktop.astro' ;
import PropertyMobile from '../../components/properties/PropertyMobile.astro' ;
import { PropertyService } from '../../services/api/properties' ;
import { getLangFromUrl } from '../../i18n/utils' ;
const lang = getLangFromUrl ( Astro . url );
const propertyId = Astro . params . slug ?. split ( '/' ). pop ();
const property = await PropertyService . getById ( propertyId , lang === 'en' ? 'en-GB' : 'es-ES' );
---
< ! DOCTYPE html >
< html >
< head > ... </ head >
< body >
< div class = "desktop-view" >
< PropertyDesktop property = { property } lang = { lang } />
</ div >
< div class = "mobile-view" >
< PropertyMobile property = { property } lang = { lang } />
</ div >
</ body >
</ html >
< style >
.mobile-view {
display : none ;
}
@media ( max-width : 900 px ) {
.desktop-view {
display : none ;
}
.mobile-view {
display : block ;
}
}
</ style >
API Integration PropertyService API methods
Data Types Property type definitions
Forms & Validation Form handling guide
Internationalization Translation utilities