Enhance listing details and map
This commit is contained in:
parent
890a9fe041
commit
3a5de63491
4 changed files with 137 additions and 61 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue