From 4c05b0628ee9675a70d922624253f8217d7628b3 Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Mon, 24 Nov 2025 17:15:20 +0200 Subject: [PATCH] feat: enhance listings browsing and amenities --- .dockerignore | 13 + .env.example | 9 + .eslintrc.json | 6 + .gitignore | 37 + Dockerfile | 38 + app/admin/pending/page.tsx | 170 + app/admin/users/page.tsx | 196 + app/api/admin/listings/approve/route.ts | 52 + app/api/admin/pending/count/route.ts | 30 + app/api/admin/pending/route.ts | 44 + app/api/admin/users/approve/route.ts | 50 + app/api/admin/users/reject/route.ts | 39 + app/api/admin/users/remove/route.ts | 42 + app/api/admin/users/role/route.ts | 32 + app/api/admin/users/route.ts | 34 + app/api/auth/login/route.ts | 50 + app/api/auth/logout/route.ts | 8 + app/api/auth/me/route.ts | 17 + app/api/auth/register/route.ts | 59 + app/api/auth/verify/route.ts | 39 + app/api/health/route.ts | 7 + app/api/listings/mine/route.ts | 22 + app/api/listings/remove/route.ts | 54 + app/api/listings/route.ts | 195 + app/api/me/route.ts | 39 + app/auth/login/page.tsx | 65 + app/auth/register/page.tsx | 67 + app/components/I18nProvider.tsx | 45 + app/components/NavBar.tsx | 126 + app/globals.css | 348 + app/layout.tsx | 26 + app/listings/[slug]/page.tsx | 100 + app/listings/mine/page.tsx | 119 + app/listings/new/page.tsx | 277 + app/listings/page.tsx | 403 + app/me/page.tsx | 109 + app/page.tsx | 171 + app/verify/page.tsx | 35 + deploy/build.sh | 33 + deploy/deploy-prod.sh | 16 + deploy/deploy-staging.sh | 16 + deploy/deploy.sh | 36 + deploy/env.sh | 19 + deploy/push.sh | 23 + deploy/rollback-prod.sh | 13 + k8s/app.yaml | 110 + k8s/cert-issuers.yaml | 29 + k8s/deployment.yaml | 47 + k8s/namespaces.yaml | 9 + lib/auth.ts | 17 + lib/i18n.ts | 364 + lib/jwt.ts | 56 + lib/listings.ts | 59 + lib/mailer.ts | 80 + lib/prisma.ts | 19 + lib/sampleListing.ts | 2 + lib/tokens.ts | 11 + next-env.d.ts | 5 + next.config.mjs | 9 + package-lock.json | 7826 +++++++++++++++++ package.json | 41 + prisma.config.ts | 14 + .../20251122192713_init_schema/migration.sql | 91 + .../migration.sql | 41 + .../migration.sql | 10 + .../migration.sql | 28 + .../20251124170753_aircon_cover/migration.sql | 3 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 142 + prisma/seed.js | 166 + public/robots.txt | 2 + scripts/reset-admin-password.js | 48 + tsconfig.json | 35 + 73 files changed, 12596 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/admin/pending/page.tsx create mode 100644 app/admin/users/page.tsx create mode 100644 app/api/admin/listings/approve/route.ts create mode 100644 app/api/admin/pending/count/route.ts create mode 100644 app/api/admin/pending/route.ts create mode 100644 app/api/admin/users/approve/route.ts create mode 100644 app/api/admin/users/reject/route.ts create mode 100644 app/api/admin/users/remove/route.ts create mode 100644 app/api/admin/users/role/route.ts create mode 100644 app/api/admin/users/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/me/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/auth/verify/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/api/listings/mine/route.ts create mode 100644 app/api/listings/remove/route.ts create mode 100644 app/api/listings/route.ts create mode 100644 app/api/me/route.ts create mode 100644 app/auth/login/page.tsx create mode 100644 app/auth/register/page.tsx create mode 100644 app/components/I18nProvider.tsx create mode 100644 app/components/NavBar.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/listings/[slug]/page.tsx create mode 100644 app/listings/mine/page.tsx create mode 100644 app/listings/new/page.tsx create mode 100644 app/listings/page.tsx create mode 100644 app/me/page.tsx create mode 100644 app/page.tsx create mode 100644 app/verify/page.tsx create mode 100755 deploy/build.sh create mode 100755 deploy/deploy-prod.sh create mode 100755 deploy/deploy-staging.sh create mode 100755 deploy/deploy.sh create mode 100755 deploy/env.sh create mode 100755 deploy/push.sh create mode 100755 deploy/rollback-prod.sh create mode 100644 k8s/app.yaml create mode 100644 k8s/cert-issuers.yaml create mode 100644 k8s/deployment.yaml create mode 100644 k8s/namespaces.yaml create mode 100644 lib/auth.ts create mode 100644 lib/i18n.ts create mode 100644 lib/jwt.ts create mode 100644 lib/listings.ts create mode 100644 lib/mailer.ts create mode 100644 lib/prisma.ts create mode 100644 lib/sampleListing.ts create mode 100644 lib/tokens.ts create mode 100644 next-env.d.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/20251122192713_init_schema/migration.sql create mode 100644 prisma/migrations/20251122200543_auth_and_status/migration.sql create mode 100644 prisma/migrations/20251123224119_add_moderator_roles/migration.sql create mode 100644 prisma/migrations/20251124135258_soft_delete_flags/migration.sql create mode 100644 prisma/migrations/20251124170753_aircon_cover/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.js create mode 100644 public/robots.txt create mode 100644 scripts/reset-admin-password.js create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0e7560e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +npm-debug.log* +Dockerfile +.dockerignore +.git +.gitignore +.next +.next/cache +out +dist +.env* +!.env.example +coverage diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a4b92f9 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..2bad790 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": ["next/core-web-vitals"], + "rules": { + "@next/next/no-img-element": "off" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8af99a7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b5c5d7a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/admin/pending/page.tsx b/app/admin/pending/page.tsx new file mode 100644 index 0000000..d7360ec --- /dev/null +++ b/app/admin/pending/page.tsx @@ -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([]); + const [pendingListings, setPendingListings] = useState([]); + const [message, setMessage] = useState(null); + const [error, setError] = useState(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 ( +
+

