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. - 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. - 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. - 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 []; 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) { export async function GET(req: Request) {
const url = new URL(req.url); const url = new URL(req.url);
const searchParams = url.searchParams; const searchParams = url.searchParams;
@ -78,6 +85,8 @@ export async function GET(req: Request) {
if (amenityFilters.includes('dishwasher')) amenityWhere.hasDishwasher = true; if (amenityFilters.includes('dishwasher')) amenityWhere.hasDishwasher = true;
if (amenityFilters.includes('washer')) amenityWhere.hasWashingMachine = true; if (amenityFilters.includes('washer')) amenityWhere.hasWashingMachine = true;
if (amenityFilters.includes('barbecue')) amenityWhere.hasBarbecue = 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 = { const where: Prisma.ListingWhereInput = {
status: ListingStatus.PUBLISHED, status: ListingStatus.PUBLISHED,
@ -158,12 +167,15 @@ export async function GET(req: Request) {
hasDishwasher: listing.hasDishwasher, hasDishwasher: listing.hasDishwasher,
hasWashingMachine: listing.hasWashingMachine, hasWashingMachine: listing.hasWashingMachine,
hasBarbecue: listing.hasBarbecue, hasBarbecue: listing.hasBarbecue,
hasMicrowave: listing.hasMicrowave,
hasFreeParking: listing.hasFreeParking,
evCharging: listing.evCharging, evCharging: listing.evCharging,
maxGuests: listing.maxGuests, maxGuests: listing.maxGuests,
bedrooms: listing.bedrooms, bedrooms: listing.bedrooms,
beds: listing.beds, beds: listing.beds,
bathrooms: listing.bathrooms, 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 }), coverImage: resolveImageUrl(listing.images.find((img) => img.isCover) ?? listing.images[0] ?? { id: '', url: null, size: null }),
isSample, isSample,
hasCalendar: Boolean(listing.calendarUrls?.length), hasCalendar: Boolean(listing.calendarUrls?.length),
@ -199,7 +211,8 @@ export async function POST(req: Request) {
const bedrooms = Number(body.bedrooms ?? 1); const bedrooms = Number(body.bedrooms ?? 1);
const beds = Number(body.beds ?? 1); const beds = Number(body.beds ?? 1);
const bathrooms = Number(body.bathrooms ?? 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 calendarUrls = normalizeCalendarUrls(body.calendarUrls);
const translationsInputRaw = Array.isArray(body.translations) ? body.translations : []; const translationsInputRaw = Array.isArray(body.translations) ? body.translations : [];
type TranslationInput = { locale: string; title: string; description: string; teaser: string | null; slug: string }; 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), hasDishwasher: Boolean(body.hasDishwasher),
hasWashingMachine: Boolean(body.hasWashingMachine), hasWashingMachine: Boolean(body.hasWashingMachine),
hasBarbecue: Boolean(body.hasBarbecue), hasBarbecue: Boolean(body.hasBarbecue),
hasMicrowave: Boolean(body.hasMicrowave),
hasFreeParking: Boolean(body.hasFreeParking),
evCharging: normalizeEvCharging(body.evCharging), evCharging: normalizeEvCharging(body.evCharging),
priceHintPerNightEuros, priceWeekdayEuros,
priceWeekendEuros,
calendarUrls, calendarUrls,
contactName, contactName,
contactEmail, contactEmail,

View file

@ -24,6 +24,8 @@ const amenityIcons: Record<string, string> = {
dishwasher: '🧼', dishwasher: '🧼',
washer: '🧺', washer: '🧺',
barbecue: '🍖', barbecue: '🍖',
microwave: '🍲',
parking: '🅿️',
}; };
export async function generateMetadata({ params }: ListingPageProps): Promise<Metadata> { 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.hasDishwasher ? { icon: amenityIcons.dishwasher, label: t('amenityDishwasher') } : null,
listing.hasWashingMachine ? { icon: amenityIcons.washer, label: t('amenityWashingMachine') } : null, listing.hasWashingMachine ? { icon: amenityIcons.washer, label: t('amenityWashingMachine') } : null,
listing.hasBarbecue ? { icon: amenityIcons.barbecue, label: t('amenityBarbecue') } : 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 }[]; ].filter(Boolean) as { icon: string; label: string }[];
const addressLine = `${listing.streetAddress ? `${listing.streetAddress}, ` : ''}${listing.city}, ${listing.region}, ${listing.country}`; 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 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 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 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 ( return (
<main className="listing-shell"> <main className="listing-shell">
@ -172,6 +182,13 @@ export default async function ListingPage({ params }: ListingPageProps) {
<div>{capacityLine}</div> <div>{capacityLine}</div>
</div> </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"> <div className="fact-row">
<span aria-hidden className="amenity-icon"></span> <span aria-hidden className="amenity-icon"></span>
<div> <div>

View file

@ -39,7 +39,8 @@ export default function NewListingPage() {
const [bedrooms, setBedrooms] = useState(2); const [bedrooms, setBedrooms] = useState(2);
const [beds, setBeds] = useState(3); const [beds, setBeds] = useState(3);
const [bathrooms, setBathrooms] = useState(1); 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 [hasSauna, setHasSauna] = useState(true);
const [hasFireplace, setHasFireplace] = useState(true); const [hasFireplace, setHasFireplace] = useState(true);
const [hasWifi, setHasWifi] = useState(true); const [hasWifi, setHasWifi] = useState(true);
@ -50,6 +51,8 @@ export default function NewListingPage() {
const [hasDishwasher, setHasDishwasher] = useState(false); const [hasDishwasher, setHasDishwasher] = useState(false);
const [hasWashingMachine, setHasWashingMachine] = useState(false); const [hasWashingMachine, setHasWashingMachine] = useState(false);
const [hasBarbecue, setHasBarbecue] = 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 [evCharging, setEvCharging] = useState<'NONE' | 'FREE' | 'PAID'>('NONE');
const [calendarUrls, setCalendarUrls] = useState(''); const [calendarUrls, setCalendarUrls] = useState('');
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]); const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
@ -129,6 +132,8 @@ export default function NewListingPage() {
{ key: 'dishwasher', label: t('amenityDishwasher'), icon: '🧼', checked: hasDishwasher, toggle: setHasDishwasher }, { key: 'dishwasher', label: t('amenityDishwasher'), icon: '🧼', checked: hasDishwasher, toggle: setHasDishwasher },
{ key: 'washer', label: t('amenityWashingMachine'), icon: '🧺', checked: hasWashingMachine, toggle: setHasWashingMachine }, { key: 'washer', label: t('amenityWashingMachine'), icon: '🧺', checked: hasWashingMachine, toggle: setHasWashingMachine },
{ key: 'barbecue', label: t('amenityBarbecue'), icon: '🍖', checked: hasBarbecue, toggle: setHasBarbecue }, { 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) { function updateTranslation(locale: Locale, field: keyof LocaleFields, value: string) {
@ -342,7 +347,8 @@ export default function NewListingPage() {
bedrooms, bedrooms,
beds, beds,
bathrooms, bathrooms,
priceHintPerNightEuros: price === '' ? null : Math.round(Number(price)), priceWeekdayEuros: priceWeekday === '' ? null : Math.round(Number(priceWeekday)),
priceWeekendEuros: priceWeekend === '' ? null : Math.round(Number(priceWeekend)),
hasSauna, hasSauna,
hasFireplace, hasFireplace,
hasWifi, hasWifi,
@ -353,6 +359,8 @@ export default function NewListingPage() {
hasDishwasher, hasDishwasher,
hasWashingMachine, hasWashingMachine,
hasBarbecue, hasBarbecue,
hasMicrowave,
hasFreeParking,
evCharging, evCharging,
coverImageIndex, coverImageIndex,
images: parseImages(), images: parseImages(),
@ -370,6 +378,24 @@ export default function NewListingPage() {
fi: { title: '', description: '', teaser: '' }, fi: { title: '', description: '', teaser: '' },
sv: { 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(''); setRegion('');
setCity(''); setCity('');
setStreetAddress(''); setStreetAddress('');
@ -594,18 +620,32 @@ export default function NewListingPage() {
))} ))}
</select> </select>
</label> </label>
</div>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<label> <label>
{t('priceHintLabel')} {t('priceWeekdayLabel')}
<input <input
type="number" type="number"
value={price} value={priceWeekday}
onChange={(e) => setPrice(e.target.value === '' ? '' : Number(e.target.value))} onChange={(e) => setPriceWeekday(e.target.value === '' ? '' : Number(e.target.value))}
min={0} min={0}
step="10" step="10"
placeholder="e.g. 120" placeholder="120"
/> />
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHintHelp')}</div>
</label> </label>
<label>
{t('priceWeekendLabel')}
<input
type="number"
value={priceWeekend}
onChange={(e) => setPriceWeekend(e.target.value === '' ? '' : Number(e.target.value))}
min={0}
step="10"
placeholder="140"
/>
</label>
</div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHintHelp')}</div>
<label style={{ gridColumn: '1 / -1' }}> <label style={{ gridColumn: '1 / -1' }}>
{t('calendarUrlsLabel')} {t('calendarUrlsLabel')}
<textarea <textarea
@ -616,7 +656,6 @@ export default function NewListingPage() {
/> />
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('calendarUrlsHelp')}</div> <div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('calendarUrlsHelp')}</div>
</label> </label>
</div>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}> <div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
<label> <label>
{t('latitudeLabel')} {t('latitudeLabel')}

View file

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

View file

@ -125,6 +125,7 @@ const baseMessages = {
listingLocation: 'Location', listingLocation: 'Location',
listingAddress: 'Address', listingAddress: 'Address',
listingCapacity: 'Capacity', listingCapacity: 'Capacity',
listingPrices: 'Pricing',
listingAmenities: 'Amenities', listingAmenities: 'Amenities',
listingNoAmenities: 'No amenities listed yet.', listingNoAmenities: 'No amenities listed yet.',
listingContact: 'Contact', listingContact: 'Contact',
@ -186,8 +187,12 @@ const baseMessages = {
bedroomsLabel: 'Bedrooms', bedroomsLabel: 'Bedrooms',
bedsLabel: 'Beds', bedsLabel: 'Beds',
bathroomsLabel: 'Bathrooms', bathroomsLabel: 'Bathrooms',
priceHintLabel: 'Price ballpark (€ / night)', priceWeekdayLabel: 'Weeknight price (€ / night)',
priceHintHelp: 'Rough nightly price in euros (not a binding offer).', 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)', calendarUrlsLabel: 'Availability calendars (iCal URLs, one per line)',
calendarUrlsHelp: 'Paste iCal links from other platforms. We will merge them to show availability.', calendarUrlsHelp: 'Paste iCal links from other platforms. We will merge them to show availability.',
imagesLabel: 'Images', imagesLabel: 'Images',
@ -214,6 +219,8 @@ const baseMessages = {
amenityDishwasher: 'Dishwasher', amenityDishwasher: 'Dishwasher',
amenityWashingMachine: 'Washing machine', amenityWashingMachine: 'Washing machine',
amenityBarbecue: 'Barbecue grill', amenityBarbecue: 'Barbecue grill',
amenityMicrowave: 'Microwave',
amenityFreeParking: 'Free parking',
amenityEvFree: 'EV charging (free)', amenityEvFree: 'EV charging (free)',
amenityEvPaid: 'EV charging (paid)', amenityEvPaid: 'EV charging (paid)',
evChargingLabel: 'EV charging', evChargingLabel: 'EV charging',
@ -428,6 +435,7 @@ const baseMessages = {
listingLocation: 'Sijainti', listingLocation: 'Sijainti',
listingAddress: 'Osoite', listingAddress: 'Osoite',
listingCapacity: 'Tilat', listingCapacity: 'Tilat',
listingPrices: 'Hinta',
listingAmenities: 'Varustelu', listingAmenities: 'Varustelu',
listingNoAmenities: 'Varustelua ei ole listattu.', listingNoAmenities: 'Varustelua ei ole listattu.',
listingContact: 'Yhteystiedot', listingContact: 'Yhteystiedot',
@ -464,8 +472,12 @@ const baseMessages = {
bedroomsLabel: 'Makuuhuoneita', bedroomsLabel: 'Makuuhuoneita',
bedsLabel: 'Vuoteita', bedsLabel: 'Vuoteita',
bathroomsLabel: 'Kylpyhuoneita', bathroomsLabel: 'Kylpyhuoneita',
priceHintLabel: 'Hinta-arvio (€ / yö)', priceWeekdayLabel: 'Arkiyön hinta (€ / yö)',
priceHintHelp: 'Suuntaa-antava hinta euroina per yö (ei sitova).', 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)', calendarUrlsLabel: 'Saatavuuskalenterit (iCal-osoitteet, yksi per rivi)',
calendarUrlsHelp: 'Liitä iCal-linkit muilta alustoilta. Yhdistämme ne saatavuuden näyttämiseen.', calendarUrlsHelp: 'Liitä iCal-linkit muilta alustoilta. Yhdistämme ne saatavuuden näyttämiseen.',
imagesLabel: 'Kuvat', imagesLabel: 'Kuvat',
@ -492,6 +504,8 @@ const baseMessages = {
amenityDishwasher: 'Astianpesukone', amenityDishwasher: 'Astianpesukone',
amenityWashingMachine: 'Pyykinpesukone', amenityWashingMachine: 'Pyykinpesukone',
amenityBarbecue: 'Grilli', amenityBarbecue: 'Grilli',
amenityMicrowave: 'Mikroaaltouuni',
amenityFreeParking: 'Maksuton pysäköinti',
amenityEvFree: 'Sähköauton lataus (ilmainen)', amenityEvFree: 'Sähköauton lataus (ilmainen)',
amenityEvPaid: 'Sähköauton lataus (maksullinen)', amenityEvPaid: 'Sähköauton lataus (maksullinen)',
evChargingLabel: 'Sähköauton lataus', evChargingLabel: 'Sähköauton lataus',
@ -606,6 +620,15 @@ 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)',
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; 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) hasDishwasher Boolean @default(false)
hasWashingMachine Boolean @default(false) hasWashingMachine Boolean @default(false)
hasBarbecue Boolean @default(false) hasBarbecue Boolean @default(false)
hasMicrowave Boolean @default(false)
hasFreeParking Boolean @default(false)
evCharging EvCharging @default(NONE) evCharging EvCharging @default(NONE)
calendarUrls String[] @db.Text @default([]) calendarUrls String[] @db.Text @default([])
priceHintPerNightEuros Int? priceWeekdayEuros Int?
priceWeekendEuros Int?
contactName String contactName String
contactEmail String contactEmail String
contactPhone String? contactPhone String?

View file

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