Update price hint to euros and improve amenities UI
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
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 { 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 });
|
||||||
|
|
|
||||||
105
app/globals.css
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,9 +85,13 @@ 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
|
||||||
|
.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)' }}>
|
<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 ? (
|
{img.altText ? (
|
||||||
<figcaption style={{ padding: '10px 12px', fontSize: 14, color: '#cbd5e1' }}>{img.altText}</figcaption>
|
<figcaption style={{ padding: '10px 12px', fontSize: 14, color: '#cbd5e1' }}>{img.altText}</figcaption>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
|
||||||
</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>
|
</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>
|
<label>
|
||||||
{t('imagesLabel')}
|
{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>
|
||||||
<label>
|
<label>
|
||||||
{t('coverImageLabel')}
|
{t('coverImageLabel')}
|
||||||
<input
|
<input type="number" min={1} max={selectedImages.length || 1} value={coverImageIndex} onChange={(e) => setCoverImageIndex(Number(e.target.value))} />
|
||||||
type="number"
|
<div style={{ color: '#cbd5e1', fontSize: 12, marginTop: 4 }}>{t('coverImageHelp')}</div>
|
||||||
min={1}
|
|
||||||
value={coverImageIndex}
|
|
||||||
onChange={(e) => setCoverImageIndex(Number(e.target.value) || 1)}
|
|
||||||
placeholder={t('coverImageHelp')}
|
|
||||||
/>
|
|
||||||
<small style={{ color: '#cbd5e1' }}>{t('coverImageHelp')}</small>
|
|
||||||
</label>
|
</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}>
|
<button className="button" type="submit" disabled={loading}>
|
||||||
{loading ? t('submittingListing') : t('submitListing')}
|
{loading ? t('submittingListing') : t('submitListing')}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -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,13 +354,16 @@ 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)}
|
||||||
>
|
>
|
||||||
|
<Link href={`/listings/${l.slug}`} aria-label={l.title} style={{ display: 'block' }}>
|
||||||
{l.coverImage ? (
|
{l.coverImage ? (
|
||||||
<img src={l.coverImage} alt={l.title} style={{ width: '100%', height: 140, objectFit: 'cover', borderRadius: 12 }} />
|
<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 }}>
|
<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}, ` : ''}
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
<a href={`/listings/${item.slug}`} className="latest-cover-link" aria-label={item.title}>
|
||||||
<img src={item.coverImage} alt={item.title} className="latest-cover" />
|
<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" />
|
<div className="latest-cover placeholder" />
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
<div className="latest-meta">
|
<div className="latest-meta">
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
<span className="badge">
|
<span className="badge">
|
||||||
{item.city}, {item.region}
|
{item.city}, {item.region}
|
||||||
</span>
|
</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' }}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,14 @@
|
||||||
<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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ flowchart LR
|
||||||
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
|
|
@ -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}
|
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
|
||||||
|
|
|
||||||
80
lib/i18n.ts
|
|
@ -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: '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',
|
searchLabel: 'Haku',
|
||||||
searchPlaceholder: 'Hae nimellä, kuvauksella tai paikkakunnalla',
|
searchPlaceholder: 'Hae nimellä, kuvauksella tai paikkakunnalla',
|
||||||
cityFilter: 'Kaupunki/kunta',
|
cityFilter: 'Kaupunki/kunta',
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
"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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
151
prisma/seed.js
|
|
@ -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 } });
|
||||||
|
if (imageCreates.length) {
|
||||||
await prisma.listingImage.createMany({
|
await prisma.listingImage.createMany({
|
||||||
data: [
|
data: imageCreates.map((img) => ({
|
||||||
{ listingId, url: item.cover.url, altText: item.cover.altText, order: 1, isCover: true },
|
...img,
|
||||||
...item.images.map((img, idx) => ({
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 |