{t('pendingAdminTitle')}

+
+
+

{t('pendingUsersTitle')}

+ {pendingUsers.length === 0 ? ( +

{t('noPendingUsers')}

+ ) : ( +
    + {pendingUsers.map((u) => ( +
  • +
    + {u.email} — {t('statusLabel')}: {u.status} — {t('verifiedLabel')}: {u.emailVerifiedAt ? t('yes') : t('no')} +
    +
    + + + +
    +
  • + ))} +
+ )} +
+
+

{t('pendingListingsTitle')}

+ {pendingListings.length === 0 ? ( +

{t('noPendingListings')}

+ ) : ( +
    + {pendingListings.map((l) => ( +
  • +
    + {l.translations[0]?.title ?? 'Listing'} — owner: {l.owner.email} +
    +
    + {t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')} +
    +
    + + + +
    +
  • + ))} +
+ )} +
+
+ {message ?

{message}

: null} + {error ?

{error}

: null} +
+ ); +} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx new file mode 100644 index 0000000..8e11aa6 --- /dev/null +++ b/app/admin/users/page.tsx @@ -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([]); + const [error, setError] = useState(null); + const [message, setMessage] = useState(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 ( +
+

{t('adminUsersTitle')}

+

{t('adminUsersLead')}

+ {message ?

{message}

: null} + {error ?

{error}

: null} + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + ))} + +
{t('tableEmail')}{t('tableRole')}{t('tableStatus')}{t('tableVerified')}{t('tableApproved')}Actions
{u.email} + + {u.status}{u.emailVerifiedAt ? 'yes' : 'no'}{u.approvedAt ? 'yes' : 'no'} +
+ {u.approvedAt ? null : ( + + )} + + +
+
+
+ ); +} diff --git a/app/api/admin/listings/approve/route.ts b/app/api/admin/listings/approve/route.ts new file mode 100644 index 0000000..3835b34 --- /dev/null +++ b/app/api/admin/listings/approve/route.ts @@ -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'; diff --git a/app/api/admin/pending/count/route.ts b/app/api/admin/pending/count/route.ts new file mode 100644 index 0000000..be5596e --- /dev/null +++ b/app/api/admin/pending/count/route.ts @@ -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'; diff --git a/app/api/admin/pending/route.ts b/app/api/admin/pending/route.ts new file mode 100644 index 0000000..14bea9c --- /dev/null +++ b/app/api/admin/pending/route.ts @@ -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'; diff --git a/app/api/admin/users/approve/route.ts b/app/api/admin/users/approve/route.ts new file mode 100644 index 0000000..b77f360 --- /dev/null +++ b/app/api/admin/users/approve/route.ts @@ -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'; diff --git a/app/api/admin/users/reject/route.ts b/app/api/admin/users/reject/route.ts new file mode 100644 index 0000000..0f3ebd9 --- /dev/null +++ b/app/api/admin/users/reject/route.ts @@ -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'; diff --git a/app/api/admin/users/remove/route.ts b/app/api/admin/users/remove/route.ts new file mode 100644 index 0000000..44c59d6 --- /dev/null +++ b/app/api/admin/users/remove/route.ts @@ -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'; diff --git a/app/api/admin/users/role/route.ts b/app/api/admin/users/role/route.ts new file mode 100644 index 0000000..4caa5ed --- /dev/null +++ b/app/api/admin/users/role/route.ts @@ -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'; diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000..b0a8c8f --- /dev/null +++ b/app/api/admin/users/route.ts @@ -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'; diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..235bae5 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -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; + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..d195693 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -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; +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..a2816bc --- /dev/null +++ b/app/api/auth/me/route.ts @@ -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 }); + } +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..2cb520f --- /dev/null +++ b/app/api/auth/register/route.ts @@ -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 }); + } +} diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts new file mode 100644 index 0000000..933e91e --- /dev/null +++ b/app/api/auth/verify/route.ts @@ -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 }); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..ab3ad80 --- /dev/null +++ b/app/api/health/route.ts @@ -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() }); +} diff --git a/app/api/listings/mine/route.ts b/app/api/listings/mine/route.ts new file mode 100644 index 0000000..683a165 --- /dev/null +++ b/app/api/listings/mine/route.ts @@ -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 }); + } +} diff --git a/app/api/listings/remove/route.ts b/app/api/listings/remove/route.ts new file mode 100644 index 0000000..cdc8f19 --- /dev/null +++ b/app/api/listings/remove/route.ts @@ -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'; diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts new file mode 100644 index 0000000..e5c2ffd --- /dev/null +++ b/app/api/listings/route.ts @@ -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(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 }); + } +} diff --git a/app/api/me/route.ts b/app/api/me/route.ts new file mode 100644 index 0000000..8fddd36 --- /dev/null +++ b/app/api/me/route.ts @@ -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'; diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 0000000..961ffc5 --- /dev/null +++ b/app/auth/login/page.tsx @@ -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(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 ( +
+

{t('loginTitle')}

+
+ + + +
+ {success ?

{t('loginSuccess')}

: null} + {error ?

{error}

: null} +
+ ); +} diff --git a/app/auth/register/page.tsx b/app/auth/register/page.tsx new file mode 100644 index 0000000..a24159f --- /dev/null +++ b/app/auth/register/page.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+

{t('registerTitle')}

+

{t('registerLead')}

+
+ + + + +
+ {message ?

{message}

: null} + {error ?

{error}

: null} +
+ ); +} diff --git a/app/components/I18nProvider.tsx b/app/components/I18nProvider.tsx new file mode 100644 index 0000000..6a92065 --- /dev/null +++ b/app/components/I18nProvider.tsx @@ -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; +}; + +const I18nContext = createContext(null); + +export function I18nProvider({ children }: { children: React.ReactNode }) { + const [locale, setLocale] = useState(() => { + 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) => translate(locale, key, vars) as string, + }), + [locale], + ); + + return {children}; +} + +export function useI18n() { + const ctx = useContext(I18nContext); + if (!ctx) { + throw new Error('useI18n must be used inside I18nProvider'); + } + return ctx; +} diff --git a/app/components/NavBar.tsx b/app/components/NavBar.tsx new file mode 100644 index 0000000..c9e0dd4 --- /dev/null +++ b/app/components/NavBar.tsx @@ -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(null); + const [pendingCount, setPendingCount] = useState(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 ( +
+
+ + {t('brand')} + + + {t('navBrowse')} + +
+ +
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..2183c36 --- /dev/null +++ b/app/globals.css @@ -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; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..8eb783a --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + {children} + + + + ); +} diff --git a/app/listings/[slug]/page.tsx b/app/listings/[slug]/page.tsx new file mode 100644 index 0000000..1ba9142 --- /dev/null +++ b/app/listings/[slug]/page.tsx @@ -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 { + 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) => 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 ( +
+
+ {t('homeCrumb')} / {params.slug} +
+
+

{title}

+

{teaser ?? description}

+
+ {t('listingAddress')}: {listing.streetAddress ? `${listing.streetAddress}, ` : ''} + {listing.city}, {listing.region}, {listing.country} +
+ {listing.addressNote ? ( +
+ {listing.addressNote} +
+ ) : null} +
+ {t('listingCapacity')}: {t('capacityGuests', { count: listing.maxGuests })} - {t('capacityBedrooms', { count: listing.bedrooms })} -{' '} + {t('capacityBeds', { count: listing.beds })} - {t('capacityBathrooms', { count: listing.bathrooms })} +
+
+ {t('listingAmenities')}:{' '} + {[ + 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(', ') || '-'} +
+
+ {t('listingContact')}: {listing.contactName} - {listing.contactEmail} + {listing.contactPhone ? ` - ${listing.contactPhone}` : ''} + {listing.externalUrl ? ( + <> + {' - '} + + {t('listingMoreInfo')} + + + ) : null} +
+ {listing.images.length > 0 ? ( +
+ {listing.images.map((img) => ( +
+ {img.altText + {img.altText ? ( +
{img.altText}
+ ) : null} +
+ ))} +
+ ) : null} +
+ {t('localeLabel')}: {translationLocale} +
+
+
+ ); +} diff --git a/app/listings/mine/page.tsx b/app/listings/mine/page.tsx new file mode 100644 index 0000000..b4c9581 --- /dev/null +++ b/app/listings/mine/page.tsx @@ -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([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(null); + const [actionId, setActionId] = useState(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 ( +
+

{t('myListingsTitle')}

+

{t('loading')}

+
+ ); + } + + if (error) { + return ( +
+

{t('myListingsTitle')}

+

{error}

+
+ ); + } + + return ( +
+

{t('myListingsTitle')}

+ {message ?

{message}

: null} +
+ + {t('createNewListing')} + +
+ {listings.length === 0 ? ( +

+ {t('noListings')}{' '} + + {t('createOne')} + + . +

+ ) : ( +
    + {listings.map((l) => ( +
  • +
    + {l.translations[0]?.title ?? 'Listing'} — {t('statusLabel')}: {l.status} +
    +
    + {t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')} +
    +
    + + {t('view')} + + +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/listings/new/page.tsx b/app/listings/new/page.tsx new file mode 100644 index 0000000..1265e4c --- /dev/null +++ b/app/listings/new/page.tsx @@ -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(''); + const [longitude, setLongitude] = useState(''); + 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(''); + 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(null); + const [error, setError] = useState(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 ( +
+

{t('createListingTitle')}

+

{t('loginToCreate')}

+
+ ); + } + + return ( +
+

{t('createListingTitle')}

+
+ + + +