feat: enhance listings browsing and amenities

This commit is contained in:
Tero Halla-aho 2025-11-24 17:15:20 +02:00
commit 4c05b0628e
73 changed files with 12596 additions and 0 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
node_modules
npm-debug.log*
Dockerfile
.dockerignore
.git
.gitignore
.next
.next/cache
out
dist
.env*
!.env.example
coverage

9
.env.example Normal file
View file

@ -0,0 +1,9 @@
# Public URLs
NEXT_PUBLIC_SITE_URL=https://lomavuokraus.fi
NEXT_PUBLIC_API_BASE=https://api.lomavuokraus.fi
# Runtime env flag used in UI
APP_ENV=local
# Secrets (override in Kubernetes Secret)
APP_SECRET=change-me

6
.eslintrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": ["next/core-web-vitals"],
"rules": {
"@next/next/no-img-element": "off"
}
}

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
node_modules
.next
out
dist
npm-debug.log*
yarn-error.log*
pnpm-lock.yaml
.DS_Store
.env*
!.env.example
.deploy-cache
.deploy
coverage
.deploy/**
.deps
.vercel
.deploy-info
deploy/.last-image
creds/
k3s.yaml
# Local-only documentation
docs-local/
/lib/generated/prisma
# Local virtualenv and build artifacts
bin/
lib/python*/
lib64/
pyvenv.cfg
tsconfig.tsbuildinfo
.lock
openai.key
docs.tgz
CACHEDIR.TAG

38
Dockerfile Normal file
View file

@ -0,0 +1,38 @@
# syntax=docker/dockerfile:1
ARG NODE_VERSION=20.19.0
FROM node:${NODE_VERSION}-bookworm-slim AS deps
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
ENV PRISMA_SKIP_POSTINSTALL_GENERATE=1
COPY package.json package-lock.json ./
RUN npm ci
FROM node:${NODE_VERSION}-bookworm-slim AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder"
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM node:${NODE_VERSION}-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --gid 1001 --disabled-password --gecos "" nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

170
app/admin/pending/page.tsx Normal file
View file

@ -0,0 +1,170 @@
'use client';
import { useEffect, useState } from 'react';
import { useI18n } from '../../components/I18nProvider';
type PendingUser = { id: string; email: string; status: string; emailVerifiedAt: string | null; approvedAt: string | null; role: string };
type PendingListing = { id: string; status: string; createdAt: string; owner: { email: string }; translations: { title: string; slug: string; locale: string }[] };
export default function PendingAdminPage() {
const { t } = useI18n();
const [pendingUsers, setPendingUsers] = useState<PendingUser[]>([]);
const [pendingListings, setPendingListings] = useState<PendingListing[]>([]);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function loadPending() {
setMessage(null);
setError(null);
try {
const res = await fetch('/api/admin/pending', { cache: 'no-store' });
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to load');
return;
}
setPendingUsers(data.users ?? []);
setPendingListings(data.listings ?? []);
} catch (e) {
setError('Failed to load');
}
}
useEffect(() => {
fetch('/api/auth/me', { cache: 'no-store' })
.then((res) => res.json())
.then((data) => {
if (data.user?.role === 'ADMIN') {
loadPending();
} else {
setError(t('adminRequired'));
}
})
.catch(() => setError(t('adminRequired')));
}, [t]);
async function approveUser(userId: string, makeAdmin: boolean) {
setMessage(null);
setError(null);
const res = await fetch('/api/admin/users/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, makeAdmin }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to approve user');
} else {
setMessage(t('userUpdated'));
loadPending();
}
}
async function approveListing(listingId: string, action: 'approve' | 'reject' | 'remove') {
setMessage(null);
setError(null);
const reason =
action === 'reject'
? window.prompt(`${t('reject')}? (optional)`)
: action === 'remove'
? window.prompt(`${t('remove')}? (optional)`)
: null;
const res = await fetch('/api/admin/listings/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ listingId, action, reason }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to update listing');
} else {
setMessage(t('approvalsMessage'));
loadPending();
}
}
async function rejectUser(userId: string) {
setMessage(null);
setError(null);
const reason = window.prompt(`${t('reject')}? (optional)`);
const res = await fetch('/api/admin/users/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, reason }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to reject user');
} else {
setMessage(t('userUpdated'));
loadPending();
}
}
return (
<main className="panel" style={{ maxWidth: 960, margin: '40px auto' }}>
<h1>{t('pendingAdminTitle')}</h1>
<div style={{ display: 'grid', gap: 16 }}>
<section>
<h3>{t('pendingUsersTitle')}</h3>
{pendingUsers.length === 0 ? (
<p>{t('noPendingUsers')}</p>
) : (
<ul style={{ display: 'grid', gap: 8, padding: 0, listStyle: 'none' }}>
{pendingUsers.map((u) => (
<li key={u.id} style={{ border: '1px solid #ddd', borderRadius: 8, padding: 12 }}>
<div>
<strong>{u.email}</strong> {t('statusLabel')}: {u.status} {t('verifiedLabel')}: {u.emailVerifiedAt ? t('yes') : t('no')}
</div>
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
<button className="button" onClick={() => approveUser(u.id, false)}>
{t('approve')}
</button>
<button className="button secondary" onClick={() => approveUser(u.id, true)}>
{t('approveAdmin')}
</button>
<button className="button secondary" onClick={() => rejectUser(u.id)}>
{t('reject')}
</button>
</div>
</li>
))}
</ul>
)}
</section>
<section>
<h3>{t('pendingListingsTitle')}</h3>
{pendingListings.length === 0 ? (
<p>{t('noPendingListings')}</p>
) : (
<ul style={{ display: 'grid', gap: 8, padding: 0, listStyle: 'none' }}>
{pendingListings.map((l) => (
<li key={l.id} style={{ border: '1px solid #ddd', borderRadius: 8, padding: 12 }}>
<div>
<strong>{l.translations[0]?.title ?? 'Listing'}</strong> owner: {l.owner.email}
</div>
<div style={{ fontSize: 12, color: '#666' }}>
{t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')}
</div>
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
<button className="button" onClick={() => approveListing(l.id, 'approve')}>
{t('publish')}
</button>
<button className="button secondary" onClick={() => approveListing(l.id, 'reject')}>
{t('reject')}
</button>
<button className="button secondary" onClick={() => approveListing(l.id, 'remove')}>
{t('remove')}
</button>
</div>
</li>
))}
</ul>
)}
</section>
</div>
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null}
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
</main>
);
}

196
app/admin/users/page.tsx Normal file
View file

