chore: fix audit alerts and formatting
Some checks failed
CI / checks (push) Has been cancelled

This commit is contained in:
Tero Halla-aho 2026-02-04 12:43:03 +02:00
parent 23b18c75bd
commit 0bb709d9c5
102 changed files with 8408 additions and 5455 deletions

View file

@ -11,7 +11,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
- run: npm ci - run: npm ci
- run: npm run lint - run: npm run lint
- run: npm run type-check - run: npm run type-check

View file

@ -5,4 +5,4 @@ creation_rules:
- age: - age:
- age1hkehkc2rryjl975c2mg5cghmjr54n4wjshncl292h2eg5l394fhs4uydrh - age1hkehkc2rryjl975c2mg5cghmjr54n4wjshncl292h2eg5l394fhs4uydrh
- age1ducvqxdzdhhluftu5hv4f2xsppmn803uh8tnnqj92v4n7nf6lprq9h3dqp - age1ducvqxdzdhhluftu5hv4f2xsppmn803uh8tnnqj92v4n7nf6lprq9h3dqp
encrypted_regex: '^(AUTH_SECRET|DATABASE_URL|DB_.*|APP_URL|SMTP_.*|DKIM_.*|AUTO_APPROVE_LISTINGS|OPENAI_.*|N8N_BILLING_API_KEY|H(ETZNER|CLOUD)_TOKEN|JOKER_DYNDNS_.*|REGISTRY_.*|NETDATA_.*|ADMIN_.*)$' encrypted_regex: "^(AUTH_SECRET|DATABASE_URL|DB_.*|APP_URL|SMTP_.*|DKIM_.*|AUTO_APPROVE_LISTINGS|OPENAI_.*|N8N_BILLING_API_KEY|H(ETZNER|CLOUD)_TOKEN|JOKER_DYNDNS_.*|REGISTRY_.*|NETDATA_.*|ADMIN_.*)$"

View file

@ -8,6 +8,7 @@
- Always assume work should continue on a feature or fix branch; if unsure whether to reuse a previous branch (which might already be pushed and deleted), ask before proceeding. - Always assume work should continue on a feature or fix branch; if unsure whether to reuse a previous branch (which might already be pushed and deleted), ask before proceeding.
## Ongoing reminders ## Ongoing reminders
- If needed, render diagrams locally from `docs/plantuml` or `docs/drawio` for reference. - If needed, render diagrams locally from `docs/plantuml` or `docs/drawio` for reference.
- Keep registry health in mind; pushes now work but monitor for regressions. - Keep registry health in mind; pushes now work but monitor for regressions.
- Future app work ideas: polish translations, add listing fields, expand admin tooling, or harden registry. - Future app work ideas: polish translations, add listing fields, expand admin tooling, or harden registry.

View file

@ -26,6 +26,7 @@
- `creds/` and `k3s.yaml` are git-ignored; contains joker DYNDNS creds and registry auth. - `creds/` and `k3s.yaml` are git-ignored; contains joker DYNDNS creds and registry auth.
## 2025-11-24 — Lomavuokraus app progress ## 2025-11-24 — Lomavuokraus app progress
- New testing DB (`lomavuokraus_testing`) holds the previous staging/prod data; the main `lomavuokraus` DB was recreated clean with only the seeded admin user. Migration history was copied, and a schema snapshot lives at `docs/db-schema.sql`. - New testing DB (`lomavuokraus_testing`) holds the previous staging/prod data; the main `lomavuokraus` DB was recreated clean with only the seeded admin user. Migration history was copied, and a schema snapshot lives at `docs/db-schema.sql`.
- Testing environment wiring added: dedicated namespace (`lomavuokraus-test`), deploy wrapper (`deploy/deploy-test.sh`), API host support, and a DNS updater for `test.lomavuokraus.fi` / `apitest.lomavuokraus.fi`. - Testing environment wiring added: dedicated namespace (`lomavuokraus-test`), deploy wrapper (`deploy/deploy-test.sh`), API host support, and a DNS updater for `test.lomavuokraus.fi` / `apitest.lomavuokraus.fi`.
- Access control tightened: middleware now gates admin routes, admin-only pages check session/role, API handlers return proper 401/403, and listing removal is limited to owners/admins (no more moderator overrides). - Access control tightened: middleware now gates admin routes, admin-only pages check session/role, API handlers return proper 401/403, and listing removal is limited to owners/admins (no more moderator overrides).
@ -41,6 +42,7 @@
- Security: `npm audit --audit-level=high` runs in build (warnings only). Trivy scan run; remaining CVEs mostly in tooling (cross-spawn, glob) and base OS Debian 12.10. Further reduction would require eslint-config-next 16.x and base image updates when available. - Security: `npm audit --audit-level=high` runs in build (warnings only). Trivy scan run; remaining CVEs mostly in tooling (cross-spawn, glob) and base OS Debian 12.10. Further reduction would require eslint-config-next 16.x and base image updates when available.
## 2025-11-24 — Recent changes ## 2025-11-24 — Recent changes
- Public browse/search page with map, address filters, and EV charging amenity; listings now store street address and geocoordinates. - Public browse/search page with map, address filters, and EV charging amenity; listings now store street address and geocoordinates.
- Amenities expanded: electric vehicle charging (free/paid) and air conditioning; cover image selectable per listing and used in cards. - Amenities expanded: electric vehicle charging (free/paid) and air conditioning; cover image selectable per listing and used in cards.
- Home page shows a rolling feed of latest listings; navbar + CTA link to browse. - Home page shows a rolling feed of latest listings; navbar + CTA link to browse.
@ -71,6 +73,7 @@
- Mermaid docs fixed: all sequence diagrams declare their participants and avoid “->” inside message text; the listing creation diagram message was rewritten to prevent parse errors. Use mermaid.live or browser console to debug future syntax issues (errors flag the offending line/column). - Mermaid docs fixed: all sequence diagrams declare their participants and avoid “->” inside message text; the listing creation diagram message was rewritten to prevent parse errors. Use mermaid.live or browser console to debug future syntax issues (errors flag the offending line/column).
## 2025-11-27 — Availability & filters ## 2025-11-27 — Availability & filters
- Availability calendars: listings can store iCal URLs, merged into a combined availability calendar on detail pages; availability filtering added to search along with amenity filters; new migration `20251127_calendar_urls`. - Availability calendars: listings can store iCal URLs, merged into a combined availability calendar on detail pages; availability filtering added to search along with amenity filters; new migration `20251127_calendar_urls`.
- Browse amenity filters now show the same icons as listing detail; image `registry.halla-aho.net/thalla/lomavuokraus-web:e95d9e0` built/pushed and rolled out to staging. - Browse amenity filters now show the same icons as listing detail; image `registry.halla-aho.net/thalla/lomavuokraus-web:e95d9e0` built/pushed and rolled out to staging.
- Home hero cleaned up (removed sample/browse CTAs), hero FI text updated, and health check link moved to About page runtime section. - Home hero cleaned up (removed sample/browse CTAs), hero FI text updated, and health check link moved to About page runtime section.
@ -82,10 +85,12 @@
- Security hardening: npm audit now passes cleanly after upgrading Prisma patch release and pinning `glob@10.5.0` via overrides to eliminate the glob CLI injection advisory in eslint tooling. - Security hardening: npm audit now passes cleanly after upgrading Prisma patch release and pinning `glob@10.5.0` via overrides to eliminate the glob CLI injection advisory in eslint tooling.
## 2025-12-06 — Pricing & amenities ## 2025-12-06 — Pricing & amenities
- Listings now capture separate weekday/weekend prices and new amenities (microwave, free parking) across schema, API, UI, and seeds. - Listings now capture separate weekday/weekend prices and new amenities (microwave, free parking) across schema, API, UI, and seeds.
- Deployed pricing/amenity update image `registry.halla-aho.net/thalla/lomavuokraus-web:bee691e` to staging and production. - Deployed pricing/amenity update image `registry.halla-aho.net/thalla/lomavuokraus-web:bee691e` to staging and production.
## 2025-12-17 — Accessibility & admin UX ## 2025-12-17 — Accessibility & admin UX
- New amenities added: kitchen, dishwasher, washing machine, barbecue; API/UI/i18n updated and seeds randomized to populate missing prices/amenities. Prisma migration `20250210_more_amenities` applied to shared DB; registry pull secret added to k8s Deployment to avoid image pull errors in prod. - New amenities added: kitchen, dishwasher, washing machine, barbecue; API/UI/i18n updated and seeds randomized to populate missing prices/amenities. Prisma migration `20250210_more_amenities` applied to shared DB; registry pull secret added to k8s Deployment to avoid image pull errors in prod.
- Added About and Pricing pages (FI/EN), moved highlights/runtime config to About, and linked footer navigation. - Added About and Pricing pages (FI/EN), moved highlights/runtime config to About, and linked footer navigation.
- Added site favicon generated from the updated logo (`public/favicon.ico`). - Added site favicon generated from the updated logo (`public/favicon.ico`).
@ -100,10 +105,14 @@
- User nav: consolidated profile/my listings/new listing/logout under an email dropdown with chevron; admin button also shows a chevron indicator. - User nav: consolidated profile/my listings/new listing/logout under an email dropdown with chevron; admin button also shows a chevron indicator.
## 2025-12-20 — Migration history repair ## 2025-12-20 — Migration history repair
- Restored missing migration `20251212_agent_billing` (agent billing columns + listing billing settings table) so Prisma history matches the DB. - Restored missing migration `20251212_agent_billing` (agent billing columns + listing billing settings table) so Prisma history matches the DB.
- Reconciled test DB migration history: aligned checksum for `20251212_agent_billing` and marked `20260310_site_settings` and `20260311_billing_preferences` as applied to stop Prisma errors and surface listings again. - Reconciled test DB migration history: aligned checksum for `20251212_agent_billing` and marked `20260310_site_settings` and `20260311_billing_preferences` as applied to stop Prisma errors and surface listings again.
- Applied the same agent billing schema to staging/prod DB (`lomavuokraus`) and marked the migration as applied; Prisma status now clean there too. - Applied the same agent billing schema to staging/prod DB (`lomavuokraus`) and marked the migration as applied; Prisma status now clean there too.
- Deploy script now runs a Prisma migration status preflight using DATABASE_URL from env or in-cluster secret and fails fast on drift before applying manifests. - Deploy script now runs a Prisma migration status preflight using DATABASE_URL from env or in-cluster secret and fails fast on drift before applying manifests.
## 2026-02-04 — Documentation ## 2026-02-04 — Documentation
- Expanded Logical Architecture doc with a Next.js App Router walkthrough: routing layout, server/client component split, data fetching patterns, mutations, auth middleware, and asset handling. - Expanded Logical Architecture doc with a Next.js App Router walkthrough: routing layout, server/client component split, data fetching patterns, mutations, auth middleware, and asset handling.
- Security/maintenance: bumped Next.js to 15.5.11, Prisma stack to 7.3.0, removed @types/nodemailer (local typings) to clear high npm audit alerts; `npm audit --audit-level=high` now passes.
- QA: fixed lint/type regressions from the upgrades (async headers/cookies, Link navigation), added local nodemailer typings, and formatted the repo with Prettier (`format:check` clean).

View file

@ -1,29 +1,31 @@
'use client'; "use client";
import Link from 'next/link'; import Link from "next/link";
import { useI18n } from '../components/I18nProvider'; import { useI18n } from "../components/I18nProvider";
const highlights = [ const highlights = [
{ keyTitle: 'highlightQualityTitle', keyBody: 'highlightQualityBody' }, { keyTitle: "highlightQualityTitle", keyBody: "highlightQualityBody" },
{ keyTitle: 'highlightLocalTitle', keyBody: 'highlightLocalBody' }, { keyTitle: "highlightLocalTitle", keyBody: "highlightLocalBody" },
{ keyTitle: 'highlightApiTitle', keyBody: 'highlightApiBody' }, { keyTitle: "highlightApiTitle", keyBody: "highlightApiBody" },
]; ];
export default function AboutPage() { export default function AboutPage() {
const { t } = useI18n(); const { t } = useI18n();
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
const apiBase = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:3000/api'; const apiBase =
const appEnv = process.env.APP_ENV || 'local'; process.env.NEXT_PUBLIC_API_BASE || "http://localhost:3000/api";
const appVersion = process.env.NEXT_PUBLIC_VERSION || 'dev'; const appEnv = process.env.APP_ENV || "local";
const appVersion = process.env.NEXT_PUBLIC_VERSION || "dev";
return ( return (
<main> <main>
<section className="panel"> <section className="panel">
<div className="breadcrumb"> <div className="breadcrumb">
<Link href="/">{t('homeCrumb')}</Link> / <span>{t('aboutTitle')}</span> <Link href="/">{t("homeCrumb")}</Link> /{" "}
<span>{t("aboutTitle")}</span>
</div> </div>
<h1>{t('aboutTitle')}</h1> <h1>{t("aboutTitle")}</h1>
<p style={{ marginTop: 8 }}>{t('aboutLead')}</p> <p style={{ marginTop: 8 }}>{t("aboutLead")}</p>
</section> </section>
<div className="cards" style={{ marginTop: 18 }}> <div className="cards" style={{ marginTop: 18 }}>
@ -36,25 +38,30 @@ export default function AboutPage() {
</div> </div>
<section className="panel env-card" style={{ marginTop: 18 }}> <section className="panel env-card" style={{ marginTop: 18 }}>
<h2 className="card-title">{t('runtimeConfigTitle')}</h2> <h2 className="card-title">{t("runtimeConfigTitle")}</h2>
<p style={{ marginTop: 4 }}>{t('runtimeConfigLead')}</p> <p style={{ marginTop: 4 }}>{t("runtimeConfigLead")}</p>
<div className="meta-grid"> <div className="meta-grid">
<span> <span>
<strong>{t('runtimeAppEnv')}</strong> <code>{appEnv}</code> <strong>{t("runtimeAppEnv")}</strong> <code>{appEnv}</code>
</span> </span>
<span> <span>
<strong>{t('runtimeSiteUrl')}</strong> <code>{siteUrl}</code> <strong>{t("runtimeSiteUrl")}</strong> <code>{siteUrl}</code>
</span> </span>
<span> <span>
<strong>{t('runtimeApiBase')}</strong> <code>{apiBase}</code> <strong>{t("runtimeApiBase")}</strong> <code>{apiBase}</code>
</span> </span>
<span> <span>
<strong>Version</strong> <code>{appVersion}</code> <strong>Version</strong> <code>{appVersion}</code>
</span> </span>
</div> </div>
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<a className="button secondary" href="/api/health" target="_blank" rel="noreferrer"> <a
{t('ctaHealth')} className="button secondary"
href="/api/health"
target="_blank"
rel="noreferrer"
>
{t("ctaHealth")}
</a> </a>
</div> </div>
</section> </section>

View file

@ -1,7 +1,7 @@
'use client'; "use client";
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from "react";
import { useI18n } from '../../components/I18nProvider'; import { useI18n } from "../../components/I18nProvider";
type HetznerServer = { type HetznerServer = {
id: number; id: number;
@ -15,7 +15,12 @@ type HetznerServer = {
}; };
type MonitorResponse = { type MonitorResponse = {
hetzner?: { ok: boolean; error?: string; missingToken?: boolean; servers?: HetznerServer[] }; hetzner?: {
ok: boolean;
error?: string;
missingToken?: boolean;
servers?: HetznerServer[];
};
k8s?: { k8s?: {
ok: boolean; ok: boolean;
error?: string; error?: string;
@ -42,16 +47,29 @@ type MonitorResponse = {
hostIP?: string | null; hostIP?: string | null;
podIP?: string | null; podIP?: string | null;
startedAt?: string | null; startedAt?: string | null;
containers: { name: string; ready: boolean; restartCount: number; state: string; lastState?: string | null }[]; containers: {
name: string;
ready: boolean;
restartCount: number;
state: string;
lastState?: string | null;
}[];
}[]; }[];
}; };
db?: { ok: boolean; error?: string; serverTime?: string; recovery?: boolean; databaseSizeBytes?: number; connections?: { state: string; count: number }[] }; db?: {
ok: boolean;
error?: string;
serverTime?: string;
recovery?: boolean;
databaseSizeBytes?: number;
connections?: { state: string; count: number }[];
};
}; };
const REFRESH_MS = 30000; const REFRESH_MS = 30000;
function formatBytes(bytes?: number) { function formatBytes(bytes?: number) {
if (!bytes || Number.isNaN(bytes)) return '—'; if (!bytes || Number.isNaN(bytes)) return "—";
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024; const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`; if (kb < 1024) return `${kb.toFixed(1)} KB`;
@ -62,12 +80,12 @@ function formatBytes(bytes?: number) {
} }
function formatDurationFrom(dateStr?: string | null) { function formatDurationFrom(dateStr?: string | null) {
if (!dateStr) return '—'; if (!dateStr) return "—";
const started = new Date(dateStr).getTime(); const started = new Date(dateStr).getTime();
const diff = Date.now() - started; const diff = Date.now() - started;
if (Number.isNaN(diff) || diff < 0) return '—'; if (Number.isNaN(diff) || diff < 0) return "—";
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
if (mins < 1) return '<1m'; if (mins < 1) return "<1m";
if (mins < 60) return `${mins}m`; if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60); const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`; if (hours < 24) return `${hours}h`;
@ -79,12 +97,12 @@ function statusPill(label: string, ok: boolean) {
return ( return (
<span <span
style={{ style={{
padding: '2px 8px', padding: "2px 8px",
borderRadius: 999, borderRadius: 999,
fontSize: 12, fontSize: 12,
background: ok ? '#e6f4ea' : '#fdecea', background: ok ? "#e6f4ea" : "#fdecea",
color: ok ? '#1b5e20' : '#c62828', color: ok ? "#1b5e20" : "#c62828",
border: `1px solid ${ok ? '#a5d6a7' : '#f5c6cb'}`, border: `1px solid ${ok ? "#a5d6a7" : "#f5c6cb"}`,
}} }}
> >
{label} {label}
@ -99,23 +117,26 @@ export default function MonitorPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<number | null>(null); const [lastUpdated, setLastUpdated] = useState<number | null>(null);
const hasData = useMemo(() => Boolean(data?.hetzner || data?.k8s || data?.db), [data]); const hasData = useMemo(
() => Boolean(data?.hetzner || data?.k8s || data?.db),
[data],
);
async function load() { async function load() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const res = await fetch('/api/admin/monitor', { cache: 'no-store' }); const res = await fetch("/api/admin/monitor", { cache: "no-store" });
const json = (await res.json()) as MonitorResponse & { error?: string }; const json = (await res.json()) as MonitorResponse & { error?: string };
if (!res.ok) { if (!res.ok) {
setError(json.error || t('monitorLoadFailed')); setError(json.error || t("monitorLoadFailed"));
setLoading(false); setLoading(false);
return; return;
} }
setData(json); setData(json);
setLastUpdated(Date.now()); setLastUpdated(Date.now());
} catch (err) { } catch (err) {
setError(t('monitorLoadFailed')); setError(t("monitorLoadFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -129,85 +150,188 @@ export default function MonitorPage() {
}, []); }, []);
return ( return (
<main className="panel" style={{ maxWidth: 1100, margin: '40px auto', display: 'grid', gap: 16 }}> <main
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', gap: 12 }}> className="panel"
style={{ maxWidth: 1100, margin: "40px auto", display: "grid", gap: 16 }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
gap: 12,
}}
>
<div> <div>
<h1>{t('monitorTitle')}</h1> <h1>{t("monitorTitle")}</h1>
<p style={{ margin: 0, color: '#555' }}>{t('monitorLead')}</p> <p style={{ margin: 0, color: "#555" }}>{t("monitorLead")}</p>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button className="button secondary" onClick={load} disabled={loading}> <button
{loading ? t('loading') : t('monitorRefresh')} className="button secondary"
onClick={load}
disabled={loading}
>
{loading ? t("loading") : t("monitorRefresh")}
</button> </button>
<span style={{ fontSize: 12, color: '#666' }}> <span style={{ fontSize: 12, color: "#666" }}>
{t('monitorLastUpdated')}: {lastUpdated ? new Date(lastUpdated).toLocaleTimeString() : '—'} {t("monitorLastUpdated")}:{" "}
{lastUpdated ? new Date(lastUpdated).toLocaleTimeString() : "—"}
</span> </span>
</div> </div>
</div> </div>
{error ? <p style={{ color: 'red', margin: 0 }}>{error}</p> : null} {error ? <p style={{ color: "red", margin: 0 }}>{error}</p> : null}
{!hasData && !loading ? <p style={{ margin: 0 }}>{t('monitorNoData')}</p> : null} {!hasData && !loading ? (
<p style={{ margin: 0 }}>{t("monitorNoData")}</p>
) : null}
<section style={{ border: '1px solid #eee', borderRadius: 12, padding: 16 }}> <section
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}> style={{ border: "1px solid #eee", borderRadius: 12, padding: 16 }}
<h2 style={{ margin: 0 }}>{t('monitorHetznerTitle')}</h2> >
{data?.hetzner?.ok ? statusPill(t('monitorHealthy'), true) : statusPill(t('monitorAttention'), false)} <div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 10,
}}
>
<h2 style={{ margin: 0 }}>{t("monitorHetznerTitle")}</h2>
{data?.hetzner?.ok
? statusPill(t("monitorHealthy"), true)
: statusPill(t("monitorAttention"), false)}
</div> </div>
{!data?.hetzner ? ( {!data?.hetzner ? (
<p>{t('monitorNoData')}</p> <p>{t("monitorNoData")}</p>
) : data.hetzner.missingToken ? ( ) : data.hetzner.missingToken ? (
<p style={{ color: '#c77c02' }}>{t('monitorHetznerMissingToken')}</p> <p style={{ color: "#c77c02" }}>{t("monitorHetznerMissingToken")}</p>
) : data.hetzner.ok && (data.hetzner.servers?.length ?? 0) > 0 ? ( ) : data.hetzner.ok && (data.hetzner.servers?.length ?? 0) > 0 ? (
<ul style={{ listStyle: 'none', padding: 0, display: 'grid', gap: 8 }}> <ul
style={{ listStyle: "none", padding: 0, display: "grid", gap: 8 }}
>
{data.hetzner.servers!.map((s) => ( {data.hetzner.servers!.map((s) => (
<li key={s.id} style={{ border: '1px solid #eee', borderRadius: 10, padding: 12, display: 'grid', gap: 6 }}> <li
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> key={s.id}
style={{
border: "1px solid #eee",
borderRadius: 10,
padding: 12,
display: "grid",
gap: 6,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div> <div>
<strong>{s.name}</strong> {s.type ?? 'server'} ({s.datacenter ?? 'dc'}) <strong>{s.name}</strong> {s.type ?? "server"} (
{s.datacenter ?? "dc"})
</div> </div>
{statusPill(s.status, s.status?.toLowerCase() === 'running')} {statusPill(s.status, s.status?.toLowerCase() === "running")}
</div> </div>
<div style={{ fontSize: 13, color: '#444', display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div
<span>Public IP: {s.publicIp ?? '—'}</span> style={{
<span>Private IP: {s.privateIp ?? '—'}</span> fontSize: 13,
<span>{t('monitorCreated')} {s.created ? new Date(s.created).toLocaleString() : '—'}</span> color: "#444",
display: "flex",
gap: 12,
flexWrap: "wrap",
}}
>
<span>Public IP: {s.publicIp ?? "—"}</span>
<span>Private IP: {s.privateIp ?? "—"}</span>
<span>
{t("monitorCreated")}{" "}
{s.created ? new Date(s.created).toLocaleString() : "—"}
</span>
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
) : ( ) : (
<p style={{ color: 'red' }}>{data.hetzner.error || t('monitorHetznerEmpty')}</p> <p style={{ color: "red" }}>
{data.hetzner.error || t("monitorHetznerEmpty")}
</p>
)} )}
</section> </section>
<section style={{ border: '1px solid #eee', borderRadius: 12, padding: 16, display: 'grid', gap: 12 }}> <section
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> style={{
<h2 style={{ margin: 0 }}>{t('monitorK8sTitle')}</h2> border: "1px solid #eee",
{data?.k8s?.ok ? statusPill(t('monitorHealthy'), true) : statusPill(t('monitorAttention'), false)} borderRadius: 12,
padding: 16,
display: "grid",
gap: 12,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2 style={{ margin: 0 }}>{t("monitorK8sTitle")}</h2>
{data?.k8s?.ok
? statusPill(t("monitorHealthy"), true)
: statusPill(t("monitorAttention"), false)}
</div> </div>
{!data?.k8s ? ( {!data?.k8s ? (
<p>{t('monitorNoData')}</p> <p>{t("monitorNoData")}</p>
) : !data.k8s.ok ? ( ) : !data.k8s.ok ? (
<p style={{ color: 'red', margin: 0 }}>{data.k8s.error ?? t('monitorLoadFailed')}</p> <p style={{ color: "red", margin: 0 }}>
{data.k8s.error ?? t("monitorLoadFailed")}
</p>
) : ( ) : (
<> <>
<div> <div>
<h3 style={{ marginBottom: 6 }}>{t('monitorNodesTitle')}</h3> <h3 style={{ marginBottom: 6 }}>{t("monitorNodesTitle")}</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 10 }}> <div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
gap: 10,
}}
>
{(data.k8s.nodes ?? []).map((n) => ( {(data.k8s.nodes ?? []).map((n) => (
<div key={n.name} style={{ border: '1px solid #eee', borderRadius: 10, padding: 10 }}> <div
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> key={n.name}
style={{
border: "1px solid #eee",
borderRadius: 10,
padding: 10,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div> <div>
<strong>{n.name}</strong> <strong>{n.name}</strong>
<div style={{ fontSize: 12, color: '#666' }}>{n.roles.length ? n.roles.join(', ') : 'node'}</div> <div style={{ fontSize: 12, color: "#666" }}>
{n.roles.length ? n.roles.join(", ") : "node"}
</div>
</div> </div>
{statusPill(n.status, n.ready)} {statusPill(n.status, n.ready)}
</div> </div>
<div style={{ fontSize: 13, color: '#444', marginTop: 6 }}> <div style={{ fontSize: 13, color: "#444", marginTop: 6 }}>
<div>IP: {n.internalIp ?? '—'}</div> <div>IP: {n.internalIp ?? "—"}</div>
<div>{n.kubeletVersion ?? 'kubelet'} · {n.osImage ?? ''}</div> <div>
<div style={{ color: '#777' }}> {n.kubeletVersion ?? "kubelet"} · {n.osImage ?? ""}
{t('monitorLastReady')}: {n.lastReadyTransition ? new Date(n.lastReadyTransition).toLocaleString() : '—'} </div>
<div style={{ color: "#777" }}>
{t("monitorLastReady")}:{" "}
{n.lastReadyTransition
? new Date(n.lastReadyTransition).toLocaleString()
: "—"}
</div> </div>
</div> </div>
</div> </div>
@ -216,43 +340,153 @@ export default function MonitorPage() {
</div> </div>
<div> <div>
<h3 style={{ marginBottom: 6 }}>{t('monitorPodsTitle')}</h3> <h3 style={{ marginBottom: 6 }}>{t("monitorPodsTitle")}</h3>
{(data.k8s.pods ?? []).length === 0 ? ( {(data.k8s.pods ?? []).length === 0 ? (
<p style={{ margin: 0 }}>{t('monitorNoPods')}</p> <p style={{ margin: 0 }}>{t("monitorNoPods")}</p>
) : ( ) : (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: "auto" }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}> <table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 14,
}}
>
<thead> <thead>
<tr> <tr>
<th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid #eee' }}>Namespace</th> <th
<th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid #eee' }}>Pod</th> style={{
<th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid #eee' }}>Ready</th> textAlign: "left",
<th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid #eee' }}>{t('monitorRestarts')}</th> padding: "6px 8px",
<th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid #eee' }}>Phase</th> borderBottom: "1px solid #eee",
<th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid #eee' }}>{t('monitorAge')}</th> }}
<th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid #eee' }}>Node</th> >
Namespace
</th>
<th
style={{
textAlign: "left",
padding: "6px 8px",
borderBottom: "1px solid #eee",
}}
>
Pod
</th>
<th
style={{
textAlign: "left",
padding: "6px 8px",
borderBottom: "1px solid #eee",
}}
>
Ready
</th>
<th
style={{
textAlign: "left",
padding: "6px 8px",
borderBottom: "1px solid #eee",
}}
>
{t("monitorRestarts")}
</th>
<th
style={{
textAlign: "left",
padding: "6px 8px",
borderBottom: "1px solid #eee",
}}
>
Phase
</th>
<th
style={{
textAlign: "left",
padding: "6px 8px",
borderBottom: "1px solid #eee",
}}
>
{t("monitorAge")}
</th>
<th
style={{
textAlign: "left",
padding: "6px 8px",
borderBottom: "1px solid #eee",
}}
>
Node
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(data.k8s.pods ?? []).map((p) => ( {(data.k8s.pods ?? []).map((p) => (
<tr key={`${p.namespace}-${p.name}`}> <tr key={`${p.namespace}-${p.name}`}>
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}>{p.namespace}</td> <td
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}> style={{
padding: "6px 8px",
borderBottom: "1px solid #f2f2f2",
}}
>
{p.namespace}
</td>
<td
style={{
padding: "6px 8px",
borderBottom: "1px solid #f2f2f2",
}}
>
<div style={{ fontWeight: 600 }}>{p.name}</div> <div style={{ fontWeight: 600 }}>{p.name}</div>
<div style={{ fontSize: 12, color: '#666' }}> <div style={{ fontSize: 12, color: "#666" }}>
{p.containers.map((c) => `${c.name} (${c.state}${c.lastState ? `, ${c.lastState}` : ''})`).join('; ')} {p.containers
.map(
(c) =>
`${c.name} (${c.state}${c.lastState ? `, ${c.lastState}` : ""})`,
)
.join("; ")}
</div> </div>
</td> </td>
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}> <td
style={{
padding: "6px 8px",
borderBottom: "1px solid #f2f2f2",
}}
>
{p.readyCount}/{p.totalContainers} {p.readyCount}/{p.totalContainers}
</td> </td>
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}>{p.restarts}</td> <td
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}> style={{
{p.phase} padding: "6px 8px",
{p.reason ? ` (${p.reason})` : ''} borderBottom: "1px solid #f2f2f2",
}}
>
{p.restarts}
</td>
<td
style={{
padding: "6px 8px",
borderBottom: "1px solid #f2f2f2",
}}
>
{p.phase}
{p.reason ? ` (${p.reason})` : ""}
</td>
<td
style={{
padding: "6px 8px",
borderBottom: "1px solid #f2f2f2",
}}
>
{formatDurationFrom(p.startedAt)}
</td>
<td
style={{
padding: "6px 8px",
borderBottom: "1px solid #f2f2f2",
}}
>
{p.nodeName ?? "—"}
</td> </td>
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}>{formatDurationFrom(p.startedAt)}</td>
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}>{p.nodeName ?? '—'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -264,39 +498,111 @@ export default function MonitorPage() {
)} )}
</section> </section>
<section style={{ border: '1px solid #eee', borderRadius: 12, padding: 16, display: 'grid', gap: 10 }}> <section
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> style={{
<h2 style={{ margin: 0 }}>{t('monitorDbTitle')}</h2> border: "1px solid #eee",
{data?.db?.ok ? statusPill(t('monitorHealthy'), true) : statusPill(t('monitorAttention'), false)} borderRadius: 12,
padding: 16,
display: "grid",
gap: 10,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2 style={{ margin: 0 }}>{t("monitorDbTitle")}</h2>
{data?.db?.ok
? statusPill(t("monitorHealthy"), true)
: statusPill(t("monitorAttention"), false)}
</div> </div>
{!data?.db ? ( {!data?.db ? (
<p>{t('monitorNoData')}</p> <p>{t("monitorNoData")}</p>
) : !data.db.ok ? ( ) : !data.db.ok ? (
<p style={{ color: 'red', margin: 0 }}>{data.db.error ?? t('monitorLoadFailed')}</p> <p style={{ color: "red", margin: 0 }}>
{data.db.error ?? t("monitorLoadFailed")}
</p>
) : ( ) : (
<> <>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 8 }}> <div
<div style={{ border: '1px solid #eee', borderRadius: 10, padding: 10 }}> style={{
<div style={{ fontSize: 12, color: '#666' }}>{t('monitorServerTime')}</div> display: "grid",
<div style={{ fontWeight: 600 }}>{data.db.serverTime ? new Date(data.db.serverTime).toLocaleString() : '—'}</div> gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
gap: 8,
}}
>
<div
style={{
border: "1px solid #eee",
borderRadius: 10,
padding: 10,
}}
>
<div style={{ fontSize: 12, color: "#666" }}>
{t("monitorServerTime")}
</div> </div>
<div style={{ border: '1px solid #eee', borderRadius: 10, padding: 10 }}> <div style={{ fontWeight: 600 }}>
<div style={{ fontSize: 12, color: '#666' }}>{t('monitorDbSize')}</div> {data.db.serverTime
<div style={{ fontWeight: 600 }}>{formatBytes(data.db.databaseSizeBytes)}</div> ? new Date(data.db.serverTime).toLocaleString()
: "—"}
</div>
</div>
<div
style={{
border: "1px solid #eee",
borderRadius: 10,
padding: 10,
}}
>
<div style={{ fontSize: 12, color: "#666" }}>
{t("monitorDbSize")}
</div>
<div style={{ fontWeight: 600 }}>
{formatBytes(data.db.databaseSizeBytes)}
</div>
</div>
<div
style={{
border: "1px solid #eee",
borderRadius: 10,
padding: 10,
}}
>
<div style={{ fontSize: 12, color: "#666" }}>
{t("monitorDbRecovery")}
</div>
<div style={{ fontWeight: 600 }}>
{data.db.recovery ? t("yes") : t("no")}
</div> </div>
<div style={{ border: '1px solid #eee', borderRadius: 10, padding: 10 }}>
<div style={{ fontSize: 12, color: '#666' }}>{t('monitorDbRecovery')}</div>
<div style={{ fontWeight: 600 }}>{data.db.recovery ? t('yes') : t('no')}</div>
</div> </div>
</div> </div>
<div> <div>
<h3 style={{ marginBottom: 6 }}>{t('monitorConnections')}</h3> <h3 style={{ marginBottom: 6 }}>{t("monitorConnections")}</h3>
{(data.db.connections ?? []).length === 0 ? ( {(data.db.connections ?? []).length === 0 ? (
<p style={{ margin: 0 }}>{t('monitorNoData')}</p> <p style={{ margin: 0 }}>{t("monitorNoData")}</p>
) : ( ) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', gap: 10, flexWrap: 'wrap' }}> <ul
style={{
listStyle: "none",
padding: 0,
margin: 0,
display: "flex",
gap: 10,
flexWrap: "wrap",
}}
>
{data.db.connections!.map((c) => ( {data.db.connections!.map((c) => (
<li key={c.state} style={{ border: '1px solid #eee', borderRadius: 10, padding: '6px 10px' }}> <li
key={c.state}
style={{
border: "1px solid #eee",
borderRadius: 10,
padding: "6px 10px",
}}
>
<strong>{c.count}</strong> {c.state} <strong>{c.count}</strong> {c.state}
</li> </li>
))} ))}

View file

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

View file

@ -1,7 +1,7 @@
'use client'; "use client";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { useI18n } from '../../components/I18nProvider'; import { useI18n } from "../../components/I18nProvider";
type SiteSettings = { type SiteSettings = {
requireLoginForContactDetails: boolean; requireLoginForContactDetails: boolean;
@ -21,25 +21,25 @@ export default function AdminSettingsPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const meRes = await fetch('/api/auth/me', { cache: 'no-store' }); const meRes = await fetch("/api/auth/me", { cache: "no-store" });
const me = await meRes.json(); const me = await meRes.json();
if (me.user?.role !== 'ADMIN') { if (me.user?.role !== "ADMIN") {
setError(t('adminRequired')); setError(t("adminRequired"));
setLoading(false); setLoading(false);
return; return;
} }
setIsAdmin(true); setIsAdmin(true);
const res = await fetch('/api/admin/settings', { cache: 'no-store' }); const res = await fetch("/api/admin/settings", { cache: "no-store" });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data.error || 'Failed to load settings'); setError(data.error || "Failed to load settings");
} else { } else {
setSettings(data.settings); setSettings(data.settings);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError('Failed to load settings'); setError("Failed to load settings");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -54,21 +54,21 @@ export default function AdminSettingsPage() {
setMessage(null); setMessage(null);
setError(null); setError(null);
try { try {
const res = await fetch('/api/admin/settings', { const res = await fetch("/api/admin/settings", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(next), body: JSON.stringify(next),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data.error || 'Failed to save settings'); setError(data.error || "Failed to save settings");
} else { } else {
setSettings(data.settings); setSettings(data.settings);
setMessage(t('settingsSaved')); setMessage(t("settingsSaved"));
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError('Failed to save settings'); setError("Failed to save settings");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -76,42 +76,56 @@ export default function AdminSettingsPage() {
function toggleRequireLoginForContactDetails() { function toggleRequireLoginForContactDetails() {
if (!settings) return; if (!settings) return;
save({ requireLoginForContactDetails: !settings.requireLoginForContactDetails }); save({
requireLoginForContactDetails: !settings.requireLoginForContactDetails,
});
} }
return ( return (
<main className="panel" style={{ maxWidth: 960, margin: '40px auto' }}> <main className="panel" style={{ maxWidth: 960, margin: "40px auto" }}>
<h1>{t('adminSettingsTitle')}</h1> <h1>{t("adminSettingsTitle")}</h1>
<p>{t('adminSettingsLead')}</p> <p>{t("adminSettingsLead")}</p>
{loading ? <p>{t('loading')}</p> : null} {loading ? <p>{t("loading")}</p> : null}
{message ? <p style={{ color: 'green' }}>{message}</p> : null} {message ? <p style={{ color: "green" }}>{message}</p> : null}
{error ? <p style={{ color: 'red' }}>{error}</p> : null} {error ? <p style={{ color: "red" }}>{error}</p> : null}
{!loading && settings ? ( {!loading && settings ? (
<div style={{ display: 'grid', gap: 16, marginTop: 16 }}> <div style={{ display: "grid", gap: 16, marginTop: 16 }}>
<section <section
style={{ style={{
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'flex-start', alignItems: "flex-start",
border: '1px solid #e5e7eb', border: "1px solid #e5e7eb",
borderRadius: 12, borderRadius: 12,
padding: 16, padding: 16,
background: 'linear-gradient(135deg, rgba(14,165,233,0.08), rgba(30,64,175,0.08))', background:
"linear-gradient(135deg, rgba(14,165,233,0.08), rgba(30,64,175,0.08))",
}} }}
> >
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
<h2 style={{ margin: '0 0 8px' }}>{t('settingContactVisibilityTitle')}</h2> <h2 style={{ margin: "0 0 8px" }}>
<p style={{ margin: 0, color: '#475569' }}>{t('settingContactVisibilityHelp')}</p> {t("settingContactVisibilityTitle")}
</h2>
<p style={{ margin: 0, color: "#475569" }}>
{t("settingContactVisibilityHelp")}
</p>
</div> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 600 }}> <label
style={{
display: "flex",
alignItems: "center",
gap: 10,
fontWeight: 600,
}}
>
<input <input
type="checkbox" type="checkbox"
checked={settings.requireLoginForContactDetails} checked={settings.requireLoginForContactDetails}
onChange={toggleRequireLoginForContactDetails} onChange={toggleRequireLoginForContactDetails}
disabled={saving} disabled={saving}
/> />
<span>{t('settingRequireLoginForContact')}</span> <span>{t("settingRequireLoginForContact")}</span>
</label> </label>
</section> </section>
</div> </div>

View file

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

View file

@ -1,29 +1,34 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { ListingStatus } from '@prisma/client'; import { ListingStatus } from "@prisma/client";
import { prisma } from '../../../../../lib/prisma'; import { prisma } from "../../../../../lib/prisma";
import { requireAuth } from '../../../../../lib/jwt'; import { requireAuth } from "../../../../../lib/jwt";
import { Role } from '@prisma/client'; import { Role } from "@prisma/client";
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const auth = await requireAuth(req); const auth = await requireAuth(req);
const canModerate = auth.role === Role.ADMIN || auth.role === Role.LISTING_MODERATOR; const canModerate =
auth.role === Role.ADMIN || auth.role === Role.LISTING_MODERATOR;
if (!canModerate) { if (!canModerate) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const body = await req.json(); const body = await req.json();
const listingId = String(body.listingId ?? ''); const listingId = String(body.listingId ?? "");
const action = body.action ?? 'approve'; const action = body.action ?? "approve";
const reason = body.reason ? String(body.reason).slice(0, 500) : null; const reason = body.reason ? String(body.reason).slice(0, 500) : null;
if (!listingId) { if (!listingId) {
return NextResponse.json({ error: 'listingId is required' }, { status: 400 }); return NextResponse.json(
{ error: "listingId is required" },
{ status: 400 },
);
} }
let status: ListingStatus; let status: ListingStatus;
if (action === 'reject') status = ListingStatus.REJECTED; if (action === "reject") status = ListingStatus.REJECTED;
else if (action === 'remove') status = ListingStatus.REMOVED; else if (action === "remove") status = ListingStatus.REMOVED;
else if (action === 'publish' || action === 'approve') status = ListingStatus.PUBLISHED; else if (action === "publish" || action === "approve")
status = ListingStatus.PUBLISHED;
else status = ListingStatus.PENDING; else status = ListingStatus.PENDING;
const updated = await prisma.listing.update({ const updated = await prisma.listing.update({
@ -45,11 +50,11 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true, listing: updated }); return NextResponse.json({ ok: true, listing: updated });
} catch (error) { } catch (error) {
if (String(error).includes('Unauthorized')) { if (String(error).includes("Unauthorized")) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
console.error('Admin listing approval error', error); console.error("Admin listing approval error", error);
return NextResponse.json({ error: 'Approval failed' }, { status: 500 }); return NextResponse.json({ error: "Approval failed" }, { status: 500 });
} }
} }
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";

View file

@ -1,24 +1,35 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { Role } from '@prisma/client'; import { Role } from "@prisma/client";
import { requireAuth } from '../../../../lib/jwt'; import { requireAuth } from "../../../../lib/jwt";
import { fetchDbStatus, fetchHetznerServers, fetchKubernetesStatus } from '../../../../lib/monitoring'; import {
fetchDbStatus,
fetchHetznerServers,
fetchKubernetesStatus,
} from "../../../../lib/monitoring";
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
const auth = await requireAuth(req); const auth = await requireAuth(req);
if (auth.role !== Role.ADMIN) { if (auth.role !== Role.ADMIN) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const [hetzner, k8s, db] = await Promise.all([fetchHetznerServers(), fetchKubernetesStatus(), fetchDbStatus()]); const [hetzner, k8s, db] = await Promise.all([
fetchHetznerServers(),
fetchKubernetesStatus(),
fetchDbStatus(),
]);
return NextResponse.json({ hetzner, k8s, db }); return NextResponse.json({ hetzner, k8s, db });
} catch (error) { } catch (error) {
if (String(error).includes('Unauthorized')) { if (String(error).includes("Unauthorized")) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
console.error('Monitoring endpoint error', error); console.error("Monitoring endpoint error", error);
return NextResponse.json({ error: 'Failed to load monitoring data' }, { status: 500 }); return NextResponse.json(
{ error: "Failed to load monitoring data" },
{ status: 500 },
);
} }
} }
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";

View file

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

View file

@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { prisma } from '../../../../lib/prisma'; import { prisma } from "../../../../lib/prisma";
import { requireAuth } from '../../../../lib/jwt'; import { requireAuth } from "../../../../lib/jwt";
import { Role, ListingStatus, UserStatus } from '@prisma/client'; import { Role, ListingStatus, UserStatus } from "@prisma/client";
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
@ -10,7 +10,7 @@ export async function GET(req: Request) {
const canUserMod = auth.role === Role.USER_MODERATOR; const canUserMod = auth.role === Role.USER_MODERATOR;
const canListingMod = auth.role === Role.LISTING_MODERATOR; const canListingMod = auth.role === Role.LISTING_MODERATOR;
if (!isAdmin && !canUserMod && !canListingMod) { if (!isAdmin && !canUserMod && !canListingMod) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const wantsUsers = isAdmin || canUserMod; const wantsUsers = isAdmin || canUserMod;
@ -20,8 +20,15 @@ export async function GET(req: Request) {
wantsUsers wantsUsers
? prisma.user.findMany({ ? prisma.user.findMany({
where: { status: UserStatus.PENDING }, where: { status: UserStatus.PENDING },
select: { id: true, email: true, status: true, emailVerifiedAt: true, approvedAt: true, role: true }, select: {
orderBy: { createdAt: 'asc' }, id: true,
email: true,
status: true,
emailVerifiedAt: true,
approvedAt: true,
role: true,
},
orderBy: { createdAt: "asc" },
take: 50, take: 50,
}) })
: Promise.resolve([]), : Promise.resolve([]),
@ -36,9 +43,11 @@ export async function GET(req: Request) {
evChargingOnSite: true, evChargingOnSite: true,
wheelchairAccessible: true, wheelchairAccessible: true,
owner: { select: { email: true } }, owner: { select: { email: true } },
translations: { select: { title: true, slug: true, locale: true } }, translations: {
select: { title: true, slug: true, locale: true },
}, },
orderBy: { createdAt: 'asc' }, },
orderBy: { createdAt: "asc" },
take: 50, take: 50,
}) })
: Promise.resolve([]), : Promise.resolve([]),
@ -46,11 +55,14 @@ export async function GET(req: Request) {
return NextResponse.json({ users, listings, role: auth.role }); return NextResponse.json({ users, listings, role: auth.role });
} catch (error) { } catch (error) {
console.error('List pending error', error); console.error("List pending error", error);
if (String(error).includes('Unauthorized')) { if (String(error).includes("Unauthorized")) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
return NextResponse.json({ error: 'Failed to load pending items' }, { status: 500 }); return NextResponse.json(
{ error: "Failed to load pending items" },
{ status: 500 },
);
} }
} }
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";

View file

@ -1,23 +1,26 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { Role } from '@prisma/client'; import { Role } from "@prisma/client";
import { requireAuth } from '../../../../lib/jwt'; import { requireAuth } from "../../../../lib/jwt";
import { getSiteSettings, updateSiteSettings } from '../../../../lib/settings'; import { getSiteSettings, updateSiteSettings } from "../../../../lib/settings";
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
const auth = await requireAuth(req); const auth = await requireAuth(req);
if (auth.role !== Role.ADMIN) { if (auth.role !== Role.ADMIN) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const settings = await getSiteSettings(); const settings = await getSiteSettings();
return NextResponse.json({ settings }); return NextResponse.json({ settings });
} catch (error) { } catch (error) {
if (String(error).includes('Unauthorized')) { if (String(error).includes("Unauthorized")) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
console.error('Load settings error', error); console.error("Load settings error", error);
return NextResponse.json({ error: 'Failed to load settings' }, { status: 500 }); return NextResponse.json(
{ error: "Failed to load settings" },
{ status: 500 },
);
} }
} }
@ -25,24 +28,29 @@ export async function POST(req: Request) {
try { try {
const auth = await requireAuth(req); const auth = await requireAuth(req);
if (auth.role !== Role.ADMIN) { if (auth.role !== Role.ADMIN) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const body = await req.json(); const body = await req.json();
const payload: Parameters<typeof updateSiteSettings>[0] = {}; const payload: Parameters<typeof updateSiteSettings>[0] = {};
if (body.requireLoginForContactDetails !== undefined) { if (body.requireLoginForContactDetails !== undefined) {
payload.requireLoginForContactDetails = Boolean(body.requireLoginForContactDetails); payload.requireLoginForContactDetails = Boolean(
body.requireLoginForContactDetails,
);
} }
const settings = await updateSiteSettings(payload); const settings = await updateSiteSettings(payload);
return NextResponse.json({ settings }); return NextResponse.json({ settings });
} catch (error) { } catch (error) {
if (String(error).includes('Unauthorized')) { if (String(error).includes("Unauthorized")) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
console.error('Save settings error', error); console.error("Save settings error", error);
return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 }); return NextResponse.json(
{ error: "Failed to save settings" },
{ status: 500 },
);
} }
} }
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";

View file

@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { prisma } from '../../../../../lib/prisma'; import { prisma } from "../../../../../lib/prisma";
import { requireAuth } from '../../../../../lib/jwt'; import { requireAuth } from "../../../../../lib/jwt";
import { Role, UserStatus } from '@prisma/client'; import { Role, UserStatus } from "@prisma/client";
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@ -9,22 +9,39 @@ export async function POST(req: Request) {
const isAdmin = auth.role === Role.ADMIN; const isAdmin = auth.role === Role.ADMIN;
const canApprove = isAdmin || auth.role === Role.USER_MODERATOR; const canApprove = isAdmin || auth.role === Role.USER_MODERATOR;
if (!canApprove) { if (!canApprove) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const body = await req.json(); const body = await req.json();
const userId = String(body.userId ?? ''); const userId = String(body.userId ?? "");
const makeAdmin = Boolean(body.makeAdmin); const makeAdmin = Boolean(body.makeAdmin);
const newRole = body.newRole as Role | undefined; const newRole = body.newRole as Role | undefined;
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'userId is required' }, { status: 400 }); return NextResponse.json(
{ error: "userId is required" },
{ status: 400 },
);
} }
if (!isAdmin && (makeAdmin || newRole === Role.ADMIN || newRole === Role.USER_MODERATOR || newRole === Role.LISTING_MODERATOR)) { if (
return NextResponse.json({ error: 'Only admins can change roles' }, { status: 403 }); !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 roleUpdate =
isAdmin && newRole
? { role: newRole }
: makeAdmin && isAdmin
? { role: Role.ADMIN }
: undefined;
const updated = await prisma.user.update({ const updated = await prisma.user.update({
where: { id: userId }, where: { id: userId },
@ -43,11 +60,11 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true, user: updated }); return NextResponse.json({ ok: true, user: updated });
} catch (error) { } catch (error) {
if (String(error).includes('Unauthorized')) { if (String(error).includes("Unauthorized")) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
console.error('Admin approve user error', error); console.error("Admin approve user error", error);
return NextResponse.json({ error: 'Approval failed' }, { status: 500 }); return NextResponse.json({ error: "Approval failed" }, { status: 500 });
} }
} }
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";

View file

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

View file

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

View file

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

View file

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

View file

@ -1,26 +1,31 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { prisma } from '../../../../lib/prisma'; import { prisma } from "../../../../lib/prisma";
import { randomToken, addHours } from '../../../../lib/tokens'; import { randomToken, addHours } from "../../../../lib/tokens";
import { sendPasswordResetEmail } from '../../../../lib/mailer'; import { sendPasswordResetEmail } from "../../../../lib/mailer";
const APP_URL = process.env.APP_URL || 'http://localhost:3000'; const APP_URL = process.env.APP_URL || "http://localhost:3000";
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const body = await req.json(); const body = await req.json();
const email = String(body.email ?? '').trim().toLowerCase(); const email = String(body.email ?? "")
.trim()
.toLowerCase();
if (!email) { if (!email) {
return NextResponse.json({ error: 'Email is required' }, { status: 400 }); return NextResponse.json({ error: "Email is required" }, { status: 400 });
} }
const user = await prisma.user.findUnique({ where: { email }, select: { id: true, emailVerifiedAt: true } }); const user = await prisma.user.findUnique({
where: { email },
select: { id: true, emailVerifiedAt: true },
});
if (user) { if (user) {
const token = randomToken(); const token = randomToken();
await prisma.verificationToken.create({ await prisma.verificationToken.create({
data: { data: {
userId: user.id, userId: user.id,
token, token,
type: 'password_reset', type: "password_reset",
expiresAt: addHours(2), expiresAt: addHours(2),
}, },
}); });
@ -30,9 +35,9 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
console.error('Forgot password error', error); console.error("Forgot password error", error);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
} }
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";

View file

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

View file

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

View file

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

View file

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

View file

@ -1,37 +1,63 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { prisma } from '../../../../lib/prisma'; import { prisma } from "../../../../lib/prisma";
import { hashPassword } from '../../../../lib/auth'; import { hashPassword } from "../../../../lib/auth";
import { addHours } from '../../../../lib/tokens'; import { addHours } from "../../../../lib/tokens";
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const body = await req.json(); const body = await req.json();
const token = String(body.token ?? '').trim(); const token = String(body.token ?? "").trim();
const password = String(body.password ?? ''); const password = String(body.password ?? "");
if (!token || !password) { if (!token || !password) {
return NextResponse.json({ error: 'Missing token or password' }, { status: 400 }); return NextResponse.json(
{ error: "Missing token or password" },
{ status: 400 },
);
} }
if (password.length < 8) { if (password.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 }); return NextResponse.json(
{ error: "Password must be at least 8 characters" },
{ status: 400 },
);
} }
const record = await prisma.verificationToken.findUnique({ where: { token }, include: { user: true } }); const record = await prisma.verificationToken.findUnique({
if (!record || record.type !== 'password_reset' || record.consumedAt || record.expiresAt < new Date()) { where: { token },
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 400 }); include: { user: true },
});
if (
!record ||
record.type !== "password_reset" ||
record.consumedAt ||
record.expiresAt < new Date()
) {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 400 },
);
} }
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
await prisma.$transaction([ await prisma.$transaction([
prisma.user.update({ where: { id: record.userId }, data: { passwordHash } }), prisma.user.update({
prisma.verificationToken.update({ where: { id: record.id }, data: { consumedAt: new Date(), expiresAt: addHours(-1) } }), where: { id: record.userId },
data: { passwordHash },
}),
prisma.verificationToken.update({
where: { id: record.id },
data: { consumedAt: new Date(), expiresAt: addHours(-1) },
}),
]); ]);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
console.error('Password reset error', error); console.error("Password reset error", error);
return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 }); return NextResponse.json(
{ error: "Failed to reset password" },
{ status: 500 },
);
} }
} }
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";

View file

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

View file

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

View file

@ -1,26 +1,29 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { prisma } from '../../../../lib/prisma'; import { prisma } from "../../../../lib/prisma";
export async function GET(_req: Request, { params }: { params: { id: string } }) { export async function GET(
_req: Request,
{ params }: { params: { id: string } },
) {
const image = await prisma.listingImage.findUnique({ const image = await prisma.listingImage.findUnique({
where: { id: params.id }, where: { id: params.id },
select: { data: true, mimeType: true, url: true, updatedAt: true }, select: { data: true, mimeType: true, url: true, updatedAt: true },
}); });
if (!image) { if (!image) {
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
if (image.data) { if (image.data) {
const res = new NextResponse(image.data, { const res = new NextResponse(image.data, {
status: 200, status: 200,
headers: { headers: {
'Content-Type': image.mimeType || 'application/octet-stream', "Content-Type": image.mimeType || "application/octet-stream",
'Cache-Control': 'public, max-age=86400', "Cache-Control": "public, max-age=86400",
}, },
}); });
if (image.updatedAt) { if (image.updatedAt) {
res.headers.set('Last-Modified', image.updatedAt.toUTCString()); res.headers.set("Last-Modified", image.updatedAt.toUTCString());
} }
return res; return res;
} }
@ -29,5 +32,5 @@ export async function GET(_req: Request, { params }: { params: { id: string } })
return NextResponse.redirect(image.url, { status: 302 }); return NextResponse.redirect(image.url, { status: 302 });
} }
return NextResponse.json({ error: 'Image missing' }, { status: 404 }); return NextResponse.json({ error: "Image missing" }, { status: 404 });
} }

View file

@ -1,18 +1,23 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { ListingStatus, UserStatus } from '@prisma/client'; import { ListingStatus, UserStatus } from "@prisma/client";
import { prisma } from '../../../../../lib/prisma'; import { prisma } from "../../../../../lib/prisma";
import { loadN8nBillingApiKey } from '../../../../../lib/apiKeys'; import { loadN8nBillingApiKey } from "../../../../../lib/apiKeys";
import { resolveBillingDetails } from '../../../../../lib/billing'; import { resolveBillingDetails } from "../../../../../lib/billing";
function pickTranslation(translations: { slug: string; locale: string }[]) { function pickTranslation(translations: { slug: string; locale: string }[]) {
return translations.find((t) => t.locale === 'en') || translations.find((t) => t.locale === 'fi') || translations[0] || null; return (
translations.find((t) => t.locale === "en") ||
translations.find((t) => t.locale === "fi") ||
translations[0] ||
null
);
} }
function extractApiKey(req: Request) { function extractApiKey(req: Request) {
const headerKey = req.headers.get('x-api-key'); const headerKey = req.headers.get("x-api-key");
if (headerKey) return headerKey.trim(); if (headerKey) return headerKey.trim();
const auth = req.headers.get('authorization'); const auth = req.headers.get("authorization");
if (auth && auth.toLowerCase().startsWith('bearer ')) { if (auth && auth.toLowerCase().startsWith("bearer ")) {
return auth.slice(7).trim(); return auth.slice(7).trim();
} }
return null; return null;
@ -21,27 +26,36 @@ function extractApiKey(req: Request) {
export async function POST(req: Request) { export async function POST(req: Request) {
const expectedKey = loadN8nBillingApiKey(); const expectedKey = loadN8nBillingApiKey();
if (!expectedKey) { if (!expectedKey) {
return NextResponse.json({ error: 'Billing API key missing' }, { status: 500 }); return NextResponse.json(
{ error: "Billing API key missing" },
{ status: 500 },
);
} }
const providedKey = extractApiKey(req); const providedKey = extractApiKey(req);
if (!providedKey || providedKey !== expectedKey) { if (!providedKey || providedKey !== expectedKey) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
let body: any; let body: any;
try { try {
body = await req.json(); body = await req.json();
} catch { } catch {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
} }
const listingId = typeof body.listingId === 'string' ? body.listingId : undefined; const listingId =
const listingSlug = typeof body.listingSlug === 'string' ? body.listingSlug.trim() : undefined; typeof body.listingId === "string" ? body.listingId : undefined;
const ownerEmailRaw = typeof body.ownerEmail === 'string' ? body.ownerEmail.trim() : undefined; const listingSlug =
typeof body.listingSlug === "string" ? body.listingSlug.trim() : undefined;
const ownerEmailRaw =
typeof body.ownerEmail === "string" ? body.ownerEmail.trim() : undefined;
const ownerEmail = ownerEmailRaw ? ownerEmailRaw.toLowerCase() : undefined; const ownerEmail = ownerEmailRaw ? ownerEmailRaw.toLowerCase() : undefined;
if (!listingId && !listingSlug && !ownerEmail) { if (!listingId && !listingSlug && !ownerEmail) {
return NextResponse.json({ error: 'Provide listingId, listingSlug, or ownerEmail' }, { status: 400 }); return NextResponse.json(
{ error: "Provide listingId, listingSlug, or ownerEmail" },
{ status: 400 },
);
} }
let listing: any = null; let listing: any = null;
@ -73,21 +87,24 @@ export async function POST(req: Request) {
}); });
if (listingSlug && listings.length > 1) { if (listingSlug && listings.length > 1) {
return NextResponse.json({ error: 'Listing slug is ambiguous; provide listingId' }, { status: 400 }); return NextResponse.json(
{ error: "Listing slug is ambiguous; provide listingId" },
{ status: 400 },
);
} }
listing = listings[0] ?? null; listing = listings[0] ?? null;
if (!listing && listingId) { if (!listing && listingId) {
return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); return NextResponse.json({ error: "Listing not found" }, { status: 404 });
} }
if (!listing && listingSlug) { if (!listing && listingSlug) {
return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); return NextResponse.json({ error: "Listing not found" }, { status: 404 });
} }
} }
let owner = listing?.owner ?? null; let owner = listing?.owner ?? null;
if (!owner && ownerEmail) { if (!owner && ownerEmail) {
owner = await prisma.user.findFirst({ owner = await prisma.user.findFirst({
where: { email: { equals: ownerEmail, mode: 'insensitive' } }, where: { email: { equals: ownerEmail, mode: "insensitive" } },
select: { select: {
id: true, id: true,
email: true, email: true,
@ -106,19 +123,27 @@ export async function POST(req: Request) {
return NextResponse.json({ enabled: false }); return NextResponse.json({ enabled: false });
} }
const ownerReady = owner.status === UserStatus.ACTIVE && owner.approvedAt && owner.emailVerifiedAt; const ownerReady =
owner.status === UserStatus.ACTIVE &&
owner.approvedAt &&
owner.emailVerifiedAt;
if (!ownerReady || !owner.billingEmailsEnabled) { if (!ownerReady || !owner.billingEmailsEnabled) {
return NextResponse.json({ enabled: false, owner: { id: owner.id, email: owner.email } }); return NextResponse.json({
enabled: false,
owner: { id: owner.id, email: owner.email },
});
} }
const billing = resolveBillingDetails(owner, listing ?? undefined); const billing = resolveBillingDetails(owner, listing ?? undefined);
const enabled = Boolean(billing.accountName && billing.iban); const enabled = Boolean(billing.accountName && billing.iban);
const listingHasOverride = const listingHasOverride =
listing && listing &&
(listing.billingAccountName !== null && listing.billingAccountName !== undefined || ((listing.billingAccountName !== null &&
listing.billingIban !== null && listing.billingIban !== undefined || listing.billingAccountName !== undefined) ||
listing.billingIncludeVatLine !== null && listing.billingIncludeVatLine !== undefined); (listing.billingIban !== null && listing.billingIban !== undefined) ||
const source = listingHasOverride ? 'listing' : 'user'; (listing.billingIncludeVatLine !== null &&
listing.billingIncludeVatLine !== undefined));
const source = listingHasOverride ? "listing" : "user";
return NextResponse.json({ return NextResponse.json({
enabled, enabled,
@ -126,7 +151,10 @@ export async function POST(req: Request) {
listing: listing listing: listing
? { ? {
id: listing.id, id: listing.id,
slug: pickTranslation(listing.translations)?.slug ?? listing.translations[0]?.slug ?? null, slug:
pickTranslation(listing.translations)?.slug ??
listing.translations[0]?.slug ??
null,
status: listing.status, status: listing.status,
} }
: null, : null,
@ -134,4 +162,4 @@ export async function POST(req: Request) {
}); });
} }
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";

View file

@ -1,20 +1,38 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { prisma } from '../../../../../lib/prisma'; import { prisma } from "../../../../../lib/prisma";
import { expandBlockedDates, getCalendarRanges } from '../../../../../lib/calendar'; import {
expandBlockedDates,
getCalendarRanges,
} from "../../../../../lib/calendar";
export async function GET(_: Request, { params }: { params: { id: string } }) { export async function GET(_: Request, { params }: { params: { id: string } }) {
const monthParam = Number(new URL(_.url).searchParams.get('month') ?? new Date().getUTCMonth()); const monthParam = Number(
const yearParam = Number(new URL(_.url).searchParams.get('year') ?? new Date().getUTCFullYear()); new URL(_.url).searchParams.get("month") ?? new Date().getUTCMonth(),
const monthsParam = Math.min(Number(new URL(_.url).searchParams.get('months') ?? 1), 12); );
const forceRefresh = new URL(_.url).searchParams.get('refresh') === '1'; const yearParam = Number(
new URL(_.url).searchParams.get("year") ?? new Date().getUTCFullYear(),
);
const monthsParam = Math.min(
Number(new URL(_.url).searchParams.get("months") ?? 1),
12,
);
const forceRefresh = new URL(_.url).searchParams.get("refresh") === "1";
const month = Number.isFinite(monthParam) ? monthParam : new Date().getUTCMonth(); const month = Number.isFinite(monthParam)
const year = Number.isFinite(yearParam) ? yearParam : new Date().getUTCFullYear(); ? monthParam
const months = Number.isFinite(monthsParam) && monthsParam > 0 ? monthsParam : 1; : new Date().getUTCMonth();
const year = Number.isFinite(yearParam)
? yearParam
: new Date().getUTCFullYear();
const months =
Number.isFinite(monthsParam) && monthsParam > 0 ? monthsParam : 1;
const listing = await prisma.listing.findUnique({ where: { id: params.id }, select: { calendarUrls: true } }); const listing = await prisma.listing.findUnique({
where: { id: params.id },
select: { calendarUrls: true },
});
if (!listing) { if (!listing) {
return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); return NextResponse.json({ error: "Listing not found" }, { status: 404 });
} }
const urls = (listing.calendarUrls ?? []).filter(Boolean); const urls = (listing.calendarUrls ?? []).filter(Boolean);

View file

@ -1,9 +1,12 @@
import { Role } from '@prisma/client'; import { Role } from "@prisma/client";
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { prisma } from '../../../../../../lib/prisma'; import { prisma } from "../../../../../../lib/prisma";
import { requireAuth } from '../../../../../../lib/jwt'; import { requireAuth } from "../../../../../../lib/jwt";
export async function DELETE(req: Request, { params }: { params: { id: string; imageId: string } }) { export async function DELETE(
req: Request,
{ params }: { params: { id: string; imageId: string } },
) {
try { try {
const auth = await requireAuth(req); const auth = await requireAuth(req);
const listing = await prisma.listing.findUnique({ const listing = await prisma.listing.findUnique({
@ -12,38 +15,48 @@ export async function DELETE(req: Request, { params }: { params: { id: string; i
id: true, id: true,
ownerId: true, ownerId: true,
status: true, status: true,
images: { orderBy: { order: 'asc' }, select: { id: true, isCover: true, order: true } }, images: {
orderBy: { order: "asc" },
select: { id: true, isCover: true, order: true },
},
}, },
}); });
if (!listing) { if (!listing) {
return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); return NextResponse.json({ error: "Listing not found" }, { status: 404 });
} }
const isOwner = listing.ownerId === auth.userId; const isOwner = listing.ownerId === auth.userId;
const isAdmin = auth.role === Role.ADMIN; const isAdmin = auth.role === Role.ADMIN;
if (!isOwner && !isAdmin) { if (!isOwner && !isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const targetImage = listing.images.find((img) => img.id === params.imageId); const targetImage = listing.images.find((img) => img.id === params.imageId);
if (!targetImage) { if (!targetImage) {
return NextResponse.json({ error: 'Image not found' }, { status: 404 }); return NextResponse.json({ error: "Image not found" }, { status: 404 });
} }
if (listing.images.length <= 1) { if (listing.images.length <= 1) {
return NextResponse.json({ error: 'At least one image is required' }, { status: 400 }); return NextResponse.json(
{ error: "At least one image is required" },
{ status: 400 },
);
} }
const remaining = listing.images.filter((img) => img.id !== params.imageId); const remaining = listing.images.filter((img) => img.id !== params.imageId);
const newCoverId = remaining.find((img) => img.isCover)?.id ?? remaining[0]?.id ?? null; const newCoverId =
remaining.find((img) => img.isCover)?.id ?? remaining[0]?.id ?? null;
await prisma.$transaction([ await prisma.$transaction([
prisma.listingImage.delete({ where: { id: params.imageId } }), prisma.listingImage.delete({ where: { id: params.imageId } }),
...remaining.map((img, idx) => ...remaining.map((img, idx) =>
prisma.listingImage.update({ prisma.listingImage.update({
where: { id: img.id }, where: { id: img.id },
data: { order: idx + 1, isCover: newCoverId ? img.id === newCoverId : img.isCover }, data: {
order: idx + 1,
isCover: newCoverId ? img.id === newCoverId : img.isCover,
},
}), }),
), ),
]); ]);
@ -52,20 +65,31 @@ export async function DELETE(req: Request, { params }: { params: { id: string; i
where: { id: listing.id }, where: { id: listing.id },
select: { select: {
images: { images: {
orderBy: { order: 'asc' }, orderBy: { order: "asc" },
select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, mimeType: true }, select: {
id: true,
url: true,
altText: true,
order: true,
isCover: true,
size: true,
mimeType: true,
},
}, },
}, },
}); });
return NextResponse.json({ ok: true, images: updated?.images ?? [] }); return NextResponse.json({ ok: true, images: updated?.images ?? [] });
} catch (error: any) { } catch (error: any) {
console.error('Delete listing image error', error); console.error("Delete listing image error", error);
if (String(error).includes('Unauthorized')) { if (String(error).includes("Unauthorized")) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
return NextResponse.json({ error: 'Failed to delete image' }, { status: 500 }); return NextResponse.json(
{ error: "Failed to delete image" },
{ status: 500 },
);
} }
} }
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";

View file

@ -1,40 +1,65 @@
import { ListingStatus, Role, UserStatus } from '@prisma/client'; import { ListingStatus, Role, UserStatus } from "@prisma/client";
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { prisma } from '../../../../lib/prisma'; import { prisma } from "../../../../lib/prisma";
import { requireAuth } from '../../../../lib/jwt'; import { requireAuth } from "../../../../lib/jwt";
import { parsePrice, normalizeCalendarUrls } from '../route'; // reuse helpers import { parsePrice, normalizeCalendarUrls } from "../route"; // reuse helpers
const MAX_IMAGES = 6; const MAX_IMAGES = 6;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
export async function GET(_req: Request, { params }: { params: { id: string } }) { export async function GET(
_req: Request,
{ params }: { params: { id: string } },
) {
try { try {
const auth = await requireAuth(_req); const auth = await requireAuth(_req);
const listing = await prisma.listing.findFirst({ const listing = await prisma.listing.findFirst({
where: { id: params.id, ownerId: auth.userId, removedAt: null }, where: { id: params.id, ownerId: auth.userId, removedAt: null },
include: { include: {
translations: true, translations: true,
images: { orderBy: { order: 'asc' }, select: { id: true, altText: true, order: true, isCover: true, size: true, url: true, mimeType: true } }, images: {
orderBy: { order: "asc" },
select: {
id: true,
altText: true,
order: true,
isCover: true,
size: true,
url: true,
mimeType: true,
},
},
}, },
}); });
if (!listing) { if (!listing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
return NextResponse.json({ listing }); return NextResponse.json({ listing });
} catch (err: any) { } catch (err: any) {
const status = err?.message === 'Unauthorized' ? 401 : 500; const status = err?.message === "Unauthorized" ? 401 : 500;
return NextResponse.json({ error: 'Failed to load listing' }, { status }); return NextResponse.json({ error: "Failed to load listing" }, { status });
} }
} }
export async function PUT(req: Request, { params }: { params: { id: string } }) { export async function PUT(
req: Request,
{ params }: { params: { id: string } },
) {
try { try {
const auth = await requireAuth(req); const auth = await requireAuth(req);
const user = await prisma.user.findUnique({ where: { id: auth.userId } }); const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user || !user.emailVerifiedAt || !user.approvedAt || user.status !== UserStatus.ACTIVE) { if (
return NextResponse.json({ error: 'User not permitted to edit listings' }, { status: 403 }); !user ||
!user.emailVerifiedAt ||
!user.approvedAt ||
user.status !== UserStatus.ACTIVE
) {
return NextResponse.json(
{ error: "User not permitted to edit listings" },
{ status: 403 },
);
} }
const existing = await prisma.listing.findFirst({ const existing = await prisma.listing.findFirst({
@ -43,81 +68,151 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
}); });
if (!existing) { if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
const body = await req.json(); const body = await req.json();
const saveDraft = Boolean(body.saveDraft); const saveDraft = Boolean(body.saveDraft);
const baseSlug = String(body.slug ?? existing.translations[0]?.slug ?? '').trim().toLowerCase(); const baseSlug = String(body.slug ?? existing.translations[0]?.slug ?? "")
.trim()
.toLowerCase();
if (!baseSlug) { if (!baseSlug) {
return NextResponse.json({ error: 'Missing slug' }, { status: 400 }); return NextResponse.json({ error: "Missing slug" }, { status: 400 });
} }
const country = String(body.country ?? existing.country ?? '').trim(); const country = String(body.country ?? existing.country ?? "").trim();
const region = String(body.region ?? existing.region ?? '').trim(); const region = String(body.region ?? existing.region ?? "").trim();
const city = String(body.city ?? existing.city ?? '').trim(); const city = String(body.city ?? existing.city ?? "").trim();
const streetAddress = String(body.streetAddress ?? existing.streetAddress ?? '').trim(); const streetAddress = String(
const contactName = String(body.contactName ?? existing.contactName ?? '').trim(); body.streetAddress ?? existing.streetAddress ?? "",
const contactEmail = String(body.contactEmail ?? existing.contactEmail ?? '').trim(); ).trim();
const contactName = String(
body.contactName ?? existing.contactName ?? "",
).trim();
const contactEmail = String(
body.contactEmail ?? existing.contactEmail ?? "",
).trim();
const maxGuests = body.maxGuests === undefined || body.maxGuests === null || body.maxGuests === '' ? existing.maxGuests : Number(body.maxGuests); const maxGuests =
const bedrooms = body.bedrooms === undefined || body.bedrooms === null || body.bedrooms === '' ? existing.bedrooms : Number(body.bedrooms); body.maxGuests === undefined ||
const beds = body.beds === undefined || body.beds === null || body.beds === '' ? existing.beds : Number(body.beds); body.maxGuests === null ||
const bathrooms = body.bathrooms === undefined || body.bathrooms === null || body.bathrooms === '' ? existing.bathrooms : Number(body.bathrooms); body.maxGuests === ""
const priceWeekdayEuros = body.priceWeekdayEuros === undefined ? existing.priceWeekdayEuros : parsePrice(body.priceWeekdayEuros); ? existing.maxGuests
const priceWeekendEuros = body.priceWeekendEuros === undefined ? existing.priceWeekendEuros : parsePrice(body.priceWeekendEuros); : Number(body.maxGuests);
const calendarUrls = normalizeCalendarUrls(body.calendarUrls ?? existing.calendarUrls); const bedrooms =
body.bedrooms === undefined ||
body.bedrooms === null ||
body.bedrooms === ""
? existing.bedrooms
: Number(body.bedrooms);
const beds =
body.beds === undefined || body.beds === null || body.beds === ""
? existing.beds
: Number(body.beds);
const bathrooms =
body.bathrooms === undefined ||
body.bathrooms === null ||
body.bathrooms === ""
? existing.bathrooms
: Number(body.bathrooms);
const priceWeekdayEuros =
body.priceWeekdayEuros === undefined
? existing.priceWeekdayEuros
: parsePrice(body.priceWeekdayEuros);
const priceWeekendEuros =
body.priceWeekendEuros === undefined
? existing.priceWeekendEuros
: parsePrice(body.priceWeekendEuros);
const calendarUrls = normalizeCalendarUrls(
body.calendarUrls ?? existing.calendarUrls,
);
const translationsInputRaw = Array.isArray(body.translations) ? body.translations : null; const translationsInputRaw = Array.isArray(body.translations)
type TranslationInput = { locale: string; title: string; description: string; teaser: string | null; slug: string }; ? body.translations
: null;
type TranslationInput = {
locale: string;
title: string;
description: string;
teaser: string | null;
slug: string;
};
const translationsInput: TranslationInput[] = const translationsInput: TranslationInput[] =
translationsInputRaw?.map((item: any) => ({ translationsInputRaw?.map((item: any) => ({
locale: String(item.locale ?? '').toLowerCase(), locale: String(item.locale ?? "").toLowerCase(),
title: typeof item.title === 'string' ? item.title.trim() : '', title: typeof item.title === "string" ? item.title.trim() : "",
description: typeof item.description === 'string' ? item.description.trim() : '', description:
teaser: typeof item.teaser === 'string' ? item.teaser.trim() : null, typeof item.description === "string" ? item.description.trim() : "",
slug: String(item.slug ?? baseSlug).trim().toLowerCase(), teaser: typeof item.teaser === "string" ? item.teaser.trim() : null,
slug: String(item.slug ?? baseSlug)
.trim()
.toLowerCase(),
})) || []; })) || [];
const fallbackTranslationTitle = typeof body.title === 'string' ? body.title.trim() : existing.translations[0]?.title ?? ''; const fallbackTranslationTitle =
const fallbackTranslationDescription = typeof body.description === 'string' ? body.description.trim() : existing.translations[0]?.description ?? ''; typeof body.title === "string"
const fallbackTranslationTeaser = typeof body.teaser === 'string' ? body.teaser.trim() : existing.translations[0]?.teaser ?? null; ? body.title.trim()
const fallbackLocale = String(body.locale ?? existing.translations[0]?.locale ?? 'en').toLowerCase(); : (existing.translations[0]?.title ?? "");
const fallbackTranslationDescription =
typeof body.description === "string"
? body.description.trim()
: (existing.translations[0]?.description ?? "");
const fallbackTranslationTeaser =
typeof body.teaser === "string"
? body.teaser.trim()
: (existing.translations[0]?.teaser ?? null);
const fallbackLocale = String(
body.locale ?? existing.translations[0]?.locale ?? "en",
).toLowerCase();
if (translationsInput.length === 0 && (fallbackTranslationTitle || saveDraft) && (fallbackTranslationDescription || saveDraft)) { if (
translationsInput.length === 0 &&
(fallbackTranslationTitle || saveDraft) &&
(fallbackTranslationDescription || saveDraft)
) {
translationsInput.push({ translationsInput.push({
locale: fallbackLocale, locale: fallbackLocale,
title: fallbackTranslationTitle ?? '', title: fallbackTranslationTitle ?? "",
description: fallbackTranslationDescription ?? '', description: fallbackTranslationDescription ?? "",
teaser: fallbackTranslationTeaser, teaser: fallbackTranslationTeaser,
slug: baseSlug, slug: baseSlug,
}); });
} }
const imagesBody = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : null; const imagesBody = Array.isArray(body.images)
? body.images.slice(0, MAX_IMAGES)
: null;
if (!saveDraft) { if (!saveDraft) {
const missing: string[] = []; const missing: string[] = [];
if (!country) missing.push('country'); if (!country) missing.push("country");
if (!region) missing.push('region'); if (!region) missing.push("region");
if (!city) missing.push('city'); if (!city) missing.push("city");
if (!contactEmail) missing.push('contactEmail'); if (!contactEmail) missing.push("contactEmail");
if (!contactName) missing.push('contactName'); if (!contactName) missing.push("contactName");
if (!maxGuests) missing.push('maxGuests'); if (!maxGuests) missing.push("maxGuests");
if (!bedrooms && bedrooms !== 0) missing.push('bedrooms'); if (!bedrooms && bedrooms !== 0) missing.push("bedrooms");
if (!beds) missing.push('beds'); if (!beds) missing.push("beds");
if (!bathrooms) missing.push('bathrooms'); if (!bathrooms) missing.push("bathrooms");
if (!translationsInput.length && !existing.translations.length) missing.push('translations'); if (!translationsInput.length && !existing.translations.length)
missing.push("translations");
const hasImagesIncoming = imagesBody && imagesBody.length > 0; const hasImagesIncoming = imagesBody && imagesBody.length > 0;
if (!hasImagesIncoming && existing.images.length === 0) missing.push('images'); if (!hasImagesIncoming && existing.images.length === 0)
missing.push("images");
if (missing.length) { if (missing.length) {
return NextResponse.json({ error: `Missing required fields: ${missing.join(', ')}` }, { status: 400 }); return NextResponse.json(
{ error: `Missing required fields: ${missing.join(", ")}` },
{ status: 400 },
);
} }
} }
let status = existing.status; let status = existing.status;
const autoApprove = !saveDraft && (process.env.AUTO_APPROVE_LISTINGS === 'true' || auth.role === Role.ADMIN); const autoApprove =
!saveDraft &&
(process.env.AUTO_APPROVE_LISTINGS === "true" ||
auth.role === Role.ADMIN);
if (saveDraft) { if (saveDraft) {
status = ListingStatus.DRAFT; status = ListingStatus.DRAFT;
} else if (existing.status === ListingStatus.PUBLISHED) { } else if (existing.status === ListingStatus.PUBLISHED) {
@ -137,14 +232,21 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
}[] = []; }[] = [];
if (imagesBody) { if (imagesBody) {
const coverImageIndex = Math.min(Math.max(Number(body.coverImageIndex ?? 1), 1), imagesBody.length || 1); const coverImageIndex = Math.min(
Math.max(Number(body.coverImageIndex ?? 1), 1),
imagesBody.length || 1,
);
for (let idx = 0; idx < imagesBody.length; idx += 1) { for (let idx = 0; idx < imagesBody.length; idx += 1) {
const img = imagesBody[idx]; const img = imagesBody[idx];
const altText = typeof img.altText === 'string' && img.altText.trim() ? img.altText.trim() : null; const altText =
const rawMime = typeof img.mimeType === 'string' ? img.mimeType : null; typeof img.altText === "string" && img.altText.trim()
const rawData = typeof img.data === 'string' ? img.data : null; ? img.altText.trim()
const rawUrl = typeof img.url === 'string' && img.url.trim() ? img.url.trim() : null; : null;
const rawMime = typeof img.mimeType === "string" ? img.mimeType : null;
const rawData = typeof img.data === "string" ? img.data : null;
const rawUrl =
typeof img.url === "string" && img.url.trim() ? img.url.trim() : null;
let mimeType = rawMime; let mimeType = rawMime;
let buffer: Buffer | null = null; let buffer: Buffer | null = null;
@ -152,15 +254,20 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/); const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/);
if (dataUrlMatch) { if (dataUrlMatch) {
mimeType = mimeType || dataUrlMatch[1] || null; mimeType = mimeType || dataUrlMatch[1] || null;
buffer = Buffer.from(dataUrlMatch[2], 'base64'); buffer = Buffer.from(dataUrlMatch[2], "base64");
} else { } else {
buffer = Buffer.from(rawData, 'base64'); buffer = Buffer.from(rawData, "base64");
} }
} }
const size = buffer ? buffer.length : null; const size = buffer ? buffer.length : null;
if (size && size > MAX_IMAGE_BYTES) { if (size && size > MAX_IMAGE_BYTES) {
return NextResponse.json({ error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)` }, { status: 400 }); return NextResponse.json(
{
error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`,
},
{ status: 400 },
);
} }
if (!buffer && !rawUrl) { if (!buffer && !rawUrl) {
@ -169,7 +276,7 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
parsedImages.push({ parsedImages.push({
data: buffer ?? undefined, data: buffer ?? undefined,
mimeType: mimeType || 'image/jpeg', mimeType: mimeType || "image/jpeg",
size, size,
url: buffer ? null : rawUrl, url: buffer ? null : rawUrl,
altText, altText,
@ -184,44 +291,107 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
} }
const incomingEvChargingOnSite = const incomingEvChargingOnSite =
body.evChargingOnSite === undefined ? existing.evChargingOnSite : Boolean(body.evChargingOnSite); body.evChargingOnSite === undefined
? existing.evChargingOnSite
: Boolean(body.evChargingOnSite);
const incomingEvChargingAvailable = const incomingEvChargingAvailable =
body.evChargingAvailable === undefined ? existing.evChargingAvailable : Boolean(body.evChargingAvailable); body.evChargingAvailable === undefined
const evChargingAvailable = incomingEvChargingOnSite ? true : incomingEvChargingAvailable; ? existing.evChargingAvailable
const evChargingOnSite = evChargingAvailable ? incomingEvChargingOnSite : false; : Boolean(body.evChargingAvailable);
const evChargingAvailable = incomingEvChargingOnSite
? true
: incomingEvChargingAvailable;
const evChargingOnSite = evChargingAvailable
? incomingEvChargingOnSite
: false;
const updateData: any = { const updateData: any = {
status, status,
approvedAt: status === ListingStatus.PUBLISHED ? existing.approvedAt ?? new Date() : null, approvedAt:
approvedById: status === ListingStatus.PUBLISHED && auth.role === Role.ADMIN ? auth.userId : existing.approvedById, status === ListingStatus.PUBLISHED
? (existing.approvedAt ?? new Date())
: null,
approvedById:
status === ListingStatus.PUBLISHED && auth.role === Role.ADMIN
? auth.userId
: existing.approvedById,
country: country || null, country: country || null,
region: region || null, region: region || null,
city: city || null, city: city || null,
streetAddress: streetAddress || null, streetAddress: streetAddress || null,
addressNote: body.addressNote ?? existing.addressNote ?? null, addressNote: body.addressNote ?? existing.addressNote ?? null,
latitude: body.latitude !== undefined && body.latitude !== null && body.latitude !== '' ? Number(body.latitude) : existing.latitude, latitude:
longitude: body.longitude !== undefined && body.longitude !== null && body.longitude !== '' ? Number(body.longitude) : existing.longitude, body.latitude !== undefined &&
body.latitude !== null &&
body.latitude !== ""
? Number(body.latitude)
: existing.latitude,
longitude:
body.longitude !== undefined &&
body.longitude !== null &&
body.longitude !== ""
? Number(body.longitude)
: existing.longitude,
maxGuests, maxGuests,
bedrooms, bedrooms,
beds, beds,
bathrooms, bathrooms,
hasSauna: body.hasSauna === undefined ? existing.hasSauna : Boolean(body.hasSauna), hasSauna:
hasFireplace: body.hasFireplace === undefined ? existing.hasFireplace : Boolean(body.hasFireplace), body.hasSauna === undefined
hasWifi: body.hasWifi === undefined ? existing.hasWifi : Boolean(body.hasWifi), ? existing.hasSauna
petsAllowed: body.petsAllowed === undefined ? existing.petsAllowed : Boolean(body.petsAllowed), : Boolean(body.hasSauna),
byTheLake: body.byTheLake === undefined ? existing.byTheLake : Boolean(body.byTheLake), hasFireplace:
hasAirConditioning: body.hasAirConditioning === undefined ? existing.hasAirConditioning : Boolean(body.hasAirConditioning), body.hasFireplace === undefined
hasKitchen: body.hasKitchen === undefined ? existing.hasKitchen : Boolean(body.hasKitchen), ? existing.hasFireplace
hasDishwasher: body.hasDishwasher === undefined ? existing.hasDishwasher : Boolean(body.hasDishwasher), : Boolean(body.hasFireplace),
hasWashingMachine: body.hasWashingMachine === undefined ? existing.hasWashingMachine : Boolean(body.hasWashingMachine), hasWifi:
hasBarbecue: body.hasBarbecue === undefined ? existing.hasBarbecue : Boolean(body.hasBarbecue), body.hasWifi === undefined ? existing.hasWifi : Boolean(body.hasWifi),
hasMicrowave: body.hasMicrowave === undefined ? existing.hasMicrowave : Boolean(body.hasMicrowave), petsAllowed:
hasFreeParking: body.hasFreeParking === undefined ? existing.hasFreeParking : Boolean(body.hasFreeParking), body.petsAllowed === undefined
hasSkiPass: body.hasSkiPass === undefined ? existing.hasSkiPass : Boolean(body.hasSkiPass), ? existing.petsAllowed
: Boolean(body.petsAllowed),
byTheLake:
body.byTheLake === undefined
? existing.byTheLake
: Boolean(body.byTheLake),
hasAirConditioning:
body.hasAirConditioning === undefined
? existing.hasAirConditioning
: Boolean(body.hasAirConditioning),
hasKitchen:
body.hasKitchen === undefined
? existing.hasKitchen
: Boolean(body.hasKitchen),
hasDishwasher:
body.hasDishwasher === undefined
? existing.hasDishwasher
: Boolean(body.hasDishwasher),
hasWashingMachine:
body.hasWashingMachine === undefined
? existing.hasWashingMachine
: Boolean(body.hasWashingMachine),
hasBarbecue:
body.hasBarbecue === undefined
? existing.hasBarbecue
: Boolean(body.hasBarbecue),
hasMicrowave:
body.hasMicrowave === undefined
? existing.hasMicrowave
: Boolean(body.hasMicrowave),
hasFreeParking:
body.hasFreeParking === undefined
? existing.hasFreeParking
: Boolean(body.hasFreeParking),
hasSkiPass:
body.hasSkiPass === undefined
? existing.hasSkiPass
: Boolean(body.hasSkiPass),
evChargingAvailable, evChargingAvailable,
evChargingOnSite, evChargingOnSite,
wheelchairAccessible: wheelchairAccessible:
body.wheelchairAccessible === undefined ? existing.wheelchairAccessible : Boolean(body.wheelchairAccessible), body.wheelchairAccessible === undefined
? existing.wheelchairAccessible
: Boolean(body.wheelchairAccessible),
priceWeekdayEuros, priceWeekdayEuros,
priceWeekendEuros, priceWeekendEuros,
calendarUrls, calendarUrls,
@ -236,7 +406,9 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
if (translationsInput && translationsInput.length) { if (translationsInput && translationsInput.length) {
tx.push( tx.push(
prisma.listingTranslation.deleteMany({ where: { listingId: existing.id } }), prisma.listingTranslation.deleteMany({
where: { listingId: existing.id },
}),
prisma.listingTranslation.createMany({ prisma.listingTranslation.createMany({
data: translationsInput.map((t) => ({ data: translationsInput.map((t) => ({
listingId: existing.id, listingId: existing.id,
@ -251,12 +423,14 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
} }
if (parsedImages && parsedImages.length) { if (parsedImages && parsedImages.length) {
tx.push(prisma.listingImage.deleteMany({ where: { listingId: existing.id } })); tx.push(
prisma.listingImage.deleteMany({ where: { listingId: existing.id } }),
);
tx.push( tx.push(
prisma.listingImage.createMany({ prisma.listingImage.createMany({
data: parsedImages.map((img) => ({ data: parsedImages.map((img) => ({
listingId: existing.id, listingId: existing.id,
mimeType: img.mimeType || 'image/jpeg', mimeType: img.mimeType || "image/jpeg",
size: img.size ?? null, size: img.size ?? null,
url: img.url ?? null, url: img.url ?? null,
altText: img.altText ?? null, altText: img.altText ?? null,
@ -268,7 +442,9 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
); );
} }
tx.unshift(prisma.listing.update({ where: { id: existing.id }, data: updateData })); tx.unshift(
prisma.listing.update({ where: { id: existing.id }, data: updateData }),
);
await prisma.$transaction(tx); await prisma.$transaction(tx);
@ -276,15 +452,29 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
where: { id: existing.id }, where: { id: existing.id },
include: { include: {
translations: true, translations: true,
images: { orderBy: { order: 'asc' }, select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, mimeType: true } }, images: {
orderBy: { order: "asc" },
select: {
id: true,
url: true,
altText: true,
order: true,
isCover: true,
size: true,
mimeType: true,
},
},
}, },
}); });
return NextResponse.json({ ok: true, listing: updated }); return NextResponse.json({ ok: true, listing: updated });
} catch (error: any) { } catch (error: any) {
console.error('Update listing error', error); console.error("Update listing error", error);
const message = error?.code === 'P2002' ? 'Slug already exists for this locale' : 'Failed to update listing'; const message =
const status = error?.message === 'Unauthorized' ? 401 : 400; error?.code === "P2002"
? "Slug already exists for this locale"
: "Failed to update listing";
const status = error?.message === "Unauthorized" ? 401 : 400;
return NextResponse.json({ error: message }, { status }); return NextResponse.json({ error: message }, { status });
} }
} }

View file

@ -1,12 +1,12 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { prisma } from '../../../../lib/prisma'; import { prisma } from "../../../../lib/prisma";
export async function GET(req: Request) { export async function GET(req: Request) {
const url = new URL(req.url); const url = new URL(req.url);
const slug = url.searchParams.get('slug')?.trim().toLowerCase(); const slug = url.searchParams.get("slug")?.trim().toLowerCase();
if (!slug) { if (!slug) {
return NextResponse.json({ error: 'Missing slug' }, { status: 400 }); return NextResponse.json({ error: "Missing slug" }, { status: 400 });
} }
const existing = await prisma.listingTranslation.findFirst({ const existing = await prisma.listingTranslation.findFirst({

View file

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

View file

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

View file

@ -1,23 +1,30 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { ListingStatus, UserStatus, Prisma } from '@prisma/client'; import { ListingStatus, UserStatus, Prisma } from "@prisma/client";
import { prisma } from '../../../lib/prisma'; import { prisma } from "../../../lib/prisma";
import { requireAuth } from '../../../lib/jwt'; import { requireAuth } from "../../../lib/jwt";
import { resolveLocale } from '../../../lib/i18n'; import { resolveLocale } from "../../../lib/i18n";
import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing'; import { SAMPLE_LISTING_SLUGS } from "../../../lib/sampleListing";
import { getCalendarRanges, isRangeAvailable } from '../../../lib/calendar'; import { getCalendarRanges, isRangeAvailable } from "../../../lib/calendar";
const MAX_IMAGES = 6; const MAX_IMAGES = 6;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image
const SAMPLE_EMAIL = 'host@lomavuokraus.fi'; const SAMPLE_EMAIL = "host@lomavuokraus.fi";
function resolveImageUrl(img: { id: string; url: string | null; size: number | null }) { function resolveImageUrl(img: {
id: string;
url: string | null;
size: number | null;
}) {
if (img.size && img.size > 0) { if (img.size && img.size > 0) {
return `/api/images/${img.id}`; return `/api/images/${img.id}`;
} }
return img.url ?? null; return img.url ?? null;
} }
function pickTranslation<T extends { locale: string }>(translations: T[], locale: string | null): T | null { function pickTranslation<T extends { locale: string }>(
translations: T[],
locale: string | null,
): T | null {
if (!translations.length) return null; if (!translations.length) return null;
if (locale) { if (locale) {
const exact = translations.find((t) => t.locale === locale); const exact = translations.find((t) => t.locale === locale);
@ -29,11 +36,11 @@ function pickTranslation<T extends { locale: string }>(translations: T[], locale
export function normalizeCalendarUrls(input: unknown): string[] { export function normalizeCalendarUrls(input: unknown): string[] {
if (Array.isArray(input)) { if (Array.isArray(input)) {
return input return input
.map((u) => (typeof u === 'string' ? u.trim() : '')) .map((u) => (typeof u === "string" ? u.trim() : ""))
.filter(Boolean) .filter(Boolean)
.slice(0, 5); .slice(0, 5);
} }
if (typeof input === 'string') { if (typeof input === "string") {
return input return input
.split(/\n|,/) .split(/\n|,/)
.map((u) => u.trim()) .map((u) => u.trim())
@ -44,7 +51,7 @@ export function normalizeCalendarUrls(input: unknown): string[] {
} }
export function parsePrice(value: unknown): number | null { export function parsePrice(value: unknown): number | null {
if (value === undefined || value === null || value === '') return null; if (value === undefined || value === null || value === "") return null;
const num = Number(value); const num = Number(value);
if (Number.isNaN(num)) return null; if (Number.isNaN(num)) return null;
return Math.round(num); return Math.round(num);
@ -53,44 +60,63 @@ export function parsePrice(value: unknown): number | null {
export async function GET(req: Request) { export async function GET(req: Request) {
const url = new URL(req.url); const url = new URL(req.url);
const searchParams = url.searchParams; const searchParams = url.searchParams;
const q = searchParams.get('q')?.trim(); const q = searchParams.get("q")?.trim();
const city = searchParams.get('city')?.trim(); const city = searchParams.get("city")?.trim();
const region = searchParams.get('region')?.trim(); const region = searchParams.get("region")?.trim();
const evChargingParam = searchParams.get('evCharging'); const evChargingParam = searchParams.get("evCharging");
const evCharging = evChargingParam === 'true' ? true : evChargingParam === 'false' ? false : null; const evCharging =
const evChargingOnSiteParam = searchParams.get('evChargingOnSite'); evChargingParam === "true"
const evChargingOnSite = evChargingOnSiteParam === 'true' ? true : evChargingOnSiteParam === 'false' ? false : null; ? true
const startDateParam = searchParams.get('availableStart'); : evChargingParam === "false"
const endDateParam = searchParams.get('availableEnd'); ? false
: null;
const evChargingOnSiteParam = searchParams.get("evChargingOnSite");
const evChargingOnSite =
evChargingOnSiteParam === "true"
? true
: evChargingOnSiteParam === "false"
? false
: null;
const startDateParam = searchParams.get("availableStart");
const endDateParam = searchParams.get("availableEnd");
const startDate = startDateParam ? new Date(startDateParam) : null; const startDate = startDateParam ? new Date(startDateParam) : null;
const endDate = endDateParam ? new Date(endDateParam) : null; const endDate = endDateParam ? new Date(endDateParam) : null;
const availabilityFilterActive = Boolean(startDate && endDate && startDate < endDate); const availabilityFilterActive = Boolean(
const amenityFilters = searchParams.getAll('amenity').map((a) => a.trim().toLowerCase()); startDate && endDate && startDate < endDate,
const locale = resolveLocale({ cookieLocale: null, acceptLanguage: req.headers.get('accept-language') }); );
const limit = Math.min(Number(searchParams.get('limit') ?? 40), 100); const amenityFilters = searchParams
.getAll("amenity")
.map((a) => a.trim().toLowerCase());
const locale = resolveLocale({
cookieLocale: null,
acceptLanguage: req.headers.get("accept-language"),
});
const limit = Math.min(Number(searchParams.get("limit") ?? 40), 100);
const amenityWhere: Prisma.ListingWhereInput = {}; const amenityWhere: Prisma.ListingWhereInput = {};
if (amenityFilters.includes('sauna')) amenityWhere.hasSauna = true; if (amenityFilters.includes("sauna")) amenityWhere.hasSauna = true;
if (amenityFilters.includes('fireplace')) amenityWhere.hasFireplace = true; if (amenityFilters.includes("fireplace")) amenityWhere.hasFireplace = true;
if (amenityFilters.includes('wifi')) amenityWhere.hasWifi = true; if (amenityFilters.includes("wifi")) amenityWhere.hasWifi = true;
if (amenityFilters.includes('pets')) amenityWhere.petsAllowed = true; if (amenityFilters.includes("pets")) amenityWhere.petsAllowed = true;
if (amenityFilters.includes('lake')) amenityWhere.byTheLake = true; if (amenityFilters.includes("lake")) amenityWhere.byTheLake = true;
if (amenityFilters.includes('ac')) amenityWhere.hasAirConditioning = true; if (amenityFilters.includes("ac")) amenityWhere.hasAirConditioning = true;
if (amenityFilters.includes('kitchen')) amenityWhere.hasKitchen = true; if (amenityFilters.includes("kitchen")) amenityWhere.hasKitchen = true;
if (amenityFilters.includes('dishwasher')) amenityWhere.hasDishwasher = true; if (amenityFilters.includes("dishwasher")) amenityWhere.hasDishwasher = true;
if (amenityFilters.includes('washer')) amenityWhere.hasWashingMachine = true; if (amenityFilters.includes("washer")) amenityWhere.hasWashingMachine = true;
if (amenityFilters.includes('barbecue')) amenityWhere.hasBarbecue = true; if (amenityFilters.includes("barbecue")) amenityWhere.hasBarbecue = true;
if (amenityFilters.includes('microwave')) amenityWhere.hasMicrowave = true; if (amenityFilters.includes("microwave")) amenityWhere.hasMicrowave = true;
if (amenityFilters.includes('parking')) amenityWhere.hasFreeParking = true; if (amenityFilters.includes("parking")) amenityWhere.hasFreeParking = true;
if (amenityFilters.includes('skipass')) amenityWhere.hasSkiPass = true; if (amenityFilters.includes("skipass")) amenityWhere.hasSkiPass = true;
if (amenityFilters.includes('accessible')) amenityWhere.wheelchairAccessible = true; if (amenityFilters.includes("accessible"))
if (amenityFilters.includes('ev-onsite')) amenityWhere.evChargingOnSite = true; amenityWhere.wheelchairAccessible = true;
if (amenityFilters.includes("ev-onsite"))
amenityWhere.evChargingOnSite = true;
const where: Prisma.ListingWhereInput = { const where: Prisma.ListingWhereInput = {
status: ListingStatus.PUBLISHED, status: ListingStatus.PUBLISHED,
removedAt: null, removedAt: null,
city: city ? { contains: city, mode: 'insensitive' } : undefined, city: city ? { contains: city, mode: "insensitive" } : undefined,
region: region ? { contains: region, mode: 'insensitive' } : undefined, region: region ? { contains: region, mode: "insensitive" } : undefined,
evChargingAvailable: evCharging ?? undefined, evChargingAvailable: evCharging ?? undefined,
evChargingOnSite: evChargingOnSite ?? undefined, evChargingOnSite: evChargingOnSite ?? undefined,
...amenityWhere, ...amenityWhere,
@ -98,9 +124,9 @@ export async function GET(req: Request) {
? { ? {
some: { some: {
OR: [ OR: [
{ title: { contains: q, mode: 'insensitive' } }, { title: { contains: q, mode: "insensitive" } },
{ description: { contains: q, mode: 'insensitive' } }, { description: { contains: q, mode: "insensitive" } },
{ teaser: { contains: q, mode: 'insensitive' } }, { teaser: { contains: q, mode: "insensitive" } },
], ],
}, },
} }
@ -110,10 +136,29 @@ export async function GET(req: Request) {
const listings = await prisma.listing.findMany({ const listings = await prisma.listing.findMany({
where, where,
include: { include: {
translations: { select: { id: true, locale: true, title: true, slug: true, teaser: true, description: true } }, translations: {
images: { select: { id: true, url: true, altText: true, order: true, isCover: true, size: true }, orderBy: { order: 'asc' } }, select: {
id: true,
locale: true,
title: true,
slug: true,
teaser: true,
description: true,
}, },
orderBy: { createdAt: 'desc' }, },
images: {
select: {
id: true,
url: true,
altText: true,
order: true,
isCover: true,
size: true,
},
orderBy: { order: "asc" },
},
},
orderBy: { createdAt: "desc" },
take: Number.isNaN(limit) ? 40 : limit, take: Number.isNaN(limit) ? 40 : limit,
}); });
@ -139,7 +184,9 @@ export async function GET(req: Request) {
listing.isSample || listing.isSample ||
listing.contactEmail === SAMPLE_EMAIL || listing.contactEmail === SAMPLE_EMAIL ||
SAMPLE_LISTING_SLUGS.includes( SAMPLE_LISTING_SLUGS.includes(
pickTranslation(listing.translations, locale)?.slug ?? listing.translations[0]?.slug ?? '', pickTranslation(listing.translations, locale)?.slug ??
listing.translations[0]?.slug ??
"",
); );
const translation = pickTranslation(listing.translations, locale); const translation = pickTranslation(listing.translations, locale);
const fallback = listing.translations[0]; const fallback = listing.translations[0];
@ -147,16 +194,20 @@ export async function GET(req: Request) {
if (!url) return false; if (!url) return false;
try { try {
const parsed = new URL(url); const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:'; return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch { } catch {
return false; return false;
} }
}); });
return { return {
id: listing.id, id: listing.id,
title: translation?.title ?? fallback?.title ?? 'Listing', title: translation?.title ?? fallback?.title ?? "Listing",
slug: translation?.slug ?? fallback?.slug ?? '', slug: translation?.slug ?? fallback?.slug ?? "",
teaser: translation?.teaser ?? translation?.description ?? fallback?.description ?? null, teaser:
translation?.teaser ??
translation?.description ??
fallback?.description ??
null,
locale: translation?.locale ?? fallback?.locale ?? locale, locale: translation?.locale ?? fallback?.locale ?? locale,
country: listing.country, country: listing.country,
region: listing.region, region: listing.region,
@ -187,10 +238,15 @@ export async function GET(req: Request) {
bathrooms: listing.bathrooms, bathrooms: listing.bathrooms,
priceWeekdayEuros: listing.priceWeekdayEuros, priceWeekdayEuros: listing.priceWeekdayEuros,
priceWeekendEuros: listing.priceWeekendEuros, priceWeekendEuros: listing.priceWeekendEuros,
coverImage: resolveImageUrl(listing.images.find((img) => img.isCover) ?? listing.images[0] ?? { id: '', url: null, size: null }), coverImage: resolveImageUrl(
listing.images.find((img) => img.isCover) ??
listing.images[0] ?? { id: "", url: null, size: null },
),
isSample, isSample,
hasCalendar: Boolean(validCalendarUrls.length), hasCalendar: Boolean(validCalendarUrls.length),
availableForDates: availabilityFilterActive ? Boolean(availabilityMap.get(listing.id)) : undefined, availableForDates: availabilityFilterActive
? Boolean(availabilityMap.get(listing.id))
: undefined,
}; };
}); });
@ -201,68 +257,127 @@ export async function POST(req: Request) {
try { try {
const auth = await requireAuth(req); const auth = await requireAuth(req);
const user = await prisma.user.findUnique({ where: { id: auth.userId } }); const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user || !user.emailVerifiedAt || !user.approvedAt || user.status !== UserStatus.ACTIVE) { if (
return NextResponse.json({ error: 'User not permitted to create listings' }, { status: 403 }); !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 body = await req.json();
const saveDraft = Boolean(body.saveDraft); const saveDraft = Boolean(body.saveDraft);
const slug = String(body.slug ?? '').trim().toLowerCase(); const slug = String(body.slug ?? "")
const country = String(body.country ?? '').trim(); .trim()
const region = String(body.region ?? '').trim(); .toLowerCase();
const city = String(body.city ?? '').trim(); const country = String(body.country ?? "").trim();
const streetAddress = String(body.streetAddress ?? '').trim(); const region = String(body.region ?? "").trim();
const contactName = String(body.contactName ?? '').trim(); const city = String(body.city ?? "").trim();
const contactEmail = String(body.contactEmail ?? '').trim(); const streetAddress = String(body.streetAddress ?? "").trim();
const contactName = String(body.contactName ?? "").trim();
const contactEmail = String(body.contactEmail ?? "").trim();
if (!slug) { if (!slug) {
return NextResponse.json({ error: 'Missing slug' }, { status: 400 }); return NextResponse.json({ error: "Missing slug" }, { status: 400 });
} }
const maxGuests = body.maxGuests === undefined || body.maxGuests === null || body.maxGuests === '' ? null : Number(body.maxGuests); const maxGuests =
const bedrooms = body.bedrooms === undefined || body.bedrooms === null || body.bedrooms === '' ? null : Number(body.bedrooms); body.maxGuests === undefined ||
const beds = body.beds === undefined || body.beds === null || body.beds === '' ? null : Number(body.beds); body.maxGuests === null ||
const bathrooms = body.bathrooms === undefined || body.bathrooms === null || body.bathrooms === '' ? null : Number(body.bathrooms); body.maxGuests === ""
? null
: Number(body.maxGuests);
const bedrooms =
body.bedrooms === undefined ||
body.bedrooms === null ||
body.bedrooms === ""
? null
: Number(body.bedrooms);
const beds =
body.beds === undefined || body.beds === null || body.beds === ""
? null
: Number(body.beds);
const bathrooms =
body.bathrooms === undefined ||
body.bathrooms === null ||
body.bathrooms === ""
? null
: Number(body.bathrooms);
const priceWeekdayEuros = parsePrice(body.priceWeekdayEuros); const priceWeekdayEuros = parsePrice(body.priceWeekdayEuros);
const priceWeekendEuros = parsePrice(body.priceWeekendEuros); const priceWeekendEuros = parsePrice(body.priceWeekendEuros);
const calendarUrls = normalizeCalendarUrls(body.calendarUrls); const calendarUrls = normalizeCalendarUrls(body.calendarUrls);
const translationsInputRaw = Array.isArray(body.translations) ? body.translations : []; const translationsInputRaw = Array.isArray(body.translations)
type TranslationInput = { locale: string; title: string; description: string; teaser: string | null; slug: string }; ? body.translations
: [];
type TranslationInput = {
locale: string;
title: string;
description: string;
teaser: string | null;
slug: string;
};
let translationsInput = let translationsInput =
translationsInputRaw translationsInputRaw
.map((item: any) => ({ .map((item: any) => ({
locale: String(item.locale ?? '').toLowerCase(), locale: String(item.locale ?? "").toLowerCase(),
title: typeof item.title === 'string' ? item.title.trim() : '', title: typeof item.title === "string" ? item.title.trim() : "",
description: typeof item.description === 'string' ? item.description.trim() : '', description:
teaser: typeof item.teaser === 'string' ? item.teaser.trim() : null, typeof item.description === "string" ? item.description.trim() : "",
slug: String(item.slug ?? slug).trim().toLowerCase(), teaser: typeof item.teaser === "string" ? item.teaser.trim() : null,
slug: String(item.slug ?? slug)
.trim()
.toLowerCase(),
})) }))
.filter((t: any) => t.locale && (saveDraft || (t.title && t.description))) || []; .filter(
(t: any) => t.locale && (saveDraft || (t.title && t.description)),
) || [];
const fallbackLocale = String(body.locale ?? 'en').toLowerCase(); const fallbackLocale = String(body.locale ?? "en").toLowerCase();
const fallbackTranslationTitle = typeof body.title === 'string' ? body.title.trim() : ''; const fallbackTranslationTitle =
const fallbackTranslationDescription = typeof body.description === 'string' ? body.description.trim() : ''; typeof body.title === "string" ? body.title.trim() : "";
const fallbackTranslationTeaser = typeof body.teaser === 'string' ? body.teaser.trim() : null; const fallbackTranslationDescription =
typeof body.description === "string" ? body.description.trim() : "";
const fallbackTranslationTeaser =
typeof body.teaser === "string" ? body.teaser.trim() : null;
if (translationsInput.length === 0 && (fallbackTranslationTitle || saveDraft) && (fallbackTranslationDescription || saveDraft)) { if (
translationsInput.length === 0 &&
(fallbackTranslationTitle || saveDraft) &&
(fallbackTranslationDescription || saveDraft)
) {
translationsInput.push({ translationsInput.push({
locale: fallbackLocale, locale: fallbackLocale,
title: fallbackTranslationTitle ?? '', title: fallbackTranslationTitle ?? "",
description: fallbackTranslationDescription ?? '', description: fallbackTranslationDescription ?? "",
teaser: fallbackTranslationTeaser, teaser: fallbackTranslationTeaser,
slug, slug,
}); });
} }
if (!translationsInput.length && !saveDraft) { if (!translationsInput.length && !saveDraft) {
return NextResponse.json({ error: 'Missing translation fields (title/description)' }, { status: 400 }); return NextResponse.json(
{ error: "Missing translation fields (title/description)" },
{ status: 400 },
);
} }
const images = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : []; const images = Array.isArray(body.images)
? body.images.slice(0, MAX_IMAGES)
: [];
if (Array.isArray(body.images) && body.images.length > MAX_IMAGES) { if (Array.isArray(body.images) && body.images.length > MAX_IMAGES) {
return NextResponse.json({ error: `Too many images (max ${MAX_IMAGES})` }, { status: 400 }); return NextResponse.json(
{ error: `Too many images (max ${MAX_IMAGES})` },
{ status: 400 },
);
} }
const coverImageIndex = Math.min(Math.max(Number(body.coverImageIndex ?? 1), 1), images.length || 1); const coverImageIndex = Math.min(
Math.max(Number(body.coverImageIndex ?? 1), 1),
images.length || 1,
);
const parsedImages: { const parsedImages: {
data?: Buffer; data?: Buffer;
@ -276,10 +391,14 @@ export async function POST(req: Request) {
for (let idx = 0; idx < images.length; idx += 1) { for (let idx = 0; idx < images.length; idx += 1) {
const img = images[idx]; const img = images[idx];
const altText = typeof img.altText === 'string' && img.altText.trim() ? img.altText.trim() : null; const altText =
const rawMime = typeof img.mimeType === 'string' ? img.mimeType : null; typeof img.altText === "string" && img.altText.trim()
const rawData = typeof img.data === 'string' ? img.data : null; ? img.altText.trim()
const rawUrl = typeof img.url === 'string' && img.url.trim() ? img.url.trim() : null; : null;
const rawMime = typeof img.mimeType === "string" ? img.mimeType : null;
const rawData = typeof img.data === "string" ? img.data : null;
const rawUrl =
typeof img.url === "string" && img.url.trim() ? img.url.trim() : null;
let mimeType = rawMime; let mimeType = rawMime;
let buffer: Buffer | null = null; let buffer: Buffer | null = null;
@ -287,15 +406,20 @@ export async function POST(req: Request) {
const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/); const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/);
if (dataUrlMatch) { if (dataUrlMatch) {
mimeType = mimeType || dataUrlMatch[1] || null; mimeType = mimeType || dataUrlMatch[1] || null;
buffer = Buffer.from(dataUrlMatch[2], 'base64'); buffer = Buffer.from(dataUrlMatch[2], "base64");
} else { } else {
buffer = Buffer.from(rawData, 'base64'); buffer = Buffer.from(rawData, "base64");
} }
} }
const size = buffer ? buffer.length : null; const size = buffer ? buffer.length : null;
if (size && size > MAX_IMAGE_BYTES) { if (size && size > MAX_IMAGE_BYTES) {
return NextResponse.json({ error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)` }, { status: 400 }); return NextResponse.json(
{
error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`,
},
{ status: 400 },
);
} }
if (!buffer && !rawUrl) { if (!buffer && !rawUrl) {
@ -304,7 +428,7 @@ export async function POST(req: Request) {
parsedImages.push({ parsedImages.push({
data: buffer ?? undefined, data: buffer ?? undefined,
mimeType: mimeType || 'image/jpeg', mimeType: mimeType || "image/jpeg",
size, size,
url: buffer ? null : rawUrl, url: buffer ? null : rawUrl,
altText, altText,
@ -319,27 +443,37 @@ export async function POST(req: Request) {
if (!saveDraft) { if (!saveDraft) {
const missingFields: string[] = []; const missingFields: string[] = [];
if (!country) missingFields.push('country'); if (!country) missingFields.push("country");
if (!region) missingFields.push('region'); if (!region) missingFields.push("region");
if (!city) missingFields.push('city'); if (!city) missingFields.push("city");
if (!contactEmail) missingFields.push('contactEmail'); if (!contactEmail) missingFields.push("contactEmail");
if (!contactName) missingFields.push('contactName'); if (!contactName) missingFields.push("contactName");
if (!maxGuests) missingFields.push('maxGuests'); if (!maxGuests) missingFields.push("maxGuests");
if (!bedrooms && bedrooms !== 0) missingFields.push('bedrooms'); if (!bedrooms && bedrooms !== 0) missingFields.push("bedrooms");
if (!beds) missingFields.push('beds'); if (!beds) missingFields.push("beds");
if (!bathrooms) missingFields.push('bathrooms'); if (!bathrooms) missingFields.push("bathrooms");
if (!translationsInput.length) missingFields.push('translations'); if (!translationsInput.length) missingFields.push("translations");
if (!parsedImages.length) missingFields.push('images'); if (!parsedImages.length) missingFields.push("images");
if (missingFields.length) { if (missingFields.length) {
return NextResponse.json({ error: `Missing required fields: ${missingFields.join(', ')}` }, { status: 400 }); return NextResponse.json(
{ error: `Missing required fields: ${missingFields.join(", ")}` },
{ status: 400 },
);
} }
} }
const autoApprove = !saveDraft && (process.env.AUTO_APPROVE_LISTINGS === 'true' || auth.role === 'ADMIN'); const autoApprove =
const status = saveDraft ? ListingStatus.DRAFT : autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING; !saveDraft &&
const isSample = (contactEmail || '').toLowerCase() === SAMPLE_EMAIL; (process.env.AUTO_APPROVE_LISTINGS === "true" || auth.role === "ADMIN");
const status = saveDraft
? ListingStatus.DRAFT
: autoApprove
? ListingStatus.PUBLISHED
: ListingStatus.PENDING;
const isSample = (contactEmail || "").toLowerCase() === SAMPLE_EMAIL;
const evChargingOnSite = Boolean(body.evChargingOnSite); const evChargingOnSite = Boolean(body.evChargingOnSite);
const evChargingAvailable = Boolean(body.evChargingAvailable) || evChargingOnSite; const evChargingAvailable =
Boolean(body.evChargingAvailable) || evChargingOnSite;
const wheelchairAccessible = Boolean(body.wheelchairAccessible); const wheelchairAccessible = Boolean(body.wheelchairAccessible);
const listing = await prisma.listing.create({ const listing = await prisma.listing.create({
@ -347,14 +481,24 @@ export async function POST(req: Request) {
ownerId: user.id, ownerId: user.id,
status, status,
approvedAt: autoApprove ? new Date() : null, approvedAt: autoApprove ? new Date() : null,
approvedById: autoApprove && auth.role === 'ADMIN' ? user.id : null, approvedById: autoApprove && auth.role === "ADMIN" ? user.id : null,
country: country || null, country: country || null,
region: region || null, region: region || null,
city: city || null, city: city || null,
streetAddress: streetAddress || null, streetAddress: streetAddress || null,
addressNote: body.addressNote ?? null, addressNote: body.addressNote ?? null,
latitude: body.latitude !== undefined && body.latitude !== null && body.latitude !== '' ? Number(body.latitude) : null, latitude:
longitude: body.longitude !== undefined && body.longitude !== null && body.longitude !== '' ? Number(body.longitude) : null, 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, maxGuests,
bedrooms, bedrooms,
beds, beds,
@ -401,13 +545,28 @@ export async function POST(req: Request) {
} }
: undefined, : undefined,
}, },
include: { translations: true, images: { select: { id: true, altText: true, order: true, isCover: true, size: true, url: true } } }, include: {
translations: true,
images: {
select: {
id: true,
altText: true,
order: true,
isCover: true,
size: true,
url: true,
},
},
},
}); });
return NextResponse.json({ ok: true, listing }); return NextResponse.json({ ok: true, listing });
} catch (error: any) { } catch (error: any) {
console.error('Create listing error', error); console.error("Create listing error", error);
const message = error?.code === 'P2002' ? 'Slug already exists for this locale' : 'Failed to create listing'; const message =
error?.code === "P2002"
? "Slug already exists for this locale"
: "Failed to create listing";
return NextResponse.json({ error: message }, { status: 400 }); return NextResponse.json({ error: message }, { status: 400 });
} }
} }

View file

@ -1,24 +1,29 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
import { requireAuth } from '../../../../lib/jwt'; import { requireAuth } from "../../../../lib/jwt";
import type { Locale } from '../../../../lib/i18n'; import type { Locale } from "../../../../lib/i18n";
type LocaleFields = { title: string; teaser: string; description: string }; type LocaleFields = { title: string; teaser: string; description: string };
const SUPPORTED_LOCALES: Locale[] = ['en', 'fi', 'sv']; const SUPPORTED_LOCALES: Locale[] = ["en", "fi", "sv"];
function loadApiKey() { function loadApiKey() {
if (process.env.OPENAI_TRANSLATIONS_KEY) return process.env.OPENAI_TRANSLATIONS_KEY; if (process.env.OPENAI_TRANSLATIONS_KEY)
const newKeyPath = path.join(process.cwd(), 'creds', 'openai-translations.key'); return process.env.OPENAI_TRANSLATIONS_KEY;
const newKeyPath = path.join(
process.cwd(),
"creds",
"openai-translations.key",
);
try { try {
return fs.readFileSync(newKeyPath, 'utf8').trim(); return fs.readFileSync(newKeyPath, "utf8").trim();
} catch { } catch {
// ignore // ignore
} }
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY; if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY;
const fallbackPath = path.join(process.cwd(), 'creds', 'openai.key'); const fallbackPath = path.join(process.cwd(), "creds", "openai.key");
try { try {
return fs.readFileSync(fallbackPath, 'utf8').trim(); return fs.readFileSync(fallbackPath, "utf8").trim();
} catch { } catch {
return null; return null;
} }
@ -28,34 +33,42 @@ export async function POST(req: Request) {
try { try {
await requireAuth(req); await requireAuth(req);
} catch (err) { } catch (err) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
const apiKey = loadApiKey(); const apiKey = loadApiKey();
if (!apiKey) { if (!apiKey) {
return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 500 }); return NextResponse.json(
{ error: "Missing OpenAI API key" },
{ status: 500 },
);
} }
let body: any; let body: any;
try { try {
body = await req.json(); body = await req.json();
} catch { } catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); return NextResponse.json({ error: "Invalid request" }, { status: 400 });
} }
const incoming = body?.translations as Record<Locale, LocaleFields> | undefined; const incoming = body?.translations as
const currentLocale = (body?.currentLocale as Locale) ?? 'en'; | Record<Locale, LocaleFields>
| undefined;
const currentLocale = (body?.currentLocale as Locale) ?? "en";
if (!incoming) { if (!incoming) {
return NextResponse.json({ error: 'Missing translations' }, { status: 400 }); return NextResponse.json(
{ error: "Missing translations" },
{ status: 400 },
);
} }
const payload = SUPPORTED_LOCALES.reduce( const payload = SUPPORTED_LOCALES.reduce(
(acc, loc) => ({ (acc, loc) => ({
...acc, ...acc,
[loc]: { [loc]: {
title: incoming[loc]?.title || '', title: incoming[loc]?.title || "",
teaser: incoming[loc]?.teaser || '', teaser: incoming[loc]?.teaser || "",
description: incoming[loc]?.description || '', description: incoming[loc]?.description || "",
}, },
}), }),
{} as Record<Locale, LocaleFields>, {} as Record<Locale, LocaleFields>,
@ -63,12 +76,12 @@ export async function POST(req: Request) {
const messages = [ const messages = [
{ {
role: 'system', role: "system",
content: content:
'You are translating holiday rental listing copy between Finnish, Swedish, and English. Fill in missing locales, keep existing text unchanged, preserve meaning and tone, and respond with JSON only. Suggest localized slugs if missing.', "You are translating holiday rental listing copy between Finnish, Swedish, and English. Fill in missing locales, keep existing text unchanged, preserve meaning and tone, and respond with JSON only. Suggest localized slugs if missing.",
}, },
{ {
role: 'user', role: "user",
content: JSON.stringify( content: JSON.stringify(
{ {
sourceLocale: currentLocale, sourceLocale: currentLocale,
@ -81,22 +94,22 @@ export async function POST(req: Request) {
), ),
}, },
{ {
role: 'system', role: "system",
content: content:
'Return JSON with top-level "locales" containing keys en, fi, sv. Each locale has title, teaser, description, and slug. Do not include explanations.', 'Return JSON with top-level "locales" containing keys en, fi, sv. Each locale has title, teaser, description, and slug. Do not include explanations.',
}, },
]; ];
let content = ''; let content = "";
try { try {
const res = await fetch('https://api.openai.com/v1/chat/completions', { const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
model: 'gpt-4o-mini', model: "gpt-4o-mini",
temperature: 0.2, temperature: 0.2,
messages, messages,
}), }),
@ -104,33 +117,42 @@ export async function POST(req: Request) {
if (!res.ok) { if (!res.ok) {
const errText = await res.text(); const errText = await res.text();
return NextResponse.json({ error: 'AI request failed', detail: errText }, { status: res.status || 500 }); return NextResponse.json(
{ error: "AI request failed", detail: errText },
{ status: res.status || 500 },
);
} }
const data = await res.json(); const data = await res.json();
content = data?.choices?.[0]?.message?.content ?? ''; content = data?.choices?.[0]?.message?.content ?? "";
} catch (err: any) { } catch (err: any) {
return NextResponse.json({ error: 'AI request failed', detail: err?.message }, { status: 500 }); return NextResponse.json(
{ error: "AI request failed", detail: err?.message },
{ status: 500 },
);
} }
const jsonText = content.match(/\{[\s\S]*\}/)?.[0] ?? content; const jsonText = content.match(/\{[\s\S]*\}/)?.[0] ?? content;
try { try {
const parsed = JSON.parse(jsonText); const parsed = JSON.parse(jsonText);
const locales = parsed?.locales || parsed; const locales = parsed?.locales || parsed;
if (!locales) throw new Error('missing locales'); if (!locales) throw new Error("missing locales");
const result = SUPPORTED_LOCALES.reduce( const result = SUPPORTED_LOCALES.reduce(
(acc, loc) => ({ (acc, loc) => ({
...acc, ...acc,
[loc]: { [loc]: {
title: locales[loc]?.title ?? '', title: locales[loc]?.title ?? "",
teaser: locales[loc]?.teaser ?? '', teaser: locales[loc]?.teaser ?? "",
description: locales[loc]?.description ?? '', description: locales[loc]?.description ?? "",
}, },
}), }),
{} as Record<Locale, LocaleFields>, {} as Record<Locale, LocaleFields>,
); );
return NextResponse.json({ translations: result }); return NextResponse.json({ translations: result });
} catch (err) { } catch (err) {
return NextResponse.json({ error: 'Could not parse AI response' }, { status: 500 }); return NextResponse.json(
{ error: "Could not parse AI response" },
{ status: 500 },
);
} }
} }

View file

@ -1,11 +1,22 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { UserStatus } from '@prisma/client'; import { UserStatus } from "@prisma/client";
import { prisma } from '../../../../lib/prisma'; import { prisma } from "../../../../lib/prisma";
import { requireAuth } from '../../../../lib/jwt'; import { requireAuth } from "../../../../lib/jwt";
import { normalizeIban, normalizeNullableBoolean, normalizeOptionalString } from '../../../../lib/billing'; import {
normalizeIban,
normalizeNullableBoolean,
normalizeOptionalString,
} from "../../../../lib/billing";
function pickTranslation(translations: { title: string; slug: string; locale: string }[]) { function pickTranslation(
return translations.find((t) => t.locale === 'en') || translations.find((t) => t.locale === 'fi') || translations[0] || null; translations: { title: string; slug: string; locale: string }[],
) {
return (
translations.find((t) => t.locale === "en") ||
translations.find((t) => t.locale === "fi") ||
translations[0] ||
null
);
} }
async function loadState(userId: string) { async function loadState(userId: string) {
@ -30,7 +41,7 @@ async function loadState(userId: string) {
billingIncludeVatLine: true, billingIncludeVatLine: true,
translations: { select: { title: true, slug: true, locale: true } }, translations: { select: { title: true, slug: true, locale: true } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
}), }),
]); ]);
return { user, listings }; return { user, listings };
@ -38,31 +49,43 @@ async function loadState(userId: string) {
function validateAccountName(name: string | null | undefined) { function validateAccountName(name: string | null | undefined) {
if (name && name.length > 120) { if (name && name.length > 120) {
return 'Account owner name is too long'; return "Account owner name is too long";
} }
return null; return null;
} }
function validateIban(iban: string | null | undefined) { function validateIban(iban: string | null | undefined) {
if (iban && !/^[A-Z0-9]{8,34}$/.test(iban)) { if (iban && !/^[A-Z0-9]{8,34}$/.test(iban)) {
return 'IBAN must be 8-34 alphanumeric characters'; return "IBAN must be 8-34 alphanumeric characters";
} }
return null; return null;
} }
function normalizeListingOverrides(body: any) { function normalizeListingOverrides(body: any) {
type OverrideInput = { id: string | null; accountName: string | null | undefined; iban: string | null | undefined; includeVatLine: boolean | null | undefined }; type OverrideInput = {
const overridesRaw: any[] = Array.isArray(body?.listings) ? body.listings : []; id: string | null;
accountName: string | null | undefined;
iban: string | null | undefined;
includeVatLine: boolean | null | undefined;
};
const overridesRaw: any[] = Array.isArray(body?.listings)
? body.listings
: [];
const mapped: OverrideInput[] = overridesRaw.map((item: any) => ({ const mapped: OverrideInput[] = overridesRaw.map((item: any) => ({
id: typeof item.id === 'string' ? item.id : null, id: typeof item.id === "string" ? item.id : null,
accountName: normalizeOptionalString(item.accountName), accountName: normalizeOptionalString(item.accountName),
iban: normalizeIban(item.iban), iban: normalizeIban(item.iban),
includeVatLine: normalizeNullableBoolean(item.includeVatLine), includeVatLine: normalizeNullableBoolean(item.includeVatLine),
})); }));
return mapped.filter((o): o is OverrideInput & { id: string } => Boolean(o.id)); return mapped.filter((o): o is OverrideInput & { id: string } =>
Boolean(o.id),
);
} }
function buildResponsePayload(user: NonNullable<Awaited<ReturnType<typeof loadState>>['user']>, listings: Awaited<ReturnType<typeof loadState>>['listings']) { function buildResponsePayload(
user: NonNullable<Awaited<ReturnType<typeof loadState>>["user"]>,
listings: Awaited<ReturnType<typeof loadState>>["listings"],
) {
return { return {
settings: { settings: {
enabled: user.billingEmailsEnabled, enabled: user.billingEmailsEnabled,
@ -74,9 +97,9 @@ function buildResponsePayload(user: NonNullable<Awaited<ReturnType<typeof loadSt
const translation = pickTranslation(listing.translations); const translation = pickTranslation(listing.translations);
return { return {
id: listing.id, id: listing.id,
title: translation?.title ?? 'Listing', title: translation?.title ?? "Listing",
slug: translation?.slug ?? '', slug: translation?.slug ?? "",
locale: translation?.locale ?? 'en', locale: translation?.locale ?? "en",
billingAccountName: listing.billingAccountName, billingAccountName: listing.billingAccountName,
billingIban: listing.billingIban, billingIban: listing.billingIban,
billingIncludeVatLine: listing.billingIncludeVatLine, billingIncludeVatLine: listing.billingIncludeVatLine,
@ -90,12 +113,15 @@ export async function GET(req: Request) {
const session = await requireAuth(req); const session = await requireAuth(req);
const { user, listings } = await loadState(session.userId); const { user, listings } = await loadState(session.userId);
if (!user || user.status !== UserStatus.ACTIVE) { if (!user || user.status !== UserStatus.ACTIVE) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
return NextResponse.json(buildResponsePayload(user, listings)); return NextResponse.json(buildResponsePayload(user, listings));
} catch (error) { } catch (error) {
console.error('Billing settings fetch failed', error); console.error("Billing settings fetch failed", error);
return NextResponse.json({ error: 'Failed to load billing settings' }, { status: 500 }); return NextResponse.json(
{ error: "Failed to load billing settings" },
{ status: 500 },
);
} }
} }
@ -104,23 +130,33 @@ export async function PATCH(req: Request) {
try { try {
body = await req.json(); body = await req.json();
} catch { } catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); return NextResponse.json(
{ error: "Invalid request body" },
{ status: 400 },
);
} }
try { try {
const session = await requireAuth(req); const session = await requireAuth(req);
const { user, listings } = await loadState(session.userId); const { user, listings } = await loadState(session.userId);
if (!user || user.status !== UserStatus.ACTIVE) { if (!user || user.status !== UserStatus.ACTIVE) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
const enabled = body.enabled === undefined ? undefined : Boolean(body.enabled); const enabled =
body.enabled === undefined ? undefined : Boolean(body.enabled);
const accountName = normalizeOptionalString(body.accountName); const accountName = normalizeOptionalString(body.accountName);
const iban = normalizeIban(body.iban); const iban = normalizeIban(body.iban);
const includeVatLine = body.includeVatLine === undefined ? undefined : Boolean(body.includeVatLine); const includeVatLine =
body.includeVatLine === undefined
? undefined
: Boolean(body.includeVatLine);
const listingOverrides = normalizeListingOverrides(body); const listingOverrides = normalizeListingOverrides(body);
const errors = [validateAccountName(accountName), validateIban(iban)].filter(Boolean) as string[]; const errors = [
validateAccountName(accountName),
validateIban(iban),
].filter(Boolean) as string[];
for (const override of listingOverrides) { for (const override of listingOverrides) {
const nameError = validateAccountName(override.accountName); const nameError = validateAccountName(override.accountName);
const ibanError = validateIban(override.iban); const ibanError = validateIban(override.iban);
@ -128,31 +164,42 @@ export async function PATCH(req: Request) {
if (ibanError) errors.push(`${ibanError} for listing ${override.id}`); if (ibanError) errors.push(`${ibanError} for listing ${override.id}`);
} }
if (errors.length) { if (errors.length) {
return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); return NextResponse.json({ error: errors.join("; ") }, { status: 400 });
} }
const userUpdates: any = {}; const userUpdates: any = {};
if (enabled !== undefined) userUpdates.billingEmailsEnabled = enabled; if (enabled !== undefined) userUpdates.billingEmailsEnabled = enabled;
if (accountName !== undefined) userUpdates.billingAccountName = accountName; if (accountName !== undefined) userUpdates.billingAccountName = accountName;
if (iban !== undefined) userUpdates.billingIban = iban; if (iban !== undefined) userUpdates.billingIban = iban;
if (includeVatLine !== undefined) userUpdates.billingIncludeVatLine = includeVatLine; if (includeVatLine !== undefined)
userUpdates.billingIncludeVatLine = includeVatLine;
const listingMap = new Map(listings.map((l) => [l.id, l])); const listingMap = new Map(listings.map((l) => [l.id, l]));
const listingUpdates: { id: string; data: Record<string, any> }[] = []; const listingUpdates: { id: string; data: Record<string, any> }[] = [];
listingOverrides.forEach((override: { id: string; accountName: string | null | undefined; iban: string | null | undefined; includeVatLine: boolean | null | undefined }) => { listingOverrides.forEach(
(override: {
id: string;
accountName: string | null | undefined;
iban: string | null | undefined;
includeVatLine: boolean | null | undefined;
}) => {
if (!listingMap.has(override.id!)) return; if (!listingMap.has(override.id!)) return;
const data: Record<string, any> = {}; const data: Record<string, any> = {};
if (override.accountName !== undefined) data.billingAccountName = override.accountName; if (override.accountName !== undefined)
data.billingAccountName = override.accountName;
if (override.iban !== undefined) data.billingIban = override.iban; if (override.iban !== undefined) data.billingIban = override.iban;
if (override.includeVatLine !== undefined) data.billingIncludeVatLine = override.includeVatLine; if (override.includeVatLine !== undefined)
data.billingIncludeVatLine = override.includeVatLine;
if (Object.keys(data).length) { if (Object.keys(data).length) {
listingUpdates.push({ id: override.id!, data }); listingUpdates.push({ id: override.id!, data });
} }
}); },
);
const targetEnabled = enabled ?? user.billingEmailsEnabled; const targetEnabled = enabled ?? user.billingEmailsEnabled;
if (targetEnabled) { if (targetEnabled) {
const targetAccountName = accountName !== undefined ? accountName : user.billingAccountName; const targetAccountName =
accountName !== undefined ? accountName : user.billingAccountName;
const targetIban = iban !== undefined ? iban : user.billingIban; const targetIban = iban !== undefined ? iban : user.billingIban;
const hasGlobalDetails = Boolean(targetAccountName && targetIban); const hasGlobalDetails = Boolean(targetAccountName && targetIban);
@ -162,9 +209,11 @@ export async function PATCH(req: Request) {
const effectiveAccountName = const effectiveAccountName =
override?.data.billingAccountName !== undefined override?.data.billingAccountName !== undefined
? override.data.billingAccountName ? override.data.billingAccountName
: listing.billingAccountName ?? targetAccountName; : (listing.billingAccountName ?? targetAccountName);
const effectiveIban = const effectiveIban =
override?.data.billingIban !== undefined ? override.data.billingIban : listing.billingIban ?? targetIban; override?.data.billingIban !== undefined
? override.data.billingIban
: (listing.billingIban ?? targetIban);
if (!effectiveAccountName || !effectiveIban) { if (!effectiveAccountName || !effectiveIban) {
const t = pickTranslation(listing.translations); const t = pickTranslation(listing.translations);
missingFor.push(t?.slug || listing.id); missingFor.push(t?.slug || listing.id);
@ -173,13 +222,18 @@ export async function PATCH(req: Request) {
if (!hasGlobalDetails && missingFor.length) { if (!hasGlobalDetails && missingFor.length) {
return NextResponse.json( return NextResponse.json(
{ error: `Provide billing account name and IBAN globally or per listing (missing for: ${missingFor.join(', ')})` }, {
error: `Provide billing account name and IBAN globally or per listing (missing for: ${missingFor.join(", ")})`,
},
{ status: 400 }, { status: 400 },
); );
} }
if (!hasGlobalDetails && listings.length === 0) { if (!hasGlobalDetails && listings.length === 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'Add a billing account name and IBAN before enabling the billing assistant.' }, {
error:
"Add a billing account name and IBAN before enabling the billing assistant.",
},
{ status: 400 }, { status: 400 },
); );
} }
@ -187,29 +241,43 @@ export async function PATCH(req: Request) {
const tx: any[] = []; const tx: any[] = [];
if (Object.keys(userUpdates).length) { if (Object.keys(userUpdates).length) {
tx.push(prisma.user.update({ where: { id: user.id }, data: userUpdates })); tx.push(
prisma.user.update({ where: { id: user.id }, data: userUpdates }),
);
} }
listingUpdates.forEach((update) => { listingUpdates.forEach((update) => {
tx.push(prisma.listing.update({ where: { id: update.id }, data: update.data })); tx.push(
prisma.listing.update({ where: { id: update.id }, data: update.data }),
);
}); });
if (tx.length) { if (tx.length) {
await prisma.$transaction(tx); await prisma.$transaction(tx);
} else { } else {
return NextResponse.json({ error: 'No updates provided' }, { status: 400 }); return NextResponse.json(
{ error: "No updates provided" },
{ status: 400 },
);
} }
const { user: updatedUser, listings: updatedListings } = await loadState(session.userId); const { user: updatedUser, listings: updatedListings } = await loadState(
session.userId,
);
if (!updatedUser) { if (!updatedUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: "User not found" }, { status: 404 });
} }
return NextResponse.json(buildResponsePayload(updatedUser, updatedListings)); return NextResponse.json(
buildResponsePayload(updatedUser, updatedListings),
);
} catch (error) { } catch (error) {
console.error('Billing settings update failed', error); console.error("Billing settings update failed", error);
const status = (error as any)?.message === 'Unauthorized' ? 401 : 500; const status = (error as any)?.message === "Unauthorized" ? 401 : 500;
return NextResponse.json({ error: 'Failed to update billing settings' }, { status }); return NextResponse.json(
{ error: "Failed to update billing settings" },
{ status },
);
} }
} }
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";

View file

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

View file

@ -1,11 +1,11 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { useI18n } from '../../components/I18nProvider'; import { useI18n } from "../../components/I18nProvider";
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const { t } = useI18n(); const { t } = useI18n();
const [email, setEmail] = useState(''); const [email, setEmail] = useState("");
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -16,39 +16,49 @@ export default function ForgotPasswordPage() {
setSent(false); setSent(false);
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/auth/forgot', { const res = await fetch("/api/auth/forgot", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }), body: JSON.stringify({ email }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
setError(data.error || t('forgotError')); setError(data.error || t("forgotError"));
} else { } else {
setSent(true); setSent(true);
} }
} catch (err) { } catch (err) {
setError(t('forgotError')); setError(t("forgotError"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
return ( return (
<main className="panel" style={{ maxWidth: 480, margin: '40px auto' }}> <main className="panel" style={{ maxWidth: 480, margin: "40px auto" }}>
<h1>{t('forgotTitle')}</h1> <h1>{t("forgotTitle")}</h1>
<p style={{ color: '#cbd5e1', marginTop: 6 }}>{t('forgotLead')}</p> <p style={{ color: "#cbd5e1", marginTop: 6 }}>{t("forgotLead")}</p>
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 12, marginTop: 14 }}> <form
onSubmit={onSubmit}
style={{ display: "grid", gap: 12, marginTop: 14 }}
>
<label> <label>
{t('emailLabel')} {t("emailLabel")}
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required /> <input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label> </label>
<button className="button" type="submit" disabled={loading}> <button className="button" type="submit" disabled={loading}>
{loading ? t('submittingListing') : t('forgotSubmit')} {loading ? t("submittingListing") : t("forgotSubmit")}
</button> </button>
</form> </form>
{sent ? <p style={{ marginTop: 12, color: 'green' }}>{t('forgotSuccess')}</p> : null} {sent ? (
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null} <p style={{ marginTop: 12, color: "green" }}>{t("forgotSuccess")}</p>
) : null}
{error ? <p style={{ marginTop: 12, color: "red" }}>{error}</p> : null}
</main> </main>
); );
} }

View file

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

View file

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

View file

@ -1,20 +1,20 @@
'use client'; "use client";
import { Suspense, useEffect, useState } from 'react'; import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from "next/navigation";
import { useI18n } from '../../components/I18nProvider'; import { useI18n } from "../../components/I18nProvider";
function ResetForm() { function ResetForm() {
const { t } = useI18n(); const { t } = useI18n();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [password, setPassword] = useState(''); const [password, setPassword] = useState("");
const [token, setToken] = useState(''); const [token, setToken] = useState("");
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
const tok = searchParams.get('token') || ''; const tok = searchParams.get("token") || "";
setToken(tok); setToken(tok);
}, [searchParams]); }, [searchParams]);
@ -23,53 +23,74 @@ function ResetForm() {
setMessage(null); setMessage(null);
setError(null); setError(null);
if (!token) { if (!token) {
setError(t('resetMissingToken')); setError(t("resetMissingToken"));
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/auth/reset', { const res = await fetch("/api/auth/reset", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }), body: JSON.stringify({ token, password }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data.error || t('resetError')); setError(data.error || t("resetError"));
} else { } else {
setMessage(t('resetSuccess')); setMessage(t("resetSuccess"));
setPassword(''); setPassword("");
} }
} catch (err) { } catch (err) {
setError(t('resetError')); setError(t("resetError"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
return ( return (
<main className="panel" style={{ maxWidth: 480, margin: '40px auto' }}> <main className="panel" style={{ maxWidth: 480, margin: "40px auto" }}>
<h1>{t('resetTitle')}</h1> <h1>{t("resetTitle")}</h1>
<p style={{ color: '#cbd5e1', marginTop: 6 }}>{t('resetLead')}</p> <p style={{ color: "#cbd5e1", marginTop: 6 }}>{t("resetLead")}</p>
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 12, marginTop: 14 }}> <form
onSubmit={onSubmit}
style={{ display: "grid", gap: 12, marginTop: 14 }}
>
<label> <label>
{t('passwordLabel')} {t("passwordLabel")}
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} required /> <input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
</label> </label>
<button className="button" type="submit" disabled={loading}> <button className="button" type="submit" disabled={loading}>
{loading ? t('saving') : t('resetSubmit')} {loading ? t("saving") : t("resetSubmit")}
</button> </button>
</form> </form>
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null} {message ? (
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null} <p style={{ marginTop: 12, color: "green" }}>{message}</p>
{!token ? <p style={{ marginTop: 12, color: '#f59e0b' }}>{t('resetMissingToken')}</p> : null} ) : null}
{error ? <p style={{ marginTop: 12, color: "red" }}>{error}</p> : null}
{!token ? (
<p style={{ marginTop: 12, color: "#f59e0b" }}>
{t("resetMissingToken")}
</p>
) : null}
</main> </main>
); );
} }
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
return ( return (
<Suspense fallback={<main className="panel" style={{ maxWidth: 480, margin: '40px auto' }}><p>Loading</p></main>}> <Suspense
fallback={
<main className="panel" style={{ maxWidth: 480, margin: "40px auto" }}>
<p>Loading</p>
</main>
}
>
<ResetForm /> <ResetForm />
</Suspense> </Suspense>
); );

View file

@ -1,14 +1,19 @@
'use client'; "use client";
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from "react";
import { useI18n } from './I18nProvider'; import { useI18n } from "./I18nProvider";
type MonthView = { type MonthView = {
label: string; label: string;
days: { label: string; date: string; blocked: boolean; isFiller: boolean }[]; days: { label: string; date: string; blocked: boolean; isFiller: boolean }[];
}; };
function buildMonths(monthCount: number, blocked: Set<string>, startYear: number, startMonth: number): MonthView[] { function buildMonths(
monthCount: number,
blocked: Set<string>,
startYear: number,
startMonth: number,
): MonthView[] {
const months: MonthView[] = []; const months: MonthView[] = [];
const base = new Date(Date.UTC(startYear, startMonth, 1)); const base = new Date(Date.UTC(startYear, startMonth, 1));
@ -17,20 +22,28 @@ function buildMonths(monthCount: number, blocked: Set<string>, startYear: number
monthDate.setUTCMonth(base.getUTCMonth() + i); monthDate.setUTCMonth(base.getUTCMonth() + i);
const year = monthDate.getUTCFullYear(); const year = monthDate.getUTCFullYear();
const month = monthDate.getUTCMonth(); const month = monthDate.getUTCMonth();
const label = monthDate.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); const label = monthDate.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
});
const firstDay = new Date(Date.UTC(year, month, 1)); const firstDay = new Date(Date.UTC(year, month, 1));
const startWeekday = firstDay.getUTCDay(); // 0=Sun const startWeekday = firstDay.getUTCDay(); // 0=Sun
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
const days: MonthView['days'] = []; const days: MonthView["days"] = [];
for (let f = 0; f < startWeekday; f += 1) { for (let f = 0; f < startWeekday; f += 1) {
days.push({ label: '', date: '', blocked: false, isFiller: true }); days.push({ label: "", date: "", blocked: false, isFiller: true });
} }
for (let d = 1; d <= daysInMonth; d += 1) { for (let d = 1; d <= daysInMonth; d += 1) {
const date = new Date(Date.UTC(year, month, d)); const date = new Date(Date.UTC(year, month, d));
const iso = date.toISOString().slice(0, 10); const iso = date.toISOString().slice(0, 10);
days.push({ label: String(d), date: iso, blocked: blocked.has(iso), isFiller: false }); days.push({
label: String(d),
date: iso,
blocked: blocked.has(iso),
isFiller: false,
});
} }
months.push({ label, days }); months.push({ label, days });
@ -41,7 +54,13 @@ function buildMonths(monthCount: number, blocked: Set<string>, startYear: number
type AvailabilityResponse = { blockedDates: string[] }; type AvailabilityResponse = { blockedDates: string[] };
export default function AvailabilityCalendar({ listingId, hasCalendar }: { listingId: string; hasCalendar: boolean }) { export default function AvailabilityCalendar({
listingId,
hasCalendar,
}: {
listingId: string;
hasCalendar: boolean;
}) {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const today = useMemo(() => new Date(), []); const today = useMemo(() => new Date(), []);
const [month, setMonth] = useState<number>(today.getUTCMonth()); const [month, setMonth] = useState<number>(today.getUTCMonth());
@ -51,7 +70,10 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const monthCount = 1; const monthCount = 1;
const blockedSet = useMemo(() => new Set(blockedDates), [blockedDates]); const blockedSet = useMemo(() => new Set(blockedDates), [blockedDates]);
const monthViews = useMemo(() => buildMonths(monthCount, blockedSet, year, month), [monthCount, blockedSet, year, month]); const monthViews = useMemo(
() => buildMonths(monthCount, blockedSet, year, month),
[monthCount, blockedSet, year, month],
);
useEffect(() => { useEffect(() => {
if (!hasCalendar) return; if (!hasCalendar) return;
@ -62,20 +84,28 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi
month: String(month), month: String(month),
year: String(year), year: String(year),
months: String(monthCount), months: String(monthCount),
refresh: '1', refresh: "1",
}); });
fetch(`/api/listings/${listingId}/availability?${params.toString()}`, { cache: 'no-store', signal: controller.signal }) fetch(`/api/listings/${listingId}/availability?${params.toString()}`, {
cache: "no-store",
signal: controller.signal,
})
.then(async (res) => { .then(async (res) => {
const data: AvailabilityResponse = await res.json(); const data: AvailabilityResponse = await res.json();
if (!res.ok) throw new Error((data as any)?.error || 'Failed to load availability'); if (!res.ok)
throw new Error(
(data as any)?.error || "Failed to load availability",
);
return data; return data;
}) })
.then((data) => { .then((data) => {
setBlockedDates(Array.isArray(data.blockedDates) ? data.blockedDates : []); setBlockedDates(
Array.isArray(data.blockedDates) ? data.blockedDates : [],
);
}) })
.catch((err) => { .catch((err) => {
if (err.name === 'AbortError') return; if (err.name === "AbortError") return;
setError(err.message || 'Failed to load availability'); setError(err.message || "Failed to load availability");
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@ -93,7 +123,9 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi
() => () =>
Array.from({ length: 12 }, (_, m) => ({ Array.from({ length: 12 }, (_, m) => ({
value: m, value: m,
label: new Date(Date.UTC(2020, m, 1)).toLocaleString(locale, { month: 'long' }), label: new Date(Date.UTC(2020, m, 1)).toLocaleString(locale, {
month: "long",
}),
})), })),
[locale], [locale],
); );
@ -103,33 +135,88 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi
}, [today]); }, [today]);
return ( return (
<div style={{ display: 'grid', gap: 12, opacity: hasCalendar ? 1 : 0.5 }}> <div style={{ display: "grid", gap: 12, opacity: hasCalendar ? 1 : 0.5 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}> <div
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}> style={{
<span style={{ fontWeight: 700 }}>{t('availabilityTitle')}</span> display: "flex",
gap: 10,
alignItems: "center",
flexWrap: "wrap",
}}
>
<div
style={{
display: "flex",
gap: 8,
alignItems: "center",
flexWrap: "wrap",
}}
>
<span style={{ fontWeight: 700 }}>{t("availabilityTitle")}</span>
</div> </div>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> <div
<span className="badge secondary">{t('availabilityLegendBooked')}</span> style={{
<button type="button" className="button secondary" onClick={() => shiftMonth(-1)} disabled={!hasCalendar || loading} style={{ padding: '6px 10px' }}> marginLeft: "auto",
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
}}
>
<span className="badge secondary">
{t("availabilityLegendBooked")}
</span>
<button
type="button"
className="button secondary"
onClick={() => shiftMonth(-1)}
disabled={!hasCalendar || loading}
style={{ padding: "6px 10px" }}
>
</button> </button>
<button type="button" className="button secondary" onClick={() => shiftMonth(1)} disabled={!hasCalendar || loading} style={{ padding: '6px 10px' }}> <button
type="button"
className="button secondary"
onClick={() => shiftMonth(1)}
disabled={!hasCalendar || loading}
style={{ padding: "6px 10px" }}
>
</button> </button>
</div> </div>
</div> </div>
{error ? <div style={{ color: '#f87171', fontSize: 13 }}>{error}</div> : null} {error ? (
<div style={{ color: "#f87171", fontSize: 13 }}>{error}</div>
) : null}
{monthViews.map((monthView) => ( {monthViews.map((monthView) => (
<div key={monthView.label} className="panel" style={{ padding: 12 }}> <div key={monthView.label} className="panel" style={{ padding: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}> <div
<select value={month} onChange={(e) => setMonth(Number(e.target.value))} disabled={!hasCalendar || loading} style={{ flex: 1 }}> style={{
display: "flex",
gap: 8,
alignItems: "center",
marginBottom: 8,
}}
>
<select
value={month}
onChange={(e) => setMonth(Number(e.target.value))}
disabled={!hasCalendar || loading}
style={{ flex: 1 }}
>
{monthOptions.map((opt) => ( {monthOptions.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{opt.label} {opt.label}
</option> </option>
))} ))}
</select> </select>
<select value={year} onChange={(e) => setYear(Number(e.target.value))} disabled={!hasCalendar || loading} style={{ width: 96 }}> <select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
disabled={!hasCalendar || loading}
style={{ width: 96 }}
>
{yearOptions.map((y) => ( {yearOptions.map((y) => (
<option key={y} value={y}> <option key={y} value={y}>
{y} {y}
@ -139,15 +226,15 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi
</div> </div>
<div <div
style={{ style={{
display: 'grid', display: "grid",
gridTemplateColumns: 'repeat(7, 1fr)', gridTemplateColumns: "repeat(7, 1fr)",
gap: 6, gap: 6,
fontSize: 12, fontSize: 12,
textAlign: 'center', textAlign: "center",
}} }}
> >
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d) => ( {["S", "M", "T", "W", "T", "F", "S"].map((d) => (
<div key={d} style={{ color: '#94a3b8', fontWeight: 600 }}> <div key={d} style={{ color: "#94a3b8", fontWeight: 600 }}>
{d} {d}
</div> </div>
))} ))}
@ -157,13 +244,25 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi
style={{ style={{
height: 32, height: 32,
borderRadius: 8, borderRadius: 8,
background: day.isFiller ? 'transparent' : day.blocked ? 'rgba(248,113,113,0.2)' : 'rgba(148,163,184,0.1)', background: day.isFiller
color: day.isFiller ? 'transparent' : day.blocked ? '#ef4444' : '#e2e8f0', ? "transparent"
display: 'flex', : day.blocked
alignItems: 'center', ? "rgba(248,113,113,0.2)"
justifyContent: 'center', : "rgba(148,163,184,0.1)",
color: day.isFiller
? "transparent"
: day.blocked
? "#ef4444"
: "#e2e8f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
}} }}
aria-label={day.date ? `${day.date}${day.blocked ? ' (booked)' : ''}` : undefined} aria-label={
day.date
? `${day.date}${day.blocked ? " (booked)" : ""}`
: undefined
}
> >
{day.label} {day.label}
</div> </div>

View file

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

View file

@ -1,10 +1,10 @@
'use client'; "use client";
import Link from 'next/link'; import Link from "next/link";
import Image from 'next/image'; import Image from "next/image";
import { useEffect, useRef, useState, type SVGProps } from 'react'; import { useEffect, useRef, useState, type SVGProps } from "react";
import { useI18n } from './I18nProvider'; import { useI18n } from "./I18nProvider";
import logo from '../../img/logo.png'; import logo from "../../img/logo.png";
type SessionUser = { id: string; email: string; role: string; status: string }; type SessionUser = { id: string; email: string; role: string; status: string };
@ -12,21 +12,21 @@ function Icon({ name }: { name: string }) {
const common: SVGProps<SVGSVGElement> = { const common: SVGProps<SVGSVGElement> = {
width: 16, width: 16,
height: 16, height: 16,
stroke: 'currentColor', stroke: "currentColor",
fill: 'none', fill: "none",
strokeWidth: 1.6, strokeWidth: 1.6,
strokeLinecap: 'round', strokeLinecap: "round",
strokeLinejoin: 'round', strokeLinejoin: "round",
}; };
switch (name) { switch (name) {
case 'profile': case "profile":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<circle cx="12" cy="8" r="4" /> <circle cx="12" cy="8" r="4" />
<path d="M5 20c0-3.3 3.1-6 7-6s7 2.7 7 6" /> <path d="M5 20c0-3.3 3.1-6 7-6s7 2.7 7 6" />
</svg> </svg>
); );
case 'list': case "list":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<path d="M9 6h11" /> <path d="M9 6h11" />
@ -37,14 +37,14 @@ function Icon({ name }: { name: string }) {
<path d="M4 18h0.01" /> <path d="M4 18h0.01" />
</svg> </svg>
); );
case 'plus': case "plus":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<path d="M12 5v14" /> <path d="M12 5v14" />
<path d="M5 12h14" /> <path d="M5 12h14" />
</svg> </svg>
); );
case 'logout': case "logout":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<path d="M9 21H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3" /> <path d="M9 21H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3" />
@ -52,7 +52,7 @@ function Icon({ name }: { name: string }) {
<path d="M21 12H9" /> <path d="M21 12H9" />
</svg> </svg>
); );
case 'login': case "login":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<path d="M15 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3" /> <path d="M15 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3" />
@ -60,7 +60,7 @@ function Icon({ name }: { name: string }) {
<path d="M15 12H3" /> <path d="M15 12H3" />
</svg> </svg>
); );
case 'users': case "users":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
@ -69,13 +69,13 @@ function Icon({ name }: { name: string }) {
<path d="M16 3.13a4 4 0 0 1 0 7.75" /> <path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg> </svg>
); );
case 'check': case "check":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<path d="M20 6L9 17l-5-5" /> <path d="M20 6L9 17l-5-5" />
</svg> </svg>
); );
case 'globe': case "globe":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
@ -83,7 +83,7 @@ function Icon({ name }: { name: string }) {
<path d="M12 3a15.3 15.3 0 0 1 4 9 15.3 15.3 0 0 1-4 9 15.3 15.3 0 0 1-4-9 15.3 15.3 0 0 1 4-9z" /> <path d="M12 3a15.3 15.3 0 0 1 4 9 15.3 15.3 0 0 1-4 9 15.3 15.3 0 0 1-4-9 15.3 15.3 0 0 1 4-9z" />
</svg> </svg>
); );
case 'monitor': case "monitor":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<rect x="3" y="4" width="18" height="14" rx="2" ry="2" /> <rect x="3" y="4" width="18" height="14" rx="2" ry="2" />
@ -91,21 +91,21 @@ function Icon({ name }: { name: string }) {
<path d="M9 14l2-3 2 2 2-4" /> <path d="M9 14l2-3 2 2 2-4" />
</svg> </svg>
); );
case 'settings': case "settings":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<path d="M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" /> <path d="M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l0.06 0.06a2 2 0 1 1-2.83 2.83l-0.06-0.06A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82l-0.06 0.06a2 2 0 1 1-2.83-2.83l0.06-0.06A1.65 1.65 0 0 0 8.6 15a1.65 1.65 0 0 0-1.82-.33l-0.06 0.06a2 2 0 1 1-2.83-2.83l0.06-0.06A1.65 1.65 0 0 0 5 9.4a1.65 1.65 0 0 0-.6-1 1.65 1.65 0 0 0-1.82-.33l-0.06-0.06a2 2 0 1 1 2.83-2.83l0.06 0.06A1.65 1.65 0 0 0 9 5a1.65 1.65 0 0 0 .33-1.82l0.06-0.06a2 2 0 1 1 2.83 2.83l-0.06 0.06A1.65 1.65 0 0 0 15 8.6a1.65 1.65 0 0 0 1 .6 1.65 1.65 0 0 0 1.82-.33l0.06-0.06a2 2 0 1 1 2.83 2.83l-0.06 0.06A1.65 1.65 0 0 0 19.4 15z" /> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l0.06 0.06a2 2 0 1 1-2.83 2.83l-0.06-0.06A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82l-0.06 0.06a2 2 0 1 1-2.83-2.83l0.06-0.06A1.65 1.65 0 0 0 8.6 15a1.65 1.65 0 0 0-1.82-.33l-0.06 0.06a2 2 0 1 1-2.83-2.83l0.06-0.06A1.65 1.65 0 0 0 5 9.4a1.65 1.65 0 0 0-.6-1 1.65 1.65 0 0 0-1.82-.33l-0.06-0.06a2 2 0 1 1 2.83-2.83l0.06 0.06A1.65 1.65 0 0 0 9 5a1.65 1.65 0 0 0 .33-1.82l0.06-0.06a2 2 0 1 1 2.83 2.83l-0.06 0.06A1.65 1.65 0 0 0 15 8.6a1.65 1.65 0 0 0 1 .6 1.65 1.65 0 0 0 1.82-.33l0.06-0.06a2 2 0 1 1 2.83 2.83l-0.06 0.06A1.65 1.65 0 0 0 19.4 15z" />
</svg> </svg>
); );
case 'admin': case "admin":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<path d="M12 2l7 4v6c0 5-3 9-7 10-4-1-7-5-7-10V6l7-4z" /> <path d="M12 2l7 4v6c0 5-3 9-7 10-4-1-7-5-7-10V6l7-4z" />
<path d="M9.5 12.5l1.8 1.8 3.7-3.7" /> <path d="M9.5 12.5l1.8 1.8 3.7-3.7" />
</svg> </svg>
); );
case 'chevron-down': case "chevron-down":
return ( return (
<svg {...common} viewBox="0 0 24 24" aria-hidden> <svg {...common} viewBox="0 0 24 24" aria-hidden>
<path d="M6 9l6 6 6-6" /> <path d="M6 9l6 6 6-6" />
@ -127,7 +127,7 @@ export default function NavBar() {
async function loadUser() { async function loadUser() {
try { try {
const res = await fetch('/api/auth/me', { cache: 'no-store' }); const res = await fetch("/api/auth/me", { cache: "no-store" });
const data = await res.json(); const data = await res.json();
if (data.user) setUser(data.user); if (data.user) setUser(data.user);
else setUser(null); else setUser(null);
@ -149,31 +149,34 @@ export default function NavBar() {
useEffect(() => { useEffect(() => {
const role = user?.role; const role = user?.role;
const canSeeApprovals = role === 'ADMIN' || role === 'LISTING_MODERATOR' || role === 'USER_MODERATOR'; const canSeeApprovals =
role === "ADMIN" ||
role === "LISTING_MODERATOR" ||
role === "USER_MODERATOR";
if (!canSeeApprovals) { if (!canSeeApprovals) {
setPendingCount(0); setPendingCount(0);
return; return;
} }
fetch('/api/admin/pending/count', { cache: 'no-store' }) fetch("/api/admin/pending/count", { cache: "no-store" })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
if (data && typeof data.total === 'number') setPendingCount(data.total); if (data && typeof data.total === "number") setPendingCount(data.total);
}) })
.catch(() => setPendingCount(0)); .catch(() => setPendingCount(0));
}, [user]); }, [user]);
async function logout() { async function logout() {
try { try {
await fetch('/api/auth/logout', { method: 'POST' }); await fetch("/api/auth/logout", { method: "POST" });
} catch (e) { } catch (e) {
// ignore // ignore
} }
setUser(null); setUser(null);
} }
const isAdmin = user?.role === 'ADMIN'; const isAdmin = user?.role === "ADMIN";
const isListingMod = user?.role === 'LISTING_MODERATOR'; const isListingMod = user?.role === "LISTING_MODERATOR";
const isUserMod = user?.role === 'USER_MODERATOR'; const isUserMod = user?.role === "USER_MODERATOR";
const showApprovals = Boolean(user && (isAdmin || isListingMod || isUserMod)); const showApprovals = Boolean(user && (isAdmin || isListingMod || isUserMod));
const showAdminMenu = Boolean(user && (showApprovals || isAdmin)); const showAdminMenu = Boolean(user && (showApprovals || isAdmin));
@ -181,37 +184,54 @@ export default function NavBar() {
if (!userMenuOpen && !adminMenuOpen) return; if (!userMenuOpen && !adminMenuOpen) return;
const onMouseDown = (e: MouseEvent) => { const onMouseDown = (e: MouseEvent) => {
const target = e.target as Node | null; const target = e.target as Node | null;
const insideAdmin = adminMenuRef.current && target && adminMenuRef.current.contains(target); const insideAdmin =
const insideUser = userMenuRef.current && target && userMenuRef.current.contains(target); adminMenuRef.current && target && adminMenuRef.current.contains(target);
const insideUser =
userMenuRef.current && target && userMenuRef.current.contains(target);
if (!insideAdmin) setAdminMenuOpen(false); if (!insideAdmin) setAdminMenuOpen(false);
if (!insideUser) setUserMenuOpen(false); if (!insideUser) setUserMenuOpen(false);
}; };
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === "Escape") {
setAdminMenuOpen(false); setAdminMenuOpen(false);
setUserMenuOpen(false); setUserMenuOpen(false);
} }
}; };
document.addEventListener('mousedown', onMouseDown); document.addEventListener("mousedown", onMouseDown);
document.addEventListener('keydown', onKeyDown); document.addEventListener("keydown", onKeyDown);
return () => { return () => {
document.removeEventListener('mousedown', onMouseDown); document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener('keydown', onKeyDown); document.removeEventListener("keydown", onKeyDown);
}; };
}, [adminMenuOpen, userMenuOpen]); }, [adminMenuOpen, userMenuOpen]);
return ( return (
<header style={{ padding: '12px 20px', borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <header
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}> 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"> <Link href="/" className="brand">
<Image src={logo} alt="Lomavuokraus.fi logo" width={34} height={48} priority style={{ width: 34, height: 'auto' }} /> <Image
<span className="brand-text">{t('brand')}</span> src={logo}
alt="Lomavuokraus.fi logo"
width={34}
height={48}
priority
style={{ width: 34, height: "auto" }}
/>
<span className="brand-text">{t("brand")}</span>
</Link> </Link>
<Link href="/listings" className="button secondary"> <Link href="/listings" className="button secondary">
<Icon name="list" /> {t('navBrowse')} <Icon name="list" /> {t("navBrowse")}
</Link> </Link>
</div> </div>
<nav style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <nav style={{ display: "flex", alignItems: "center", gap: 12 }}>
{user ? ( {user ? (
<> <>
<div className="nav-admin" ref={userMenuRef}> <div className="nav-admin" ref={userMenuRef}>
@ -221,24 +241,49 @@ export default function NavBar() {
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={userMenuOpen} aria-expanded={userMenuOpen}
onClick={() => setUserMenuOpen((v) => !v)} onClick={() => setUserMenuOpen((v) => !v)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }} style={{
display: "inline-flex",
alignItems: "center",
gap: 10,
}}
> >
<div style={{ display: 'grid', gap: 2, textAlign: 'left' }}> <div style={{ display: "grid", gap: 2, textAlign: "left" }}>
<span style={{ fontWeight: 600 }}>{user.email}</span> <span style={{ fontWeight: 600 }}>{user.email}</span>
<span style={{ fontSize: 12, opacity: 0.7 }}>{user.role}</span> <span style={{ fontSize: 12, opacity: 0.7 }}>
{user.role}
</span>
</div> </div>
<Icon name="chevron-down" /> <Icon name="chevron-down" />
</button> </button>
{userMenuOpen ? ( {userMenuOpen ? (
<div className="nav-admin-menu" role="menu" aria-label={t('navProfile')}> <div
<Link href="/me" className="nav-admin-item button secondary" role="menuitem" onClick={() => setUserMenuOpen(false)}> className="nav-admin-menu"
<Icon name="profile" /> {t('navProfile')} role="menu"
aria-label={t("navProfile")}
>
<Link
href="/me"
className="nav-admin-item button secondary"
role="menuitem"
onClick={() => setUserMenuOpen(false)}
>
<Icon name="profile" /> {t("navProfile")}
</Link> </Link>
<Link href="/listings/mine" className="nav-admin-item button secondary" role="menuitem" onClick={() => setUserMenuOpen(false)}> <Link
<Icon name="list" /> {t('navMyListings')} href="/listings/mine"
className="nav-admin-item button secondary"
role="menuitem"
onClick={() => setUserMenuOpen(false)}
>
<Icon name="list" /> {t("navMyListings")}
</Link> </Link>
<Link href="/listings/new" className="nav-admin-item button secondary" role="menuitem" onClick={() => setUserMenuOpen(false)}> <Link
<Icon name="plus" /> {t('navNewListing')} href="/listings/new"
className="nav-admin-item button secondary"
role="menuitem"
onClick={() => setUserMenuOpen(false)}
>
<Icon name="plus" /> {t("navNewListing")}
</Link> </Link>
<button <button
type="button" type="button"
@ -249,7 +294,7 @@ export default function NavBar() {
logout(); logout();
}} }}
> >
<Icon name="logout" /> {t('navLogout')} <Icon name="logout" /> {t("navLogout")}
</button> </button>
</div> </div>
) : null} ) : null}
@ -262,41 +307,79 @@ export default function NavBar() {
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={adminMenuOpen} aria-expanded={adminMenuOpen}
onClick={() => setAdminMenuOpen((v) => !v)} onClick={() => setAdminMenuOpen((v) => !v)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
}}
> >
<Icon name="admin" /> {t('navAdmin')} <Icon name="admin" /> {t("navAdmin")}
{showApprovals && pendingCount > 0 ? ( {showApprovals && pendingCount > 0 ? (
<span className="nav-admin-badge" aria-label={t('approvalsPending', { count: pendingCount })}> <span
{t('approvalsBadge', { count: pendingCount })} className="nav-admin-badge"
aria-label={t("approvalsPending", {
count: pendingCount,
})}
>
{t("approvalsBadge", { count: pendingCount })}
</span> </span>
) : null} ) : null}
<Icon name="chevron-down" /> <Icon name="chevron-down" />
</button> </button>
{adminMenuOpen ? ( {adminMenuOpen ? (
<div className="nav-admin-menu" role="menu" aria-label={t('navAdmin')}> <div
className="nav-admin-menu"
role="menu"
aria-label={t("navAdmin")}
>
{showApprovals ? ( {showApprovals ? (
<Link href="/admin/pending" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}> <Link
<Icon name="check" /> {t('navApprovals')} href="/admin/pending"
className="nav-admin-item button secondary"
role="menuitem"
onClick={() => setAdminMenuOpen(false)}
>
<Icon name="check" /> {t("navApprovals")}
{pendingCount > 0 ? ( {pendingCount > 0 ? (
<span className="nav-admin-badge" aria-label={t('approvalsPending', { count: pendingCount })}> <span
{t('approvalsBadge', { count: pendingCount })} className="nav-admin-badge"
aria-label={t("approvalsPending", {
count: pendingCount,
})}
>
{t("approvalsBadge", { count: pendingCount })}
</span> </span>
) : null} ) : null}
</Link> </Link>
) : null} ) : null}
{isAdmin ? ( {isAdmin ? (
<Link href="/admin/users" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}> <Link
<Icon name="users" /> {t('navUsers')} href="/admin/users"
className="nav-admin-item button secondary"
role="menuitem"
onClick={() => setAdminMenuOpen(false)}
>
<Icon name="users" /> {t("navUsers")}
</Link> </Link>
) : null} ) : null}
{isAdmin ? ( {isAdmin ? (
<Link href="/admin/monitor" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}> <Link
<Icon name="monitor" /> {t('navMonitoring')} href="/admin/monitor"
className="nav-admin-item button secondary"
role="menuitem"
onClick={() => setAdminMenuOpen(false)}
>
<Icon name="monitor" /> {t("navMonitoring")}
</Link> </Link>
) : null} ) : null}
{isAdmin ? ( {isAdmin ? (
<Link href="/admin/settings" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}> <Link
<Icon name="settings" /> {t('navSettings')} href="/admin/settings"
className="nav-admin-item button secondary"
role="menuitem"
onClick={() => setAdminMenuOpen(false)}
>
<Icon name="settings" /> {t("navSettings")}
</Link> </Link>
) : null} ) : null}
</div> </div>
@ -307,16 +390,16 @@ export default function NavBar() {
) : ( ) : (
<> <>
<Link href="/auth/login" className="button secondary"> <Link href="/auth/login" className="button secondary">
<Icon name="login" /> {t('navLogin')} <Icon name="login" /> {t("navLogin")}
</Link> </Link>
<Link href="/auth/register" className="button"> <Link href="/auth/register" className="button">
<Icon name="plus" /> {t('navSignup')} <Icon name="plus" /> {t("navSignup")}
</Link> </Link>
</> </>
)} )}
<label className="language-wrapper"> <label className="language-wrapper">
<select <select
aria-label={t('navLanguage')} aria-label={t("navLanguage")}
value={locale} value={locale}
onChange={(e) => setLocale(e.target.value as any)} onChange={(e) => setLocale(e.target.value as any)}
className="language-select" className="language-select"

View file

@ -1,25 +1,33 @@
'use client'; "use client";
import Link from 'next/link'; import Link from "next/link";
import { useI18n } from './I18nProvider'; import { useI18n } from "./I18nProvider";
export default function SiteFooter() { export default function SiteFooter() {
const { t } = useI18n(); const { t } = useI18n();
const version = process.env.NEXT_PUBLIC_VERSION || 'dev'; const version = process.env.NEXT_PUBLIC_VERSION || "dev";
return ( return (
<footer className="site-footer"> <footer className="site-footer">
<div className="footer-row"> <div className="footer-row">
<span className="footer-text" style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}> <span
<Link href="/about">{t('footerAbout')}</Link> className="footer-text"
<Link href="/pricing">{t('footerPricing')}</Link> style={{
<Link href="/privacy">{t('footerPrivacy')}</Link> display: "flex",
gap: 10,
alignItems: "center",
flexWrap: "wrap",
}}
>
<Link href="/about">{t("footerAbout")}</Link>
<Link href="/pricing">{t("footerPricing")}</Link>
<Link href="/privacy">{t("footerPrivacy")}</Link>
</span> </span>
<span className="footer-text"> <span className="footer-text">
Version <code>{version}</code> Version <code>{version}</code>
</span> </span>
</div> </div>
<p className="footer-cookie">{t('footerCookieNotice')}</p> <p className="footer-cookie">{t("footerCookieNotice")}</p>
</footer> </footer>
); );
} }

View file

@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap'); @import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap");
:root { :root {
--bg: #0f172a; --bg: #0f172a;
@ -16,9 +16,18 @@
body { body {
margin: 0; margin: 0;
font-family: 'Space Grotesk', 'Helvetica Neue', sans-serif; font-family: "Space Grotesk", "Helvetica Neue", sans-serif;
background: radial-gradient(circle at 20% 20%, rgba(34, 211, 238, 0.08), transparent 30%), background:
radial-gradient(circle at 80% 0%, rgba(14, 165, 233, 0.12), transparent 35%), 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); var(--bg);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
@ -129,7 +138,10 @@ p {
text-decoration: none; text-decoration: none;
background: var(--accent); background: var(--accent);
color: #0b1224; color: #0b1224;
transition: transform 120ms ease, box-shadow 180ms ease, background 120ms ease; transition:
transform 120ms ease,
box-shadow 180ms ease,
background 120ms ease;
box-shadow: 0 15px 40px rgba(34, 211, 238, 0.16); box-shadow: 0 15px 40px rgba(34, 211, 238, 0.16);
} }
@ -246,7 +258,11 @@ p {
} }
.latest-cover.placeholder { .latest-cover.placeholder {
background: linear-gradient(130deg, rgba(34, 211, 238, 0.12), rgba(14, 165, 233, 0.12)); background: linear-gradient(
130deg,
rgba(34, 211, 238, 0.12),
rgba(14, 165, 233, 0.12)
);
} }
.latest-meta { .latest-meta {
@ -269,7 +285,9 @@ p {
border-radius: 999px; border-radius: 999px;
background: rgba(148, 163, 184, 0.4); background: rgba(148, 163, 184, 0.4);
cursor: pointer; cursor: pointer;
transition: transform 120ms ease, background 120ms ease; transition:
transform 120ms ease,
background 120ms ease;
} }
.dot.active { .dot.active {
@ -298,7 +316,11 @@ p {
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
background: linear-gradient(145deg, rgba(34, 211, 238, 0.04), rgba(14, 165, 233, 0.06)); background: linear-gradient(
145deg,
rgba(34, 211, 238, 0.04),
rgba(14, 165, 233, 0.06)
);
color: var(--muted); color: var(--muted);
z-index: 1; z-index: 1;
} }
@ -308,7 +330,10 @@ p {
border-radius: 14px; border-radius: 14px;
padding: 10px; padding: 10px;
background: rgba(255, 255, 255, 0.01); background: rgba(255, 255, 255, 0.01);
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease; transition:
border-color 120ms ease,
transform 120ms ease,
box-shadow 120ms ease;
} }
.listing-card:hover { .listing-card:hover {
@ -386,9 +411,17 @@ p {
padding: 12px 14px; padding: 12px 14px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.25); border: 1px solid rgba(148, 163, 184, 0.25);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.04)); background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.02),
rgba(255, 255, 255, 0.04)
);
cursor: pointer; cursor: pointer;
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease, background 120ms ease; transition:
border-color 120ms ease,
box-shadow 120ms ease,
transform 120ms ease,
background 120ms ease;
color: var(--text); color: var(--text);
width: 100%; width: 100%;
text-align: left; text-align: left;
@ -402,7 +435,11 @@ p {
.amenity-option.selected { .amenity-option.selected {
border-color: var(--accent-strong); border-color: var(--accent-strong);
box-shadow: 0 12px 36px rgba(14, 165, 233, 0.2); box-shadow: 0 12px 36px rgba(14, 165, 233, 0.2);
background: linear-gradient(145deg, rgba(34, 211, 238, 0.08), rgba(14, 165, 233, 0.12)); background: linear-gradient(
145deg,
rgba(34, 211, 238, 0.08),
rgba(14, 165, 233, 0.12)
);
} }
.amenity-option-meta { .amenity-option-meta {
@ -462,7 +499,10 @@ p {
color: var(--text); color: var(--text);
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 700;
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease; transition:
border-color 120ms ease,
background 120ms ease,
transform 120ms ease;
} }
.ev-toggle:hover { .ev-toggle:hover {
@ -537,7 +577,7 @@ code {
background: rgba(148, 163, 184, 0.1); background: rgba(148, 163, 184, 0.1);
padding: 3px 6px; padding: 3px 6px;
border-radius: 8px; border-radius: 8px;
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace; font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
} }
.env-card { .env-card {
@ -563,7 +603,9 @@ textarea {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
color: var(--text); color: var(--text);
font-size: 15px; font-size: 15px;
transition: border-color 120ms ease, box-shadow 120ms ease; transition:
border-color 120ms ease,
box-shadow 120ms ease;
} }
select { select {
@ -577,13 +619,18 @@ select {
color: var(--text); color: var(--text);
font-size: 15px; font-size: 15px;
line-height: 1.3; line-height: 1.3;
transition: border-color 120ms ease, box-shadow 120ms ease; transition:
border-color 120ms ease,
box-shadow 120ms ease;
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
appearance: none; appearance: none;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%); linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: right 12px center, right 6px center; background-position:
right 12px center,
right 6px center;
background-size: 7px 7px; background-size: 7px 7px;
background-repeat: no-repeat; background-repeat: no-repeat;
} }

View file

@ -1,12 +1,12 @@
import type { Metadata } from 'next'; import type { Metadata } from "next/dist/types";
import './globals.css'; import "./globals.css";
import NavBar from './components/NavBar'; import NavBar from "./components/NavBar";
import { I18nProvider } from './components/I18nProvider'; import { I18nProvider } from "./components/I18nProvider";
import SiteFooter from './components/SiteFooter'; import SiteFooter from "./components/SiteFooter";
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Lomavuokraus.fi', title: "Lomavuokraus.fi",
description: 'Modern vacation rentals in Finland.', description: "Modern vacation rentals in Finland.",
}; };
export default function RootLayout({ export default function RootLayout({

View file

@ -1,53 +1,69 @@
import type { Metadata } from 'next'; import type { Metadata } from "next/dist/types";
import { ListingStatus } from '@prisma/client'; import { ListingStatus } from "@prisma/client";
import Link from 'next/link'; import Link from "next/link";
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation";
import { cookies, headers } from 'next/headers'; import { cookies, headers } from "next/headers";
import { getListingBySlug, DEFAULT_LOCALE, withResolvedListingImages } from '../../../lib/listings'; import {
import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing'; getListingBySlug,
import { resolveLocale, t as translate } from '../../../lib/i18n'; DEFAULT_LOCALE,
import AvailabilityCalendar from '../../components/AvailabilityCalendar'; withResolvedListingImages,
import { verifyAccessToken } from '../../../lib/jwt'; } from "../../../lib/listings";
import { getSiteSettings } from '../../../lib/settings'; import { SAMPLE_LISTING_SLUGS } from "../../../lib/sampleListing";
import type { UrlObject } from 'url'; import { resolveLocale, t as translate } from "../../../lib/i18n";
import AvailabilityCalendar from "../../components/AvailabilityCalendar";
import { verifyAccessToken } from "../../../lib/jwt";
import { getSiteSettings } from "../../../lib/settings";
import type { UrlObject } from "url";
type ListingPageProps = { type ListingPageProps = {
params: { slug: string }; params: { slug: string };
}; };
const amenityIcons: Record<string, string> = { const amenityIcons: Record<string, string> = {
sauna: '🧖', sauna: "🧖",
fireplace: '🔥', fireplace: "🔥",
wifi: '📶', wifi: "📶",
pets: '🐾', pets: "🐾",
lake: '🌊', lake: "🌊",
ac: '❄️', ac: "❄️",
ev: '⚡', ev: "⚡",
evOnSite: '🔌', evOnSite: "🔌",
kitchen: '🍽️', kitchen: "🍽️",
dishwasher: '🧼', dishwasher: "🧼",
washer: '🧺', washer: "🧺",
barbecue: '🍖', barbecue: "🍖",
microwave: '🍲', microwave: "🍲",
parking: '🅿️', parking: "🅿️",
accessible: '♿', accessible: "♿",
ski: '⛷️', ski: "⛷️",
}; };
export async function generateMetadata({ params }: ListingPageProps): Promise<Metadata> { export async function generateMetadata({
const translation = await getListingBySlug({ slug: params.slug, locale: DEFAULT_LOCALE }); params,
}: ListingPageProps): Promise<Metadata> {
const translation = await getListingBySlug({
slug: params.slug,
locale: DEFAULT_LOCALE,
});
return { return {
title: translation ? `${translation.title} | Lomavuokraus.fi` : `${params.slug} | Lomavuokraus.fi`, title: translation
? `${translation.title} | Lomavuokraus.fi`
: `${params.slug} | Lomavuokraus.fi`,
description: translation?.teaser ?? translation?.description?.slice(0, 140), description: translation?.teaser ?? translation?.description?.slice(0, 140),
}; };
} }
export default async function ListingPage({ params }: ListingPageProps) { export default async function ListingPage({ params }: ListingPageProps) {
const cookieStore = cookies(); const cookieStore = await cookies();
const locale = resolveLocale({ cookieLocale: cookieStore.get('locale')?.value, acceptLanguage: headers().get('accept-language') }); const headerList = await headers();
const t = (key: any, vars?: Record<string, string | number>) => translate(locale, key as any, vars); const locale = resolveLocale({
const sessionToken = cookieStore.get('session_token')?.value; cookieLocale: cookieStore.get("locale")?.value,
acceptLanguage: headerList.get("accept-language"),
});
const t = (key: any, vars?: Record<string, string | number>) =>
translate(locale, key as any, vars);
const sessionToken = cookieStore.get("session_token")?.value;
let viewerId: string | null = null; let viewerId: string | null = null;
if (sessionToken) { if (sessionToken) {
try { try {
@ -65,98 +81,184 @@ export default async function ListingPage({ params }: ListingPageProps) {
locale: locale ?? DEFAULT_LOCALE, locale: locale ?? DEFAULT_LOCALE,
includeOwnerDraftsForUserId: viewerId ?? undefined, includeOwnerDraftsForUserId: viewerId ?? undefined,
}); });
const translation = translationRaw ? withResolvedListingImages(translationRaw) : null; const translation = translationRaw
? withResolvedListingImages(translationRaw)
: null;
if (!translation) { if (!translation) {
notFound(); notFound();
} }
const { listing, title, description, teaser, locale: translationLocale } = translation; const {
const isSample = listing.isSample || listing.contactEmail === 'host@lomavuokraus.fi' || SAMPLE_LISTING_SLUGS.includes(params.slug); listing,
title,
description,
teaser,
locale: translationLocale,
} = translation;
const isSample =
listing.isSample ||
listing.contactEmail === "host@lomavuokraus.fi" ||
SAMPLE_LISTING_SLUGS.includes(params.slug);
const calendarUrls = (listing.calendarUrls ?? []).filter((url) => { const calendarUrls = (listing.calendarUrls ?? []).filter((url) => {
if (!url) return false; if (!url) return false;
try { try {
const parsed = new URL(url); const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:'; return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch { } catch {
return false; return false;
} }
}); });
const hasCalendar = calendarUrls.length > 0; const hasCalendar = calendarUrls.length > 0;
const amenities = [ const amenities = [
listing.hasSauna ? { icon: amenityIcons.sauna, label: t('amenitySauna') } : null, listing.hasSauna
listing.hasFireplace ? { icon: amenityIcons.fireplace, label: t('amenityFireplace') } : null, ? { icon: amenityIcons.sauna, label: t("amenitySauna") }
listing.hasWifi ? { icon: amenityIcons.wifi, label: t('amenityWifi') } : null, : null,
listing.petsAllowed ? { icon: amenityIcons.pets, label: t('amenityPets') } : null, listing.hasFireplace
listing.byTheLake ? { icon: amenityIcons.lake, label: t('amenityLake') } : null, ? { icon: amenityIcons.fireplace, label: t("amenityFireplace") }
listing.hasAirConditioning ? { icon: amenityIcons.ac, label: t('amenityAirConditioning') } : null, : null,
listing.evChargingOnSite ? { icon: amenityIcons.evOnSite, label: t('amenityEvOnSite') } : null, listing.hasWifi
listing.evChargingAvailable && !listing.evChargingOnSite ? { icon: amenityIcons.ev, label: t('amenityEvNearby') } : null, ? { icon: amenityIcons.wifi, label: t("amenityWifi") }
listing.wheelchairAccessible ? { icon: amenityIcons.accessible, label: t('amenityWheelchairAccessible') } : null, : null,
listing.hasSkiPass ? { icon: amenityIcons.ski, label: t('amenitySkiPass') } : null, listing.petsAllowed
listing.hasKitchen ? { icon: amenityIcons.kitchen, label: t('amenityKitchen') } : null, ? { icon: amenityIcons.pets, label: t("amenityPets") }
listing.hasDishwasher ? { icon: amenityIcons.dishwasher, label: t('amenityDishwasher') } : null, : null,
listing.hasWashingMachine ? { icon: amenityIcons.washer, label: t('amenityWashingMachine') } : null, listing.byTheLake
listing.hasBarbecue ? { icon: amenityIcons.barbecue, label: t('amenityBarbecue') } : null, ? { icon: amenityIcons.lake, label: t("amenityLake") }
listing.hasMicrowave ? { icon: amenityIcons.microwave, label: t('amenityMicrowave') } : null, : null,
listing.hasFreeParking ? { icon: amenityIcons.parking, label: t('amenityFreeParking') } : null, listing.hasAirConditioning
? { icon: amenityIcons.ac, label: t("amenityAirConditioning") }
: null,
listing.evChargingOnSite
? { icon: amenityIcons.evOnSite, label: t("amenityEvOnSite") }
: null,
listing.evChargingAvailable && !listing.evChargingOnSite
? { icon: amenityIcons.ev, label: t("amenityEvNearby") }
: null,
listing.wheelchairAccessible
? {
icon: amenityIcons.accessible,
label: t("amenityWheelchairAccessible"),
}
: null,
listing.hasSkiPass
? { icon: amenityIcons.ski, label: t("amenitySkiPass") }
: null,
listing.hasKitchen
? { icon: amenityIcons.kitchen, label: t("amenityKitchen") }
: null,
listing.hasDishwasher
? { icon: amenityIcons.dishwasher, label: t("amenityDishwasher") }
: null,
listing.hasWashingMachine
? { icon: amenityIcons.washer, label: t("amenityWashingMachine") }
: null,
listing.hasBarbecue
? { icon: amenityIcons.barbecue, label: t("amenityBarbecue") }
: null,
listing.hasMicrowave
? { icon: amenityIcons.microwave, label: t("amenityMicrowave") }
: null,
listing.hasFreeParking
? { icon: amenityIcons.parking, label: t("amenityFreeParking") }
: null,
].filter(Boolean) as { icon: string; label: string }[]; ].filter(Boolean) as { icon: string; label: string }[];
const addressLine = `${listing.streetAddress ? `${listing.streetAddress}, ` : ''}${listing.city}, ${listing.region}, ${listing.country}`; const addressLine = `${listing.streetAddress ? `${listing.streetAddress}, ` : ""}${listing.city}, ${listing.region}, ${listing.country}`;
const capacityParts = [ const capacityParts = [
listing.maxGuests ? t('capacityGuests', { count: listing.maxGuests }) : null, listing.maxGuests
listing.bedrooms ? t('capacityBedrooms', { count: listing.bedrooms }) : null, ? t("capacityGuests", { count: listing.maxGuests })
listing.beds ? t('capacityBeds', { count: listing.beds }) : null, : null,
listing.bathrooms ? t('capacityBathrooms', { count: listing.bathrooms }) : null, listing.bedrooms
? t("capacityBedrooms", { count: listing.bedrooms })
: null,
listing.beds ? t("capacityBeds", { count: listing.beds }) : null,
listing.bathrooms
? t("capacityBathrooms", { count: listing.bathrooms })
: null,
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
const capacityLine = capacityParts.length ? capacityParts.join(' · ') : t('capacityUnknown'); const capacityLine = capacityParts.length
const contactParts = [listing.contactName, listing.contactEmail, listing.contactPhone].filter(Boolean) as string[]; ? capacityParts.join(" · ")
const contactLine = contactParts.length ? contactParts.join(' · ') : '—'; : t("capacityUnknown");
const canViewContact = !siteSettings.requireLoginForContactDetails || Boolean(viewerId); const contactParts = [
const loginRedirectUrl: UrlObject = { pathname: '/auth/login', query: { redirect: `/listings/${params.slug}` } }; listing.contactName,
const coverImage = listing.images.find((img) => img.isCover) ?? listing.images[0] ?? null; listing.contactEmail,
const priceCandidates = [listing.priceWeekdayEuros, listing.priceWeekendEuros].filter((p): p is number => typeof p === 'number'); listing.contactPhone,
const startingFromEuros = priceCandidates.length ? Math.min(...priceCandidates) : null; ].filter(Boolean) as string[];
const contactLine = contactParts.length ? contactParts.join(" · ") : "—";
const canViewContact =
!siteSettings.requireLoginForContactDetails || Boolean(viewerId);
const loginRedirectUrl: UrlObject = {
pathname: "/auth/login",
query: { redirect: `/listings/${params.slug}` },
};
const coverImage =
listing.images.find((img) => img.isCover) ?? listing.images[0] ?? null;
const priceCandidates = [
listing.priceWeekdayEuros,
listing.priceWeekendEuros,
].filter((p): p is number => typeof p === "number");
const startingFromEuros = priceCandidates.length
? Math.min(...priceCandidates)
: null;
const priceLine = const priceLine =
listing.priceWeekdayEuros || listing.priceWeekendEuros listing.priceWeekdayEuros || listing.priceWeekendEuros
? `${startingFromEuros !== null ? t('priceStartingFromShort', { price: startingFromEuros }) : ''}${ ? `${startingFromEuros !== null ? t("priceStartingFromShort", { price: startingFromEuros }) : ""}${
listing.priceWeekdayEuros || listing.priceWeekendEuros listing.priceWeekdayEuros || listing.priceWeekendEuros
? ` (${[listing.priceWeekdayEuros ? t('priceWeekdayShort', { price: listing.priceWeekdayEuros }) : null, listing.priceWeekendEuros ? t('priceWeekendShort', { price: listing.priceWeekendEuros }) : null] ? ` (${[
listing.priceWeekdayEuros
? t("priceWeekdayShort", { price: listing.priceWeekdayEuros })
: null,
listing.priceWeekendEuros
? t("priceWeekendShort", { price: listing.priceWeekendEuros })
: null,
]
.filter(Boolean) .filter(Boolean)
.join(' · ')})` .join(" · ")})`
: '' : ""
}` }`
: t('priceNotSet'); : t("priceNotSet");
const isDraftOrPending = listing.status !== ListingStatus.PUBLISHED; const isDraftOrPending = listing.status !== ListingStatus.PUBLISHED;
const isOwnerView = viewerId && listing.ownerId === viewerId; const isOwnerView = viewerId && listing.ownerId === viewerId;
return ( return (
<main className="listing-shell"> <main className="listing-shell">
<div className="breadcrumb"> <div className="breadcrumb">
<Link href="/">{t('homeCrumb')}</Link> / <span>{params.slug}</span> <Link href="/">{t("homeCrumb")}</Link> / <span>{params.slug}</span>
</div> </div>
<div className="listing-layout"> <div className="listing-layout">
<div className="panel listing-main"> <div className="panel listing-main">
{isDraftOrPending ? ( {isDraftOrPending ? (
<div className="badge warning" style={{ marginBottom: 10, display: 'inline-block' }}> <div
{isOwnerView ? t('statusLabel') : 'Status'}: {listing.status} className="badge warning"
style={{ marginBottom: 10, display: "inline-block" }}
>
{isOwnerView ? t("statusLabel") : "Status"}: {listing.status}
</div> </div>
) : null} ) : null}
{isSample ? ( {isSample ? (
<div className="badge warning" style={{ marginBottom: 10, display: 'inline-block' }}> <div
{t('sampleBadge')} className="badge warning"
style={{ marginBottom: 10, display: "inline-block" }}
>
{t("sampleBadge")}
</div> </div>
) : null} ) : null}
<h1>{title}</h1> <h1>{title}</h1>
<p style={{ marginTop: 8 }}>{teaser ?? description}</p> <p style={{ marginTop: 8 }}>{teaser ?? description}</p>
{listing.addressNote ? ( {listing.addressNote ? (
<div style={{ marginTop: 4, color: '#cbd5e1' }}> <div style={{ marginTop: 4, color: "#cbd5e1" }}>
<em>{listing.addressNote}</em> <em>{listing.addressNote}</em>
</div> </div>
) : null} ) : null}
{listing.externalUrl ? ( {listing.externalUrl ? (
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<a href={listing.externalUrl} target="_blank" rel="noreferrer" className="button secondary"> <a
{t('listingMoreInfo')} href={listing.externalUrl}
target="_blank"
rel="noreferrer"
className="button secondary"
>
{t("listingMoreInfo")}
</a> </a>
</div> </div>
) : null} ) : null}
@ -164,44 +266,60 @@ export default async function ListingPage({ params }: ListingPageProps) {
<div <div
style={{ style={{
marginTop: 16, marginTop: 16,
display: 'grid', display: "grid",
gap: 12, gap: 12,
gridTemplateColumns: 'minmax(240px, 1.4fr) minmax(240px, 1fr)', gridTemplateColumns: "minmax(240px, 1.4fr) minmax(240px, 1fr)",
alignItems: 'stretch', alignItems: "stretch",
}} }}
> >
<div className="panel" style={{ padding: 0, overflow: 'hidden' }}> <div className="panel" style={{ padding: 0, overflow: "hidden" }}>
{coverImage ? ( {coverImage ? (
<a href={coverImage.url || ''} target="_blank" rel="noreferrer" style={{ display: 'block', cursor: 'zoom-in' }}> <a
href={coverImage.url || ""}
target="_blank"
rel="noreferrer"
style={{ display: "block", cursor: "zoom-in" }}
>
<img <img
src={coverImage.url || ''} src={coverImage.url || ""}
alt={coverImage.altText ?? title} alt={coverImage.altText ?? title}
style={{ width: '100%', height: 280, objectFit: 'cover' }} style={{ width: "100%", height: 280, objectFit: "cover" }}
/> />
</a> </a>
) : ( ) : (
<div style={{ width: '100%', height: 280, background: 'linear-gradient(120deg, rgba(14,165,233,0.15), rgba(30,64,175,0.2))' }} /> <div
style={{
width: "100%",
height: 280,
background:
"linear-gradient(120deg, rgba(14,165,233,0.15), rgba(30,64,175,0.2))",
}}
/>
)} )}
</div> </div>
<div className="panel" style={{ padding: 12 }}> <div className="panel" style={{ padding: 12 }}>
<div style={{ position: 'relative' }}> <div style={{ position: "relative" }}>
<AvailabilityCalendar listingId={listing.id} hasCalendar={hasCalendar} /> <AvailabilityCalendar
listingId={listing.id}
hasCalendar={hasCalendar}
/>
{!hasCalendar ? ( {!hasCalendar ? (
<div <div
style={{ style={{
position: 'absolute', position: "absolute",
inset: 0, inset: 0,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
color: '#cbd5e1', color: "#cbd5e1",
fontWeight: 600, fontWeight: 600,
textAlign: 'center', textAlign: "center",
background: 'linear-gradient(135deg, rgba(15,23,42,0.55), rgba(15,23,42,0.65))', background:
"linear-gradient(135deg, rgba(15,23,42,0.55), rgba(15,23,42,0.65))",
borderRadius: 12, borderRadius: 12,
}} }}
> >
{t('availabilityMissing')} {t("availabilityMissing")}
</div> </div>
) : null} ) : null}
</div> </div>
@ -209,72 +327,126 @@ export default async function ListingPage({ params }: ListingPageProps) {
</div> </div>
)} )}
{listing.images.length > 0 ? ( {listing.images.length > 0 ? (
<div style={{ marginTop: 12, display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}> <div
style={{
marginTop: 12,
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
}}
>
{listing.images {listing.images
.filter((img) => Boolean(img.url)) .filter((img) => Boolean(img.url))
.map((img) => ( .map((img) => (
<figure key={img.id} style={{ border: '1px solid rgba(148, 163, 184, 0.25)', borderRadius: 12, overflow: 'hidden', background: 'rgba(255,255,255,0.03)' }}> <figure
<a href={img.url || ''} target="_blank" rel="noreferrer" style={{ display: 'block', cursor: 'zoom-in' }}> key={img.id}
<img src={img.url || ''} alt={img.altText ?? title} style={{ width: '100%', height: '200px', objectFit: 'cover' }} /> style={{
border: "1px solid rgba(148, 163, 184, 0.25)",
borderRadius: 12,
overflow: "hidden",
background: "rgba(255,255,255,0.03)",
}}
>
<a
href={img.url || ""}
target="_blank"
rel="noreferrer"
style={{ display: "block", cursor: "zoom-in" }}
>
<img
src={img.url || ""}
alt={img.altText ?? title}
style={{
width: "100%",
height: "200px",
objectFit: "cover",
}}
/>
</a> </a>
</figure> </figure>
))} ))}
</div> </div>
) : null} ) : null}
<div style={{ marginTop: 16, fontSize: 14, color: '#666' }}> <div style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
{t('localeLabel')}: <code>{translationLocale}</code> {t("localeLabel")}: <code>{translationLocale}</code>
</div> </div>
</div> </div>
<aside className="panel listing-aside"> <aside className="panel listing-aside">
<div className="fact-row"> <div className="fact-row">
<span aria-hidden className="amenity-icon">📍</span> <span aria-hidden className="amenity-icon">
📍
</span>
<div> <div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingLocation')}</div> <div style={{ color: "#cbd5e1", fontSize: 12 }}>
{t("listingLocation")}
</div>
<div>{addressLine}</div> <div>{addressLine}</div>
</div> </div>
</div> </div>
<div className="fact-row"> <div className="fact-row">
<span aria-hidden className="amenity-icon">👥</span> <span aria-hidden className="amenity-icon">
👥
</span>
<div> <div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingCapacity')}</div> <div style={{ color: "#cbd5e1", fontSize: 12 }}>
{t("listingCapacity")}
</div>
<div>{capacityLine}</div> <div>{capacityLine}</div>
</div> </div>
</div> </div>
<div className="fact-row"> <div className="fact-row">
<span aria-hidden className="amenity-icon">💶</span> <span aria-hidden className="amenity-icon">
💶
</span>
<div> <div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingPrices')}</div> <div style={{ color: "#cbd5e1", fontSize: 12 }}>
{t("listingPrices")}
</div>
<div>{priceLine}</div> <div>{priceLine}</div>
</div> </div>
</div> </div>
<div className="fact-row"> <div className="fact-row">
<span aria-hidden className="amenity-icon"></span> <span aria-hidden className="amenity-icon">
</span>
<div> <div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingContact')}</div> <div style={{ color: "#cbd5e1", fontSize: 12 }}>
{t("listingContact")}
</div>
{canViewContact ? ( {canViewContact ? (
<div>{contactLine}</div> <div>{contactLine}</div>
) : ( ) : (
<div style={{ marginTop: 4 }}> <div style={{ marginTop: 4 }}>
<Link href={loginRedirectUrl} className="button secondary"> <Link href={loginRedirectUrl} className="button secondary">
{t('contactLoginToView')} {t("contactLoginToView")}
</Link> </Link>
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="fact-row"> <div className="fact-row">
<span aria-hidden className="amenity-icon">📅</span> <span aria-hidden className="amenity-icon">
📅
</span>
<div> <div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('searchAvailability')}</div> <div style={{ color: "#cbd5e1", fontSize: 12 }}>
<div>{hasCalendar ? t('calendarConnected') : t('availabilityMissing')}</div> {t("searchAvailability")}
</div>
<div>
{hasCalendar
? t("calendarConnected")
: t("availabilityMissing")}
</div>
</div> </div>
</div> </div>
<div className="amenity-list"> <div className="amenity-list">
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingAmenities')}</div> <div style={{ color: "#cbd5e1", fontSize: 12 }}>
{t("listingAmenities")}
</div>
{amenities.length === 0 ? ( {amenities.length === 0 ? (
<div className="amenity-row" style={{ borderStyle: 'dashed' }}> <div className="amenity-row" style={{ borderStyle: "dashed" }}>
<span className="amenity-icon"></span> <span className="amenity-icon"></span>
<span>{t('listingNoAmenities')}</span> <span>{t("listingNoAmenities")}</span>
</div> </div>
) : ( ) : (
amenities.map((item) => ( amenities.map((item) => (

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,9 @@
'use client'; "use client";
import 'leaflet/dist/leaflet.css'; import "leaflet/dist/leaflet.css";
import Link from 'next/link'; import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from '../components/I18nProvider'; import { useI18n } from "../components/I18nProvider";
type ListingResult = { type ListingResult = {
id: string; id: string;
@ -61,41 +61,42 @@ function haversineKm(a: LatLng, b: LatLng) {
return 2 * R * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); return 2 * R * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
} }
type LeafletLib = typeof import('leaflet'); type LeafletLib = typeof import("leaflet");
async function loadLeaflet(): Promise<LeafletLib> { async function loadLeaflet(): Promise<LeafletLib> {
if (typeof window === 'undefined') return Promise.reject(new Error('No window')); if (typeof window === "undefined")
return Promise.reject(new Error("No window"));
if ((window as any).L) return (window as any).L as LeafletLib; if ((window as any).L) return (window as any).L as LeafletLib;
const linkId = 'leaflet-css'; const linkId = "leaflet-css";
if (!document.getElementById(linkId)) { if (!document.getElementById(linkId)) {
const link = document.createElement('link'); const link = document.createElement("link");
link.id = linkId; link.id = linkId;
link.rel = 'stylesheet'; link.rel = "stylesheet";
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
document.head.appendChild(link); document.head.appendChild(link);
} }
const mod: LeafletLib = await import('leaflet'); const mod: LeafletLib = await import("leaflet");
(window as any).L = mod; (window as any).L = mod;
return mod; return mod;
} }
const amenityIcons: Record<string, string> = { const amenityIcons: Record<string, string> = {
sauna: '🧖', sauna: "🧖",
fireplace: '🔥', fireplace: "🔥",
wifi: '📶', wifi: "📶",
pets: '🐾', pets: "🐾",
lake: '🌊', lake: "🌊",
ac: '❄️', ac: "❄️",
kitchen: '🍽️', kitchen: "🍽️",
dishwasher: '🧼', dishwasher: "🧼",
washer: '🧺', washer: "🧺",
barbecue: '🍖', barbecue: "🍖",
microwave: '🍲', microwave: "🍲",
parking: '🅿️', parking: "🅿️",
accessible: '♿', accessible: "♿",
ski: '⛷️', ski: "⛷️",
ev: '⚡', ev: "⚡",
evOnSite: '🔌', evOnSite: "🔌",
}; };
function ListingsMap({ function ListingsMap({
@ -126,9 +127,12 @@ function ListingsMap({
setMapError(null); setMapError(null);
if (!mapContainerRef.current) return; if (!mapContainerRef.current) return;
if (!mapRef.current) { if (!mapRef.current) {
mapRef.current = L.map(mapContainerRef.current).setView([64.5, 26], 5); mapRef.current = L.map(mapContainerRef.current).setView(
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { [64.5, 26],
attribution: '&copy; OpenStreetMap contributors', 5,
);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; OpenStreetMap contributors",
maxZoom: 18, maxZoom: 18,
}).addTo(mapRef.current); }).addTo(mapRef.current);
} }
@ -139,26 +143,32 @@ function ListingsMap({
listings listings
.filter((l) => l.latitude !== null && l.longitude !== null) .filter((l) => l.latitude !== null && l.longitude !== null)
.forEach((l) => { .forEach((l) => {
const marker = L.marker([l.latitude!, l.longitude!], { title: l.title }); const marker = L.marker([l.latitude!, l.longitude!], {
title: l.title,
});
marker.addTo(mapRef.current); marker.addTo(mapRef.current);
marker.on('click', () => onSelect(l.id)); marker.on("click", () => onSelect(l.id));
markersRef.current.push(marker); markersRef.current.push(marker);
}); });
const withCoords = listings.filter((l) => l.latitude !== null && l.longitude !== null); const withCoords = listings.filter(
(l) => l.latitude !== null && l.longitude !== null,
);
if (center && mapRef.current) { if (center && mapRef.current) {
mapRef.current.setView([center.lat, center.lon], 8); mapRef.current.setView([center.lat, center.lon], 8);
} else if (withCoords.length && mapRef.current) { } else if (withCoords.length && mapRef.current) {
const group = L.featureGroup( const group = L.featureGroup(
withCoords.map((l) => L.marker([l.latitude as number, l.longitude as number])) withCoords.map((l) =>
L.marker([l.latitude as number, l.longitude as number]),
),
); );
mapRef.current.fitBounds(group.getBounds().pad(0.25)); mapRef.current.fitBounds(group.getBounds().pad(0.25));
} }
}) })
.catch((err) => { .catch((err) => {
console.error('Leaflet load failed', err); console.error("Leaflet load failed", err);
setReady(false); setReady(false);
setMapError('Map could not be loaded right now.'); setMapError("Map could not be loaded right now.");
}); });
return () => { return () => {
@ -178,27 +188,27 @@ function ListingsMap({
<div className="map-frame"> <div className="map-frame">
{!ready ? <div className="map-placeholder">{loadingText}</div> : null} {!ready ? <div className="map-placeholder">{loadingText}</div> : null}
{mapError ? <div className="map-placeholder">{mapError}</div> : null} {mapError ? <div className="map-placeholder">{mapError}</div> : null}
<div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} /> <div ref={mapContainerRef} style={{ width: "100%", height: "100%" }} />
</div> </div>
); );
} }
export default function ListingsIndexPage() { export default function ListingsIndexPage() {
const { t } = useI18n(); const { t } = useI18n();
const [query, setQuery] = useState(''); const [query, setQuery] = useState("");
const [city, setCity] = useState(''); const [city, setCity] = useState("");
const [region, setRegion] = useState(''); const [region, setRegion] = useState("");
const [listings, setListings] = useState<ListingResult[]>([]); const [listings, setListings] = useState<ListingResult[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [addressQuery, setAddressQuery] = useState(''); const [addressQuery, setAddressQuery] = useState("");
const [addressCenter, setAddressCenter] = useState<LatLng | null>(null); const [addressCenter, setAddressCenter] = useState<LatLng | null>(null);
const [radiusKm, setRadiusKm] = useState(50); const [radiusKm, setRadiusKm] = useState(50);
const [geocoding, setGeocoding] = useState(false); const [geocoding, setGeocoding] = useState(false);
const [geoError, setGeoError] = useState<string | null>(null); const [geoError, setGeoError] = useState<string | null>(null);
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState("");
const [amenities, setAmenities] = useState<string[]>([]); const [amenities, setAmenities] = useState<string[]>([]);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
@ -206,7 +216,10 @@ export default function ListingsIndexPage() {
if (!addressCenter) return listings; if (!addressCenter) return listings;
return listings.filter((l) => { return listings.filter((l) => {
if (l.latitude === null || l.longitude === null) return false; if (l.latitude === null || l.longitude === null) return false;
const d = haversineKm(addressCenter, { lat: l.latitude, lon: l.longitude }); const d = haversineKm(addressCenter, {
lat: l.latitude,
lon: l.longitude,
});
return d <= radiusKm; return d <= radiusKm;
}); });
}, [listings, addressCenter, radiusKm]); }, [listings, addressCenter, radiusKm]);
@ -214,22 +227,54 @@ export default function ListingsIndexPage() {
const filtered = filteredByAddress; const filtered = filteredByAddress;
const amenityOptions = [ const amenityOptions = [
{ key: 'sauna', label: t('amenitySauna'), icon: amenityIcons.sauna }, { key: "sauna", label: t("amenitySauna"), icon: amenityIcons.sauna },
{ key: 'fireplace', label: t('amenityFireplace'), icon: amenityIcons.fireplace }, {
{ key: 'wifi', label: t('amenityWifi'), icon: amenityIcons.wifi }, key: "fireplace",
{ key: 'pets', label: t('amenityPets'), icon: amenityIcons.pets }, label: t("amenityFireplace"),
{ key: 'lake', label: t('amenityLake'), icon: amenityIcons.lake }, icon: amenityIcons.fireplace,
{ key: 'ac', label: t('amenityAirConditioning'), icon: amenityIcons.ac }, },
{ key: 'kitchen', label: t('amenityKitchen'), icon: amenityIcons.kitchen }, { key: "wifi", label: t("amenityWifi"), icon: amenityIcons.wifi },
{ key: 'dishwasher', label: t('amenityDishwasher'), icon: amenityIcons.dishwasher }, { key: "pets", label: t("amenityPets"), icon: amenityIcons.pets },
{ key: 'washer', label: t('amenityWashingMachine'), icon: amenityIcons.washer }, { key: "lake", label: t("amenityLake"), icon: amenityIcons.lake },
{ key: 'barbecue', label: t('amenityBarbecue'), icon: amenityIcons.barbecue }, { key: "ac", label: t("amenityAirConditioning"), icon: amenityIcons.ac },
{ key: 'microwave', label: t('amenityMicrowave'), icon: amenityIcons.microwave }, { key: "kitchen", label: t("amenityKitchen"), icon: amenityIcons.kitchen },
{ key: 'parking', label: t('amenityFreeParking'), icon: amenityIcons.parking }, {
{ key: 'accessible', label: t('amenityWheelchairAccessible'), icon: amenityIcons.accessible }, key: "dishwasher",
{ key: 'skipass', label: t('amenitySkiPass'), icon: amenityIcons.ski }, label: t("amenityDishwasher"),
{ key: 'ev', label: t('amenityEvNearby'), icon: amenityIcons.ev }, icon: amenityIcons.dishwasher,
{ key: 'ev-onsite', label: t('amenityEvOnSite'), icon: amenityIcons.evOnSite }, },
{
key: "washer",
label: t("amenityWashingMachine"),
icon: amenityIcons.washer,
},
{
key: "barbecue",
label: t("amenityBarbecue"),
icon: amenityIcons.barbecue,
},
{
key: "microwave",
label: t("amenityMicrowave"),
icon: amenityIcons.microwave,
},
{
key: "parking",
label: t("amenityFreeParking"),
icon: amenityIcons.parking,
},
{
key: "accessible",
label: t("amenityWheelchairAccessible"),
icon: amenityIcons.accessible,
},
{ key: "skipass", label: t("amenitySkiPass"), icon: amenityIcons.ski },
{ key: "ev", label: t("amenityEvNearby"), icon: amenityIcons.ev },
{
key: "ev-onsite",
label: t("amenityEvOnSite"),
icon: amenityIcons.evOnSite,
},
]; ];
async function fetchListings() { async function fetchListings() {
@ -237,32 +282,36 @@ export default function ListingsIndexPage() {
setError(null); setError(null);
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query) params.set('q', query); if (query) params.set("q", query);
if (city) params.set('city', city); if (city) params.set("city", city);
if (region) params.set('region', region); if (region) params.set("region", region);
if (startDate) params.set('availableStart', startDate); if (startDate) params.set("availableStart", startDate);
if (endDate) params.set('availableEnd', endDate); if (endDate) params.set("availableEnd", endDate);
const evSelected = amenities.includes('ev'); const evSelected = amenities.includes("ev");
if (evSelected) params.set('evCharging', 'true'); if (evSelected) params.set("evCharging", "true");
amenities amenities
.filter((a) => a !== 'ev') .filter((a) => a !== "ev")
.forEach((a) => params.append('amenity', a)); .forEach((a) => params.append("amenity", a));
const res = await fetch(`/api/listings?${params.toString()}`, { cache: 'no-store' }); const res = await fetch(`/api/listings?${params.toString()}`, {
cache: "no-store",
});
const data = await res.json(); const data = await res.json();
if (!res.ok || data.error) { if (!res.ok || data.error) {
throw new Error(data.error || 'Failed to load listings'); throw new Error(data.error || "Failed to load listings");
} }
setListings(data.listings ?? []); setListings(data.listings ?? []);
setSelectedId(data.listings?.[0]?.id ?? null); setSelectedId(data.listings?.[0]?.id ?? null);
} catch (e: any) { } catch (e: any) {
setError(e.message || 'Failed to load listings'); setError(e.message || "Failed to load listings");
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
function toggleAmenity(key: string) { function toggleAmenity(key: string) {
setAmenities((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); setAmenities((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
} }
async function locateAddress() { async function locateAddress() {
@ -271,17 +320,20 @@ export default function ListingsIndexPage() {
setGeoError(null); setGeoError(null);
try { try {
const res = await fetch( const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(addressQuery)}&limit=1` `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(addressQuery)}&limit=1`,
); );
const data = await res.json(); const data = await res.json();
if (Array.isArray(data) && data.length > 0) { if (Array.isArray(data) && data.length > 0) {
const hit = data[0]; const hit = data[0];
setAddressCenter({ lat: parseFloat(hit.lat), lon: parseFloat(hit.lon) }); setAddressCenter({
lat: parseFloat(hit.lat),
lon: parseFloat(hit.lon),
});
} else { } else {
setGeoError(t('addressNotFound')); setGeoError(t("addressNotFound"));
} }
} catch (e) { } catch (e) {
setGeoError(t('addressLookupFailed')); setGeoError(t("addressLookupFailed"));
} finally { } finally {
setGeocoding(false); setGeocoding(false);
} }
@ -292,13 +344,15 @@ export default function ListingsIndexPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const countLabel = t('listingsFound', { count: filtered.length }); const countLabel = t("listingsFound", { count: filtered.length });
useEffect(() => { useEffect(() => {
if (!selectedId) return; if (!selectedId) return;
const el = document.querySelector<HTMLElement>(`[data-listing-id="${selectedId}"]`); const el = document.querySelector<HTMLElement>(
`[data-listing-id="${selectedId}"]`,
);
if (el) { if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.scrollIntoView({ behavior: "smooth", block: "center" });
} }
}, [selectedId]); }, [selectedId]);
@ -306,47 +360,76 @@ export default function ListingsIndexPage() {
<main> <main>
<section className="panel"> <section className="panel">
<div className="breadcrumb"> <div className="breadcrumb">
<Link href="/">{t('homeCrumb')}</Link> / <span>{t('navBrowse')}</span> <Link href="/">{t("homeCrumb")}</Link> / <span>{t("navBrowse")}</span>
</div> </div>
<h1>{t('browseListingsTitle')}</h1> <h1>{t("browseListingsTitle")}</h1>
<p style={{ marginTop: 8 }}>{t('browseListingsLead')}</p> <p style={{ marginTop: 8 }}>{t("browseListingsLead")}</p>
<div className="search-grid" style={{ marginTop: 16 }}> <div className="search-grid" style={{ marginTop: 16 }}>
<label> <label>
{t('searchLabel')} {t("searchLabel")}
<input <input
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder={t('searchPlaceholder')} placeholder={t("searchPlaceholder")}
autoComplete="off" autoComplete="off"
/> />
</label> </label>
<label> <label>
{t('cityFilter')} {t("cityFilter")}
<input value={city} onChange={(e) => setCity(e.target.value)} placeholder={t('cityFilter')} /> <input
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder={t("cityFilter")}
/>
</label> </label>
<label> <label>
{t('regionFilter')} {t("regionFilter")}
<input value={region} onChange={(e) => setRegion(e.target.value)} placeholder={t('regionFilter')} /> <input
value={region}
onChange={(e) => setRegion(e.target.value)}
placeholder={t("regionFilter")}
/>
</label> </label>
</div> </div>
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', marginTop: 12 }}> <div
style={{
display: "grid",
gap: 10,
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
marginTop: 12,
}}
>
<label> <label>
{t('startDate')} {t("startDate")}
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} /> <input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</label> </label>
<label> <label>
{t('endDate')} {t("endDate")}
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} /> <input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</label> </label>
</div> </div>
<div style={{ marginTop: 8, color: '#cbd5e1', fontSize: 13 }}>{t('availabilityOnlyWithCalendar')}</div> <div style={{ marginTop: 8, color: "#cbd5e1", fontSize: 13 }}>
{t("availabilityOnlyWithCalendar")}
</div>
<div className="amenity-grid" style={{ marginTop: 12 }}> <div className="amenity-grid" style={{ marginTop: 12 }}>
<div style={{ gridColumn: '1 / -1', color: '#cbd5e1', fontWeight: 600 }}>{t('searchAmenities')}</div> <div
style={{ gridColumn: "1 / -1", color: "#cbd5e1", fontWeight: 600 }}
>
{t("searchAmenities")}
</div>
{amenityOptions.map((opt) => ( {amenityOptions.map((opt) => (
<button <button
key={opt.key} key={opt.key}
type="button" type="button"
className={`amenity-option ${amenities.includes(opt.key) ? 'selected' : ''}`} className={`amenity-option ${amenities.includes(opt.key) ? "selected" : ""}`}
aria-pressed={amenities.includes(opt.key)} aria-pressed={amenities.includes(opt.key)}
onClick={() => toggleAmenity(opt.key)} onClick={() => toggleAmenity(opt.key)}
> >
@ -357,51 +440,68 @@ export default function ListingsIndexPage() {
<span className="amenity-name">{opt.label}</span> <span className="amenity-name">{opt.label}</span>
</div> </div>
<span className="amenity-check" aria-hidden> <span className="amenity-check" aria-hidden>
{amenities.includes(opt.key) ? '✓' : ''} {amenities.includes(opt.key) ? "✓" : ""}
</span> </span>
</button> </button>
))} ))}
</div> </div>
<div style={{ display: 'flex', gap: 10, marginTop: 12, flexWrap: 'wrap' }}> <div
style={{ display: "flex", gap: 10, marginTop: 12, flexWrap: "wrap" }}
>
<button className="button" onClick={fetchListings} disabled={loading}> <button className="button" onClick={fetchListings} disabled={loading}>
{loading ? t('loading') : t('searchButton')} {loading ? t("loading") : t("searchButton")}
</button> </button>
<button <button
className="button secondary" className="button secondary"
onClick={() => { onClick={() => {
setQuery(''); setQuery("");
setCity(''); setCity("");
setRegion(''); setRegion("");
setAddressCenter(null); setAddressCenter(null);
setAddressQuery(''); setAddressQuery("");
setStartDate(''); setStartDate("");
setEndDate(''); setEndDate("");
setAmenities([]); setAmenities([]);
}} }}
> >
{t('clearFilters')} {t("clearFilters")}
</button> </button>
<span style={{ alignSelf: 'center', color: '#cbd5e1' }}>{countLabel}</span> <span style={{ alignSelf: "center", color: "#cbd5e1" }}>
{countLabel}
</span>
</div> </div>
{error ? <p style={{ marginTop: 8, color: '#ef4444' }}>{error}</p> : null} {error ? (
<p style={{ marginTop: 8, color: "#ef4444" }}>{error}</p>
) : null}
</section> </section>
<section className="map-grid" style={{ marginTop: 18 }}> <section className="map-grid" style={{ marginTop: 18 }}>
<div className="panel"> <div className="panel">
<div style={{ display: 'grid', gap: 10, marginBottom: 12 }}> <div style={{ display: "grid", gap: 10, marginBottom: 12 }}>
<label> <label>
{t('addressSearchLabel')} {t("addressSearchLabel")}
<input <input
value={addressQuery} value={addressQuery}
onChange={(e) => setAddressQuery(e.target.value)} onChange={(e) => setAddressQuery(e.target.value)}
placeholder={t('addressSearchPlaceholder')} placeholder={t("addressSearchPlaceholder")}
/> />
</label> </label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}> <div
<button className="button secondary" onClick={locateAddress} disabled={geocoding}> style={{
{geocoding ? t('loading') : t('locateAddress')} display: "flex",
gap: 8,
alignItems: "center",
flexWrap: "wrap",
}}
>
<button
className="button secondary"
onClick={locateAddress}
disabled={geocoding}
>
{geocoding ? t("loading") : t("locateAddress")}
</button> </button>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input <input
type="range" type="range"
min={10} min={10}
@ -411,102 +511,191 @@ export default function ListingsIndexPage() {
onChange={(e) => setRadiusKm(Number(e.target.value))} onChange={(e) => setRadiusKm(Number(e.target.value))}
disabled={!addressCenter} disabled={!addressCenter}
/> />
<span style={{ color: '#cbd5e1' }}>{t('addressRadiusLabel', { km: radiusKm })}</span> <span style={{ color: "#cbd5e1" }}>
{t("addressRadiusLabel", { km: radiusKm })}
</span>
</label> </label>
{addressCenter ? ( {addressCenter ? (
<button className="button secondary" onClick={() => setAddressCenter(null)}> <button
{t('clearFilters')} className="button secondary"
onClick={() => setAddressCenter(null)}
>
{t("clearFilters")}
</button> </button>
) : null} ) : null}
</div> </div>
{geoError ? <p style={{ color: '#ef4444' }}>{geoError}</p> : null} {geoError ? <p style={{ color: "#ef4444" }}>{geoError}</p> : null}
</div> </div>
<ListingsMap <ListingsMap
listings={filtered} listings={filtered}
center={addressCenter} center={addressCenter}
selectedId={selectedId} selectedId={selectedId}
onSelect={setSelectedId} onSelect={setSelectedId}
loadingText={t('loadingMap')} loadingText={t("loadingMap")}
/> />
</div> </div>
<div className="panel"> <div className="panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}> <div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 10,
}}
>
<strong>{countLabel}</strong> <strong>{countLabel}</strong>
{addressCenter ? ( {addressCenter ? (
<span className="badge">{t('addressRadiusLabel', { km: radiusKm })}</span> <span className="badge">
{t("addressRadiusLabel", { km: radiusKm })}
</span>
) : null} ) : null}
</div> </div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<p>{t('mapNoResults')}</p> <p>{t("mapNoResults")}</p>
) : ( ) : (
<div className="results-grid" ref={scrollRef}> <div className="results-grid" ref={scrollRef}>
{filtered.map((l) => ( {filtered.map((l) => (
<article <article
key={l.id} key={l.id}
className={`listing-card ${selectedId === l.id ? 'active' : ''}`} className={`listing-card ${selectedId === l.id ? "active" : ""}`}
data-listing-id={l.id} data-listing-id={l.id}
onMouseEnter={() => setSelectedId(l.id)} onMouseEnter={() => setSelectedId(l.id)}
onClick={() => setSelectedId(l.id)} onClick={() => setSelectedId(l.id)}
> >
<Link href={`/listings/${l.slug}`} aria-label={l.title} style={{ display: 'block' }}> <Link
href={`/listings/${l.slug}`}
aria-label={l.title}
style={{ display: "block" }}
>
{l.coverImage ? ( {l.coverImage ? (
<img src={l.coverImage} alt={l.title} style={{ width: '100%', height: 140, objectFit: 'cover', borderRadius: 12 }} /> <img
src={l.coverImage}
alt={l.title}
style={{
width: "100%",
height: 140,
objectFit: "cover",
borderRadius: 12,
}}
/>
) : ( ) : (
<div <div
style={{ style={{
height: 140, height: 140,
borderRadius: 12, borderRadius: 12,
background: 'linear-gradient(120deg, rgba(34,211,238,0.12), rgba(14,165,233,0.12))', background:
"linear-gradient(120deg, rgba(34,211,238,0.12), rgba(14,165,233,0.12))",
}} }}
/> />
)} )}
</Link> </Link>
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}> <div style={{ display: "grid", gap: 6, marginTop: 8 }}>
<h3 style={{ margin: 0 }}>{l.title}</h3> <h3 style={{ margin: 0 }}>{l.title}</h3>
{l.isSample ? ( {l.isSample ? (
<span className="badge warning" style={{ width: 'fit-content' }}> <span
{t('sampleBadge')} className="badge warning"
style={{ width: "fit-content" }}
>
{t("sampleBadge")}
</span> </span>
) : null} ) : null}
<p style={{ margin: 0 }}>{l.teaser ?? ''}</p> <p style={{ margin: 0 }}>{l.teaser ?? ""}</p>
{l.priceWeekdayEuros || l.priceWeekendEuros ? ( {l.priceWeekdayEuros || l.priceWeekendEuros ? (
<div style={{ color: '#cbd5e1', fontSize: 14 }}> <div style={{ color: "#cbd5e1", fontSize: 14 }}>
{t('priceStartingFromShort', { {t("priceStartingFromShort", {
price: Math.min(...([l.priceWeekdayEuros, l.priceWeekendEuros].filter((p): p is number => typeof p === 'number'))), price: Math.min(
...[
l.priceWeekdayEuros,
l.priceWeekendEuros,
].filter((p): p is number => typeof p === "number"),
),
})} })}
</div> </div>
) : null} ) : null}
<div style={{ color: '#cbd5e1', fontSize: 14 }}> <div style={{ color: "#cbd5e1", fontSize: 14 }}>
{l.streetAddress ? `${l.streetAddress}, ` : ''} {l.streetAddress ? `${l.streetAddress}, ` : ""}
{l.city}, {l.region} {l.city}, {l.region}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', fontSize: 13 }}> <div
<span className="badge">{t('capacityGuests', { count: l.maxGuests })}</span> style={{
<span className="badge">{t('capacityBedrooms', { count: l.bedrooms })}</span> display: "flex",
{l.hasCalendar ? <span className="badge secondary">{t('calendarConnected')}</span> : null} gap: 6,
{startDate && endDate && l.availableForDates ? ( flexWrap: "wrap",
<span className="badge">{t('availableForDates')}</span> fontSize: 13,
}}
>
<span className="badge">
{t("capacityGuests", { count: l.maxGuests })}
</span>
<span className="badge">
{t("capacityBedrooms", { count: l.bedrooms })}
</span>
{l.hasCalendar ? (
<span className="badge secondary">
{t("calendarConnected")}
</span>
) : null}
{startDate && endDate && l.availableForDates ? (
<span className="badge">{t("availableForDates")}</span>
) : null}
{l.evChargingOnSite ? (
<span className="badge">{t("amenityEvOnSite")}</span>
) : null}
{l.evChargingAvailable && !l.evChargingOnSite ? (
<span className="badge">{t("amenityEvNearby")}</span>
) : null}
{l.wheelchairAccessible ? (
<span className="badge">
{t("amenityWheelchairAccessible")}
</span>
) : null}
{l.hasSkiPass ? (
<span className="badge">{t("amenitySkiPass")}</span>
) : null}
{l.hasAirConditioning ? (
<span className="badge">
{t("amenityAirConditioning")}
</span>
) : null}
{l.hasKitchen ? (
<span className="badge">{t("amenityKitchen")}</span>
) : null}
{l.hasDishwasher ? (
<span className="badge">{t("amenityDishwasher")}</span>
) : null}
{l.hasWashingMachine ? (
<span className="badge">
{t("amenityWashingMachine")}
</span>
) : null}
{l.hasBarbecue ? (
<span className="badge">{t("amenityBarbecue")}</span>
) : null}
{l.hasMicrowave ? (
<span className="badge">{t("amenityMicrowave")}</span>
) : null}
{l.hasFreeParking ? (
<span className="badge">{t("amenityFreeParking")}</span>
) : null}
{l.hasSauna ? (
<span className="badge">{t("amenitySauna")}</span>
) : null}
{l.hasWifi ? (
<span className="badge">{t("amenityWifi")}</span>
) : null} ) : null}
{l.evChargingOnSite ? <span className="badge">{t('amenityEvOnSite')}</span> : null}
{l.evChargingAvailable && !l.evChargingOnSite ? <span className="badge">{t('amenityEvNearby')}</span> : null}
{l.wheelchairAccessible ? <span className="badge">{t('amenityWheelchairAccessible')}</span> : null}
{l.hasSkiPass ? <span className="badge">{t('amenitySkiPass')}</span> : null}
{l.hasAirConditioning ? <span className="badge">{t('amenityAirConditioning')}</span> : null}
{l.hasKitchen ? <span className="badge">{t('amenityKitchen')}</span> : null}
{l.hasDishwasher ? <span className="badge">{t('amenityDishwasher')}</span> : null}
{l.hasWashingMachine ? <span className="badge">{t('amenityWashingMachine')}</span> : null}
{l.hasBarbecue ? <span className="badge">{t('amenityBarbecue')}</span> : null}
{l.hasMicrowave ? <span className="badge">{t('amenityMicrowave')}</span> : null}
{l.hasFreeParking ? <span className="badge">{t('amenityFreeParking')}</span> : null}
{l.hasSauna ? <span className="badge">{t('amenitySauna')}</span> : null}
{l.hasWifi ? <span className="badge">{t('amenityWifi')}</span> : null}
</div> </div>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}> <div style={{ display: "flex", gap: 8, marginTop: 6 }}>
<Link className="button secondary" href={`/listings/${l.slug}`}> <Link
{t('openListing')} className="button secondary"
href={`/listings/${l.slug}`}
>
{t("openListing")}
</Link> </Link>
<button className="button secondary" onClick={() => setSelectedId(l.id)}> <button
{t('locateAddress')} className="button secondary"
onClick={() => setSelectedId(l.id)}
>
{t("locateAddress")}
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,9 +1,19 @@
'use client'; "use client";
import { useEffect, useState } from 'react'; import Link from "next/link";
import { useI18n } from '../components/I18nProvider'; 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; phone: string | null }; type User = {
id: string;
email: string;
role: string;
status: string;
emailVerifiedAt: string | null;
approvedAt: string | null;
name: string | null;
phone: string | null;
};
type BillingListing = { type BillingListing = {
id: string; id: string;
title: string; title: string;
@ -18,14 +28,14 @@ export default function ProfilePage() {
const { t } = useI18n(); const { t } = useI18n();
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [name, setName] = useState(''); const [name, setName] = useState("");
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState("");
const [password, setPassword] = useState(''); const [password, setPassword] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [billingEnabled, setBillingEnabled] = useState(false); const [billingEnabled, setBillingEnabled] = useState(false);
const [billingAccountName, setBillingAccountName] = useState(''); const [billingAccountName, setBillingAccountName] = useState("");
const [billingIban, setBillingIban] = useState(''); const [billingIban, setBillingIban] = useState("");
const [billingIncludeVatLine, setBillingIncludeVatLine] = useState(false); const [billingIncludeVatLine, setBillingIncludeVatLine] = useState(false);
const [billingListings, setBillingListings] = useState<BillingListing[]>([]); const [billingListings, setBillingListings] = useState<BillingListing[]>([]);
const [billingLoading, setBillingLoading] = useState(false); const [billingLoading, setBillingLoading] = useState(false);
@ -34,30 +44,30 @@ export default function ProfilePage() {
const [billingError, setBillingError] = useState<string | null>(null); const [billingError, setBillingError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetch('/api/auth/me', { cache: 'no-store' }) fetch("/api/auth/me", { cache: "no-store" })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
if (data.user) { if (data.user) {
setUser(data.user); setUser(data.user);
setName(data.user.name ?? ''); setName(data.user.name ?? "");
setPhone(data.user.phone ?? ''); setPhone(data.user.phone ?? "");
} else setError(t('notLoggedIn')); } else setError(t("notLoggedIn"));
}) })
.catch(() => setError(t('notLoggedIn'))); .catch(() => setError(t("notLoggedIn")));
}, [t]); }, [t]);
useEffect(() => { useEffect(() => {
setBillingLoading(true); setBillingLoading(true);
fetch('/api/me/billing', { cache: 'no-store' }) fetch("/api/me/billing", { cache: "no-store" })
.then(async (res) => { .then(async (res) => {
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed'); if (!res.ok) throw new Error(data.error || "Failed");
return data; return data;
}) })
.then((data) => { .then((data) => {
setBillingEnabled(Boolean(data.settings?.enabled)); setBillingEnabled(Boolean(data.settings?.enabled));
setBillingAccountName(data.settings?.accountName ?? ''); setBillingAccountName(data.settings?.accountName ?? "");
setBillingIban(data.settings?.iban ?? ''); setBillingIban(data.settings?.iban ?? "");
setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine)); setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine));
setBillingListings( setBillingListings(
Array.isArray(data.listings) Array.isArray(data.listings)
@ -66,15 +76,15 @@ export default function ProfilePage() {
title: l.title, title: l.title,
slug: l.slug, slug: l.slug,
locale: l.locale, locale: l.locale,
billingAccountName: l.billingAccountName ?? '', billingAccountName: l.billingAccountName ?? "",
billingIban: l.billingIban ?? '', billingIban: l.billingIban ?? "",
billingIncludeVatLine: l.billingIncludeVatLine ?? null, billingIncludeVatLine: l.billingIncludeVatLine ?? null,
})) }))
: [], : [],
); );
setBillingError(null); setBillingError(null);
}) })
.catch(() => setBillingError(t('billingLoadFailed'))) .catch(() => setBillingError(t("billingLoadFailed")))
.finally(() => setBillingLoading(false)); .finally(() => setBillingLoading(false));
}, [t]); }, [t]);
@ -84,21 +94,21 @@ export default function ProfilePage() {
setError(null); setError(null);
setMessage(null); setMessage(null);
try { try {
const res = await fetch('/api/me', { const res = await fetch("/api/me", {
method: 'PATCH', method: "PATCH",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, phone, password: password || undefined }), body: JSON.stringify({ name, phone, password: password || undefined }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data.error || 'Update failed'); setError(data.error || "Update failed");
} else { } else {
setUser(data.user); setUser(data.user);
setPassword(''); setPassword("");
setMessage(t('profileUpdated')); setMessage(t("profileUpdated"));
} }
} catch (err) { } catch (err) {
setError('Update failed'); setError("Update failed");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -110,9 +120,9 @@ export default function ProfilePage() {
setBillingError(null); setBillingError(null);
setBillingMessage(null); setBillingMessage(null);
try { try {
const res = await fetch('/api/me/billing', { const res = await fetch("/api/me/billing", {
method: 'PATCH', method: "PATCH",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
enabled: billingEnabled, enabled: billingEnabled,
accountName: billingAccountName, accountName: billingAccountName,
@ -128,12 +138,12 @@ export default function ProfilePage() {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
setBillingError(data.error || t('billingSaveFailed')); setBillingError(data.error || t("billingSaveFailed"));
return; return;
} }
setBillingEnabled(Boolean(data.settings?.enabled)); setBillingEnabled(Boolean(data.settings?.enabled));
setBillingAccountName(data.settings?.accountName ?? ''); setBillingAccountName(data.settings?.accountName ?? "");
setBillingIban(data.settings?.iban ?? ''); setBillingIban(data.settings?.iban ?? "");
setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine)); setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine));
setBillingListings( setBillingListings(
Array.isArray(data.listings) Array.isArray(data.listings)
@ -142,176 +152,341 @@ export default function ProfilePage() {
title: l.title, title: l.title,
slug: l.slug, slug: l.slug,
locale: l.locale, locale: l.locale,
billingAccountName: l.billingAccountName ?? '', billingAccountName: l.billingAccountName ?? "",
billingIban: l.billingIban ?? '', billingIban: l.billingIban ?? "",
billingIncludeVatLine: l.billingIncludeVatLine ?? null, billingIncludeVatLine: l.billingIncludeVatLine ?? null,
})) }))
: [], : [],
); );
setBillingMessage(t('billingSaved')); setBillingMessage(t("billingSaved"));
} catch (err) { } catch (err) {
setBillingError(t('billingSaveFailed')); setBillingError(t("billingSaveFailed"));
} finally { } finally {
setBillingSaving(false); setBillingSaving(false);
} }
} }
return ( return (
<main style={{ maxWidth: 1040, margin: '32px auto', display: 'grid', gap: 18 }}> <main
<header className="panel" style={{ display: 'grid', gap: 12, padding: 18 }}> style={{ maxWidth: 1040, margin: "32px auto", display: "grid", gap: 18 }}
>
<header
className="panel"
style={{ display: "grid", gap: 12, padding: 18 }}
>
<div> <div>
<h1 style={{ margin: 0 }}>{t('myProfileTitle')}</h1> <h1 style={{ margin: 0 }}>{t("myProfileTitle")}</h1>
{message ? <p style={{ color: 'green', margin: '6px 0 0' }}>{message}</p> : null} {message ? (
<p style={{ color: "green", margin: "6px 0 0" }}>{message}</p>
) : null}
</div> </div>
{user ? ( {user ? (
<> <>
<div style={{ display: 'grid', gap: 6, gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}> <div
<div><strong>{t('profileEmail')}:</strong> {user.email}</div> style={{
<div><strong>{t('profileName')}:</strong> {user.name ?? '—'}</div> display: "grid",
<div><strong>{t('profilePhone')}:</strong> {user.phone ?? '—'}</div> gap: 6,
<div><strong>{t('profileRole')}:</strong> {user.role}</div> gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
<div><strong>{t('profileStatus')}:</strong> {user.status}</div> }}
<div><strong>{t('profileEmailVerified')}:</strong> {user.emailVerifiedAt ? t('yes') : t('no')}</div> >
<div><strong>{t('profileApproved')}:</strong> {user.approvedAt ? t('yes') : t('no')}</div> <div>
<strong>{t("profileEmail")}:</strong> {user.email}
</div> </div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}> <div>
<a className="button secondary" href="/listings/mine"> <strong>{t("profileName")}:</strong> {user.name ?? "—"}
{t('navMyListings')} </div>
</a> <div>
<a className="button secondary" href="/listings/new"> <strong>{t("profilePhone")}:</strong> {user.phone ?? "—"}
{t('navNewListing')} </div>
</a> <div>
<strong>{t("profileRole")}:</strong> {user.role}
</div>
<div>
<strong>{t("profileStatus")}:</strong> {user.status}
</div>
<div>
<strong>{t("profileEmailVerified")}:</strong>{" "}
{user.emailVerifiedAt ? t("yes") : t("no")}
</div>
<div>
<strong>{t("profileApproved")}:</strong>{" "}
{user.approvedAt ? t("yes") : t("no")}
</div>
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<Link className="button secondary" href="/listings/mine">
{t("navMyListings")}
</Link>
<Link className="button secondary" href="/listings/new">
{t("navNewListing")}
</Link>
</div> </div>
</> </>
) : ( ) : (
<p style={{ color: 'red', margin: 0 }}>{error ?? t('notLoggedIn')}</p> <p style={{ color: "red", margin: 0 }}>{error ?? t("notLoggedIn")}</p>
)} )}
</header> </header>
{user ? ( {user ? (
<> <>
<section className="panel" style={{ padding: 18, display: 'grid', gap: 12 }}> <section
<h2 style={{ margin: 0 }}>{t('myProfileTitle')}</h2> className="panel"
<form onSubmit={onSave} style={{ display: 'grid', gap: 12 }}> style={{ padding: 18, display: "grid", gap: 12 }}
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', maxWidth: 760 }}> >
<h2 style={{ margin: 0 }}>{t("myProfileTitle")}</h2>
<form onSubmit={onSave} style={{ display: "grid", gap: 12 }}>
<div
style={{
display: "grid",
gap: 10,
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
maxWidth: 760,
}}
>
<label> <label>
{t('profileName')} {t("profileName")}
<input value={name} onChange={(e) => setName(e.target.value)} /> <input
value={name}
onChange={(e) => setName(e.target.value)}
/>
</label> </label>
<label> <label>
{t('profilePhone')} {t("profilePhone")}
<input value={phone} onChange={(e) => setPhone(e.target.value)} /> <input
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</label> </label>
<label> <label>
{t('passwordLabel')} ({t('passwordHint')}) {t("passwordLabel")} ({t("passwordHint")})
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} /> <input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
/>
</label> </label>
</div> </div>
<p style={{ fontSize: 12, color: '#666', margin: 0 }}>{t('emailLocked')}</p> <p style={{ fontSize: 12, color: "#666", margin: 0 }}>
{t("emailLocked")}
</p>
<div> <div>
<button className="button" type="submit" disabled={saving} style={{ minWidth: 160 }}> <button
{saving ? t('saving') : t('save')} className="button"
type="submit"
disabled={saving}
style={{ minWidth: 160 }}
>
{saving ? t("saving") : t("save")}
</button> </button>
</div> </div>
</form> </form>
</section> </section>
<section className="panel" style={{ padding: 18, display: 'grid', gap: 12 }}> <section
className="panel"
style={{ padding: 18, display: "grid", gap: 12 }}
>
<div> <div>
<h2 style={{ margin: 0 }}>{t('billingSettingsTitle')}</h2> <h2 style={{ margin: 0 }}>{t("billingSettingsTitle")}</h2>
<p style={{ color: '#444', margin: '6px 0 0' }}>{t('billingSettingsLead')}</p> <p style={{ color: "#444", margin: "6px 0 0" }}>
{t("billingSettingsLead")}
</p>
</div> </div>
{billingMessage ? <p style={{ color: 'green', margin: 0 }}>{billingMessage}</p> : null} {billingMessage ? (
{billingError ? <p style={{ color: 'red', margin: 0 }}>{billingError}</p> : null} <p style={{ color: "green", margin: 0 }}>{billingMessage}</p>
<form onSubmit={onSaveBilling} style={{ display: 'grid', gap: 14 }}> ) : null}
{billingError ? (
<p style={{ color: "red", margin: 0 }}>{billingError}</p>
) : null}
<form onSubmit={onSaveBilling} style={{ display: "grid", gap: 14 }}>
<label <label
className="amenity-option" className="amenity-option"
style={{ maxWidth: 520, display: 'grid', gridTemplateColumns: 'auto 1fr auto', alignItems: 'center', columnGap: 10, minHeight: 52 }} style={{
maxWidth: 520,
display: "grid",
gridTemplateColumns: "auto 1fr auto",
alignItems: "center",
columnGap: 10,
minHeight: 52,
}}
> >
<span aria-hidden className="amenity-emoji">💸</span> <span aria-hidden className="amenity-emoji">
<span className="amenity-name" style={{ fontWeight: 600 }}>{t('billingEnableLabel')}</span> 💸
</span>
<span className="amenity-name" style={{ fontWeight: 600 }}>
{t("billingEnableLabel")}
</span>
<input <input
type="checkbox" type="checkbox"
checked={billingEnabled} checked={billingEnabled}
onChange={(e) => setBillingEnabled(e.target.checked)} onChange={(e) => setBillingEnabled(e.target.checked)}
style={{ width: 22, height: 22, margin: 0, justifySelf: 'end' }} style={{
width: 22,
height: 22,
margin: 0,
justifySelf: "end",
}}
/> />
</label> </label>
{billingEnabled ? ( {billingEnabled ? (
<> <>
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', maxWidth: 760 }}> <div
style={{
display: "grid",
gap: 10,
gridTemplateColumns:
"repeat(auto-fit, minmax(260px, 1fr))",
maxWidth: 760,
}}
>
<label> <label>
{t('billingAccountNameLabel')} {t("billingAccountNameLabel")}
<input value={billingAccountName} onChange={(e) => setBillingAccountName(e.target.value)} placeholder="Example Oy" /> <input
value={billingAccountName}
onChange={(e) => setBillingAccountName(e.target.value)}
placeholder="Example Oy"
/>
</label> </label>
<label> <label>
{t('billingIbanLabel')} {t("billingIbanLabel")}
<input value={billingIban} onChange={(e) => setBillingIban(e.target.value)} placeholder="FI00 1234 5600 0007 85" /> <input
value={billingIban}
onChange={(e) => setBillingIban(e.target.value)}
placeholder="FI00 1234 5600 0007 85"
/>
</label> </label>
<label <label
className="amenity-option" className="amenity-option"
style={{ maxWidth: 520, display: 'grid', gridTemplateColumns: 'auto 1fr auto', alignItems: 'center', columnGap: 10, minHeight: 52 }} style={{
maxWidth: 520,
display: "grid",
gridTemplateColumns: "auto 1fr auto",
alignItems: "center",
columnGap: 10,
minHeight: 52,
}}
> >
<span aria-hidden className="amenity-emoji">🧾</span> <span aria-hidden className="amenity-emoji">
<span className="amenity-name" style={{ fontWeight: 600 }}>{t('billingIncludeVat')}</span> 🧾
</span>
<span
className="amenity-name"
style={{ fontWeight: 600 }}
>
{t("billingIncludeVat")}
</span>
<input <input
type="checkbox" type="checkbox"
checked={billingIncludeVatLine} checked={billingIncludeVatLine}
onChange={(e) => setBillingIncludeVatLine(e.target.checked)} onChange={(e) =>
style={{ width: 22, height: 22, margin: 0, justifySelf: 'end' }} setBillingIncludeVatLine(e.target.checked)
}
style={{
width: 22,
height: 22,
margin: 0,
justifySelf: "end",
}}
/> />
</label> </label>
</div> </div>
<div style={{ border: '1px solid rgba(148,163,184,0.3)', padding: 12, borderRadius: 8, background: 'rgba(255,255,255,0.02)' }}> <div
style={{
border: "1px solid rgba(148,163,184,0.3)",
padding: 12,
borderRadius: 8,
background: "rgba(255,255,255,0.02)",
}}
>
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<strong>{t('billingListingsTitle')}</strong> <strong>{t("billingListingsTitle")}</strong>
<div style={{ color: '#555', fontSize: 13 }}>{t('billingListingsLead')}</div> <div style={{ color: "#555", fontSize: 13 }}>
{t("billingListingsLead")}
</div>
</div> </div>
{billingLoading ? ( {billingLoading ? (
<p>{t('loading')}</p> <p>{t("loading")}</p>
) : billingListings.length === 0 ? ( ) : billingListings.length === 0 ? (
<p style={{ color: '#666' }}>{t('billingNoListings')}</p> <p style={{ color: "#666" }}>{t("billingNoListings")}</p>
) : ( ) : (
<div style={{ display: 'grid', gap: 12 }}> <div style={{ display: "grid", gap: 12 }}>
{billingListings.map((listing) => { {billingListings.map((listing) => {
const vatValue = const vatValue =
listing.billingIncludeVatLine === null || listing.billingIncludeVatLine === undefined listing.billingIncludeVatLine === null ||
? 'inherit' listing.billingIncludeVatLine === undefined
? "inherit"
: listing.billingIncludeVatLine : listing.billingIncludeVatLine
? 'yes' ? "yes"
: 'no'; : "no";
return ( return (
<div key={listing.id} style={{ border: '1px solid #f0f0f0', padding: 10, borderRadius: 6, display: 'grid', gap: 8 }}> <div
key={listing.id}
style={{
border: "1px solid #f0f0f0",
padding: 10,
borderRadius: 6,
display: "grid",
gap: 8,
}}
>
<div style={{ fontWeight: 600 }}> <div style={{ fontWeight: 600 }}>
{listing.title} ({listing.slug}) {listing.title} ({listing.slug})
</div> </div>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}> <div
style={{
display: "grid",
gap: 8,
gridTemplateColumns:
"repeat(auto-fit, minmax(240px, 1fr))",
}}
>
<label> <label>
{t('billingAccountNameLabel')} {t("billingAccountNameLabel")}
<input <input
value={listing.billingAccountName ?? ''} value={listing.billingAccountName ?? ""}
onChange={(e) => onChange={(e) =>
setBillingListings((prev) => setBillingListings((prev) =>
prev.map((l) => (l.id === listing.id ? { ...l, billingAccountName: e.target.value } : l)), prev.map((l) =>
l.id === listing.id
? {
...l,
billingAccountName:
e.target.value,
}
: l,
),
) )
} }
placeholder={billingAccountName || t('billingAccountPlaceholder')} placeholder={
billingAccountName ||
t("billingAccountPlaceholder")
}
/> />
</label> </label>
<label> <label>
{t('billingIbanLabel')} {t("billingIbanLabel")}
<input <input
value={listing.billingIban ?? ''} value={listing.billingIban ?? ""}
onChange={(e) => onChange={(e) =>
setBillingListings((prev) => setBillingListings((prev) =>
prev.map((l) => (l.id === listing.id ? { ...l, billingIban: e.target.value } : l)), prev.map((l) =>
l.id === listing.id
? {
...l,
billingIban: e.target.value,
}
: l,
),
) )
} }
placeholder={billingIban || t('billingIbanPlaceholder')} placeholder={
billingIban || t("billingIbanPlaceholder")
}
/> />
</label> </label>
<label> <label>
{t('billingVatChoice')} {t("billingVatChoice")}
<select <select
value={vatValue as string} value={vatValue as string}
onChange={(e) => { onChange={(e) => {
@ -321,16 +496,25 @@ export default function ProfilePage() {
l.id === listing.id l.id === listing.id
? { ? {
...l, ...l,
billingIncludeVatLine: choice === 'inherit' ? null : choice === 'yes', billingIncludeVatLine:
choice === "inherit"
? null
: choice === "yes",
} }
: l, : l,
), ),
); );
}} }}
> >
<option value="inherit">{t('billingVatInherit')}</option> <option value="inherit">
<option value="yes">{t('billingVatYes')}</option> {t("billingVatInherit")}
<option value="no">{t('billingVatNo')}</option> </option>
<option value="yes">
{t("billingVatYes")}
</option>
<option value="no">
{t("billingVatNo")}
</option>
</select> </select>
</label> </label>
</div> </div>
@ -342,11 +526,18 @@ export default function ProfilePage() {
</div> </div>
</> </>
) : ( ) : (
<p style={{ color: '#666', margin: 0 }}>{t('billingDisabledHint')}</p> <p style={{ color: "#666", margin: 0 }}>
{t("billingDisabledHint")}
</p>
)} )}
<div> <div>
<button className="button" type="submit" disabled={billingSaving} style={{ minWidth: 160 }}> <button
{billingSaving ? t('saving') : t('save')} className="button"
type="submit"
disabled={billingSaving}
style={{ minWidth: 160 }}
>
{billingSaving ? t("saving") : t("save")}
</button> </button>
</div> </div>
</form> </form>

View file

@ -1,8 +1,8 @@
'use client'; "use client";
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from "react";
import Link from 'next/link'; import Link from "next/link";
import { useI18n } from './components/I18nProvider'; import { useI18n } from "./components/I18nProvider";
type LatestListing = { type LatestListing = {
id: string; id: string;
@ -17,7 +17,7 @@ type LatestListing = {
priceWeekendEuros: number | null; priceWeekendEuros: number | null;
}; };
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";
export default function HomePage() { export default function HomePage() {
const { t } = useI18n(); const { t } = useI18n();
@ -28,7 +28,7 @@ export default function HomePage() {
useEffect(() => { useEffect(() => {
setLoadingLatest(true); setLoadingLatest(true);
fetch('/api/listings?limit=8', { cache: 'no-store' }) fetch("/api/listings?limit=8", { cache: "no-store" })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => setLatest((data.listings ?? []).slice(0, 5))) .then((data) => setLatest((data.listings ?? []).slice(0, 5)))
.catch(() => setLatest([])) .catch(() => setLatest([]))
@ -57,61 +57,114 @@ export default function HomePage() {
return ( return (
<main> <main>
<section className="hero"> <section className="hero">
<span className="eyebrow">{t('heroEyebrow')}</span> <span className="eyebrow">{t("heroEyebrow")}</span>
<h1>{t('heroTitle')}</h1> <h1>{t("heroTitle")}</h1>
<p>{t('heroBody')}</p> <p>{t("heroBody")}</p>
</section> </section>
<section className="panel latest-panel"> <section className="panel latest-panel">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}> <div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 10,
}}
>
<div> <div>
<h2 style={{ margin: 0 }}>{t('latestListingsTitle')}</h2> <h2 style={{ margin: 0 }}>{t("latestListingsTitle")}</h2>
<p style={{ marginTop: 4 }}>{t('latestListingsLead')}</p> <p style={{ marginTop: 4 }}>{t("latestListingsLead")}</p>
</div> </div>
<Link className="button secondary" href="/listings"> <Link className="button secondary" href="/listings">
{t('ctaBrowse')} {t("ctaBrowse")}
</Link> </Link>
</div> </div>
{loadingLatest ? ( {loadingLatest ? (
<p style={{ color: '#cbd5e1', marginTop: 10 }}>{t('loading')}</p> <p style={{ color: "#cbd5e1", marginTop: 10 }}>{t("loading")}</p>
) : !activeListing ? ( ) : !activeListing ? (
<p style={{ color: '#cbd5e1', marginTop: 10 }}>{t('mapNoResults')}</p> <p style={{ color: "#cbd5e1", marginTop: 10 }}>{t("mapNoResults")}</p>
) : ( ) : (
<div className="latest-carousel" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}> <div
className="latest-carousel"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div className="carousel-window"> <div className="carousel-window">
<div className="carousel-track" style={{ transform: `translateX(-${activeIndex * 100}%)` }}> <div
className="carousel-track"
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
>
{latest.map((item) => ( {latest.map((item) => (
<article key={item.id} className="carousel-slide"> <article key={item.id} className="carousel-slide">
{item.coverImage ? ( {item.coverImage ? (
<a href={`/listings/${item.slug}`} className="latest-cover-link" aria-label={item.title}> <a
<img src={item.coverImage} alt={item.title} className="latest-cover" /> href={`/listings/${item.slug}`}
className="latest-cover-link"
aria-label={item.title}
>
<img
src={item.coverImage}
alt={item.title}
className="latest-cover"
/>
</a> </a>
) : ( ) : (
<a href={`/listings/${item.slug}`} className="latest-cover-link" aria-label={item.title}> <a
href={`/listings/${item.slug}`}
className="latest-cover-link"
aria-label={item.title}
>
<div className="latest-cover placeholder" /> <div className="latest-cover placeholder" />
</a> </a>
)} )}
<div className="latest-meta"> <div className="latest-meta">
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div
style={{ display: "flex", gap: 6, flexWrap: "wrap" }}
>
<span className="badge"> <span className="badge">
{item.city}, {item.region} {item.city}, {item.region}
</span> </span>
{item.isSample ? <span className="badge warning">{t('sampleBadge')}</span> : null} {item.isSample ? (
<span className="badge warning">
{t("sampleBadge")}
</span>
) : null}
</div> </div>
<h3 style={{ margin: '6px 0 4px' }}>{item.title}</h3> <h3 style={{ margin: "6px 0 4px" }}>{item.title}</h3>
<p style={{ margin: 0 }}>{item.teaser}</p> <p style={{ margin: 0 }}>{item.teaser}</p>
{item.priceWeekdayEuros || item.priceWeekendEuros ? ( {item.priceWeekdayEuros || item.priceWeekendEuros ? (
<div style={{ color: '#cbd5e1', fontSize: 14, marginTop: 2 }}> <div
{t('priceStartingFromShort', { style={{
color: "#cbd5e1",
fontSize: 14,
marginTop: 2,
}}
>
{t("priceStartingFromShort", {
price: Math.min( price: Math.min(
...([item.priceWeekdayEuros, item.priceWeekendEuros].filter((p): p is number => typeof p === 'number')), ...[
item.priceWeekdayEuros,
item.priceWeekendEuros,
].filter(
(p): p is number => typeof p === "number",
),
), ),
})} })}
</div> </div>
) : null} ) : null}
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}> <div
<Link className="button secondary" href={`/listings/${item.slug}`}> style={{
{t('openListing')} display: "flex",
gap: 8,
marginTop: 10,
flexWrap: "wrap",
}}
>
<Link
className="button secondary"
href={`/listings/${item.slug}`}
>
{t("openListing")}
</Link> </Link>
</div> </div>
</div> </div>
@ -121,7 +174,11 @@ export default function HomePage() {
</div> </div>
<div className="dot-row"> <div className="dot-row">
{latest.map((_, idx) => ( {latest.map((_, idx) => (
<span key={idx} className={`dot ${idx === activeIndex ? 'active' : ''}`} onClick={() => setActiveIndex(idx)} /> <span
key={idx}
className={`dot ${idx === activeIndex ? "active" : ""}`}
onClick={() => setActiveIndex(idx)}
/>
))} ))}
</div> </div>
</div> </div>

View file

@ -1,20 +1,20 @@
'use client'; "use client";
import Link from 'next/link'; import Link from "next/link";
import { useI18n } from '../components/I18nProvider'; import { useI18n } from "../components/I18nProvider";
const pricing = [ const pricing = [
{ {
keyTitle: 'pricingMonthly', keyTitle: "pricingMonthly",
price: '10€', price: "10€",
interval: 'pricingPerMonth', interval: "pricingPerMonth",
keyBody: 'pricingMonthlyBody', keyBody: "pricingMonthlyBody",
}, },
{ {
keyTitle: 'pricingAnnual', keyTitle: "pricingAnnual",
price: '100€', price: "100€",
interval: 'pricingPerYear', interval: "pricingPerYear",
keyBody: 'pricingAnnualBody', keyBody: "pricingAnnualBody",
}, },
]; ];
@ -25,33 +25,46 @@ export default function PricingPage() {
<main> <main>
<section className="panel"> <section className="panel">
<div className="breadcrumb"> <div className="breadcrumb">
<Link href="/">{t('homeCrumb')}</Link> / <span>{t('pricingTitle')}</span> <Link href="/">{t("homeCrumb")}</Link> /{" "}
<span>{t("pricingTitle")}</span>
</div> </div>
<h1>{t('pricingTitle')}</h1> <h1>{t("pricingTitle")}</h1>
<p style={{ marginTop: 8 }}>{t('pricingLead')}</p> <p style={{ marginTop: 8 }}>{t("pricingLead")}</p>
</section> </section>
<div className="cards" style={{ marginTop: 18 }}> <div className="cards" style={{ marginTop: 18 }}>
{pricing.map((item) => ( {pricing.map((item) => (
<div key={item.keyTitle} className="panel"> <div key={item.keyTitle} className="panel">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h3 className="card-title" style={{ margin: 0 }}> <h3 className="card-title" style={{ margin: 0 }}>
{t(item.keyTitle as any)} {t(item.keyTitle as any)}
</h3> </h3>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: "right" }}>
<div style={{ fontSize: 26, fontWeight: 700 }}>{item.price}</div> <div style={{ fontSize: 26, fontWeight: 700 }}>
<div style={{ color: '#cbd5e1' }}>{t(item.interval as any)}</div> {item.price}
</div>
<div style={{ color: "#cbd5e1" }}>
{t(item.interval as any)}
</div>
</div> </div>
</div> </div>
<p style={{ marginTop: 10 }}>{t(item.keyBody as any)}</p> <p style={{ marginTop: 10 }}>{t(item.keyBody as any)}</p>
<p style={{ color: '#cbd5e1', marginTop: 6 }}>{t('pricingPerListing')}</p> <p style={{ color: "#cbd5e1", marginTop: 6 }}>
{t("pricingPerListing")}
</p>
</div> </div>
))} ))}
</div> </div>
<section className="panel" style={{ marginTop: 18 }}> <section className="panel" style={{ marginTop: 18 }}>
<h2 className="card-title">{t('pricingNotesTitle')}</h2> <h2 className="card-title">{t("pricingNotesTitle")}</h2>
<p style={{ marginTop: 8 }}>{t('pricingNotesBody')}</p> <p style={{ marginTop: 8 }}>{t("pricingNotesBody")}</p>
</section> </section>
</main> </main>
); );

View file

@ -1,69 +1,73 @@
'use client'; "use client";
import Link from 'next/link'; import Link from "next/link";
import { useI18n } from '../components/I18nProvider'; import { useI18n } from "../components/I18nProvider";
export default function PrivacyPage() { export default function PrivacyPage() {
const { t } = useI18n(); const { t } = useI18n();
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
return ( return (
<main className="panel" style={{ maxWidth: 900, margin: '40px auto', display: 'grid', gap: 14 }}> <main
className="panel"
style={{ maxWidth: 900, margin: "40px auto", display: "grid", gap: 14 }}
>
<div className="breadcrumb"> <div className="breadcrumb">
<Link href="/">{t('homeCrumb')}</Link> / <span>{t('privacyTitle')}</span> <Link href="/">{t("homeCrumb")}</Link> /{" "}
<span>{t("privacyTitle")}</span>
</div> </div>
<h1>{t('privacyTitle')}</h1> <h1>{t("privacyTitle")}</h1>
<p style={{ color: '#cbd5e1' }}>{t('privacyUpdated', { date: today })}</p> <p style={{ color: "#cbd5e1" }}>{t("privacyUpdated", { date: today })}</p>
<section className="privacy-block"> <section className="privacy-block">
<h3>{t('privacyCollectTitle')}</h3> <h3>{t("privacyCollectTitle")}</h3>
<ul> <ul>
<li>{t('privacyCollectAccounts')}</li> <li>{t("privacyCollectAccounts")}</li>
<li>{t('privacyCollectListings')}</li> <li>{t("privacyCollectListings")}</li>
<li>{t('privacyCollectLogs')}</li> <li>{t("privacyCollectLogs")}</li>
</ul> </ul>
</section> </section>
<section className="privacy-block"> <section className="privacy-block">
<h3>{t('privacyUseTitle')}</h3> <h3>{t("privacyUseTitle")}</h3>
<ul> <ul>
<li>{t('privacyUseAuth')}</li> <li>{t("privacyUseAuth")}</li>
<li>{t('privacyUseListings')}</li> <li>{t("privacyUseListings")}</li>
<li>{t('privacyUseMail')}</li> <li>{t("privacyUseMail")}</li>
<li>{t('privacyUseLegal')}</li> <li>{t("privacyUseLegal")}</li>
</ul> </ul>
</section> </section>
<section className="privacy-block"> <section className="privacy-block">
<h3>{t('privacyStoreTitle')}</h3> <h3>{t("privacyStoreTitle")}</h3>
<ul> <ul>
<li>{t('privacyStoreDb')}</li> <li>{t("privacyStoreDb")}</li>
<li>{t('privacyStoreBackups')}</li> <li>{t("privacyStoreBackups")}</li>
</ul> </ul>
</section> </section>
<section className="privacy-block"> <section className="privacy-block">
<h3>{t('privacyCookiesTitle')}</h3> <h3>{t("privacyCookiesTitle")}</h3>
<ul> <ul>
<li>{t('privacyCookiesSession')}</li> <li>{t("privacyCookiesSession")}</li>
<li>{t('privacyCookiesNoTracking')}</li> <li>{t("privacyCookiesNoTracking")}</li>
</ul> </ul>
</section> </section>
<section className="privacy-block"> <section className="privacy-block">
<h3>{t('privacySharingTitle')}</h3> <h3>{t("privacySharingTitle")}</h3>
<ul> <ul>
<li>{t('privacySharingAds')}</li> <li>{t("privacySharingAds")}</li>
<li>{t('privacySharingOps')}</li> <li>{t("privacySharingOps")}</li>
</ul> </ul>
</section> </section>
<section className="privacy-block"> <section className="privacy-block">
<h3>{t('privacyRightsTitle')}</h3> <h3>{t("privacyRightsTitle")}</h3>
<ul> <ul>
<li>{t('privacyRightsAccess')}</li> <li>{t("privacyRightsAccess")}</li>
<li>{t('privacyRightsConsent')}</li> <li>{t("privacyRightsConsent")}</li>
<li>{t('privacyRightsContact')}</li> <li>{t("privacyRightsContact")}</li>
</ul> </ul>
</section> </section>
</main> </main>

View file

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

View file

@ -1,27 +1,30 @@
Deploying to k3s (Hetzner) # Deploying to k3s (Hetzner)
==========================
Prereqs Prereqs
- `kubectl` installed locally. - `kubectl` installed locally.
- Access to the cluster kubeconfig. - Access to the cluster kubeconfig.
- Secrets loaded (dotenv via `scripts/load-secrets.sh`). - Secrets loaded (dotenv via `scripts/load-secrets.sh`).
Kubeconfig Kubeconfig
- By default `deploy/deploy.sh` will use `$KUBECONFIG`. If that is unset and `creds/kubeconfig.yaml` exists, it will export `KUBECONFIG=$PWD/creds/kubeconfig.yaml`. - By default `deploy/deploy.sh` will use `$KUBECONFIG`. If that is unset and `creds/kubeconfig.yaml` exists, it will export `KUBECONFIG=$PWD/creds/kubeconfig.yaml`.
- Recommended flow for new devs: - Recommended flow for new devs:
1) Obtain the kubeconfig from the cluster admin. 1. Obtain the kubeconfig from the cluster admin.
2) Save it as `creds/kubeconfig.yaml` (ignored by git), or set `KUBECONFIG` to your own path. The repo also includes `creds/kubeconfig.enc.yaml` (sops/age-encrypted) and a plaintext copy can be produced with the age key. 2. Save it as `creds/kubeconfig.yaml` (ignored by git), or set `KUBECONFIG` to your own path. The repo also includes `creds/kubeconfig.enc.yaml` (sops/age-encrypted) and a plaintext copy can be produced with the age key.
3) Verify access: `kubectl get ns` (you should see `lomavuokraus-test/staging/prod`). 3. Verify access: `kubectl get ns` (you should see `lomavuokraus-test/staging/prod`).
- If you want to keep the kubeconfig in-repo but encrypted, store it as `creds/kubeconfig.enc.yaml` with sops/age and decrypt to `creds/kubeconfig.yaml` before deploying: - If you want to keep the kubeconfig in-repo but encrypted, store it as `creds/kubeconfig.enc.yaml` with sops/age and decrypt to `creds/kubeconfig.yaml` before deploying:
- Decrypt: `SOPS_AGE_KEY_FILE=creds/age-key.txt sops -d creds/kubeconfig.enc.yaml > creds/kubeconfig.yaml` - Decrypt: `SOPS_AGE_KEY_FILE=creds/age-key.txt sops -d creds/kubeconfig.enc.yaml > creds/kubeconfig.yaml`
- Encrypt (admin only): `SOPS_AGE_KEY_FILE=creds/age-key.txt sops -e kubeconfig.yaml > creds/kubeconfig.enc.yaml` - Encrypt (admin only): `SOPS_AGE_KEY_FILE=creds/age-key.txt sops -e kubeconfig.yaml > creds/kubeconfig.enc.yaml`
Deploy commands Deploy commands
- Test: `./deploy/deploy-test.sh` - Test: `./deploy/deploy-test.sh`
- Staging (default): `./deploy/deploy-staging.sh` or `TARGET=staging ./deploy/deploy.sh` - Staging (default): `./deploy/deploy-staging.sh` or `TARGET=staging ./deploy/deploy.sh`
- Prod: `./deploy/deploy-prod.sh` - Prod: `./deploy/deploy-prod.sh`
Notes Notes
- Ensure `deploy/.last-image` exists (run `deploy/build.sh` first). - Ensure `deploy/.last-image` exists (run `deploy/build.sh` first).
- `AUTH_SECRET`/`DATABASE_URL` should be in your env or loaded via `scripts/load-secrets.sh`. - `AUTH_SECRET`/`DATABASE_URL` should be in your env or loaded via `scripts/load-secrets.sh`.
- `deploy/deploy.sh` runs `prisma migrate deploy` automatically when `DATABASE_URL` is set; if it isn't, it will try to read `DATABASE_URL` from the in-cluster `lomavuokraus-web-secrets` in the target namespace (recommended for test/staging/prod). - `deploy/deploy.sh` runs `prisma migrate deploy` automatically when `DATABASE_URL` is set; if it isn't, it will try to read `DATABASE_URL` from the in-cluster `lomavuokraus-web-secrets` in the target namespace (recommended for test/staging/prod).

View file

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -8,7 +8,10 @@
<body> <body>
<header> <header>
<h1>Logical Architecture</h1> <h1>Logical Architecture</h1>
<div class="meta">Next.js App Router, Prisma/Postgres, role-based auth, email verification, approvals.</div> <div class="meta">
Next.js App Router, Prisma/Postgres, role-based auth, email
verification, approvals.
</div>
</header> </header>
<main class="grid"> <main class="grid">
<section class="card"> <section class="card">
@ -26,13 +29,16 @@ flowchart LR
Admin["Admins & moderators"] --> Traefik Admin["Admins & moderators"] --> Traefik
</pre> </pre>
</div> </div>
<div class="callout">Edit the Mermaid block above to evolve the architecture.</div> <div class="callout">
Edit the Mermaid block above to evolve the architecture.
</div>
</section> </section>
<section class="card"> <section class="card">
<h2>Domain model</h2> <h2>Domain model</h2>
<div class="diagram"> <div class="diagram">
<pre class="mermaid">erDiagram <pre class="mermaid">
erDiagram
USER ||--o{ LISTING : owns USER ||--o{ LISTING : owns
USER ||--o{ LISTING : approves USER ||--o{ LISTING : approves
LISTING ||--|{ LISTINGTRANSLATION : has LISTING ||--|{ LISTINGTRANSLATION : has
@ -78,35 +84,94 @@ flowchart LR
boolean isCover boolean isCover
int order int order
} }
</pre> </pre
>
</div> </div>
</section> </section>
<section class="card"> <section class="card">
<h2>Key notes</h2> <h2>Key notes</h2>
<ul> <ul>
<li><strong>Web</strong>: Next.js app (App Router), server-rendered pages, client hooks for auth state.</li> <li>
<li><strong>API routes</strong>: Authentication, admin approvals, listings CRUD (soft-delete), profile update.</li> <strong>Web</strong>: Next.js app (App Router), server-rendered
<li><strong>Data</strong>: Postgres via Prisma (models: User, Listing, ListingTranslation, ListingImage, VerificationToken); listing images stored as bytes + metadata and served through <code>/api/images/:id</code>.</li> pages, client hooks for auth state.
<li><strong>Caching</strong>: Varnish sidecar caches <code>/api/images/*</code> (24h) and <code>/_next/static</code> assets (7d) before requests reach Next.js.</li> </li>
<li><strong>Mail</strong>: SMTP (smtp.lomavuokraus.fi CNAME to smtp.sohva.org) + DKIM signing for verification emails.</li> <li>
<li><strong>Auth</strong>: Email/password, verified+approved requirement, JWT session cookie (<code>session_token</code>), roles.</li> <strong>API routes</strong>: Authentication, admin approvals,
listings CRUD (soft-delete), profile update.
</li>
<li>
<strong>Data</strong>: Postgres via Prisma (models: User, Listing,
ListingTranslation, ListingImage, VerificationToken); listing images
stored as bytes + metadata and served through
<code>/api/images/:id</code>.
</li>
<li>
<strong>Caching</strong>: Varnish sidecar caches
<code>/api/images/*</code> (24h) and
<code>/_next/static</code> assets (7d) before requests reach
Next.js.
</li>
<li>
<strong>Mail</strong>: SMTP (smtp.lomavuokraus.fi CNAME to
smtp.sohva.org) + DKIM signing for verification emails.
</li>
<li>
<strong>Auth</strong>: Email/password, verified+approved
requirement, JWT session cookie (<code>session_token</code>), roles.
</li>
</ul> </ul>
<h3>How the Next.js App Router is wired here</h3> <h3>How the Next.js App Router is wired here</h3>
<ul> <ul>
<li><strong>Rendering model</strong>: Server Components by default for pages under <code>app/</code>; Client Components opt in with <code>"use client"</code> (forms, maps, language toggle). This keeps most data fetching server-side and ships minimal JS.</li> <li>
<li><strong>Routes</strong>: File-system routing in <code>app/</code>; notable paths: <code>app/page.tsx</code> (home), <code>app/browse/page.tsx</code> (search + map), <code>app/listings/[slug]/page.tsx</code> (detail), and admin routes under <code>app/admin</code>. API handlers live in <code>app/api/*/route.ts</code> (REST-like endpoints used by forms and fetch calls).</li> <strong>Rendering model</strong>: Server Components by default for
<li><strong>Data fetching</strong>: Server Components call Prisma directly; where client state is needed, pages expose lightweight fetchers that hit the API routes. Revalidation uses <code>fetch(..., { cache: 'no-store' })</code> for sensitive pages (profile/admin) and ISR for mostly-read content (listing details + home feed).</li> pages under <code>app/</code>; Client Components opt in with
<li><strong>Mutations</strong>: Forms post to API route handlers (e.g., auth, listing create/edit, approvals). Responses trigger router refresh on the client via <code>useRouter().refresh()</code> so the server-rendered data is re-fetched without a full page reload.</li> <code>"use client"</code> (forms, maps, language toggle). This keeps
<li><strong>Auth enforcement</strong>: <code>middleware.ts</code> checks the session cookie early and guards admin/listing edit areas; protected pages also re-validate the session on the server before rendering. The same session util is shared by API handlers to avoid drift.</li> most data fetching server-side and ships minimal JS.
<li><strong>Assets & images</strong>: Static assets are served from <code>/public</code> and cached by Varnish and Next.js. Listing images are streamed through <code>/api/images/:id</code>; the handler sets cache headers while respecting auth where required.</li> </li>
<li>
<strong>Routes</strong>: File-system routing in <code>app/</code>;
notable paths: <code>app/page.tsx</code> (home),
<code>app/browse/page.tsx</code> (search + map),
<code>app/listings/[slug]/page.tsx</code> (detail), and admin routes
under <code>app/admin</code>. API handlers live in
<code>app/api/*/route.ts</code> (REST-like endpoints used by forms
and fetch calls).
</li>
<li>
<strong>Data fetching</strong>: Server Components call Prisma
directly; where client state is needed, pages expose lightweight
fetchers that hit the API routes. Revalidation uses
<code>fetch(..., { cache: 'no-store' })</code> for sensitive pages
(profile/admin) and ISR for mostly-read content (listing details +
home feed).
</li>
<li>
<strong>Mutations</strong>: Forms post to API route handlers (e.g.,
auth, listing create/edit, approvals). Responses trigger router
refresh on the client via <code>useRouter().refresh()</code> so the
server-rendered data is re-fetched without a full page reload.
</li>
<li>
<strong>Auth enforcement</strong>: <code>middleware.ts</code> checks
the session cookie early and guards admin/listing edit areas;
protected pages also re-validate the session on the server before
rendering. The same session util is shared by API handlers to avoid
drift.
</li>
<li>
<strong>Assets & images</strong>: Static assets are served from
<code>/public</code> and cached by Varnish and Next.js. Listing
images are streamed through <code>/api/images/:id</code>; the
handler sets cache headers while respecting auth where required.
</li>
</ul> </ul>
</section> </section>
</main> </main>
<script type="module"> <script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs'; import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({ startOnLoad: true, theme: 'dark' }); mermaid.initialize({ startOnLoad: true, theme: "dark" });
</script> </script>
</body> </body>
</html> </html>

View file

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -8,15 +8,28 @@
<body> <body>
<header> <header>
<h1>Build &amp; Deploy Pipeline</h1> <h1>Build &amp; Deploy Pipeline</h1>
<div class="meta">Node/Next build, Docker multi-stage, registry push, kubectl rollout.</div> <div class="meta">
Node/Next build, Docker multi-stage, registry push, kubectl rollout.
</div>
</header> </header>
<main class="grid"> <main class="grid">
<section class="card"> <section class="card">
<h2>Local prerequisites (macOS)</h2> <h2>Local prerequisites (macOS)</h2>
<ul> <ul>
<li>Run <code>./scripts/install-mac-prereqs.sh</code> to install dev/test tools via Homebrew (Node 20, envsubst/gettext, kubectl, sops, Trivy, Docker Desktop).</li> <li>
<li>Requires Homebrew pre-installed; set <code>SKIP_TRIVY=1</code> and/or <code>SKIP_SOPS=1</code> to avoid optional security tools.</li> Run <code>./scripts/install-mac-prereqs.sh</code> to install
<li>After install, open Docker.app once so the daemon is running before you build or run ZAP/Trivy scans.</li> dev/test tools via Homebrew (Node 20, envsubst/gettext, kubectl,
sops, Trivy, Docker Desktop).
</li>
<li>
Requires Homebrew pre-installed; set
<code>SKIP_TRIVY=1</code> and/or <code>SKIP_SOPS=1</code> to avoid
optional security tools.
</li>
<li>
After install, open Docker.app once so the daemon is running before
you build or run ZAP/Trivy scans.
</li>
</ul> </ul>
</section> </section>
@ -37,16 +50,28 @@ flowchart LR
DeployProd --> RolloutProd["kubectl apply + rollout\n(prod)"] DeployProd --> RolloutProd["kubectl apply + rollout\n(prod)"]
</pre> </pre>
</div> </div>
<div class="callout">Edit the Mermaid block to reflect pipeline changes; no external tooling required.</div> <div class="callout">
Edit the Mermaid block to reflect pipeline changes; no external
tooling required.
</div>
</section> </section>
<section class="card"> <section class="card">
<h2>Build Inputs</h2> <h2>Build Inputs</h2>
<ul> <ul>
<li>Source: Next.js app with TypeScript and Prisma.</li> <li>Source: Next.js app with TypeScript and Prisma.</li>
<li>Env: <code>.env</code> (local), K8s Secret <code>lomavuokraus-web-secrets</code> in cluster.</li> <li>
<li>Local secrets: <code>creds/secrets.env</code> (dotenv) loadable via <code>scripts/load-secrets.sh</code>.</li> Env: <code>.env</code> (local), K8s Secret
<li>Prisma schema: <code>prisma/schema.prisma</code>, migrations in <code>prisma/migrations/</code>.</li> <code>lomavuokraus-web-secrets</code> in cluster.
</li>
<li>
Local secrets: <code>creds/secrets.env</code> (dotenv) loadable via
<code>scripts/load-secrets.sh</code>.
</li>
<li>
Prisma schema: <code>prisma/schema.prisma</code>, migrations in
<code>prisma/migrations/</code>.
</li>
</ul> </ul>
</section> </section>
@ -54,17 +79,24 @@ flowchart LR
<h2>NPM Scripts</h2> <h2>NPM Scripts</h2>
<ul> <ul>
<li><code>npm run lint</code><code>next lint</code></li> <li><code>npm run lint</code><code>next lint</code></li>
<li><code>npm run build</code><code>next build</code> (used inside Docker and locally)</li> <li>
<code>npm run build</code><code>next build</code> (used inside
Docker and locally)
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Docker Image</h2> <h2>Docker Image</h2>
<ul> <ul>
<li>Multi-stage Dockerfile: <li>
Multi-stage Dockerfile:
<ul> <ul>
<li>deps: npm ci</li> <li>deps: npm ci</li>
<li>builder: copy source, <code>npx prisma generate</code>, <code>npm run build</code></li> <li>
builder: copy source, <code>npx prisma generate</code>,
<code>npm run build</code>
</li>
<li>runner: Node 20 bookworm-slim, copy standalone + static</li> <li>runner: Node 20 bookworm-slim, copy standalone + static</li>
</ul> </ul>
</li> </li>
@ -76,33 +108,55 @@ flowchart LR
<section class="card"> <section class="card">
<h2>Deploy Scripts</h2> <h2>Deploy Scripts</h2>
<ul> <ul>
<li><code>deploy/build.sh</code> → build image, write <code>deploy/.last-image</code>.</li> <li>
<code>deploy/build.sh</code> → build image, write
<code>deploy/.last-image</code>.
</li>
<li><code>deploy/push.sh</code> → push image.</li> <li><code>deploy/push.sh</code> → push image.</li>
<li><code>deploy/deploy.sh</code> → envsubst <code>k8s/app.yaml</code>, kubectl apply, rollout.</li> <li>
<li>Environment wrappers: <code>deploy/deploy.sh</code> → envsubst <code>k8s/app.yaml</code>,
kubectl apply, rollout.
</li>
<li>
Environment wrappers:
<ul> <ul>
<li><code>deploy/deploy-staging.sh</code></li> <li><code>deploy/deploy-staging.sh</code></li>
<li><code>deploy/deploy-prod.sh</code></li> <li><code>deploy/deploy-prod.sh</code></li>
<li><code>deploy/deploy-test.sh</code></li> <li><code>deploy/deploy-test.sh</code></li>
</ul> </ul>
</li> </li>
<li>DNS helpers: <code>deploy/update-test-dns.sh</code> updates test.lomavuokraus.fi + apitest.lomavuokraus.fi via Joker DYNDNS.</li> <li>
DNS helpers: <code>deploy/update-test-dns.sh</code> updates
test.lomavuokraus.fi + apitest.lomavuokraus.fi via Joker DYNDNS.
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Config & Env Vars</h2> <h2>Config & Env Vars</h2>
<ul> <ul>
<li>From ConfigMap (public): <code>NEXT_PUBLIC_SITE_URL</code>, <code>NEXT_PUBLIC_API_BASE</code>, <code>APP_ENV</code>.</li> <li>
<li>From Secret: DB URL, AUTH_SECRET, SMTP, DKIM, etc. (materialize from <code>creds/secrets.env</code>).</li> From ConfigMap (public): <code>NEXT_PUBLIC_SITE_URL</code>,
<li>App env resolution: <code>process.env.*</code> in Next server code.</li> <code>NEXT_PUBLIC_API_BASE</code>, <code>APP_ENV</code>.
<li>n8n billing assistant: <code>N8N_BILLING_API_KEY</code> or file <code>creds/n8n-billing.key</code> protects <code>/api/integrations/billing/verify</code>.</li> </li>
<li>
From Secret: DB URL, AUTH_SECRET, SMTP, DKIM, etc. (materialize from
<code>creds/secrets.env</code>).
</li>
<li>
App env resolution: <code>process.env.*</code> in Next server code.
</li>
<li>
n8n billing assistant: <code>N8N_BILLING_API_KEY</code> or file
<code>creds/n8n-billing.key</code> protects
<code>/api/integrations/billing/verify</code>.
</li>
</ul> </ul>
</section> </section>
</main> </main>
<script type="module"> <script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs'; import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({ startOnLoad: true, theme: 'dark' }); mermaid.initialize({ startOnLoad: true, theme: "dark" });
</script> </script>
</body> </body>
</html> </html>

View file

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -8,14 +8,28 @@
<body> <body>
<header> <header>
<h1>Git Workflow &amp; Branch Protection</h1> <h1>Git Workflow &amp; Branch Protection</h1>
<div class="meta">Keep master protected; land changes via PRs with review and passing checks.</div> <div class="meta">
Keep master protected; land changes via PRs with review and passing
checks.
</div>
</header> </header>
<main class="grid"> <main class="grid">
<section class="card"> <section class="card">
<h2>Remotes</h2> <h2>Remotes</h2>
<ul> <ul>
<li>Forgejo: <code>ssh://git@git.halla-aho.net:2223/thalla/lomavuokraus.git</code></li> <li>
<li>Add remote if missing: <code>git remote add origin ssh://git@git.halla-aho.net:2223/thalla/lomavuokraus.git</code></li> Forgejo:
<code
>ssh://git@git.halla-aho.net:2223/thalla/lomavuokraus.git</code
>
</li>
<li>
Add remote if missing:
<code
>git remote add origin
ssh://git@git.halla-aho.net:2223/thalla/lomavuokraus.git</code
>
</li>
<li>Verify remotes: <code>git remote -v</code></li> <li>Verify remotes: <code>git remote -v</code></li>
</ul> </ul>
</section> </section>
@ -23,40 +37,81 @@
<section class="card"> <section class="card">
<h2>Daily flow</h2> <h2>Daily flow</h2>
<ul> <ul>
<li>Sync master: <code>git checkout master && git pull --rebase</code></li> <li>
<li>Create feature branch: <code>git checkout -b feature/&lt;name&gt;</code></li> Sync master: <code>git checkout master && git pull --rebase</code>
<li>Commit locally: <code>git status</code>, <code>git add</code>, <code>git commit -m "message"</code></li> </li>
<li>Push branch: <code>git push -u origin feature/&lt;name&gt;</code></li> <li>
Create feature branch:
<code>git checkout -b feature/&lt;name&gt;</code>
</li>
<li>
Commit locally: <code>git status</code>, <code>git add</code>,
<code>git commit -m "message"</code>
</li>
<li>
Push branch: <code>git push -u origin feature/&lt;name&gt;</code>
</li>
<li>Open PR to master in Forgejo; wait for review + CI</li> <li>Open PR to master in Forgejo; wait for review + CI</li>
<li>After merge: <code>git checkout master && git pull --rebase && git branch -d feature/&lt;name&gt;</code></li> <li>
After merge:
<code
>git checkout master && git pull --rebase && git branch -d
feature/&lt;name&gt;</code
>
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Branch protection</h2> <h2>Branch protection</h2>
<ul> <ul>
<li>Protect <code>master</code> in Forgejo (no direct pushes, require PR + ≥1 approval).</li> <li>
Protect <code>master</code> in Forgejo (no direct pushes, require PR
+ ≥1 approval).
</li>
<li>Optional: require status checks and dismiss stale approvals.</li> <li>Optional: require status checks and dismiss stale approvals.</li>
<li>Restrict who can push to protected branches to admins/maintainers.</li> <li>
Restrict who can push to protected branches to admins/maintainers.
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Local config</h2> <h2>Local config</h2>
<ul> <ul>
<li>Pull strategy: <code>git config pull.rebase true</code> (or false/ff-only per preference).</li> <li>
<li>User: <code>git config user.name "Your Name"</code>, <code>git config user.email "you@example.com"</code></li> Pull strategy: <code>git config pull.rebase true</code> (or
false/ff-only per preference).
</li>
<li>
User: <code>git config user.name "Your Name"</code>,
<code>git config user.email "you@example.com"</code>
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Troubleshooting</h2> <h2>Troubleshooting</h2>
<ul> <ul>
<li>Divergent pull: set pull strategy, rerun <code>git pull --rebase</code> or <code>git pull</code>.</li> <li>
<li>Protected branch rejection: push to feature branch and open a PR.</li> Divergent pull: set pull strategy, rerun
<li>Conflicts: <code>git status</code>, resolve, <code>git add</code>, then <code>git rebase --continue</code> or commit.</li> <code>git pull --rebase</code> or <code>git pull</code>.
<li>Wrong branch: <code>git checkout -b feature/fix</code>, push, open PR.</li> </li>
<li>Clean merged branches: <code>git branch --merged master</code> then <code>git branch -d ...</code></li> <li>
Protected branch rejection: push to feature branch and open a PR.
</li>
<li>
Conflicts: <code>git status</code>, resolve, <code>git add</code>,
then <code>git rebase --continue</code> or commit.
</li>
<li>
Wrong branch: <code>git checkout -b feature/fix</code>, push, open
PR.
</li>
<li>
Clean merged branches: <code>git branch --merged master</code> then
<code>git branch -d ...</code>
</li>
</ul> </ul>
</section> </section>
@ -71,8 +126,15 @@
<section class="card"> <section class="card">
<h2>Secrets &amp; kubeconfig</h2> <h2>Secrets &amp; kubeconfig</h2>
<ul> <ul>
<li>Secrets: <code>creds/secrets.enc.env</code> (sops/age). Load via <code>source scripts/load-secrets.sh</code>.</li> <li>
<li>Kubeconfig: <code>creds/kubeconfig.enc.yaml</code> decrypted automatically by <code>scripts/load-secrets.sh</code> when sops is available.</li> Secrets: <code>creds/secrets.enc.env</code> (sops/age). Load via
<code>source scripts/load-secrets.sh</code>.
</li>
<li>
Kubeconfig: <code>creds/kubeconfig.enc.yaml</code> decrypted
automatically by <code>scripts/load-secrets.sh</code> when sops is
available.
</li>
</ul> </ul>
</section> </section>
</main> </main>

View file

@ -1,38 +1,40 @@
Git Workflow and Branch Protection # Git Workflow and Branch Protection
==================================
Goal Goal
- Keep `master` protected; changes land via pull requests with review and passing checks. - Keep `master` protected; changes land via pull requests with review and passing checks.
Remotes Remotes
- Forgejo repo: `ssh://git@git.halla-aho.net:2223/thalla/lomavuokraus.git` - Forgejo repo: `ssh://git@git.halla-aho.net:2223/thalla/lomavuokraus.git`
- Add remote if missing: `git remote add origin ssh://git@git.halla-aho.net:2223/thalla/lomavuokraus.git` - Add remote if missing: `git remote add origin ssh://git@git.halla-aho.net:2223/thalla/lomavuokraus.git`
- Verify: `git remote -v` - Verify: `git remote -v`
Daily flow Daily flow
1) Sync `master`:
1. Sync `master`:
``` ```
git checkout master git checkout master
git pull --rebase # or `git pull` if you prefer merges; see config below git pull --rebase # or `git pull` if you prefer merges; see config below
``` ```
2) Create a feature branch: 2. Create a feature branch:
``` ```
git checkout -b feature/<short-name> git checkout -b feature/<short-name>
``` ```
3) Commit locally: 3. Commit locally:
``` ```
git status git status
git add <files> git add <files>
git commit -m "Your change" git commit -m "Your change"
``` ```
4) Push branch to Forgejo: 4. Push branch to Forgejo:
``` ```
git push -u origin feature/<short-name> git push -u origin feature/<short-name>
``` ```
5) Open a PR targeting `master` in Forgejo. 5. Open a PR targeting `master` in Forgejo.
6) Get review + approvals; ensure CI passes. 6. Get review + approvals; ensure CI passes.
7) Merge via the PR; master stays protected. 7. Merge via the PR; master stays protected.
8) After merge, sync local master: 8. After merge, sync local master:
``` ```
git checkout master git checkout master
git pull --rebase git pull --rebase
@ -40,6 +42,7 @@ Daily flow
``` ```
Branch protection (set in Forgejo UI) Branch protection (set in Forgejo UI)
- Settings → Branches → Protect `master`. - Settings → Branches → Protect `master`.
- Enable: - Enable:
- Prevent direct pushes (except admins if desired). - Prevent direct pushes (except admins if desired).
@ -49,6 +52,7 @@ Branch protection (set in Forgejo UI)
- Optionally dismiss stale approvals and require status checks (CI). - Optionally dismiss stale approvals and require status checks (CI).
Recommended local config Recommended local config
- Default pull strategy (pick one): - Default pull strategy (pick one):
- Rebase pulls: `git config pull.rebase true` - Rebase pulls: `git config pull.rebase true`
- Merge pulls: `git config pull.rebase false` - Merge pulls: `git config pull.rebase false`
@ -60,6 +64,7 @@ Recommended local config
``` ```
Common troubleshooting Common troubleshooting
- Divergent branches on pull: set pull strategy (see above) and rerun `git pull --rebase` or `git pull`. - Divergent branches on pull: set pull strategy (see above) and rerun `git pull --rebase` or `git pull`.
- “Remote not found”: add `origin` remote (see Remotes). - “Remote not found”: add `origin` remote (see Remotes).
- Push rejected (protected branch): push to a feature branch and open a PR. - Push rejected (protected branch): push to a feature branch and open a PR.
@ -79,8 +84,10 @@ Common troubleshooting
- Clean up merged branches locally: `git branch --merged master` then `git branch -d <branch>`. - Clean up merged branches locally: `git branch --merged master` then `git branch -d <branch>`.
CI CI
- Workflows live under `.forgejo/workflows/`. Ensure CI passes before merging. - Workflows live under `.forgejo/workflows/`. Ensure CI passes before merging.
Secrets and kubeconfig Secrets and kubeconfig
- `creds/` is git-ignored; place kubeconfig at `creds/kubeconfig.yaml` or set `KUBECONFIG`. - `creds/` is git-ignored; place kubeconfig at `creds/kubeconfig.yaml` or set `KUBECONFIG`.
- Age/SOPS key: store at `~/.config/age/keys.txt` or set `SOPS_AGE_KEY_FILE`. - Age/SOPS key: store at `~/.config/age/keys.txt` or set `SOPS_AGE_KEY_FILE`.

View file

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -8,7 +8,10 @@
<body> <body>
<header> <header>
<h1>Lomavuokraus Documentation</h1> <h1>Lomavuokraus Documentation</h1>
<div class="meta">Diagram-first docs rendered with Mermaid; tracked in git, not deployed with the app.</div> <div class="meta">
Diagram-first docs rendered with Mermaid; tracked in git, not deployed
with the app.
</div>
</header> </header>
<main class="grid"> <main class="grid">
<section class="card"> <section class="card">
@ -26,15 +29,21 @@
<section class="card"> <section class="card">
<h3>How diagrams work</h3> <h3>How diagrams work</h3>
<ul> <ul>
<li>All diagrams use Mermaid (rendered in-browser via CDN; no extra tooling needed).</li> <li>
All diagrams use Mermaid (rendered in-browser via CDN; no extra
tooling needed).
</li>
<li>Graph definitions live inline in the HTML for quick edits.</li> <li>Graph definitions live inline in the HTML for quick edits.</li>
<li>Docs live in <code>docs/</code>; they are tracked but not shipped with the app.</li> <li>
Docs live in <code>docs/</code>; they are tracked but not shipped
with the app.
</li>
</ul> </ul>
</section> </section>
</main> </main>
<script type="module"> <script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs'; import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({ startOnLoad: true, theme: 'dark' }); mermaid.initialize({ startOnLoad: true, theme: "dark" });
</script> </script>
</body> </body>
</html> </html>

View file

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -8,7 +8,10 @@
<body> <body>
<header> <header>
<h1>Infrastructure Overview</h1> <h1>Infrastructure Overview</h1>
<div class="meta">Hetzner k3s cluster, Traefik ingress, cert-manager TLS, private registry, staging/prod namespaces.</div> <div class="meta">
Hetzner k3s cluster, Traefik ingress, cert-manager TLS, private
registry, staging/prod namespaces.
</div>
</header> </header>
<main class="grid"> <main class="grid">
<section class="card"> <section class="card">
@ -31,7 +34,10 @@ flowchart LR
Registry["registry.halla-aho.net/thalla/lomavuokraus-web"] -->|"pull"| Pod Registry["registry.halla-aho.net/thalla/lomavuokraus-web"] -->|"pull"| Pod
</pre> </pre>
</div> </div>
<div class="callout">Mermaid renders directly in the browser; edit the graph in this file to update.</div> <div class="callout">
Mermaid renders directly in the browser; edit the graph in this file
to update.
</div>
</section> </section>
<section class="card"> <section class="card">
@ -68,70 +74,152 @@ flowchart TB
<section class="card"> <section class="card">
<h2>Cluster &amp; Namespaces</h2> <h2>Cluster &amp; Namespaces</h2>
<ul> <ul>
<li>Single-node k3s (Hetzner hel1 cx23) at <code>157.180.66.64</code>.</li> <li>
<li>Namespaces: <code>lomavuokraus-prod</code>, <code>lomavuokraus-staging</code>, <code>lomavuokraus-test</code>.</li> Single-node k3s (Hetzner hel1 cx23) at <code>157.180.66.64</code>.
</li>
<li>
Namespaces: <code>lomavuokraus-prod</code>,
<code>lomavuokraus-staging</code>, <code>lomavuokraus-test</code>.
</li>
<li>Ingress controller: Traefik (k3s default).</li> <li>Ingress controller: Traefik (k3s default).</li>
<li>cert-manager v1.15.3 with ClusterIssuers: <li>
cert-manager v1.15.3 with ClusterIssuers:
<ul> <ul>
<li><code>letsencrypt-prod</code> (ACME prod)</li> <li><code>letsencrypt-prod</code> (ACME prod)</li>
<li><code>letsencrypt-staging</code> (ACME staging for test certs)</li> <li>
<code>letsencrypt-staging</code> (ACME staging for test certs)
</li>
</ul> </ul>
</li> </li>
<li>Service points to a Varnish sidecar (port 8080) in each pod before the Next.js container (3000) to cache <code>/api/images/*</code> and static assets.</li> <li>
<li>Cache policy: images cached 24h with <code>Cache-Control: public, max-age=86400, immutable</code>; <code>_next/static</code> cached 7d; non-GET traffic and health checks bypass cache.</li> Service points to a Varnish sidecar (port 8080) in each pod before
<li>DNS: <code>lomavuokraus.fi</code>, <code>staging.lomavuokraus.fi</code>, <code>api.lomavuokraus.fi</code> -> cluster IP.</li> the Next.js container (3000) to cache <code>/api/images/*</code> and
static assets.
</li>
<li>
Cache policy: images cached 24h with
<code>Cache-Control: public, max-age=86400, immutable</code>;
<code>_next/static</code> cached 7d; non-GET traffic and health
checks bypass cache.
</li>
<li>
DNS: <code>lomavuokraus.fi</code>,
<code>staging.lomavuokraus.fi</code>,
<code>api.lomavuokraus.fi</code> -> cluster IP.
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Registry</h2> <h2>Registry</h2>
<ul> <ul>
<li>Private registry: <code>registry.halla-aho.net/thalla/lomavuokraus-web</code>.</li> <li>
<li>Credentials stored outside repo (<code>creds/</code>), image pull secret <code>registry-halla</code> in staging/prod namespaces.</li> Private registry:
<li>Images tagged with git SHA-derived numeric tag and <code>:latest</code>.</li> <code>registry.halla-aho.net/thalla/lomavuokraus-web</code>.
</li>
<li>
Credentials stored outside repo (<code>creds/</code>), image pull
secret <code>registry-halla</code> in staging/prod namespaces.
</li>
<li>
Images tagged with git SHA-derived numeric tag and
<code>:latest</code>.
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>App Manifests</h2> <h2>App Manifests</h2>
<ul> <ul>
<li><code>k8s/app.yaml</code> templated via envsubst in deploy scripts.</li> <li>
<li>Objects: <code>k8s/app.yaml</code> templated via envsubst in deploy scripts.
</li>
<li>
Objects:
<ul> <ul>
<li>ConfigMap: <code>lomavuokraus-web-config</code> (public env).</li> <li>
<li>Deployment: 2 replicas, Varnish sidecar on port 8080 in front of the Next.js container (3000), liveness/readiness on <code>/api/health</code> via Varnish.</li> ConfigMap: <code>lomavuokraus-web-config</code> (public env).
<li>Service: ClusterIP on port 80 targeting the Varnish container.</li> </li>
<li>Ingress: Traefik class, TLS via cert-manager, HTTPS redirect middleware.</li> <li>
<li>Traefik Middleware: <code>https-redirect</code> to force HTTPS.</li> Deployment: 2 replicas, Varnish sidecar on port 8080 in front of
the Next.js container (3000), liveness/readiness on
<code>/api/health</code> via Varnish.
</li>
<li>
Service: ClusterIP on port 80 targeting the Varnish container.
</li>
<li>
Ingress: Traefik class, TLS via cert-manager, HTTPS redirect
middleware.
</li>
<li>
Traefik Middleware: <code>https-redirect</code> to force HTTPS.
</li>
</ul> </ul>
</li> </li>
<li>Secrets: <code>lomavuokraus-web-secrets</code> in cluster (not in repo).</li> <li>
Secrets: <code>lomavuokraus-web-secrets</code> in cluster (not in
repo).
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Runtime Environment</h2> <h2>Runtime Environment</h2>
<ul> <ul>
<li>Next.js 14.2.33 (App Router) running via Node.js 20 in Docker.</li> <li>
<li>PostgreSQL at <code>46.62.203.202</code>; staging/prod DB <code>lomavuokraus</code> is clean with only the seeded admin, testing DB <code>lomavuokraus_testing</code> holds the previous data. Schema snapshot tracked in <code>docs/db-schema.sql</code>.</li> Next.js 14.2.33 (App Router) running via Node.js 20 in Docker.
<li>SMTP: smtp.lomavuokraus.fi (CNAME to smtp.sohva.org), DKIM key under <code>creds/dkim/...</code>.</li> </li>
<li>Session auth: signed JWT cookie <code>session_token</code>; roles: USER, ADMIN, USER_MODERATOR, LISTING_MODERATOR.</li> <li>
PostgreSQL at <code>46.62.203.202</code>; staging/prod DB
<code>lomavuokraus</code> is clean with only the seeded admin,
testing DB <code>lomavuokraus_testing</code> holds the previous
data. Schema snapshot tracked in <code>docs/db-schema.sql</code>.
</li>
<li>
SMTP: smtp.lomavuokraus.fi (CNAME to smtp.sohva.org), DKIM key under
<code>creds/dkim/...</code>.
</li>
<li>
Session auth: signed JWT cookie <code>session_token</code>; roles:
USER, ADMIN, USER_MODERATOR, LISTING_MODERATOR.
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Emergency shutdown</h2> <h2>Emergency shutdown</h2>
<ul> <ul>
<li>Script: <code>scripts/emergency-shutdown.sh</code> issues Hetzner poweroff/shutdown commands for tracked nodes (currently <code>node1.lomavuokraus.fi</code> and <code>db1.lomavuokraus.fi</code>).</li> <li>
<li>List tracked nodes: <code>./scripts/emergency-shutdown.sh --list</code>.</li> Script: <code>scripts/emergency-shutdown.sh</code> issues Hetzner
<li>Hard stop everything: <code>./scripts/emergency-shutdown.sh --yes --confirm "SHUTDOWN ALL LOMAVUOKRAUS NODES NOW"</code> (default action is <code>poweroff</code>; use <code>--action shutdown</code> for ACPI).</li> poweroff/shutdown commands for tracked nodes (currently
<li>Requires <code>hcloud</code> CLI with <code>HCLOUD_TOKEN</code> or configured at <code>~/.config/hcloud/cli.toml</code>; keep the node list updated when adding servers.</li> <code>node1.lomavuokraus.fi</code> and
<code>db1.lomavuokraus.fi</code>).
</li>
<li>
List tracked nodes:
<code>./scripts/emergency-shutdown.sh --list</code>.
</li>
<li>
Hard stop everything:
<code
>./scripts/emergency-shutdown.sh --yes --confirm "SHUTDOWN ALL
LOMAVUOKRAUS NODES NOW"</code
>
(default action is <code>poweroff</code>; use
<code>--action shutdown</code> for ACPI).
</li>
<li>
Requires <code>hcloud</code> CLI with <code>HCLOUD_TOKEN</code> or
configured at <code>~/.config/hcloud/cli.toml</code>; keep the node
list updated when adding servers.
</li>
</ul> </ul>
</section> </section>
</main> </main>
<script type="module"> <script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs'; import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({ startOnLoad: true, theme: 'dark' }); mermaid.initialize({ startOnLoad: true, theme: "dark" });
</script> </script>
</body> </body>
</html> </html>

View file

@ -9,6 +9,7 @@ We ship a lightweight logging stack into the cluster so API/UI logs are searchab
## Install / upgrade ## Install / upgrade
Prereqs: Prereqs:
- `kubectl`/`helm` access to the cluster (the script downloads Helm if missing). - `kubectl`/`helm` access to the cluster (the script downloads Helm if missing).
- Environment: `GRAFANA_ADMIN_PASSWORD` (required), optional `LOGS_HOST` (default `logs.lomavuokraus.fi`), `GRAFANA_CLUSTER_ISSUER` (default `letsencrypt-prod`), `LOGGING_NAMESPACE` (default `logging`). - Environment: `GRAFANA_ADMIN_PASSWORD` (required), optional `LOGS_HOST` (default `logs.lomavuokraus.fi`), `GRAFANA_CLUSTER_ISSUER` (default `letsencrypt-prod`), `LOGGING_NAMESPACE` (default `logging`).
@ -22,6 +23,7 @@ bash deploy/install-logging.sh
``` ```
The script: The script:
1. Ensures Helm is available. 1. Ensures Helm is available.
2. Installs/updates Loki, Promtail, and Grafana in the logging namespace. 2. Installs/updates Loki, Promtail, and Grafana in the logging namespace.
3. Creates a Grafana ingress with TLS via the chosen ClusterIssuer. 3. Creates a Grafana ingress with TLS via the chosen ClusterIssuer.

View file

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -8,41 +8,87 @@
<body> <body>
<header> <header>
<h1>Redmine Integration</h1> <h1>Redmine Integration</h1>
<div class="meta">File tickets automatically when tests fail and review open work by tracker.</div> <div class="meta">
File tickets automatically when tests fail and review open work by
tracker.
</div>
</header> </header>
<main class="grid"> <main class="grid">
<section class="card"> <section class="card">
<h2>Setup</h2> <h2>Setup</h2>
<ul> <ul>
<li>Env vars (see <code>.env.example</code>): <code>REDMINE_URL</code>, <code>REDMINE_API_KEY</code>, <code>REDMINE_PROJECT_ID</code>, <code>REDMINE_TRACKER_BUG_ID</code>, <code>REDMINE_TRACKER_SECURITY_ID</code> (optional, falls back to bug), <code>REDMINE_ASSIGNEE_ID</code> (optional default owner).</li> <li>
<li>Uses the Redmine REST API with the API key for authentication.</li> Env vars (see <code>.env.example</code>): <code>REDMINE_URL</code>,
<li>Ensure the API key is scoped to the project and can create issues.</li> <code>REDMINE_API_KEY</code>, <code>REDMINE_PROJECT_ID</code>,
<code>REDMINE_TRACKER_BUG_ID</code>,
<code>REDMINE_TRACKER_SECURITY_ID</code> (optional, falls back to
bug), <code>REDMINE_ASSIGNEE_ID</code> (optional default owner).
</li>
<li>
Uses the Redmine REST API with the API key for authentication.
</li>
<li>
Ensure the API key is scoped to the project and can create issues.
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Automatic tickets on failures</h2> <h2>Automatic tickets on failures</h2>
<ul> <ul>
<li><code>scripts/run-test-suite.sh</code> now files a Redmine issue whenever any check fails. Security-related failures (npm audit, Trivy, ZAP) use the security tracker when configured.</li> <li>
<li>Issues are de-duplicated by fingerprinting the suite/target + failure details; reruns of the same failing state will re-use the open ticket instead of creating a duplicate.</li> <code>scripts/run-test-suite.sh</code> now files a Redmine issue
<li>Manual wrapper for other test commands: <code>./scripts/run-tests-with-redmine.sh npm test</code> (set <code>TEST_NAME</code> or <code>TRACKER</code> to override labels).</li> whenever any check fails. Security-related failures (npm audit,
Trivy, ZAP) use the security tracker when configured.
</li>
<li>
Issues are de-duplicated by fingerprinting the suite/target +
failure details; reruns of the same failing state will re-use the
open ticket instead of creating a duplicate.
</li>
<li>
Manual wrapper for other test commands:
<code>./scripts/run-tests-with-redmine.sh npm test</code> (set
<code>TEST_NAME</code> or <code>TRACKER</code> to override labels).
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>CLI tools</h2> <h2>CLI tools</h2>
<ul> <ul>
<li>List open tickets grouped by tracker: <code>REDMINE_URL=... REDMINE_API_KEY=... REDMINE_PROJECT_ID=... ./scripts/redmine-report.js list-open</code></li> <li>
<li>Manual issue creation from a log file: <code>./scripts/redmine-report.js create-test-issue --suite my-tests --failures-file /path/to/log --tracker bug</code></li> List open tickets grouped by tracker:
<li>Outputs go to stdout; non-zero exit code on API errors so CI can fail fast.</li> <code
>REDMINE_URL=... REDMINE_API_KEY=... REDMINE_PROJECT_ID=...
./scripts/redmine-report.js list-open</code
>
</li>
<li>
Manual issue creation from a log file:
<code
>./scripts/redmine-report.js create-test-issue --suite my-tests
--failures-file /path/to/log --tracker bug</code
>
</li>
<li>
Outputs go to stdout; non-zero exit code on API errors so CI can
fail fast.
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Notes</h2> <h2>Notes</h2>
<ul> <ul>
<li>Tickets include a short fingerprint in the subject and description; keep it when editing so future runs keep de-duplicating.</li> <li>
<li>Summary/log paths are included in the issue body to help locate artifacts from the run.</li> Tickets include a short fingerprint in the subject and description;
keep it when editing so future runs keep de-duplicating.
</li>
<li>
Summary/log paths are included in the issue body to help locate
artifacts from the run.
</li>
</ul> </ul>
</section> </section>
</main> </main>

View file

@ -1,6 +1,7 @@
# Secrets workflow (sops + age) # Secrets workflow (sops + age)
## Files ## Files
- `creds/age-key.txt`: age private key (keep out of git; store in a password manager). Public key is in the header. - `creds/age-key.txt`: age private key (keep out of git; store in a password manager). Public key is in the header.
- `creds/secrets.enc.env`: encrypted dotenv managed by sops/age (committable). - `creds/secrets.enc.env`: encrypted dotenv managed by sops/age (committable).
- `creds/secrets.env`: decrypted dotenv (git-ignored) produced when loading secrets; not committed. - `creds/secrets.env`: decrypted dotenv (git-ignored) produced when loading secrets; not committed.
@ -8,36 +9,45 @@
- `creds/n8n-billing.key`: API key for the billing verification endpoint (git-ignored). Can also be provided via `N8N_BILLING_API_KEY`. - `creds/n8n-billing.key`: API key for the billing verification endpoint (git-ignored). Can also be provided via `N8N_BILLING_API_KEY`.
## Editing secrets ## Editing secrets
```bash ```bash
# Ensure sops+age binaries are available # Ensure sops+age binaries are available
sops creds/secrets.enc.env sops creds/secrets.enc.env
``` ```
Sops will decrypt, open in $EDITOR, and re-encrypt on save. The age recipient is configured in `.sops.yaml`. Sops will decrypt, open in $EDITOR, and re-encrypt on save. The age recipient is configured in `.sops.yaml`.
## Loading secrets locally ## Loading secrets locally
```bash ```bash
source scripts/load-secrets.sh source scripts/load-secrets.sh
``` ```
This decrypts `creds/secrets.enc.env` to `creds/secrets.env` if needed (requires sops) and exports all variables. This decrypts `creds/secrets.enc.env` to `creds/secrets.env` if needed (requires sops) and exports all variables.
## Adding developers ## Adding developers
- Share `creds/age-key.txt` securely (password manager). They need the age secret key to decrypt. - Share `creds/age-key.txt` securely (password manager). They need the age secret key to decrypt.
- No change to `.sops.yaml` is needed unless you rotate keys. - No change to `.sops.yaml` is needed unless you rotate keys.
## Deploys/CI ## Deploys/CI
- `deploy/deploy.sh` sources `scripts/load-secrets.sh`, so providing `creds/secrets.enc.env` + age key is enough for secret env injection. - `deploy/deploy.sh` sources `scripts/load-secrets.sh`, so providing `creds/secrets.enc.env` + age key is enough for secret env injection.
## Rotating keys ## Rotating keys
- Generate a new age key: `age-keygen -o creds/age-key.txt` (keep old backup if you need to reencrypt). - Generate a new age key: `age-keygen -o creds/age-key.txt` (keep old backup if you need to reencrypt).
- Update `.sops.yaml` recipient to the new public key. - Update `.sops.yaml` recipient to the new public key.
- Re-encrypt: `SOPS_AGE_KEY_FILE=creds/age-key.txt sops --encrypt --in-place creds/secrets.enc.env`. - Re-encrypt: `SOPS_AGE_KEY_FILE=creds/age-key.txt sops --encrypt --in-place creds/secrets.enc.env`.
## n8n billing API key ## n8n billing API key
- The billing assistant verification endpoint (`/api/integrations/billing/verify`) requires an API key. - The billing assistant verification endpoint (`/api/integrations/billing/verify`) requires an API key.
- Store it in `creds/n8n-billing.key` (git-ignored) or export `N8N_BILLING_API_KEY` via `creds/secrets.env`. - Store it in `creds/n8n-billing.key` (git-ignored) or export `N8N_BILLING_API_KEY` via `creds/secrets.env`.
- Rotate by replacing the file/env value and restarting the app/n8n caller with the new key. - Rotate by replacing the file/env value and restarting the app/n8n caller with the new key.
## Per-user age keys ## Per-user age keys
- Keys live under `creds/age/<user>.key` (git-ignored) and carry a public key in the header. - Keys live under `creds/age/<user>.key` (git-ignored) and carry a public key in the header.
- Helper: `./scripts/manage-age-key.sh add alice` generates a key and appends the recipient to `.sops.yaml`. - Helper: `./scripts/manage-age-key.sh add alice` generates a key and appends the recipient to `.sops.yaml`.
- Remove: `./scripts/manage-age-key.sh remove alice` deletes the key file and strips the recipient (re-encrypt afterwards). - Remove: `./scripts/manage-age-key.sh remove alice` deletes the key file and strips the recipient (re-encrypt afterwards).

View file

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -8,48 +8,99 @@
<body> <body>
<header> <header>
<h1>Security Testing</h1> <h1>Security Testing</h1>
<div class="meta">Quick OWASP ZAP baseline checks against any deployed environment.</div> <div class="meta">
Quick OWASP ZAP baseline checks against any deployed environment.
</div>
</header> </header>
<main class="grid"> <main class="grid">
<section class="card"> <section class="card">
<h2>Baseline scan</h2> <h2>Baseline scan</h2>
<ul> <ul>
<li>Script: <code>scripts/zap-baseline.sh</code></li> <li>Script: <code>scripts/zap-baseline.sh</code></li>
<li>Default target: <code>https://test.lomavuokraus.fi</code> (override with <code>TARGET</code>).</li> <li>
<li>Reports: <code>reports/security/zap-report.html</code> (also JSON/XML).</li> Default target: <code>https://test.lomavuokraus.fi</code> (override
<li>Example: <code>TARGET=https://staging.lomavuokraus.fi ./scripts/zap-baseline.sh</code></li> with <code>TARGET</code>).
<li>Duration: ~5 minutes by default (<code>TIMEOUT_MINUTES</code> env).</li> </li>
<li>Docker image: <code>owasp/zap2docker-stable</code> (override with <code>ZAP_IMAGE</code>).</li> <li>
Reports: <code>reports/security/zap-report.html</code> (also
JSON/XML).
</li>
<li>
Example:
<code
>TARGET=https://staging.lomavuokraus.fi
./scripts/zap-baseline.sh</code
>
</li>
<li>
Duration: ~5 minutes by default (<code>TIMEOUT_MINUTES</code> env).
</li>
<li>
Docker image: <code>owasp/zap2docker-stable</code> (override with
<code>ZAP_IMAGE</code>).
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Full test suite</h2> <h2>Full test suite</h2>
<ul> <ul>
<li>Script: <code>scripts/run-test-suite.sh</code></li> <li>Script: <code>scripts/run-test-suite.sh</code></li>
<li>Runs: <code>npm audit</code> (high), Trivy fs scan, ZAP baseline.</li> <li>
<li>Outputs: <code>reports/runs/&lt;timestamp&gt;/summary.html</code> with links to all tool reports and a textual summary printed to the console. Index of all runs: <code>reports/index.html</code>.</li> Runs: <code>npm audit</code> (high), Trivy fs scan, ZAP baseline.
<li>Config: </li>
<li>
Outputs:
<code>reports/runs/&lt;timestamp&gt;/summary.html</code> with links
to all tool reports and a textual summary printed to the console.
Index of all runs: <code>reports/index.html</code>.
</li>
<li>
Config:
<ul> <ul>
<li><code>TARGET</code>: ZAP target URL (default test env).</li> <li><code>TARGET</code>: ZAP target URL (default test env).</li>
<li><code>TRIVY_TARGET</code>/<code>TRIVY_MODE</code>: adjust Trivy scope (fs/image).</li> <li>
<li><code>ZAP_IMAGE</code>: override container image if needed.</li> <code>TRIVY_TARGET</code>/<code>TRIVY_MODE</code>: adjust Trivy
scope (fs/image).
</li>
<li>
<code>ZAP_IMAGE</code>: override container image if needed.
</li>
</ul> </ul>
</li> </li>
<li>Example: <code>TARGET=https://staging.lomavuokraus.fi TRIVY_MODE=fs ./scripts/run-test-suite.sh</code></li> <li>
Example:
<code
>TARGET=https://staging.lomavuokraus.fi TRIVY_MODE=fs
./scripts/run-test-suite.sh</code
>
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Auth considerations</h2> <h2>Auth considerations</h2>
<ul> <ul>
<li>The baseline scan is unauthenticated; it covers public pages and APIs.</li> <li>
<li>For authenticated testing, generate a session cookie manually and pass via <code>-z</code> extras in the script or run an active scan with a ZAP context file.</li> The baseline scan is unauthenticated; it covers public pages and
<li>Keep admin creds out of the script; prefer test accounts and the testing environment.</li> APIs.
</li>
<li>
For authenticated testing, generate a session cookie manually and
pass via <code>-z</code> extras in the script or run an active scan
with a ZAP context file.
</li>
<li>
Keep admin creds out of the script; prefer test accounts and the
testing environment.
</li>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<h2>Next steps</h2> <h2>Next steps</h2>
<ul> <ul>
<li>Add ZAP active scans with context + logged-in session for deeper coverage.</li> <li>
Add ZAP active scans with context + logged-in session for deeper
coverage.
</li>
<li>Consider scheduling scans against test env before releases.</li> <li>Consider scheduling scans against test env before releases.</li>
<li>Track findings in issues; rerun after auth/role changes.</li> <li>Track findings in issues; rerun after auth/role changes.</li>
</ul> </ul>

View file

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -8,7 +8,9 @@
<body> <body>
<header> <header>
<h1>Sequence Diagrams</h1> <h1>Sequence Diagrams</h1>
<div class="meta">Mermaid-rendered flows for the most important user journeys.</div> <div class="meta">
Mermaid-rendered flows for the most important user journeys.
</div>
</header> </header>
<main class="grid"> <main class="grid">
<section class="card"> <section class="card">
@ -99,14 +101,17 @@ sequenceDiagram
<section class="card"> <section class="card">
<h2>Rendering instructions</h2> <h2>Rendering instructions</h2>
<ul> <ul>
<li>Mermaid renders automatically in-browser via CDN; no local tooling required.</li> <li>
Mermaid renders automatically in-browser via CDN; no local tooling
required.
</li>
<li>Edit the Mermaid blocks inline in these HTML files.</li> <li>Edit the Mermaid blocks inline in these HTML files.</li>
</ul> </ul>
</section> </section>
</main> </main>
<script type="module"> <script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs'; import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({ startOnLoad: true, theme: 'dark' }); mermaid.initialize({ startOnLoad: true, theme: "dark" });
</script> </script>
</body> </body>
</html> </html>

View file

@ -1,5 +1,11 @@
body { body {
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family:
"Inter",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
background: #0f172a; background: #0f172a;
@ -50,7 +56,9 @@ pre {
border: 1px solid #1f2937; border: 1px solid #1f2937;
} }
code { code {
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family:
"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
color: #cbd5e1; color: #cbd5e1;
} }
.diagram { .diagram {

View file

@ -1,41 +1,47 @@
Forgejo on halla-aho.net # Forgejo on halla-aho.net
========================
Lightweight Git hosting + CI with Forgejo (Gitea fork) behind Apache on halla-aho.net. Lightweight Git hosting + CI with Forgejo (Gitea fork) behind Apache on halla-aho.net.
Whats included Whats included
- Docker Compose for Forgejo + SSH and an Actions runner (`forgejo/docker-compose.yml`). - Docker Compose for Forgejo + SSH and an Actions runner (`forgejo/docker-compose.yml`).
- Apache vhost snippet (added to `default-ssl.conf`) to reverse-proxy `git.halla-aho.net` to the Forgejo container on port 3200. - Apache vhost snippet (added to `default-ssl.conf`) to reverse-proxy `git.halla-aho.net` to the Forgejo container on port 3200.
- SSH for git is exposed on host port 2223 (mapped to container 22); change in compose if that port is taken. - SSH for git is exposed on host port 2223 (mapped to container 22); change in compose if that port is taken.
Prereqs Prereqs
- Docker installed on halla-aho.net. - Docker installed on halla-aho.net.
- SSLMate certs for `git.halla-aho.net` placed on the host (paths referenced in `default-ssl.conf`). - SSLMate certs for `git.halla-aho.net` placed on the host (paths referenced in `default-ssl.conf`).
- A DNS record for `git.halla-aho.net` pointing to the server. - A DNS record for `git.halla-aho.net` pointing to the server.
Deploy Forgejo Deploy Forgejo
1) Create host dirs for data:
1. Create host dirs for data:
``` ```
sudo mkdir -p /srv/forgejo/data /srv/forgejo/runner sudo mkdir -p /srv/forgejo/data /srv/forgejo/runner
sudo chown -R $USER:$USER /srv/forgejo sudo chown -R $USER:$USER /srv/forgejo
``` ```
2) Start the Forgejo service: 2. Start the Forgejo service:
``` ```
docker compose -f forgejo/docker-compose.yml up -d forgejo docker compose -f forgejo/docker-compose.yml up -d forgejo
``` ```
- If port 2223 is already in use, edit `forgejo/docker-compose.yml` (`ports:` and `FORGEJO__SERVER__SSH_PORT`) to another free port, then rerun the command. - If port 2223 is already in use, edit `forgejo/docker-compose.yml` (`ports:` and `FORGEJO__SERVER__SSH_PORT`) to another free port, then rerun the command.
3) Configure Apache (already added to `default-ssl.conf`):
3. Configure Apache (already added to `default-ssl.conf`):
- VirtualHost `git.halla-aho.net:9443` proxies to `http://127.0.0.1:3200/`. - VirtualHost `git.halla-aho.net:9443` proxies to `http://127.0.0.1:3200/`.
- TLS files: `/etc/apache2/ssl/git.halla-aho.net.{crt,key,chain.crt}` (update if different). - TLS files: `/etc/apache2/ssl/git.halla-aho.net.{crt,key,chain.crt}` (update if different).
- Enable the site and reload Apache. - Enable the site and reload Apache.
4) Finish setup in the UI at `https://git.halla-aho.net/`: 4. Finish setup in the UI at `https://git.halla-aho.net/`:
- Create the admin user. - Create the admin user.
- Configure SMTP in the admin UI (Mail settings). - Configure SMTP in the admin UI (Mail settings).
- Set `ROOT_URL`/`SSH_DOMAIN` if you change ports/domains. - Set `ROOT_URL`/`SSH_DOMAIN` if you change ports/domains.
Register the Actions runner Register the Actions runner
1) In Forgejo, create a runner registration token (Site Admin → Runners).
2) Register the runner (writes `/srv/forgejo/runner/config.yaml`): 1. In Forgejo, create a runner registration token (Site Admin → Runners).
2. Register the runner (writes `/srv/forgejo/runner/config.yaml`):
``` ```
docker compose -f forgejo/docker-compose.yml run --rm runner \ docker compose -f forgejo/docker-compose.yml run --rm runner \
forgejo-runner register \ forgejo-runner register \
@ -45,11 +51,12 @@ Register the Actions runner
--labels docker \ --labels docker \
--config /data/config.yaml --config /data/config.yaml
``` ```
3) Start the runner: 3. Start the runner:
``` ```
docker compose -f forgejo/docker-compose.yml up -d runner docker compose -f forgejo/docker-compose.yml up -d runner
``` ```
CI workflow for this repo CI workflow for this repo
- Add workflows under `.forgejo/workflows/`. - Add workflows under `.forgejo/workflows/`.
- Example included: `ci.yml` runs npm install + lint + type-check + format check on push/PR using the `docker` runner label. - Example included: `ci.yml` runs npm install + lint + type-check + format check on push/PR using the `docker` runner label.

View file

@ -125,7 +125,15 @@ spec:
ports: ports:
- containerPort: 8080 - containerPort: 8080
name: http name: http
args: ["-a", ":8080", "-f", "/etc/varnish/default.vcl", "-s", "malloc,256m"] args:
[
"-a",
":8080",
"-f",
"/etc/varnish/default.vcl",
"-s",
"malloc,256m",
]
volumeMounts: volumeMounts:
- name: varnish-vcl - name: varnish-vcl
mountPath: /etc/varnish/default.vcl mountPath: /etc/varnish/default.vcl

View file

@ -44,4 +44,3 @@ spec:
port: 80 port: 80
targetPort: 3000 targetPort: 3000
type: ClusterIP type: ClusterIP

View file

@ -1,11 +1,11 @@
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
export function loadN8nBillingApiKey() { export function loadN8nBillingApiKey() {
if (process.env.N8N_BILLING_API_KEY) return process.env.N8N_BILLING_API_KEY; if (process.env.N8N_BILLING_API_KEY) return process.env.N8N_BILLING_API_KEY;
const keyPath = path.join(process.cwd(), 'creds', 'n8n-billing.key'); const keyPath = path.join(process.cwd(), "creds", "n8n-billing.key");
try { try {
return fs.readFileSync(keyPath, 'utf8').trim(); return fs.readFileSync(keyPath, "utf8").trim();
} catch { } catch {
return null; return null;
} }

View file

@ -1,4 +1,4 @@
import bcrypt from 'bcryptjs'; import bcrypt from "bcryptjs";
const DEFAULT_ROUNDS = 12; const DEFAULT_ROUNDS = 12;
@ -11,7 +11,10 @@ export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, getSaltRounds()); return bcrypt.hash(password, getSaltRounds());
} }
export async function verifyPassword(password: string, hash: string): Promise<boolean> { export async function verifyPassword(
password: string,
hash: string,
): Promise<boolean> {
if (!hash) return false; if (!hash) return false;
return bcrypt.compare(password, hash); return bcrypt.compare(password, hash);
} }

View file

@ -1,4 +1,4 @@
import type { Listing, User } from '@prisma/client'; import type { Listing, User } from "@prisma/client";
export type BillingConfig = { export type BillingConfig = {
accountName: string | null; accountName: string | null;
@ -6,10 +6,18 @@ export type BillingConfig = {
includeVatLine: boolean; includeVatLine: boolean;
}; };
type BillingUser = Pick<User, 'billingAccountName' | 'billingIban' | 'billingIncludeVatLine'>; type BillingUser = Pick<
type BillingListing = Pick<Listing, 'billingAccountName' | 'billingIban' | 'billingIncludeVatLine'>; User,
"billingAccountName" | "billingIban" | "billingIncludeVatLine"
>;
type BillingListing = Pick<
Listing,
"billingAccountName" | "billingIban" | "billingIncludeVatLine"
>;
export function normalizeOptionalString(input: unknown): string | null | undefined { export function normalizeOptionalString(
input: unknown,
): string | null | undefined {
if (input === undefined) return undefined; if (input === undefined) return undefined;
if (input === null) return null; if (input === null) return null;
const value = String(input).trim(); const value = String(input).trim();
@ -19,21 +27,28 @@ export function normalizeOptionalString(input: unknown): string | null | undefin
export function normalizeIban(input: unknown): string | null | undefined { export function normalizeIban(input: unknown): string | null | undefined {
if (input === undefined) return undefined; if (input === undefined) return undefined;
if (input === null) return null; if (input === null) return null;
const value = String(input).replace(/\s+/g, '').toUpperCase(); const value = String(input).replace(/\s+/g, "").toUpperCase();
return value ? value : null; return value ? value : null;
} }
export function normalizeNullableBoolean(input: unknown): boolean | null | undefined { export function normalizeNullableBoolean(
input: unknown,
): boolean | null | undefined {
if (input === undefined) return undefined; if (input === undefined) return undefined;
if (input === null) return null; if (input === null) return null;
return Boolean(input); return Boolean(input);
} }
export function resolveBillingDetails(user: BillingUser, listing?: BillingListing | null): BillingConfig { export function resolveBillingDetails(
const accountName = listing?.billingAccountName ?? user.billingAccountName ?? null; user: BillingUser,
listing?: BillingListing | null,
): BillingConfig {
const accountName =
listing?.billingAccountName ?? user.billingAccountName ?? null;
const iban = listing?.billingIban ?? user.billingIban ?? null; const iban = listing?.billingIban ?? user.billingIban ?? null;
const includeVatLine = const includeVatLine =
listing?.billingIncludeVatLine !== null && listing?.billingIncludeVatLine !== undefined listing?.billingIncludeVatLine !== null &&
listing?.billingIncludeVatLine !== undefined
? Boolean(listing.billingIncludeVatLine) ? Boolean(listing.billingIncludeVatLine)
: Boolean(user.billingIncludeVatLine); : Boolean(user.billingIncludeVatLine);

View file

@ -5,7 +5,8 @@ type CacheEntry = { expiresAt: number; ranges: TimeRange[] };
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const MAX_RANGE_DAYS = 365; // guard against unbounded events const MAX_RANGE_DAYS = 365; // guard against unbounded events
const globalCache = (globalThis as any).__icalCache || new Map<string, CacheEntry>(); const globalCache =
(globalThis as any).__icalCache || new Map<string, CacheEntry>();
(globalThis as any).__icalCache = globalCache; (globalThis as any).__icalCache = globalCache;
function parseDateValue(raw: string): Date | null { function parseDateValue(raw: string): Date | null {
@ -21,7 +22,10 @@ function parseDateValue(raw: string): Date | null {
return Number.isNaN(parsed.getTime()) ? null : parsed; return Number.isNaN(parsed.getTime()) ? null : parsed;
} }
function normalizeRange(startRaw: string | null, endRaw: string | null): TimeRange | null { function normalizeRange(
startRaw: string | null,
endRaw: string | null,
): TimeRange | null {
const start = startRaw ? parseDateValue(startRaw) : null; const start = startRaw ? parseDateValue(startRaw) : null;
let end = endRaw ? parseDateValue(endRaw) : null; let end = endRaw ? parseDateValue(endRaw) : null;
@ -43,17 +47,21 @@ function normalizeRange(startRaw: string | null, endRaw: string | null): TimeRan
function parseIcs(text: string): TimeRange[] { function parseIcs(text: string): TimeRange[] {
const lines = text.split(/\r?\n/); const lines = text.split(/\r?\n/);
const ranges: TimeRange[] = []; const ranges: TimeRange[] = [];
let current: { dtstart?: string; dtend?: string; status?: string } | null = null; let current: { dtstart?: string; dtend?: string; status?: string } | null =
null;
for (const rawLine of lines) { for (const rawLine of lines) {
const line = rawLine.trim(); const line = rawLine.trim();
if (line === 'BEGIN:VEVENT') { if (line === "BEGIN:VEVENT") {
current = {}; current = {};
continue; continue;
} }
if (line === 'END:VEVENT') { if (line === "END:VEVENT") {
if (current && current.status !== 'CANCELLED') { if (current && current.status !== "CANCELLED") {
const range = normalizeRange(current.dtstart || null, current.dtend || null); const range = normalizeRange(
current.dtstart || null,
current.dtend || null,
);
if (range) ranges.push(range); if (range) ranges.push(range);
} }
current = null; current = null;
@ -61,14 +69,14 @@ function parseIcs(text: string): TimeRange[] {
} }
if (!current) continue; if (!current) continue;
if (line.startsWith('DTSTART')) { if (line.startsWith("DTSTART")) {
const [, value] = line.split(':'); const [, value] = line.split(":");
current.dtstart = value; current.dtstart = value;
} else if (line.startsWith('DTEND')) { } else if (line.startsWith("DTEND")) {
const [, value] = line.split(':'); const [, value] = line.split(":");
current.dtend = value; current.dtend = value;
} else if (line.startsWith('STATUS')) { } else if (line.startsWith("STATUS")) {
const [, value] = line.split(':'); const [, value] = line.split(":");
current.status = value; current.status = value;
} }
} }
@ -76,7 +84,10 @@ function parseIcs(text: string): TimeRange[] {
return ranges; return ranges;
} }
async function fetchCalendarUrl(url: string, forceRefresh = false): Promise<TimeRange[]> { async function fetchCalendarUrl(
url: string,
forceRefresh = false,
): Promise<TimeRange[]> {
const cached = globalCache.get(url) as CacheEntry | undefined; const cached = globalCache.get(url) as CacheEntry | undefined;
const now = Date.now(); const now = Date.now();
if (!forceRefresh && cached && cached.expiresAt > now) { if (!forceRefresh && cached && cached.expiresAt > now) {
@ -84,28 +95,40 @@ async function fetchCalendarUrl(url: string, forceRefresh = false): Promise<Time
} }
try { try {
const res = await fetch(url, { headers: { 'User-Agent': 'lomavuokraus-ical/1.0' }, cache: 'no-store' }); const res = await fetch(url, {
headers: { "User-Agent": "lomavuokraus-ical/1.0" },
cache: "no-store",
});
if (!res.ok) throw new Error(`Fetch failed (${res.status})`); if (!res.ok) throw new Error(`Fetch failed (${res.status})`);
const text = await res.text(); const text = await res.text();
const ranges = parseIcs(text); const ranges = parseIcs(text);
globalCache.set(url, { expiresAt: now + CACHE_TTL_MS, ranges }); globalCache.set(url, { expiresAt: now + CACHE_TTL_MS, ranges });
return ranges; return ranges;
} catch (err) { } catch (err) {
console.error('Failed to fetch calendar', url, err); console.error("Failed to fetch calendar", url, err);
globalCache.set(url, { expiresAt: now + CACHE_TTL_MS, ranges: [] }); globalCache.set(url, { expiresAt: now + CACHE_TTL_MS, ranges: [] });
return []; return [];
} }
} }
export async function getCalendarRanges(urls: string[], opts?: { forceRefresh?: boolean }): Promise<TimeRange[]> { export async function getCalendarRanges(
urls: string[],
opts?: { forceRefresh?: boolean },
): Promise<TimeRange[]> {
const forceRefresh = Boolean(opts?.forceRefresh); const forceRefresh = Boolean(opts?.forceRefresh);
const unique = Array.from(new Set(urls.filter(Boolean))); const unique = Array.from(new Set(urls.filter(Boolean)));
if (!unique.length) return []; if (!unique.length) return [];
const results = await Promise.all(unique.map((u) => fetchCalendarUrl(u, forceRefresh))); const results = await Promise.all(
unique.map((u) => fetchCalendarUrl(u, forceRefresh)),
);
return results.flat(); return results.flat();
} }
export function isRangeAvailable(ranges: TimeRange[], start: Date, end: Date): boolean { export function isRangeAvailable(
ranges: TimeRange[],
start: Date,
end: Date,
): boolean {
for (const r of ranges) { for (const r of ranges) {
if (r.start < end && r.end > start) { if (r.start < end && r.end > start) {
return false; return false;
@ -114,7 +137,11 @@ export function isRangeAvailable(ranges: TimeRange[], start: Date, end: Date): b
return true; return true;
} }
export function expandBlockedDates(ranges: TimeRange[], from: Date, to: Date): string[] { export function expandBlockedDates(
ranges: TimeRange[],
from: Date,
to: Date,
): string[] {
const days: string[] = []; const days: string[] = [];
const cursor = new Date(from); const cursor = new Date(from);
while (cursor <= to) { while (cursor <= to) {

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,12 +1,22 @@
import { Prisma, ListingStatus } from '@prisma/client'; import { Prisma, ListingStatus } from "@prisma/client";
import { prisma } from './prisma'; import { prisma } from "./prisma";
import { DEFAULT_LOCALE, SAMPLE_LISTING_SLUG } from './sampleListing'; import { DEFAULT_LOCALE, SAMPLE_LISTING_SLUG } from "./sampleListing";
export type ListingWithTranslations = Prisma.ListingTranslationGetPayload<{ export type ListingWithTranslations = Prisma.ListingTranslationGetPayload<{
include: { include: {
listing: { listing: {
include: { include: {
images: { select: { id: true; url: true; altText: true; order: true; isCover: true; size: true; mimeType: true } }; images: {
select: {
id: true;
url: true;
altText: true;
order: true;
isCover: true;
size: true;
mimeType: true;
};
};
owner: true; owner: true;
}; };
}; };
@ -19,7 +29,11 @@ type FetchOptions = {
includeOwnerDraftsForUserId?: string; includeOwnerDraftsForUserId?: string;
}; };
function resolveImageUrl(img: { id: string; url: string | null; size: number | null }) { function resolveImageUrl(img: {
id: string;
url: string | null;
size: number | null;
}) {
if (img.size && img.size > 0) { if (img.size && img.size > 0) {
return `/api/images/${img.id}`; return `/api/images/${img.id}`;
} }
@ -30,16 +44,22 @@ function resolveImageUrl(img: { id: string; url: string | null; size: number | n
* Fetch a listing translation by slug and locale. * Fetch a listing translation by slug and locale.
* Falls back to any locale if the requested locale is missing. * Falls back to any locale if the requested locale is missing.
*/ */
export async function getListingBySlug({ slug, locale, includeOwnerDraftsForUserId }: FetchOptions): Promise<ListingWithTranslations | null> { export async function getListingBySlug({
slug,
locale,
includeOwnerDraftsForUserId,
}: FetchOptions): Promise<ListingWithTranslations | null> {
const targetLocale = locale ?? DEFAULT_LOCALE; const targetLocale = locale ?? DEFAULT_LOCALE;
const listingWhere: Prisma.ListingWhereInput = const listingWhere: Prisma.ListingWhereInput = includeOwnerDraftsForUserId
includeOwnerDraftsForUserId
? { ? {
removedAt: null, removedAt: null,
OR: [ OR: [
{ status: ListingStatus.PUBLISHED }, { status: ListingStatus.PUBLISHED },
{ ownerId: includeOwnerDraftsForUserId, status: { in: [ListingStatus.DRAFT, ListingStatus.PENDING] } }, {
ownerId: includeOwnerDraftsForUserId,
status: { in: [ListingStatus.DRAFT, ListingStatus.PENDING] },
},
], ],
} }
: { status: ListingStatus.PUBLISHED, removedAt: null }; : { status: ListingStatus.PUBLISHED, removedAt: null };
@ -49,7 +69,18 @@ export async function getListingBySlug({ slug, locale, includeOwnerDraftsForUser
include: { include: {
listing: { listing: {
include: { include: {
images: { orderBy: { order: 'asc' }, select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, mimeType: true } }, images: {
orderBy: { order: "asc" },
select: {
id: true,
url: true,
altText: true,
order: true,
isCover: true,
size: true,
mimeType: true,
},
},
owner: true, owner: true,
}, },
}, },
@ -66,23 +97,36 @@ export async function getListingBySlug({ slug, locale, includeOwnerDraftsForUser
include: { include: {
listing: { listing: {
include: { include: {
images: { orderBy: { order: 'asc' }, select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, mimeType: true } }, images: {
orderBy: { order: "asc" },
select: {
id: true,
url: true,
altText: true,
order: true,
isCover: true,
size: true,
mimeType: true,
},
},
owner: true, owner: true,
}, },
}, },
}, },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: "asc" },
}); });
} }
export function withResolvedListingImages(translation: ListingWithTranslations): ListingWithTranslations { export function withResolvedListingImages(
translation: ListingWithTranslations,
): ListingWithTranslations {
const images = translation.listing.images const images = translation.listing.images
.map((img) => { .map((img) => {
const url = resolveImageUrl(img); const url = resolveImageUrl(img);
if (!url) return null; if (!url) return null;
return { ...img, url }; return { ...img, url };
}) })
.filter(Boolean) as ListingWithTranslations['listing']['images']; .filter(Boolean) as ListingWithTranslations["listing"]["images"];
return { return {
...translation, ...translation,

View file

@ -1,19 +1,22 @@
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
import { execFileSync } from 'child_process'; import { execFileSync } from "child_process";
function parseDotenv(contents: string) { function parseDotenv(contents: string) {
contents contents
.split('\n') .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
.filter((line) => line && !line.startsWith('#')) .filter((line) => line && !line.startsWith("#"))
.forEach((line) => { .forEach((line) => {
const idx = line.indexOf('='); const idx = line.indexOf("=");
if (idx === -1) return; if (idx === -1) return;
const key = line.slice(0, idx).trim(); const key = line.slice(0, idx).trim();
let value = line.slice(idx + 1).trim(); let value = line.slice(idx + 1).trim();
if (!key || key in process.env) return; if (!key || key in process.env) return;
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1); value = value.slice(1, -1);
} }
process.env[key] = value; process.env[key] = value;
@ -22,12 +25,12 @@ function parseDotenv(contents: string) {
export function loadLocalSecrets() { export function loadLocalSecrets() {
const root = process.cwd(); const root = process.cwd();
const plainPath = path.join(root, 'creds', 'secrets.env'); const plainPath = path.join(root, "creds", "secrets.env");
const encPath = path.join(root, 'creds', 'secrets.enc.env'); const encPath = path.join(root, "creds", "secrets.enc.env");
if (fs.existsSync(plainPath)) { if (fs.existsSync(plainPath)) {
try { try {
parseDotenv(fs.readFileSync(plainPath, 'utf8')); parseDotenv(fs.readFileSync(plainPath, "utf8"));
return; return;
} catch { } catch {
// ignore and try encrypted // ignore and try encrypted
@ -36,7 +39,9 @@ export function loadLocalSecrets() {
if (fs.existsSync(encPath) && !process.env.SKIP_SOPS_AUTOLOAD) { if (fs.existsSync(encPath) && !process.env.SKIP_SOPS_AUTOLOAD) {
try { try {
const output = execFileSync('sops', ['-d', encPath], { encoding: 'utf8' }); const output = execFileSync("sops", ["-d", encPath], {
encoding: "utf8",
});
parseDotenv(output); parseDotenv(output);
} catch { } catch {
// silent fail if sops/key not available // silent fail if sops/key not available

View file

@ -1,7 +1,6 @@
import fs from 'fs'; import fs from "fs";
import nodemailer from 'nodemailer'; import nodemailer from "nodemailer";
import type SMTPTransport from 'nodemailer/lib/smtp-transport'; import path from "path";
import path from 'path';
type MailOptions = { type MailOptions = {
to: string; to: string;
@ -26,13 +25,15 @@ async function createTransport() {
} = process.env; } = process.env;
if (!SMTP_HOST || !SMTP_PORT || !SMTP_USER || !SMTP_PASS || !SMTP_FROM) { if (!SMTP_HOST || !SMTP_PORT || !SMTP_USER || !SMTP_PASS || !SMTP_FROM) {
throw new Error('SMTP configuration is missing required environment variables'); throw new Error(
"SMTP configuration is missing required environment variables",
);
} }
const secure = SMTP_SSL === 'true'; const secure = SMTP_SSL === "true";
const requireTLS = SMTP_TLS === 'true'; const requireTLS = SMTP_TLS === "true";
const transporterOptions: SMTPTransport.Options = { const transporterOptions: Record<string, any> = {
host: SMTP_HOST, host: SMTP_HOST,
port: Number(SMTP_PORT), port: Number(SMTP_PORT),
secure, secure,
@ -40,7 +41,7 @@ async function createTransport() {
auth: { user: SMTP_USER, pass: SMTP_PASS }, auth: { user: SMTP_USER, pass: SMTP_PASS },
}; };
if (SMTP_REJECT_UNAUTHORIZED === 'false') { if (SMTP_REJECT_UNAUTHORIZED === "false") {
transporterOptions.tls = { rejectUnauthorized: false }; transporterOptions.tls = { rejectUnauthorized: false };
} }
@ -50,7 +51,7 @@ async function createTransport() {
transporterOptions.dkim = { transporterOptions.dkim = {
domainName: DKIM_DOMAIN, domainName: DKIM_DOMAIN,
keySelector: DKIM_SELECTOR, keySelector: DKIM_SELECTOR,
privateKey: fs.readFileSync(keyPath, 'utf8'), privateKey: fs.readFileSync(keyPath, "utf8"),
}; };
} }
} }
@ -58,7 +59,7 @@ async function createTransport() {
return nodemailer.createTransport(transporterOptions); return nodemailer.createTransport(transporterOptions);
} }
let cachedTransport: nodemailer.Transporter | null = null; let cachedTransport: any = null;
async function getTransport() { async function getTransport() {
if (cachedTransport) return cachedTransport; if (cachedTransport) return cachedTransport;
@ -73,14 +74,14 @@ export async function sendMail({ to, subject, text, html }: MailOptions) {
} }
export async function sendVerificationEmail(to: string, link: string) { export async function sendVerificationEmail(to: string, link: string) {
const subject = 'Verify your email for lomavuokraus.fi'; 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 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>`; 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 }); return sendMail({ to, subject, text, html });
} }
export async function sendPasswordResetEmail(to: string, link: string) { export async function sendPasswordResetEmail(to: string, link: string) {
const subject = 'Reset your password for lomavuokraus.fi'; const subject = "Reset your password for lomavuokraus.fi";
const text = `We received a request to reset your password.\n\nReset here: ${link}\n\nIf you did not request this, you can ignore this email.`; const text = `We received a request to reset your password.\n\nReset here: ${link}\n\nIf you did not request this, you can ignore this email.`;
const html = `<p>We received a request to reset your password.</p><p><a href="${link}">Reset your password</a></p><p>If you did not request this, you can ignore this email.</p>`; const html = `<p>We received a request to reset your password.</p><p><a href="${link}">Reset your password</a></p><p>If you did not request this, you can ignore this email.</p>`;
return sendMail({ to, subject, text, html }); return sendMail({ to, subject, text, html });

View file

@ -1,6 +1,6 @@
import fs from 'fs'; import fs from "fs";
import https from 'https'; import https from "https";
import { prisma } from './prisma'; import { prisma } from "./prisma";
type HetznerServerSummary = { type HetznerServerSummary = {
id: number; id: number;
@ -80,7 +80,7 @@ export type DbStatus = {
function readFileSafe(path: string): string | null { function readFileSafe(path: string): string | null {
try { try {
return fs.readFileSync(path, 'utf8'); return fs.readFileSync(path, "utf8");
} catch { } catch {
return null; return null;
} }
@ -89,17 +89,24 @@ function readFileSafe(path: string): string | null {
export async function fetchHetznerServers(): Promise<HetznerStatus> { export async function fetchHetznerServers(): Promise<HetznerStatus> {
const token = process.env.HCLOUD_TOKEN ?? process.env.HETZNER_TOKEN; const token = process.env.HCLOUD_TOKEN ?? process.env.HETZNER_TOKEN;
if (!token) { if (!token) {
return { ok: false, missingToken: true, error: 'HCLOUD_TOKEN not configured' }; return {
ok: false,
missingToken: true,
error: "HCLOUD_TOKEN not configured",
};
} }
try { try {
const res = await fetch('https://api.hetzner.cloud/v1/servers', { const res = await fetch("https://api.hetzner.cloud/v1/servers", {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(8000), signal: AbortSignal.timeout(8000),
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.text(); const body = await res.text();
return { ok: false, error: `Hetzner API ${res.status}: ${body.slice(0, 200)}` }; return {
ok: false,
error: `Hetzner API ${res.status}: ${body.slice(0, 200)}`,
};
} }
const json = (await res.json()) as { servers?: any[] }; const json = (await res.json()) as { servers?: any[] };
const servers = const servers =
@ -108,22 +115,30 @@ export async function fetchHetznerServers(): Promise<HetznerStatus> {
name: s.name, name: s.name,
status: s.status, status: s.status,
type: s.server_type?.name, type: s.server_type?.name,
datacenter: s.datacenter?.name ?? s.datacenter?.description ?? s.datacenter?.location?.name, datacenter:
s.datacenter?.name ??
s.datacenter?.description ??
s.datacenter?.location?.name,
publicIp: s.public_net?.ipv4?.ip, publicIp: s.public_net?.ipv4?.ip,
privateIp: s.private_net?.[0]?.ip, privateIp: s.private_net?.[0]?.ip,
created: s.created, created: s.created,
})) ?? []; })) ?? [];
return { ok: true, servers }; return { ok: true, servers };
} catch (error: any) { } catch (error: any) {
return { ok: false, error: error?.message ?? 'Hetzner API request failed' }; return { ok: false, error: error?.message ?? "Hetzner API request failed" };
} }
} }
function loadKubernetesConfig(): KubernetesClientConfig | null { function loadKubernetesConfig(): KubernetesClientConfig | null {
const token = readFileSafe('/var/run/secrets/kubernetes.io/serviceaccount/token'); const token = readFileSafe(
const ca = readFileSafe('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'); "/var/run/secrets/kubernetes.io/serviceaccount/token",
const serviceHost = process.env.KUBERNETES_SERVICE_HOST ?? 'kubernetes.default.svc'; );
const servicePort = process.env.KUBERNETES_SERVICE_PORT ?? '443'; const ca = readFileSafe(
"/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
);
const serviceHost =
process.env.KUBERNETES_SERVICE_HOST ?? "kubernetes.default.svc";
const servicePort = process.env.KUBERNETES_SERVICE_PORT ?? "443";
if (token) { if (token) {
return { return {
@ -136,10 +151,10 @@ function loadKubernetesConfig(): KubernetesClientConfig | null {
if (process.env.K8S_API_SERVER && process.env.K8S_BEARER_TOKEN) { if (process.env.K8S_API_SERVER && process.env.K8S_BEARER_TOKEN) {
const caCert = process.env.K8S_CA_CERT; const caCert = process.env.K8S_CA_CERT;
return { return {
server: process.env.K8S_API_SERVER.replace(/\/$/, ''), server: process.env.K8S_API_SERVER.replace(/\/$/, ""),
token: process.env.K8S_BEARER_TOKEN, token: process.env.K8S_BEARER_TOKEN,
ca: caCert, ca: caCert,
insecureSkipTlsVerify: process.env.K8S_INSECURE_SKIP_TLS === 'true', insecureSkipTlsVerify: process.env.K8S_INSECURE_SKIP_TLS === "true",
}; };
} }
@ -148,14 +163,14 @@ function loadKubernetesConfig(): KubernetesClientConfig | null {
function k8sRequest(path: string, cfg: KubernetesClientConfig): Promise<any> { function k8sRequest(path: string, cfg: KubernetesClientConfig): Promise<any> {
const url = `${cfg.server}${path}`; const url = `${cfg.server}${path}`;
const headers: Record<string, string> = { Accept: 'application/json' }; const headers: Record<string, string> = { Accept: "application/json" };
if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`; if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = https.request( const req = https.request(
url, url,
{ {
method: 'GET', method: "GET",
headers, headers,
ca: cfg.ca, ca: cfg.ca,
rejectUnauthorized: cfg.insecureSkipTlsVerify ? false : true, rejectUnauthorized: cfg.insecureSkipTlsVerify ? false : true,
@ -163,11 +178,15 @@ function k8sRequest(path: string, cfg: KubernetesClientConfig): Promise<any> {
}, },
(res) => { (res) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
res.on('data', (d) => chunks.push(typeof d === 'string' ? Buffer.from(d) : d)); res.on("data", (d) =>
res.on('end', () => { chunks.push(typeof d === "string" ? Buffer.from(d) : d),
const body = Buffer.concat(chunks).toString('utf8'); );
res.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
if (res.statusCode && res.statusCode >= 400) { if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`Kubernetes ${res.statusCode}: ${body.slice(0, 200)}`)); reject(
new Error(`Kubernetes ${res.statusCode}: ${body.slice(0, 200)}`),
);
return; return;
} }
try { try {
@ -178,8 +197,10 @@ function k8sRequest(path: string, cfg: KubernetesClientConfig): Promise<any> {
}); });
}, },
); );
req.on('error', reject); req.on("error", reject);
req.on('timeout', () => req.destroy(new Error('Kubernetes request timed out'))); req.on("timeout", () =>
req.destroy(new Error("Kubernetes request timed out")),
);
req.end(); req.end();
}); });
} }
@ -189,15 +210,24 @@ function parseK8sNodes(data: any): KubernetesNodeSummary[] {
return items.map((node) => { return items.map((node) => {
const labels = node?.metadata?.labels ?? {}; const labels = node?.metadata?.labels ?? {};
const roles = Object.keys(labels) const roles = Object.keys(labels)
.filter((key) => key.startsWith('node-role.kubernetes.io/')) .filter((key) => key.startsWith("node-role.kubernetes.io/"))
.map((key) => key.replace('node-role.kubernetes.io/', '') || 'control-plane'); .map(
const readyCondition = (node?.status?.conditions ?? []).find((c: any) => c.type === 'Ready'); (key) => key.replace("node-role.kubernetes.io/", "") || "control-plane",
);
const readyCondition = (node?.status?.conditions ?? []).find(
(c: any) => c.type === "Ready",
);
const addresses = node?.status?.addresses ?? []; const addresses = node?.status?.addresses ?? [];
const internal = addresses.find((a: any) => a.type === 'InternalIP')?.address; const internal = addresses.find(
(a: any) => a.type === "InternalIP",
)?.address;
return { return {
name: node?.metadata?.name ?? 'unknown', name: node?.metadata?.name ?? "unknown",
ready: readyCondition?.status === 'True', ready: readyCondition?.status === "True",
status: readyCondition?.status === 'True' ? 'Ready' : readyCondition?.reason ?? 'NotReady', status:
readyCondition?.status === "True"
? "Ready"
: (readyCondition?.reason ?? "NotReady"),
roles, roles,
internalIp: internal, internalIp: internal,
kubeletVersion: node?.status?.nodeInfo?.kubeletVersion, kubeletVersion: node?.status?.nodeInfo?.kubeletVersion,
@ -208,33 +238,37 @@ function parseK8sNodes(data: any): KubernetesNodeSummary[] {
} }
function parseContainerState(status: any): string { function parseContainerState(status: any): string {
if (!status) return 'unknown'; if (!status) return "unknown";
if (status.state?.running) return 'running'; if (status.state?.running) return "running";
if (status.state?.waiting?.reason) return status.state.waiting.reason; if (status.state?.waiting?.reason) return status.state.waiting.reason;
if (status.state?.terminated?.reason) return status.state.terminated.reason; if (status.state?.terminated?.reason) return status.state.terminated.reason;
if (status.state?.waiting) return 'waiting'; if (status.state?.waiting) return "waiting";
if (status.state?.terminated) return 'terminated'; if (status.state?.terminated) return "terminated";
return 'unknown'; return "unknown";
} }
function parseLastState(status: any): string | null { function parseLastState(status: any): string | null {
if (!status?.lastState) return null; if (!status?.lastState) return null;
const { lastState } = status; const { lastState } = status;
if (lastState.terminated?.reason) return `terminated: ${lastState.terminated.reason}`; if (lastState.terminated?.reason)
if (lastState.terminated?.exitCode !== undefined) return `terminated: code ${lastState.terminated.exitCode}`; return `terminated: ${lastState.terminated.reason}`;
if (lastState.terminated?.exitCode !== undefined)
return `terminated: code ${lastState.terminated.exitCode}`;
if (lastState.waiting?.reason) return `waiting: ${lastState.waiting.reason}`; if (lastState.waiting?.reason) return `waiting: ${lastState.waiting.reason}`;
return 'previous state recorded'; return "previous state recorded";
} }
function parseK8sPods(data: any): KubernetesPodSummary[] { function parseK8sPods(data: any): KubernetesPodSummary[] {
const items: any[] = data?.items ?? []; const items: any[] = data?.items ?? [];
return items return items
.filter((pod) => { .filter((pod) => {
const ns = pod?.metadata?.namespace ?? ''; const ns = pod?.metadata?.namespace ?? "";
return ns.startsWith('lomavuokraus-') || ns === 'default'; return ns.startsWith("lomavuokraus-") || ns === "default";
}) })
.map((pod) => { .map((pod) => {
const containers: KubernetesPodContainer[] = (pod?.status?.containerStatuses ?? []).map((c: any) => ({ const containers: KubernetesPodContainer[] = (
pod?.status?.containerStatuses ?? []
).map((c: any) => ({
name: c.name, name: c.name,
ready: Boolean(c.ready), ready: Boolean(c.ready),
restartCount: Number(c.restartCount ?? 0), restartCount: Number(c.restartCount ?? 0),
@ -244,9 +278,9 @@ function parseK8sPods(data: any): KubernetesPodSummary[] {
const readyCount = containers.filter((c) => c.ready).length; const readyCount = containers.filter((c) => c.ready).length;
const restarts = containers.reduce((sum, c) => sum + c.restartCount, 0); const restarts = containers.reduce((sum, c) => sum + c.restartCount, 0);
return { return {
name: pod?.metadata?.name ?? 'pod', name: pod?.metadata?.name ?? "pod",
namespace: pod?.metadata?.namespace ?? 'unknown', namespace: pod?.metadata?.namespace ?? "unknown",
phase: pod?.status?.phase ?? 'Unknown', phase: pod?.status?.phase ?? "Unknown",
reason: pod?.status?.reason ?? null, reason: pod?.status?.reason ?? null,
message: pod?.status?.message ?? null, message: pod?.status?.message ?? null,
readyCount, readyCount,
@ -264,21 +298,30 @@ function parseK8sPods(data: any): KubernetesPodSummary[] {
export async function fetchKubernetesStatus(): Promise<KubernetesStatus> { export async function fetchKubernetesStatus(): Promise<KubernetesStatus> {
const config = loadKubernetesConfig(); const config = loadKubernetesConfig();
if (!config) { if (!config) {
return { ok: false, error: 'Kubernetes config not found (service account or env K8S_API_SERVER/K8S_BEARER_TOKEN required)' }; return {
ok: false,
error:
"Kubernetes config not found (service account or env K8S_API_SERVER/K8S_BEARER_TOKEN required)",
};
} }
try { try {
const [nodes, pods] = await Promise.all([k8sRequest('/api/v1/nodes', config), k8sRequest('/api/v1/pods', config)]); const [nodes, pods] = await Promise.all([
k8sRequest("/api/v1/nodes", config),
k8sRequest("/api/v1/pods", config),
]);
return { ok: true, nodes: parseK8sNodes(nodes), pods: parseK8sPods(pods) }; return { ok: true, nodes: parseK8sNodes(nodes), pods: parseK8sPods(pods) };
} catch (error: any) { } catch (error: any) {
return { ok: false, error: error?.message ?? 'Kubernetes query failed' }; return { ok: false, error: error?.message ?? "Kubernetes query failed" };
} }
} }
export async function fetchDbStatus(): Promise<DbStatus> { export async function fetchDbStatus(): Promise<DbStatus> {
try { try {
const [summary, activity] = await Promise.all([ const [summary, activity] = await Promise.all([
prisma.$queryRaw<{ server_time: Date; recovery: boolean; size_bytes: bigint | number }[]>` prisma.$queryRaw<
{ server_time: Date; recovery: boolean; size_bytes: bigint | number }[]
>`
SELECT now() as server_time, pg_is_in_recovery() as recovery, pg_database_size(current_database()) as size_bytes SELECT now() as server_time, pg_is_in_recovery() as recovery, pg_database_size(current_database()) as size_bytes
`, `,
prisma.$queryRaw<{ state: string | null; count: number }[]>` prisma.$queryRaw<{ state: string | null; count: number }[]>`
@ -287,10 +330,20 @@ export async function fetchDbStatus(): Promise<DbStatus> {
]); ]);
const row = summary?.[0]; const row = summary?.[0];
const serverTime = row?.server_time instanceof Date ? row.server_time.toISOString() : row?.server_time ? String(row.server_time) : undefined; const serverTime =
row?.server_time instanceof Date
? row.server_time.toISOString()
: row?.server_time
? String(row.server_time)
: undefined;
const sizeVal = row?.size_bytes ?? 0; const sizeVal = row?.size_bytes ?? 0;
const sizeNumber = typeof sizeVal === 'bigint' ? Number(sizeVal) : Number(sizeVal); const sizeNumber =
const connections = activity?.map((a) => ({ state: a.state ?? 'unknown', count: Number(a.count ?? 0) })) ?? []; typeof sizeVal === "bigint" ? Number(sizeVal) : Number(sizeVal);
const connections =
activity?.map((a) => ({
state: a.state ?? "unknown",
count: Number(a.count ?? 0),
})) ?? [];
return { return {
ok: true, ok: true,
@ -300,6 +353,6 @@ export async function fetchDbStatus(): Promise<DbStatus> {
connections, connections,
}; };
} catch (error: any) { } catch (error: any) {
return { ok: false, error: error?.message ?? 'Database query failed' }; return { ok: false, error: error?.message ?? "Database query failed" };
} }
} }

View file

@ -1,13 +1,15 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from "@prisma/client";
import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from 'pg'; import { Pool } from "pg";
import { loadLocalSecrets } from './loadSecrets'; import { loadLocalSecrets } from "./loadSecrets";
loadLocalSecrets(); loadLocalSecrets();
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
const databaseUrl = process.env.DATABASE_URL || 'postgresql://localhost:5432/lomavuokraus?sslmode=disable'; const databaseUrl =
process.env.DATABASE_URL ||
"postgresql://localhost:5432/lomavuokraus?sslmode=disable";
process.env.DATABASE_URL = databaseUrl; process.env.DATABASE_URL = databaseUrl;
const pool = new Pool({ connectionString: databaseUrl }); const pool = new Pool({ connectionString: databaseUrl });
const adapter = new PrismaPg(pool); const adapter = new PrismaPg(pool);
@ -16,9 +18,12 @@ export const prisma =
globalForPrisma.prisma ?? globalForPrisma.prisma ??
new PrismaClient({ new PrismaClient({
adapter, adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
}); });
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma; globalForPrisma.prisma = prisma;
} }

View file

@ -1,14 +1,14 @@
export const SAMPLE_LISTING_SLUG = 'saimaa-lakeside-cabin'; export const SAMPLE_LISTING_SLUG = "saimaa-lakeside-cabin";
export const SAMPLE_LISTING_SLUGS = [ export const SAMPLE_LISTING_SLUGS = [
'saimaa-lakeside-cabin', "saimaa-lakeside-cabin",
'helsinki-design-loft', "helsinki-design-loft",
'turku-riverside-apartment', "turku-riverside-apartment",
'rovaniemi-aurora-cabin', "rovaniemi-aurora-cabin",
'tampere-sauna-studio', "tampere-sauna-studio",
'vaasa-seaside-villa', "vaasa-seaside-villa",
'kuopio-lakeside-apartment', "kuopio-lakeside-apartment",
'porvoo-river-loft', "porvoo-river-loft",
'oulu-tech-apartment', "oulu-tech-apartment",
'mariehamn-harbor-flat', "mariehamn-harbor-flat",
]; ];
export const DEFAULT_LOCALE = 'en'; export const DEFAULT_LOCALE = "en";

View file

@ -1,36 +1,46 @@
import { prisma } from './prisma'; import { prisma } from "./prisma";
export type SiteSettings = { export type SiteSettings = {
requireLoginForContactDetails: boolean; requireLoginForContactDetails: boolean;
}; };
const SETTINGS_ID = 'default'; const SETTINGS_ID = "default";
const DEFAULT_SETTINGS: SiteSettings = { const DEFAULT_SETTINGS: SiteSettings = {
requireLoginForContactDetails: true, requireLoginForContactDetails: true,
}; };
function mergeSettings(input: Partial<SiteSettings> | null | undefined): SiteSettings { function mergeSettings(
input: Partial<SiteSettings> | null | undefined,
): SiteSettings {
return { return {
requireLoginForContactDetails: requireLoginForContactDetails:
input?.requireLoginForContactDetails ?? DEFAULT_SETTINGS.requireLoginForContactDetails, input?.requireLoginForContactDetails ??
DEFAULT_SETTINGS.requireLoginForContactDetails,
}; };
} }
export async function getSiteSettings(): Promise<SiteSettings> { export async function getSiteSettings(): Promise<SiteSettings> {
try { try {
const existing = await prisma.siteSettings.findUnique({ where: { id: SETTINGS_ID } }); const existing = await prisma.siteSettings.findUnique({
where: { id: SETTINGS_ID },
});
if (!existing) { if (!existing) {
return DEFAULT_SETTINGS; return DEFAULT_SETTINGS;
} }
return mergeSettings(existing); return mergeSettings(existing);
} catch (error) { } catch (error) {
console.error('Failed to load site settings, falling back to defaults', error); console.error(
"Failed to load site settings, falling back to defaults",
error,
);
return DEFAULT_SETTINGS; return DEFAULT_SETTINGS;
} }
} }
export async function updateSiteSettings(input: Partial<SiteSettings>): Promise<SiteSettings> { export async function updateSiteSettings(
input: Partial<SiteSettings>,
): Promise<SiteSettings> {
const current = await getSiteSettings(); const current = await getSiteSettings();
const data = mergeSettings({ ...current, ...input }); const data = mergeSettings({ ...current, ...input });
const saved = await prisma.siteSettings.upsert({ const saved = await prisma.siteSettings.upsert({

View file

@ -1,7 +1,7 @@
import crypto from 'crypto'; import crypto from "crypto";
export function randomToken(bytes = 32): string { export function randomToken(bytes = 32): string {
return crypto.randomBytes(bytes).toString('base64url'); return crypto.randomBytes(bytes).toString("base64url");
} }
export function addHours(hours: number): Date { export function addHours(hours: number): Date {

View file

@ -1,18 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import { getAuthFromRequest } from './lib/jwt'; import { getAuthFromRequest } from "./lib/jwt";
const ADMIN_ONLY_PATHS = ['/admin/users', '/admin/monitor', '/admin/settings']; const ADMIN_ONLY_PATHS = ["/admin/users", "/admin/monitor", "/admin/settings"];
const MODERATOR_PATHS = ['/admin/pending']; const MODERATOR_PATHS = ["/admin/pending"];
function buildLoginRedirect(req: NextRequest) { function buildLoginRedirect(req: NextRequest) {
const url = new URL('/auth/login', req.url); const url = new URL("/auth/login", req.url);
url.searchParams.set('redirect', req.nextUrl.pathname + req.nextUrl.search); url.searchParams.set("redirect", req.nextUrl.pathname + req.nextUrl.search);
return url; return url;
} }
export async function middleware(req: NextRequest) { export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl; const { pathname } = req.nextUrl;
if (!pathname.startsWith('/admin')) { if (!pathname.startsWith("/admin")) {
return NextResponse.next(); return NextResponse.next();
} }
@ -23,18 +23,25 @@ export async function middleware(req: NextRequest) {
const role = session.role; const role = session.role;
const isAdminOnly = ADMIN_ONLY_PATHS.some((p) => pathname.startsWith(p)); const isAdminOnly = ADMIN_ONLY_PATHS.some((p) => pathname.startsWith(p));
if (isAdminOnly && role !== 'ADMIN') { if (isAdminOnly && role !== "ADMIN") {
return NextResponse.redirect(new URL('/', req.url)); return NextResponse.redirect(new URL("/", req.url));
} }
const isModeratorPath = MODERATOR_PATHS.some((p) => pathname.startsWith(p)); const isModeratorPath = MODERATOR_PATHS.some((p) => pathname.startsWith(p));
if (isModeratorPath && !(role === 'ADMIN' || role === 'USER_MODERATOR' || role === 'LISTING_MODERATOR')) { if (
return NextResponse.redirect(new URL('/', req.url)); isModeratorPath &&
!(
role === "ADMIN" ||
role === "USER_MODERATOR" ||
role === "LISTING_MODERATOR"
)
) {
return NextResponse.redirect(new URL("/", req.url));
} }
return NextResponse.next(); return NextResponse.next();
} }
export const config = { export const config = {
matcher: ['/admin/:path*'], matcher: ["/admin/:path*"],
}; };

3
next-env.d.ts vendored
View file

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

View file

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

2475
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,27 +13,26 @@
"test": "echo \"No tests yet\"" "test": "echo \"No tests yet\""
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.0.0", "@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.0.0", "@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jose": "^6.1.2", "jose": "^6.1.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"next": "^14.2.32", "next": "^15.5.11",
"nodemailer": "^7.0.10", "nodemailer": "^7.0.10",
"pg": "^8.16.3", "pg": "^8.16.3",
"prisma": "^7.0.0", "prisma": "^7.3.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.15.6", "@types/pg": "^8.15.6",
"@types/react": "^18.2.67", "@types/react": "^18.2.67",
"@types/react-dom": "^18.2.21", "@types/react-dom": "^18.2.21",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-next": "^14.2.32", "eslint-config-next": "^15.5.11",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"typescript": "^5.4.5" "typescript": "^5.4.5"
}, },

View file

@ -1,17 +1,19 @@
// This file was generated by Prisma and assumes you have installed the following: // This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv // npm install --save-dev prisma dotenv
import 'dotenv/config'; import "dotenv/config";
import { defineConfig } from 'prisma/config'; import { defineConfig } from "prisma/config";
import { loadLocalSecrets } from './lib/loadSecrets'; import { loadLocalSecrets } from "./lib/loadSecrets";
loadLocalSecrets(); loadLocalSecrets();
const databaseUrl = process.env.DATABASE_URL || 'postgresql://localhost:5432/lomavuokraus?sslmode=disable'; const databaseUrl =
process.env.DATABASE_URL ||
"postgresql://localhost:5432/lomavuokraus?sslmode=disable";
export default defineConfig({ export default defineConfig({
schema: 'prisma/schema.prisma', schema: "prisma/schema.prisma",
migrations: { migrations: {
path: 'prisma/migrations', path: "prisma/migrations",
}, },
datasource: { datasource: {
// Fallback to a local dev URL so builds/linting can run without secrets // Fallback to a local dev URL so builds/linting can run without secrets

View file

@ -1,35 +1,44 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
const path = require('path'); const path = require("path");
const fs = require('fs'); const fs = require("fs");
require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); require("dotenv").config({ path: path.join(__dirname, "..", ".env") });
if (fs.existsSync(path.join(__dirname, '..', 'creds', '.env'))) { if (fs.existsSync(path.join(__dirname, "..", "creds", ".env"))) {
require('dotenv').config({ path: path.join(__dirname, '..', 'creds', '.env') }); require("dotenv").config({
path: path.join(__dirname, "..", "creds", ".env"),
});
} }
const bcrypt = require('bcryptjs'); const bcrypt = require("bcryptjs");
const { PrismaClient, Role, UserStatus, ListingStatus } = require('@prisma/client'); const {
const { PrismaPg } = require('@prisma/adapter-pg'); PrismaClient,
const { Pool } = require('pg'); Role,
UserStatus,
ListingStatus,
} = require("@prisma/client");
const { PrismaPg } = require("@prisma/adapter-pg");
const { Pool } = require("pg");
if (!process.env.DATABASE_URL) { if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL is not set; cannot seed.'); console.error("DATABASE_URL is not set; cannot seed.");
process.exit(1); process.exit(1);
} }
const prisma = new PrismaClient({ const prisma = new PrismaClient({
adapter: new PrismaPg(new Pool({ connectionString: process.env.DATABASE_URL })), adapter: new PrismaPg(
new Pool({ connectionString: process.env.DATABASE_URL }),
),
}); });
const SAMPLE_SLUG = 'saimaa-lakeside-cabin'; const SAMPLE_SLUG = "saimaa-lakeside-cabin";
const DEFAULT_LOCALE = 'en'; const DEFAULT_LOCALE = "en";
const SAMPLE_EMAIL = 'host@lomavuokraus.fi'; const SAMPLE_EMAIL = "host@lomavuokraus.fi";
const SAMPLE_IMAGE_DIR = path.join(__dirname, '..', 'sampleimages'); const SAMPLE_IMAGE_DIR = path.join(__dirname, "..", "sampleimages");
function detectMimeType(fileName) { function detectMimeType(fileName) {
const ext = path.extname(fileName).toLowerCase(); const ext = path.extname(fileName).toLowerCase();
if (ext === '.png') return 'image/png'; if (ext === ".png") return "image/png";
if (ext === '.webp') return 'image/webp'; if (ext === ".webp") return "image/webp";
if (ext === '.gif') return 'image/gif'; if (ext === ".gif") return "image/gif";
return 'image/jpeg'; return "image/jpeg";
} }
function loadSampleImage(fileName) { function loadSampleImage(fileName) {
@ -58,7 +67,7 @@ async function main() {
data: coverFile?.data, data: coverFile?.data,
mimeType: coverFile?.mimeType || (item.cover.url ? null : undefined), mimeType: coverFile?.mimeType || (item.cover.url ? null : undefined),
size: coverFile?.size ?? null, size: coverFile?.size ?? null,
url: coverFile ? null : item.cover.url ?? null, url: coverFile ? null : (item.cover.url ?? null),
altText: item.cover.altText ?? null, altText: item.cover.altText ?? null,
order: 1, order: 1,
isCover: true, isCover: true,
@ -73,7 +82,7 @@ async function main() {
data: file?.data, data: file?.data,
mimeType: file?.mimeType || (img.url ? null : undefined), mimeType: file?.mimeType || (img.url ? null : undefined),
size: file?.size ?? null, size: file?.size ?? null,
url: file ? null : img.url ?? null, url: file ? null : (img.url ?? null),
altText: img.altText ?? null, altText: img.altText ?? null,
order: results.length + 1, order: results.length + 1,
isCover: false, isCover: false,
@ -84,7 +93,9 @@ async function main() {
} }
if (!adminEmail || !adminPassword) { if (!adminEmail || !adminPassword) {
console.warn('ADMIN_EMAIL or ADMIN_INITIAL_PASSWORD missing; admin user will not be seeded.'); console.warn(
"ADMIN_EMAIL or ADMIN_INITIAL_PASSWORD missing; admin user will not be seeded.",
);
} }
let adminUser = null; let adminUser = null;
@ -110,13 +121,13 @@ async function main() {
}); });
} }
const sampleHostHash = await bcrypt.hash('changeme-sample', 12); const sampleHostHash = await bcrypt.hash("changeme-sample", 12);
const owner = await prisma.user.upsert({ const owner = await prisma.user.upsert({
where: { email: SAMPLE_EMAIL }, where: { email: SAMPLE_EMAIL },
update: { update: {
name: 'Sample Host', name: "Sample Host",
phone: '+358401234567', phone: "+358401234567",
role: 'USER', role: "USER",
passwordHash: sampleHostHash, passwordHash: sampleHostHash,
status: UserStatus.ACTIVE, status: UserStatus.ACTIVE,
emailVerifiedAt: new Date(), emailVerifiedAt: new Date(),
@ -124,9 +135,9 @@ async function main() {
}, },
create: { create: {
email: SAMPLE_EMAIL, email: SAMPLE_EMAIL,
name: 'Sample Host', name: "Sample Host",
phone: '+358401234567', phone: "+358401234567",
role: 'USER', role: "USER",
passwordHash: sampleHostHash, passwordHash: sampleHostHash,
status: UserStatus.ACTIVE, status: UserStatus.ACTIVE,
emailVerifiedAt: new Date(), emailVerifiedAt: new Date(),
@ -138,11 +149,11 @@ async function main() {
{ {
slug: SAMPLE_SLUG, slug: SAMPLE_SLUG,
isSample: true, isSample: true,
city: 'Punkaharju', city: "Punkaharju",
region: 'South Karelia', region: "South Karelia",
country: 'Finland', country: "Finland",
streetAddress: 'Saimaan rantatie 12', streetAddress: "Saimaan rantatie 12",
addressNote: 'Lakeside trail, 5 min from main road', addressNote: "Lakeside trail, 5 min from main road",
latitude: 61.756, latitude: 61.756,
longitude: 29.328, longitude: 29.328,
maxGuests: 6, maxGuests: 6,
@ -166,31 +177,39 @@ async function main() {
priceWeekdayEuros: 145, priceWeekdayEuros: 145,
priceWeekendEuros: 165, priceWeekendEuros: 165,
cover: { cover: {
file: 'saimaa-lakeside-cabin-cover.jpg', file: "saimaa-lakeside-cabin-cover.jpg",
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
altText: 'Lakeside cabin with sauna', altText: "Lakeside cabin with sauna",
}, },
images: [ images: [
{ file: 'saimaa-lakeside-cabin-sauna.jpg', url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80', altText: 'Wood-fired sauna by the lake' }, {
{ file: 'saimaa-lakeside-cabin-lounge.jpg', url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', altText: 'Living area with fireplace' }, file: "saimaa-lakeside-cabin-sauna.jpg",
], url: "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80",
titleEn: 'Saimaa lakeside cabin with sauna', altText: "Wood-fired sauna by the lake",
teaserEn: 'Sauna, lake view, private dock, and cozy fireplace.',
descEn:
'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.',
titleFi: 'Saimaan rantamökki saunalla',
teaserFi: 'Puusauna, järvinäköala, oma laituri ja takka.',
descFi:
'Perinteinen hirsimökki Saimaan rannalla. Puusauna, oma laituri ja lyhyt metsäreitti kylään. Sopii täydellisesti viikonloppuihin ja juhannukseen.',
}, },
{ {
slug: 'helsinki-design-loft', file: "saimaa-lakeside-cabin-lounge.jpg",
url: "https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80",
altText: "Living area with fireplace",
},
],
titleEn: "Saimaa lakeside cabin with sauna",
teaserEn: "Sauna, lake view, private dock, and cozy fireplace.",
descEn:
"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.",
titleFi: "Saimaan rantamökki saunalla",
teaserFi: "Puusauna, järvinäköala, oma laituri ja takka.",
descFi:
"Perinteinen hirsimökki Saimaan rannalla. Puusauna, oma laituri ja lyhyt metsäreitti kylään. Sopii täydellisesti viikonloppuihin ja juhannukseen.",
},
{
slug: "helsinki-design-loft",
isSample: true, isSample: true,
city: 'Helsinki', city: "Helsinki",
region: 'Uusimaa', region: "Uusimaa",
country: 'Finland', country: "Finland",
streetAddress: 'Katajanokanranta 4', streetAddress: "Katajanokanranta 4",
addressNote: 'Buzz 12B, elevator to 5th floor', addressNote: "Buzz 12B, elevator to 5th floor",
latitude: 60.1675, latitude: 60.1675,
longitude: 24.9529, longitude: 24.9529,
maxGuests: 4, maxGuests: 4,
@ -214,29 +233,39 @@ async function main() {
priceWeekdayEuros: 165, priceWeekdayEuros: 165,
priceWeekendEuros: 185, priceWeekendEuros: 185,
cover: { cover: {
file: 'helsinki-design-loft-cover.jpg', file: "helsinki-design-loft-cover.jpg",
url: 'https://images.unsplash.com/photo-1505693415763-3bd1620f58c3?auto=format&fit=crop&w=1600&q=80', url: "https://images.unsplash.com/photo-1505693415763-3bd1620f58c3?auto=format&fit=crop&w=1600&q=80",
altText: 'Modern loft living room', altText: "Modern loft living room",
}, },
images: [ images: [
{ file: 'helsinki-design-loft-balcony.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Balcony view' }, {
{ file: 'helsinki-design-loft-bedroom.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Cozy bedroom' }, file: "helsinki-design-loft-balcony.jpg",
], url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
titleEn: 'Helsinki design loft with AC', altText: "Balcony view",
teaserEn: 'Top-floor loft, AC, fast Wi-Fi, tram at the door.',
descEn: 'Bright design loft near the harbor. Air conditioning, fiber Wi-Fi, and views over Katajanokka. Perfect city break base.',
titleFi: 'Helsingin design-lofti ilmastoinnilla',
teaserFi: 'Ylimmän kerroksen loft, ilmastointi ja nopea netti.',
descFi: 'Valoisa loft Katajanokalla. Ilmastointi, kuitunetti ja näkymä merelle. Täydellinen kaupunkiloma.',
}, },
{ {
slug: 'turku-riverside-apartment', file: "helsinki-design-loft-bedroom.jpg",
url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
altText: "Cozy bedroom",
},
],
titleEn: "Helsinki design loft with AC",
teaserEn: "Top-floor loft, AC, fast Wi-Fi, tram at the door.",
descEn:
"Bright design loft near the harbor. Air conditioning, fiber Wi-Fi, and views over Katajanokka. Perfect city break base.",
titleFi: "Helsingin design-lofti ilmastoinnilla",
teaserFi: "Ylimmän kerroksen loft, ilmastointi ja nopea netti.",
descFi:
"Valoisa loft Katajanokalla. Ilmastointi, kuitunetti ja näkymä merelle. Täydellinen kaupunkiloma.",
},
{
slug: "turku-riverside-apartment",
isSample: true, isSample: true,
city: 'Turku', city: "Turku",
region: 'Varsinais-Suomi', region: "Varsinais-Suomi",
country: 'Finland', country: "Finland",
streetAddress: 'Läntinen Rantakatu 10', streetAddress: "Läntinen Rantakatu 10",
addressNote: 'Self check-in lockbox', addressNote: "Self check-in lockbox",
latitude: 60.4518, latitude: 60.4518,
longitude: 22.2666, longitude: 22.2666,
maxGuests: 3, maxGuests: 3,
@ -260,28 +289,34 @@ async function main() {
priceWeekdayEuros: 110, priceWeekdayEuros: 110,
priceWeekendEuros: 125, priceWeekendEuros: 125,
cover: { cover: {
file: 'turku-riverside-apartment-cover.jpg', file: "turku-riverside-apartment-cover.jpg",
url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80', url: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80",
altText: 'Apartment living room', altText: "Apartment living room",
}, },
images: [ images: [
{ file: 'turku-riverside-apartment-kitchen.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Kitchen area' }, {
file: "turku-riverside-apartment-kitchen.jpg",
url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
altText: "Kitchen area",
},
], ],
titleEn: 'Riverside apartment in Turku', titleEn: "Riverside apartment in Turku",
teaserEn: 'By the Aura river, pet-friendly, cozy base.', teaserEn: "By the Aura river, pet-friendly, cozy base.",
descEn: 'Compact one-bedroom next to the Aura river. Cafés outside, pet-friendly, fiber internet for workations.', descEn:
titleFi: 'Aurajoen varrella, lemmikkiystävällinen', "Compact one-bedroom next to the Aura river. Cafés outside, pet-friendly, fiber internet for workations.",
teaserFi: 'Aurajoen kupeessa, lemmikit sallittu.', titleFi: "Aurajoen varrella, lemmikkiystävällinen",
descFi: 'Kompakti yksiö Aurajoen varrella. Kahvilat vieressä, lemmikit sallittu, kuitunetti etätöihin.', teaserFi: "Aurajoen kupeessa, lemmikit sallittu.",
descFi:
"Kompakti yksiö Aurajoen varrella. Kahvilat vieressä, lemmikit sallittu, kuitunetti etätöihin.",
}, },
{ {
slug: 'rovaniemi-aurora-cabin', slug: "rovaniemi-aurora-cabin",
isSample: true, isSample: true,
city: 'Rovaniemi', city: "Rovaniemi",
region: 'Lapland', region: "Lapland",
country: 'Finland', country: "Finland",
streetAddress: 'Ounasjoenkuja 8', streetAddress: "Ounasjoenkuja 8",
addressNote: 'Snow tires required in winter', addressNote: "Snow tires required in winter",
latitude: 66.5039, latitude: 66.5039,
longitude: 25.7294, longitude: 25.7294,
maxGuests: 5, maxGuests: 5,
@ -306,28 +341,34 @@ async function main() {
priceWeekdayEuros: 189, priceWeekdayEuros: 189,
priceWeekendEuros: 215, priceWeekendEuros: 215,
cover: { cover: {
file: 'rovaniemi-aurora-cabin-cover.jpg', file: "rovaniemi-aurora-cabin-cover.jpg",
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80', url: "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80",
altText: 'Aurora cabin by the river', altText: "Aurora cabin by the river",
}, },
images: [ images: [
{ file: 'rovaniemi-aurora-cabin-lounge.jpg', url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', altText: 'Fireplace lounge' }, {
file: "rovaniemi-aurora-cabin-lounge.jpg",
url: "https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80",
altText: "Fireplace lounge",
},
], ],
titleEn: 'Aurora riverside cabin', titleEn: "Aurora riverside cabin",
teaserEn: 'Sauna, fireplace, river views, EV charging.', teaserEn: "Sauna, fireplace, river views, EV charging.",
descEn: 'Timber cabin on the Ounasjoki riverside. Wood sauna, fireplace, glass lounge for auroras, free EV charging.', descEn:
titleFi: 'Revontulikämppä joen rannalla', "Timber cabin on the Ounasjoki riverside. Wood sauna, fireplace, glass lounge for auroras, free EV charging.",
teaserFi: 'Sauna, takka, jokinäkymä ja ilmainen lataus.', titleFi: "Revontulikämppä joen rannalla",
descFi: 'Hirsimökki Ounasjoen rannalla. Puusauna, takka ja lasikuisti revontulien katseluun, ilmainen sähköauton lataus.', teaserFi: "Sauna, takka, jokinäkymä ja ilmainen lataus.",
descFi:
"Hirsimökki Ounasjoen rannalla. Puusauna, takka ja lasikuisti revontulien katseluun, ilmainen sähköauton lataus.",
}, },
{ {
slug: 'tampere-sauna-studio', slug: "tampere-sauna-studio",
isSample: true, isSample: true,
city: 'Tampere', city: "Tampere",
region: 'Pirkanmaa', region: "Pirkanmaa",
country: 'Finland', country: "Finland",
streetAddress: 'Hämeenkatu 25', streetAddress: "Hämeenkatu 25",
addressNote: 'Key pickup from lobby', addressNote: "Key pickup from lobby",
latitude: 61.4981, latitude: 61.4981,
longitude: 23.7608, longitude: 23.7608,
maxGuests: 2, maxGuests: 2,
@ -351,26 +392,34 @@ async function main() {
priceWeekdayEuros: 95, priceWeekdayEuros: 95,
priceWeekendEuros: 110, priceWeekendEuros: 110,
cover: { cover: {
file: 'tampere-sauna-studio-cover.jpg', file: "tampere-sauna-studio-cover.jpg",
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
altText: 'Studio interior', altText: "Studio interior",
}, },
images: [{ file: 'tampere-sauna-studio-sauna.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Private sauna' }], images: [
titleEn: 'Tampere studio with private sauna', {
teaserEn: 'City center, private sauna, AC and fiber.', file: "tampere-sauna-studio-sauna.jpg",
descEn: 'Compact studio on Hämeenkatu with private electric sauna, air conditioning, and fiber internet. Steps from tram.', url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
titleFi: 'Tampereen keskustastudio saunalla', altText: "Private sauna",
teaserFi: 'Yksityinen sauna, ilmastointi, kuitu.', },
descFi: 'Kompakti studio Hämeenkadulla. Oma sähkösauna, ilmastointi ja kuitunetti. Ratikka vieressä.', ],
titleEn: "Tampere studio with private sauna",
teaserEn: "City center, private sauna, AC and fiber.",
descEn:
"Compact studio on Hämeenkatu with private electric sauna, air conditioning, and fiber internet. Steps from tram.",
titleFi: "Tampereen keskustastudio saunalla",
teaserFi: "Yksityinen sauna, ilmastointi, kuitu.",
descFi:
"Kompakti studio Hämeenkadulla. Oma sähkösauna, ilmastointi ja kuitunetti. Ratikka vieressä.",
}, },
{ {
slug: 'vaasa-seaside-villa', slug: "vaasa-seaside-villa",
isSample: true, isSample: true,
city: 'Vaasa', city: "Vaasa",
region: 'Ostrobothnia', region: "Ostrobothnia",
country: 'Finland', country: "Finland",
streetAddress: 'Rantakatu 3', streetAddress: "Rantakatu 3",
addressNote: 'Parking for 3 cars', addressNote: "Parking for 3 cars",
latitude: 63.096, latitude: 63.096,
longitude: 21.6158, longitude: 21.6158,
maxGuests: 8, maxGuests: 8,
@ -395,26 +444,34 @@ async function main() {
priceWeekdayEuros: 245, priceWeekdayEuros: 245,
priceWeekendEuros: 275, priceWeekendEuros: 275,
cover: { cover: {
file: 'vaasa-seaside-villa-cover.jpg', file: "vaasa-seaside-villa-cover.jpg",
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
altText: 'Seaside villa deck', altText: "Seaside villa deck",
}, },
images: [{ file: 'vaasa-seaside-villa-lounge.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Seaside villa lounge' }], images: [
titleEn: 'Seaside villa in Vaasa', {
teaserEn: 'Deck, sauna, pet-friendly, paid EV charging.', file: "vaasa-seaside-villa-lounge.jpg",
descEn: 'Spacious villa on the coast with large deck, wood sauna, fireplace lounge. Pets welcome; paid EV charger on site.', url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
titleFi: 'Rantahuvila Vaasassa', altText: "Seaside villa lounge",
teaserFi: 'Terassi, sauna, lemmikit ok, maksullinen lataus.', },
descFi: 'Tilava huvila meren rannalla, iso terassi, puusauna ja takkahuone. Lemmikit sallittu; maksullinen latauspiste.', ],
titleEn: "Seaside villa in Vaasa",
teaserEn: "Deck, sauna, pet-friendly, paid EV charging.",
descEn:
"Spacious villa on the coast with large deck, wood sauna, fireplace lounge. Pets welcome; paid EV charger on site.",
titleFi: "Rantahuvila Vaasassa",
teaserFi: "Terassi, sauna, lemmikit ok, maksullinen lataus.",
descFi:
"Tilava huvila meren rannalla, iso terassi, puusauna ja takkahuone. Lemmikit sallittu; maksullinen latauspiste.",
}, },
{ {
slug: 'kuopio-lakeside-apartment', slug: "kuopio-lakeside-apartment",
isSample: true, isSample: true,
city: 'Kuopio', city: "Kuopio",
region: 'Northern Savonia', region: "Northern Savonia",
country: 'Finland', country: "Finland",
streetAddress: 'Satamakatu 7', streetAddress: "Satamakatu 7",
addressNote: 'Underground parking', addressNote: "Underground parking",
latitude: 62.8924, latitude: 62.8924,
longitude: 27.6783, longitude: 27.6783,
maxGuests: 4, maxGuests: 4,
@ -439,26 +496,34 @@ async function main() {
priceWeekdayEuros: 129, priceWeekdayEuros: 129,
priceWeekendEuros: 149, priceWeekendEuros: 149,
cover: { cover: {
file: 'kuopio-lakeside-apartment-cover.jpg', file: "kuopio-lakeside-apartment-cover.jpg",
url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', url: "https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80",
altText: 'Lake view balcony', altText: "Lake view balcony",
}, },
images: [{ file: 'kuopio-lakeside-apartment-sauna.jpg', url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', altText: 'Apartment sauna' }], images: [
titleEn: 'Kuopio lakeside apartment with sauna', {
teaserEn: 'Balcony to Kallavesi, sauna, free EV charging.', file: "kuopio-lakeside-apartment-sauna.jpg",
descEn: 'Two-bedroom apartment overlooking Lake Kallavesi. Glass balcony, electric sauna, underground parking with free EV charging.', url: "https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80",
titleFi: 'Kuopion järvinäkymä ja sauna', altText: "Apartment sauna",
teaserFi: 'Parveke Kallavedelle, sauna, ilmainen lataus.', },
descFi: 'Kaksio Kallaveden rannalla. Lasitettu parveke, sähkösauna, hallipaikka ja ilmainen sähköauton lataus.', ],
titleEn: "Kuopio lakeside apartment with sauna",
teaserEn: "Balcony to Kallavesi, sauna, free EV charging.",
descEn:
"Two-bedroom apartment overlooking Lake Kallavesi. Glass balcony, electric sauna, underground parking with free EV charging.",
titleFi: "Kuopion järvinäkymä ja sauna",
teaserFi: "Parveke Kallavedelle, sauna, ilmainen lataus.",
descFi:
"Kaksio Kallaveden rannalla. Lasitettu parveke, sähkösauna, hallipaikka ja ilmainen sähköauton lataus.",
}, },
{ {
slug: 'porvoo-river-loft', slug: "porvoo-river-loft",
isSample: true, isSample: true,
city: 'Porvoo', city: "Porvoo",
region: 'Uusimaa', region: "Uusimaa",
country: 'Finland', country: "Finland",
streetAddress: 'Mannerheiminkatu 12', streetAddress: "Mannerheiminkatu 12",
addressNote: 'Historic building, stairs only', addressNote: "Historic building, stairs only",
latitude: 60.3943, latitude: 60.3943,
longitude: 25.6659, longitude: 25.6659,
maxGuests: 2, maxGuests: 2,
@ -482,26 +547,34 @@ async function main() {
priceWeekdayEuros: 99, priceWeekdayEuros: 99,
priceWeekendEuros: 115, priceWeekendEuros: 115,
cover: { cover: {
file: 'porvoo-river-loft-cover.jpg', file: "porvoo-river-loft-cover.jpg",
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
altText: 'Loft interior', altText: "Loft interior",
}, },
images: [{ file: 'porvoo-river-loft-fireplace.jpg', url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Fireplace corner' }], images: [
titleEn: 'Porvoo old town river loft', {
teaserEn: 'Historic charm, fireplace, steps from river.', file: "porvoo-river-loft-fireplace.jpg",
descEn: 'Cozy loft in Porvoo old town. Brick walls, fireplace, and views toward the riverside warehouses.', url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
titleFi: 'Porvoon jokilofti', altText: "Fireplace corner",
teaserFi: 'Takka ja vanhan kaupungin tunnelma.', },
descFi: 'Kotoisa loft Porvoon vanhassa kaupungissa. Tiiliseinät, takka ja näkymä jokirantaan.', ],
titleEn: "Porvoo old town river loft",
teaserEn: "Historic charm, fireplace, steps from river.",
descEn:
"Cozy loft in Porvoo old town. Brick walls, fireplace, and views toward the riverside warehouses.",
titleFi: "Porvoon jokilofti",
teaserFi: "Takka ja vanhan kaupungin tunnelma.",
descFi:
"Kotoisa loft Porvoon vanhassa kaupungissa. Tiiliseinät, takka ja näkymä jokirantaan.",
}, },
{ {
slug: 'oulu-tech-apartment', slug: "oulu-tech-apartment",
isSample: true, isSample: true,
city: 'Oulu', city: "Oulu",
region: 'Northern Ostrobothnia', region: "Northern Ostrobothnia",
country: 'Finland', country: "Finland",
streetAddress: 'Technopolis 2', streetAddress: "Technopolis 2",
addressNote: 'Smart lock entry', addressNote: "Smart lock entry",
latitude: 65.0121, latitude: 65.0121,
longitude: 25.4651, longitude: 25.4651,
maxGuests: 2, maxGuests: 2,
@ -525,26 +598,34 @@ async function main() {
priceWeekdayEuros: 105, priceWeekdayEuros: 105,
priceWeekendEuros: 120, priceWeekendEuros: 120,
cover: { cover: {
file: 'oulu-tech-apartment-cover.jpg', file: "oulu-tech-apartment-cover.jpg",
url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80', url: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80",
altText: 'Modern apartment', altText: "Modern apartment",
}, },
images: [{ file: 'oulu-tech-apartment-desk.jpg', url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80', altText: 'Work desk in apartment' }], images: [
titleEn: 'Smart apartment in Oulu', {
teaserEn: 'AC, smart lock, free EV charging in garage.', file: "oulu-tech-apartment-desk.jpg",
descEn: 'Modern one-bedroom near Technopolis. Air conditioning, smart lock, desk for work, garage with free EV chargers.', url: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80",
titleFi: 'Moderni Oulun yksiö', altText: "Work desk in apartment",
teaserFi: 'Ilmastointi, älylukko, ilmainen lataus.', },
descFi: 'Moderni yksiö Technopoliksen lähellä. Ilmastointi, älylukko, työpiste ja ilmaiset latauspaikat hallissa.', ],
titleEn: "Smart apartment in Oulu",
teaserEn: "AC, smart lock, free EV charging in garage.",
descEn:
"Modern one-bedroom near Technopolis. Air conditioning, smart lock, desk for work, garage with free EV chargers.",
titleFi: "Moderni Oulun yksiö",
teaserFi: "Ilmastointi, älylukko, ilmainen lataus.",
descFi:
"Moderni yksiö Technopoliksen lähellä. Ilmastointi, älylukko, työpiste ja ilmaiset latauspaikat hallissa.",
}, },
{ {
slug: 'mariehamn-harbor-flat', slug: "mariehamn-harbor-flat",
isSample: true, isSample: true,
city: 'Mariehamn', city: "Mariehamn",
region: 'Åland', region: "Åland",
country: 'Finland', country: "Finland",
streetAddress: 'Hamngatan 5', streetAddress: "Hamngatan 5",
addressNote: 'Ferry terminal 10 min walk', addressNote: "Ferry terminal 10 min walk",
latitude: 60.0973, latitude: 60.0973,
longitude: 19.9348, longitude: 19.9348,
maxGuests: 3, maxGuests: 3,
@ -568,30 +649,41 @@ async function main() {
priceWeekdayEuros: 115, priceWeekdayEuros: 115,
priceWeekendEuros: 130, priceWeekendEuros: 130,
cover: { cover: {
file: 'mariehamn-harbor-flat-cover.jpg', file: "mariehamn-harbor-flat-cover.jpg",
url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', url: "https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80",
altText: 'Harbor view', altText: "Harbor view",
}, },
images: [{ file: 'mariehamn-harbor-flat-living.jpg', url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', altText: 'Harbor-facing living room' }], images: [
titleEn: 'Harbor flat in Mariehamn', {
teaserEn: 'Walk to ferries, harbor views, paid EV nearby.', file: "mariehamn-harbor-flat-living.jpg",
descEn: 'Bright flat near the harbor. Walk to ferries and restaurants, harbor-facing balcony, paid EV charging at the public lot.', url: "https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80",
titleFi: 'Satamahuoneisto Maarianhaminassa', altText: "Harbor-facing living room",
teaserFi: 'Satamanäkymä, kävely lautoille.', },
descFi: 'Valoisa huoneisto sataman tuntumassa. Parveke satamaan, ravintolat lähellä, maksullinen lataus viereisellä parkkipaikalla.', ],
titleEn: "Harbor flat in Mariehamn",
teaserEn: "Walk to ferries, harbor views, paid EV nearby.",
descEn:
"Bright flat near the harbor. Walk to ferries and restaurants, harbor-facing balcony, paid EV charging at the public lot.",
titleFi: "Satamahuoneisto Maarianhaminassa",
teaserFi: "Satamanäkymä, kävely lautoille.",
descFi:
"Valoisa huoneisto sataman tuntumassa. Parveke satamaan, ravintolat lähellä, maksullinen lataus viereisellä parkkipaikalla.",
}, },
]; ];
// Fill in any missing amenities/prices with reasonable defaults // Fill in any missing amenities/prices with reasonable defaults
const randBool = (p = 0.5) => Math.random() < p; const randBool = (p = 0.5) => Math.random() < p;
listings = listings.map((item) => { listings = listings.map((item) => {
const weekdayPrice = item.priceWeekdayEuros ?? Math.round(Math.random() * (220 - 90) + 90); const weekdayPrice =
item.priceWeekdayEuros ?? Math.round(Math.random() * (220 - 90) + 90);
const evChargingOnSite = item.evChargingOnSite ?? randBool(0.15); const evChargingOnSite = item.evChargingOnSite ?? randBool(0.15);
const evChargingAvailable = item.evChargingAvailable ?? evChargingOnSite ?? randBool(0.4); const evChargingAvailable =
item.evChargingAvailable ?? evChargingOnSite ?? randBool(0.4);
return { return {
...item, ...item,
priceWeekdayEuros: weekdayPrice, priceWeekdayEuros: weekdayPrice,
priceWeekendEuros: item.priceWeekendEuros ?? (weekdayPrice ? weekdayPrice + 15 : null), priceWeekendEuros:
item.priceWeekendEuros ?? (weekdayPrice ? weekdayPrice + 15 : null),
hasKitchen: item.hasKitchen ?? randBool(0.9), hasKitchen: item.hasKitchen ?? randBool(0.9),
hasDishwasher: item.hasDishwasher ?? randBool(0.6), hasDishwasher: item.hasDishwasher ?? randBool(0.6),
hasWashingMachine: item.hasWashingMachine ?? randBool(0.6), hasWashingMachine: item.hasWashingMachine ?? randBool(0.6),
@ -606,7 +698,10 @@ async function main() {
}); });
for (const item of listings) { for (const item of listings) {
const existing = await prisma.listingTranslation.findFirst({ where: { slug: item.slug }, select: { listingId: true } }); const existing = await prisma.listingTranslation.findFirst({
where: { slug: item.slug },
select: { listingId: true },
});
const imageCreates = buildImageCreates(item); const imageCreates = buildImageCreates(item);
if (!existing) { if (!existing) {
const created = await prisma.listing.create({ const created = await prisma.listing.create({
@ -639,27 +734,40 @@ async function main() {
hasFreeParking: item.hasFreeParking ?? false, hasFreeParking: item.hasFreeParking ?? false,
petsAllowed: item.petsAllowed, petsAllowed: item.petsAllowed,
byTheLake: item.byTheLake, byTheLake: item.byTheLake,
evChargingAvailable: item.evChargingAvailable ?? item.evChargingOnSite ?? false, evChargingAvailable:
item.evChargingAvailable ?? item.evChargingOnSite ?? false,
evChargingOnSite: item.evChargingOnSite ?? false, evChargingOnSite: item.evChargingOnSite ?? false,
wheelchairAccessible: item.wheelchairAccessible ?? false, wheelchairAccessible: item.wheelchairAccessible ?? false,
priceWeekdayEuros: item.priceWeekdayEuros, priceWeekdayEuros: item.priceWeekdayEuros,
priceWeekendEuros: item.priceWeekendEuros, priceWeekendEuros: item.priceWeekendEuros,
contactName: 'Sample Host', contactName: "Sample Host",
contactEmail: SAMPLE_EMAIL, contactEmail: SAMPLE_EMAIL,
contactPhone: owner.phone, contactPhone: owner.phone,
published: true, published: true,
translations: { translations: {
createMany: { createMany: {
data: [ data: [
{ locale: 'en', slug: item.slug, title: item.titleEn, teaser: item.teaserEn, description: item.descEn }, {
{ locale: 'fi', slug: item.slug, title: item.titleFi, teaser: item.teaserFi, description: item.descFi }, locale: "en",
slug: item.slug,
title: item.titleEn,
teaser: item.teaserEn,
description: item.descEn,
},
{
locale: "fi",
slug: item.slug,
title: item.titleFi,
teaser: item.teaserFi,
description: item.descFi,
},
], ],
}, },
}, },
images: imageCreates.length ? { create: imageCreates } : undefined, images: imageCreates.length ? { create: imageCreates } : undefined,
}, },
}); });
console.log('Seeded listing:', created.id, item.slug); console.log("Seeded listing:", created.id, item.slug);
continue; continue;
} }
@ -691,12 +799,13 @@ async function main() {
hasFreeParking: item.hasFreeParking ?? false, hasFreeParking: item.hasFreeParking ?? false,
petsAllowed: item.petsAllowed, petsAllowed: item.petsAllowed,
byTheLake: item.byTheLake, byTheLake: item.byTheLake,
evChargingAvailable: item.evChargingAvailable ?? item.evChargingOnSite ?? false, evChargingAvailable:
item.evChargingAvailable ?? item.evChargingOnSite ?? false,
evChargingOnSite: item.evChargingOnSite ?? false, evChargingOnSite: item.evChargingOnSite ?? false,
wheelchairAccessible: item.wheelchairAccessible ?? false, wheelchairAccessible: item.wheelchairAccessible ?? false,
priceWeekdayEuros: item.priceWeekdayEuros, priceWeekdayEuros: item.priceWeekdayEuros,
priceWeekendEuros: item.priceWeekendEuros, priceWeekendEuros: item.priceWeekendEuros,
contactName: 'Sample Host', contactName: "Sample Host",
contactEmail: SAMPLE_EMAIL, contactEmail: SAMPLE_EMAIL,
contactPhone: owner.phone, contactPhone: owner.phone,
published: true, published: true,
@ -706,14 +815,36 @@ async function main() {
}); });
await prisma.listingTranslation.upsert({ await prisma.listingTranslation.upsert({
where: { slug_locale: { slug: item.slug, locale: 'en' } }, where: { slug_locale: { slug: item.slug, locale: "en" } },
create: { listingId, locale: 'en', slug: item.slug, title: item.titleEn, teaser: item.teaserEn, description: item.descEn }, create: {
update: { title: item.titleEn, teaser: item.teaserEn, description: item.descEn }, listingId,
locale: "en",
slug: item.slug,
title: item.titleEn,
teaser: item.teaserEn,
description: item.descEn,
},
update: {
title: item.titleEn,
teaser: item.teaserEn,
description: item.descEn,
},
}); });
await prisma.listingTranslation.upsert({ await prisma.listingTranslation.upsert({
where: { slug_locale: { slug: item.slug, locale: 'fi' } }, where: { slug_locale: { slug: item.slug, locale: "fi" } },
create: { listingId, locale: 'fi', slug: item.slug, title: item.titleFi, teaser: item.teaserFi, description: item.descFi }, create: {
update: { title: item.titleFi, teaser: item.teaserFi, description: item.descFi }, listingId,
locale: "fi",
slug: item.slug,
title: item.titleFi,
teaser: item.teaserFi,
description: item.descFi,
},
update: {
title: item.titleFi,
teaser: item.teaserFi,
description: item.descFi,
},
}); });
await prisma.listingImage.deleteMany({ where: { listingId } }); await prisma.listingImage.deleteMany({ where: { listingId } });
@ -726,10 +857,10 @@ async function main() {
}); });
} }
console.log('Updated listing:', item.slug); console.log("Updated listing:", item.slug);
} }
console.log('Seed completed for sample listings.'); console.log("Seed completed for sample listings.");
} }
main() main()

View file

@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
const fs = require('fs'); const fs = require("fs");
const crypto = require('crypto'); const crypto = require("crypto");
const [command, ...argv] = process.argv.slice(2); const [command, ...argv] = process.argv.slice(2);
@ -9,10 +9,10 @@ const parseArgs = (args) => {
let i = 0; let i = 0;
while (i < args.length) { while (i < args.length) {
const arg = args[i]; const arg = args[i];
if (arg.startsWith('--')) { if (arg.startsWith("--")) {
const key = arg.replace(/^--/, ''); const key = arg.replace(/^--/, "");
const next = args[i + 1]; const next = args[i + 1];
if (next && !next.startsWith('--')) { if (next && !next.startsWith("--")) {
parsed[key] = next; parsed[key] = next;
i += 2; i += 2;
} else { } else {
@ -39,22 +39,22 @@ const ensureEnv = (key, optional = false) => {
}; };
const readConfig = () => { const readConfig = () => {
const baseUrl = ensureEnv('REDMINE_URL'); const baseUrl = ensureEnv("REDMINE_URL");
const url = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; const url = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return { return {
url, url,
apiKey: ensureEnv('REDMINE_API_KEY'), apiKey: ensureEnv("REDMINE_API_KEY"),
projectId: ensureEnv('REDMINE_PROJECT_ID'), projectId: ensureEnv("REDMINE_PROJECT_ID"),
trackerBugId: ensureEnv('REDMINE_TRACKER_BUG_ID'), trackerBugId: ensureEnv("REDMINE_TRACKER_BUG_ID"),
trackerSecurityId: process.env.REDMINE_TRACKER_SECURITY_ID, trackerSecurityId: process.env.REDMINE_TRACKER_SECURITY_ID,
assigneeId: process.env.REDMINE_ASSIGNEE_ID, assigneeId: process.env.REDMINE_ASSIGNEE_ID,
}; };
}; };
const redmineUrl = (config, path, params = {}) => { const redmineUrl = (config, path, params = {}) => {
const url = new URL(path.replace(/^\//, ''), config.url); const url = new URL(path.replace(/^\//, ""), config.url);
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') { if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, value); url.searchParams.set(key, value);
} }
}); });
@ -65,8 +65,8 @@ const fetchJson = async (config, path, options = {}, params = {}) => {
const url = redmineUrl(config, path, params); const url = redmineUrl(config, path, params);
const res = await fetch(url, { const res = await fetch(url, {
headers: { headers: {
'X-Redmine-API-Key': config.apiKey, "X-Redmine-API-Key": config.apiKey,
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
...options, ...options,
}); });
@ -92,14 +92,14 @@ const fetchAllOpenIssues = async (config) => {
while (total === null || offset < total) { while (total === null || offset < total) {
const data = await fetchJson( const data = await fetchJson(
config, config,
'/issues.json', "/issues.json",
{}, {},
{ {
project_id: config.projectId, project_id: config.projectId,
status_id: 'open', status_id: "open",
limit, limit,
offset, offset,
sort: 'updated_on:desc', sort: "updated_on:desc",
}, },
); );
if (Array.isArray(data.issues)) { if (Array.isArray(data.issues)) {
@ -126,15 +126,15 @@ const findExistingIssue = async (config, fingerprint) => {
}; };
const computeFingerprint = (seed) => { const computeFingerprint = (seed) => {
const hash = crypto.createHash('sha1').update(seed).digest('hex'); const hash = crypto.createHash("sha1").update(seed).digest("hex");
return hash.slice(0, 12); return hash.slice(0, 12);
}; };
const readFailures = (opts) => { const readFailures = (opts) => {
const failures = []; const failures = [];
if (opts['failures-file']) { if (opts["failures-file"]) {
try { try {
const text = fs.readFileSync(opts['failures-file'], 'utf8'); const text = fs.readFileSync(opts["failures-file"], "utf8");
text text
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => line.trim()) .map((line) => line.trim())
@ -150,7 +150,7 @@ const readFailures = (opts) => {
} }
if (failures.length === 0) { if (failures.length === 0) {
failures.push('Test failure (no details provided)'); failures.push("Test failure (no details provided)");
} }
return failures; return failures;
@ -170,8 +170,8 @@ const createIssue = async (config, payload) => {
body.issue.assigned_to_id = config.assigneeId; body.issue.assigned_to_id = config.assigneeId;
} }
const res = await fetchJson(config, '/issues.json', { const res = await fetchJson(config, "/issues.json", {
method: 'POST', method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
return res.issue || res; return res.issue || res;
@ -179,24 +179,26 @@ const createIssue = async (config, payload) => {
const handleCreateTestIssue = async () => { const handleCreateTestIssue = async () => {
const config = readConfig(); const config = readConfig();
const suite = args.suite || 'tests'; const suite = args.suite || "tests";
const tracker = (args.tracker || 'bug').toLowerCase(); const tracker = (args.tracker || "bug").toLowerCase();
const trackerId = const trackerId =
tracker === 'security' tracker === "security"
? config.trackerSecurityId || config.trackerBugId ? config.trackerSecurityId || config.trackerBugId
: config.trackerBugId; : config.trackerBugId;
if (!trackerId) { if (!trackerId) {
throw new Error('Missing tracker id. Set REDMINE_TRACKER_BUG_ID (and optionally REDMINE_TRACKER_SECURITY_ID).'); throw new Error(
"Missing tracker id. Set REDMINE_TRACKER_BUG_ID (and optionally REDMINE_TRACKER_SECURITY_ID).",
);
} }
const failCount = Number(args['fail-count'] || args.failcount || 0); const failCount = Number(args["fail-count"] || args.failcount || 0);
const failures = readFailures(args); const failures = readFailures(args);
const fingerprintSeed = const fingerprintSeed =
args['fingerprint-seed'] || args["fingerprint-seed"] ||
`${suite}|${args.target || ''}|${failCount}|${failures.join('|')}`; `${suite}|${args.target || ""}|${failCount}|${failures.join("|")}`;
const fingerprint = args.fingerprint || computeFingerprint(fingerprintSeed); const fingerprint = args.fingerprint || computeFingerprint(fingerprintSeed);
const subject = `[${suite}] ${failCount || failures.length} failure${failCount === 1 ? '' : 's'} (${tracker}) [${fingerprint}]`; const subject = `[${suite}] ${failCount || failures.length} failure${failCount === 1 ? "" : "s"} (${tracker}) [${fingerprint}]`;
const descriptionLines = [ const descriptionLines = [
`Suite: ${suite}`, `Suite: ${suite}`,
@ -205,8 +207,8 @@ const handleCreateTestIssue = async () => {
`Tracker: ${tracker}`, `Tracker: ${tracker}`,
`Failures (${failures.length}):`, `Failures (${failures.length}):`,
...failures.map((line) => `- ${line}`), ...failures.map((line) => `- ${line}`),
args['summary-file'] ? `Summary: ${args['summary-file']}` : null, args["summary-file"] ? `Summary: ${args["summary-file"]}` : null,
args['failures-file'] ? `Log: ${args['failures-file']}` : null, args["failures-file"] ? `Log: ${args["failures-file"]}` : null,
`Fingerprint: ${fingerprint}`, `Fingerprint: ${fingerprint}`,
].filter(Boolean); ].filter(Boolean);
@ -221,7 +223,7 @@ const handleCreateTestIssue = async () => {
const created = await createIssue(config, { const created = await createIssue(config, {
trackerId, trackerId,
subject, subject,
description: descriptionLines.join('\n'), description: descriptionLines.join("\n"),
}); });
console.log(`Created Redmine issue #${created.id}: ${subject}`); console.log(`Created Redmine issue #${created.id}: ${subject}`);
@ -231,12 +233,12 @@ const handleListOpen = async () => {
const config = readConfig(); const config = readConfig();
const issues = await fetchAllOpenIssues(config); const issues = await fetchAllOpenIssues(config);
if (!issues.length) { if (!issues.length) {
console.log('No open issues found for the configured project.'); console.log("No open issues found for the configured project.");
return; return;
} }
const groups = issues.reduce((acc, issue) => { const groups = issues.reduce((acc, issue) => {
const tracker = issue.tracker?.name || 'Unknown'; const tracker = issue.tracker?.name || "Unknown";
acc[tracker] = acc[tracker] || []; acc[tracker] = acc[tracker] || [];
acc[tracker].push(issue); acc[tracker].push(issue);
return acc; return acc;
@ -245,11 +247,11 @@ const handleListOpen = async () => {
Object.entries(groups).forEach(([trackerName, trackerIssues]) => { Object.entries(groups).forEach(([trackerName, trackerIssues]) => {
console.log(`${trackerName} (${trackerIssues.length})`); console.log(`${trackerName} (${trackerIssues.length})`);
trackerIssues.forEach((issue) => { trackerIssues.forEach((issue) => {
const status = issue.status?.name ? ` [${issue.status.name}]` : ''; const status = issue.status?.name ? ` [${issue.status.name}]` : "";
const priority = issue.priority?.name ? ` (${issue.priority.name})` : ''; const priority = issue.priority?.name ? ` (${issue.priority.name})` : "";
console.log(`- #${issue.id}${status}${priority}: ${issue.subject}`); console.log(`- #${issue.id}${status}${priority}: ${issue.subject}`);
}); });
console.log(''); console.log("");
}); });
}; };
@ -271,14 +273,14 @@ Env:
const main = async () => { const main = async () => {
try { try {
switch (command) { switch (command) {
case 'create-test-issue': case "create-test-issue":
await handleCreateTestIssue(); await handleCreateTestIssue();
break; break;
case 'list-open': case "list-open":
await handleListOpen(); await handleListOpen();
break; break;
case '-h': case "-h":
case '--help': case "--help":
case undefined: case undefined:
printHelp(); printHelp();
process.exit(command ? 0 : 1); process.exit(command ? 0 : 1);

View file

@ -1,21 +1,23 @@
/* Reset admin password for thallaa@gmail.com to a known value. /* Reset admin password for thallaa@gmail.com to a known value.
Usage: node scripts/reset-admin-password.js Usage: node scripts/reset-admin-password.js
*/ */
const path = require('path'); const path = require("path");
require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); require("dotenv").config({ path: path.join(__dirname, "..", ".env") });
const { PrismaClient, Role, UserStatus } = require('@prisma/client'); const { PrismaClient, Role, UserStatus } = require("@prisma/client");
const { PrismaPg } = require('@prisma/adapter-pg'); const { PrismaPg } = require("@prisma/adapter-pg");
const { Pool } = require('pg'); const { Pool } = require("pg");
const bcrypt = require('bcryptjs'); const bcrypt = require("bcryptjs");
async function main() { async function main() {
const email = process.env.ADMIN_EMAIL || 'thallaa@gmail.com'; const email = process.env.ADMIN_EMAIL || "thallaa@gmail.com";
const newPassword = process.env.ADMIN_INITIAL_PASSWORD; const newPassword = process.env.ADMIN_INITIAL_PASSWORD;
if (!newPassword) throw new Error('ADMIN_INITIAL_PASSWORD not set in env'); if (!newPassword) throw new Error("ADMIN_INITIAL_PASSWORD not set in env");
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL not set'); if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL not set");
const prisma = new PrismaClient({ const prisma = new PrismaClient({
adapter: new PrismaPg(new Pool({ connectionString: process.env.DATABASE_URL })), adapter: new PrismaPg(
new Pool({ connectionString: process.env.DATABASE_URL }),
),
}); });
const hash = await bcrypt.hash(newPassword, 12); const hash = await bcrypt.hash(newPassword, 12);
@ -38,7 +40,7 @@ async function main() {
}, },
}); });
console.log('Password reset for', user.email); console.log("Password reset for", user.email);
await prisma.$disconnect(); await prisma.$disconnect();
} }

View file

@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false, "allowJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@ -23,13 +19,6 @@
} }
] ]
}, },
"include": [ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"next-env.d.ts", "exclude": ["node_modules"]
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }

15
types/nodemailer.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
declare module "nodemailer" {
export type TransportOptions = Record<string, any>;
export interface Transporter {
sendMail(options: Record<string, any>): Promise<any>;
}
export function createTransport(options: TransportOptions): Transporter;
const nodemailer: { createTransport: typeof createTransport };
export default nodemailer;
}
declare module "nodemailer/lib/smtp-transport" {
export interface Options extends Record<string, any> {}
const SMTPTransport: any;
export default SMTPTransport;
}