feature/price-starting-from-wording #10

Merged
thalla merged 2 commits from feature/price-starting-from-wording into master 2025-12-18 13:47:36 +02:00
5 changed files with 43 additions and 17 deletions

View file

@ -88,3 +88,4 @@
- Forgejo deployment scaffolding added: Docker Compose + runner config guidance and Apache vhost for git.halla-aho.net, plus CI workflow placeholder under `.forgejo/workflows/`. - Forgejo deployment scaffolding added: Docker Compose + runner config guidance and Apache vhost for git.halla-aho.net, plus CI workflow placeholder under `.forgejo/workflows/`.
- Amenities: added separate EV charging flags (on-site vs nearby) plus wheelchair accessibility, including browse filters and admin approvals view badges. - Amenities: added separate EV charging flags (on-site vs nearby) plus wheelchair accessibility, including browse filters and admin approvals view badges.
- Navbar: combined admin actions (approvals/users/monitoring) under a single “Admin” dropdown menu. - Navbar: combined admin actions (approvals/users/monitoring) under a single “Admin” dropdown menu.
- Pricing copy: treat listing prices as indicative “starting from” values and show starting-from line on browse cards + home latest carousel.

View file

@ -114,11 +114,17 @@ export default async function ListingPage({ params }: ListingPageProps) {
const capacityLine = capacityParts.length ? capacityParts.join(' · ') : t('capacityUnknown'); const capacityLine = capacityParts.length ? capacityParts.join(' · ') : t('capacityUnknown');
const contactLine = `${listing.contactName} · ${listing.contactEmail}${listing.contactPhone ? ` · ${listing.contactPhone}` : ''}`; const contactLine = `${listing.contactName} · ${listing.contactEmail}${listing.contactPhone ? ` · ${listing.contactPhone}` : ''}`;
const coverImage = listing.images.find((img) => img.isCover) ?? listing.images[0] ?? null; const coverImage = listing.images.find((img) => img.isCover) ?? listing.images[0] ?? null;
const priceCandidates = [listing.priceWeekdayEuros, listing.priceWeekendEuros].filter((p): p is number => typeof p === 'number');
const startingFromEuros = priceCandidates.length ? Math.min(...priceCandidates) : null;
const priceLine = const priceLine =
listing.priceWeekdayEuros || listing.priceWeekendEuros listing.priceWeekdayEuros || listing.priceWeekendEuros
? [listing.priceWeekdayEuros ? t('priceWeekdayShort', { price: listing.priceWeekdayEuros }) : null, listing.priceWeekendEuros ? t('priceWeekendShort', { price: listing.priceWeekendEuros }) : null] ? `${startingFromEuros !== null ? t('priceStartingFromShort', { price: startingFromEuros }) : ''}${
.filter(Boolean) listing.priceWeekdayEuros || listing.priceWeekendEuros
.join(' · ') ? ` (${[listing.priceWeekdayEuros ? t('priceWeekdayShort', { price: listing.priceWeekdayEuros }) : null, listing.priceWeekendEuros ? t('priceWeekendShort', { price: listing.priceWeekendEuros }) : null]
.filter(Boolean)
.join(' · ')})`
: ''
}`
: t('priceNotSet'); : t('priceNotSet');
const isDraftOrPending = listing.status !== ListingStatus.PUBLISHED; const isDraftOrPending = listing.status !== ListingStatus.PUBLISHED;
const isOwnerView = viewerId && listing.ownerId === viewerId; const isOwnerView = viewerId && listing.ownerId === viewerId;

View file

@ -469,13 +469,18 @@ export default function ListingsIndexPage() {
</span> </span>
) : null} ) : null}
<p style={{ margin: 0 }}>{l.teaser ?? ''}</p> <p style={{ margin: 0 }}>{l.teaser ?? ''}</p>
{l.priceWeekdayEuros || l.priceWeekendEuros ? (
<div style={{ color: '#cbd5e1', fontSize: 14 }}>
{t('priceStartingFromShort', {
price: Math.min(...([l.priceWeekdayEuros, l.priceWeekendEuros].filter((p): p is number => typeof p === 'number'))),
})}
</div>
) : null}
<div style={{ color: '#cbd5e1', fontSize: 14 }}> <div style={{ color: '#cbd5e1', fontSize: 14 }}>
{l.streetAddress ? `${l.streetAddress}, ` : ''} {l.streetAddress ? `${l.streetAddress}, ` : ''}
{l.city}, {l.region} {l.city}, {l.region}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', fontSize: 13 }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', fontSize: 13 }}>
{l.priceWeekdayEuros ? <span className="badge">{t('priceWeekdayShort', { price: l.priceWeekdayEuros })}</span> : null}
{l.priceWeekendEuros ? <span className="badge">{t('priceWeekendShort', { price: l.priceWeekendEuros })}</span> : null}
<span className="badge">{t('capacityGuests', { count: l.maxGuests })}</span> <span className="badge">{t('capacityGuests', { count: l.maxGuests })}</span>
<span className="badge">{t('capacityBedrooms', { count: l.bedrooms })}</span> <span className="badge">{t('capacityBedrooms', { count: l.bedrooms })}</span>
{l.hasCalendar ? <span className="badge secondary">{t('calendarConnected')}</span> : null} {l.hasCalendar ? <span className="badge secondary">{t('calendarConnected')}</span> : null}