@ -0,0 +1,196 @@
'use client';
import { useEffect, useState } from 'react';
import { useI18n } from '../../components/I18nProvider';
type UserRow = {
id: string;
email: string;
name: string | null;
role: string;
status: string;
emailVerifiedAt: string | null;
approvedAt: string | null;
};
const roleOptions = ['USER', 'USER_MODERATOR', 'LISTING_MODERATOR', 'ADMIN'];
export default function AdminUsersPage() {
const { t } = useI18n();
const [users, setUsers] = useState<UserRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function load() {
setError(null);
try {
const res = await fetch('/api/admin/users', { cache: 'no-store' });
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to load users');
} else {
setUsers(data.users ?? []);
}
} catch (e) {
setError('Failed to load users');
}
}
useEffect(() => {
load();
}, []);
async function setRole(userId: string, role: string) {
setMessage(null);
setError(null);
setLoading(true);
try {
const res = await fetch('/api/admin/users/role', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, role }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to update role');
} else {
setMessage(t('userUpdated'));
load();
}
} catch (e) {
setError('Failed to update role');
} finally {
setLoading(false);
}
}
async function approve(userId: string) {
setMessage(null);
setError(null);
setLoading(true);
try {
const res = await fetch('/api/admin/users/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to approve user');
} else {
setMessage(t('userUpdated'));
load();
}
} catch (e) {
setError('Failed to approve user');
} finally {
setLoading(false);
}
}
async function reject(userId: string) {
setMessage(null);
setError(null);
setLoading(true);
try {
const reason = window.prompt('Reason for rejection? (optional)');
const res = await fetch('/api/admin/users/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, reason }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to reject user');
} else {
setMessage(t('userUpdated'));
load();
}
} catch (e) {
setError('Failed to reject user');
} finally {
setLoading(false);
}
}
async function remove(userId: string) {
setMessage(null);
setError(null);
setLoading(true);
try {
const reason = window.prompt('Reason for removal? (optional)');
const res = await fetch('/api/admin/users/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, reason }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to remove user');
} else {
setMessage(t('userUpdated'));
load();
}
} catch (e) {
setError('Failed to remove user');
} finally {
setLoading(false);
}
}
return (
<main className="panel" style={{ maxWidth: 960, margin: '40px auto' }}>
<h1>{t('adminUsersTitle')}</h1>
<p>{t('adminUsersLead')}</p>
{message ? <p style={{ color: 'green' }}>{message}</p> : null}
{error ? <p style={{ color: 'red' }}>{error}</p> : null}
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 12 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8 }}>{t('tableEmail')}</th>
<th style={{ textAlign: 'left', padding: 8 }}>{t('tableRole')}</th>
<th style={{ textAlign: 'left', padding: 8 }}>{t('tableStatus')}</th>
<th style={{ textAlign: 'left', padding: 8 }}>{t('tableVerified')}</th>
<th style={{ textAlign: 'left', padding: 8 }}>{t('tableApproved')}</th>
<th style={{ textAlign: 'left', padding: 8 }}>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} style={{ borderTop: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{u.email}</td>
<td style={{ padding: 8 }}>
<select value={u.role} onChange={(e) => setRole(u.id, e.target.value)} disabled={loading}>
{roleOptions.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</td>
<td style={{ padding: 8 }}>{u.status}</td>
<td style={{ padding: 8 }}>{u.emailVerifiedAt ? 'yes' : 'no'}</td>
<td style={{ padding: 8 }}>{u.approvedAt ? 'yes' : 'no'}</td>
<td style={{ padding: 8 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{u.approvedAt ? null : (
<button className="button secondary" onClick={() => approve(u.id)} disabled={loading}>
{t('approve')}
</button>
)}
<button className="button secondary" onClick={() => reject(u.id)} disabled={loading}>
{t('reject')}
</button>
<button className="button secondary" onClick={() => remove(u.id)} disabled={loading}>
{t('remove')}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</main>
);
}

View file

@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import { ListingStatus } from '@prisma/client';
import { prisma } from '../../../../../lib/prisma';
import { requireAuth } from '../../../../../lib/jwt';
import { Role } from '@prisma/client';
export async function POST(req: Request) {
try {
const auth = await requireAuth(req);
const canModerate = auth.role === Role.ADMIN || auth.role === Role.LISTING_MODERATOR;
if (!canModerate) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json();
const listingId = String(body.listingId ?? '');
const action = body.action ?? 'approve';
const reason = body.reason ? String(body.reason).slice(0, 500) : null;
if (!listingId) {
return NextResponse.json({ error: 'listingId is required' }, { status: 400 });
}
let status: ListingStatus;
if (action === 'reject') status = ListingStatus.REJECTED;
else if (action === 'remove') status = ListingStatus.REMOVED;
else if (action === 'publish' || action === 'approve') status = ListingStatus.PUBLISHED;
else status = ListingStatus.PENDING;
const updated = await prisma.listing.update({
where: { id: listingId },
data: {
status,
published: status === ListingStatus.PUBLISHED,
approvedAt: status === ListingStatus.PUBLISHED ? new Date() : null,
approvedById: status === ListingStatus.PUBLISHED ? auth.userId : null,
rejectedAt: status === ListingStatus.REJECTED ? new Date() : null,
rejectedById: status === ListingStatus.REJECTED ? auth.userId : null,
rejectedReason: status === ListingStatus.REJECTED ? reason : null,
removedAt: status === ListingStatus.REMOVED ? new Date() : null,
removedById: status === ListingStatus.REMOVED ? auth.userId : null,
removedReason: status === ListingStatus.REMOVED ? reason : null,
},
select: { id: true, status: true, approvedAt: true, approvedById: true },
});
return NextResponse.json({ ok: true, listing: updated });
} catch (error) {
console.error('Admin listing approval error', error);
return NextResponse.json({ error: 'Approval failed' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';

View file

@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../../../lib/prisma';
import { requireAuth } from '../../../../../lib/jwt';
import { ListingStatus, Role, UserStatus } from '@prisma/client';
export async function GET(req: Request) {
try {
const auth = await requireAuth(req);
const isAdmin = auth.role === Role.ADMIN;
const canUserMod = auth.role === Role.USER_MODERATOR;
const canListingMod = auth.role === Role.LISTING_MODERATOR;
if (!isAdmin && !canUserMod && !canListingMod) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const wantsUsers = isAdmin || canUserMod;
const wantsListings = isAdmin || canListingMod;
const [users, listings] = await Promise.all([
wantsUsers ? prisma.user.count({ where: { status: UserStatus.PENDING } }) : Promise.resolve(0),
wantsListings ? prisma.listing.count({ where: { status: ListingStatus.PENDING, removedAt: null } }) : Promise.resolve(0),
]);
return NextResponse.json({ users, listings, total: users + listings });
} catch (error) {
console.error('Pending count error', error);
return NextResponse.json({ error: 'Failed to load count' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';

View file

@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../../lib/prisma';
import { requireAuth } from '../../../../lib/jwt';
import { Role, ListingStatus, UserStatus } from '@prisma/client';
export async function GET(req: Request) {
try {
const auth = await requireAuth(req);
const isAdmin = auth.role === Role.ADMIN;
const canUserMod = auth.role === Role.USER_MODERATOR;
const canListingMod = auth.role === Role.LISTING_MODERATOR;
if (!isAdmin && !canUserMod && !canListingMod) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const wantsUsers = isAdmin || canUserMod;
const wantsListings = isAdmin || canListingMod;
const [users, listings] = await Promise.all([
wantsUsers
? prisma.user.findMany({
where: { status: UserStatus.PENDING },
select: { id: true, email: true, status: true, emailVerifiedAt: true, approvedAt: true, role: true },
orderBy: { createdAt: 'asc' },
take: 50,
})
: Promise.resolve([]),
wantsListings
? prisma.listing.findMany({
where: { status: ListingStatus.PENDING, removedAt: null },
select: { id: true, status: true, createdAt: true, owner: { select: { email: true } }, translations: { select: { title: true, slug: true, locale: true } } },
orderBy: { createdAt: 'asc' },
take: 50,
})
: Promise.resolve([]),
]);
return NextResponse.json({ users, listings, role: auth.role });
} catch (error) {
console.error('List pending error', error);
return NextResponse.json({ error: 'Failed to load pending items' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';

View file

@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../../../lib/prisma';
import { requireAuth } from '../../../../../lib/jwt';
import { Role, UserStatus } from '@prisma/client';
export async function POST(req: Request) {
try {
const auth = await requireAuth(req);
const isAdmin = auth.role === Role.ADMIN;
const canApprove = isAdmin || auth.role === Role.USER_MODERATOR;
if (!canApprove) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json();
const userId = String(body.userId ?? '');
const makeAdmin = Boolean(body.makeAdmin);
const newRole = body.newRole as Role | undefined;
if (!userId) {
return NextResponse.json({ error: 'userId is required' }, { status: 400 });
}
if (!isAdmin && (makeAdmin || newRole === Role.ADMIN || newRole === Role.USER_MODERATOR || newRole === Role.LISTING_MODERATOR)) {
return NextResponse.json({ error: 'Only admins can change roles' }, { status: 403 });
}
const roleUpdate = isAdmin && newRole ? { role: newRole } : makeAdmin && isAdmin ? { role: Role.ADMIN } : undefined;
const updated = await prisma.user.update({
where: { id: userId },
data: {
status: UserStatus.ACTIVE,
approvedAt: new Date(),
rejectedAt: null,
rejectedReason: null,
removedAt: null,
removedById: null,
removedReason: null,
...(roleUpdate ?? {}),
},
select: { id: true, role: true, status: true, approvedAt: true },
});
return NextResponse.json({ ok: true, user: updated });
} catch (error) {
console.error('Admin approve user error', error);
return NextResponse.json({ error: 'Approval failed' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';

View file

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../../../lib/prisma';
import { requireAuth } from '../../../../../lib/jwt';
import { Role, UserStatus } from '@prisma/client';
export async function POST(req: Request) {
try {
const auth = await requireAuth(req);
const canReject = auth.role === Role.ADMIN || auth.role === Role.USER_MODERATOR;
if (!canReject) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json();
const userId = String(body.userId ?? '');
const reason = body.reason ? String(body.reason).slice(0, 500) : null;
if (!userId) {
return NextResponse.json({ error: 'userId is required' }, { status: 400 });
}
const updated = await prisma.user.update({
where: { id: userId },
data: {
status: UserStatus.REJECTED,
rejectedAt: new Date(),
rejectedReason: reason,
approvedAt: null,
},
select: { id: true, role: true, status: true, rejectedAt: true, rejectedReason: true },
});
return NextResponse.json({ ok: true, user: updated });
} catch (error) {
console.error('Admin reject user error', error);
return NextResponse.json({ error: 'Reject failed' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';

View file

@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../../../lib/prisma';
import { requireAuth } from '../../../../../lib/jwt';
import { Role, UserStatus } from '@prisma/client';
export async function POST(req: Request) {
try {
const auth = await requireAuth(req);
if (auth.role !== Role.ADMIN) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json();
const userId = String(body.userId ?? '');
const reason = body.reason ? String(body.reason).slice(0, 500) : null;
if (!userId) {
return NextResponse.json({ error: 'userId is required' }, { status: 400 });
}
if (userId === auth.userId) {
return NextResponse.json({ error: 'Cannot remove your own account' }, { status: 400 });
}
const updated = await prisma.user.update({
where: { id: userId },
data: {
status: UserStatus.REMOVED,
removedAt: new Date(),
removedById: auth.userId,
removedReason: reason,
},
select: { id: true, role: true, status: true, removedAt: true, removedReason: true },
});
return NextResponse.json({ ok: true, user: updated });
} catch (error) {
console.error('Admin remove user error', error);
return NextResponse.json({ error: 'Remove failed' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';

View file

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../../../lib/prisma';
import { requireAuth } from '../../../../../lib/jwt';
import { Role } from '@prisma/client';
export async function POST(req: Request) {
try {
const auth = await requireAuth(req);
if (auth.role !== Role.ADMIN) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json();
const userId = String(body.userId ?? '');
const role = body.role as Role | undefined;
if (!userId || !role) {
return NextResponse.json({ error: 'userId and role are required' }, { status: 400 });
}
const updated = await prisma.user.update({
where: { id: userId },
data: { role },
select: { id: true, email: true, role: true },
});
return NextResponse.json({ ok: true, user: updated });
} catch (error) {
console.error('Update role error', error);
return NextResponse.json({ error: 'Failed to update role' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';

View file

@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../../lib/prisma';
import { requireAuth } from '../../../../lib/jwt';
import { Role, UserStatus } from '@prisma/client';
export async function GET(req: Request) {
try {
const auth = await requireAuth(req);
if (auth.role !== Role.ADMIN) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
emailVerifiedAt: true,
approvedAt: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
take: 200,
});
return NextResponse.json({ users, roles: Object.values(Role), statuses: Object.values(UserStatus) });
} catch (error) {
console.error('List users error', error);
return NextResponse.json({ error: 'Failed to load users' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';

View file

@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { UserStatus } from '@prisma/client';
import { prisma } from '../../../../lib/prisma';
import { verifyPassword } from '../../../../lib/auth';
import { signAccessToken, buildSessionCookie, clearSessionCookie } from '../../../../lib/jwt';
export async function POST(req: Request) {
try {
const body = await req.json();
const email = String(body.email ?? '').trim().toLowerCase();
const password = String(body.password ?? '');
if (!email || !password) {
return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
if (!user.emailVerifiedAt) {
return NextResponse.json({ error: 'Email not verified yet' }, { status: 403 });
}
if (!user.approvedAt || user.status !== UserStatus.ACTIVE) {
const statusMessage =
user.status === UserStatus.REJECTED
? 'User access was rejected'
: user.status === UserStatus.REMOVED
? 'User has been removed'
: 'User is not approved yet';
return NextResponse.json({ error: statusMessage }, { status: 403 });
}
const token = await signAccessToken({ userId: user.id, role: user.role });
const res = NextResponse.json({ token, user: { id: user.id, role: user.role, email: user.email } });
res.headers.append('Set-Cookie', buildSessionCookie(token));
return res;
} catch (error) {
console.error('Login error', error);
const res = NextResponse.json({ error: 'Login failed' }, { status: 500 });
res.headers.append('Set-Cookie', clearSessionCookie());
return res;
}
}

View file

@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { clearSessionCookie } from '../../../../lib/jwt';
export async function POST() {
const res = NextResponse.json({ ok: true });
res.headers.append('Set-Cookie', clearSessionCookie());
return res;
}

17
app/api/auth/me/route.ts Normal file
View file

@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../../lib/prisma';
import { requireAuth } from '../../../../lib/jwt';
export async function GET(req: Request) {
try {
const session = await requireAuth(req);
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: { id: true, email: true, role: true, status: true, emailVerifiedAt: true, approvedAt: true, name: true },
});
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ user });
} catch (error) {
return NextResponse.json({ user: null }, { status: 200 });
}
}

View file

@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { Role, UserStatus } from '@prisma/client';
import { prisma } from '../../../../lib/prisma';
import { hashPassword } from '../../../../lib/auth';
import { randomToken, addHours } from '../../../../lib/tokens';
import { sendVerificationEmail } from '../../../../lib/mailer';
const APP_URL = process.env.APP_URL || 'http://localhost:3000';
export async function POST(req: Request) {
try {
const body = await req.json();
const email = String(body.email ?? '').trim().toLowerCase();
const password = String(body.password ?? '');
const name = body.name ? String(body.name).trim() : null;
if (!email || !password) {
return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: 'Email already registered' }, { status: 409 });
}
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: {
email,
name,
passwordHash,
status: UserStatus.PENDING,
role: Role.USER,
},
});
const token = randomToken();
await prisma.verificationToken.create({
data: {
userId: user.id,
token,
type: 'email_verify',
expiresAt: addHours(24),
},
});
const verifyUrl = `${APP_URL}/verify?token=${token}`;
await sendVerificationEmail(email, verifyUrl);
return NextResponse.json({ ok: true });
} catch (error) {
console.error('Register error', error);
return NextResponse.json({ error: 'Registration failed' }, { status: 500 });
}
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../../lib/prisma';
export async function POST(req: Request) {
try {
const body = await req.json();
const token = String(body.token ?? '').trim();
if (!token) {
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
}
const record = await prisma.verificationToken.findUnique({ where: { token }, include: { user: true } });
if (!record) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
}
if (record.consumedAt) {
return NextResponse.json({ error: 'Token already used' }, { status: 400 });
}
if (record.expiresAt < new Date()) {
return NextResponse.json({ error: 'Token expired' }, { status: 400 });
}
await prisma.$transaction([
prisma.user.update({
where: { id: record.userId },
data: { emailVerifiedAt: new Date() },
}),
prisma.verificationToken.update({
where: { id: record.id },
data: { consumedAt: new Date() },
}),
]);
return NextResponse.json({ ok: true });
} catch (error) {
console.error('Verify error', error);
return NextResponse.json({ error: 'Verification failed' }, { status: 500 });
}
}

7
app/api/health/route.ts Normal file
View file

@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET() {
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
}

View file

@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../../lib/prisma';
import { UserStatus } from '@prisma/client';
import { requireAuth } from '../../../../lib/jwt';
export async function GET(req: Request) {
try {
const session = await requireAuth(req);
const user = await prisma.user.findUnique({ where: { id: session.userId }, select: { status: true } });
if (!user || user.status !== UserStatus.ACTIVE) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const listings = await prisma.listing.findMany({
where: { ownerId: session.userId },
select: { id: true, status: true, translations: { select: { slug: true, title: true, locale: true } } },
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({ listings });
} catch (error) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}

View file

@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { ListingStatus, Role } from '@prisma/client';
import { prisma } from '../../../../lib/prisma';
import { requireAuth } from '../../../../lib/jwt';
export async function POST(req: Request) {
try {
const auth = await requireAuth(req);
const body = await req.json();
const listingId = String(body.listingId ?? '');
const reason = body.reason ? String(body.reason).slice(0, 500) : null;
if (!listingId) {
return NextResponse.json({ error: 'listingId is required' }, { status: 400 });
}
const listing = await prisma.listing.findUnique({
where: { id: listingId },
select: { id: true, ownerId: true, status: true },
});
if (!listing) {
return NextResponse.json({ error: 'Listing not found' }, { status: 404 });
}
const isOwner = listing.ownerId === auth.userId;
const canModerate = auth.role === Role.ADMIN || auth.role === Role.LISTING_MODERATOR;
if (!isOwner && !canModerate) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (listing.status === ListingStatus.REMOVED) {
return NextResponse.json({ ok: true, listing });
}
const updated = await prisma.listing.update({
where: { id: listingId },
data: {
status: ListingStatus.REMOVED,
published: false,
removedAt: new Date(),
removedById: auth.userId,
removedReason: reason ?? (isOwner ? 'Removed by owner' : null),
},
select: { id: true, status: true, removedAt: true, removedReason: true },
});
return NextResponse.json({ ok: true, listing: updated });
} catch (error) {
console.error('Remove listing error', error);
return NextResponse.json({ error: 'Failed to remove listing' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';

195
app/api/listings/route.ts Normal file
View file

@ -0,0 +1,195 @@
import { NextResponse } from 'next/server';
import { ListingStatus, UserStatus, EvCharging, Prisma } from '@prisma/client';
import { prisma } from '../../../lib/prisma';
import { requireAuth } from '../../../lib/jwt';
import { resolveLocale } from '../../../lib/i18n';
function normalizeEvCharging(input?: string | null): EvCharging {
const value = String(input ?? 'NONE').toUpperCase();
if (value === 'FREE') return EvCharging.FREE;
if (value === 'PAID') return EvCharging.PAID;
return EvCharging.NONE;
}
function pickTranslation<T extends { locale: string }>(translations: T[], locale: string | null): T | null {
if (!translations.length) return null;
if (locale) {
const exact = translations.find((t) => t.locale === locale);
if (exact) return exact;
}
return translations[0];
}
export async function GET(req: Request) {
const url = new URL(req.url);
const searchParams = url.searchParams;
const q = searchParams.get('q')?.trim();
const city = searchParams.get('city')?.trim();
const region = searchParams.get('region')?.trim();
const evChargingParam = searchParams.get('evCharging');
const evCharging = evChargingParam ? normalizeEvCharging(evChargingParam) : null;
const locale = resolveLocale({ cookieLocale: null, acceptLanguage: req.headers.get('accept-language') });
const limit = Math.min(Number(searchParams.get('limit') ?? 40), 100);
const where: Prisma.ListingWhereInput = {
status: ListingStatus.PUBLISHED,
removedAt: null,
city: city ? { contains: city, mode: 'insensitive' } : undefined,
region: region ? { contains: region, mode: 'insensitive' } : undefined,
evCharging: evCharging ?? undefined,
translations: q
? {
some: {
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ teaser: { contains: q, mode: 'insensitive' } },
],
},
}
: undefined,
};
const listings = await prisma.listing.findMany({
where,
include: {
translations: { select: { id: true, locale: true, title: true, slug: true, teaser: true, description: true } },
images: { select: { id: true, url: true, altText: true, order: true, isCover: true }, orderBy: { order: 'asc' } },
},
orderBy: { createdAt: 'desc' },
take: Number.isNaN(limit) ? 40 : limit,
});
const payload = listings.map((listing) => {
const translation = pickTranslation(listing.translations, locale);
const fallback = listing.translations[0];
return {
id: listing.id,
title: translation?.title ?? fallback?.title ?? 'Listing',
slug: translation?.slug ?? fallback?.slug ?? '',
teaser: translation?.teaser ?? translation?.description ?? fallback?.description ?? null,
locale: translation?.locale ?? fallback?.locale ?? locale,
country: listing.country,
region: listing.region,
city: listing.city,
streetAddress: listing.streetAddress,
addressNote: listing.addressNote,
latitude: listing.latitude,
longitude: listing.longitude,
hasSauna: listing.hasSauna,
hasFireplace: listing.hasFireplace,
hasWifi: listing.hasWifi,
petsAllowed: listing.petsAllowed,
byTheLake: listing.byTheLake,
hasAirConditioning: listing.hasAirConditioning,
evCharging: listing.evCharging,
maxGuests: listing.maxGuests,
bedrooms: listing.bedrooms,
beds: listing.beds,
bathrooms: listing.bathrooms,
priceHintPerNightCents: listing.priceHintPerNightCents,
coverImage: (listing.images.find((img) => img.isCover) ?? listing.images[0])?.url ?? null,
};
});
return NextResponse.json({ listings: payload });
}
const MAX_IMAGES = 10;
export async function POST(req: Request) {
try {
const auth = await requireAuth(req);
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user || !user.emailVerifiedAt || !user.approvedAt || user.status !== UserStatus.ACTIVE) {
return NextResponse.json({ error: 'User not permitted to create listings' }, { status: 403 });
}
const body = await req.json();
const slug = String(body.slug ?? '').trim().toLowerCase();
const locale = String(body.locale ?? 'en').toLowerCase();
const title = String(body.title ?? '').trim();
const description = String(body.description ?? '').trim();
const country = String(body.country ?? '').trim();
const region = String(body.region ?? '').trim();
const city = String(body.city ?? '').trim();
const streetAddress = String(body.streetAddress ?? '').trim();
const contactName = String(body.contactName ?? '').trim();
const contactEmail = String(body.contactEmail ?? '').trim();
if (!slug || !title || !description || !country || !region || !city || !contactEmail || !contactName) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const maxGuests = Number(body.maxGuests ?? 1);
const bedrooms = Number(body.bedrooms ?? 1);
const beds = Number(body.beds ?? 1);
const bathrooms = Number(body.bathrooms ?? 1);
const priceHintPerNightCents = body.priceHintPerNightCents ? Number(body.priceHintPerNightCents) : null;
const images = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : [];
const coverImageIndex = Math.min(Math.max(Number(body.coverImageIndex ?? 1), 1), images.length || 1);
const autoApprove = process.env.AUTO_APPROVE_LISTINGS === 'true' || auth.role === 'ADMIN';
const status = autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING;
const listing = await prisma.listing.create({
data: {
ownerId: user.id,
status,
approvedAt: autoApprove ? new Date() : null,
approvedById: autoApprove && auth.role === 'ADMIN' ? user.id : null,
country,
region,
city,
streetAddress: streetAddress || null,
addressNote: body.addressNote ?? null,
latitude: body.latitude !== undefined && body.latitude !== null && body.latitude !== '' ? Number(body.latitude) : null,
longitude: body.longitude !== undefined && body.longitude !== null && body.longitude !== '' ? Number(body.longitude) : null,
maxGuests,
bedrooms,
beds,
bathrooms,
hasSauna: Boolean(body.hasSauna),
hasFireplace: Boolean(body.hasFireplace),
hasWifi: Boolean(body.hasWifi),
petsAllowed: Boolean(body.petsAllowed),
byTheLake: Boolean(body.byTheLake),
hasAirConditioning: Boolean(body.hasAirConditioning),
evCharging: normalizeEvCharging(body.evCharging),
priceHintPerNightCents,
contactName,
contactEmail,
contactPhone: body.contactPhone ?? null,
externalUrl: body.externalUrl ?? null,
published: status === ListingStatus.PUBLISHED,
translations: {
create: {
locale,
slug,
title,
description,
teaser: body.teaser ?? null,
},
},
images: images.length
? {
create: images.map((img: any, idx: number) => ({
url: String(img.url ?? ''),
altText: img.altText ? String(img.altText) : null,
order: idx + 1,
isCover: coverImageIndex === idx + 1,
})),
}
: undefined,
},
include: { translations: true, images: true },
});
return NextResponse.json({ ok: true, listing });
} catch (error: any) {
console.error('Create listing error', error);
const message = error?.code === 'P2002' ? 'Slug already exists for this locale' : 'Failed to create listing';
return NextResponse.json({ error: message }, { status: 400 });
}
}

39
app/api/me/route.ts Normal file
View file

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../lib/prisma';
import { requireAuth } from '../../../lib/jwt';
import { hashPassword } from '../../../lib/auth';
export async function PATCH(req: Request) {
try {
const session = await requireAuth(req);
const body = await req.json();
const name = body.name !== undefined && body.name !== null ? String(body.name).trim() : undefined;
const password = body.password ? String(body.password) : undefined;
if (name === undefined && !password) {
return NextResponse.json({ error: 'No updates provided' }, { status: 400 });
}
const data: any = {};
if (name !== undefined) data.name = name || null;
if (password) {
if (password.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
data.passwordHash = await hashPassword(password);
}
const user = await prisma.user.update({
where: { id: session.userId },
data,
select: { id: true, email: true, name: true, role: true, status: true, emailVerifiedAt: true, approvedAt: true },
});
return NextResponse.json({ ok: true, user });
} catch (error) {
console.error('Profile update error', error);
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';

65
app/auth/login/page.tsx Normal file
View file

@ -0,0 +1,65 @@
'use client';
import { useState } from 'react';
import { useI18n } from '../../components/I18nProvider';
export default function LoginPage() {
const { t } = useI18n();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSuccess(false);
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Login failed');
} else {
try {
setSuccess(true);
localStorage.setItem('auth_token', data.token);
document.cookie = `auth_token=${data.token}; path=/; SameSite=Lax`;
window.location.href = '/';
} catch (err) {
// ignore storage errors
}
}
} catch (err) {
setError('Login failed');
} finally {
setLoading(false);
}
}
return (
<main className="panel" style={{ maxWidth: 480, margin: '40px auto' }}>
<h1>{t('loginTitle')}</h1>
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 12 }}>
<label>
{t('emailLabel')}
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
{t('passwordLabel')}
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<button className="button" type="submit" disabled={loading}>
{loading ? t('loggingIn') : t('loginButton')}
</button>
</form>
{success ? <p style={{ marginTop: 12, color: 'green' }}>{t('loginSuccess')}</p> : null}
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
</main>
);
}

View file

@ -0,0 +1,67 @@
/* eslint-disable react/no-unescaped-entities */
'use client';
import { useState } from 'react';
import { useI18n } from '../../components/I18nProvider';
export default function RegisterPage() {
const { t } = useI18n();
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setMessage(null);
setLoading(true);
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, name, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Registration failed');
} else {
setMessage(t('registerSuccess'));
setEmail('');
setPassword('');
}
} catch (err) {
setError('Registration failed');
} finally {
setLoading(false);
}
}
return (
<main className="panel" style={{ maxWidth: 480, margin: '40px auto' }}>
<h1>{t('registerTitle')}</h1>
<p style={{ marginBottom: 16 }}>{t('registerLead')}</p>
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 12 }}>
<label>
{t('emailLabel')}
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
{t('nameOptional')}
<input type="text" value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label>
{t('passwordHint')}
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} />
</label>
<button className="button" type="submit" disabled={loading}>
{loading ? t('registering') : t('registerButton')}
</button>
</form>
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null}
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
</main>
);
}

View file

@ -0,0 +1,45 @@
'use client';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { Locale, MessageKey, resolveLocale, t as translate } from '../../lib/i18n';
type I18nContextValue = {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: MessageKey, vars?: Record<string, string | number>) => string;
};
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocale] = useState<Locale>(() => {
if (typeof window === 'undefined') return 'en';
const stored = localStorage.getItem('locale');
if (stored === 'fi' || stored === 'en') return stored;
return resolveLocale({ cookieLocale: null, acceptLanguage: navigator.language ?? navigator.languages?.[0] ?? null });
});
useEffect(() => {
localStorage.setItem('locale', locale);
document.cookie = `locale=${locale}; path=/; max-age=${60 * 60 * 24 * 365};`;
}, [locale]);
const value: I18nContextValue = useMemo(
() => ({
locale,
setLocale,
t: (key: MessageKey, vars?: Record<string, string | number>) => translate(locale, key, vars) as string,
}),
[locale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n() {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error('useI18n must be used inside I18nProvider');
}
return ctx;
}

126
app/components/NavBar.tsx Normal file
View file

@ -0,0 +1,126 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useI18n } from './I18nProvider';
type SessionUser = { id: string; email: string; role: string; status: string };
export default function NavBar() {
const { t, locale, setLocale } = useI18n();
const [user, setUser] = useState<SessionUser | null>(null);
const [pendingCount, setPendingCount] = useState<number>(0);
async function loadUser() {
try {
const res = await fetch('/api/auth/me', { cache: 'no-store' });
const data = await res.json();
if (data.user) setUser(data.user);
else setUser(null);
} catch (e) {
setUser(null);
}
}
useEffect(() => {
loadUser();
}, []);
useEffect(() => {
const role = user?.role;
const canSeeApprovals = role === 'ADMIN' || role === 'LISTING_MODERATOR' || role === 'USER_MODERATOR';
if (!canSeeApprovals) {
setPendingCount(0);
return;
}
fetch('/api/admin/pending/count', { cache: 'no-store' })
.then((res) => res.json())
.then((data) => {
if (data && typeof data.total === 'number') setPendingCount(data.total);
})
.catch(() => setPendingCount(0));
}, [user]);
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch (e) {
// ignore
}
setUser(null);
}
const isAdmin = user?.role === 'ADMIN';
const isListingMod = user?.role === 'LISTING_MODERATOR';
const isUserMod = user?.role === 'USER_MODERATOR';
const showApprovals = Boolean(user && (isAdmin || isListingMod || isUserMod));
return (
<header style={{ padding: '12px 20px', borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Link href="/" className="brand">
{t('brand')}
</Link>
<Link href="/listings" className="button secondary">
{t('navBrowse')}
</Link>
</div>
<nav style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{user ? (
<>
<span style={{ fontSize: 12, color: '#444', border: '1px solid #ddd', borderRadius: 12, padding: '4px 10px' }}>
{user.email} · {user.role}
</span>
<Link href="/me" className="button secondary">
{t('navProfile')}
</Link>
<Link href="/listings/mine" className="button secondary">
{t('navMyListings')}
</Link>
<Link href="/listings/new" className="button secondary">
{t('navNewListing')}
</Link>
{showApprovals ? (
<>
<Link href="/admin/pending" className="button secondary">
{t('navApprovals')}
{pendingCount > 0 ? (
<span style={{ marginLeft: 6, background: '#ff7043', color: '#fff', borderRadius: 10, padding: '2px 6px', fontSize: 12 }}>
{t('approvalsBadge', { count: pendingCount })}
</span>
) : null}
</Link>
{isAdmin ? (
<Link href="/admin/users" className="button secondary">
{t('navUsers')}
</Link>
) : null}
</>
) : null}
<button className="button secondary" onClick={logout}>
{t('navLogout')}
</button>
</>
) : (
<>
<Link href="/auth/login" className="button secondary">
{t('navLogin')}
</Link>
<Link href="/auth/register" className="button">
{t('navSignup')}
</Link>
</>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginLeft: 8 }}>
<span style={{ fontSize: 12, color: '#555' }}>{t('navLanguage')}:</span>
<button className="button secondary" onClick={() => setLocale('fi')} style={{ padding: '4px 8px', opacity: locale === 'fi' ? 1 : 0.7 }}>
FI
</button>
<button className="button secondary" onClick={() => setLocale('en')} style={{ padding: '4px 8px', opacity: locale === 'en' ? 1 : 0.7 }}>
EN
</button>
</div>
</nav>
</header>
);
}

348
app/globals.css Normal file
View file

@ -0,0 +1,348 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap');
:root {
--bg: #0f172a;
--panel: #111827;
--accent: #22d3ee;
--accent-strong: #0ea5e9;
--text: #e5e7eb;
--muted: #94a3b8;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Space Grotesk', 'Helvetica Neue', sans-serif;
background: radial-gradient(circle at 20% 20%, rgba(34, 211, 238, 0.08), transparent 30%),
radial-gradient(circle at 80% 0%, rgba(14, 165, 233, 0.12), transparent 35%),
var(--bg);
color: var(--text);
min-height: 100vh;
}
main {
padding: 48px 20px 80px;
max-width: 1100px;
margin: 0 auto;
}
.hero {
display: grid;
gap: 12px;
margin-bottom: 32px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 12px;
color: var(--muted);
}
h1 {
margin: 0;
font-size: clamp(32px, 6vw, 52px);
line-height: 1.1;
}
p {
margin: 0;
color: var(--muted);
font-size: 16px;
line-height: 1.6;
}
.cta-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(34, 211, 238, 0.12);
border: 1px solid rgba(34, 211, 238, 0.25);
color: #b3ecff;
padding: 4px 8px;
border-radius: 10px;
}
.button {
background: var(--accent);
color: #0b1224;
padding: 12px 16px;
border-radius: 12px;
border: none;
font-weight: 700;
cursor: pointer;
text-decoration: none;
transition: transform 120ms ease, box-shadow 180ms ease;
box-shadow: 0 15px 40px rgba(34, 211, 238, 0.16);
}
.button.secondary {
background: transparent;
color: var(--text);
border: 1px solid rgba(148, 163, 184, 0.35);
box-shadow: none;
}
.button:hover {
transform: translateY(-2px);
}
.panel {
background: var(--panel);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 16px;
padding: 18px;
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.25);
}
.search-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.search-grid label {
display: grid;
gap: 6px;
font-size: 14px;
color: var(--muted);
}
.map-grid {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 16px;
}
.latest-panel {
margin-top: 20px;
}
.latest-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 14px;
margin-top: 14px;
}
.latest-card {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 240px;
}
.latest-cover {
width: 100%;
height: 100%;
object-fit: cover;
min-height: 240px;
}
.latest-cover.placeholder {
background: linear-gradient(130deg, rgba(34, 211, 238, 0.12), rgba(14, 165, 233, 0.12));
}
.latest-meta {
padding: 14px;
display: grid;
gap: 6px;
}
.latest-rail {
display: grid;
gap: 8px;
max-height: 320px;
overflow: auto;
padding-right: 4px;
}
.rail-item {
width: 100%;
display: grid;
grid-template-columns: 76px 1fr;
gap: 10px;
padding: 8px;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(255, 255, 255, 0.01);
cursor: pointer;
color: inherit;
}
.rail-item.active {
border-color: var(--accent);
background: rgba(34, 211, 238, 0.08);
}
.rail-thumb {
width: 100%;
height: 64px;
border-radius: 10px;
overflow: hidden;
background: rgba(14, 165, 233, 0.06);
}
.rail-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.rail-fallback {
width: 100%;
height: 100%;
background: linear-gradient(110deg, rgba(34, 211, 238, 0.1), rgba(14, 165, 233, 0.1));
}
.rail-text {
display: grid;
gap: 4px;
text-align: left;
}
.rail-title {
font-weight: 600;
}
.rail-sub {
color: var(--muted);
font-size: 13px;
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
}
.map-frame {
position: relative;
width: 100%;
min-height: 360px;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.16);
}
.map-placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: linear-gradient(145deg, rgba(34, 211, 238, 0.04), rgba(14, 165, 233, 0.06));
color: var(--muted);
z-index: 1;
}
.listing-card {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 14px;
padding: 10px;
background: rgba(255, 255, 255, 0.01);
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
}
.listing-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
}
.listing-card.active {
border-color: var(--accent-strong);
box-shadow: 0 14px 40px rgba(14, 165, 233, 0.22);
}
.listing-shell {
display: grid;
gap: 16px;
}
.breadcrumb {
color: var(--muted);
font-size: 14px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-top: 24px;
}
.card-title {
margin: 0 0 6px;
font-size: 18px;
}
.meta-grid {
display: grid;
gap: 6px;
font-size: 14px;
color: var(--muted);
}
code {
background: rgba(148, 163, 184, 0.1);
padding: 3px 6px;
border-radius: 8px;
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
}
.env-card {
display: grid;
gap: 6px;
}
label {
color: var(--muted);
}
input,
select,
textarea {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-size: 15px;
}
@media (max-width: 640px) {
main {
padding: 32px 16px 64px;
}
.cards {
grid-template-columns: 1fr;
}
.map-grid {
grid-template-columns: 1fr;
}
.latest-grid {
grid-template-columns: 1fr;
}
.latest-card {
grid-template-columns: 1fr;
}
}

26
app/layout.tsx Normal file
View file

@ -0,0 +1,26 @@
import type { Metadata } from 'next';
import './globals.css';
import NavBar from './components/NavBar';
import { I18nProvider } from './components/I18nProvider';
export const metadata: Metadata = {
title: 'Lomavuokraus.fi',
description: 'Modern vacation rentals in Finland.',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<I18nProvider>
<NavBar />
{children}
</I18nProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,100 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { cookies, headers } from 'next/headers';
import { getListingBySlug, DEFAULT_LOCALE } from '../../../lib/listings';
import { resolveLocale, t as translate } from '../../../lib/i18n';
type ListingPageProps = {
params: { slug: string };
};
export async function generateMetadata({ params }: ListingPageProps): Promise<Metadata> {
const translation = await getListingBySlug({ slug: params.slug, locale: DEFAULT_LOCALE });
return {
title: translation ? `${translation.title} | Lomavuokraus.fi` : `${params.slug} | Lomavuokraus.fi`,
description: translation?.teaser ?? translation?.description?.slice(0, 140),
};
}
export default async function ListingPage({ params }: ListingPageProps) {
const cookieStore = cookies();
const locale = resolveLocale({ cookieLocale: cookieStore.get('locale')?.value, acceptLanguage: headers().get('accept-language') });
const t = (key: any, vars?: Record<string, string | number>) => translate(locale, key as any, vars);
const translation = await getListingBySlug({ slug: params.slug, locale: locale ?? DEFAULT_LOCALE });
if (!translation) {
notFound();
}
const { listing, title, description, teaser, locale: translationLocale } = translation;
return (
<main className="listing-shell">
<div className="breadcrumb">
<Link href="/">{t('homeCrumb')}</Link> / <span>{params.slug}</span>
</div>
<div className="panel">
<h1>{title}</h1>
<p style={{ marginTop: 8 }}>{teaser ?? description}</p>
<div style={{ marginTop: 12 }}>
<strong>{t('listingAddress')}:</strong> {listing.streetAddress ? `${listing.streetAddress}, ` : ''}
{listing.city}, {listing.region}, {listing.country}
</div>
{listing.addressNote ? (
<div style={{ marginTop: 4, color: '#cbd5e1' }}>
<em>{listing.addressNote}</em>
</div>
) : null}
<div style={{ marginTop: 12 }}>
<strong>{t('listingCapacity')}:</strong> {t('capacityGuests', { count: listing.maxGuests })} - {t('capacityBedrooms', { count: listing.bedrooms })} -{' '}
{t('capacityBeds', { count: listing.beds })} - {t('capacityBathrooms', { count: listing.bathrooms })}
</div>
<div style={{ marginTop: 8 }}>
<strong>{t('listingAmenities')}:</strong>{' '}
{[
listing.hasSauna && t('amenitySauna'),
listing.hasFireplace && t('amenityFireplace'),
listing.hasWifi && t('amenityWifi'),
listing.petsAllowed && t('amenityPets'),
listing.byTheLake && t('amenityLake'),
listing.hasAirConditioning && t('amenityAirConditioning'),
listing.evCharging === 'FREE' && t('amenityEvFree'),
listing.evCharging === 'PAID' && t('amenityEvPaid'),
]
.filter(Boolean)
.join(', ') || '-'}
</div>
<div style={{ marginTop: 8 }}>
<strong>{t('listingContact')}:</strong> {listing.contactName} - {listing.contactEmail}
{listing.contactPhone ? ` - ${listing.contactPhone}` : ''}
{listing.externalUrl ? (
<>
{' - '}
<a href={listing.externalUrl} target="_blank" rel="noreferrer">
{t('listingMoreInfo')}
</a>
</>
) : null}
</div>
{listing.images.length > 0 ? (
<div style={{ marginTop: 16, display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}>
{listing.images.map((img) => (
<figure key={img.id} style={{ border: '1px solid #ddd', borderRadius: 8, overflow: 'hidden', background: '#fafafa' }}>
<img src={img.url} alt={img.altText ?? title} style={{ width: '100%', height: '180px', objectFit: 'cover' }} />
{img.altText ? (
<figcaption style={{ padding: '8px 12px', fontSize: 14, color: '#444' }}>{img.altText}</figcaption>
) : null}
</figure>
))}
</div>
) : null}
<div style={{ marginTop: 16, fontSize: 14, color: '#666' }}>
{t('localeLabel')}: <code>{translationLocale}</code>
</div>
</div>
</main>
);
}

119
app/listings/mine/page.tsx Normal file
View file

@ -0,0 +1,119 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useI18n } from '../../components/I18nProvider';
type MyListing = {
id: string;
status: string;
translations: { title: string; slug: string; locale: string }[];
};
export default function MyListingsPage() {
const { t } = useI18n();
const [listings, setListings] = useState<MyListing[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [actionId, setActionId] = useState<string | null>(null);
useEffect(() => {
fetch('/api/listings/mine', { cache: 'no-store' })
.then((res) => res.json())
.then((data) => {
if (data.error) {
setError(data.error);
} else {
setListings(data.listings ?? []);
}
})
.catch(() => setError('Failed to load'))
.finally(() => setLoading(false));
}, []);
async function removeListing(listingId: string) {
if (!window.confirm(t('removeConfirm'))) return;
setActionId(listingId);
setError(null);
setMessage(null);
try {
const res = await fetch('/api/listings/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ listingId }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to remove listing');
} else {
setMessage(t('removed'));
setListings((prev) => prev.map((l) => (l.id === listingId ? { ...l, status: 'REMOVED' } : l)));
}
} catch (e) {
setError('Failed to remove listing');
} finally {
setActionId(null);
}
}
if (loading) {
return (
<main className="panel" style={{ maxWidth: 720, margin: '40px auto' }}>
<h1>{t('myListingsTitle')}</h1>
<p>{t('loading')}</p>
</main>
);
}
if (error) {
return (
<main className="panel" style={{ maxWidth: 720, margin: '40px auto' }}>
<h1>{t('myListingsTitle')}</h1>
<p style={{ color: 'red' }}>{error}</p>
</main>
);
}
return (
<main className="panel" style={{ maxWidth: 720, margin: '40px auto' }}>
<h1>{t('myListingsTitle')}</h1>
{message ? <p style={{ color: 'green' }}>{message}</p> : null}
<div style={{ marginBottom: 12 }}>
<Link href="/listings/new" className="button secondary">
{t('createNewListing')}
</Link>
</div>
{listings.length === 0 ? (
<p>
{t('noListings')}{' '}
<Link href="/listings/new">
{t('createOne')}
</Link>
.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, display: 'grid', gap: 10 }}>
{listings.map((l) => (
<li key={l.id} style={{ border: '1px solid #ddd', borderRadius: 8, padding: 12 }}>
<div>
<strong>{l.translations[0]?.title ?? 'Listing'}</strong> {t('statusLabel')}: {l.status}
</div>
<div style={{ fontSize: 12, color: '#666' }}>
{t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')}
</div>
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
<Link href={`/listings/${l.translations[0]?.slug ?? ''}`} className="button secondary">
{t('view')}
</Link>
<button className="button secondary" onClick={() => removeListing(l.id)} disabled={actionId === l.id}>
{actionId === l.id ? t('removing') : t('remove')}
</button>
</div>
</li>
))}
</ul>
)}
</main>
);
}

277
app/listings/new/page.tsx Normal file
View file

@ -0,0 +1,277 @@
'use client';
import { useEffect, useState } from 'react';
import { useI18n } from '../../components/I18nProvider';
import type { Locale } from '../../../lib/i18n';
type ImageInput = { url: string; altText?: string };
export default function NewListingPage() {
const { t, locale: uiLocale } = useI18n();
const [slug, setSlug] = useState('');
const [locale, setLocale] = useState(uiLocale);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [teaser, setTeaser] = useState('');
const [country, setCountry] = useState('Finland');
const [region, setRegion] = useState('');
const [city, setCity] = useState('');
const [streetAddress, setStreetAddress] = useState('');
const [addressNote, setAddressNote] = useState('');
const [latitude, setLatitude] = useState<number | ''>('');
const [longitude, setLongitude] = useState<number | ''>('');
const [contactName, setContactName] = useState('');
const [contactEmail, setContactEmail] = useState('');
const [maxGuests, setMaxGuests] = useState(4);
const [bedrooms, setBedrooms] = useState(2);
const [beds, setBeds] = useState(3);
const [bathrooms, setBathrooms] = useState(1);
const [price, setPrice] = useState<number | ''>('');
const [hasSauna, setHasSauna] = useState(true);
const [hasFireplace, setHasFireplace] = useState(true);
const [hasWifi, setHasWifi] = useState(true);
const [petsAllowed, setPetsAllowed] = useState(false);
const [byTheLake, setByTheLake] = useState(false);
const [hasAirConditioning, setHasAirConditioning] = useState(false);
const [evCharging, setEvCharging] = useState<'NONE' | 'FREE' | 'PAID'>('NONE');
const [imagesText, setImagesText] = useState('');
const [coverImageIndex, setCoverImageIndex] = useState(1);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [isAuthed, setIsAuthed] = useState(false);
useEffect(() => {
setLocale(uiLocale);
}, [uiLocale]);
useEffect(() => {
// simple check if session exists
fetch('/api/auth/me', { cache: 'no-store' })
.then((res) => res.json())
.then((data) => setIsAuthed(Boolean(data.user)))
.catch(() => setIsAuthed(false));
}, []);
function parseImages(): ImageInput[] {
return imagesText
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => ({ url: line }));
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setMessage(null);
setError(null);
setLoading(true);
try {
const res = await fetch('/api/listings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
locale,
title,
description,
teaser,
country,
region,
city,
streetAddress,
addressNote,
latitude: latitude === '' ? null : latitude,
longitude: longitude === '' ? null : longitude,
contactName,
contactEmail,
maxGuests,
bedrooms,
beds,
bathrooms,
priceHintPerNightCents: price === '' ? null : Number(price),
hasSauna,
hasFireplace,
hasWifi,
petsAllowed,
byTheLake,
hasAirConditioning,
evCharging,
coverImageIndex,
images: parseImages(),
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to create listing');
} else {
setMessage(t('createListingSuccess', { id: data.listing.id, status: data.listing.status }));
setSlug('');
setTitle('');
setDescription('');
setTeaser('');
setRegion('');
setCity('');
setStreetAddress('');
setAddressNote('');
setLatitude('');
setLongitude('');
setContactName('');
setContactEmail('');
setImagesText('');
setCoverImageIndex(1);
}
} catch (err) {
setError('Failed to create listing');
} finally {
setLoading(false);
}
}
if (!isAuthed) {
return (
<main className="panel" style={{ maxWidth: 720, margin: '40px auto' }}>
<h1>{t('createListingTitle')}</h1>
<p>{t('loginToCreate')}</p>
</main>
);
}
return (
<main className="panel" style={{ maxWidth: 720, margin: '40px auto' }}>
<h1>{t('createListingTitle')}</h1>
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 10 }}>
<label>
{t('slugLabel')}
<input value={slug} onChange={(e) => setSlug(e.target.value)} required />
</label>
<label>
{t('localeInput')}
<input value={locale} onChange={(e) => setLocale(e.target.value as Locale)} required />
</label>
<label>
{t('titleLabel')}
<input value={title} onChange={(e) => setTitle(e.target.value)} required />
</label>
<label>
{t('descriptionLabel')}
<textarea value={description} onChange={(e) => setDescription(e.target.value)} required rows={4} />
</label>
<label>
{t('teaserLabel')}
<input value={teaser} onChange={(e) => setTeaser(e.target.value)} />
</label>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<label>
{t('countryLabel')}
<input value={country} onChange={(e) => setCountry(e.target.value)} required />
</label>
<label>
{t('regionLabel')}
<input value={region} onChange={(e) => setRegion(e.target.value)} required />
</label>
<label>
{t('cityLabel')}
<input value={city} onChange={(e) => setCity(e.target.value)} required />
</label>
</div>
<label>
{t('streetAddressLabel')}
<input value={streetAddress} onChange={(e) => setStreetAddress(e.target.value)} required />
</label>
<label>
{t('addressNoteLabel')}
<input value={addressNote} onChange={(e) => setAddressNote(e.target.value)} placeholder={t('addressNotePlaceholder')} />
</label>
<label>
{t('contactNameLabel')}
<input value={contactName} onChange={(e) => setContactName(e.target.value)} required />
</label>
<label>
{t('contactEmailLabel')}
<input type="email" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} required />
</label>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
<label>
{t('maxGuestsLabel')}
<input type="number" value={maxGuests} onChange={(e) => setMaxGuests(Number(e.target.value))} min={1} />
</label>
<label>
{t('bedroomsLabel')}
<input type="number" value={bedrooms} onChange={(e) => setBedrooms(Number(e.target.value))} min={0} />
</label>
<label>
{t('bedsLabel')}
<input type="number" value={beds} onChange={(e) => setBeds(Number(e.target.value))} min={0} />
</label>
<label>
{t('bathroomsLabel')}
<input type="number" value={bathrooms} onChange={(e) => setBathrooms(Number(e.target.value))} min={0} />
</label>
<label>
{t('priceHintLabel')}
<input type="number" value={price} onChange={(e) => setPrice(e.target.value === '' ? '' : Number(e.target.value))} min={0} />
</label>
</div>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
<label>
{t('latitudeLabel')}
<input type="number" value={latitude} onChange={(e) => setLatitude(e.target.value === '' ? '' : Number(e.target.value))} step="0.000001" />
</label>
<label>
{t('longitudeLabel')}
<input type="number" value={longitude} onChange={(e) => setLongitude(e.target.value === '' ? '' : Number(e.target.value))} step="0.000001" />
</label>
</div>
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={hasSauna} onChange={(e) => setHasSauna(e.target.checked)} /> {t('amenitySauna')}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={hasFireplace} onChange={(e) => setHasFireplace(e.target.checked)} /> {t('amenityFireplace')}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={hasWifi} onChange={(e) => setHasWifi(e.target.checked)} /> {t('amenityWifi')}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={petsAllowed} onChange={(e) => setPetsAllowed(e.target.checked)} /> {t('amenityPets')}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={byTheLake} onChange={(e) => setByTheLake(e.target.checked)} /> {t('amenityLake')}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={hasAirConditioning} onChange={(e) => setHasAirConditioning(e.target.checked)} /> {t('amenityAirConditioning')}
</label>
<label>
{t('evChargingLabel')}
<select value={evCharging} onChange={(e) => setEvCharging(e.target.value as any)}>
<option value="NONE">{t('evChargingNone')}</option>
<option value="FREE">{t('evChargingFree')}</option>
<option value="PAID">{t('evChargingPaid')}</option>
</select>
</label>
</div>
<label>
{t('imagesLabel')}
<textarea value={imagesText} onChange={(e) => setImagesText(e.target.value)} rows={4} placeholder="https://example.com/image.jpg" />
</label>
<label>
{t('coverImageLabel')}
<input
type="number"
min={1}
value={coverImageIndex}
onChange={(e) => setCoverImageIndex(Number(e.target.value) || 1)}
placeholder={t('coverImageHelp')}
/>
<small style={{ color: '#cbd5e1' }}>{t('coverImageHelp')}</small>
</label>
<button className="button" type="submit" disabled={loading}>
{loading ? t('submittingListing') : t('submitListing')}
</button>
</form>
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null}
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
</main>
);
}

403
app/listings/page.tsx Normal file
View file

@ -0,0 +1,403 @@
'use client';
import Link from 'next/link';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../components/I18nProvider';
type ListingResult = {
id: string;
title: string;
slug: string;
teaser: string | null;
locale: string | null;
country: string;
region: string;
city: string;
streetAddress: string | null;
addressNote: string | null;
latitude: number | null;
longitude: number | null;
hasSauna: boolean;
hasFireplace: boolean;
hasWifi: boolean;
petsAllowed: boolean;
byTheLake: boolean;
hasAirConditioning: boolean;
evCharging: 'NONE' | 'FREE' | 'PAID';
maxGuests: number;
bedrooms: number;
beds: number;
bathrooms: number;
priceHintPerNightCents: number | null;
coverImage: string | null;
};
type LatLng = { lat: number; lon: number };
function haversineKm(a: LatLng, b: LatLng) {
const toRad = (v: number) => (v * Math.PI) / 180;
const R = 6371;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const sinLat = Math.sin(dLat / 2);
const sinLon = Math.sin(dLon / 2);
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
return 2 * R * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
}
function loadLeaflet(): Promise<any> {
if (typeof window === 'undefined') return Promise.reject();
if ((window as any).L) return Promise.resolve((window as any).L);
return new Promise((resolve, reject) => {
const existingStyle = document.querySelector('link[data-leaflet-style]');
if (!existingStyle) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
link.setAttribute('data-leaflet-style', 'true');
document.head.appendChild(link);
}
const existingScript = document.querySelector('script[data-leaflet]');
if (existingScript) {
existingScript.addEventListener('load', () => resolve((window as any).L));
existingScript.addEventListener('error', reject);
return;
}
const script = document.createElement('script');
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
script.async = true;
script.setAttribute('data-leaflet', 'true');
script.onload = () => resolve((window as any).L);
script.onerror = reject;
document.body.appendChild(script);
});
}
function ListingsMap({
listings,
center,
selectedId,
onSelect,
loadingText,
}: {
listings: ListingResult[];
center: LatLng | null;
selectedId: string | null;
onSelect: (id: string) => void;
loadingText: string;
}) {
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<any>(null);
const markersRef = useRef<any[]>([]);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
loadLeaflet()
.then((L) => {
if (cancelled) return;
setReady(true);
if (!mapContainerRef.current) return;
if (!mapRef.current) {
mapRef.current = L.map(mapContainerRef.current).setView([64.5, 26], 5);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 18,
}).addTo(mapRef.current);
}
markersRef.current.forEach((m) => m.remove());
markersRef.current = [];
listings
.filter((l) => l.latitude !== null && l.longitude !== null)
.forEach((l) => {
const marker = L.marker([l.latitude!, l.longitude!], { title: l.title });
marker.addTo(mapRef.current);
marker.on('click', () => onSelect(l.id));
markersRef.current.push(marker);
});
const withCoords = listings.filter((l) => l.latitude !== null && l.longitude !== null);
if (center && mapRef.current) {
mapRef.current.setView([center.lat, center.lon], 8);
} else if (withCoords.length && mapRef.current) {
const group = L.featureGroup(
withCoords.map((l) => L.marker([l.latitude as number, l.longitude as number]))
);
mapRef.current.fitBounds(group.getBounds().pad(0.25));
}
})
.catch(() => {
setReady(false);
});
return () => {
cancelled = true;
};
}, [listings, center, onSelect]);
useEffect(() => {
if (!mapRef.current || !selectedId) return;
const listing = listings.find((l) => l.id === selectedId);
if (listing && listing.latitude && listing.longitude) {
mapRef.current.setView([listing.latitude, listing.longitude], 10);
}
}, [selectedId, listings]);
return (
<div className="map-frame">
{!ready ? <div className="map-placeholder">{loadingText}</div> : null}
<div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />
</div>
);
}
export default function ListingsIndexPage() {
const { t } = useI18n();
const [query, setQuery] = useState('');
const [city, setCity] = useState('');
const [region, setRegion] = useState('');
const [evCharging, setEvCharging] = useState<'ALL' | 'FREE' | 'PAID' | 'NONE'>('ALL');
const [listings, setListings] = useState<ListingResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [addressQuery, setAddressQuery] = useState('');
const [addressCenter, setAddressCenter] = useState<LatLng | null>(null);
const [radiusKm, setRadiusKm] = useState(50);
const [geocoding, setGeocoding] = useState(false);
const [geoError, setGeoError] = useState<string | null>(null);
const filteredByAddress = useMemo(() => {
if (!addressCenter) return listings;
return listings.filter((l) => {
if (l.latitude === null || l.longitude === null) return false;
const d = haversineKm(addressCenter, { lat: l.latitude, lon: l.longitude });
return d <= radiusKm;
});
}, [listings, addressCenter, radiusKm]);
const filtered = useMemo(() => {
if (evCharging === 'ALL') return filteredByAddress;
return filteredByAddress.filter((l) => l.evCharging === evCharging);
}, [filteredByAddress, evCharging]);
async function fetchListings() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (query) params.set('q', query);
if (city) params.set('city', city);
if (region) params.set('region', region);
if (evCharging !== 'ALL') params.set('evCharging', evCharging);
const res = await fetch(`/api/listings?${params.toString()}`, { cache: 'no-store' });
const data = await res.json();
if (!res.ok || data.error) {
throw new Error(data.error || 'Failed to load listings');
}
setListings(data.listings ?? []);
setSelectedId(data.listings?.[0]?.id ?? null);
} catch (e: any) {
setError(e.message || 'Failed to load listings');
} finally {
setLoading(false);
}
}
async function locateAddress() {
if (!addressQuery.trim()) return;
setGeocoding(true);
setGeoError(null);
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(addressQuery)}&limit=1`
);
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
const hit = data[0];
setAddressCenter({ lat: parseFloat(hit.lat), lon: parseFloat(hit.lon) });
} else {
setGeoError(t('addressNotFound'));
}
} catch (e) {
setGeoError(t('addressLookupFailed'));
} finally {
setGeocoding(false);
}
}
useEffect(() => {
fetchListings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const countLabel = t('listingsFound', { count: filtered.length });
return (
<main>
<section className="panel">
<div className="breadcrumb">
<Link href="/">{t('homeCrumb')}</Link> / <span>{t('navBrowse')}</span>
</div>
<h1>{t('browseListingsTitle')}</h1>
<p style={{ marginTop: 8 }}>{t('browseListingsLead')}</p>
<div className="search-grid" style={{ marginTop: 16 }}>
<label>
{t('searchLabel')}
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
autoComplete="off"
/>
</label>
<label>
{t('cityFilter')}
<input value={city} onChange={(e) => setCity(e.target.value)} placeholder={t('cityFilter')} />
</label>
<label>
{t('regionFilter')}
<input value={region} onChange={(e) => setRegion(e.target.value)} placeholder={t('regionFilter')} />
</label>
<label>
{t('evChargingLabel')}
<select value={evCharging} onChange={(e) => setEvCharging(e.target.value as any)}>
<option value="ALL">{t('evChargingAny')}</option>
<option value="FREE">{t('evChargingFree')}</option>
<option value="PAID">{t('evChargingPaid')}</option>
<option value="NONE">{t('evChargingNone')}</option>
</select>
</label>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 12, flexWrap: 'wrap' }}>
<button className="button" onClick={fetchListings} disabled={loading}>
{loading ? t('loading') : t('searchButton')}
</button>
<button
className="button secondary"
onClick={() => {
setQuery('');
setCity('');
setRegion('');
setEvCharging('ALL');
setAddressCenter(null);
setAddressQuery('');
}}
>
{t('clearFilters')}
</button>
<span style={{ alignSelf: 'center', color: '#cbd5e1' }}>{countLabel}</span>
</div>
{error ? <p style={{ marginTop: 8, color: '#ef4444' }}>{error}</p> : null}
</section>
<section className="map-grid" style={{ marginTop: 18 }}>
<div className="panel">
<div style={{ display: 'grid', gap: 10, marginBottom: 12 }}>
<label>
{t('addressSearchLabel')}
<input
value={addressQuery}
onChange={(e) => setAddressQuery(e.target.value)}
placeholder={t('addressSearchPlaceholder')}
/>
</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button className="button secondary" onClick={locateAddress} disabled={geocoding}>
{geocoding ? t('loading') : t('locateAddress')}
</button>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="range"
min={10}
max={200}
step={10}
value={radiusKm}
onChange={(e) => setRadiusKm(Number(e.target.value))}
disabled={!addressCenter}
/>
<span style={{ color: '#cbd5e1' }}>{t('addressRadiusLabel', { km: radiusKm })}</span>
</label>
{addressCenter ? (
<button className="button secondary" onClick={() => setAddressCenter(null)}>
{t('clearFilters')}
</button>
) : null}
</div>
{geoError ? <p style={{ color: '#ef4444' }}>{geoError}</p> : null}
</div>
<ListingsMap
listings={filtered}
center={addressCenter}
selectedId={selectedId}
onSelect={setSelectedId}
loadingText={t('loadingMap')}
/>
</div>
<div className="panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<strong>{countLabel}</strong>
{addressCenter ? (
<span className="badge">{t('addressRadiusLabel', { km: radiusKm })}</span>
) : null}
</div>
{filtered.length === 0 ? (
<p>{t('mapNoResults')}</p>
) : (
<div className="results-grid">
{filtered.map((l) => (
<article
key={l.id}
className={`listing-card ${selectedId === l.id ? 'active' : ''}`}
onMouseEnter={() => setSelectedId(l.id)}
>
{l.coverImage ? (
<img src={l.coverImage} alt={l.title} style={{ width: '100%', height: 140, objectFit: 'cover', borderRadius: 12 }} />
) : (
<div
style={{
height: 140,
borderRadius: 12,
background: 'linear-gradient(120deg, rgba(34,211,238,0.12), rgba(14,165,233,0.12))',
}}
/>
)}
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}>
<h3 style={{ margin: 0 }}>{l.title}</h3>
<p style={{ margin: 0 }}>{l.teaser ?? ''}</p>
<div style={{ color: '#cbd5e1', fontSize: 14 }}>
{l.streetAddress ? `${l.streetAddress}, ` : ''}
{l.city}, {l.region}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', fontSize: 13 }}>
<span className="badge">{t('capacityGuests', { count: l.maxGuests })}</span>
<span className="badge">{t('capacityBedrooms', { count: l.bedrooms })}</span>
{l.evCharging === 'FREE' ? <span className="badge">{t('amenityEvFree')}</span> : null}
{l.evCharging === 'PAID' ? <span className="badge">{t('amenityEvPaid')}</span> : null}
{l.hasAirConditioning ? <span className="badge">{t('amenityAirConditioning')}</span> : null}
{l.hasSauna ? <span className="badge">{t('amenitySauna')}</span> : null}
{l.hasWifi ? <span className="badge">{t('amenityWifi')}</span> : null}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<Link className="button secondary" href={`/listings/${l.slug}`}>
{t('openListing')}
</Link>
<button className="button secondary" onClick={() => setSelectedId(l.id)}>
{t('locateAddress')}
</button>
</div>
</div>
</article>
))}
</div>
)}
</div>
</section>
</main>
);
}

109
app/me/page.tsx Normal file
View file

@ -0,0 +1,109 @@
'use client';
import { useEffect, useState } from 'react';
import { useI18n } from '../components/I18nProvider';
type User = { id: string; email: string; role: string; status: string; emailVerifiedAt: string | null; approvedAt: string | null; name: string | null };
export default function ProfilePage() {
const { t } = useI18n();
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
fetch('/api/auth/me', { cache: 'no-store' })
.then((res) => res.json())
.then((data) => {
if (data.user) {
setUser(data.user);
setName(data.user.name ?? '');
} else setError(t('notLoggedIn'));
})
.catch(() => setError(t('notLoggedIn')));
}, [t]);
async function onSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setMessage(null);
try {
const res = await fetch('/api/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, password: password || undefined }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Update failed');
} else {
setUser(data.user);
setPassword('');
setMessage(t('profileUpdated'));
}
} catch (err) {
setError('Update failed');
} finally {
setSaving(false);
}
}
return (
<main className="panel" style={{ maxWidth: 640, margin: '40px auto' }}>
<h1>{t('myProfileTitle')}</h1>
{message ? <p style={{ color: 'green' }}>{message}</p> : null}
{user ? (
<>
<ul>
<li>
<strong>{t('profileEmail')}:</strong> {user.email}
</li>
<li>
<strong>{t('profileName')}:</strong> {user.name ?? '—'}
</li>
<li>
<strong>{t('profileRole')}:</strong> {user.role}
</li>
<li>
<strong>{t('profileStatus')}:</strong> {user.status}
</li>
<li>
<strong>{t('profileEmailVerified')}:</strong> {user.emailVerifiedAt ? t('yes') : t('no')}
</li>
<li>
<strong>{t('profileApproved')}:</strong> {user.approvedAt ? t('yes') : t('no')}
</li>
</ul>
<div style={{ marginTop: 16, display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<a className="button secondary" href="/listings/mine">
{t('navMyListings')}
</a>
<a className="button secondary" href="/listings/new">
{t('navNewListing')}
</a>
</div>
<form onSubmit={onSave} style={{ marginTop: 20, display: 'grid', gap: 10, maxWidth: 420 }}>
<label>
{t('profileName')}
<input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label>
{t('passwordLabel')} ({t('passwordHint')})
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} />
</label>
<p style={{ fontSize: 12, color: '#666' }}>{t('emailLocked')}</p>
<button className="button" type="submit" disabled={saving}>
{saving ? t('saving') : t('save')}
</button>
</form>
</>
) : (
<p style={{ color: 'red' }}>{error ?? t('notLoggedIn')}</p>
)}
</main>
);
}

171
app/page.tsx Normal file
View file

@ -0,0 +1,171 @@
'use client';
import Link from 'next/link';
import { SAMPLE_LISTING_SLUG } from '../lib/sampleListing';
import { useI18n } from './components/I18nProvider';
import { useEffect, useMemo, useState } from 'react';
type LatestListing = {
id: string;
title: string;
slug: string;
teaser: string | null;
coverImage: string | null;
city: string;
region: string;
};
export const dynamic = 'force-dynamic';
const highlights = [
{
keyTitle: 'highlightQualityTitle',
keyBody: 'highlightQualityBody',
},
{
keyTitle: 'highlightLocalTitle',
keyBody: 'highlightLocalBody',
},
{
keyTitle: 'highlightApiTitle',
keyBody: 'highlightApiBody',
},
];
export default function HomePage() {
const { t } = useI18n();
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const apiBase = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:3000/api';
const appEnv = process.env.APP_ENV || 'local';
const [latest, setLatest] = useState<LatestListing[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [loadingLatest, setLoadingLatest] = useState(false);
useEffect(() => {
setLoadingLatest(true);
fetch('/api/listings?limit=8', { cache: 'no-store' })
.then((res) => res.json())
.then((data) => setLatest(data.listings ?? []))
.catch(() => setLatest([]))
.finally(() => setLoadingLatest(false));
}, []);
useEffect(() => {
if (!latest.length) return;
const id = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % latest.length);
}, 4200);
return () => clearInterval(id);
}, [latest]);
useEffect(() => {
if (activeIndex >= latest.length) {
setActiveIndex(0);
}
}, [activeIndex, latest.length]);
const activeListing = useMemo(() => {
if (!latest.length) return null;
return latest[Math.min(activeIndex, latest.length - 1)];
}, [latest, activeIndex]);
return (
<main>
<section className="hero">
<span className="eyebrow">{t('heroEyebrow')}</span>
<h1>{t('heroTitle')}</h1>
<p>{t('heroBody')}</p>
<div className="cta-row">
<Link className="button" href={`/listings/${SAMPLE_LISTING_SLUG}`}>
{t('ctaViewSample')}
</Link>
<Link className="button secondary" href="/listings">
{t('ctaBrowse')}
</Link>
<a className="button secondary" href="/api/health" target="_blank" rel="noreferrer">
{t('ctaHealth')}
</a>
</div>
</section>
<div className="cards">
{highlights.map((item) => (
<div key={item.keyTitle} className="panel">
<h3 className="card-title">{t(item.keyTitle as any)}</h3>
<p>{t(item.keyBody as any)}</p>
</div>
))}
<div className="panel env-card">
<h3 className="card-title">{t('runtimeConfigTitle')}</h3>
<div className="meta-grid">
<span>
<strong>{t('runtimeAppEnv')}</strong> <code>{appEnv}</code>
</span>
<span>
<strong>{t('runtimeSiteUrl')}</strong> <code>{siteUrl}</code>
</span>
<span>
<strong>{t('runtimeApiBase')}</strong> <code>{apiBase}</code>
</span>
</div>
</div>
</div>
<section className="panel latest-panel">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
<div>
<h2 style={{ margin: 0 }}>{t('latestListingsTitle')}</h2>
<p style={{ marginTop: 4 }}>{t('latestListingsLead')}</p>
</div>
<Link className="button secondary" href="/listings">
{t('ctaBrowse')}
</Link>
</div>
{loadingLatest ? (
<p style={{ color: '#cbd5e1', marginTop: 10 }}>{t('loading')}</p>
) : !activeListing ? (
<p style={{ color: '#cbd5e1', marginTop: 10 }}>{t('mapNoResults')}</p>
) : (
<div className="latest-grid">
<div className="latest-card">
{activeListing.coverImage ? (
<img src={activeListing.coverImage} alt={activeListing.title} className="latest-cover" />
) : (
<div className="latest-cover placeholder" />
)}
<div className="latest-meta">
<span className="badge">
{activeListing.city}, {activeListing.region}
</span>
<h3 style={{ margin: '6px 0 4px' }}>{activeListing.title}</h3>
<p style={{ margin: 0 }}>{activeListing.teaser}</p>
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
<Link className="button secondary" href={`/listings/${activeListing.slug}`}>
{t('openListing')}
</Link>
</div>
</div>
</div>
<div className="latest-rail">
{latest.map((item, idx) => (
<button
key={item.id}
className={`rail-item ${idx === activeIndex ? 'active' : ''}`}
onClick={() => setActiveIndex(idx)}
>
<div className="rail-thumb">
{item.coverImage ? <img src={item.coverImage} alt={item.title} /> : <div className="rail-fallback" />}
</div>
<div className="rail-text">
<div className="rail-title">{item.title}</div>
<div className="rail-sub">{item.city}, {item.region}</div>
</div>
</button>
))}
</div>
</div>
)}
</section>
</main>
);
}

35
app/verify/page.tsx Normal file
View file

@ -0,0 +1,35 @@
import { notFound } from 'next/navigation';
import { cookies, headers } from 'next/headers';
import { resolveLocale, t } from '../../lib/i18n';
type Props = {
searchParams: { token?: string };
};
export default async function VerifyPage({ searchParams }: Props) {
const token = searchParams.token;
if (!token) {
notFound();
}
const locale = resolveLocale({ cookieLocale: cookies().get('locale')?.value, acceptLanguage: headers().get('accept-language') });
const translate = (key: any) => t(locale, key as any);
const res = await fetch(`${process.env.APP_URL ?? 'http://localhost:3000'}/api/auth/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
cache: 'no-store',
});
const ok = res.ok;
return (
<main className="panel" style={{ maxWidth: 520, margin: '40px auto' }}>
<h1>{translate('verifyTitle')}</h1>
{ok ? (
<p>{translate('verifyOk')}</p>
) : (
<p style={{ color: 'red' }}>{translate('verifyFail')}</p>
)}
</main>
);
}

