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.
|
- 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).
|
- 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.
|
- 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:
|
To resume:
|
||||||
1) If desired, render diagrams locally: PlantUML in `docs/plantuml`, draw.io in `docs/drawio`.
|
1) If desired, render diagrams locally: PlantUML in `docs/plantuml`, draw.io in `docs/drawio`.
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,7 @@ p {
|
||||||
.map-frame {
|
.map-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 420px;
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -250,6 +251,51 @@ p {
|
||||||
gap: 16px;
|
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 {
|
.breadcrumb {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
@ -360,4 +406,12 @@ textarea:focus {
|
||||||
.latest-card {
|
.latest-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listing-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-aside {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,79 +40,98 @@ export default async function ListingPage({ params }: ListingPageProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { listing, title, description, teaser, locale: translationLocale } = translation;
|
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 (
|
return (
|
||||||
<main className="listing-shell">
|
<main className="listing-shell">
|
||||||
<div className="breadcrumb">
|
<div className="breadcrumb">
|
||||||
<Link href="/">{t('homeCrumb')}</Link> / <span>{params.slug}</span>
|
<Link href="/">{t('homeCrumb')}</Link> / <span>{params.slug}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel">
|
<div className="listing-layout">
|
||||||
<h1>{title}</h1>
|
<div className="panel listing-main">
|
||||||
<p style={{ marginTop: 8 }}>{teaser ?? description}</p>
|
<h1>{title}</h1>
|
||||||
<div style={{ marginTop: 12 }}>
|
<p style={{ marginTop: 8 }}>{teaser ?? description}</p>
|
||||||
<strong>{t('listingAddress')}:</strong> {listing.streetAddress ? `${listing.streetAddress}, ` : ''}
|
{listing.addressNote ? (
|
||||||
{listing.city}, {listing.region}, {listing.country}
|
<div style={{ marginTop: 4, color: '#cbd5e1' }}>
|
||||||
</div>
|
<em>{listing.addressNote}</em>
|
||||||
{listing.addressNote ? (
|
</div>
|
||||||
<div style={{ marginTop: 4, color: '#cbd5e1' }}>
|
) : null}
|
||||||
<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 ? (
|
{listing.externalUrl ? (
|
||||||
<>
|
<div style={{ marginTop: 12 }}>
|
||||||
{' - '}
|
<a href={listing.externalUrl} target="_blank" rel="noreferrer" className="button secondary">
|
||||||
<a href={listing.externalUrl} target="_blank" rel="noreferrer">
|
|
||||||
{t('listingMoreInfo')}
|
{t('listingMoreInfo')}
|
||||||
</a>
|
</a>
|
||||||
</>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
{listing.images.length > 0 ? (
|
||||||
{listing.images.length > 0 ? (
|
<div style={{ marginTop: 12, display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
|
||||||
<div style={{ marginTop: 16, display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}>
|
{listing.images.map((img) => (
|
||||||
{listing.images.map((img) => (
|
<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)' }}>
|
||||||
<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: '200px', objectFit: 'cover' }} />
|
||||||
<img src={img.url} alt={img.altText ?? title} style={{ width: '100%', height: '180px', objectFit: 'cover' }} />
|
{img.altText ? (
|
||||||
{img.altText ? (
|
<figcaption style={{ padding: '10px 12px', fontSize: 14, color: '#cbd5e1' }}>{img.altText}</figcaption>
|
||||||
<figcaption style={{ padding: '8px 12px', fontSize: 14, color: '#444' }}>{img.altText}</figcaption>
|
) : null}
|
||||||
) : null}
|
</figure>
|
||||||
</figure>
|
))}
|
||||||
))}
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div style={{ marginTop: 16, fontSize: 14, color: '#666' }}>
|
||||||
|
{t('localeLabel')}: <code>{translationLocale}</code>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
<div style={{ marginTop: 16, fontSize: 14, color: '#666' }}>
|
|
||||||
{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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ const allMessages = {
|
||||||
listingAddress: 'Address',
|
listingAddress: 'Address',
|
||||||
listingCapacity: 'Capacity',
|
listingCapacity: 'Capacity',
|
||||||
listingAmenities: 'Amenities',
|
listingAmenities: 'Amenities',
|
||||||
|
listingNoAmenities: 'No amenities listed yet.',
|
||||||
listingContact: 'Contact',
|
listingContact: 'Contact',
|
||||||
listingMoreInfo: 'More info',
|
listingMoreInfo: 'More info',
|
||||||
localeLabel: 'Locale',
|
localeLabel: 'Locale',
|
||||||
|
|
@ -289,6 +290,7 @@ const allMessages = {
|
||||||
listingAddress: 'Osoite',
|
listingAddress: 'Osoite',
|
||||||
listingCapacity: 'Tilat',
|
listingCapacity: 'Tilat',
|
||||||
listingAmenities: 'Varustelu',
|
listingAmenities: 'Varustelu',
|
||||||
|
listingNoAmenities: 'Varustelua ei ole listattu.',
|
||||||
listingContact: 'Yhteystiedot',
|
listingContact: 'Yhteystiedot',
|
||||||
listingMoreInfo: 'Lisätietoja',
|
listingMoreInfo: 'Lisätietoja',
|
||||||
localeLabel: 'Kieli',
|
localeLabel: 'Kieli',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue