Update price hint to euros and improve amenities UI

This commit is contained in:
Tero Halla-aho 2025-11-26 14:27:55 +02:00
parent 6d8d23b8fc
commit 0041561795
48 changed files with 1004 additions and 209 deletions

View file

@ -13,6 +13,8 @@ FROM node:${NODE_VERSION}-bookworm-slim AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder"
ARG APP_VERSION=dev
ENV NEXT_PUBLIC_VERSION=$APP_VERSION
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate

View file

@ -51,6 +51,12 @@
- Updated docs to fix Mermaid syntax and labels; Mermaid renders cleanly across all pages.
- Local Docker cleanup: removed all stale images (including registry.halla-aho.net:443 tags); only current `3a5de63` and `latest` remain.
- 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.
- Listing images now stored in DB (binary) with API serving `/api/images/:id`; upload limited to 6 images (5MB each) and seed pulls from `sampleimages/` if present.
- Sample listings flagged via `isSample`, seeded demo listings marked, and UI badges added to identify them.
- Privacy page localized (FI/EN) via i18n.
- Version hash now injected via build arg (`NEXT_PUBLIC_VERSION`) and shown in footer; build scripts updated.
- In-cluster Varnish cache added in Deployment to cache `/api/images/*` and static assets.
- Added `generate_images.py` and committed sample image assets for reseeding/rebuilds.
To resume:
1) If desired, render diagrams locally: PlantUML in `docs/plantuml`, draw.io in `docs/drawio`.

View file

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../../lib/prisma';
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const image = await prisma.listingImage.findUnique({
where: { id: params.id },
select: { data: true, mimeType: true, url: true, updatedAt: true },
});
if (!image) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (image.data) {
const res = new NextResponse(image.data, {
status: 200,
headers: {
'Content-Type': image.mimeType || 'application/octet-stream',
'Cache-Control': 'public, max-age=86400',
},
});
if (image.updatedAt) {
res.headers.set('Last-Modified', image.updatedAt.toUTCString());
}
return res;
}
if (image.url) {
return NextResponse.redirect(image.url, { status: 302 });
}
return NextResponse.json({ error: 'Image missing' }, { status: 404 });
}

View file