33
deploy/build.sh Executable file
View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source deploy/env.sh
GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || date +%s)
IMAGE_REPO="${REGISTRY}/${REGISTRY_REPO}"
IMAGE="${IMAGE_REPO}:${GIT_SHA}"
IMAGE_LATEST="${IMAGE_REPO}:latest"
echo "Building image:"
echo " $IMAGE"
echo " $IMAGE_LATEST"
# npm audit (high severity and above)
echo "Running npm audit (high)..."
npm audit --audit-level=high || echo "npm audit reported issues above."
# Build
docker build -t "$IMAGE" -t "$IMAGE_LATEST" .
echo "$IMAGE" > deploy/.last-image
echo "Done. Last image: $IMAGE"
# Trivy image scan (if available)
if command -v trivy >/dev/null 2>&1; then
echo "Running Trivy scan on $IMAGE ..."
trivy image --exit-code 0 "$IMAGE" || true
else
echo "Trivy not installed; skipping image scan."
fi

16
deploy/deploy-prod.sh Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source deploy/env.sh
export K8S_NAMESPACE="$PROD_NAMESPACE"
export APP_HOST="$PROD_HOST"
export NEXT_PUBLIC_SITE_URL="https://${PROD_HOST}"
export NEXT_PUBLIC_API_BASE="https://${PROD_HOST}/api"
export APP_ENV="production"
export CLUSTER_ISSUER="$PROD_CLUSTER_ISSUER"
export INGRESS_CLASS
# optionally set APP_SECRET in the environment before running
bash deploy/deploy.sh

