Update price hint to euros and improve amenities UI
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
33
app/api/images/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
105
app/globals.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}, ` : ''}
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.")
|
||||
101
k8s/app.yaml
|
|
@ -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
|
||||
|
|
|
|||
80
lib/i18n.ts
|
|
@ -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: 'Transaktiosä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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- Add a flag to mark sample/demo listings
|
||||
ALTER TABLE "Listing" ADD COLUMN "isSample" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
151
prisma/seed.js
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
BIN
sampleimages/helsinki-design-loft-balcony.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
sampleimages/helsinki-design-loft-bedroom.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
sampleimages/helsinki-design-loft-cover.jpg
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
sampleimages/kuopio-lakeside-apartment-cover.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
sampleimages/kuopio-lakeside-apartment-sauna.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
sampleimages/mariehamn-harbor-flat-cover.jpg
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
sampleimages/mariehamn-harbor-flat-living.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
sampleimages/oulu-tech-apartment-cover.jpg
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
sampleimages/oulu-tech-apartment-desk.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
sampleimages/porvoo-river-loft-cover.jpg
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
sampleimages/porvoo-river-loft-fireplace.jpg
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
sampleimages/rovaniemi-aurora-cabin-cover.jpg
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
sampleimages/rovaniemi-aurora-cabin-lounge.jpg
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
sampleimages/saimaa-lakeside-cabin-cover.jpg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
sampleimages/saimaa-lakeside-cabin-lounge.jpg
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
sampleimages/saimaa-lakeside-cabin-sauna.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
sampleimages/tampere-sauna-studio-cover.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
sampleimages/tampere-sauna-studio-sauna.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
sampleimages/turku-riverside-apartment-cover.jpg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
sampleimages/turku-riverside-apartment-kitchen.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
sampleimages/vaasa-seaside-villa-cover.jpg
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
sampleimages/vaasa-seaside-villa-lounge.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |