Enhance listing details and map

This commit is contained in:
Tero Halla-aho 2025-11-24 22:19:23 +02:00
parent 890a9fe041
commit 3a5de63491
4 changed files with 137 additions and 61 deletions

View file

@ -47,6 +47,7 @@
- Soft rejection/removal states for users/listings with timestamps; owner listing removal; login redirects home; listing visibility hides removed/not-published.
- Profile page now allows editing name and password (email immutable).
- Docs: Added docs in `docs/` (tracked, not shipped) with HTML + PlantUML sequences + draw.io diagrams. Ignored from deploy via runtime paths; kept in git.
- Listing details: right rail now surfaces quick facts + amenity icons; browse map given fixed height so OpenStreetMap tiles show reliably; footer links to privacy page with version indicator.
To resume:
1) If desired, render diagrams locally: PlantUML in `docs/plantuml`, draw.io in `docs/drawio`.

View file

@ -210,6 +210,7 @@ p {
.map-frame {
position: relative;
width: 100%;
height: 420px;
min-height: 360px;
border-radius: 12px;
overflow: hidden;
@ -250,6 +251,51 @@ p {
gap: 16px;
}
.listing-layout {
display: grid;
gap: 16px;
grid-template-columns: 2fr 1fr;
}
.listing-main {
display: grid;
gap: 12px;
}
.listing-aside {
display: grid;
gap: 10px;
align-self: start;
position: sticky;
top: 24px;
}
.fact-row {
display: grid;
grid-template-columns: 28px 1fr;
gap: 10px;
align-items: start;
}
.amenity-list {
display: grid;
gap: 8px;
}
.amenity-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.02);
}
.amenity-icon {
font-size: 18px;
}
.breadcrumb {
color: var(--muted);
font-size: 14px;
@ -360,4 +406,12 @@ textarea:focus {
.latest-card {
grid-template-columns: 1fr;
}
.listing-layout {
grid-template-columns: 1fr;
}
.listing-aside {
position: static;
}
}

View file

@ -40,71 +40,48 @@ export default async function ListingPage({ params }: ListingPageProps) {
}
const { listing, title, description, teaser, locale: translationLocale } = translation;
const amenities = [
listing.hasSauna ? { icon: amenityIcons.sauna, label: t('amenitySauna') } : null,
listing.hasFireplace ? { icon: amenityIcons.fireplace, label: t('amenityFireplace') } : null,
listing.hasWifi ? { icon: amenityIcons.wifi, label: t('amenityWifi') } : null,
listing.petsAllowed ? { icon: amenityIcons.pets, label: t('amenityPets') } : null,
listing.byTheLake ? { icon: amenityIcons.lake, label: t('amenityLake') } : null,
listing.hasAirConditioning ? { icon: amenityIcons.ac, label: t('amenityAirConditioning') } : null,
listing.evCharging === 'FREE' ? { icon: amenityIcons.ev, label: t('amenityEvFree') } : null,
listing.evCharging === 'PAID' ? { icon: amenityIcons.ev, label: t('amenityEvPaid') } : null,
].filter(Boolean) as { icon: string; label: string }[];
const addressLine = `${listing.streetAddress ? `${listing.streetAddress}, ` : ''}${listing.city}, ${listing.region}, ${listing.country}`;
const capacityLine = `${t('capacityGuests', { count: listing.maxGuests })} · ${t('capacityBedrooms', { count: listing.bedrooms })} · ${t('capacityBeds', { count: listing.beds })} · ${t('capacityBathrooms', { count: listing.bathrooms })}`;
const contactLine = `${listing.contactName} · ${listing.contactEmail}${listing.contactPhone ? ` · ${listing.contactPhone}` : ''}`;
return (
<main className="listing-shell">
<div className="breadcrumb">
<Link href="/">{t('homeCrumb')}</Link> / <span>{params.slug}</span>
</div>
<div className="panel">
<div className="listing-layout">
<div className="panel listing-main">
<h1>{title}</h1>
<p style={{ marginTop: 8 }}>{teaser ?? description}</p>
<div style={{ marginTop: 12 }}>
<strong>{t('listingAddress')}:</strong> {listing.streetAddress ? `${listing.streetAddress}, ` : ''}
{listing.city}, {listing.region}, {listing.country}
</div>
{listing.addressNote ? (
<div style={{ marginTop: 4, color: '#cbd5e1' }}>
<em>{listing.addressNote}</em>
</div>
) : null}
<div style={{ marginTop: 12 }}>
<strong>{t('listingCapacity')}:</strong> {t('capacityGuests', { count: listing.maxGuests })} - {t('capacityBedrooms', { count: listing.bedrooms })} -{' '}
{t('capacityBeds', { count: listing.beds })} - {t('capacityBathrooms', { count: listing.bathrooms })}
</div>
<div style={{ marginTop: 12 }}>
<strong>{t('listingAmenities')}:</strong>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
{listing.hasSauna ? <span className="badge">{amenityIcons.sauna} {t('amenitySauna')}</span> : null}
{listing.hasFireplace ? <span className="badge">{amenityIcons.fireplace} {t('amenityFireplace')}</span> : null}
{listing.hasWifi ? <span className="badge">{amenityIcons.wifi} {t('amenityWifi')}</span> : null}
{listing.petsAllowed ? <span className="badge">{amenityIcons.pets} {t('amenityPets')}</span> : null}
{listing.byTheLake ? <span className="badge">{amenityIcons.lake} {t('amenityLake')}</span> : null}
{listing.hasAirConditioning ? <span className="badge">{amenityIcons.ac} {t('amenityAirConditioning')}</span> : null}
{listing.evCharging === 'FREE' ? <span className="badge">{amenityIcons.ev} {t('amenityEvFree')}</span> : null}
{listing.evCharging === 'PAID' ? <span className="badge">{amenityIcons.ev} {t('amenityEvPaid')}</span> : null}
{!(
listing.hasSauna ||
listing.hasFireplace ||
listing.hasWifi ||
listing.petsAllowed ||
listing.byTheLake ||
listing.hasAirConditioning ||
listing.evCharging !== 'NONE'
)
? <span className="badge">-</span>
: null}
</div>
</div>
<div style={{ marginTop: 8 }}>
<strong>{t('listingContact')}:</strong> {listing.contactName} - {listing.contactEmail}
{listing.contactPhone ? ` - ${listing.contactPhone}` : ''}
{listing.externalUrl ? (
<>
{' - '}
<a href={listing.externalUrl} target="_blank" rel="noreferrer">
<div style={{ marginTop: 12 }}>
<a href={listing.externalUrl} target="_blank" rel="noreferrer" className="button secondary">
{t('listingMoreInfo')}
</a>
</>
) : null}
</div>
) : null}
{listing.images.length > 0 ? (
<div style={{ marginTop: 16, display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}>
<div style={{ marginTop: 12, display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
{listing.images.map((img) => (
<figure key={img.id} style={{ border: '1px solid #ddd', borderRadius: 8, overflow: 'hidden', background: '#fafafa' }}>
<img src={img.url} alt={img.altText ?? title} style={{ width: '100%', height: '180px', objectFit: 'cover' }} />
<figure key={img.id} style={{ border: '1px solid rgba(148, 163, 184, 0.25)', borderRadius: 12, overflow: 'hidden', background: 'rgba(255,255,255,0.03)' }}>
<img src={img.url} alt={img.altText ?? title} style={{ width: '100%', height: '200px', objectFit: 'cover' }} />
{img.altText ? (
<figcaption style={{ padding: '8px 12px', fontSize: 14, color: '#444' }}>{img.altText}</figcaption>
<figcaption style={{ padding: '10px 12px', fontSize: 14, color: '#cbd5e1' }}>{img.altText}</figcaption>
) : null}
</figure>
))}
@ -114,6 +91,48 @@ export default async function ListingPage({ params }: ListingPageProps) {
{t('localeLabel')}: <code>{translationLocale}</code>
</div>
</div>
<aside className="panel listing-aside">
<div className="fact-row">
<span aria-hidden className="amenity-icon">📍</span>
<div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingLocation')}</div>
<div>{addressLine}</div>
</div>
</div>
<div className="fact-row">
<span aria-hidden className="amenity-icon">👥</span>
<div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingCapacity')}</div>
<div>{capacityLine}</div>
</div>
</div>
<div className="fact-row">
<span aria-hidden className="amenity-icon"></span>
<div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingContact')}</div>
<div>{contactLine}</div>
</div>
</div>
<div className="amenity-list">
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingAmenities')}</div>
{amenities.length === 0 ? (
<div className="amenity-row" style={{ borderStyle: 'dashed' }}>
<span className="amenity-icon"></span>
<span>{t('listingNoAmenities')}</span>
</div>
) : (
amenities.map((item) => (
<div key={item.label} className="amenity-row">
<span className="amenity-icon" aria-hidden>
{item.icon}
</span>
<span>{item.label}</span>
</div>
))
)}
</div>
</aside>
</div>
</main>
);
}

View file

@ -109,6 +109,7 @@ const allMessages = {
listingAddress: 'Address',
listingCapacity: 'Capacity',
listingAmenities: 'Amenities',
listingNoAmenities: 'No amenities listed yet.',
listingContact: 'Contact',
listingMoreInfo: 'More info',
localeLabel: 'Locale',
@ -289,6 +290,7 @@ const allMessages = {
listingAddress: 'Osoite',
listingCapacity: 'Tilat',
listingAmenities: 'Varustelu',
listingNoAmenities: 'Varustelua ei ole listattu.',
listingContact: 'Yhteystiedot',
listingMoreInfo: 'Lisätietoja',
localeLabel: 'Kieli',