16
deploy/deploy-staging.sh Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source deploy/env.sh
export K8S_NAMESPACE="$STAGING_NAMESPACE"
export APP_HOST="$STAGING_HOST"
export NEXT_PUBLIC_SITE_URL="https://${STAGING_HOST}"
export NEXT_PUBLIC_API_BASE="https://${STAGING_HOST}/api"
export APP_ENV="staging"
export CLUSTER_ISSUER="$STAGING_CLUSTER_ISSUER"
export INGRESS_CLASS
# optionally set APP_SECRET in the environment before running
bash deploy/deploy.sh

36
deploy/deploy.sh Executable file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source deploy/env.sh
if [[ ! -f deploy/.last-image ]]; then
echo "deploy/.last-image puuttuu. Aja ensin ./deploy/build.sh"
exit 1
fi
: "${K8S_NAMESPACE:?K8S_NAMESPACE pitää asettaa}"
: "${APP_HOST:?APP_HOST pitää asettaa}"
: "${NEXT_PUBLIC_SITE_URL:?NEXT_PUBLIC_SITE_URL pitää asettaa}"
: "${NEXT_PUBLIC_API_BASE:?NEXT_PUBLIC_API_BASE pitää asettaa}"
: "${APP_ENV:?APP_ENV pitää asettaa}"
: "${CLUSTER_ISSUER:?CLUSTER_ISSUER pitää asettaa}"
: "${INGRESS_CLASS:?INGRESS_CLASS pitää asettaa}"
IMAGE=$(cat deploy/.last-image)
K8S_IMAGE="$IMAGE"
export K8S_NAMESPACE APP_HOST NEXT_PUBLIC_SITE_URL NEXT_PUBLIC_API_BASE APP_ENV CLUSTER_ISSUER INGRESS_CLASS K8S_IMAGE
TMP_MANIFEST=$(mktemp)
envsubst < k8s/app.yaml > "$TMP_MANIFEST"
echo "Applying manifest to namespace: $K8S_NAMESPACE"
kubectl apply -f "$TMP_MANIFEST"
echo "Waiting for rollout..."
kubectl rollout status deployment/"$DEPLOYMENT_NAME" -n "$K8S_NAMESPACE"
rm "$TMP_MANIFEST"
echo "Deploy OK."