View file

@ -13,6 +13,8 @@ type LatestListing = {
city: string; city: string;
region: string; region: string;
isSample: boolean; isSample: boolean;
priceWeekdayEuros: number | null;
priceWeekendEuros: number | null;
}; };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -98,6 +100,15 @@ export default function HomePage() {
</div> </div>
<h3 style={{ margin: '6px 0 4px' }}>{item.title}</h3> <h3 style={{ margin: '6px 0 4px' }}>{item.title}</h3>
<p style={{ margin: 0 }}>{item.teaser}</p> <p style={{ margin: 0 }}>{item.teaser}</p>
{item.priceWeekdayEuros || item.priceWeekendEuros ? (
<div style={{ color: '#cbd5e1', fontSize: 14, marginTop: 2 }}>
{t('priceStartingFromShort', {
price: Math.min(
...([item.priceWeekdayEuros, item.priceWeekendEuros].filter((p): p is number => typeof p === 'number')),
),
})}
</div>
) : null}
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
<Link className="button secondary" href={`/listings/${item.slug}`}> <Link className="button secondary" href={`/listings/${item.slug}`}>
{t('openListing')} {t('openListing')}

View file

@ -153,7 +153,7 @@ const baseMessages = {
listingLocation: 'Location', listingLocation: 'Location',
listingAddress: 'Address', listingAddress: 'Address',
listingCapacity: 'Capacity', listingCapacity: 'Capacity',
listingPrices: 'Pricing', listingPrices: 'Price (starting from)',
listingAmenities: 'Amenities', listingAmenities: 'Amenities',
listingNoAmenities: 'No amenities listed yet.', listingNoAmenities: 'No amenities listed yet.',
listingContact: 'Contact', listingContact: 'Contact',
@ -218,9 +218,10 @@ const baseMessages = {
bedroomsLabel: 'Bedrooms', bedroomsLabel: 'Bedrooms',
bedsLabel: 'Beds', bedsLabel: 'Beds',
bathroomsLabel: 'Bathrooms', bathroomsLabel: 'Bathrooms',
priceWeekdayLabel: 'Weeknight price (€ / night)', priceWeekdayLabel: 'Price starting from (weeknight, € / night)',
priceWeekendLabel: 'Weekend price (€ / night)', priceWeekendLabel: 'Price starting from (weekend, € / night)',
priceHintHelp: 'Set separate weeknight and weekend prices in euros (optional, not a binding offer).', priceHintHelp: 'These prices are indicative only (starting from), not a binding offer.',
priceStartingFromShort: 'Starting from {price}€ / night',
priceWeekdayShort: '{price}€ weekday', priceWeekdayShort: '{price}€ weekday',
priceWeekendShort: '{price}€ weekend', priceWeekendShort: '{price}€ weekend',
priceNotSet: 'Not provided', priceNotSet: 'Not provided',
@ -500,7 +501,7 @@ const baseMessages = {
listingLocation: 'Sijainti', listingLocation: 'Sijainti',
listingAddress: 'Osoite', listingAddress: 'Osoite',
listingCapacity: 'Tilat', listingCapacity: 'Tilat',
listingPrices: 'Hinta', listingPrices: 'Hinta (alkaen)',
listingAmenities: 'Varustelu', listingAmenities: 'Varustelu',
listingNoAmenities: 'Varustelua ei ole listattu.', listingNoAmenities: 'Varustelua ei ole listattu.',
listingContact: 'Yhteystiedot', listingContact: 'Yhteystiedot',
@ -538,9 +539,10 @@ const baseMessages = {
bedroomsLabel: 'Makuuhuoneita', bedroomsLabel: 'Makuuhuoneita',
bedsLabel: 'Vuoteita', bedsLabel: 'Vuoteita',
bathroomsLabel: 'Kylpyhuoneita', bathroomsLabel: 'Kylpyhuoneita',
priceWeekdayLabel: 'Arkiyön hinta (€ / yö)', priceWeekdayLabel: 'Hinta alkaen (arki, € / yö)',
priceWeekendLabel: 'Viikonlopun hinta (€ / yö)', priceWeekendLabel: 'Hinta alkaen (viikonloppu, € / yö)',
priceHintHelp: 'Aseta erilliset hinnat arki- ja viikonloppuyöille euroissa (valinnainen, ei sitova).', priceHintHelp: 'Hinnat ovat suuntaa-antavia (alkaen), eivät sitovia.',
priceStartingFromShort: 'Alkaen {price}€ / yö',
priceWeekdayShort: '{price}€ arki', priceWeekdayShort: '{price}€ arki',
priceWeekendShort: '{price}€ viikonloppu', priceWeekendShort: '{price}€ viikonloppu',
priceNotSet: 'Ei ilmoitettu', priceNotSet: 'Ei ilmoitettu',
@ -719,13 +721,14 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
slugTaken: 'Sluggen används redan', slugTaken: 'Sluggen används redan',
slugCheckError: 'Kunde inte kontrollera sluggen nu', slugCheckError: 'Kunde inte kontrollera sluggen nu',
teaserHelp: 'Kort ingress som syns i korten', teaserHelp: 'Kort ingress som syns i korten',
priceWeekdayLabel: 'Vardagspris (€ / natt)', priceWeekdayLabel: 'Pris från (vardag, € / natt)',
priceWeekendLabel: 'Helgpris (€ / natt)', priceWeekendLabel: 'Pris från (helg, € / natt)',
priceHintHelp: 'Ange separata priser för vardag och helg i euro per natt (frivilligt).', priceHintHelp: 'Priserna är endast vägledande (från), inte ett bindande erbjudande.',
priceStartingFromShort: 'Från {price}€ / natt',
priceWeekdayShort: '{price}€ vardag', priceWeekdayShort: '{price}€ vardag',
priceWeekendShort: '{price}€ helg', priceWeekendShort: '{price}€ helg',
priceNotSet: 'Ej angivet', priceNotSet: 'Ej angivet',
listingPrices: 'Priser', listingPrices: 'Pris (från)',
capacityUnknown: 'Kapacitet ej angiven', capacityUnknown: 'Kapacitet ej angiven',
amenityEvAvailable: 'EV-laddning i närheten', amenityEvAvailable: 'EV-laddning i närheten',
amenityEvNearby: 'EV-laddning i närheten', amenityEvNearby: 'EV-laddning i närheten',