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 WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" 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 --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npx prisma generate RUN npx prisma generate

View file

@ -51,6 +51,12 @@
- Updated docs to fix Mermaid syntax and labels; Mermaid renders cleanly across all pages. - 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. - 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 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: To resume:
1) If desired, render diagrams locally: PlantUML in `docs/plantuml`, draw.io in `docs/drawio`. 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 { prisma } from '../../../lib/prisma';
import { requireAuth } from '../../../lib/jwt'; import { requireAuth } from '../../../lib/jwt';
import { resolveLocale } from '../../../lib/i18n'; 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 { function normalizeEvCharging(input?: string | null): EvCharging {
const value = String(input ?? 'NONE').toUpperCase(); const value = String(input ?? 'NONE').toUpperCase();
@ -11,6 +16,13 @@ function normalizeEvCharging(input?: string | null): EvCharging {
return EvCharging.NONE; 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 { function pickTranslation<T extends { locale: string }>(translations: T[], locale: string | null): T | null {
if (!translations.length) return null; if (!translations.length) return null;
if (locale) { if (locale) {
@ -54,13 +66,19 @@ export async function GET(req: Request) {
where, where,
include: { include: {
translations: { select: { id: true, locale: true, title: true, slug: true, teaser: true, description: true } }, 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' }, orderBy: { createdAt: 'desc' },
take: Number.isNaN(limit) ? 40 : limit, take: Number.isNaN(limit) ? 40 : limit,
}); });
const payload = listings.map((listing) => { 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 translation = pickTranslation(listing.translations, locale);
const fallback = listing.translations[0]; const fallback = listing.translations[0];
return { return {
@ -87,16 +105,15 @@ export async function GET(req: Request) {
bedrooms: listing.bedrooms, bedrooms: listing.bedrooms,
beds: listing.beds, beds: listing.beds,
bathrooms: listing.bathrooms, bathrooms: listing.bathrooms,
priceHintPerNightCents: listing.priceHintPerNightCents, priceHintPerNightEuros: listing.priceHintPerNightEuros,
coverImage: (listing.images.find((img) => img.isCover) ?? listing.images[0])?.url ?? null, coverImage: resolveImageUrl(listing.images.find((img) => img.isCover) ?? listing.images[0] ?? { id: '', url: null, size: null }),
isSample,
}; };
}); });
return NextResponse.json({ listings: payload }); return NextResponse.json({ listings: payload });
} }
const MAX_IMAGES = 10;
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const auth = await requireAuth(req); const auth = await requireAuth(req);
@ -125,13 +142,70 @@ 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 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) : []; 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 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 autoApprove = process.env.AUTO_APPROVE_LISTINGS === 'true' || auth.role === 'ADMIN';
const status = autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING; const status = autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING;
const isSample = contactEmail.toLowerCase() === SAMPLE_EMAIL;
const listing = await prisma.listing.create({ const listing = await prisma.listing.create({
data: { data: {
@ -157,12 +231,13 @@ export async function POST(req: Request) {
byTheLake: Boolean(body.byTheLake), byTheLake: Boolean(body.byTheLake),
hasAirConditioning: Boolean(body.hasAirConditioning), hasAirConditioning: Boolean(body.hasAirConditioning),
evCharging: normalizeEvCharging(body.evCharging), evCharging: normalizeEvCharging(body.evCharging),
priceHintPerNightCents, priceHintPerNightEuros,
contactName, contactName,
contactEmail, contactEmail,
contactPhone: body.contactPhone ?? null, contactPhone: body.contactPhone ?? null,
externalUrl: body.externalUrl ?? null, externalUrl: body.externalUrl ?? null,
published: status === ListingStatus.PUBLISHED, published: status === ListingStatus.PUBLISHED,
isSample,
translations: { translations: {
create: { create: {
locale, locale,
@ -172,18 +247,13 @@ export async function POST(req: Request) {
teaser: body.teaser ?? null, teaser: body.teaser ?? null,
}, },
}, },
images: images.length images: parsedImages.length
? { ? {
create: images.map((img: any, idx: number) => ({ create: parsedImages,
url: String(img.url ?? ''),
altText: img.altText ? String(img.altText) : null,
order: idx + 1,
isCover: coverImageIndex === idx + 1,
})),
} }
: undefined, : 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 }); return NextResponse.json({ ok: true, listing });

View file

@ -296,6 +296,111 @@ p {
font-size: 18px; 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 { .breadcrumb {
color: var(--muted); color: var(--muted);
font-size: 14px; font-size: 14px;

View file

@ -2,7 +2,8 @@ import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { cookies, headers } from 'next/headers'; 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'; import { resolveLocale, t as translate } from '../../../lib/i18n';
type ListingPageProps = { 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 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 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) { if (!translation) {
notFound(); notFound();
} }
const { listing, title, description, teaser, locale: translationLocale } = translation; 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 = [ const amenities = [
listing.hasSauna ? { icon: amenityIcons.sauna, label: t('amenitySauna') } : null, listing.hasSauna ? { icon: amenityIcons.sauna, label: t('amenitySauna') } : null,
listing.hasFireplace ? { icon: amenityIcons.fireplace, label: t('amenityFireplace') } : null, listing.hasFireplace ? { icon: amenityIcons.fireplace, label: t('amenityFireplace') } : null,
@ -61,6 +64,11 @@ export default async function ListingPage({ params }: ListingPageProps) {
</div> </div>
<div className="listing-layout"> <div className="listing-layout">
<div className="panel listing-main"> <div className="panel listing-main">
{isSample ? (
<div className="badge warning" style={{ marginBottom: 10, display: 'inline-block' }}>
{t('sampleBadge')}
</div>
) : null}
<h1>{title}</h1> <h1>{title}</h1>
<p style={{ marginTop: 8 }}>{teaser ?? description}</p> <p style={{ marginTop: 8 }}>{teaser ?? description}</p>
{listing.addressNote ? ( {listing.addressNote ? (
@ -77,14 +85,18 @@ export default async function ListingPage({ params }: ListingPageProps) {
) : null} ) : null}
{listing.images.length > 0 ? ( {listing.images.length > 0 ? (
<div style={{ marginTop: 12, display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}> <div style={{ marginTop: 12, display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
{listing.images.map((img) => ( {listing.images
<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)' }}> .filter((img) => Boolean(img.url))
<img src={img.url} alt={img.altText ?? title} style={{ width: '100%', height: '200px', objectFit: 'cover' }} /> .map((img) => (
{img.altText ? ( <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)' }}>
<figcaption style={{ padding: '10px 12px', fontSize: 14, color: '#cbd5e1' }}>{img.altText}</figcaption> <a href={img.url || ''} target="_blank" rel="noreferrer" style={{ display: 'block', cursor: 'zoom-in' }}>
) : null} <img src={img.url || ''} alt={img.altText ?? title} style={{ width: '100%', height: '200px', objectFit: 'cover' }} />
</figure> </a>
))} {img.altText ? (
<figcaption style={{ padding: '10px 12px', fontSize: 14, color: '#cbd5e1' }}>{img.altText}</figcaption>
) : null}
</figure>
))}
</div> </div>
) : null} ) : null}
<div style={{ marginTop: 16, fontSize: 14, color: '#666' }}> <div style={{ marginTop: 16, fontSize: 14, color: '#666' }}>

View file

@ -4,7 +4,16 @@ import { useEffect, useState } from 'react';
import { useI18n } from '../../components/I18nProvider'; import { useI18n } from '../../components/I18nProvider';
import type { Locale } from '../../../lib/i18n'; 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() { export default function NewListingPage() {
const { t, locale: uiLocale } = useI18n(); const { t, locale: uiLocale } = useI18n();
@ -34,7 +43,7 @@ export default function NewListingPage() {
const [byTheLake, setByTheLake] = useState(false); const [byTheLake, setByTheLake] = useState(false);
const [hasAirConditioning, setHasAirConditioning] = useState(false); const [hasAirConditioning, setHasAirConditioning] = useState(false);
const [evCharging, setEvCharging] = useState<'NONE' | 'FREE' | 'PAID'>('NONE'); const [evCharging, setEvCharging] = useState<'NONE' | 'FREE' | 'PAID'>('NONE');
const [imagesText, setImagesText] = useState(''); const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
const [coverImageIndex, setCoverImageIndex] = useState(1); const [coverImageIndex, setCoverImageIndex] = useState(1);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -53,12 +62,63 @@ export default function NewListingPage() {
.catch(() => setIsAuthed(false)); .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[] { function parseImages(): ImageInput[] {
return imagesText return selectedImages.map((img) => ({
.split('\n') data: img.dataUrl,
.map((line) => line.trim()) mimeType: img.mimeType,
.filter(Boolean) altText: img.name.replace(/[-_]/g, ' '),
.map((line) => ({ url: line })); }));
} }
async function onSubmit(e: React.FormEvent) { async function onSubmit(e: React.FormEvent) {
@ -67,6 +127,12 @@ export default function NewListingPage() {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
if (selectedImages.length === 0) {
setError(t('imagesRequired'));
setLoading(false);
return;
}
const res = await fetch('/api/listings', { const res = await fetch('/api/listings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -89,7 +155,7 @@ export default function NewListingPage() {
bedrooms, bedrooms,
beds, beds,
bathrooms, bathrooms,
priceHintPerNightCents: price === '' ? null : Number(price), priceHintPerNightEuros: price === '' ? null : Math.round(Number(price)),
hasSauna, hasSauna,
hasFireplace, hasFireplace,
hasWifi, hasWifi,
@ -118,7 +184,7 @@ export default function NewListingPage() {
setLongitude(''); setLongitude('');
setContactName(''); setContactName('');
setContactEmail(''); setContactEmail('');
setImagesText(''); setSelectedImages([]);
setCoverImageIndex(1); setCoverImageIndex(1);
} }
} catch (err) { } catch (err) {
@ -194,23 +260,55 @@ export default function NewListingPage() {
<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('maxGuestsLabel')} {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>
<label> <label>
{t('bedroomsLabel')} {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>
<label> <label>
{t('bedsLabel')} {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>
<label> <label>
{t('bathroomsLabel')} {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>
<label> <label>
{t('priceHintLabel')} {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> </label>
</div> </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))' }}>
@ -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" /> <input type="number" value={longitude} onChange={(e) => setLongitude(e.target.value === '' ? '' : Number(e.target.value))} step="0.000001" />
</label> </label>
</div> </div>
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}> <div className="amenity-grid">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}> {amenityOptions.map((option) => (
<input type="checkbox" checked={hasSauna} onChange={(e) => setHasSauna(e.target.checked)} /> {t('amenitySauna')} <button
</label> key={option.key}
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}> type="button"
<input type="checkbox" checked={hasFireplace} onChange={(e) => setHasFireplace(e.target.checked)} /> {t('amenityFireplace')} className={`amenity-option ${option.checked ? 'selected' : ''}`}
</label> aria-pressed={option.checked}
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}> onClick={() => option.toggle(!option.checked)}
<input type="checkbox" checked={hasWifi} onChange={(e) => setHasWifi(e.target.checked)} /> {t('amenityWifi')} >
</label> <div className="amenity-option-meta">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <span aria-hidden className="amenity-emoji">
<input type="checkbox" checked={petsAllowed} onChange={(e) => setPetsAllowed(e.target.checked)} /> {t('amenityPets')} {option.icon}
</label> </span>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <span className="amenity-name">{option.label}</span>
<input type="checkbox" checked={byTheLake} onChange={(e) => setByTheLake(e.target.checked)} /> {t('amenityLake')} </div>
</label> <span className="amenity-check" aria-hidden>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}> {option.checked ? '✓' : ''}
<input type="checkbox" checked={hasAirConditioning} onChange={(e) => setHasAirConditioning(e.target.checked)} /> {t('amenityAirConditioning')} </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')}
<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>
<label> <label>
{t('evChargingLabel')} {t('coverImageLabel')}
<select value={evCharging} onChange={(e) => setEvCharging(e.target.value as any)}> <input type="number" min={1} max={selectedImages.length || 1} value={coverImageIndex} onChange={(e) => setCoverImageIndex(Number(e.target.value))} />
<option value="NONE">{t('evChargingNone')}</option> <div style={{ color: '#cbd5e1', fontSize: 12, marginTop: 4 }}>{t('coverImageHelp')}</div>
<option value="FREE">{t('evChargingFree')}</option>
<option value="PAID">{t('evChargingPaid')}</option>
</select>
</label> </label>
</div> </div>
<label> {selectedImages.length > 0 ? (
{t('imagesLabel')} <div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<textarea value={imagesText} onChange={(e) => setImagesText(e.target.value)} rows={4} placeholder="https://example.com/image.jpg" /> {selectedImages.map((img, idx) => (
</label> <div key={img.name + idx} style={{ border: '1px solid rgba(148,163,184,0.3)', padding: 8, borderRadius: 8 }}>
<label> <div style={{ fontWeight: 600 }}>{img.name}</div>
{t('coverImageLabel')} <div style={{ fontSize: 12, color: '#cbd5e1' }}>
<input {(img.size / 1024).toFixed(0)} KB · {img.mimeType || 'image/jpeg'}
type="number" </div>
min={1} <div style={{ fontSize: 12, marginTop: 4 }}>{t('coverChoice', { index: idx + 1 })}</div>
value={coverImageIndex} </div>
onChange={(e) => setCoverImageIndex(Number(e.target.value) || 1)} ))}
placeholder={t('coverImageHelp')} </div>
/> ) : null}
<small style={{ color: '#cbd5e1' }}>{t('coverImageHelp')}</small>
</label>
<button className="button" type="submit" disabled={loading}> <button className="button" type="submit" disabled={loading}>
{loading ? t('submittingListing') : t('submitListing')} {loading ? t('submittingListing') : t('submitListing')}
</button> </button>

View file

@ -29,8 +29,9 @@ type ListingResult = {
bedrooms: number; bedrooms: number;
beds: number; beds: number;
bathrooms: number; bathrooms: number;
priceHintPerNightCents: number | null; priceHintPerNightEuros: number | null;
coverImage: string | null; coverImage: string | null;
isSample: boolean;
}; };
type LatLng = { lat: number; lon: number }; type LatLng = { lat: number; lon: number };
@ -166,6 +167,7 @@ export default function ListingsIndexPage() {
const [radiusKm, setRadiusKm] = useState(50); const [radiusKm, setRadiusKm] = useState(50);
const [geocoding, setGeocoding] = useState(false); const [geocoding, setGeocoding] = useState(false);
const [geoError, setGeoError] = useState<string | null>(null); const [geoError, setGeoError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const filteredByAddress = useMemo(() => { const filteredByAddress = useMemo(() => {
if (!addressCenter) return listings; if (!addressCenter) return listings;
@ -233,6 +235,14 @@ export default function ListingsIndexPage() {
const countLabel = t('listingsFound', { count: filtered.length }); 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 ( return (
<main> <main>
<section className="panel"> <section className="panel">
@ -344,26 +354,35 @@ export default function ListingsIndexPage() {
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<p>{t('mapNoResults')}</p> <p>{t('mapNoResults')}</p>
) : ( ) : (
<div className="results-grid"> <div className="results-grid" ref={scrollRef}>
{filtered.map((l) => ( {filtered.map((l) => (
<article <article
key={l.id} key={l.id}
className={`listing-card ${selectedId === l.id ? 'active' : ''}`} className={`listing-card ${selectedId === l.id ? 'active' : ''}`}
data-listing-id={l.id}
onMouseEnter={() => setSelectedId(l.id)} onMouseEnter={() => setSelectedId(l.id)}
onClick={() => setSelectedId(l.id)}
> >
{l.coverImage ? ( <Link href={`/listings/${l.slug}`} aria-label={l.title} style={{ display: 'block' }}>
<img src={l.coverImage} alt={l.title} style={{ width: '100%', height: 140, objectFit: 'cover', borderRadius: 12 }} /> {l.coverImage ? (
) : ( <img src={l.coverImage} alt={l.title} style={{ width: '100%', height: 140, objectFit: 'cover', borderRadius: 12 }} />
<div ) : (
style={{ <div
height: 140, style={{
borderRadius: 12, height: 140,
background: 'linear-gradient(120deg, rgba(34,211,238,0.12), rgba(14,165,233,0.12))', borderRadius: 12,
}} background: 'linear-gradient(120deg, rgba(34,211,238,0.12), rgba(14,165,233,0.12))',
/> }}
)} />
)}
</Link>
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}> <div style={{ display: 'grid', gap: 6, marginTop: 8 }}>
<h3 style={{ margin: 0 }}>{l.title}</h3> <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> <p style={{ margin: 0 }}>{l.teaser ?? ''}</p>
<div style={{ color: '#cbd5e1', fontSize: 14 }}> <div style={{ color: '#cbd5e1', fontSize: 14 }}>
{l.streetAddress ? `${l.streetAddress}, ` : ''} {l.streetAddress ? `${l.streetAddress}, ` : ''}

View file

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

View file

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

View file

@ -5,8 +5,15 @@ cd "$(dirname "$0")/.."
source deploy/env.sh source deploy/env.sh
GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || date +%s) 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_REPO="${REGISTRY}/${REGISTRY_REPO}"
IMAGE="${IMAGE_REPO}:${GIT_SHA}" IMAGE="${IMAGE_REPO}:${BASE_TAG}"
IMAGE_LATEST="${IMAGE_REPO}:latest" IMAGE_LATEST="${IMAGE_REPO}:latest"
echo "Building image:" echo "Building image:"
@ -18,7 +25,7 @@ echo "Running npm audit (high)..."
npm audit --audit-level=high || echo "npm audit reported issues above." npm audit --audit-level=high || echo "npm audit reported issues above."
# Build # 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 echo "$IMAGE" > deploy/.last-image

View file

@ -9,6 +9,31 @@ if [[ ! -f deploy/.last-image ]]; then
exit 1 exit 1
fi 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}" : "${K8S_NAMESPACE:?K8S_NAMESPACE pitää asettaa}"
: "${APP_HOST:?APP_HOST pitää asettaa}" : "${APP_HOST:?APP_HOST pitää asettaa}"
: "${NEXT_PUBLIC_SITE_URL:?NEXT_PUBLIC_SITE_URL pitää asettaa}" : "${NEXT_PUBLIC_SITE_URL:?NEXT_PUBLIC_SITE_URL pitää asettaa}"

View file

@ -12,21 +12,22 @@
</header> </header>
<main class="grid"> <main class="grid">
<section class="card"> <section class="card">
<h2>Component map</h2> <h2>Component map</h2>
<div class="diagram"> <div class="diagram">
<pre class="mermaid"> <pre class="mermaid">
flowchart LR 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 --> Auth["Auth/session module\n(JWT cookie)"]
Next --> Prisma["Prisma ORM"] Next --> Prisma["Prisma ORM"]
Prisma --> Postgres[(PostgreSQL)] Prisma --> Postgres[(PostgreSQL\nlistings + images)]
Next --> Mailer["SMTP mailer\nsmtp.lomavuokraus.fi (CNAME) + DKIM"] Next --> Mailer["SMTP mailer\nsmtp.lomavuokraus.fi (CNAME) + DKIM"]
Next --> Storage["Image storage (remote bucket)"] Admin["Admins & moderators"] --> Traefik
Admin["Admins & moderators"] --> Next
</pre> </pre>
</div> </div>
<div class="callout">Edit the Mermaid block above to evolve the architecture.</div> <div class="callout">Edit the Mermaid block above to evolve the architecture.</div>
</section> </section>
<section class="card"> <section class="card">
<h2>Domain model</h2> <h2>Domain model</h2>
@ -70,7 +71,12 @@ flowchart LR
LISTINGIMAGE { LISTINGIMAGE {
string id string id
string url string url
string data
string mimeType
int size
string altText
boolean isCover boolean isCover
int order
} }
</pre> </pre>
</div> </div>
@ -81,7 +87,8 @@ flowchart LR
<ul> <ul>
<li><strong>Web</strong>: Next.js app (App Router), server-rendered pages, client hooks for auth state.</li> <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>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>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> <li><strong>Auth</strong>: Email/password, verified+approved requirement, JWT session cookie (<code>session_token</code>), roles.</li>
</ul> </ul>

View file

@ -13,15 +13,16 @@
<main class="grid"> <main class="grid">
<section class="card"> <section class="card">
<h2>Traffic flow</h2> <h2>Traffic flow</h2>
<div class="diagram"> <div class="diagram">
<pre class="mermaid"> <pre class="mermaid">
flowchart LR flowchart LR
DNS["lomavuokraus.fi\nstaging.lomavuokraus.fi\napi.lomavuokraus.fi"] --> Traefik["Traefik ingress\n(class: traefik)"] DNS["lomavuokraus.fi\nstaging.lomavuokraus.fi\napi.lomavuokraus.fi"] --> Traefik["Traefik ingress\n(class: traefik)"]
User["User browser"] -->|"HTTPS"| Traefik User["User browser"] -->|"HTTPS"| Traefik
CertMgr["cert-manager\nletsencrypt prod/staging"] -->|"TLS"| Traefik CertMgr["cert-manager\nletsencrypt prod/staging"] -->|"TLS"| Traefik
subgraph Cluster["k3s hel1 cx22 (157.180.66.64)"] subgraph Cluster["k3s hel1 cx22 (157.180.66.64)"]
Traefik --> Service["Service :80 -> 3000"] Traefik --> Service["Service :80 -> 8080"]
Service --> Pod["Next.js pods (2)"] 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 --> DB["PostgreSQL 46.62.203.202"]
Pod --> SMTP["smtp.lomavuokraus.fi"] Pod --> SMTP["smtp.lomavuokraus.fi"]
Secret["Secret: lomavuokraus-web-secrets"] Secret["Secret: lomavuokraus-web-secrets"]
@ -76,6 +77,8 @@ flowchart TB
<li><code>letsencrypt-staging</code> (ACME staging for test certs)</li> <li><code>letsencrypt-staging</code> (ACME staging for test certs)</li>
</ul> </ul>
</li> </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> <li>DNS: <code>lomavuokraus.fi</code>, <code>staging.lomavuokraus.fi</code>, <code>api.lomavuokraus.fi</code> -> cluster IP.</li>
</ul> </ul>
</section> </section>
@ -96,8 +99,8 @@ flowchart TB
<li>Objects: <li>Objects:
<ul> <ul>
<li>ConfigMap: <code>lomavuokraus-web-config</code> (public env).</li> <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>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.</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>Ingress: Traefik class, TLS via cert-manager, HTTPS redirect middleware.</li>
<li>Traefik Middleware: <code>https-redirect</code> to force HTTPS.</li> <li>Traefik Middleware: <code>https-redirect</code> to force HTTPS.</li>
</ul> </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} APP_ENV: ${APP_ENV}
NEXT_PUBLIC_VERSION: ${APP_VERSION} 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 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@ -27,18 +88,17 @@ spec:
app: lomavuokraus-web app: lomavuokraus-web
spec: spec:
containers: containers:
- name: lomavuokraus-web - name: varnish
image: ${K8S_IMAGE} image: varnish:7.5
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 3000 - containerPort: 8080
name: http name: http
envFrom: args: ["-a", ":8080", "-f", "/etc/varnish/default.vcl", "-s", "malloc,256m"]
- configMapRef: volumeMounts:
name: lomavuokraus-web-config - name: varnish-vcl
envFrom: mountPath: /etc/varnish/default.vcl
- secretRef: subPath: default.vcl
name: lomavuokraus-web-secrets
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /api/health path: /api/health
@ -51,6 +111,25 @@ spec:
port: http port: http
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 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: resources:
requests: requests:
cpu: "100m" cpu: "100m"
@ -58,6 +137,10 @@ spec:
limits: limits:
cpu: "500m" cpu: "500m"
memory: "512Mi" memory: "512Mi"
volumes:
- name: varnish-vcl
configMap:
name: lomavuokraus-web-varnish
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service

View file

@ -135,10 +135,17 @@ const allMessages = {
bedroomsLabel: 'Bedrooms', bedroomsLabel: 'Bedrooms',
bedsLabel: 'Beds', bedsLabel: 'Beds',
bathroomsLabel: 'Bathrooms', bathroomsLabel: 'Bathrooms',
priceHintLabel: 'Price hint (cents)', priceHintLabel: 'Price ballpark (€ / night)',
imagesLabel: 'Images (one URL per line, max 10)', priceHintHelp: 'Rough nightly price in euros (not a binding offer).',
coverImageLabel: 'Cover image line number', imagesLabel: 'Images',
coverImageHelp: 'Which image line should be shown as the cover in listings (defaults to 1)', 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', submitListing: 'Create listing',
submittingListing: 'Submitting…', submittingListing: 'Submitting…',
createListingSuccess: 'Listing created with id {id} (status: {status})', createListingSuccess: 'Listing created with id {id} (status: {status})',
@ -162,6 +169,31 @@ const allMessages = {
capacityBathrooms: '{count} bathrooms', capacityBathrooms: '{count} bathrooms',
browseListingsTitle: 'Browse listings', browseListingsTitle: 'Browse listings',
browseListingsLead: 'Search public listings, filter by location, and explore them on the map.', 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', searchLabel: 'Search',
searchPlaceholder: 'Search by name, description, or city', searchPlaceholder: 'Search by name, description, or city',
cityFilter: 'City', cityFilter: 'City',
@ -316,10 +348,17 @@ const allMessages = {
bedroomsLabel: 'Makuuhuoneita', bedroomsLabel: 'Makuuhuoneita',
bedsLabel: 'Vuoteita', bedsLabel: 'Vuoteita',
bathroomsLabel: 'Kylpyhuoneita', bathroomsLabel: 'Kylpyhuoneita',
priceHintLabel: 'Hinta-arvio (senttiä)', priceHintLabel: 'Hinta-arvio (€ / yö)',
imagesLabel: 'Kuvat (yksi URL per rivi, max 10)', priceHintHelp: 'Suuntaa-antava hinta euroina per yö (ei sitova).',
coverImageLabel: 'Kansikuvan rivinumero', imagesLabel: 'Kuvat',
coverImageHelp: 'Mikä kuvista näytetään kansikuvana listauksissa (oletus 1)', 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', submitListing: 'Luo kohde',
submittingListing: 'Lähetetään…', submittingListing: 'Lähetetään…',
createListingSuccess: 'Kohde luotu id:llä {id} (tila: {status})', createListingSuccess: 'Kohde luotu id:llä {id} (tila: {status})',
@ -343,6 +382,31 @@ const allMessages = {
capacityBathrooms: '{count} kylpyhuonetta', capacityBathrooms: '{count} kylpyhuonetta',
browseListingsTitle: 'Selaa kohteita', browseListingsTitle: 'Selaa kohteita',
browseListingsLead: 'Hae julkaistuja kohteita, rajaa sijainnilla ja tutki kartalla.', 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', searchLabel: 'Haku',
searchPlaceholder: 'Hae nimellä, kuvauksella tai paikkakunnalla', searchPlaceholder: 'Hae nimellä, kuvauksella tai paikkakunnalla',
cityFilter: 'Kaupunki/kunta', cityFilter: 'Kaupunki/kunta',

View file

@ -6,7 +6,7 @@ export type ListingWithTranslations = Prisma.ListingTranslationGetPayload<{
include: { include: {
listing: { listing: {
include: { include: {
images: true; images: { select: { id: true; url: true; altText: true; order: true; isCover: true; size: true; mimeType: true } };
owner: true; owner: true;
}; };
}; };
@ -18,6 +18,13 @@ type FetchOptions = {
locale?: string; 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. * Fetch a listing translation by slug and locale.
* Falls back to any locale if the requested locale is missing. * Falls back to any locale if the requested locale is missing.
@ -30,7 +37,7 @@ export async function getListingBySlug({ slug, locale }: FetchOptions): Promise<
include: { include: {
listing: { listing: {
include: { 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, owner: true,
}, },
}, },
@ -47,7 +54,7 @@ export async function getListingBySlug({ slug, locale }: FetchOptions): Promise<
include: { include: {
listing: { listing: {
include: { 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, 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 }; export { SAMPLE_LISTING_SLUG, DEFAULT_LOCALE };

View file

@ -1,2 +1,14 @@
export const SAMPLE_LISTING_SLUG = 'saimaa-lakeside-cabin'; 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'; export const DEFAULT_LOCALE = 'en';

View file

@ -5,7 +5,7 @@
"description": "Lomavuokraus.fi Next.js app and deploy tooling", "description": "Lomavuokraus.fi Next.js app and deploy tooling",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "prisma generate && next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"type-check": "tsc --noEmit" "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, "hasWifi" BOOLEAN NOT NULL DEFAULT false,
"petsAllowed" BOOLEAN NOT NULL DEFAULT false, "petsAllowed" BOOLEAN NOT NULL DEFAULT false,
"byTheLake" BOOLEAN NOT NULL DEFAULT false, "byTheLake" BOOLEAN NOT NULL DEFAULT false,
"priceHintPerNightCents" INTEGER, "priceHintPerNightEuros" INTEGER,
"contactName" TEXT NOT NULL, "contactName" TEXT NOT NULL,
"contactEmail" TEXT NOT NULL, "contactEmail" TEXT NOT NULL,
"contactPhone" TEXT, "contactPhone" TEXT,

View file

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

View file

@ -22,10 +22,66 @@ const prisma = new PrismaClient({
const SAMPLE_SLUG = 'saimaa-lakeside-cabin'; const SAMPLE_SLUG = 'saimaa-lakeside-cabin';
const DEFAULT_LOCALE = 'en'; const DEFAULT_LOCALE = 'en';
const SAMPLE_EMAIL = 'host@lomavuokraus.fi'; 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() { async function main() {
const adminEmail = process.env.ADMIN_EMAIL; const adminEmail = process.env.ADMIN_EMAIL;
const adminPassword = process.env.ADMIN_INITIAL_PASSWORD; 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) { if (!adminEmail || !adminPassword) {
console.warn('ADMIN_EMAIL or ADMIN_INITIAL_PASSWORD missing; admin user will not be seeded.'); console.warn('ADMIN_EMAIL or ADMIN_INITIAL_PASSWORD missing; admin user will not be seeded.');
@ -81,6 +137,7 @@ async function main() {
const listings = [ const listings = [
{ {
slug: SAMPLE_SLUG, slug: SAMPLE_SLUG,
isSample: true,
city: 'Punkaharju', city: 'Punkaharju',
region: 'South Karelia', region: 'South Karelia',
country: 'Finland', country: 'Finland',
@ -99,14 +156,15 @@ async function main() {
petsAllowed: false, petsAllowed: false,
byTheLake: true, byTheLake: true,
evCharging: 'FREE', evCharging: 'FREE',
priceHintPerNightCents: 14500, priceHintPerNightEuros: 145,
cover: { cover: {
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',
altText: 'Lakeside cabin with sauna', altText: 'Lakeside cabin with sauna',
}, },
images: [ images: [
{ 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-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' },
{ 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-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', titleEn: 'Saimaa lakeside cabin with sauna',
teaserEn: 'Sauna, lake view, private dock, and cozy fireplace.', teaserEn: 'Sauna, lake view, private dock, and cozy fireplace.',
@ -119,6 +177,7 @@ async function main() {
}, },
{ {
slug: 'helsinki-design-loft', slug: 'helsinki-design-loft',
isSample: true,
city: 'Helsinki', city: 'Helsinki',
region: 'Uusimaa', region: 'Uusimaa',
country: 'Finland', country: 'Finland',
@ -137,14 +196,15 @@ async function main() {
petsAllowed: false, petsAllowed: false,
byTheLake: false, byTheLake: false,
evCharging: 'PAID', evCharging: 'PAID',
priceHintPerNightCents: 16500, priceHintPerNightEuros: 165,
cover: { cover: {
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',
altText: 'Modern loft living room', altText: 'Modern loft living room',
}, },
images: [ images: [
{ url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Balcony view' }, { 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' },
{ url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Cozy bedroom' }, { 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', titleEn: 'Helsinki design loft with AC',
teaserEn: 'Top-floor loft, AC, fast Wi-Fi, tram at the door.', teaserEn: 'Top-floor loft, AC, fast Wi-Fi, tram at the door.',
@ -155,6 +215,7 @@ async function main() {
}, },
{ {
slug: 'turku-riverside-apartment', slug: 'turku-riverside-apartment',
isSample: true,
city: 'Turku', city: 'Turku',
region: 'Varsinais-Suomi', region: 'Varsinais-Suomi',
country: 'Finland', country: 'Finland',
@ -173,13 +234,14 @@ async function main() {
petsAllowed: true, petsAllowed: true,
byTheLake: false, byTheLake: false,
evCharging: 'NONE', evCharging: 'NONE',
priceHintPerNightCents: 11000, priceHintPerNightEuros: 110,
cover: { cover: {
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',
altText: 'Apartment living room', altText: 'Apartment living room',
}, },
images: [ 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', titleEn: 'Riverside apartment in Turku',
teaserEn: 'By the Aura river, pet-friendly, cozy base.', teaserEn: 'By the Aura river, pet-friendly, cozy base.',
@ -190,6 +252,7 @@ async function main() {
}, },
{ {
slug: 'rovaniemi-aurora-cabin', slug: 'rovaniemi-aurora-cabin',
isSample: true,
city: 'Rovaniemi', city: 'Rovaniemi',
region: 'Lapland', region: 'Lapland',
country: 'Finland', country: 'Finland',
@ -208,13 +271,14 @@ async function main() {
petsAllowed: false, petsAllowed: false,
byTheLake: true, byTheLake: true,
evCharging: 'FREE', evCharging: 'FREE',
priceHintPerNightCents: 18900, priceHintPerNightEuros: 189,
cover: { cover: {
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',
altText: 'Aurora cabin by the river', altText: 'Aurora cabin by the river',
}, },
images: [ 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', titleEn: 'Aurora riverside cabin',
teaserEn: 'Sauna, fireplace, river views, EV charging.', teaserEn: 'Sauna, fireplace, river views, EV charging.',
@ -225,6 +289,7 @@ async function main() {
}, },
{ {
slug: 'tampere-sauna-studio', slug: 'tampere-sauna-studio',
isSample: true,
city: 'Tampere', city: 'Tampere',
region: 'Pirkanmaa', region: 'Pirkanmaa',
country: 'Finland', country: 'Finland',
@ -243,12 +308,13 @@ async function main() {
petsAllowed: false, petsAllowed: false,
byTheLake: false, byTheLake: false,
evCharging: 'NONE', evCharging: 'NONE',
priceHintPerNightCents: 9500, priceHintPerNightEuros: 95,
cover: { cover: {
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',
altText: 'Studio interior', 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', titleEn: 'Tampere studio with private sauna',
teaserEn: 'City center, private sauna, AC and fiber.', 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.', 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', slug: 'vaasa-seaside-villa',
isSample: true,
city: 'Vaasa', city: 'Vaasa',
region: 'Ostrobothnia', region: 'Ostrobothnia',
country: 'Finland', country: 'Finland',
@ -276,12 +343,13 @@ async function main() {
petsAllowed: true, petsAllowed: true,
byTheLake: true, byTheLake: true,
evCharging: 'PAID', evCharging: 'PAID',
priceHintPerNightCents: 24500, priceHintPerNightEuros: 245,
cover: { cover: {
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',
altText: 'Seaside villa deck', 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', titleEn: 'Seaside villa in Vaasa',
teaserEn: 'Deck, sauna, pet-friendly, paid EV charging.', 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.', 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', slug: 'kuopio-lakeside-apartment',
isSample: true,
city: 'Kuopio', city: 'Kuopio',
region: 'Northern Savonia', region: 'Northern Savonia',
country: 'Finland', country: 'Finland',
@ -309,12 +378,13 @@ async function main() {
petsAllowed: false, petsAllowed: false,
byTheLake: true, byTheLake: true,
evCharging: 'FREE', evCharging: 'FREE',
priceHintPerNightCents: 12900, priceHintPerNightEuros: 129,
cover: { cover: {
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',
altText: 'Lake view balcony', 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', titleEn: 'Kuopio lakeside apartment with sauna',
teaserEn: 'Balcony to Kallavesi, sauna, free EV charging.', 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.', 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', slug: 'porvoo-river-loft',
isSample: true,
city: 'Porvoo', city: 'Porvoo',
region: 'Uusimaa', region: 'Uusimaa',
country: 'Finland', country: 'Finland',
@ -342,12 +413,13 @@ async function main() {
petsAllowed: false, petsAllowed: false,
byTheLake: false, byTheLake: false,
evCharging: 'NONE', evCharging: 'NONE',
priceHintPerNightCents: 9900, priceHintPerNightEuros: 99,
cover: { cover: {
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',
altText: 'Loft interior', 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', titleEn: 'Porvoo old town river loft',
teaserEn: 'Historic charm, fireplace, steps from river.', teaserEn: 'Historic charm, fireplace, steps from river.',
descEn: 'Cozy loft in Porvoo old town. Brick walls, fireplace, and views toward the riverside warehouses.', 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', slug: 'oulu-tech-apartment',
isSample: true,
city: 'Oulu', city: 'Oulu',
region: 'Northern Ostrobothnia', region: 'Northern Ostrobothnia',
country: 'Finland', country: 'Finland',
@ -375,12 +448,13 @@ async function main() {
petsAllowed: false, petsAllowed: false,
byTheLake: false, byTheLake: false,
evCharging: 'FREE', evCharging: 'FREE',
priceHintPerNightCents: 10500, priceHintPerNightEuros: 105,
cover: { cover: {
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',
altText: 'Modern apartment', 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', titleEn: 'Smart apartment in Oulu',
teaserEn: 'AC, smart lock, free EV charging in garage.', 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.', 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', slug: 'mariehamn-harbor-flat',
isSample: true,
city: 'Mariehamn', city: 'Mariehamn',
region: 'Åland', region: 'Åland',
country: 'Finland', country: 'Finland',
@ -408,12 +483,13 @@ async function main() {
petsAllowed: false, petsAllowed: false,
byTheLake: true, byTheLake: true,
evCharging: 'PAID', evCharging: 'PAID',
priceHintPerNightCents: 11500, priceHintPerNightEuros: 115,
cover: { cover: {
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',
altText: 'Harbor view', 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', titleEn: 'Harbor flat in Mariehamn',
teaserEn: 'Walk to ferries, harbor views, paid EV nearby.', 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.', 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) { 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 } });
const imageCreates = buildImageCreates(item);
if (!existing) { if (!existing) {
const created = await prisma.listing.create({ const created = await prisma.listing.create({
data: { data: {
@ -432,6 +509,7 @@ async function main() {
status: ListingStatus.PUBLISHED, status: ListingStatus.PUBLISHED,
approvedAt: new Date(), approvedAt: new Date(),
approvedById: adminUser ? adminUser.id : owner.id, approvedById: adminUser ? adminUser.id : owner.id,
isSample: item.isSample ?? false,
country: item.country, country: item.country,
region: item.region, region: item.region,
city: item.city, city: item.city,
@ -450,7 +528,7 @@ async function main() {
petsAllowed: item.petsAllowed, petsAllowed: item.petsAllowed,
byTheLake: item.byTheLake, byTheLake: item.byTheLake,
evCharging: item.evCharging, evCharging: item.evCharging,
priceHintPerNightCents: item.priceHintPerNightCents, priceHintPerNightEuros: item.priceHintPerNightEuros,
contactName: 'Sample Host', contactName: 'Sample Host',
contactEmail: SAMPLE_EMAIL, contactEmail: SAMPLE_EMAIL,
contactPhone: owner.phone, contactPhone: owner.phone,
@ -463,17 +541,7 @@ async function main() {
], ],
}, },
}, },
images: { images: imageCreates.length ? { create: imageCreates } : undefined,
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,
})),
],
},
}, },
}); });
console.log('Seeded listing:', created.id, item.slug); console.log('Seeded listing:', created.id, item.slug);
@ -484,6 +552,7 @@ async function main() {
await prisma.listing.update({ await prisma.listing.update({
where: { id: listingId }, where: { id: listingId },
data: { data: {
isSample: item.isSample ?? false,
country: item.country, country: item.country,
region: item.region, region: item.region,
city: item.city, city: item.city,
@ -502,7 +571,7 @@ async function main() {
petsAllowed: item.petsAllowed, petsAllowed: item.petsAllowed,
byTheLake: item.byTheLake, byTheLake: item.byTheLake,
evCharging: item.evCharging, evCharging: item.evCharging,
priceHintPerNightCents: item.priceHintPerNightCents, priceHintPerNightEuros: item.priceHintPerNightEuros,
contactName: 'Sample Host', contactName: 'Sample Host',
contactEmail: SAMPLE_EMAIL, contactEmail: SAMPLE_EMAIL,
contactPhone: owner.phone, contactPhone: owner.phone,
@ -524,18 +593,14 @@ async function main() {
}); });
await prisma.listingImage.deleteMany({ where: { listingId } }); await prisma.listingImage.deleteMany({ where: { listingId } });
await prisma.listingImage.createMany({ if (imageCreates.length) {
data: [ await prisma.listingImage.createMany({
{ listingId, url: item.cover.url, altText: item.cover.altText, order: 1, isCover: true }, data: imageCreates.map((img) => ({
...item.images.map((img, idx) => ({ ...img,
listingId, listingId,
url: img.url,
altText: img.altText ?? null,
order: idx + 2,
isCover: false,
})), })),
], });
}); }
console.log('Updated listing:', item.slug); 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