19
deploy/env.sh Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# ---- Container registry ----
export REGISTRY="registry.halla-aho.net"
export REGISTRY_REPO="thalla/lomavuokraus-web"
# ---- Kubernetes base settings ----
export DEPLOYMENT_NAME="lomavuokraus-web"
export PROD_NAMESPACE="lomavuokraus-prod"
export STAGING_NAMESPACE="lomavuokraus-staging"
# ---- Domains and cert-manager issuers ----
export PROD_HOST="lomavuokraus.fi"
export STAGING_HOST="staging.lomavuokraus.fi"
export PROD_CLUSTER_ISSUER="letsencrypt-prod"
export STAGING_CLUSTER_ISSUER="letsencrypt-staging"
# ---- Ingress class (k3s ships with Traefik by default) ----
export INGRESS_CLASS="traefik"

23
deploy/push.sh Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source deploy/env.sh
if [[ ! -f deploy/.last-image ]]; then
echo "deploy/.last-image puuttuu. Aja ensin ./deploy/build.sh"
exit 1
fi
IMAGE=$(cat deploy/.last-image)
IMAGE_REPO="${REGISTRY}/${REGISTRY_REPO}"
IMAGE_LATEST="${IMAGE_REPO}:latest"
echo "Pushing:"
echo " $IMAGE"
echo " $IMAGE_LATEST"
docker push "$IMAGE"
docker push "$IMAGE_LATEST"
echo "Push OK."

