feat: enhance listings browsing and amenities
This commit is contained in:
commit
4c05b0628e
73 changed files with 12596 additions and 0 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
9
.env.example
Normal 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
6
.eslintrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals"],
|
||||||
|
"rules": {
|
||||||
|
"@next/next/no-img-element": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal 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
38
Dockerfile
Normal 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
170
app/admin/pending/page.tsx
Normal 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
196
app/admin/users/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
app/api/admin/listings/approve/route.ts
Normal file
52
app/api/admin/listings/approve/route.ts
Normal 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';
|
||||||
30
app/api/admin/pending/count/route.ts
Normal file
30
app/api/admin/pending/count/route.ts
Normal 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';
|
||||||
44
app/api/admin/pending/route.ts
Normal file
44
app/api/admin/pending/route.ts
Normal 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';
|
||||||
50
app/api/admin/users/approve/route.ts
Normal file
50
app/api/admin/users/approve/route.ts
Normal 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';
|
||||||
39
app/api/admin/users/reject/route.ts
Normal file
39
app/api/admin/users/reject/route.ts
Normal 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';
|
||||||
42
app/api/admin/users/remove/route.ts
Normal file
42
app/api/admin/users/remove/route.ts
Normal 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';
|
||||||
32
app/api/admin/users/role/route.ts
Normal file
32
app/api/admin/users/role/route.ts
Normal 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';
|
||||||
34
app/api/admin/users/route.ts
Normal file
34
app/api/admin/users/route.ts
Normal 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';
|
||||||
50
app/api/auth/login/route.ts
Normal file
50
app/api/auth/login/route.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/api/auth/logout/route.ts
Normal file
8
app/api/auth/logout/route.ts
Normal 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
17
app/api/auth/me/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/api/auth/register/route.ts
Normal file
59
app/api/auth/register/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/api/auth/verify/route.ts
Normal file
39
app/api/auth/verify/route.ts
Normal 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
7
app/api/health/route.ts
Normal 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() });
|
||||||
|
}
|
||||||
22
app/api/listings/mine/route.ts
Normal file
22
app/api/listings/mine/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/listings/remove/route.ts
Normal file
54
app/api/listings/remove/route.ts
Normal 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
195
app/api/listings/route.ts
Normal 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
39
app/api/me/route.ts
Normal 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
65
app/auth/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
app/auth/register/page.tsx
Normal file
67
app/auth/register/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/components/I18nProvider.tsx
Normal file
45
app/components/I18nProvider.tsx
Normal 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
126
app/components/NavBar.tsx
Normal 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
348
app/globals.css
Normal 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
26
app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
app/listings/[slug]/page.tsx
Normal file
100
app/listings/[slug]/page.tsx
Normal 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
119
app/listings/mine/page.tsx
Normal 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
277
app/listings/new/page.tsx
Normal 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
403
app/listings/page.tsx
Normal 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: '© 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
109
app/me/page.tsx
Normal 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
171
app/page.tsx
Normal 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
35
app/verify/page.tsx
Normal 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
33
deploy/build.sh
Executable 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
16
deploy/deploy-prod.sh
Executable 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
16
deploy/deploy-staging.sh
Executable 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
36
deploy/deploy.sh
Executable 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
19
deploy/env.sh
Executable 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
23
deploy/push.sh
Executable 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
13
deploy/rollback-prod.sh
Executable 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
110
k8s/app.yaml
Normal 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
29
k8s/cert-issuers.yaml
Normal 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
47
k8s/deployment.yaml
Normal 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
9
k8s/namespaces.yaml
Normal 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
17
lib/auth.ts
Normal 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
364
lib/i18n.ts
Normal 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
56
lib/jwt.ts
Normal 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
59
lib/listings.ts
Normal 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
80
lib/mailer.ts
Normal 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
19
lib/prisma.ts
Normal 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
2
lib/sampleListing.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const SAMPLE_LISTING_SLUG = 'saimaa-lakeside-cabin';
|
||||||
|
export const DEFAULT_LOCALE = 'en';
|
||||||
11
lib/tokens.ts
Normal file
11
lib/tokens.ts
Normal 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
5
next-env.d.ts
vendored
Normal 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
9
next.config.mjs
Normal 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
7826
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
41
package.json
Normal file
41
package.json
Normal 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
14
prisma.config.ts
Normal 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"),
|
||||||
|
},
|
||||||
|
});
|
||||||
91
prisma/migrations/20251122192713_init_schema/migration.sql
Normal file
91
prisma/migrations/20251122192713_init_schema/migration.sql
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
142
prisma/schema.prisma
Normal 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
166
prisma/seed.js
Normal 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
2
public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
48
scripts/reset-admin-password.js
Normal file
48
scripts/reset-admin-password.js
Normal 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
35
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue