Add weekday/weekend pricing and new amenities

This commit is contained in:
Tero Halla-aho 2025-12-06 13:46:19 +02:00
parent 8bd0224597
commit bee691ebd8
9 changed files with 211 additions and 46 deletions

View file

@ -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.

View file

@ -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,

View file

@ -24,6 +24,8 @@ const amenityIcons: Record<string, string> = {
dishwasher: '🧼',
washer: '🧺',
barbecue: '🍖',
microwave: '🍲',
parking: '🅿️',
};
export async function generateMetadata({ params }: ListingPageProps): Promise<Metadata> {
@ -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 (
<main className="listing-shell">
@ -172,6 +182,13 @@ export default async function ListingPage({ params }: ListingPageProps) {
<div>{capacityLine}</div>
</div>
</div>
<div className="fact-row">
<span aria-hidden className="amenity-icon">💶</span>
<div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingPrices')}</div>
<div>{priceLine}</div>
</div>
</div>
<div className="fact-row">
<span aria-hidden className="amenity-icon"></span>
<div>

View file

@ -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<number | ''>('');
const [priceWeekday, setPriceWeekday] = useState<number | ''>('');
const [priceWeekend, setPriceWeekend] = useState<number | ''>('');
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<SelectedImage[]>([]);
@ -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() {
))}
</select>
</label>
</div>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<label>
{t('priceHintLabel')}
{t('priceWeekdayLabel')}
<input
type="number"
value={price}
onChange={(e) => setPrice(e.target.value === '' ? '' : Number(e.target.value))}
value={priceWeekday}
onChange={(e) => setPriceWeekday(e.target.value === '' ? '' : Number(e.target.value))}
min={0}
step="10"
placeholder="e.g. 120"
placeholder="120"
/>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHintHelp')}</div>
</label>
<label style={{ gridColumn: '1 / -1' }}>
{t('calendarUrlsLabel')}
<textarea
value={calendarUrls}
onChange={(e) => setCalendarUrls(e.target.value)}
placeholder="https://example.com/calendar.ics"
rows={3}
<label>
{t('priceWeekendLabel')}
<input
type="number"
value={priceWeekend}
onChange={(e) => setPriceWeekend(e.target.value === '' ? '' : Number(e.target.value))}
min={0}
step="10"
placeholder="140"
/>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('calendarUrlsHelp')}</div>
</label>
</div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHintHelp')}</div>
<label style={{ gridColumn: '1 / -1' }}>
{t('calendarUrlsLabel')}
<textarea
value={calendarUrls}
onChange={(e) => setCalendarUrls(e.target.value)}
placeholder="https://example.com/calendar.ics"
rows={3}
/>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('calendarUrlsHelp')}</div>
</label>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
<label>
{t('latitudeLabel')}

View file

@ -28,12 +28,15 @@ type ListingResult = {
hasDishwasher: boolean;
hasWashingMachine: boolean;
hasBarbecue: boolean;
hasMicrowave: boolean;
hasFreeParking: boolean;
evCharging: 'NONE' | 'FREE' | 'PAID';
maxGuests: number;
bedrooms: number;
beds: number;
bathrooms: number;
priceHintPerNightEuros: number | null;
priceWeekdayEuros: number | null;
priceWeekendEuros: number | null;
coverImage: string | null;
isSample: boolean;
hasCalendar: boolean;
@ -84,6 +87,8 @@ const amenityIcons: Record<string, string> = {
dishwasher: '🧼',
washer: '🧺',
barbecue: '🍖',
microwave: '🍲',
parking: '🅿️',
};
function ListingsMap({
@ -216,6 +221,8 @@ export default function ListingsIndexPage() {
{ key: 'dishwasher', label: t('amenityDishwasher'), icon: amenityIcons.dishwasher },
{ key: 'washer', label: t('amenityWashingMachine'), icon: amenityIcons.washer },
{ key: 'barbecue', label: t('amenityBarbecue'), icon: amenityIcons.barbecue },
{ key: 'microwave', label: t('amenityMicrowave'), icon: amenityIcons.microwave },
{ key: 'parking', label: t('amenityFreeParking'), icon: amenityIcons.parking },
];
async function fetchListings() {
@ -467,6 +474,8 @@ export default function ListingsIndexPage() {
{l.city}, {l.region}
</div>
<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('capacityBedrooms', { count: l.bedrooms })}</span>
{l.hasCalendar ? <span className="badge secondary">{t('calendarConnected')}</span> : null}
@ -480,6 +489,8 @@ export default function ListingsIndexPage() {
{l.hasDishwasher ? <span className="badge">{t('amenityDishwasher')}</span> : null}
{l.hasWashingMachine ? <span className="badge">{t('amenityWashingMachine')}</span> : null}
{l.hasBarbecue ? <span className="badge">{t('amenityBarbecue')}</span> : null}
{l.hasMicrowave ? <span className="badge">{t('amenityMicrowave')}</span> : null}
{l.hasFreeParking ? <span className="badge">{t('amenityFreeParking')}</span> : null}
{l.hasSauna ? <span className="badge">{t('amenitySauna')}</span> : null}
{l.hasWifi ? <span className="badge">{t('amenityWifi')}</span> : null}
</div>

View file

@ -125,6 +125,7 @@ const baseMessages = {
listingLocation: 'Location',
listingAddress: 'Address',
listingCapacity: 'Capacity',
listingPrices: 'Pricing',
listingAmenities: 'Amenities',
listingNoAmenities: 'No amenities listed yet.',
listingContact: 'Contact',
@ -186,8 +187,12 @@ const baseMessages = {
bedroomsLabel: 'Bedrooms',
bedsLabel: 'Beds',
bathroomsLabel: 'Bathrooms',
priceHintLabel: 'Price ballpark (€ / night)',
priceHintHelp: 'Rough nightly price in euros (not a binding offer).',
priceWeekdayLabel: 'Weeknight price (€ / night)',
priceWeekendLabel: 'Weekend price (€ / night)',
priceHintHelp: 'Set separate weeknight and weekend prices in euros (optional, not a binding offer).',
priceWeekdayShort: '{price}€ weekday',
priceWeekendShort: '{price}€ weekend',
priceNotSet: 'Not provided',
calendarUrlsLabel: 'Availability calendars (iCal URLs, one per line)',
calendarUrlsHelp: 'Paste iCal links from other platforms. We will merge them to show availability.',
imagesLabel: 'Images',
@ -214,6 +219,8 @@ const baseMessages = {
amenityDishwasher: 'Dishwasher',
amenityWashingMachine: 'Washing machine',
amenityBarbecue: 'Barbecue grill',
amenityMicrowave: 'Microwave',
amenityFreeParking: 'Free parking',
amenityEvFree: 'EV charging (free)',
amenityEvPaid: 'EV charging (paid)',
evChargingLabel: 'EV charging',
@ -428,6 +435,7 @@ const baseMessages = {
listingLocation: 'Sijainti',
listingAddress: 'Osoite',
listingCapacity: 'Tilat',
listingPrices: 'Hinta',
listingAmenities: 'Varustelu',
listingNoAmenities: 'Varustelua ei ole listattu.',
listingContact: 'Yhteystiedot',
@ -464,8 +472,12 @@ const baseMessages = {
bedroomsLabel: 'Makuuhuoneita',
bedsLabel: 'Vuoteita',
bathroomsLabel: 'Kylpyhuoneita',
priceHintLabel: 'Hinta-arvio (€ / yö)',
priceHintHelp: 'Suuntaa-antava hinta euroina per yö (ei sitova).',
priceWeekdayLabel: 'Arkiyön hinta (€ / yö)',
priceWeekendLabel: 'Viikonlopun hinta (€ / yö)',
priceHintHelp: 'Aseta erilliset hinnat arki- ja viikonloppuyöille euroissa (valinnainen, ei sitova).',
priceWeekdayShort: '{price}€ arki',
priceWeekendShort: '{price}€ viikonloppu',
priceNotSet: 'Ei ilmoitettu',
calendarUrlsLabel: 'Saatavuuskalenterit (iCal-osoitteet, yksi per rivi)',
calendarUrlsHelp: 'Liitä iCal-linkit muilta alustoilta. Yhdistämme ne saatavuuden näyttämiseen.',
imagesLabel: 'Kuvat',
@ -492,6 +504,8 @@ const baseMessages = {
amenityDishwasher: 'Astianpesukone',
amenityWashingMachine: 'Pyykinpesukone',
amenityBarbecue: 'Grilli',
amenityMicrowave: 'Mikroaaltouuni',
amenityFreeParking: 'Maksuton pysäköinti',
amenityEvFree: 'Sähköauton lataus (ilmainen)',
amenityEvPaid: 'Sähköauton lataus (maksullinen)',
evChargingLabel: 'Sähköauton lataus',
@ -606,6 +620,15 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
slugTaken: 'Sluggen används redan',
slugCheckError: 'Kunde inte kontrollera sluggen nu',
teaserHelp: 'Kort ingress som syns i korten',
priceWeekdayLabel: 'Vardagspris (€ / natt)',
priceWeekendLabel: 'Helgpris (€ / natt)',
priceHintHelp: 'Ange separata priser för vardag och helg i euro per natt (frivilligt).',
priceWeekdayShort: '{price}€ vardag',
priceWeekendShort: '{price}€ helg',
priceNotSet: 'Ej angivet',
listingPrices: 'Priser',
amenityMicrowave: 'Mikrovågsugn',
amenityFreeParking: 'Gratis parkering',
};
export const messages = { ...baseMessages, sv: svMessages } as const;

View file

@ -0,0 +1,15 @@
-- Split single price hint into weekday/weekend prices and add new amenities
ALTER TABLE "Listing"
ADD COLUMN "priceWeekdayEuros" INTEGER,
ADD COLUMN "priceWeekendEuros" INTEGER,
ADD COLUMN "hasMicrowave" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "hasFreeParking" BOOLEAN NOT NULL DEFAULT false;
UPDATE "Listing"
SET
"priceWeekdayEuros" = "priceHintPerNightEuros",
"priceWeekendEuros" = "priceHintPerNightEuros"
WHERE "priceWeekdayEuros" IS NULL
AND "priceWeekendEuros" IS NULL;
ALTER TABLE "Listing" DROP COLUMN "priceHintPerNightEuros";

View file

@ -92,9 +92,12 @@ model Listing {
hasDishwasher Boolean @default(false)
hasWashingMachine Boolean @default(false)
hasBarbecue Boolean @default(false)
hasMicrowave Boolean @default(false)
hasFreeParking Boolean @default(false)
evCharging EvCharging @default(NONE)
calendarUrls String[] @db.Text @default([])
priceHintPerNightEuros Int?
priceWeekdayEuros Int?
priceWeekendEuros Int?
contactName String
contactEmail String
contactPhone String?

View file

@ -157,10 +157,13 @@ async function main() {
hasDishwasher: false,
hasWashingMachine: false,
hasBarbecue: false,
hasMicrowave: true,
hasFreeParking: true,
petsAllowed: false,
byTheLake: true,
evCharging: 'FREE',
priceHintPerNightEuros: 145,
priceWeekdayEuros: 145,
priceWeekendEuros: 165,
cover: {
file: 'saimaa-lakeside-cabin-cover.jpg',
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
@ -201,10 +204,13 @@ async function main() {
hasDishwasher: false,
hasWashingMachine: false,
hasBarbecue: false,
hasMicrowave: true,
hasFreeParking: false,
petsAllowed: false,
byTheLake: false,
evCharging: 'PAID',
priceHintPerNightEuros: 165,
priceWeekdayEuros: 165,
priceWeekendEuros: 185,
cover: {
file: 'helsinki-design-loft-cover.jpg',
url: 'https://images.unsplash.com/photo-1505693415763-3bd1620f58c3?auto=format&fit=crop&w=1600&q=80',
@ -243,10 +249,13 @@ async function main() {
hasDishwasher: false,
hasWashingMachine: false,
hasBarbecue: false,
hasMicrowave: true,
hasFreeParking: true,
petsAllowed: true,
byTheLake: false,
evCharging: 'NONE',
priceHintPerNightEuros: 110,
priceWeekdayEuros: 110,
priceWeekendEuros: 125,
cover: {
file: 'turku-riverside-apartment-cover.jpg',
url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80',
@ -284,10 +293,13 @@ async function main() {
hasDishwasher: false,
hasWashingMachine: false,
hasBarbecue: false,
hasMicrowave: false,
hasFreeParking: true,
petsAllowed: false,
byTheLake: true,
evCharging: 'FREE',
priceHintPerNightEuros: 189,
priceWeekdayEuros: 189,
priceWeekendEuros: 215,
cover: {
file: 'rovaniemi-aurora-cabin-cover.jpg',
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80',
@ -325,10 +337,13 @@ async function main() {
hasDishwasher: false,
hasWashingMachine: false,
hasBarbecue: false,
hasMicrowave: true,
hasFreeParking: false,
petsAllowed: false,
byTheLake: false,
evCharging: 'NONE',
priceHintPerNightEuros: 95,
priceWeekdayEuros: 95,
priceWeekendEuros: 110,
cover: {
file: 'tampere-sauna-studio-cover.jpg',
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
@ -364,10 +379,13 @@ async function main() {
hasDishwasher: false,
hasWashingMachine: false,
hasBarbecue: false,
hasMicrowave: true,
hasFreeParking: true,
petsAllowed: true,
byTheLake: true,
evCharging: 'PAID',
priceHintPerNightEuros: 245,
priceWeekdayEuros: 245,
priceWeekendEuros: 275,
cover: {
file: 'vaasa-seaside-villa-cover.jpg',
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
@ -403,10 +421,13 @@ async function main() {
hasDishwasher: false,
hasWashingMachine: false,
hasBarbecue: false,
hasMicrowave: true,
hasFreeParking: true,
petsAllowed: false,
byTheLake: true,
evCharging: 'FREE',
priceHintPerNightEuros: 129,
priceWeekdayEuros: 129,
priceWeekendEuros: 149,
cover: {
file: 'kuopio-lakeside-apartment-cover.jpg',
url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80',
@ -442,10 +463,13 @@ async function main() {
hasDishwasher: false,
hasWashingMachine: false,
hasBarbecue: false,
hasMicrowave: true,
hasFreeParking: false,
petsAllowed: false,
byTheLake: false,
evCharging: 'NONE',
priceHintPerNightEuros: 99,
priceWeekdayEuros: 99,
priceWeekendEuros: 115,
cover: {
file: 'porvoo-river-loft-cover.jpg',
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
@ -481,10 +505,13 @@ async function main() {
hasDishwasher: false,
hasWashingMachine: false,
hasBarbecue: false,
hasMicrowave: true,
hasFreeParking: true,
petsAllowed: false,
byTheLake: false,
evCharging: 'FREE',
priceHintPerNightEuros: 105,
priceWeekdayEuros: 105,
priceWeekendEuros: 120,
cover: {
file: 'oulu-tech-apartment-cover.jpg',
url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80',
@ -520,10 +547,13 @@ async function main() {
hasDishwasher: false,
hasWashingMachine: false,
hasBarbecue: false,
hasMicrowave: true,
hasFreeParking: true,
petsAllowed: false,
byTheLake: true,
evCharging: 'PAID',
priceHintPerNightEuros: 115,
priceWeekdayEuros: 115,
priceWeekendEuros: 130,
cover: {
file: 'mariehamn-harbor-flat-cover.jpg',
url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80',
@ -541,16 +571,20 @@ async function main() {
// Fill in any missing amenities/prices with reasonable defaults
const randBool = (p = 0.5) => Math.random() < p;
listings = listings.map((item) => ({
...item,
priceHintPerNightEuros:
item.priceHintPerNightEuros ??
Math.round(Math.random() * (220 - 90) + 90),
hasKitchen: item.hasKitchen ?? randBool(0.9),
hasDishwasher: item.hasDishwasher ?? randBool(0.6),
hasWashingMachine: item.hasWashingMachine ?? randBool(0.6),
hasBarbecue: item.hasBarbecue ?? randBool(0.5),
}));
listings = listings.map((item) => {
const weekdayPrice = item.priceWeekdayEuros ?? Math.round(Math.random() * (220 - 90) + 90);
return {
...item,
priceWeekdayEuros: weekdayPrice,
priceWeekendEuros: item.priceWeekendEuros ?? (weekdayPrice ? weekdayPrice + 15 : null),
hasKitchen: item.hasKitchen ?? randBool(0.9),
hasDishwasher: item.hasDishwasher ?? randBool(0.6),
hasWashingMachine: item.hasWashingMachine ?? randBool(0.6),
hasBarbecue: item.hasBarbecue ?? randBool(0.5),
hasMicrowave: item.hasMicrowave ?? randBool(0.7),
hasFreeParking: item.hasFreeParking ?? randBool(0.6),
};
});
for (const item of listings) {
const existing = await prisma.listingTranslation.findFirst({ where: { slug: item.slug }, select: { listingId: true } });
@ -582,10 +616,13 @@ async function main() {
hasDishwasher: item.hasDishwasher ?? false,
hasWashingMachine: item.hasWashingMachine ?? false,
hasBarbecue: item.hasBarbecue ?? false,
hasMicrowave: item.hasMicrowave ?? false,
hasFreeParking: item.hasFreeParking ?? false,
petsAllowed: item.petsAllowed,
byTheLake: item.byTheLake,
evCharging: item.evCharging,
priceHintPerNightEuros: item.priceHintPerNightEuros,
priceWeekdayEuros: item.priceWeekdayEuros,
priceWeekendEuros: item.priceWeekendEuros,
contactName: 'Sample Host',
contactEmail: SAMPLE_EMAIL,
contactPhone: owner.phone,
@ -629,10 +666,13 @@ async function main() {
hasDishwasher: item.hasDishwasher ?? false,
hasWashingMachine: item.hasWashingMachine ?? false,
hasBarbecue: item.hasBarbecue ?? false,
hasMicrowave: item.hasMicrowave ?? false,
hasFreeParking: item.hasFreeParking ?? false,
petsAllowed: item.petsAllowed,
byTheLake: item.byTheLake,
evCharging: item.evCharging,
priceHintPerNightEuros: item.priceHintPerNightEuros,
priceWeekdayEuros: item.priceWeekdayEuros,
priceWeekendEuros: item.priceWeekendEuros,
contactName: 'Sample Host',
contactEmail: SAMPLE_EMAIL,
contactPhone: owner.phone,