13
deploy/rollback-prod.sh Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source deploy/env.sh
TARGET_NAMESPACE="${1:-$PROD_NAMESPACE}"
echo "Rolling back deployment/$DEPLOYMENT_NAME in namespace $TARGET_NAMESPACE"
kubectl rollout undo deployment/"$DEPLOYMENT_NAME" -n "$TARGET_NAMESPACE"
kubectl rollout status deployment/"$DEPLOYMENT_NAME" -n "$TARGET_NAMESPACE"
echo "Rollback OK."

110
k8s/app.yaml Normal file
View file

@ -0,0 +1,110 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: lomavuokraus-web-config
namespace: ${K8S_NAMESPACE}
data:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
NEXT_PUBLIC_API_BASE: ${NEXT_PUBLIC_API_BASE}
APP_ENV: ${APP_ENV}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: lomavuokraus-web
namespace: ${K8S_NAMESPACE}
labels:
app: lomavuokraus-web
spec:
replicas: 2
selector:
matchLabels:
app: lomavuokraus-web
template:
metadata:
labels:
app: lomavuokraus-web
spec:
containers:
- name: lomavuokraus-web
image: ${K8S_IMAGE}
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
name: http
envFrom:
- secretRef:
name: lomavuokraus-web-secrets
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
---
apiVersion: v1
kind: Service
metadata:
name: lomavuokraus-web
namespace: ${K8S_NAMESPACE}
labels:
app: lomavuokraus-web
spec:
selector:
app: lomavuokraus-web
ports:
- name: http
port: 80
targetPort: http
type: ClusterIP
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: https-redirect
namespace: ${K8S_NAMESPACE}
spec:
redirectScheme:
scheme: https
permanent: true
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: lomavuokraus-web
namespace: ${K8S_NAMESPACE}
annotations:
cert-manager.io/cluster-issuer: ${CLUSTER_ISSUER}
kubernetes.io/ingress.class: ${INGRESS_CLASS}
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.middlewares: ${K8S_NAMESPACE}-https-redirect@kubernetescrd
spec:
ingressClassName: ${INGRESS_CLASS}
tls:
- hosts:
- ${APP_HOST}
secretName: lomavuokraus-web-tls
rules:
- host: ${APP_HOST}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: lomavuokraus-web
port:
number: 80

29
k8s/cert-issuers.yaml Normal file
View file

@ -0,0 +1,29 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
email: admin@lomavuokraus.fi
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
email: admin@lomavuokraus.fi
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-staging
solvers:
- http01:
ingress:
class: traefik

47
k8s/deployment.yaml Normal file
View file

@ -0,0 +1,47 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: lomavuokraus-web
namespace: ${K8S_NAMESPACE}
spec:
replicas: 2
selector:
matchLabels:
app: lomavuokraus-web
template:
metadata:
labels:
app: lomavuokraus-web
spec:
containers:
- name: lomavuokraus-web
image: ${K8S_IMAGE}
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
# lisää tänne NEXT_PUBLIC_* yms.
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
---
apiVersion: v1
kind: Service
metadata:
name: lomavuokraus-web
namespace: ${K8S_NAMESPACE}
spec:
selector:
app: lomavuokraus-web
ports:
- name: http
port: 80
targetPort: 3000
type: ClusterIP

9
k8s/namespaces.yaml Normal file
View file

@ -0,0 +1,9 @@
apiVersion: v1
kind: Namespace
metadata:
name: lomavuokraus-prod
---
apiVersion: v1
kind: Namespace
metadata:
name: lomavuokraus-staging

17
lib/auth.ts Normal file
View file

@ -0,0 +1,17 @@
import bcrypt from 'bcryptjs';
const DEFAULT_ROUNDS = 12;
export function getSaltRounds(): number {
const val = Number(process.env.BCRYPT_ROUNDS ?? DEFAULT_ROUNDS);
return Number.isInteger(val) && val > 8 ? val : DEFAULT_ROUNDS;
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, getSaltRounds());
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
if (!hash) return false;
return bcrypt.compare(password, hash);
}

364
lib/i18n.ts Normal file
View file