@ -3,6 +3,11 @@ import { ListingStatus, UserStatus, EvCharging, Prisma } from '@prisma/client';
import { prisma } from '../../../lib/prisma';
import { requireAuth } from '../../../lib/jwt';
import { resolveLocale } from '../../../lib/i18n';
import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing';
const MAX_IMAGES = 6;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image
const SAMPLE_EMAIL = 'host@lomavuokraus.fi';
function normalizeEvCharging(input?: string | null): EvCharging {
const value = String(input ?? 'NONE').toUpperCase();
@ -11,6 +16,13 @@ function normalizeEvCharging(input?: string | null): EvCharging {
return EvCharging.NONE;
}
function resolveImageUrl(img: { id: string; url: string | null; size: number | null }) {
if (img.size && img.size > 0) {
return `/api/images/${img.id}`;
}
return img.url ?? null;
}
function pickTranslation<T extends { locale: string }>(translations: T[], locale: string | null): T | null {
if (!translations.length) return null;
if (locale) {
@ -54,13 +66,19 @@ export async function GET(req: Request) {
where,
include: {
translations: { select: { id: true, locale: true, title: true, slug: true, teaser: true, description: true } },
images: { select: { id: true, url: true, altText: true, order: true, isCover: true }, orderBy: { order: 'asc' } },
images: { select: { id: true, url: true, altText: true, order: true, isCover: true, size: true }, orderBy: { order: 'asc' } },
},
orderBy: { createdAt: 'desc' },
take: Number.isNaN(limit) ? 40 : limit,
});
const payload = listings.map((listing) => {
const isSample =
listing.isSample ||
listing.contactEmail === SAMPLE_EMAIL ||
SAMPLE_LISTING_SLUGS.includes(
pickTranslation(listing.translations, locale)?.slug ?? listing.translations[0]?.slug ?? '',
);
const translation = pickTranslation(listing.translations, locale);
const fallback = listing.translations[0];
return {
@ -87,16 +105,15 @@ export async function GET(req: Request) {
bedrooms: listing.bedrooms,
beds: listing.beds,
bathrooms: listing.bathrooms,
priceHintPerNightCents: listing.priceHintPerNightCents,
coverImage: (listing.images.find((img) => img.isCover) ?? listing.images[0])?.url ?? null,
priceHintPerNightEuros: listing.priceHintPerNightEuros,
coverImage: resolveImageUrl(listing.images.find((img) => img.isCover) ?? listing.images[0] ?? { id: '', url: null, size: null }),
isSample,
};
});
return NextResponse.json({ listings: payload });
}
const MAX_IMAGES = 10;
export async function POST(req: Request) {
try {
const auth = await requireAuth(req);
@ -125,13 +142,70 @@ 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 priceHintPerNightCents = body.priceHintPerNightCents ? Number(body.priceHintPerNightCents) : null;
const priceHintPerNightEuros = body.priceHintPerNightEuros !== undefined && body.priceHintPerNightEuros !== null && body.priceHintPerNightEuros !== '' ? Math.round(Number(body.priceHintPerNightEuros)) : null;
const images = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : [];
if (Array.isArray(body.images) && body.images.length > MAX_IMAGES) {
return NextResponse.json({ error: `Too many images (max ${MAX_IMAGES})` }, { status: 400 });
}
const coverImageIndex = Math.min(Math.max(Number(body.coverImageIndex ?? 1), 1), images.length || 1);
const parsedImages: {
data?: Buffer;
mimeType?: string | null;
size?: number | null;
url?: string | null;
altText?: string | null;
order: number;
isCover: boolean;
}[] = [];
for (let idx = 0; idx < images.length; idx += 1) {
const img = images[idx];
const altText = typeof img.altText === 'string' && img.altText.trim() ? img.altText.trim() : null;
const rawMime = typeof img.mimeType === 'string' ? img.mimeType : null;
const rawData = typeof img.data === 'string' ? img.data : null;
const rawUrl = typeof img.url === 'string' && img.url.trim() ? img.url.trim() : null;
let mimeType = rawMime;
let buffer: Buffer | null = null;
if (rawData) {
const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/);
if (dataUrlMatch) {
mimeType = mimeType || dataUrlMatch[1] || null;
buffer = Buffer.from(dataUrlMatch[2], 'base64');
} else {
buffer = Buffer.from(rawData, 'base64');
}
}
const size = buffer ? buffer.length : null;
if (size && size > MAX_IMAGE_BYTES) {
return NextResponse.json({ error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)` }, { status: 400 });
}
if (!buffer && !rawUrl) {
continue;
}
parsedImages.push({
data: buffer ?? undefined,
mimeType: mimeType || 'image/jpeg',
size,
url: buffer ? null : rawUrl,
altText,
order: idx + 1,
isCover: coverImageIndex === idx + 1,
});
}
if (parsedImages.length && !parsedImages.some((img) => img.isCover)) {
parsedImages[0].isCover = true;
}
const autoApprove = process.env.AUTO_APPROVE_LISTINGS === 'true' || auth.role === 'ADMIN';
const status = autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING;
const isSample = contactEmail.toLowerCase() === SAMPLE_EMAIL;
const listing = await prisma.listing.create({
data: {
@ -157,12 +231,13 @@ export async function POST(req: Request) {
byTheLake: Boolean(body.byTheLake),
hasAirConditioning: Boolean(body.hasAirConditioning),
evCharging: normalizeEvCharging(body.evCharging),
priceHintPerNightCents,
priceHintPerNightEuros,
contactName,
contactEmail,
contactPhone: body.contactPhone ?? null,
externalUrl: body.externalUrl ?? null,
published: status === ListingStatus.PUBLISHED,
isSample,
translations: {
create: {
locale,
@ -172,18 +247,13 @@ export async function POST(req: Request) {
teaser: body.teaser ?? null,
},
},
images: images.length
images: parsedImages.length
? {
create: images.map((img: any, idx: number) => ({
url: String(img.url ?? ''),
altText: img.altText ? String(img.altText) : null,
order: idx + 1,
isCover: coverImageIndex === idx + 1,
})),
create: parsedImages,
}
: undefined,
},
include: { translations: true, images: true },
include: { translations: true, images: { select: { id: true, altText: true, order: true, isCover: true, size: true, url: true } } },
});
return NextResponse.json({ ok: true, listing });

View file

@ -296,6 +296,111 @@ p {
font-size: 18px;
}
.amenity-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.amenity-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.04));
cursor: pointer;
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease, background 120ms ease;
color: var(--text);
width: 100%;
text-align: left;
}
.amenity-option:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.amenity-option.selected {
border-color: var(--accent-strong);
box-shadow: 0 12px 36px rgba(14, 165, 233, 0.2);
background: linear-gradient(145deg, rgba(34, 211, 238, 0.08), rgba(14, 165, 233, 0.12));
}
.amenity-option-meta {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
}
.amenity-emoji {
font-size: 20px;
}
.amenity-name {
color: var(--text);
}
.amenity-check {
width: 26px;
height: 26px;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.4);
display: grid;
place-items: center;
font-weight: 800;
color: #0b1224;
background: rgba(255, 255, 255, 0.8);
}
.amenity-ev {
border: 1px dashed rgba(148, 163, 184, 0.35);
border-radius: 12px;
padding: 12px 14px;
display: grid;
gap: 8px;
}
.amenity-ev-label {
font-weight: 700;
color: var(--muted);
}
.ev-toggle-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.ev-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(148, 163, 184, 0.25);
color: var(--text);
cursor: pointer;
font-weight: 700;
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
}
.ev-toggle:hover {
transform: translateY(-1px);
border-color: var(--accent);
}
.ev-toggle.active {
background: var(--accent);
color: #0b1224;
border-color: var(--accent-strong);
box-shadow: 0 10px 28px rgba(34, 211, 238, 0.2);
}
.breadcrumb {
color: var(--muted);
font-size: 14px;

View file

@ -2,7 +2,8 @@ import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { cookies, headers } from 'next/headers';
import { getListingBySlug, DEFAULT_LOCALE } from '../../../lib/listings';
import { getListingBySlug, DEFAULT_LOCALE, withResolvedListingImages } from '../../../lib/listings';
import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing';
import { resolveLocale, t as translate } from '../../../lib/i18n';
type ListingPageProps = {
@ -33,13 +34,15 @@ export default async function ListingPage({ params }: ListingPageProps) {
const locale = resolveLocale({ cookieLocale: cookieStore.get('locale')?.value, acceptLanguage: headers().get('accept-language') });
const t = (key: any, vars?: Record<string, string | number>) => translate(locale, key as any, vars);
const translation = await getListingBySlug({ slug: params.slug, locale: locale ?? DEFAULT_LOCALE });
const translationRaw = await getListingBySlug({ slug: params.slug, locale: locale ?? DEFAULT_LOCALE });
const translation = translationRaw ? withResolvedListingImages(translationRaw) : null;
if (!translation) {
notFound();
}
const { listing, title, description, teaser, locale: translationLocale } = translation;
const isSample = listing.isSample || listing.contactEmail === 'host@lomavuokraus.fi' || SAMPLE_LISTING_SLUGS.includes(params.slug);
const amenities = [
listing.hasSauna ? { icon: amenityIcons.sauna, label: t('amenitySauna') } : null,
listing.hasFireplace ? { icon: amenityIcons.fireplace, label: t('amenityFireplace') } : null,
@ -61,6 +64,11 @@ export default async function ListingPage({ params }: ListingPageProps) {
</div>
<div className="listing-layout">
<div className="panel listing-main">
{isSample ? (
<div className="badge warning" style={{ marginBottom: 10, display: 'inline-block' }}>
{t('sampleBadge')}
</div>
) : null}
<h1>{title}</h1>
<p style={{ marginTop: 8 }}>{teaser ?? description}</p>
{listing.addressNote ? (
@ -77,9 +85,13 @@ export default async function ListingPage({ params }: ListingPageProps) {
) : null}
{listing.images.length > 0 ? (
<div style={{ marginTop: 12, display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
{listing.images.map((img) => (
{listing.images
.filter((img) => Boolean(img.url))
.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)' }}>
<img src={img.url} alt={img.altText ?? title} style={{ width: '100%', height: '200px', objectFit: 'cover' }} />
<a href={img.url || ''} target="_blank" rel="noreferrer" style={{ display: 'block', cursor: 'zoom-in' }}>
<img src={img.url || ''} alt={img.altText ?? title} style={{ width: '100%', height: '200px', objectFit: 'cover' }} />
</a>
{img.altText ? (
<figcaption style={{ padding: '10px 12px', fontSize: 14, color: '#cbd5e1' }}>{img.altText}</figcaption>
) : null}

View file

@ -4,7 +4,16 @@ import { useEffect, useState } from 'react';
import { useI18n } from '../../components/I18nProvider';
import type { Locale } from '../../../lib/i18n';
type ImageInput = { url: string; altText?: string };
type ImageInput = { data: string; mimeType: string; altText?: string };
type SelectedImage = {
name: string;
size: number;
mimeType: string;
dataUrl: string;
};
const MAX_IMAGES = 6;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image
export default function NewListingPage() {
const { t, locale: uiLocale } = useI18n();
@ -34,7 +43,7 @@ export default function NewListingPage() {
const [byTheLake, setByTheLake] = useState(false);
const [hasAirConditioning, setHasAirConditioning] = useState(false);
const [evCharging, setEvCharging] = useState<'NONE' | 'FREE' | 'PAID'>('NONE');
const [imagesText, setImagesText] = useState('');
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
const [coverImageIndex, setCoverImageIndex] = useState(1);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@ -53,12 +62,63 @@ export default function NewListingPage() {
.catch(() => setIsAuthed(false));
}, []);
function readFileAsDataUrl(file: File): Promise<SelectedImage> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve({
name: file.name,
size: file.size,
mimeType: file.type || 'image/jpeg',
dataUrl: String(reader.result),
});
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
setError(null);
setMessage(null);
if (!files.length) return;
if (files.length > MAX_IMAGES) {
setError(t('imagesTooMany', { count: MAX_IMAGES }));
return;
}
const tooLarge = files.find((f) => f.size > MAX_IMAGE_BYTES);
if (tooLarge) {
setError(t('imagesTooLarge', { sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) }));
return;
}
try {
const parsed = await Promise.all(files.map(readFileAsDataUrl));
setSelectedImages(parsed);
setCoverImageIndex(1);
} catch (err) {
setError(t('imagesReadFailed'));
}
}
const amenityOptions = [
{ key: 'sauna', label: t('amenitySauna'), icon: '🧖', checked: hasSauna, toggle: setHasSauna },
{ key: 'fireplace', label: t('amenityFireplace'), icon: '🔥', checked: hasFireplace, toggle: setHasFireplace },
{ key: 'wifi', label: t('amenityWifi'), icon: '📶', checked: hasWifi, toggle: setHasWifi },
{ key: 'pets', label: t('amenityPets'), icon: '🐾', checked: petsAllowed, toggle: setPetsAllowed },
{ key: 'lake', label: t('amenityLake'), icon: '🌊', checked: byTheLake, toggle: setByTheLake },
{ key: 'ac', label: t('amenityAirConditioning'), icon: '❄️', checked: hasAirConditioning, toggle: setHasAirConditioning },
];
function parseImages(): ImageInput[] {
return imagesText
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => ({ url: line }));
return selectedImages.map((img) => ({
data: img.dataUrl,
mimeType: img.mimeType,
altText: img.name.replace(/[-_]/g, ' '),
}));
}
async function onSubmit(e: React.FormEvent) {
@ -67,6 +127,12 @@ export default function NewListingPage() {
setError(null);
setLoading(true);
try {
if (selectedImages.length === 0) {
setError(t('imagesRequired'));
setLoading(false);
return;
}
const res = await fetch('/api/listings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -89,7 +155,7 @@ export default function NewListingPage() {
bedrooms,
beds,
bathrooms,
priceHintPerNightCents: price === '' ? null : Number(price),
priceHintPerNightEuros: price === '' ? null : Math.round(Number(price)),
hasSauna,
hasFireplace,
hasWifi,
@ -118,7 +184,7 @@ export default function NewListingPage() {
setLongitude('');
setContactName('');
setContactEmail('');
setImagesText('');
setSelectedImages([]);
setCoverImageIndex(1);
}
} catch (err) {
@ -194,23 +260,55 @@ export default function NewListingPage() {
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
<label>
{t('maxGuestsLabel')}
<input type="number" value={maxGuests} onChange={(e) => setMaxGuests(Number(e.target.value))} min={1} />
<select value={maxGuests} onChange={(e) => setMaxGuests(Number(e.target.value))}>
{[2, 4, 6, 8, 10, 12, 14, 16].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
<label>
{t('bedroomsLabel')}
<input type="number" value={bedrooms} onChange={(e) => setBedrooms(Number(e.target.value))} min={0} />
<select value={bedrooms} onChange={(e) => setBedrooms(Number(e.target.value))}>
{[0, 1, 2, 3, 4, 5, 6].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
<label>
{t('bedsLabel')}
<input type="number" value={beds} onChange={(e) => setBeds(Number(e.target.value))} min={0} />
<select value={beds} onChange={(e) => setBeds(Number(e.target.value))}>
{[1, 2, 3, 4, 5, 6, 8, 10].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
<label>
{t('bathroomsLabel')}
<input type="number" value={bathrooms} onChange={(e) => setBathrooms(Number(e.target.value))} min={0} />
<select value={bathrooms} onChange={(e) => setBathrooms(Number(e.target.value))}>
{[1, 2, 3, 4].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
<label>
{t('priceHintLabel')}
<input type="number" value={price} onChange={(e) => setPrice(e.target.value === '' ? '' : Number(e.target.value))} min={0} />
<input
type="number"
value={price}
onChange={(e) => setPrice(e.target.value === '' ? '' : Number(e.target.value))}
min={0}
step="10"
placeholder="e.g. 120"
/>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHintHelp')}</div>
</label>
</div>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
@ -223,49 +321,74 @@ export default function NewListingPage() {
<input type="number" value={longitude} onChange={(e) => setLongitude(e.target.value === '' ? '' : Number(e.target.value))} step="0.000001" />
</label>
</div>
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={hasSauna} onChange={(e) => setHasSauna(e.target.checked)} /> {t('amenitySauna')}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={hasFireplace} onChange={(e) => setHasFireplace(e.target.checked)} /> {t('amenityFireplace')}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={hasWifi} onChange={(e) => setHasWifi(e.target.checked)} /> {t('amenityWifi')}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={petsAllowed} onChange={(e) => setPetsAllowed(e.target.checked)} /> {t('amenityPets')}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={byTheLake} onChange={(e) => setByTheLake(e.target.checked)} /> {t('amenityLake')}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={hasAirConditioning} onChange={(e) => setHasAirConditioning(e.target.checked)} /> {t('amenityAirConditioning')}
</label>
<label>
{t('evChargingLabel')}
<select value={evCharging} onChange={(e) => setEvCharging(e.target.value as any)}>
<option value="NONE">{t('evChargingNone')}</option>
<option value="FREE">{t('evChargingFree')}</option>
<option value="PAID">{t('evChargingPaid')}</option>
</select>
</label>
<div className="amenity-grid">
{amenityOptions.map((option) => (
<button
key={option.key}
type="button"
className={`amenity-option ${option.checked ? 'selected' : ''}`}
aria-pressed={option.checked}
onClick={() => option.toggle(!option.checked)}
>
<div className="amenity-option-meta">
<span aria-hidden className="amenity-emoji">
{option.icon}
</span>
<span className="amenity-name">{option.label}</span>
</div>
<span className="amenity-check" aria-hidden>
{option.checked ? '✓' : ''}
</span>
</button>
))}
<div className="amenity-ev">
<div className="amenity-ev-label">{t('evChargingLabel')}</div>
<div className="ev-toggle-group">
{[
{ value: 'NONE', label: t('evChargingNone'), icon: '🚗' },
{ value: 'FREE', label: t('evChargingFree'), icon: '⚡' },
{ value: 'PAID', label: t('evChargingPaid'), icon: '💳' },
].map((opt) => (
<button
key={opt.value}
type="button"
className={`ev-toggle ${evCharging === opt.value ? 'active' : ''}`}
onClick={() => setEvCharging(opt.value as typeof evCharging)}
>
<span aria-hidden className="amenity-emoji">
{opt.icon}
</span>
{opt.label}
</button>
))}
</div>
</div>
</div>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
<label>
{t('imagesLabel')}
<textarea value={imagesText} onChange={(e) => setImagesText(e.target.value)} rows={4} placeholder="https://example.com/image.jpg" />
<input type="file" accept="image/*" multiple onChange={handleFileChange} />
<div style={{ color: '#cbd5e1', fontSize: 12, marginTop: 4 }}>{t('imagesHelp', { count: MAX_IMAGES, sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })}</div>
</label>
<label>
{t('coverImageLabel')}
<input
type="number"
min={1}
value={coverImageIndex}
onChange={(e) => setCoverImageIndex(Number(e.target.value) || 1)}
placeholder={t('coverImageHelp')}
/>
<small style={{ color: '#cbd5e1' }}>{t('coverImageHelp')}</small>
<input type="number" min={1} max={selectedImages.length || 1} value={coverImageIndex} onChange={(e) => setCoverImageIndex(Number(e.target.value))} />
<div style={{ color: '#cbd5e1', fontSize: 12, marginTop: 4 }}>{t('coverImageHelp')}</div>
</label>
</div>
{selectedImages.length > 0 ? (
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
{selectedImages.map((img, idx) => (
<div key={img.name + idx} style={{ border: '1px solid rgba(148,163,184,0.3)', padding: 8, borderRadius: 8 }}>
<div style={{ fontWeight: 600 }}>{img.name}</div>
<div style={{ fontSize: 12, color: '#cbd5e1' }}>
{(img.size / 1024).toFixed(0)} KB · {img.mimeType || 'image/jpeg'}
</div>
<div style={{ fontSize: 12, marginTop: 4 }}>{t('coverChoice', { index: idx + 1 })}</div>
</div>
))}
</div>
) : null}
<button className="button" type="submit" disabled={loading}>
{loading ? t('submittingListing') : t('submitListing')}
</button>

View file

@ -29,8 +29,9 @@ type ListingResult = {
bedrooms: number;
beds: number;
bathrooms: number;
priceHintPerNightCents: number | null;
priceHintPerNightEuros: number | null;
coverImage: string | null;
isSample: boolean;
};
type LatLng = { lat: number; lon: number };
@ -166,6 +167,7 @@ export default function ListingsIndexPage() {
const [radiusKm, setRadiusKm] = useState(50);
const [geocoding, setGeocoding] = useState(false);
const [geoError, setGeoError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const filteredByAddress = useMemo(() => {
if (!addressCenter) return listings;
@ -233,6 +235,14 @@ export default function ListingsIndexPage() {
const countLabel = t('listingsFound', { count: filtered.length });
useEffect(() => {
if (!selectedId) return;
const el = document.querySelector<HTMLElement>(`[data-listing-id="${selectedId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [selectedId]);
return (
<main>
<section className="panel">
@ -344,13 +354,16 @@ export default function ListingsIndexPage() {
{filtered.length === 0 ? (
<p>{t('mapNoResults')}</p>
) : (
<div className="results-grid">
<div className="results-grid" ref={scrollRef}>
{filtered.map((l) => (
<article
key={l.id}
className={`listing-card ${selectedId === l.id ? 'active' : ''}`}
data-listing-id={l.id}
onMouseEnter={() => setSelectedId(l.id)}
onClick={() => setSelectedId(l.id)}
>
<Link href={`/listings/${l.slug}`} aria-label={l.title} style={{ display: 'block' }}>
{l.coverImage ? (
<img src={l.coverImage} alt={l.title} style={{ width: '100%', height: 140, objectFit: 'cover', borderRadius: 12 }} />
) : (
@ -362,8 +375,14 @@ export default function ListingsIndexPage() {
}}
/>
)}
</Link>
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}>
<h3 style={{ margin: 0 }}>{l.title}</h3>
{l.isSample ? (
<span className="badge warning" style={{ width: 'fit-content' }}>
{t('sampleBadge')}
</span>
) : null}
<p style={{ margin: 0 }}>{l.teaser ?? ''}</p>
<div style={{ color: '#cbd5e1', fontSize: 14 }}>
{l.streetAddress ? `${l.streetAddress}, ` : ''}

View file

@ -13,6 +13,7 @@ type LatestListing = {
coverImage: string | null;
city: string;
region: string;
isSample: boolean;
};
export const dynamic = 'force-dynamic';
@ -137,14 +138,21 @@ export default function HomePage() {
{latest.map((item) => (
<article key={item.id} className="carousel-slide">
{item.coverImage ? (
<a href={`/listings/${item.slug}`} className="latest-cover-link" aria-label={item.title}>
<img src={item.coverImage} alt={item.title} className="latest-cover" />
</a>
) : (
<a href={`/listings/${item.slug}`} className="latest-cover-link" aria-label={item.title}>
<div className="latest-cover placeholder" />
</a>
)}
<div className="latest-meta">
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span className="badge">
{item.city}, {item.region}
</span>
{item.isSample ? <span className="badge warning">{t('sampleBadge')}</span> : null}
</div>
<h3 style={{ margin: '6px 0 4px' }}>{item.title}</h3>
<p style={{ margin: 0 }}>{item.teaser}</p>
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>

View file

@ -1,65 +1,69 @@
'use client';
import Link from 'next/link';
import { useI18n } from '../components/I18nProvider';
export default function PrivacyPage() {
const { t } = useI18n();
const today = new Date().toISOString().slice(0, 10);
return (
<main className="panel" style={{ maxWidth: 900, margin: '40px auto', display: 'grid', gap: 14 }}>
<div className="breadcrumb">
<Link href="/">Home</Link> / <span>Privacy & cookies</span>
<Link href="/">{t('homeCrumb')}</Link> / <span>{t('privacyTitle')}</span>
</div>
<h1>Privacy & cookies</h1>
<p style={{ color: '#cbd5e1' }}>Updated: {new Date().toISOString().slice(0, 10)}</p>
<h1>{t('privacyTitle')}</h1>
<p style={{ color: '#cbd5e1' }}>{t('privacyUpdated', { date: today })}</p>
<section className="privacy-block">
<h3>What data we collect</h3>
<h3>{t('privacyCollectTitle')}</h3>
<ul>
<li>Account data: email, password hash, name, phone (optional), role/status.</li>
<li>Listing data: location, contact details, amenities, photos, translations.</li>
<li>Operational logs: minimal request metadata for diagnostics.</li>
<li>{t('privacyCollectAccounts')}</li>
<li>{t('privacyCollectListings')}</li>
<li>{t('privacyCollectLogs')}</li>
</ul>
</section>
<section className="privacy-block">
<h3>How we use your data</h3>
<h3>{t('privacyUseTitle')}</h3>
<ul>
<li>To authenticate users and manage sessions.</li>
<li>To publish and moderate listings.</li>
<li>To send transactional email (verification, password reset).</li>
<li>To comply with legal requests or protect the service.</li>
<li>{t('privacyUseAuth')}</li>
<li>{t('privacyUseListings')}</li>
<li>{t('privacyUseMail')}</li>
<li>{t('privacyUseLegal')}</li>
</ul>
</section>
<section className="privacy-block">
<h3>Storage & retention</h3>
<h3>{t('privacyStoreTitle')}</h3>
<ul>
<li>Data is stored in our Postgres database hosted in the EU.</li>
<li>Backups are retained for disaster recovery; removed accounts/listings may persist in backups for a limited time.</li>
<li>{t('privacyStoreDb')}</li>
<li>{t('privacyStoreBackups')}</li>
</ul>
</section>
<section className="privacy-block">
<h3>Cookies</h3>
<h3>{t('privacyCookiesTitle')}</h3>
<ul>
<li>We use essential cookies only: a session cookie for login/authentication.</li>
<li>No analytics, marketing, or tracking cookies are used.</li>
<li>{t('privacyCookiesSession')}</li>
<li>{t('privacyCookiesNoTracking')}</li>
</ul>
</section>
<section className="privacy-block">
<h3>Sharing</h3>
<h3>{t('privacySharingTitle')}</h3>
<ul>
<li>We do not sell or share personal data with advertisers.</li>
<li>Data may be shared with service providers strictly for running the service (email delivery, hosting).</li>
<li>{t('privacySharingAds')}</li>
<li>{t('privacySharingOps')}</li>
</ul>
</section>
<section className="privacy-block">
<h3>Your rights</h3>
<h3>{t('privacyRightsTitle')}</h3>
<ul>
<li>Request access, correction, or deletion of your data.</li>
<li>Withdraw consent for non-essential processing (we currently process only essentials).</li>
<li>Contact support for any privacy questions.</li>
<li>{t('privacyRightsAccess')}</li>
<li>{t('privacyRightsConsent')}</li>
<li>{t('privacyRightsContact')}</li>
</ul>
</section>
</main>

View file

@ -5,8 +5,15 @@ cd "$(dirname "$0")/.."
source deploy/env.sh
GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || date +%s)
BASE_TAG=${BUILD_TAG:-$GIT_SHA}
# Optional dev override: set FORCE_DEV_TAG=1 to append a timestamp without committing
if [[ -n "${FORCE_DEV_TAG:-}" ]]; then
BASE_TAG="${BASE_TAG}-dev$(date +%s)"
fi
IMAGE_REPO="${REGISTRY}/${REGISTRY_REPO}"
IMAGE="${IMAGE_REPO}:${GIT_SHA}"
IMAGE="${IMAGE_REPO}:${BASE_TAG}"
IMAGE_LATEST="${IMAGE_REPO}:latest"
echo "Building image:"
@ -18,7 +25,7 @@ echo "Running npm audit (high)..."
npm audit --audit-level=high || echo "npm audit reported issues above."
# Build
docker build -t "$IMAGE" -t "$IMAGE_LATEST" .
docker build --build-arg APP_VERSION="$GIT_SHA" -t "$IMAGE" -t "$IMAGE_LATEST" .
echo "$IMAGE" > deploy/.last-image

View file

@ -9,6 +9,31 @@ if [[ ! -f deploy/.last-image ]]; then
exit 1
fi
# Default env selection: DEPLOY_TARGET=staging|prod (fallback staging) to avoid manual export
if [[ -z "${K8S_NAMESPACE:-}" || -z "${APP_HOST:-}" || -z "${NEXT_PUBLIC_SITE_URL:-}" || -z "${NEXT_PUBLIC_API_BASE:-}" || -z "${APP_ENV:-}" || -z "${CLUSTER_ISSUER:-}" || -z "${INGRESS_CLASS:-}" ]]; then
TARGET="${DEPLOY_TARGET:-${TARGET:-staging}}"
case "$TARGET" in
prod|production)
K8S_NAMESPACE="${K8S_NAMESPACE:-$PROD_NAMESPACE}"
APP_HOST="${APP_HOST:-$PROD_HOST}"
NEXT_PUBLIC_SITE_URL="${NEXT_PUBLIC_SITE_URL:-https://$PROD_HOST}"
NEXT_PUBLIC_API_BASE="${NEXT_PUBLIC_API_BASE:-https://$PROD_HOST/api}"
APP_ENV="${APP_ENV:-production}"
CLUSTER_ISSUER="${CLUSTER_ISSUER:-$PROD_CLUSTER_ISSUER}"
;;
staging|stage|stg|*)
K8S_NAMESPACE="${K8S_NAMESPACE:-$STAGING_NAMESPACE}"
APP_HOST="${APP_HOST:-$STAGING_HOST}"
NEXT_PUBLIC_SITE_URL="${NEXT_PUBLIC_SITE_URL:-https://$STAGING_HOST}"
NEXT_PUBLIC_API_BASE="${NEXT_PUBLIC_API_BASE:-https://$STAGING_HOST/api}"
APP_ENV="${APP_ENV:-staging}"
CLUSTER_ISSUER="${CLUSTER_ISSUER:-$STAGING_CLUSTER_ISSUER}"
;;
esac
INGRESS_CLASS="${INGRESS_CLASS:-$INGRESS_CLASS}"
echo "Using target: $TARGET (namespace=$K8S_NAMESPACE host=$APP_HOST env=$APP_ENV)"
fi
: "${K8S_NAMESPACE:?K8S_NAMESPACE pitää asettaa}"
: "${APP_HOST:?APP_HOST pitää asettaa}"
: "${NEXT_PUBLIC_SITE_URL:?NEXT_PUBLIC_SITE_URL pitää asettaa}"

View file

@ -16,13 +16,14 @@
<div class="diagram">
<pre class="mermaid">
flowchart LR
Browser["Client browser"] -->|"HTTPS"| Next["Next.js App Router\nSSR/ISR + API routes"]
Browser["Client browser"] -->|"HTTPS"| Traefik["Traefik ingress"]
Traefik --> Varnish["Varnish cache\n(static + /api/images/*)"]
Varnish --> Next["Next.js App Router\nSSR/ISR + API routes"]
Next --> Auth["Auth/session module\n(JWT cookie)"]
Next --> Prisma["Prisma ORM"]
Prisma --> Postgres[(PostgreSQL)]
Prisma --> Postgres[(PostgreSQL\nlistings + images)]
Next --> Mailer["SMTP mailer\nsmtp.lomavuokraus.fi (CNAME) + DKIM"]
Next --> Storage["Image storage (remote bucket)"]
Admin["Admins & moderators"] --> Next
Admin["Admins & moderators"] --> Traefik
</pre>
</div>
<div class="callout">Edit the Mermaid block above to evolve the architecture.</div>
@ -70,7 +71,12 @@ flowchart LR
LISTINGIMAGE {
string id
string url
string data
string mimeType
int size
string altText
boolean isCover
int order
}
</pre>
</div>
@ -81,7 +87,8 @@ flowchart LR
<ul>
<li><strong>Web</strong>: Next.js app (App Router), server-rendered pages, client hooks for auth state.</li>
<li><strong>API routes</strong>: Authentication, admin approvals, listings CRUD (soft-delete), profile update.</li>
<li><strong>Data</strong>: Postgres via Prisma (models: User, Listing, ListingTranslation, ListingImage, VerificationToken).</li>
<li><strong>Data</strong>: Postgres via Prisma (models: User, Listing, ListingTranslation, ListingImage, VerificationToken); listing images stored as bytes + metadata and served through <code>/api/images/:id</code>.</li>
<li><strong>Caching</strong>: Varnish sidecar caches <code>/api/images/*</code> (24h) and <code>/_next/static</code> assets (7d) before requests reach Next.js.</li>
<li><strong>Mail</strong>: SMTP (smtp.lomavuokraus.fi CNAME to smtp.sohva.org) + DKIM signing for verification emails.</li>
<li><strong>Auth</strong>: Email/password, verified+approved requirement, JWT session cookie (<code>session_token</code>), roles.</li>
</ul>

View file

@ -20,8 +20,9 @@ flowchart LR
User["User browser"] -->|"HTTPS"| Traefik
CertMgr["cert-manager\nletsencrypt prod/staging"] -->|"TLS"| Traefik
subgraph Cluster["k3s hel1 cx22 (157.180.66.64)"]
Traefik --> Service["Service :80 -> 3000"]
Service --> Pod["Next.js pods (2)"]
Traefik --> Service["Service :80 -> 8080"]
Service --> Varnish["Varnish cache\n(static + /api/images/*)"]
Varnish --> Pod["Next.js pods (2)\n(port 3000)"]
Pod --> DB["PostgreSQL 46.62.203.202"]
Pod --> SMTP["smtp.lomavuokraus.fi"]
Secret["Secret: lomavuokraus-web-secrets"]
@ -76,6 +77,8 @@ flowchart TB
<li><code>letsencrypt-staging</code> (ACME staging for test certs)</li>
</ul>
</li>
<li>Service points to a Varnish sidecar (port 8080) in each pod before the Next.js container (3000) to cache <code>/api/images/*</code> and static assets.</li>
<li>Cache policy: images cached 24h with <code>Cache-Control: public, max-age=86400, immutable</code>; <code>_next/static</code> cached 7d; non-GET traffic and health checks bypass cache.</li>
<li>DNS: <code>lomavuokraus.fi</code>, <code>staging.lomavuokraus.fi</code>, <code>api.lomavuokraus.fi</code> -> cluster IP.</li>
</ul>
</section>
@ -96,8 +99,8 @@ flowchart TB
<li>Objects:
<ul>
<li>ConfigMap: <code>lomavuokraus-web-config</code> (public env).</li>
<li>Deployment: 2 replicas, container port 3000, liveness/readiness on <code>/api/health</code>.</li>
<li>Service: ClusterIP on port 80.</li>
<li>Deployment: 2 replicas, Varnish sidecar on port 8080 in front of the Next.js container (3000), liveness/readiness on <code>/api/health</code> via Varnish.</li>
<li>Service: ClusterIP on port 80 targeting the Varnish container.</li>
<li>Ingress: Traefik class, TLS via cert-manager, HTTPS redirect middleware.</li>
<li>Traefik Middleware: <code>https-redirect</code> to force HTTPS.</li>
</ul>

99
generate_images.py Normal file
View file

@ -0,0 +1,99 @@
#!/usr/bin/env python
import os
import base64
from openai import OpenAI
# Load API key from environment or fallback file under creds/
if not os.getenv("OPENAI_API_KEY"):
key_path = os.path.join(os.path.dirname(__file__), "creds", "openai.key")
if os.path.exists(key_path):
with open(key_path, "r", encoding="utf-8") as fh:
os.environ["OPENAI_API_KEY"] = fh.read().strip()
client = OpenAI() # käyttää OPENAI_API_KEY-ympäristömuuttujaa
# Varmista että hakemisto on olemassa
os.makedirs("sampleimages", exist_ok=True)
images = {
"sampleimages/saimaa-lakeside-cabin-cover.jpg":
"Finnish timber lakeside cabin on Lake Saimaa at golden hour, wooden deck with private dock, calm water, pine forest background, subtle sauna chimney smoke, summer green, cozy mood, wide hero photo, ultra realistic, high resolution.",
"sampleimages/saimaa-lakeside-cabin-sauna.jpg":
"Wood-fired lakeside sauna hut with small pier, steam drifting, lake reflections, dusk light, rustic wood, Finland, ultra realistic photo.",
"sampleimages/saimaa-lakeside-cabin-lounge.jpg":
"Cabin living area with stone fireplace, timber walls, soft textiles, lake view through large window, warm lighting, cozy Scandinavian interior, realistic photo.",
"sampleimages/helsinki-design-loft-cover.jpg":
"Modern Helsinki top-floor loft, Scandinavian design, airy living room, big windows over Katajanokka harbor, light wood floors, clean lines, hero interior photo, ultra realistic.",
"sampleimages/helsinki-design-loft-balcony.jpg":
"Balcony view toward Helsinki harbor and ferries from a design loft, glass railing, evening light, realistic photo.",
"sampleimages/helsinki-design-loft-bedroom.jpg":
"Minimalist loft bedroom, light wood, linen bedding, black accents, soft daylight, Scandinavian interior, realistic photo.",
"sampleimages/turku-riverside-apartment-cover.jpg":
"Cozy one-bedroom apartment living room near Aura river in Turku, window view to riverside trees, Nordic decor, warm neutrals, realistic interior photo.",
"sampleimages/turku-riverside-apartment-kitchen.jpg":
"Compact Scandinavian kitchen corner, white cabinets, wood countertop, small dining table by window, daylight, realistic photo.",
"sampleimages/rovaniemi-aurora-cabin-cover.jpg":
"Lapland riverside cabin under aurora borealis sky, snowy ground, glass lounge glowing warm, river nearby, pine trees, wide angle, ultra realistic night photo.",
"sampleimages/rovaniemi-aurora-cabin-lounge.jpg":
"Timber cabin lounge with fireplace, fur throws, window showing snowy riverbank, northern ambiance, cozy realistic interior photo.",
"sampleimages/tampere-sauna-studio-cover.jpg":
"Compact city studio in Tampere center, modern interior, small sofa, bed nook, visible private electric sauna door, AC unit, bright window, realistic interior photo.",
"sampleimages/tampere-sauna-studio-sauna.jpg":
"Small electric sauna cabin with glass door inside an apartment, clean light wood benches, Finnish style, realistic photo.",
"sampleimages/vaasa-seaside-villa-cover.jpg":
"Seaside villa deck in Vaasa, view to calm sea, big wooden terrace with seating, Nordic summer evening light, wide hero photo, realistic.",
"sampleimages/vaasa-seaside-villa-lounge.jpg":
"Spacious living room with fireplace, large windows to sea, light wood, cozy Scandinavian decor, realistic interior photo.",
"sampleimages/kuopio-lakeside-apartment-cover.jpg":
"Apartment balcony overlooking Lake Kallavesi in Kuopio, glass railing, morning mist on water, wide balcony shot, realistic photo.",
"sampleimages/kuopio-lakeside-apartment-sauna.jpg":
"Apartment electric sauna, clean tile and light wood, glass door, modern Finnish style, realistic photo.",
"sampleimages/porvoo-river-loft-cover.jpg":
"Porvoo old town loft interior, exposed brick, fireplace, window view toward riverside warehouses, warm lighting, cozy Scandinavian loft, interior photo.",
"sampleimages/porvoo-river-loft-fireplace.jpg":
"Cozy reading corner by brick fireplace, wood beams, vintage Nordic charm, realistic interior photo.",
"sampleimages/oulu-tech-apartment-cover.jpg":
"Modern one-bedroom near Technopolis Oulu, sleek interior, work desk, smart lock door visible, AC unit, daylight, minimalist Scandinavian style, interior photo.",
"sampleimages/oulu-tech-apartment-desk.jpg":
"Work-from-home desk setup in modern apartment, large monitor, ergonomic chair, fiber internet vibe, clean Scandinavian style, realistic photo.",
"sampleimages/mariehamn-harbor-flat-cover.jpg":
"Harbor-facing flat in Mariehamn, balcony overlooking ferries and sailboats, evening light, red wooden buildings in distance, wide balcony harbor view, realistic photo.",
"sampleimages/mariehamn-harbor-flat-living.jpg":
"Bright living room with large window to harbor, light wood floors, simple Nordic decor, airy feeling, realistic interior photo.",
}
for path, prompt in images.items():
# Jos tiedosto on jo olemassa ja ei ole tyhjä, jätetään väliin
if os.path.exists(path) and os.path.getsize(path) > 0:
print(f"Skipping existing file: {path}")
continue
# Valitse koko: kansikuvat leveänä, muut neliönä
if "cover" in path:
size = "1536x1024"
else:
size = "1024x1024"
print(f"Generating {path} ({size})...")
try:
result = client.images.generate(
model="gpt-image-1",
prompt=prompt,
size=size,
n=1,
)
image_b64 = result.data[0].b64_json
image_bytes = base64.b64decode(image_b64)
with open(path, "wb") as f:
f.write(image_bytes)
print(f"Saved {path}")
except Exception as e:
# Ei kaadeta koko skriptiä yhden virheen takia
print(f"ERROR generating {path}: {e}")
print("Done.")

View file

@ -9,6 +9,67 @@ data:
APP_ENV: ${APP_ENV}
NEXT_PUBLIC_VERSION: ${APP_VERSION}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: lomavuokraus-web-varnish
namespace: ${K8S_NAMESPACE}
data:
default.vcl: |
vcl 4.1;
backend app {
.host = "127.0.0.1";
.port = "3000";
}
sub vcl_recv {
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Never cache health
if (req.url ~ "^/api/health") {
return (pass);
}
# Cache image API responses
if (req.url ~ "^/api/images/") {
return (hash);
}
# Cache static assets
if (req.url ~ "^/_next/static" ||
req.url ~ "^/favicon" ||
req.url ~ "^/robots.txt" ||
req.url ~ "^/sitemap") {
return (hash);
}
return (pass);
}
sub vcl_backend_response {
# Default TTL
set beresp.ttl = 1h;
if (bereq.url ~ "^/api/images/") {
set beresp.ttl = 24h;
set beresp.http.Cache-Control = "public, max-age=86400, immutable";
} else if (bereq.url ~ "^/_next/static") {
set beresp.ttl = 7d;
set beresp.http.Cache-Control = "public, max-age=604800, immutable";
}
}
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
@ -27,18 +88,17 @@ spec:
app: lomavuokraus-web
spec:
containers:
- name: lomavuokraus-web
image: ${K8S_IMAGE}
- name: varnish
image: varnish:7.5
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
- containerPort: 8080
name: http
envFrom:
- configMapRef:
name: lomavuokraus-web-config
envFrom:
- secretRef:
name: lomavuokraus-web-secrets
args: ["-a", ":8080", "-f", "/etc/varnish/default.vcl", "-s", "malloc,256m"]
volumeMounts:
- name: varnish-vcl
mountPath: /etc/varnish/default.vcl
subPath: default.vcl
livenessProbe:
httpGet:
path: /api/health
@ -51,6 +111,25 @@ spec:
port: http
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: "50m"
memory: "128Mi"
limits:
cpu: "200m"
memory: "256Mi"
- name: lomavuokraus-web
image: ${K8S_IMAGE}
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
name: app
envFrom:
- configMapRef:
name: lomavuokraus-web-config
envFrom:
- secretRef:
name: lomavuokraus-web-secrets
resources:
requests:
cpu: "100m"
@ -58,6 +137,10 @@ spec:
limits:
cpu: "500m"
memory: "512Mi"
volumes:
- name: varnish-vcl
configMap:
name: lomavuokraus-web-varnish
---
apiVersion: v1
kind: Service

View file

@ -135,10 +135,17 @@ const allMessages = {
bedroomsLabel: 'Bedrooms',
bedsLabel: 'Beds',
bathroomsLabel: 'Bathrooms',
priceHintLabel: 'Price hint (cents)',
imagesLabel: 'Images (one URL per line, max 10)',
coverImageLabel: 'Cover image line number',
coverImageHelp: 'Which image line should be shown as the cover in listings (defaults to 1)',
priceHintLabel: 'Price ballpark (€ / night)',
priceHintHelp: 'Rough nightly price in euros (not a binding offer).',
imagesLabel: 'Images',
imagesHelp: 'Upload up to {count} images (max {sizeMb}MB each).',
imagesTooMany: 'Too many images (max {count}).',
imagesTooLarge: 'Image is too large (max {sizeMb}MB).',
imagesReadFailed: 'Could not read selected images.',
imagesRequired: 'Please upload at least one image.',
coverImageLabel: 'Cover image order',
coverImageHelp: '1-based index of the uploaded images (defaults to 1)',
coverChoice: 'Cover position {index}',
submitListing: 'Create listing',
submittingListing: 'Submitting…',
createListingSuccess: 'Listing created with id {id} (status: {status})',
@ -162,6 +169,31 @@ const allMessages = {
capacityBathrooms: '{count} bathrooms',
browseListingsTitle: 'Browse listings',
browseListingsLead: 'Search public listings, filter by location, and explore them on the map.',
sampleBadge: 'Sample',
privacyTitle: 'Privacy & cookies',
privacyUpdated: 'Updated: {date}',
privacyCollectTitle: 'What data we collect',
privacyCollectAccounts: 'Account data: email, password hash, name, phone (optional), role/status.',
privacyCollectListings: 'Listing data: location, contact details, amenities, photos, translations.',
privacyCollectLogs: 'Operational logs: minimal request metadata for diagnostics.',
privacyUseTitle: 'How we use your data',
privacyUseAuth: 'To authenticate users and manage sessions.',
privacyUseListings: 'To publish and moderate listings.',
privacyUseMail: 'To send transactional email (verification, password reset).',
privacyUseLegal: 'To comply with legal requests or protect the service.',
privacyStoreTitle: 'Storage & retention',
privacyStoreDb: 'Data is stored in our Postgres database hosted in the EU.',
privacyStoreBackups: 'Backups are retained for disaster recovery; removed accounts/listings may persist in backups for a limited time.',
privacyCookiesTitle: 'Cookies',
privacyCookiesSession: 'We use essential cookies only: a session cookie for login/authentication.',
privacyCookiesNoTracking: 'No analytics, marketing, or tracking cookies are used.',
privacySharingTitle: 'Sharing',
privacySharingAds: 'We do not sell or share personal data with advertisers.',
privacySharingOps: 'Data may be shared with service providers strictly for running the service (email delivery, hosting).',
privacyRightsTitle: 'Your rights',
privacyRightsAccess: 'Request access, correction, or deletion of your data.',
privacyRightsConsent: 'Withdraw consent for non-essential processing (we currently process only essentials).',
privacyRightsContact: 'Contact support for any privacy questions.',
searchLabel: 'Search',
searchPlaceholder: 'Search by name, description, or city',
cityFilter: 'City',
@ -316,10 +348,17 @@ const allMessages = {
bedroomsLabel: 'Makuuhuoneita',
bedsLabel: 'Vuoteita',
bathroomsLabel: 'Kylpyhuoneita',
priceHintLabel: 'Hinta-arvio (senttiä)',
imagesLabel: 'Kuvat (yksi URL per rivi, max 10)',
coverImageLabel: 'Kansikuvan rivinumero',
coverImageHelp: 'Mikä kuvista näytetään kansikuvana listauksissa (oletus 1)',
priceHintLabel: 'Hinta-arvio (€ / yö)',
priceHintHelp: 'Suuntaa-antava hinta euroina per yö (ei sitova).',
imagesLabel: 'Kuvat',
imagesHelp: 'Lataa enintään {count} kuvaa (max {sizeMb} Mt / kuva).',
imagesTooMany: 'Liikaa kuvia (enintään {count}).',
imagesTooLarge: 'Kuva on liian suuri (max {sizeMb} Mt).',
imagesReadFailed: 'Kuvien luku epäonnistui.',
imagesRequired: 'Lataa vähintään yksi kuva.',
coverImageLabel: 'Kansikuvan järjestys',
coverImageHelp: 'Monettako ladatuista kuvista käytetään kansikuvana (oletus 1)',
coverChoice: 'Kansipaikka {index}',
submitListing: 'Luo kohde',
submittingListing: 'Lähetetään…',
createListingSuccess: 'Kohde luotu id:llä {id} (tila: {status})',
@ -343,6 +382,31 @@ const allMessages = {
capacityBathrooms: '{count} kylpyhuonetta',
browseListingsTitle: 'Selaa kohteita',
browseListingsLead: 'Hae julkaistuja kohteita, rajaa sijainnilla ja tutki kartalla.',
sampleBadge: 'Mallikohde',
privacyTitle: 'Tietosuoja ja evästeet',
privacyUpdated: 'Päivitetty: {date}',
privacyCollectTitle: 'Mitä tietoja keräämme',
privacyCollectAccounts: 'Käyttäjätiedot: sähköposti, salasanatiiviste, nimi, puhelin (valinnainen), rooli/tila.',
privacyCollectListings: 'Kohdetiedot: sijainti, yhteystiedot, varustelu, kuvat, kieliversiot.',
privacyCollectLogs: 'Lokit: rajattu pyyntömeta diagnostiikkaan.',
privacyUseTitle: 'Miten käytämme tietoja',
privacyUseAuth: 'Kirjautumiseen ja sessioiden hallintaan.',
privacyUseListings: 'Kohteiden julkaisuun ja moderointiin.',
privacyUseMail: 'Transaktio­sähköposteihin (vahvistus, salasanan palautus).',
privacyUseLegal: 'Lakivelvoitteiden täyttämiseen ja palvelun suojaamiseen.',
privacyStoreTitle: 'Säilytys',
privacyStoreDb: 'Tiedot tallennetaan EU:ssa olevaan Postgres-tietokantaan.',
privacyStoreBackups: 'Varmuuskopioita säilytetään palautusta varten; poistetut tiedot voivat esiintyä varmuuskopioissa rajatun ajan.',
privacyCookiesTitle: 'Evästeet',
privacyCookiesSession: 'Käytämme vain välttämättömiä evästeitä: kirjautumissessio.',
privacyCookiesNoTracking: 'Ei analytiikka-, mainos- tai seurantateknologioita.',
privacySharingTitle: 'Tietojen jakaminen',
privacySharingAds: 'Emme myy tai jaa henkilötietoja mainostajille.',
privacySharingOps: 'Palveluntarjoajille vain palvelun pyörittämiseen (sähköposti, hosting).',
privacyRightsTitle: 'Oikeutesi',
privacyRightsAccess: 'Voit pyytää tietojen nähtävyyttä, oikaisua tai poistoa.',
privacyRightsConsent: 'Voit perua suostumuksen ei-välttämättömästä käsittelystä (tällä hetkellä vain välttämättömät).',
privacyRightsContact: 'Ota yhteyttä, jos sinulla on kysyttävää tietosuojasta.',
searchLabel: 'Haku',
searchPlaceholder: 'Hae nimellä, kuvauksella tai paikkakunnalla',
cityFilter: 'Kaupunki/kunta',

View file

@ -6,7 +6,7 @@ export type ListingWithTranslations = Prisma.ListingTranslationGetPayload<{
include: {
listing: {
include: {
images: true;
images: { select: { id: true; url: true; altText: true; order: true; isCover: true; size: true; mimeType: true } };
owner: true;
};
};
@ -18,6 +18,13 @@ type FetchOptions = {
locale?: string;
};
function resolveImageUrl(img: { id: string; url: string | null; size: number | null }) {
if (img.size && img.size > 0) {
return `/api/images/${img.id}`;
}
return img.url;
}
/**
* Fetch a listing translation by slug and locale.
* Falls back to any locale if the requested locale is missing.
@ -30,7 +37,7 @@ export async function getListingBySlug({ slug, locale }: FetchOptions): Promise<
include: {
listing: {
include: {
images: { orderBy: { order: 'asc' } },
images: { orderBy: { order: 'asc' }, select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, mimeType: true } },
owner: true,
},
},
@ -47,7 +54,7 @@ export async function getListingBySlug({ slug, locale }: FetchOptions): Promise<
include: {
listing: {
include: {
images: { orderBy: { order: 'asc' } },
images: { orderBy: { order: 'asc' }, select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, mimeType: true } },
owner: true,
},
},
@ -56,4 +63,22 @@ export async function getListingBySlug({ slug, locale }: FetchOptions): Promise<
});
}
export function withResolvedListingImages(translation: ListingWithTranslations): ListingWithTranslations {
const images = translation.listing.images
.map((img) => {
const url = resolveImageUrl(img);
if (!url) return null;
return { ...img, url };
})
.filter(Boolean) as ListingWithTranslations['listing']['images'];
return {
...translation,
listing: {
...translation.listing,
images,
},
};
}
export { SAMPLE_LISTING_SLUG, DEFAULT_LOCALE };

View file

@ -1,2 +1,14 @@
export const SAMPLE_LISTING_SLUG = 'saimaa-lakeside-cabin';
export const SAMPLE_LISTING_SLUGS = [
'saimaa-lakeside-cabin',
'helsinki-design-loft',
'turku-riverside-apartment',
'rovaniemi-aurora-cabin',
'tampere-sauna-studio',
'vaasa-seaside-villa',
'kuopio-lakeside-apartment',
'porvoo-river-loft',
'oulu-tech-apartment',
'mariehamn-harbor-flat',
];
export const DEFAULT_LOCALE = 'en';

View file

@ -5,7 +5,7 @@
"description": "Lomavuokraus.fi Next.js app and deploy tooling",
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"

View file

@ -0,0 +1,8 @@
-- Make listing image URL optional and store image binary + metadata
ALTER TABLE "ListingImage"
ALTER COLUMN "url" DROP NOT NULL,
ADD COLUMN "data" BYTEA,
ADD COLUMN "mimeType" TEXT,
ADD COLUMN "size" INTEGER;
-- Optional: index on listingId for faster image lookups remains from previous schema

View file

@ -0,0 +1,2 @@
-- Add a flag to mark sample/demo listings
ALTER TABLE "Listing" ADD COLUMN "isSample" BOOLEAN NOT NULL DEFAULT false;

View file

@ -0,0 +1,9 @@
-- Rename price hint column from cents to euros
ALTER TABLE "Listing" RENAME COLUMN "priceHintPerNightCents" TO "priceHintPerNightEuros";
-- Convert stored values from cents to euros (assuming integer cents)
UPDATE "Listing"
SET "priceHintPerNightEuros" = CASE
WHEN "priceHintPerNightEuros" IS NULL THEN NULL
ELSE "priceHintPerNightEuros" / 100
END;

View file

@ -32,7 +32,7 @@ CREATE TABLE "Listing" (
"hasWifi" BOOLEAN NOT NULL DEFAULT false,
"petsAllowed" BOOLEAN NOT NULL DEFAULT false,
"byTheLake" BOOLEAN NOT NULL DEFAULT false,
"priceHintPerNightCents" INTEGER,
"priceHintPerNightEuros" INTEGER,
"contactName" TEXT NOT NULL,
"contactEmail" TEXT NOT NULL,
"contactPhone" TEXT,

View file

@ -89,12 +89,13 @@ model Listing {
byTheLake Boolean @default(false)
hasAirConditioning Boolean @default(false)
evCharging EvCharging @default(NONE)
priceHintPerNightCents Int?
priceHintPerNightEuros Int?
contactName String
contactEmail String
contactPhone String?
externalUrl String?
published Boolean @default(true)
isSample Boolean @default(false)
translations ListingTranslation[]
images ListingImage[]
createdAt DateTime @default(now())
@ -121,7 +122,10 @@ model ListingImage {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id])
url String
url String?
data Bytes?
mimeType String?
size Int?
altText String?
isCover Boolean @default(false)
order Int @default(0)

View file

@ -22,10 +22,66 @@ const prisma = new PrismaClient({
const SAMPLE_SLUG = 'saimaa-lakeside-cabin';
const DEFAULT_LOCALE = 'en';
const SAMPLE_EMAIL = 'host@lomavuokraus.fi';
const SAMPLE_IMAGE_DIR = path.join(__dirname, '..', 'sampleimages');
function detectMimeType(fileName) {
const ext = path.extname(fileName).toLowerCase();
if (ext === '.png') return 'image/png';
if (ext === '.webp') return 'image/webp';
if (ext === '.gif') return 'image/gif';
return 'image/jpeg';
}
function loadSampleImage(fileName) {
if (!fileName) return null;
const filePath = path.join(SAMPLE_IMAGE_DIR, fileName);
if (!fs.existsSync(filePath)) {
console.warn(`Sample image missing: ${filePath}`);
return null;
}
const data = fs.readFileSync(filePath);
return {
data,
size: data.length,
mimeType: detectMimeType(fileName),
};
}
async function main() {
const adminEmail = process.env.ADMIN_EMAIL;
const adminPassword = process.env.ADMIN_INITIAL_PASSWORD;
function buildImageCreates(item) {
const results = [];
const coverFile = loadSampleImage(item.cover.file);
if (coverFile || item.cover.url) {
results.push({
data: coverFile?.data,
mimeType: coverFile?.mimeType || (item.cover.url ? null : undefined),
size: coverFile?.size ?? null,
url: coverFile ? null : item.cover.url ?? null,
altText: item.cover.altText ?? null,
order: 1,
isCover: true,
});
}
item.images.forEach((img) => {
const file = loadSampleImage(img.file);
const hasSource = file || img.url;
if (!hasSource) return;
results.push({
data: file?.data,
mimeType: file?.mimeType || (img.url ? null : undefined),
size: file?.size ?? null,
url: file ? null : img.url ?? null,
altText: img.altText ?? null,
order: results.length + 1,
isCover: false,
});
});
return results;
}
if (!adminEmail || !adminPassword) {
console.warn('ADMIN_EMAIL or ADMIN_INITIAL_PASSWORD missing; admin user will not be seeded.');
@ -81,6 +137,7 @@ async function main() {
const listings = [
{
slug: SAMPLE_SLUG,
isSample: true,
city: 'Punkaharju',
region: 'South Karelia',
country: 'Finland',
@ -99,14 +156,15 @@ async function main() {
petsAllowed: false,
byTheLake: true,
evCharging: 'FREE',
priceHintPerNightCents: 14500,
priceHintPerNightEuros: 145,
cover: {
file: 'saimaa-lakeside-cabin-cover.jpg',
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
altText: 'Lakeside cabin with sauna',
},
images: [
{ url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80', altText: 'Wood-fired sauna by the lake' },
{ url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', altText: 'Living area with fireplace' },
{ file: 'saimaa-lakeside-cabin-sauna.jpg', url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80', altText: 'Wood-fired sauna by the lake' },
{ file: 'saimaa-lakeside-cabin-lounge.jpg', url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', altText: 'Living area with fireplace' },
],
titleEn: 'Saimaa lakeside cabin with sauna',
teaserEn: 'Sauna, lake view, private dock, and cozy fireplace.',
@ -119,6 +177,7 @@ async function main() {
},
{
slug: 'helsinki-design-loft',
isSample: true,
city: 'Helsinki',
region: 'Uusimaa',
country: 'Finland',
@ -137,14 +196,15 @@ async function main() {
petsAllowed: false,
byTheLake: false,
evCharging: 'PAID',
priceHintPerNightCents: 16500,
priceHintPerNightEuros: 165,
cover: {
file: 'helsinki-design-loft-cover.jpg',
url: 'https://images.unsplash.com/photo-1505693415763-3bd1620f58c3?auto=format&fit=crop&w=1600&q=80',
altText: 'Modern loft living room',
},
images: [
{ url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Balcony view' },
{ url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Cozy bedroom' },
{ file: 'helsinki-design-loft-balcony.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Balcony view' },
{ file: 'helsinki-design-loft-bedroom.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Cozy bedroom' },
],
titleEn: 'Helsinki design loft with AC',
teaserEn: 'Top-floor loft, AC, fast Wi-Fi, tram at the door.',
@ -155,6 +215,7 @@ async function main() {
},
{
slug: 'turku-riverside-apartment',
isSample: true,
city: 'Turku',
region: 'Varsinais-Suomi',
country: 'Finland',
@ -173,13 +234,14 @@ async function main() {
petsAllowed: true,
byTheLake: false,
evCharging: 'NONE',
priceHintPerNightCents: 11000,
priceHintPerNightEuros: 110,
cover: {
file: 'turku-riverside-apartment-cover.jpg',
url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80',
altText: 'Apartment living room',
},
images: [
{ url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Kitchen area' },
{ file: 'turku-riverside-apartment-kitchen.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Kitchen area' },
],
titleEn: 'Riverside apartment in Turku',
teaserEn: 'By the Aura river, pet-friendly, cozy base.',
@ -190,6 +252,7 @@ async function main() {
},
{
slug: 'rovaniemi-aurora-cabin',
isSample: true,
city: 'Rovaniemi',
region: 'Lapland',
country: 'Finland',
@ -208,13 +271,14 @@ async function main() {
petsAllowed: false,
byTheLake: true,
evCharging: 'FREE',
priceHintPerNightCents: 18900,
priceHintPerNightEuros: 189,
cover: {
file: 'rovaniemi-aurora-cabin-cover.jpg',
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80',
altText: 'Aurora cabin by the river',
},
images: [
{ url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', altText: 'Fireplace lounge' },
{ file: 'rovaniemi-aurora-cabin-lounge.jpg', url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', altText: 'Fireplace lounge' },
],
titleEn: 'Aurora riverside cabin',
teaserEn: 'Sauna, fireplace, river views, EV charging.',
@ -225,6 +289,7 @@ async function main() {
},
{
slug: 'tampere-sauna-studio',
isSample: true,
city: 'Tampere',
region: 'Pirkanmaa',
country: 'Finland',
@ -243,12 +308,13 @@ async function main() {
petsAllowed: false,
byTheLake: false,
evCharging: 'NONE',
priceHintPerNightCents: 9500,
priceHintPerNightEuros: 95,
cover: {
file: 'tampere-sauna-studio-cover.jpg',
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
altText: 'Studio interior',
},
images: [],
images: [{ file: 'tampere-sauna-studio-sauna.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Private sauna' }],
titleEn: 'Tampere studio with private sauna',
teaserEn: 'City center, private sauna, AC and fiber.',
descEn: 'Compact studio on Hämeenkatu with private electric sauna, air conditioning, and fiber internet. Steps from tram.',
@ -258,6 +324,7 @@ async function main() {
},
{
slug: 'vaasa-seaside-villa',
isSample: true,
city: 'Vaasa',
region: 'Ostrobothnia',
country: 'Finland',
@ -276,12 +343,13 @@ async function main() {
petsAllowed: true,
byTheLake: true,
evCharging: 'PAID',
priceHintPerNightCents: 24500,
priceHintPerNightEuros: 245,
cover: {
file: 'vaasa-seaside-villa-cover.jpg',
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
altText: 'Seaside villa deck',
},
images: [],
images: [{ file: 'vaasa-seaside-villa-lounge.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Seaside villa lounge' }],
titleEn: 'Seaside villa in Vaasa',
teaserEn: 'Deck, sauna, pet-friendly, paid EV charging.',
descEn: 'Spacious villa on the coast with large deck, wood sauna, fireplace lounge. Pets welcome; paid EV charger on site.',
@ -291,6 +359,7 @@ async function main() {
},
{
slug: 'kuopio-lakeside-apartment',
isSample: true,
city: 'Kuopio',
region: 'Northern Savonia',
country: 'Finland',
@ -309,12 +378,13 @@ async function main() {
petsAllowed: false,
byTheLake: true,
evCharging: 'FREE',
priceHintPerNightCents: 12900,
priceHintPerNightEuros: 129,
cover: {
file: 'kuopio-lakeside-apartment-cover.jpg',
url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80',
altText: 'Lake view balcony',
},
images: [],
images: [{ file: 'kuopio-lakeside-apartment-sauna.jpg', url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', altText: 'Apartment sauna' }],
titleEn: 'Kuopio lakeside apartment with sauna',
teaserEn: 'Balcony to Kallavesi, sauna, free EV charging.',
descEn: 'Two-bedroom apartment overlooking Lake Kallavesi. Glass balcony, electric sauna, underground parking with free EV charging.',
@ -324,6 +394,7 @@ async function main() {
},
{
slug: 'porvoo-river-loft',
isSample: true,
city: 'Porvoo',
region: 'Uusimaa',
country: 'Finland',
@ -342,12 +413,13 @@ async function main() {
petsAllowed: false,
byTheLake: false,
evCharging: 'NONE',
priceHintPerNightCents: 9900,
priceHintPerNightEuros: 99,
cover: {
file: 'porvoo-river-loft-cover.jpg',
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
altText: 'Loft interior',
},
images: [],
images: [{ file: 'porvoo-river-loft-fireplace.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Fireplace corner' }],
titleEn: 'Porvoo old town river loft',
teaserEn: 'Historic charm, fireplace, steps from river.',
descEn: 'Cozy loft in Porvoo old town. Brick walls, fireplace, and views toward the riverside warehouses.',
@ -357,6 +429,7 @@ async function main() {
},
{
slug: 'oulu-tech-apartment',
isSample: true,
city: 'Oulu',
region: 'Northern Ostrobothnia',
country: 'Finland',
@ -375,12 +448,13 @@ async function main() {
petsAllowed: false,
byTheLake: false,
evCharging: 'FREE',
priceHintPerNightCents: 10500,
priceHintPerNightEuros: 105,
cover: {
file: 'oulu-tech-apartment-cover.jpg',
url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80',
altText: 'Modern apartment',
},
images: [],
images: [{ file: 'oulu-tech-apartment-desk.jpg', url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80', altText: 'Work desk in apartment' }],
titleEn: 'Smart apartment in Oulu',
teaserEn: 'AC, smart lock, free EV charging in garage.',
descEn: 'Modern one-bedroom near Technopolis. Air conditioning, smart lock, desk for work, garage with free EV chargers.',
@ -390,6 +464,7 @@ async function main() {
},
{
slug: 'mariehamn-harbor-flat',
isSample: true,
city: 'Mariehamn',
region: 'Åland',
country: 'Finland',
@ -408,12 +483,13 @@ async function main() {
petsAllowed: false,
byTheLake: true,
evCharging: 'PAID',
priceHintPerNightCents: 11500,
priceHintPerNightEuros: 115,
cover: {
file: 'mariehamn-harbor-flat-cover.jpg',
url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80',
altText: 'Harbor view',
},
images: [],
images: [{ file: 'mariehamn-harbor-flat-living.jpg', url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', altText: 'Harbor-facing living room' }],
titleEn: 'Harbor flat in Mariehamn',
teaserEn: 'Walk to ferries, harbor views, paid EV nearby.',
descEn: 'Bright flat near the harbor. Walk to ferries and restaurants, harbor-facing balcony, paid EV charging at the public lot.',
@ -425,6 +501,7 @@ async function main() {
for (const item of listings) {
const existing = await prisma.listingTranslation.findFirst({ where: { slug: item.slug }, select: { listingId: true } });
const imageCreates = buildImageCreates(item);
if (!existing) {
const created = await prisma.listing.create({
data: {
@ -432,6 +509,7 @@ async function main() {
status: ListingStatus.PUBLISHED,
approvedAt: new Date(),
approvedById: adminUser ? adminUser.id : owner.id,
isSample: item.isSample ?? false,
country: item.country,
region: item.region,
city: item.city,
@ -450,7 +528,7 @@ async function main() {
petsAllowed: item.petsAllowed,
byTheLake: item.byTheLake,
evCharging: item.evCharging,
priceHintPerNightCents: item.priceHintPerNightCents,
priceHintPerNightEuros: item.priceHintPerNightEuros,
contactName: 'Sample Host',
contactEmail: SAMPLE_EMAIL,
contactPhone: owner.phone,
@ -463,17 +541,7 @@ async function main() {
],
},
},
images: {
create: [
{ url: item.cover.url, altText: item.cover.altText, order: 1, isCover: true },
...item.images.map((img, idx) => ({
url: img.url,
altText: img.altText ?? null,
order: idx + 2,
isCover: false,
})),
],
},
images: imageCreates.length ? { create: imageCreates } : undefined,
},
});
console.log('Seeded listing:', created.id, item.slug);
@ -484,6 +552,7 @@ async function main() {
await prisma.listing.update({
where: { id: listingId },
data: {
isSample: item.isSample ?? false,
country: item.country,
region: item.region,
city: item.city,
@ -502,7 +571,7 @@ async function main() {
petsAllowed: item.petsAllowed,
byTheLake: item.byTheLake,
evCharging: item.evCharging,
priceHintPerNightCents: item.priceHintPerNightCents,
priceHintPerNightEuros: item.priceHintPerNightEuros,
contactName: 'Sample Host',
contactEmail: SAMPLE_EMAIL,
contactPhone: owner.phone,
@ -524,18 +593,14 @@ async function main() {
});
await prisma.listingImage.deleteMany({ where: { listingId } });
if (imageCreates.length) {
await prisma.listingImage.createMany({
data: [
{ listingId, url: item.cover.url, altText: item.cover.altText, order: 1, isCover: true },
...item.images.map((img, idx) => ({
data: imageCreates.map((img) => ({
...img,
listingId,
url: img.url,
altText: img.altText ?? null,
order: idx + 2,
isCover: false,
})),
],
});
}
console.log('Updated listing:', item.slug);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB