From bee691ebd80b518489e7421e170fbabf3bd65911 Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Sat, 6 Dec 2025 13:46:19 +0200 Subject: [PATCH] Add weekday/weekend pricing and new amenities --- PROGRESS.md | 1 + app/api/listings/route.ts | 22 ++++- app/listings/[slug]/page.tsx | 17 ++++ app/listings/new/page.tsx | 69 +++++++++++---- app/listings/page.tsx | 13 ++- lib/i18n.ts | 31 ++++++- .../migration.sql | 15 ++++ prisma/schema.prisma | 5 +- prisma/seed.js | 84 ++++++++++++++----- 9 files changed, 211 insertions(+), 46 deletions(-) create mode 100644 prisma/migrations/20251206_weekday_weekend_prices/migration.sql diff --git a/PROGRESS.md b/PROGRESS.md index 99d0d54..9f3e98a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -70,3 +70,4 @@ - Site navbar now shows the new logo above the lomavuokraus.fi brand text on every page. - Language selector in the navbar aligned with other buttons and given higher-contrast styling. - Security hardening: npm audit now passes cleanly after upgrading Prisma patch release and pinning `glob@10.5.0` via overrides to eliminate the glob CLI injection advisory in eslint tooling. +- Listings now capture separate weekday/weekend prices and new amenities (microwave, free parking) across schema, API, UI, and seeds. diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index ef5019b..86d5cb9 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -50,6 +50,13 @@ function normalizeCalendarUrls(input: unknown): string[] { return []; } +function parsePrice(value: unknown): number | null { + if (value === undefined || value === null || value === '') return null; + const num = Number(value); + if (Number.isNaN(num)) return null; + return Math.round(num); +} + export async function GET(req: Request) { const url = new URL(req.url); const searchParams = url.searchParams; @@ -78,6 +85,8 @@ export async function GET(req: Request) { if (amenityFilters.includes('dishwasher')) amenityWhere.hasDishwasher = true; if (amenityFilters.includes('washer')) amenityWhere.hasWashingMachine = true; if (amenityFilters.includes('barbecue')) amenityWhere.hasBarbecue = true; + if (amenityFilters.includes('microwave')) amenityWhere.hasMicrowave = true; + if (amenityFilters.includes('parking')) amenityWhere.hasFreeParking = true; const where: Prisma.ListingWhereInput = { status: ListingStatus.PUBLISHED, @@ -158,12 +167,15 @@ export async function GET(req: Request) { hasDishwasher: listing.hasDishwasher, hasWashingMachine: listing.hasWashingMachine, hasBarbecue: listing.hasBarbecue, + hasMicrowave: listing.hasMicrowave, + hasFreeParking: listing.hasFreeParking, evCharging: listing.evCharging, maxGuests: listing.maxGuests, bedrooms: listing.bedrooms, beds: listing.beds, bathrooms: listing.bathrooms, - priceHintPerNightEuros: listing.priceHintPerNightEuros, + priceWeekdayEuros: listing.priceWeekdayEuros, + priceWeekendEuros: listing.priceWeekendEuros, coverImage: resolveImageUrl(listing.images.find((img) => img.isCover) ?? listing.images[0] ?? { id: '', url: null, size: null }), isSample, hasCalendar: Boolean(listing.calendarUrls?.length), @@ -199,7 +211,8 @@ export async function POST(req: Request) { const bedrooms = Number(body.bedrooms ?? 1); const beds = Number(body.beds ?? 1); const bathrooms = Number(body.bathrooms ?? 1); - const priceHintPerNightEuros = body.priceHintPerNightEuros !== undefined && body.priceHintPerNightEuros !== null && body.priceHintPerNightEuros !== '' ? Math.round(Number(body.priceHintPerNightEuros)) : null; + const priceWeekdayEuros = parsePrice(body.priceWeekdayEuros); + const priceWeekendEuros = parsePrice(body.priceWeekendEuros); const calendarUrls = normalizeCalendarUrls(body.calendarUrls); const translationsInputRaw = Array.isArray(body.translations) ? body.translations : []; type TranslationInput = { locale: string; title: string; description: string; teaser: string | null; slug: string }; @@ -323,8 +336,11 @@ export async function POST(req: Request) { hasDishwasher: Boolean(body.hasDishwasher), hasWashingMachine: Boolean(body.hasWashingMachine), hasBarbecue: Boolean(body.hasBarbecue), + hasMicrowave: Boolean(body.hasMicrowave), + hasFreeParking: Boolean(body.hasFreeParking), evCharging: normalizeEvCharging(body.evCharging), - priceHintPerNightEuros, + priceWeekdayEuros, + priceWeekendEuros, calendarUrls, contactName, contactEmail, diff --git a/app/listings/[slug]/page.tsx b/app/listings/[slug]/page.tsx index 2e54ac4..e22c6fc 100644 --- a/app/listings/[slug]/page.tsx +++ b/app/listings/[slug]/page.tsx @@ -24,6 +24,8 @@ const amenityIcons: Record = { dishwasher: '🧼', washer: '🧺', barbecue: '🍖', + microwave: '🍲', + parking: '🅿️', }; export async function generateMetadata({ params }: ListingPageProps): Promise { @@ -70,11 +72,19 @@ export default async function ListingPage({ params }: ListingPageProps) { listing.hasDishwasher ? { icon: amenityIcons.dishwasher, label: t('amenityDishwasher') } : null, listing.hasWashingMachine ? { icon: amenityIcons.washer, label: t('amenityWashingMachine') } : null, listing.hasBarbecue ? { icon: amenityIcons.barbecue, label: t('amenityBarbecue') } : null, + listing.hasMicrowave ? { icon: amenityIcons.microwave, label: t('amenityMicrowave') } : null, + listing.hasFreeParking ? { icon: amenityIcons.parking, label: t('amenityFreeParking') } : 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}` : ''}`; const coverImage = listing.images.find((img) => img.isCover) ?? listing.images[0] ?? null; + const priceLine = + listing.priceWeekdayEuros || listing.priceWeekendEuros + ? [listing.priceWeekdayEuros ? t('priceWeekdayShort', { price: listing.priceWeekdayEuros }) : null, listing.priceWeekendEuros ? t('priceWeekendShort', { price: listing.priceWeekendEuros }) : null] + .filter(Boolean) + .join(' · ') + : t('priceNotSet'); return (
@@ -172,6 +182,13 @@ export default async function ListingPage({ params }: ListingPageProps) {
{capacityLine}
+
+ 💶 +
+
{t('listingPrices')}
+
{priceLine}
+
+
✉️
diff --git a/app/listings/new/page.tsx b/app/listings/new/page.tsx index 0130cc1..61a7748 100644 --- a/app/listings/new/page.tsx +++ b/app/listings/new/page.tsx @@ -39,7 +39,8 @@ export default function NewListingPage() { const [bedrooms, setBedrooms] = useState(2); const [beds, setBeds] = useState(3); const [bathrooms, setBathrooms] = useState(1); - const [price, setPrice] = useState(''); + const [priceWeekday, setPriceWeekday] = useState(''); + const [priceWeekend, setPriceWeekend] = useState(''); const [hasSauna, setHasSauna] = useState(true); const [hasFireplace, setHasFireplace] = useState(true); const [hasWifi, setHasWifi] = useState(true); @@ -50,6 +51,8 @@ export default function NewListingPage() { const [hasDishwasher, setHasDishwasher] = useState(false); const [hasWashingMachine, setHasWashingMachine] = useState(false); const [hasBarbecue, setHasBarbecue] = useState(false); + const [hasMicrowave, setHasMicrowave] = useState(false); + const [hasFreeParking, setHasFreeParking] = useState(false); const [evCharging, setEvCharging] = useState<'NONE' | 'FREE' | 'PAID'>('NONE'); const [calendarUrls, setCalendarUrls] = useState(''); const [selectedImages, setSelectedImages] = useState([]); @@ -129,6 +132,8 @@ export default function NewListingPage() { { key: 'dishwasher', label: t('amenityDishwasher'), icon: '🧼', checked: hasDishwasher, toggle: setHasDishwasher }, { key: 'washer', label: t('amenityWashingMachine'), icon: '🧺', checked: hasWashingMachine, toggle: setHasWashingMachine }, { key: 'barbecue', label: t('amenityBarbecue'), icon: '🍖', checked: hasBarbecue, toggle: setHasBarbecue }, + { key: 'microwave', label: t('amenityMicrowave'), icon: '🍲', checked: hasMicrowave, toggle: setHasMicrowave }, + { key: 'parking', label: t('amenityFreeParking'), icon: '🅿️', checked: hasFreeParking, toggle: setHasFreeParking }, ]; function updateTranslation(locale: Locale, field: keyof LocaleFields, value: string) { @@ -342,7 +347,8 @@ export default function NewListingPage() { bedrooms, beds, bathrooms, - priceHintPerNightEuros: price === '' ? null : Math.round(Number(price)), + priceWeekdayEuros: priceWeekday === '' ? null : Math.round(Number(priceWeekday)), + priceWeekendEuros: priceWeekend === '' ? null : Math.round(Number(priceWeekend)), hasSauna, hasFireplace, hasWifi, @@ -353,6 +359,8 @@ export default function NewListingPage() { hasDishwasher, hasWashingMachine, hasBarbecue, + hasMicrowave, + hasFreeParking, evCharging, coverImageIndex, images: parseImages(), @@ -370,6 +378,24 @@ export default function NewListingPage() { fi: { title: '', description: '', teaser: '' }, sv: { title: '', description: '', teaser: '' }, }); + setMaxGuests(4); + setBedrooms(2); + setBeds(3); + setBathrooms(1); + setPriceWeekday(''); + setPriceWeekend(''); + setHasSauna(true); + setHasFireplace(true); + setHasWifi(true); + setPetsAllowed(false); + setByTheLake(false); + setHasAirConditioning(false); + setHasKitchen(true); + setHasDishwasher(false); + setHasWashingMachine(false); + setHasBarbecue(false); + setHasMicrowave(false); + setHasFreeParking(false); setRegion(''); setCity(''); setStreetAddress(''); @@ -594,29 +620,42 @@ export default function NewListingPage() { ))} +
+
-