@ -0,0 +1,364 @@
export type Locale = 'en' | 'fi';
const allMessages = {
en: {
brand: 'lomavuokraus.fi',
navProfile: 'Profile',
navMyListings: 'My listings',
navNewListing: 'New listing',
navApprovals: 'Approvals',
navUsers: 'Users',
navLogout: 'Logout',
navLogin: 'Login',
navSignup: 'Sign up',
navBrowse: 'Browse listings',
navLanguage: 'Language',
approvalsBadge: '{count}',
heroEyebrow: 'lomavuokraus.fi',
heroTitle: 'Find your next Finnish getaway',
heroBody: 'A fast, modern marketplace for holiday rentals. Built with the Next.js App Router and ready for Kubernetes from day one.',
ctaViewSample: 'View a sample listing',
ctaHealth: 'Check health endpoint',
ctaBrowse: 'Browse listings',
highlightQualityTitle: 'Quality stays',
highlightQualityBody: 'Curated cabins and villas with clear availability and simple booking.',
highlightLocalTitle: 'Local-first',
highlightLocalBody: 'Built for Finnish seasons with fast content delivery in the Nordics.',
highlightApiTitle: 'API-friendly',
highlightApiBody: 'Structured data so you can surface listings wherever you need them.',
runtimeConfigTitle: 'Runtime configuration',
runtimeAppEnv: 'APP_ENV',
runtimeSiteUrl: 'NEXT_PUBLIC_SITE_URL',
runtimeApiBase: 'NEXT_PUBLIC_API_BASE',
loginTitle: 'Login',
emailLabel: 'Email',
passwordLabel: 'Password',
loginButton: 'Login',
loggingIn: 'Logging in…',
loginSuccess: 'Login successful.',
registerTitle: 'Create an account',
registerLead: 'Verify email is required, and an admin will approve your account.',
nameOptional: 'Name (optional)',
passwordHint: 'Password (min 8 chars)',
registerButton: 'Register',
registering: 'Submitting…',
registerSuccess: 'Registration successful. Check your email for a verification link.',
pendingAdminTitle: 'Admin: pending items',
pendingUsersTitle: 'Pending users',
pendingListingsTitle: 'Pending listings',
noPendingUsers: 'No pending users.',
noPendingListings: 'No pending listings.',
statusLabel: 'status',
slugsLabel: 'Slugs',
verifiedLabel: 'verified',
approve: 'Approve',
approveAdmin: 'Approve + make admin',
reject: 'Reject',
remove: 'Remove',
publish: 'Publish',
approvalsMessage: 'Listing updated',
userUpdated: 'User updated',
adminRequired: 'Admin access required',
adminUsersTitle: 'Admin: users',
adminUsersLead: 'Manage user roles and approvals.',
tableEmail: 'Email',
tableRole: 'Role',
tableStatus: 'Status',
tableVerified: 'Verified',
tableApproved: 'Approved',
myListingsTitle: 'My listings',
createNewListing: 'Create new listing',
noListings: 'No listings yet.',
createOne: 'Create one',
loading: 'Loading…',
view: 'View',
removing: 'Removing…',
removed: 'Listing removed',
removeConfirm: 'Remove this listing? It will be hidden from others.',
myProfileTitle: 'My profile',
notLoggedIn: 'Not logged in',
profileEmail: 'Email',
profileName: 'Name',
profileRole: 'Role',
profileStatus: 'Status',
profileEmailVerified: 'Email verified',
profileApproved: 'Approved',
profileUpdated: 'Profile updated',
emailLocked: 'Email cannot be changed',
save: 'Save',
saving: 'Saving…',
yes: 'yes',
no: 'no',
verifyTitle: 'Email verification',
verifyOk: 'Your email is verified. An admin will approve your account shortly.',
verifyFail: 'Verification failed or token invalid.',
listingLocation: 'Location',
listingAddress: 'Address',
listingCapacity: 'Capacity',
listingAmenities: 'Amenities',
listingContact: 'Contact',
listingMoreInfo: 'More info',
localeLabel: 'Locale',
homeCrumb: 'Home',
createListingTitle: 'Create listing',
loginToCreate: 'Please log in first to create a listing.',
slugLabel: 'Slug',
localeInput: 'Locale',
titleLabel: 'Title',
descriptionLabel: 'Description',
teaserLabel: 'Teaser',
countryLabel: 'Country',
regionLabel: 'Region',
cityLabel: 'City',
contactNameLabel: 'Contact name',
contactEmailLabel: 'Contact email',
streetAddressLabel: 'Street address',
addressNoteLabel: 'Address note (optional)',
addressNotePlaceholder: 'Directions, parking, or arrival details',
latitudeLabel: 'Latitude (optional)',
longitudeLabel: 'Longitude (optional)',
maxGuestsLabel: 'Max guests',
bedroomsLabel: 'Bedrooms',
bedsLabel: 'Beds',
bathroomsLabel: 'Bathrooms',
priceHintLabel: 'Price hint (cents)',
imagesLabel: 'Images (one URL per line, max 10)',
coverImageLabel: 'Cover image line number',
coverImageHelp: 'Which image line should be shown as the cover in listings (defaults to 1)',
submitListing: 'Create listing',
submittingListing: 'Submitting…',
createListingSuccess: 'Listing created with id {id} (status: {status})',
approvalsCountLabel: 'Approvals',
approvalsPending: '{count} pending',
amenitySauna: 'Sauna',
amenityFireplace: 'Fireplace',
amenityWifi: 'Wi-Fi',
amenityPets: 'Pets allowed',
amenityLake: 'By the lake',
amenityAirConditioning: 'Air conditioning',
amenityEvFree: 'EV charging (free)',
amenityEvPaid: 'EV charging (paid)',
evChargingLabel: 'EV charging',
evChargingNone: 'Not available',
evChargingFree: 'Free for guests',
evChargingPaid: 'Paid on-site',
capacityGuests: '{count} guests',
capacityBedrooms: '{count} bedrooms',
capacityBeds: '{count} beds',
capacityBathrooms: '{count} bathrooms',
browseListingsTitle: 'Browse listings',
browseListingsLead: 'Search public listings, filter by location, and explore them on the map.',
searchLabel: 'Search',
searchPlaceholder: 'Search by name, description, or city',
cityFilter: 'City',
regionFilter: 'Region',
searchButton: 'Search',
clearFilters: 'Clear filters',
addressSearchLabel: 'Find listings near an address',
addressSearchPlaceholder: 'Street, city, or place',
locateAddress: 'Locate on map',
addressRadiusLabel: 'Within {km} km',
listingsFound: '{count} listings',
openListing: 'Open listing',
mapNoResults: 'No listings match your filters.',
evChargingAny: 'Any',
addressNotFound: 'Address not found',
addressLookupFailed: 'Failed to locate address',
loadingMap: 'Loading map…',
latestListingsTitle: 'Latest listings',
latestListingsLead: 'Fresh rentals as they are published. Tap to browse.',
},
fi: {
brand: 'lomavuokraus.fi',
navProfile: 'Profiili',
navMyListings: 'Omat kohteet',
navNewListing: 'Luo kohde',
navApprovals: 'Tarkastettavat',
navUsers: 'Käyttäjät',
navLogout: 'Kirjaudu ulos',
navLogin: 'Kirjaudu',
navSignup: 'Rekisteröidy',
navBrowse: 'Selaa kohteita',
navLanguage: 'Kieli',
approvalsBadge: '{count}',
heroEyebrow: 'lomavuokraus.fi',
heroTitle: 'Löydä seuraava mökkilomasi',
heroBody: 'Nopea, moderni vuokramökkipalvelu. Rakennettu Next.js App Routerilla ja valmiina Kubernetes-ympäristöön.',
ctaViewSample: 'Katso esimerkkikohde',
ctaHealth: 'Tarkista health-päätepiste',
ctaBrowse: 'Selaa kohteita',
highlightQualityTitle: 'Laadukkaat kohteet',
highlightQualityBody: 'Kuratoidut mökit ja huvilat, selkeät saatavuudet ja helppo varaus.',
highlightLocalTitle: 'Suomi edellä',
highlightLocalBody: 'Tehty Suomen olosuhteisiin, nopea sisällönjakelu Pohjoismaissa.',
highlightApiTitle: 'API-ystävällinen',
highlightApiBody: 'Strukturoitu data, jotta löydät kohteet kaikissa kanavissa.',
runtimeConfigTitle: 'Ajoaikainen konfiguraatio',
runtimeAppEnv: 'APP_ENV',
runtimeSiteUrl: 'NEXT_PUBLIC_SITE_URL',
runtimeApiBase: 'NEXT_PUBLIC_API_BASE',
loginTitle: 'Kirjaudu sisään',
emailLabel: 'Sähköposti',
passwordLabel: 'Salasana',
loginButton: 'Kirjaudu',
loggingIn: 'Kirjaudutaan…',
loginSuccess: 'Kirjautuminen onnistui.',
registerTitle: 'Luo tili',
registerLead: 'Sähköpostin varmistus vaaditaan, ja ylläpitäjä hyväksyy tilin.',
nameOptional: 'Nimi (valinnainen)',
passwordHint: 'Salasana (väh. 8 merkkiä)',
registerButton: 'Rekisteröidy',
registering: 'Lähetetään…',
registerSuccess: 'Rekisteröinti onnistui. Tarkista sähköpostisi vahvistuslinkin vuoksi.',
pendingAdminTitle: 'Ylläpito: tarkastettavat',
pendingUsersTitle: 'Odottavat käyttäjät',
pendingListingsTitle: 'Odottavat kohteet',
noPendingUsers: 'Ei odottavia käyttäjiä.',
noPendingListings: 'Ei odottavia kohteita.',
statusLabel: 'tila',
slugsLabel: 'Osoitepolut',
verifiedLabel: 'vahvistettu',
approve: 'Hyväksy',
approveAdmin: 'Hyväksy + admin',
reject: 'Hylkää',
remove: 'Poista käytöstä',
publish: 'Julkaise',
approvalsMessage: 'Kohde päivitetty',
userUpdated: 'Käyttäjä päivitetty',
adminRequired: 'Ylläpitäjän oikeudet vaaditaan',
adminUsersTitle: 'Ylläpito: käyttäjät',
adminUsersLead: 'Hallinnoi rooleja ja hyväksyntöjä.',
tableEmail: 'Sähköposti',
tableRole: 'Rooli',
tableStatus: 'Tila',
tableVerified: 'Vahvistettu',
tableApproved: 'Hyväksytty',
myListingsTitle: 'Omat kohteet',
createNewListing: 'Luo uusi kohde',
noListings: 'Ei kohteita vielä.',
createOne: 'Luo kohde',
loading: 'Ladataan…',
view: 'Näytä',
removing: 'Poistetaan…',
removed: 'Kohde poistettu näkyvistä',
removeConfirm: 'Poista kohde? Se piilotetaan muilta.',
myProfileTitle: 'Oma profiili',
notLoggedIn: 'Et ole kirjautunut',
profileEmail: 'Sähköposti',
profileName: 'Nimi',
profileRole: 'Rooli',
profileStatus: 'Tila',
profileEmailVerified: 'Sähköposti vahvistettu',
profileApproved: 'Hyväksytty',
profileUpdated: 'Profiili päivitetty',
emailLocked: 'Sähköpostia ei voi vaihtaa',
save: 'Tallenna',
saving: 'Tallennetaan…',
yes: 'kyllä',
no: 'ei',
verifyTitle: 'Sähköpostin vahvistus',
verifyOk: 'Sähköposti vahvistettu. Ylläpitäjä hyväksyy tilin pian.',
verifyFail: 'Vahvistus epäonnistui tai token on virheellinen.',
listingLocation: 'Sijainti',
listingAddress: 'Osoite',
listingCapacity: 'Tilat',
listingAmenities: 'Varustelu',
listingContact: 'Yhteystiedot',
listingMoreInfo: 'Lisätietoja',
localeLabel: 'Kieli',
homeCrumb: 'Etusivu',
createListingTitle: 'Luo kohde',
loginToCreate: 'Kirjaudu ensin luodaksesi kohteen.',
slugLabel: 'Osoitepolku',
localeInput: 'Kieli',
titleLabel: 'Otsikko',
descriptionLabel: 'Kuvaus',
teaserLabel: 'Tiivistelmä',
countryLabel: 'Maa',
regionLabel: 'Maakunta/alue',
cityLabel: 'Kunta/kaupunki',
contactNameLabel: 'Yhteyshenkilö',
contactEmailLabel: 'Yhteyssähköposti',
streetAddressLabel: 'Katuosoite',
addressNoteLabel: 'Saapumisohje (valinnainen)',
addressNotePlaceholder: 'Reittiohje, pysäköinti tai muut saapumisohjeet',
latitudeLabel: 'Leveysaste (valinnainen)',
longitudeLabel: 'Pituusaste (valinnainen)',
maxGuestsLabel: 'Vieraita enintään',
bedroomsLabel: 'Makuuhuoneita',
bedsLabel: 'Vuoteita',
bathroomsLabel: 'Kylpyhuoneita',
priceHintLabel: 'Hinta-arvio (senttiä)',
imagesLabel: 'Kuvat (yksi URL per rivi, max 10)',
coverImageLabel: 'Kansikuvan rivinumero',
coverImageHelp: 'Mikä kuvista näytetään kansikuvana listauksissa (oletus 1)',
submitListing: 'Luo kohde',
submittingListing: 'Lähetetään…',
createListingSuccess: 'Kohde luotu id:llä {id} (tila: {status})',
approvalsCountLabel: 'Tarkastettavat',
approvalsPending: '{count} odottaa',
amenitySauna: 'Sauna',
amenityFireplace: 'Takka',
amenityWifi: 'Wi-Fi',
amenityPets: 'Lemmikit sallittu',
amenityLake: 'Järven rannalla',
amenityAirConditioning: 'Ilmastointi',
amenityEvFree: 'Sähköauton lataus (ilmainen)',
amenityEvPaid: 'Sähköauton lataus (maksullinen)',
evChargingLabel: 'Sähköauton lataus',
evChargingNone: 'Ei saatavilla',
evChargingFree: 'Ilmainen asiakkaille',
evChargingPaid: 'Maksullinen',
capacityGuests: '{count} vierasta',
capacityBedrooms: '{count} makuuhuonetta',
capacityBeds: '{count} vuodetta',
capacityBathrooms: '{count} kylpyhuonetta',
browseListingsTitle: 'Selaa kohteita',
browseListingsLead: 'Hae julkaistuja kohteita, rajaa sijainnilla ja tutki kartalla.',
searchLabel: 'Haku',
searchPlaceholder: 'Hae nimellä, kuvauksella tai paikkakunnalla',
cityFilter: 'Kaupunki/kunta',
regionFilter: 'Maakunta/alue',
searchButton: 'Hae',
clearFilters: 'Tyhjennä suodattimet',
addressSearchLabel: 'Etsi kohteita osoitteen läheltä',
addressSearchPlaceholder: 'Katu, kaupunki tai paikka',
locateAddress: 'Paikanna kartalle',
addressRadiusLabel: '{km} km säteellä',
listingsFound: '{count} kohdetta',
openListing: 'Avaa kohde',
mapNoResults: 'Suodattimilla ei löytynyt kohteita.',
evChargingAny: 'Kaikki',
addressNotFound: 'Osoitetta ei löydy',
addressLookupFailed: 'Paikannus epäonnistui',
loadingMap: 'Ladataan karttaa…',
latestListingsTitle: 'Viimeisimmät kohteet',
latestListingsLead: 'Tuoreimmat kohteet heti julkaisun jälkeen.',
},
} as const;
export const messages = allMessages;
export type MessageKey = keyof typeof allMessages.en;
function normalizeLocale(input?: string | null): Locale | null {
if (!input) return null;
const lower = input.toLowerCase();
if (lower.startsWith('fi')) return 'fi';
if (lower.startsWith('en')) return 'en';
return null;
}
export function resolveLocale(opts: { cookieLocale?: string | null; acceptLanguage?: string | null }): Locale {
const fromCookie = normalizeLocale(opts.cookieLocale);
if (fromCookie) return fromCookie;
const fromHeader = normalizeLocale(opts.acceptLanguage);
if (fromHeader) return fromHeader;
return 'en';
}
export function t(locale: Locale, key: MessageKey, vars?: Record<string, string | number>) {
const table = messages[locale] ?? messages.en;
const template = String(table[key] ?? messages.en[key]);
if (!vars) return template;
return Object.entries(vars).reduce<string>((acc, [k, v]) => acc.replace(`{${k}}`, String(v)), template);
}

56
lib/jwt.ts Normal file
View file

@ -0,0 +1,56 @@
import { SignJWT, jwtVerify } from 'jose';
import { NextRequest } from 'next/server';
const ALGORITHM = 'HS256';
const TOKEN_EXP_HOURS = 24;
function getSecret() {
if (!process.env.AUTH_SECRET) {
throw new Error('AUTH_SECRET is not set');
}
return new TextEncoder().encode(process.env.AUTH_SECRET);
}
export async function signAccessToken(payload: { userId: string; role: string }) {
const secret = getSecret();
const exp = Math.floor(Date.now() / 1000) + TOKEN_EXP_HOURS * 3600;
return new SignJWT(payload).setProtectedHeader({ alg: ALGORITHM }).setExpirationTime(exp).sign(secret);
}
export async function verifyAccessToken(token: string) {
const secret = getSecret();
const { payload } = await jwtVerify(token, secret, { algorithms: [ALGORITHM] });
return payload as { userId: string; role: string };
}
export async function requireAuth(request: Request | NextRequest) {
let token: string | null = null;
const header = request.headers.get('authorization');
if (header?.startsWith('Bearer ')) {
token = header.slice('Bearer '.length);
}
if (!token) {
const cookieHeader = request.headers.get('cookie') ?? '';
const match = cookieHeader.split(';').map((c) => c.trim()).find((c) => c.startsWith('session_token='));
if (match) {
token = decodeURIComponent(match.split('=')[1]);
}
}
if (!token) {
throw new Error('Unauthorized');
}
return verifyAccessToken(token);
}
export function buildSessionCookie(token: string) {
const secure = process.env.APP_URL?.startsWith('https://') || process.env.NODE_ENV === 'production';
const maxAge = TOKEN_EXP_HOURS * 3600;
return `session_token=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge};${secure ? ' Secure;' : ''}`;
}
export function clearSessionCookie() {
return 'session_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0;';
}

59
lib/listings.ts Normal file
View file

@ -0,0 +1,59 @@
import { Prisma, ListingStatus } from '@prisma/client';
import { prisma } from './prisma';
import { DEFAULT_LOCALE, SAMPLE_LISTING_SLUG } from './sampleListing';
export type ListingWithTranslations = Prisma.ListingTranslationGetPayload<{
include: {
listing: {
include: {
images: true;
owner: true;
};
};
};
}>;
type FetchOptions = {
slug: string;
locale?: string;
};
/**
* Fetch a listing translation by slug and locale.
* Falls back to any locale if the requested locale is missing.
*/
export async function getListingBySlug({ slug, locale }: FetchOptions): Promise<ListingWithTranslations | null> {
const targetLocale = locale ?? DEFAULT_LOCALE;
const translation = await prisma.listingTranslation.findFirst({
where: { slug, locale: targetLocale, listing: { status: ListingStatus.PUBLISHED, removedAt: null } },
include: {
listing: {
include: {
images: { orderBy: { order: 'asc' } },
owner: true,
},
},
},
});
if (translation) {
return translation;
}
// Fallback: first translation for this slug
return prisma.listingTranslation.findFirst({
where: { slug, listing: { status: ListingStatus.PUBLISHED, removedAt: null } },
include: {
listing: {
include: {
images: { orderBy: { order: 'asc' } },
owner: true,
},
},
},
orderBy: { createdAt: 'asc' },
});
}
export { SAMPLE_LISTING_SLUG, DEFAULT_LOCALE };

80
lib/mailer.ts Normal file
View file

@ -0,0 +1,80 @@
import fs from 'fs';
import nodemailer from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import path from 'path';
type MailOptions = {
to: string;
subject: string;
text: string;
html?: string;
};
async function createTransport() {
const {
SMTP_HOST,
SMTP_PORT,
SMTP_USER,
SMTP_PASS,
SMTP_FROM,
SMTP_TLS,
SMTP_SSL,
SMTP_REJECT_UNAUTHORIZED,
DKIM_SELECTOR,
DKIM_DOMAIN,
DKIM_PRIVATE_KEY_PATH,
} = process.env;
if (!SMTP_HOST || !SMTP_PORT || !SMTP_USER || !SMTP_PASS || !SMTP_FROM) {
throw new Error('SMTP configuration is missing required environment variables');
}
const secure = SMTP_SSL === 'true';
const requireTLS = SMTP_TLS === 'true';
const transporterOptions: SMTPTransport.Options = {
host: SMTP_HOST,
port: Number(SMTP_PORT),
secure,
requireTLS,
auth: { user: SMTP_USER, pass: SMTP_PASS },
};
if (SMTP_REJECT_UNAUTHORIZED === 'false') {
transporterOptions.tls = { rejectUnauthorized: false };
}
if (DKIM_SELECTOR && DKIM_DOMAIN && DKIM_PRIVATE_KEY_PATH) {
const keyPath = path.resolve(process.cwd(), DKIM_PRIVATE_KEY_PATH);
if (fs.existsSync(keyPath)) {
transporterOptions.dkim = {
domainName: DKIM_DOMAIN,
keySelector: DKIM_SELECTOR,
privateKey: fs.readFileSync(keyPath, 'utf8'),
};
}
}
return nodemailer.createTransport(transporterOptions);
}
let cachedTransport: nodemailer.Transporter | null = null;
async function getTransport() {
if (cachedTransport) return cachedTransport;
cachedTransport = await createTransport();
return cachedTransport;
}
export async function sendMail({ to, subject, text, html }: MailOptions) {
const transporter = await getTransport();
const from = process.env.SMTP_FROM!;
return transporter.sendMail({ from, to, subject, text, html: html ?? text });
}
export async function sendVerificationEmail(to: string, link: string) {
const subject = 'Verify your email for lomavuokraus.fi';
const text = `Please verify your email by visiting: ${link}\n\nIf you did not request this, you can ignore this email.`;
const html = `<p>Please verify your email by clicking <a href="${link}">this link</a>.</p><p>If you did not request this, you can ignore this email.</p>`;
return sendMail({ to, subject, text, html });
}

19
lib/prisma.ts Normal file
View file

@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
const pool = process.env.DATABASE_URL ? new Pool({ connectionString: process.env.DATABASE_URL }) : undefined;
const adapter = pool ? new PrismaPg(pool) : undefined;
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}

2
lib/sampleListing.ts Normal file
View file

@ -0,0 +1,2 @@
export const SAMPLE_LISTING_SLUG = 'saimaa-lakeside-cabin';
export const DEFAULT_LOCALE = 'en';

11
lib/tokens.ts Normal file
View file

@ -0,0 +1,11 @@
import crypto from 'crypto';
export function randomToken(bytes = 32): string {
return crypto.randomBytes(bytes).toString('base64url');
}
export function addHours(hours: number): Date {
const d = new Date();
d.setHours(d.getHours() + hours);
return d;
}

5
next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

9
next.config.mjs Normal file
View file

@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
experimental: {
typedRoutes: true
}
};
export default nextConfig;

7826
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "lomavuokraus",
"version": "0.1.0",
"private": true,
"description": "Lomavuokraus.fi Next.js app and deploy tooling",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.0",
"bcryptjs": "^3.0.3",
"jose": "^6.1.2",
"next": "^14.2.32",
"nodemailer": "^7.0.10",
"pg": "^8.16.3",
"prisma": "^7.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "^20.12.7",
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.15.6",
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.21",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.32",
"typescript": "^5.4.5"
},
"engines": {
"node": ">=20"
},
"prisma": {
"seed": "node prisma/seed.js"
}
}

14
prisma.config.ts Normal file
View file

@ -0,0 +1,14 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});

View file

@ -0,0 +1,91 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"role" "Role" NOT NULL DEFAULT 'USER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Listing" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"country" TEXT NOT NULL,
"region" TEXT NOT NULL,
"city" TEXT NOT NULL,
"addressNote" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"maxGuests" INTEGER NOT NULL,
"bedrooms" INTEGER NOT NULL,
"beds" INTEGER NOT NULL,
"bathrooms" INTEGER NOT NULL,
"hasSauna" BOOLEAN NOT NULL DEFAULT false,
"hasFireplace" BOOLEAN NOT NULL DEFAULT false,
"hasWifi" BOOLEAN NOT NULL DEFAULT false,
"petsAllowed" BOOLEAN NOT NULL DEFAULT false,
"byTheLake" BOOLEAN NOT NULL DEFAULT false,
"priceHintPerNightCents" INTEGER,
"contactName" TEXT NOT NULL,
"contactEmail" TEXT NOT NULL,
"contactPhone" TEXT,
"externalUrl" TEXT,
"published" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Listing_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ListingTranslation" (
"id" TEXT NOT NULL,
"listingId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT NOT NULL,
"teaser" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ListingTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ListingImage" (
"id" TEXT NOT NULL,
"listingId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"altText" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ListingImage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "ListingTranslation_listingId_locale_key" ON "ListingTranslation"("listingId", "locale");
-- CreateIndex
CREATE UNIQUE INDEX "ListingTranslation_slug_locale_key" ON "ListingTranslation"("slug", "locale");
-- AddForeignKey
ALTER TABLE "Listing" ADD CONSTRAINT "Listing_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ListingTranslation" ADD CONSTRAINT "ListingTranslation_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ListingImage" ADD CONSTRAINT "ListingImage_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -0,0 +1,41 @@
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('PENDING', 'ACTIVE', 'DISABLED');
-- CreateEnum
CREATE TYPE "ListingStatus" AS ENUM ('DRAFT', 'PENDING', 'PUBLISHED', 'REJECTED');
-- AlterTable
ALTER TABLE "Listing" ADD COLUMN "approvedAt" TIMESTAMP(3),
ADD COLUMN "approvedById" TEXT,
ADD COLUMN "status" "ListingStatus" NOT NULL DEFAULT 'PENDING';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "approvedAt" TIMESTAMP(3),
ADD COLUMN "emailVerifiedAt" TIMESTAMP(3),
ADD COLUMN "passwordHash" TEXT NOT NULL DEFAULT '',
ADD COLUMN "status" "UserStatus" NOT NULL DEFAULT 'PENDING';
-- CreateTable
CREATE TABLE "VerificationToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"type" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE INDEX "VerificationToken_userId_idx" ON "VerificationToken"("userId");
-- AddForeignKey
ALTER TABLE "Listing" ADD CONSTRAINT "Listing_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -0,0 +1,10 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "Role" ADD VALUE 'USER_MODERATOR';
ALTER TYPE "Role" ADD VALUE 'LISTING_MODERATOR';

View file

@ -0,0 +1,28 @@
-- AlterEnum
ALTER TYPE "ListingStatus" ADD VALUE 'REMOVED';
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "UserStatus" ADD VALUE 'REJECTED';
ALTER TYPE "UserStatus" ADD VALUE 'REMOVED';
-- AlterTable
ALTER TABLE "Listing" ADD COLUMN "rejectedAt" TIMESTAMP(3),
ADD COLUMN "rejectedById" TEXT,
ADD COLUMN "rejectedReason" TEXT,
ADD COLUMN "removedAt" TIMESTAMP(3),
ADD COLUMN "removedById" TEXT,
ADD COLUMN "removedReason" TEXT;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "rejectedAt" TIMESTAMP(3),
ADD COLUMN "rejectedReason" TEXT,
ADD COLUMN "removedAt" TIMESTAMP(3),
ADD COLUMN "removedById" TEXT,
ADD COLUMN "removedReason" TEXT;

View file

@ -0,0 +1,3 @@
-- Add air conditioning amenity and cover image flag
ALTER TABLE "Listing" ADD COLUMN "hasAirConditioning" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "ListingImage" ADD COLUMN "isCover" BOOLEAN NOT NULL DEFAULT false;

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

142
prisma/schema.prisma Normal file
View file

@ -0,0 +1,142 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum Role {
USER
ADMIN
USER_MODERATOR
LISTING_MODERATOR
}
enum UserStatus {
PENDING
ACTIVE
DISABLED
REJECTED
REMOVED
}
enum ListingStatus {
DRAFT
PENDING
PUBLISHED
REJECTED
REMOVED
}
enum EvCharging {
NONE
FREE
PAID
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String @default("")
name String?
role Role @default(USER)
status UserStatus @default(PENDING)
emailVerifiedAt DateTime?
approvedAt DateTime?
rejectedAt DateTime?
rejectedReason String?
removedAt DateTime?
removedById String?
removedReason String?
listings Listing[]
approvedListings Listing[] @relation("ListingApprovedBy")
verificationTokens VerificationToken[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Listing {
id String @id @default(cuid())
ownerId String
owner User @relation(fields: [ownerId], references: [id])
status ListingStatus @default(PENDING)
approvedAt DateTime?
approvedById String?
approvedBy User? @relation("ListingApprovedBy", fields: [approvedById], references: [id])
rejectedAt DateTime?
rejectedById String?
rejectedReason String?
removedAt DateTime?
removedById String?
removedReason String?
country String
region String
city String
streetAddress String?
addressNote String?
latitude Float?
longitude Float?
maxGuests Int
bedrooms Int
beds Int
bathrooms Int
hasSauna Boolean @default(false)
hasFireplace Boolean @default(false)
hasWifi Boolean @default(false)
petsAllowed Boolean @default(false)
byTheLake Boolean @default(false)
hasAirConditioning Boolean @default(false)
evCharging EvCharging @default(NONE)
priceHintPerNightCents Int?
contactName String
contactEmail String
contactPhone String?
externalUrl String?
published Boolean @default(true)
translations ListingTranslation[]
images ListingImage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ListingTranslation {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id])
locale String
title String
slug String
description String
teaser String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([listingId, locale])
@@unique([slug, locale])
}
model ListingImage {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id])
url String
altText String?
isCover Boolean @default(false)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VerificationToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
token String @unique
type String
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}

166
prisma/seed.js Normal file
View file

@ -0,0 +1,166 @@
/* eslint-disable no-console */
const path = require('path');
const fs = require('fs');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
if (fs.existsSync(path.join(__dirname, '..', 'creds', '.env'))) {
require('dotenv').config({ path: path.join(__dirname, '..', 'creds', '.env') });
}
const bcrypt = require('bcryptjs');
const { PrismaClient, Role, UserStatus, ListingStatus } = require('@prisma/client');
const { PrismaPg } = require('@prisma/adapter-pg');
const { Pool } = require('pg');
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL is not set; cannot seed.');
process.exit(1);
}
const prisma = new PrismaClient({
adapter: new PrismaPg(new Pool({ connectionString: process.env.DATABASE_URL })),
});
const SAMPLE_SLUG = 'saimaa-lakeside-cabin';
const DEFAULT_LOCALE = 'en';
const SAMPLE_EMAIL = 'host@lomavuokraus.fi';
async function main() {
const adminEmail = process.env.ADMIN_EMAIL;
const adminPassword = process.env.ADMIN_INITIAL_PASSWORD;
if (!adminEmail || !adminPassword) {
console.warn('ADMIN_EMAIL or ADMIN_INITIAL_PASSWORD missing; admin user will not be seeded.');
}
let adminUser = null;
if (adminEmail && adminPassword) {
const adminHash = await bcrypt.hash(adminPassword, 12);
adminUser = await prisma.user.upsert({
where: { email: adminEmail },
update: {
passwordHash: adminHash,
role: Role.ADMIN,
status: UserStatus.ACTIVE,
emailVerifiedAt: new Date(),
approvedAt: new Date(),
},
create: {
email: adminEmail,
passwordHash: adminHash,
role: Role.ADMIN,
status: UserStatus.ACTIVE,
emailVerifiedAt: new Date(),
approvedAt: new Date(),
},
});
}
const sampleHostHash = await bcrypt.hash('changeme-sample', 12);
const existing = await prisma.listingTranslation.findFirst({ where: { slug: SAMPLE_SLUG } });
if (existing) {
console.log('Sample listing already exists, skipping seed.');
return;
}
const owner = await prisma.user.upsert({
where: { email: SAMPLE_EMAIL },
update: { name: 'Sample Host', role: 'USER', passwordHash: sampleHostHash, status: UserStatus.ACTIVE, emailVerifiedAt: new Date(), approvedAt: new Date() },
create: {
email: SAMPLE_EMAIL,
name: 'Sample Host',
role: 'USER',
passwordHash: sampleHostHash,
status: UserStatus.ACTIVE,
emailVerifiedAt: new Date(),
approvedAt: new Date(),
},
});
const listing = await prisma.listing.create({
data: {
ownerId: owner.id,
status: ListingStatus.PUBLISHED,
approvedAt: new Date(),
approvedById: adminUser ? adminUser.id : owner.id,
country: 'Finland',
region: 'South Karelia',
city: 'Punkaharju',
streetAddress: 'Saimaan rantatie 12',
addressNote: 'Lakeside trail, 5 min from main road',
latitude: 61.756,
longitude: 29.328,
maxGuests: 6,
bedrooms: 3,
beds: 4,
bathrooms: 1,
hasSauna: true,
hasFireplace: true,
hasWifi: true,
petsAllowed: false,
byTheLake: true,
hasAirConditioning: false,
evCharging: 'FREE',
priceHintPerNightCents: 14500,
contactName: 'Sample Host',
contactEmail: SAMPLE_EMAIL,
contactPhone: '+358401234567',
externalUrl: 'https://example.com/saimaa-cabin',
published: true,
images: {
createMany: {
data: [
{
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
altText: 'Lakeside cabin with sauna',
isCover: true,
order: 1,
},
{
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80',
altText: 'Wood-fired sauna by the lake',
order: 2,
},
{
url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80',
altText: 'Living area with fireplace',
order: 3,
},
],
},
},
},
});
await prisma.listingTranslation.createMany({
data: [
{
listingId: listing.id,
locale: DEFAULT_LOCALE,
slug: SAMPLE_SLUG,
title: 'Saimaa lakeside cabin with sauna',
description:
'Classic timber cabin right on Lake Saimaa. Wood-fired sauna, private dock, and a short forest walk to the nearest village. Perfect for slow weekends and midsummer gatherings.',
teaser: 'Sauna, lake view, private dock, and cozy fireplace.',
},
{
listingId: listing.id,
locale: 'fi',
slug: SAMPLE_SLUG,
title: 'Saimaan rantamökki saunalla',
description:
'Perinteinen hirsimökki Saimaan rannalla. Puusauna, oma laituri ja lyhyt metsäreitti kylään. Sopii täydellisesti viikonloppuihin ja juhannukseen.',
teaser: 'Puusauna, järvinäköala, oma laituri ja takka.',
},
],
});
console.log('Seeded sample listing at slug:', SAMPLE_SLUG);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

2
public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-agent: *
Allow: /

View file

@ -0,0 +1,48 @@
/* Reset admin password for thallaa@gmail.com to a known value.
Usage: node scripts/reset-admin-password.js
*/
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const { PrismaClient, Role, UserStatus } = require('@prisma/client');
const { PrismaPg } = require('@prisma/adapter-pg');
const { Pool } = require('pg');
const bcrypt = require('bcryptjs');
async function main() {
const email = process.env.ADMIN_EMAIL || 'thallaa@gmail.com';
const newPassword = process.env.ADMIN_INITIAL_PASSWORD;
if (!newPassword) throw new Error('ADMIN_INITIAL_PASSWORD not set in env');
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL not set');
const prisma = new PrismaClient({
adapter: new PrismaPg(new Pool({ connectionString: process.env.DATABASE_URL })),
});
const hash = await bcrypt.hash(newPassword, 12);
const user = await prisma.user.upsert({
where: { email },
update: {
passwordHash: hash,
role: Role.ADMIN,
status: UserStatus.ACTIVE,
emailVerifiedAt: new Date(),
approvedAt: new Date(),
},
create: {
email,
passwordHash: hash,
role: Role.ADMIN,
status: UserStatus.ACTIVE,
emailVerifiedAt: new Date(),
approvedAt: new Date(),
},
});
console.log('Password reset for', user.email);
await prisma.$disconnect();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

35
tsconfig.json Normal file
View file

@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}