This commit is contained in:
parent
23b18c75bd
commit
0bb709d9c5
102 changed files with 8408 additions and 5455 deletions
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: "20"
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run type-check
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ creation_rules:
|
|||
- age:
|
||||
- age1hkehkc2rryjl975c2mg5cghmjr54n4wjshncl292h2eg5l394fhs4uydrh
|
||||
- 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_.*)$"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
## Ongoing reminders
|
||||
|
||||
- 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.
|
||||
- Future app work ideas: polish translations, add listing fields, expand admin tooling, or harden registry.
|
||||
|
|
|
|||
11
PROGRESS.md
11
PROGRESS.md
|
|
@ -15,7 +15,7 @@
|
|||
- k3s v1.33.5 installed; kubeconfig in `k3s.yaml` (git-ignored) and `~/.kube/config`
|
||||
- Namespaces: `lomavuokraus-prod`, `lomavuokraus-staging`
|
||||
- cert-manager v1.15.3 installed; ClusterIssuers `letsencrypt-prod`/`staging`
|
||||
- App deployed to both namespaces; ingress host rules in place via Traefik
|
||||
- App deployed to both namespaces; ingress host rules in place via Traefik
|
||||
- DNS: `lomavuokraus.fi`, `staging.lomavuokraus.fi`, `api.lomavuokraus.fi` all A -> `157.180.66.64` (updated via Joker DYNDNS).
|
||||
- Registry issue (open):
|
||||
- Builds succeed and image `registry.halla-aho.net:443/thalla/lomavuokraus-web:1763823196` exists locally and was imported into k3s via `ctr import`.
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
- `creds/` and `k3s.yaml` are git-ignored; contains joker DYNDNS creds and registry auth.
|
||||
|
||||
## 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`.
|
||||
- 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).
|
||||
|
|
@ -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.
|
||||
|
||||
## 2025-11-24 — Recent changes
|
||||
|
||||
- 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.
|
||||
- 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).
|
||||
|
||||
## 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`.
|
||||
- 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.
|
||||
|
|
@ -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.
|
||||
|
||||
## 2025-12-06 — Pricing & amenities
|
||||
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- 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`).
|
||||
|
|
@ -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.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- 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).
|
||||
|
|
|
|||
|
|
@ -1,29 +1,31 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useI18n } from '../components/I18nProvider';
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "../components/I18nProvider";
|
||||
|
||||
const highlights = [
|
||||
{ keyTitle: 'highlightQualityTitle', keyBody: 'highlightQualityBody' },
|
||||
{ keyTitle: 'highlightLocalTitle', keyBody: 'highlightLocalBody' },
|
||||
{ keyTitle: 'highlightApiTitle', keyBody: 'highlightApiBody' },
|
||||
{ keyTitle: "highlightQualityTitle", keyBody: "highlightQualityBody" },
|
||||
{ keyTitle: "highlightLocalTitle", keyBody: "highlightLocalBody" },
|
||||
{ keyTitle: "highlightApiTitle", keyBody: "highlightApiBody" },
|
||||
];
|
||||
|
||||
export default function AboutPage() {
|
||||
const { t } = useI18n();
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:3000/api';
|
||||
const appEnv = process.env.APP_ENV || 'local';
|
||||
const appVersion = process.env.NEXT_PUBLIC_VERSION || 'dev';
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE || "http://localhost:3000/api";
|
||||
const appEnv = process.env.APP_ENV || "local";
|
||||
const appVersion = process.env.NEXT_PUBLIC_VERSION || "dev";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="panel">
|
||||
<div className="breadcrumb">
|
||||
<Link href="/">{t('homeCrumb')}</Link> / <span>{t('aboutTitle')}</span>
|
||||
<Link href="/">{t("homeCrumb")}</Link> /{" "}
|
||||
<span>{t("aboutTitle")}</span>
|
||||
</div>
|
||||
<h1>{t('aboutTitle')}</h1>
|
||||
<p style={{ marginTop: 8 }}>{t('aboutLead')}</p>
|
||||
<h1>{t("aboutTitle")}</h1>
|
||||
<p style={{ marginTop: 8 }}>{t("aboutLead")}</p>
|
||||
</section>
|
||||
|
||||
<div className="cards" style={{ marginTop: 18 }}>
|
||||
|
|
@ -36,25 +38,30 @@ export default function AboutPage() {
|
|||
</div>
|
||||
|
||||
<section className="panel env-card" style={{ marginTop: 18 }}>
|
||||
<h2 className="card-title">{t('runtimeConfigTitle')}</h2>
|
||||
<p style={{ marginTop: 4 }}>{t('runtimeConfigLead')}</p>
|
||||
<h2 className="card-title">{t("runtimeConfigTitle")}</h2>
|
||||
<p style={{ marginTop: 4 }}>{t("runtimeConfigLead")}</p>
|
||||
<div className="meta-grid">
|
||||
<span>
|
||||
<strong>{t('runtimeAppEnv')}</strong> <code>{appEnv}</code>
|
||||
<strong>{t("runtimeAppEnv")}</strong> <code>{appEnv}</code>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{t('runtimeSiteUrl')}</strong> <code>{siteUrl}</code>
|
||||
<strong>{t("runtimeSiteUrl")}</strong> <code>{siteUrl}</code>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{t('runtimeApiBase')}</strong> <code>{apiBase}</code>
|
||||
<strong>{t("runtimeApiBase")}</strong> <code>{apiBase}</code>
|
||||
</span>
|
||||
<span>
|
||||
<strong>Version</strong> <code>{appVersion}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<a className="button secondary" href="/api/health" target="_blank" rel="noreferrer">
|
||||
{t('ctaHealth')}
|
||||
<a
|
||||
className="button secondary"
|
||||
href="/api/health"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("ctaHealth")}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../components/I18nProvider';
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "../../components/I18nProvider";
|
||||
|
||||
type HetznerServer = {
|
||||
id: number;
|
||||
|
|
@ -15,7 +15,12 @@ type HetznerServer = {
|
|||
};
|
||||
|
||||
type MonitorResponse = {
|
||||
hetzner?: { ok: boolean; error?: string; missingToken?: boolean; servers?: HetznerServer[] };
|
||||
hetzner?: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
missingToken?: boolean;
|
||||
servers?: HetznerServer[];
|
||||
};
|
||||
k8s?: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
|
|
@ -42,16 +47,29 @@ type MonitorResponse = {
|
|||
hostIP?: string | null;
|
||||
podIP?: 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;
|
||||
|
||||
function formatBytes(bytes?: number) {
|
||||
if (!bytes || Number.isNaN(bytes)) return '—';
|
||||
if (!bytes || Number.isNaN(bytes)) return "—";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||
|
|
@ -62,12 +80,12 @@ function formatBytes(bytes?: number) {
|
|||
}
|
||||
|
||||
function formatDurationFrom(dateStr?: string | null) {
|
||||
if (!dateStr) return '—';
|
||||
if (!dateStr) return "—";
|
||||
const started = new Date(dateStr).getTime();
|
||||
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);
|
||||
if (mins < 1) return '<1m';
|
||||
if (mins < 1) return "<1m";
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
|
|
@ -79,12 +97,12 @@ function statusPill(label: string, ok: boolean) {
|
|||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
background: ok ? '#e6f4ea' : '#fdecea',
|
||||
color: ok ? '#1b5e20' : '#c62828',
|
||||
border: `1px solid ${ok ? '#a5d6a7' : '#f5c6cb'}`,
|
||||
background: ok ? "#e6f4ea" : "#fdecea",
|
||||
color: ok ? "#1b5e20" : "#c62828",
|
||||
border: `1px solid ${ok ? "#a5d6a7" : "#f5c6cb"}`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
|
|
@ -99,23 +117,26 @@ export default function MonitorPage() {
|
|||
const [error, setError] = useState<string | 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() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
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 };
|
||||
if (!res.ok) {
|
||||
setError(json.error || t('monitorLoadFailed'));
|
||||
setError(json.error || t("monitorLoadFailed"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setData(json);
|
||||
setLastUpdated(Date.now());
|
||||
} catch (err) {
|
||||
setError(t('monitorLoadFailed'));
|
||||
setError(t("monitorLoadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -129,85 +150,188 @@ export default function MonitorPage() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 1100, margin: '40px auto', display: 'grid', gap: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', gap: 12 }}>
|
||||
<main
|
||||
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>
|
||||
<h1>{t('monitorTitle')}</h1>
|
||||
<p style={{ margin: 0, color: '#555' }}>{t('monitorLead')}</p>
|
||||
<h1>{t("monitorTitle")}</h1>
|
||||
<p style={{ margin: 0, color: "#555" }}>{t("monitorLead")}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<button className="button secondary" onClick={load} disabled={loading}>
|
||||
{loading ? t('loading') : t('monitorRefresh')}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t("loading") : t("monitorRefresh")}
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: '#666' }}>
|
||||
{t('monitorLastUpdated')}: {lastUpdated ? new Date(lastUpdated).toLocaleTimeString() : '—'}
|
||||
<span style={{ fontSize: 12, color: "#666" }}>
|
||||
{t("monitorLastUpdated")}:{" "}
|
||||
{lastUpdated ? new Date(lastUpdated).toLocaleTimeString() : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p style={{ color: 'red', margin: 0 }}>{error}</p> : null}
|
||||
{!hasData && !loading ? <p style={{ margin: 0 }}>{t('monitorNoData')}</p> : null}
|
||||
{error ? <p style={{ color: "red", margin: 0 }}>{error}</p> : null}
|
||||
{!hasData && !loading ? (
|
||||
<p style={{ margin: 0 }}>{t("monitorNoData")}</p>
|
||||
) : null}
|
||||
|
||||
<section style={{ border: '1px solid #eee', borderRadius: 12, padding: 16 }}>
|
||||
<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)}
|
||||
<section
|
||||
style={{ border: "1px solid #eee", borderRadius: 12, padding: 16 }}
|
||||
>
|
||||
<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>
|
||||
{!data?.hetzner ? (
|
||||
<p>{t('monitorNoData')}</p>
|
||||
<p>{t("monitorNoData")}</p>
|
||||
) : 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 ? (
|
||||
<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) => (
|
||||
<li 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' }}>
|
||||
<li
|
||||
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>
|
||||
<strong>{s.name}</strong> — {s.type ?? 'server'} ({s.datacenter ?? 'dc'})
|
||||
<strong>{s.name}</strong> — {s.type ?? "server"} (
|
||||
{s.datacenter ?? "dc"})
|
||||
</div>
|
||||
{statusPill(s.status, s.status?.toLowerCase() === 'running')}
|
||||
{statusPill(s.status, s.status?.toLowerCase() === "running")}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, 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
|
||||
style={{
|
||||
fontSize: 13,
|
||||
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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p style={{ color: 'red' }}>{data.hetzner.error || t('monitorHetznerEmpty')}</p>
|
||||
<p style={{ color: "red" }}>
|
||||
{data.hetzner.error || t("monitorHetznerEmpty")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ border: '1px solid #eee', 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)}
|
||||
<section
|
||||
style={{
|
||||
border: "1px solid #eee",
|
||||
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>
|
||||
{!data?.k8s ? (
|
||||
<p>{t('monitorNoData')}</p>
|
||||
<p>{t("monitorNoData")}</p>
|
||||
) : !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>
|
||||
<h3 style={{ marginBottom: 6 }}>{t('monitorNodesTitle')}</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 10 }}>
|
||||
<h3 style={{ marginBottom: 6 }}>{t("monitorNodesTitle")}</h3>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{(data.k8s.nodes ?? []).map((n) => (
|
||||
<div key={n.name} style={{ border: '1px solid #eee', borderRadius: 10, padding: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div
|
||||
key={n.name}
|
||||
style={{
|
||||
border: "1px solid #eee",
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<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>
|
||||
{statusPill(n.status, n.ready)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#444', marginTop: 6 }}>
|
||||
<div>IP: {n.internalIp ?? '—'}</div>
|
||||
<div>{n.kubeletVersion ?? 'kubelet'} · {n.osImage ?? ''}</div>
|
||||
<div style={{ color: '#777' }}>
|
||||
{t('monitorLastReady')}: {n.lastReadyTransition ? new Date(n.lastReadyTransition).toLocaleString() : '—'}
|
||||
<div style={{ fontSize: 13, color: "#444", marginTop: 6 }}>
|
||||
<div>IP: {n.internalIp ?? "—"}</div>
|
||||
<div>
|
||||
{n.kubeletVersion ?? "kubelet"} · {n.osImage ?? ""}
|
||||
</div>
|
||||
<div style={{ color: "#777" }}>
|
||||
{t("monitorLastReady")}:{" "}
|
||||
{n.lastReadyTransition
|
||||
? new Date(n.lastReadyTransition).toLocaleString()
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -216,43 +340,153 @@ export default function MonitorPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style={{ marginBottom: 6 }}>{t('monitorPodsTitle')}</h3>
|
||||
<h3 style={{ marginBottom: 6 }}>{t("monitorPodsTitle")}</h3>
|
||||
{(data.k8s.pods ?? []).length === 0 ? (
|
||||
<p style={{ margin: 0 }}>{t('monitorNoPods')}</p>
|
||||
<p style={{ margin: 0 }}>{t("monitorNoPods")}</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid #eee' }}>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>
|
||||
<th
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "6px 8px",
|
||||
borderBottom: "1px solid #eee",
|
||||
}}
|
||||
>
|
||||
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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data.k8s.pods ?? []).map((p) => (
|
||||
<tr key={`${p.namespace}-${p.name}`}>
|
||||
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}>{p.namespace}</td>
|
||||
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}>
|
||||
<td
|
||||
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={{ fontSize: 12, color: '#666' }}>
|
||||
{p.containers.map((c) => `${c.name} (${c.state}${c.lastState ? `, ${c.lastState}` : ''})`).join('; ')}
|
||||
<div style={{ fontSize: 12, color: "#666" }}>
|
||||
{p.containers
|
||||
.map(
|
||||
(c) =>
|
||||
`${c.name} (${c.state}${c.lastState ? `, ${c.lastState}` : ""})`,
|
||||
)
|
||||
.join("; ")}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
borderBottom: "1px solid #f2f2f2",
|
||||
}}
|
||||
>
|
||||
{p.readyCount}/{p.totalContainers}
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}>{p.restarts}</td>
|
||||
<td style={{ padding: '6px 8px', borderBottom: '1px solid #f2f2f2' }}>
|
||||
{p.phase}
|
||||
{p.reason ? ` (${p.reason})` : ''}
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
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 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>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -264,39 +498,111 @@ export default function MonitorPage() {
|
|||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ border: '1px solid #eee', 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)}
|
||||
<section
|
||||
style={{
|
||||
border: "1px solid #eee",
|
||||
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>
|
||||
{!data?.db ? (
|
||||
<p>{t('monitorNoData')}</p>
|
||||
<p>{t("monitorNoData")}</p>
|
||||
) : !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 style={{ border: '1px solid #eee', borderRadius: 10, padding: 10 }}>
|
||||
<div style={{ fontSize: 12, color: '#666' }}>{t('monitorServerTime')}</div>
|
||||
<div style={{ fontWeight: 600 }}>{data.db.serverTime ? new Date(data.db.serverTime).toLocaleString() : '—'}</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
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 style={{ fontWeight: 600 }}>
|
||||
{data.db.serverTime
|
||||
? 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
|
||||
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
|
||||
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>
|
||||
<h3 style={{ marginBottom: 6 }}>{t('monitorConnections')}</h3>
|
||||
<h3 style={{ marginBottom: 6 }}>{t("monitorConnections")}</h3>
|
||||
{(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) => (
|
||||
<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}
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useI18n } from '../../components/I18nProvider';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "../../components/I18nProvider";
|
||||
|
||||
type PendingUser = { id: string; email: string; status: string; emailVerifiedAt: string | null; approvedAt: string | null; role: string };
|
||||
type PendingUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
status: string;
|
||||
emailVerifiedAt: string | null;
|
||||
approvedAt: string | null;
|
||||
role: string;
|
||||
};
|
||||
type PendingListing = {
|
||||
id: string;
|
||||
status: string;
|
||||
|
|
@ -27,74 +34,81 @@ export default function PendingAdminPage() {
|
|||
setMessage(null);
|
||||
setError(null);
|
||||
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();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to load');
|
||||
setError(data.error || "Failed to load");
|
||||
return;
|
||||
}
|
||||
setRole(data.role ?? null);
|
||||
setPendingUsers(data.users ?? []);
|
||||
setPendingListings(data.listings ?? []);
|
||||
} catch (e) {
|
||||
setError('Failed to load');
|
||||
setError("Failed to load");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me', { cache: 'no-store' })
|
||||
fetch("/api/auth/me", { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setRole(data.user?.role ?? null);
|
||||
if (!data.user?.role) {
|
||||
setError(t('adminRequired'));
|
||||
setError(t("adminRequired"));
|
||||
return;
|
||||
}
|
||||
if (['ADMIN', 'USER_MODERATOR', 'LISTING_MODERATOR'].includes(data.user.role)) {
|
||||
if (
|
||||
["ADMIN", "USER_MODERATOR", "LISTING_MODERATOR"].includes(
|
||||
data.user.role,
|
||||
)
|
||||
) {
|
||||
loadPending();
|
||||
return;
|
||||
}
|
||||
setError(t('adminRequired'));
|
||||
setError(t("adminRequired"));
|
||||
})
|
||||
.catch(() => setError(t('adminRequired')));
|
||||
.catch(() => setError(t("adminRequired")));
|
||||
}, [t]);
|
||||
|
||||
async function approveUser(userId: string, makeAdmin: boolean) {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
const res = await fetch('/api/admin/users/approve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/admin/users/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, makeAdmin }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to approve user');
|
||||
setError(data.error || "Failed to approve user");
|
||||
} else {
|
||||
setMessage(t('userUpdated'));
|
||||
setMessage(t("userUpdated"));
|
||||
loadPending();
|
||||
}
|
||||
}
|
||||
|
||||
async function approveListing(listingId: string, action: 'approve' | 'reject' | 'remove') {
|
||||
async function approveListing(
|
||||
listingId: string,
|
||||
action: "approve" | "reject" | "remove",
|
||||
) {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
const reason =
|
||||
action === 'reject'
|
||||
? window.prompt(`${t('reject')}? (optional)`)
|
||||
: action === 'remove'
|
||||
? window.prompt(`${t('remove')}? (optional)`)
|
||||
action === "reject"
|
||||
? window.prompt(`${t("reject")}? (optional)`)
|
||||
: action === "remove"
|
||||
? window.prompt(`${t("remove")}? (optional)`)
|
||||
: null;
|
||||
const res = await fetch('/api/admin/listings/approve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/admin/listings/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ listingId, action, reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to update listing');
|
||||
setError(data.error || "Failed to update listing");
|
||||
} else {
|
||||
setMessage(t('approvalsMessage'));
|
||||
setMessage(t("approvalsMessage"));
|
||||
loadPending();
|
||||
}
|
||||
}
|
||||
|
|
@ -102,45 +116,68 @@ export default function PendingAdminPage() {
|
|||
async function rejectUser(userId: string) {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
const reason = window.prompt(`${t('reject')}? (optional)`);
|
||||
const res = await fetch('/api/admin/users/reject', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const reason = window.prompt(`${t("reject")}? (optional)`);
|
||||
const res = await fetch("/api/admin/users/reject", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to reject user');
|
||||
setError(data.error || "Failed to reject user");
|
||||
} else {
|
||||
setMessage(t('userUpdated'));
|
||||
setMessage(t("userUpdated"));
|
||||
loadPending();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 960, margin: '40px auto' }}>
|
||||
<h1>{t('pendingAdminTitle')}</h1>
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<main className="panel" style={{ maxWidth: 960, margin: "40px auto" }}>
|
||||
<h1>{t("pendingAdminTitle")}</h1>
|
||||
<div style={{ display: "grid", gap: 16 }}>
|
||||
<section>
|
||||
<h3>{t('pendingUsersTitle')}</h3>
|
||||
<h3>{t("pendingUsersTitle")}</h3>
|
||||
{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) => (
|
||||
<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>
|
||||
<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 style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<button className="button" onClick={() => approveUser(u.id, false)} disabled={role !== 'ADMIN' && role !== 'USER_MODERATOR'}>
|
||||
{t('approve')}
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => approveUser(u.id, false)}
|
||||
disabled={role !== "ADMIN" && role !== "USER_MODERATOR"}
|
||||
>
|
||||
{t("approve")}
|
||||
</button>
|
||||
<button className="button secondary" onClick={() => approveUser(u.id, true)} disabled={role !== 'ADMIN'}>
|
||||
{t('approveAdmin')}
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => approveUser(u.id, true)}
|
||||
disabled={role !== "ADMIN"}
|
||||
>
|
||||
{t("approveAdmin")}
|
||||
</button>
|
||||
<button className="button secondary" onClick={() => rejectUser(u.id)} disabled={role !== 'ADMIN' && role !== 'USER_MODERATOR'}>
|
||||
{t('reject')}
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => rejectUser(u.id)}
|
||||
disabled={role !== "ADMIN" && role !== "USER_MODERATOR"}
|
||||
>
|
||||
{t("reject")}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
|
|
@ -149,33 +186,79 @@ export default function PendingAdminPage() {
|
|||
)}
|
||||
</section>
|
||||
<section>
|
||||
<h3>{t('pendingListingsTitle')}</h3>
|
||||
<h3>{t("pendingListingsTitle")}</h3>
|
||||
{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) => (
|
||||
<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>
|
||||
<strong>{l.translations[0]?.title ?? 'Listing'}</strong> — owner: {l.owner.email}
|
||||
<strong>{l.translations[0]?.title ?? "Listing"}</strong> —
|
||||
owner: {l.owner.email}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, 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
|
||||
style={{
|
||||
marginTop: 6,
|
||||
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 style={{ fontSize: 12, color: '#666' }}>
|
||||
{t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')}
|
||||
<div style={{ fontSize: 12, color: "#666" }}>
|
||||
{t("slugsLabel")}:{" "}
|
||||
{l.translations
|
||||
.map((t) => `${t.slug} (${t.locale})`)
|
||||
.join(", ")}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<button className="button" onClick={() => approveListing(l.id, 'approve')} disabled={role !== 'ADMIN' && role !== 'LISTING_MODERATOR'}>
|
||||
{t('publish')}
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => approveListing(l.id, "approve")}
|
||||
disabled={
|
||||
role !== "ADMIN" && role !== "LISTING_MODERATOR"
|
||||
}
|
||||
>
|
||||
{t("publish")}
|
||||
</button>
|
||||
<button className="button secondary" onClick={() => approveListing(l.id, 'reject')} disabled={role !== 'ADMIN' && role !== 'LISTING_MODERATOR'}>
|
||||
{t('reject')}
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => approveListing(l.id, "reject")}
|
||||
disabled={
|
||||
role !== "ADMIN" && role !== "LISTING_MODERATOR"
|
||||
}
|
||||
>
|
||||
{t("reject")}
|
||||
</button>
|
||||
<button className="button secondary" onClick={() => approveListing(l.id, 'remove')} disabled={role !== 'ADMIN' && role !== 'LISTING_MODERATOR'}>
|
||||
{t('remove')}
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => approveListing(l.id, "remove")}
|
||||
disabled={
|
||||
role !== "ADMIN" && role !== "LISTING_MODERATOR"
|
||||
}
|
||||
>
|
||||
{t("remove")}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
|
|
@ -184,8 +267,10 @@ export default function PendingAdminPage() {
|
|||
)}
|
||||
</section>
|
||||
</div>
|
||||
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null}
|
||||
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
|
||||
{message ? (
|
||||
<p style={{ marginTop: 12, color: "green" }}>{message}</p>
|
||||
) : null}
|
||||
{error ? <p style={{ marginTop: 12, color: "red" }}>{error}</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useI18n } from '../../components/I18nProvider';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "../../components/I18nProvider";
|
||||
|
||||
type SiteSettings = {
|
||||
requireLoginForContactDetails: boolean;
|
||||
|
|
@ -21,25 +21,25 @@ export default function AdminSettingsPage() {
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
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();
|
||||
if (me.user?.role !== 'ADMIN') {
|
||||
setError(t('adminRequired'));
|
||||
if (me.user?.role !== "ADMIN") {
|
||||
setError(t("adminRequired"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to load settings');
|
||||
setError(data.error || "Failed to load settings");
|
||||
} else {
|
||||
setSettings(data.settings);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Failed to load settings');
|
||||
setError("Failed to load settings");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -54,21 +54,21 @@ export default function AdminSettingsPage() {
|
|||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to save settings');
|
||||
setError(data.error || "Failed to save settings");
|
||||
} else {
|
||||
setSettings(data.settings);
|
||||
setMessage(t('settingsSaved'));
|
||||
setMessage(t("settingsSaved"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Failed to save settings');
|
||||
setError("Failed to save settings");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -76,42 +76,56 @@ export default function AdminSettingsPage() {
|
|||
|
||||
function toggleRequireLoginForContactDetails() {
|
||||
if (!settings) return;
|
||||
save({ requireLoginForContactDetails: !settings.requireLoginForContactDetails });
|
||||
save({
|
||||
requireLoginForContactDetails: !settings.requireLoginForContactDetails,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 960, margin: '40px auto' }}>
|
||||
<h1>{t('adminSettingsTitle')}</h1>
|
||||
<p>{t('adminSettingsLead')}</p>
|
||||
{loading ? <p>{t('loading')}</p> : null}
|
||||
{message ? <p style={{ color: 'green' }}>{message}</p> : null}
|
||||
{error ? <p style={{ color: 'red' }}>{error}</p> : null}
|
||||
<main className="panel" style={{ maxWidth: 960, margin: "40px auto" }}>
|
||||
<h1>{t("adminSettingsTitle")}</h1>
|
||||
<p>{t("adminSettingsLead")}</p>
|
||||
{loading ? <p>{t("loading")}</p> : null}
|
||||
{message ? <p style={{ color: "green" }}>{message}</p> : null}
|
||||
{error ? <p style={{ color: "red" }}>{error}</p> : null}
|
||||
|
||||
{!loading && settings ? (
|
||||
<div style={{ display: 'grid', gap: 16, marginTop: 16 }}>
|
||||
<div style={{ display: "grid", gap: 16, marginTop: 16 }}>
|
||||
<section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
border: '1px solid #e5e7eb',
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 12,
|
||||
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 }}>
|
||||
<h2 style={{ margin: '0 0 8px' }}>{t('settingContactVisibilityTitle')}</h2>
|
||||
<p style={{ margin: 0, color: '#475569' }}>{t('settingContactVisibilityHelp')}</p>
|
||||
<h2 style={{ margin: "0 0 8px" }}>
|
||||
{t("settingContactVisibilityTitle")}
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: "#475569" }}>
|
||||
{t("settingContactVisibilityHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 600 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.requireLoginForContactDetails}
|
||||
onChange={toggleRequireLoginForContactDetails}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span>{t('settingRequireLoginForContact')}</span>
|
||||
<span>{t("settingRequireLoginForContact")}</span>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useI18n } from '../../components/I18nProvider';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "../../components/I18nProvider";
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
|
|
@ -13,7 +13,7 @@ type UserRow = {
|
|||
approvedAt: string | null;
|
||||
};
|
||||
|
||||
const roleOptions = ['USER', 'USER_MODERATOR', 'LISTING_MODERATOR', 'ADMIN'];
|
||||
const roleOptions = ["USER", "USER_MODERATOR", "LISTING_MODERATOR", "ADMIN"];
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -26,30 +26,30 @@ export default function AdminUsersPage() {
|
|||
async function load() {
|
||||
setError(null);
|
||||
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();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to load users');
|
||||
setError(data.error || "Failed to load users");
|
||||
} else {
|
||||
setUsers(data.users ?? []);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to load users');
|
||||
setError("Failed to load users");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me', { cache: 'no-store' })
|
||||
fetch("/api/auth/me", { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setRole(data.user?.role ?? null);
|
||||
if (data.user?.role === 'ADMIN') {
|
||||
if (data.user?.role === "ADMIN") {
|
||||
load();
|
||||
} else {
|
||||
setError(t('adminRequired'));
|
||||
setError(t("adminRequired"));
|
||||
}
|
||||
})
|
||||
.catch(() => setError(t('adminRequired')));
|
||||
.catch(() => setError(t("adminRequired")));
|
||||
}, [t]);
|
||||
|
||||
async function setUserRole(userId: string, role: string) {
|
||||
|
|
@ -57,20 +57,20 @@ export default function AdminUsersPage() {
|
|||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/role', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/admin/users/role", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, role }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to update role');
|
||||
setError(data.error || "Failed to update role");
|
||||
} else {
|
||||
setMessage(t('userUpdated'));
|
||||
setMessage(t("userUpdated"));
|
||||
load();
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to update role');
|
||||
setError("Failed to update role");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -81,20 +81,20 @@ export default function AdminUsersPage() {
|
|||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/approve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/admin/users/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to approve user');
|
||||
setError(data.error || "Failed to approve user");
|
||||
} else {
|
||||
setMessage(t('userUpdated'));
|
||||
setMessage(t("userUpdated"));
|
||||
load();
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to approve user');
|
||||
setError("Failed to approve user");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -105,21 +105,21 @@ export default function AdminUsersPage() {
|
|||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const reason = window.prompt('Reason for rejection? (optional)');
|
||||
const res = await fetch('/api/admin/users/reject', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const reason = window.prompt("Reason for rejection? (optional)");
|
||||
const res = await fetch("/api/admin/users/reject", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to reject user');
|
||||
setError(data.error || "Failed to reject user");
|
||||
} else {
|
||||
setMessage(t('userUpdated'));
|
||||
setMessage(t("userUpdated"));
|
||||
load();
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to reject user');
|
||||
setError("Failed to reject user");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -130,49 +130,61 @@ export default function AdminUsersPage() {
|
|||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const reason = window.prompt('Reason for removal? (optional)');
|
||||
const res = await fetch('/api/admin/users/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const reason = window.prompt("Reason for removal? (optional)");
|
||||
const res = await fetch("/api/admin/users/remove", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to remove user');
|
||||
setError(data.error || "Failed to remove user");
|
||||
} else {
|
||||
setMessage(t('userUpdated'));
|
||||
setMessage(t("userUpdated"));
|
||||
load();
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to remove user');
|
||||
setError("Failed to remove user");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 960, margin: '40px auto' }}>
|
||||
<h1>{t('adminUsersTitle')}</h1>
|
||||
<p>{t('adminUsersLead')}</p>
|
||||
{message ? <p style={{ color: 'green' }}>{message}</p> : null}
|
||||
{error ? <p style={{ color: 'red' }}>{error}</p> : null}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 12 }}>
|
||||
<main className="panel" style={{ maxWidth: 960, margin: "40px auto" }}>
|
||||
<h1>{t("adminUsersTitle")}</h1>
|
||||
<p>{t("adminUsersLead")}</p>
|
||||
{message ? <p style={{ color: "green" }}>{message}</p> : null}
|
||||
{error ? <p style={{ color: "red" }}>{error}</p> : null}
|
||||
<table
|
||||
style={{ width: "100%", borderCollapse: "collapse", marginTop: 12 }}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>{t('tableEmail')}</th>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>{t('tableRole')}</th>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>{t('tableStatus')}</th>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>{t('tableVerified')}</th>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>{t('tableApproved')}</th>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>Actions</th>
|
||||
<th style={{ textAlign: "left", padding: 8 }}>{t("tableEmail")}</th>
|
||||
<th style={{ textAlign: "left", padding: 8 }}>{t("tableRole")}</th>
|
||||
<th style={{ textAlign: "left", padding: 8 }}>
|
||||
{t("tableStatus")}
|
||||
</th>
|
||||
<th style={{ textAlign: "left", padding: 8 }}>
|
||||
{t("tableVerified")}
|
||||
</th>
|
||||
<th style={{ textAlign: "left", padding: 8 }}>
|
||||
{t("tableApproved")}
|
||||
</th>
|
||||
<th style={{ textAlign: "left", padding: 8 }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} style={{ borderTop: '1px solid #eee' }}>
|
||||
<tr key={u.id} style={{ borderTop: "1px solid #eee" }}>
|
||||
<td style={{ padding: 8 }}>{u.email}</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
<select value={u.role} onChange={(e) => 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) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
|
|
@ -181,20 +193,32 @@ export default function AdminUsersPage() {
|
|||
</select>
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>{u.status}</td>
|
||||
<td style={{ padding: 8 }}>{u.emailVerifiedAt ? 'yes' : 'no'}</td>
|
||||
<td style={{ padding: 8 }}>{u.approvedAt ? 'yes' : 'no'}</td>
|
||||
<td style={{ padding: 8 }}>{u.emailVerifiedAt ? "yes" : "no"}</td>
|
||||
<td style={{ padding: 8 }}>{u.approvedAt ? "yes" : "no"}</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{u.approvedAt ? null : (
|
||||
<button className="button secondary" onClick={() => approve(u.id)} disabled={loading || role !== 'ADMIN'}>
|
||||
{t('approve')}
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => approve(u.id)}
|
||||
disabled={loading || role !== "ADMIN"}
|
||||
>
|
||||
{t("approve")}
|
||||
</button>
|
||||
)}
|
||||
<button className="button secondary" onClick={() => reject(u.id)} disabled={loading || role !== 'ADMIN'}>
|
||||
{t('reject')}
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => reject(u.id)}
|
||||
disabled={loading || role !== "ADMIN"}
|
||||
>
|
||||
{t("reject")}
|
||||
</button>
|
||||
<button className="button secondary" onClick={() => remove(u.id)} disabled={loading || role !== 'ADMIN'}>
|
||||
{t('remove')}
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => remove(u.id)}
|
||||
disabled={loading || role !== "ADMIN"}
|
||||
>
|
||||
{t("remove")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,34 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { ListingStatus } from '@prisma/client';
|
||||
import { prisma } from '../../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../../lib/jwt';
|
||||
import { Role } from '@prisma/client';
|
||||
import { NextResponse } from "next/server";
|
||||
import { ListingStatus } from "@prisma/client";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../../lib/jwt";
|
||||
import { Role } from "@prisma/client";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const auth = await requireAuth(req);
|
||||
const canModerate = auth.role === Role.ADMIN || auth.role === Role.LISTING_MODERATOR;
|
||||
const canModerate =
|
||||
auth.role === Role.ADMIN || auth.role === Role.LISTING_MODERATOR;
|
||||
if (!canModerate) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const listingId = String(body.listingId ?? '');
|
||||
const action = body.action ?? 'approve';
|
||||
const listingId = String(body.listingId ?? "");
|
||||
const action = body.action ?? "approve";
|
||||
const reason = body.reason ? String(body.reason).slice(0, 500) : null;
|
||||
if (!listingId) {
|
||||
return NextResponse.json({ error: 'listingId is required' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "listingId is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let status: ListingStatus;
|
||||
if (action === 'reject') status = ListingStatus.REJECTED;
|
||||
else if (action === 'remove') status = ListingStatus.REMOVED;
|
||||
else if (action === 'publish' || action === 'approve') status = ListingStatus.PUBLISHED;
|
||||
if (action === "reject") status = ListingStatus.REJECTED;
|
||||
else if (action === "remove") status = ListingStatus.REMOVED;
|
||||
else if (action === "publish" || action === "approve")
|
||||
status = ListingStatus.PUBLISHED;
|
||||
else status = ListingStatus.PENDING;
|
||||
|
||||
const updated = await prisma.listing.update({
|
||||
|
|
@ -45,11 +50,11 @@ export async function POST(req: Request) {
|
|||
|
||||
return NextResponse.json({ ok: true, listing: updated });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error('Admin listing approval error', error);
|
||||
return NextResponse.json({ error: 'Approval failed' }, { status: 500 });
|
||||
console.error("Admin listing approval error", error);
|
||||
return NextResponse.json({ error: "Approval failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,24 +1,35 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { Role } from '@prisma/client';
|
||||
import { requireAuth } from '../../../../lib/jwt';
|
||||
import { fetchDbStatus, fetchHetznerServers, fetchKubernetesStatus } from '../../../../lib/monitoring';
|
||||
import { NextResponse } from "next/server";
|
||||
import { Role } from "@prisma/client";
|
||||
import { requireAuth } from "../../../../lib/jwt";
|
||||
import {
|
||||
fetchDbStatus,
|
||||
fetchHetznerServers,
|
||||
fetchKubernetesStatus,
|
||||
} from "../../../../lib/monitoring";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const auth = await requireAuth(req);
|
||||
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 });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error('Monitoring endpoint error', error);
|
||||
return NextResponse.json({ error: 'Failed to load monitoring data' }, { status: 500 });
|
||||
console.error("Monitoring endpoint error", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load monitoring data" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../../lib/jwt';
|
||||
import { ListingStatus, Role, UserStatus } from '@prisma/client';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../../lib/jwt";
|
||||
import { ListingStatus, Role, UserStatus } from "@prisma/client";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
|
|
@ -10,24 +10,33 @@ export async function GET(req: Request) {
|
|||
const canUserMod = auth.role === Role.USER_MODERATOR;
|
||||
const canListingMod = auth.role === Role.LISTING_MODERATOR;
|
||||
if (!isAdmin && !canUserMod && !canListingMod) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const wantsUsers = isAdmin || canUserMod;
|
||||
const wantsListings = isAdmin || canListingMod;
|
||||
|
||||
const [users, listings] = await Promise.all([
|
||||
wantsUsers ? prisma.user.count({ where: { status: UserStatus.PENDING } }) : Promise.resolve(0),
|
||||
wantsListings ? prisma.listing.count({ where: { status: ListingStatus.PENDING, removedAt: null } }) : Promise.resolve(0),
|
||||
wantsUsers
|
||||
? prisma.user.count({ where: { status: UserStatus.PENDING } })
|
||||
: Promise.resolve(0),
|
||||
wantsListings
|
||||
? prisma.listing.count({
|
||||
where: { status: ListingStatus.PENDING, removedAt: null },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ users, listings, total: users + listings });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error('Pending count error', error);
|
||||
return NextResponse.json({ error: 'Failed to load count' }, { status: 500 });
|
||||
console.error("Pending count error", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load count" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../lib/jwt';
|
||||
import { Role, ListingStatus, UserStatus } from '@prisma/client';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../lib/jwt";
|
||||
import { Role, ListingStatus, UserStatus } from "@prisma/client";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
|
|
@ -10,7 +10,7 @@ export async function GET(req: Request) {
|
|||
const canUserMod = auth.role === Role.USER_MODERATOR;
|
||||
const canListingMod = auth.role === Role.LISTING_MODERATOR;
|
||||
if (!isAdmin && !canUserMod && !canListingMod) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const wantsUsers = isAdmin || canUserMod;
|
||||
|
|
@ -20,8 +20,15 @@ export async function GET(req: Request) {
|
|||
wantsUsers
|
||||
? prisma.user.findMany({
|
||||
where: { status: UserStatus.PENDING },
|
||||
select: { id: true, email: true, status: true, emailVerifiedAt: true, approvedAt: true, role: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
status: true,
|
||||
emailVerifiedAt: true,
|
||||
approvedAt: true,
|
||||
role: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 50,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
|
|
@ -36,9 +43,11 @@ export async function GET(req: Request) {
|
|||
evChargingOnSite: true,
|
||||
wheelchairAccessible: 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,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
|
|
@ -46,11 +55,14 @@ export async function GET(req: Request) {
|
|||
|
||||
return NextResponse.json({ users, listings, role: auth.role });
|
||||
} catch (error) {
|
||||
console.error('List pending error', error);
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error("List pending error", error);
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -1,23 +1,26 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { Role } from '@prisma/client';
|
||||
import { requireAuth } from '../../../../lib/jwt';
|
||||
import { getSiteSettings, updateSiteSettings } from '../../../../lib/settings';
|
||||
import { NextResponse } from "next/server";
|
||||
import { Role } from "@prisma/client";
|
||||
import { requireAuth } from "../../../../lib/jwt";
|
||||
import { getSiteSettings, updateSiteSettings } from "../../../../lib/settings";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const auth = await requireAuth(req);
|
||||
if (auth.role !== Role.ADMIN) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
return NextResponse.json({ settings });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error('Load settings error', error);
|
||||
return NextResponse.json({ error: 'Failed to load settings' }, { status: 500 });
|
||||
console.error("Load settings error", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load settings" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -25,24 +28,29 @@ export async function POST(req: Request) {
|
|||
try {
|
||||
const auth = await requireAuth(req);
|
||||
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 payload: Parameters<typeof updateSiteSettings>[0] = {};
|
||||
if (body.requireLoginForContactDetails !== undefined) {
|
||||
payload.requireLoginForContactDetails = Boolean(body.requireLoginForContactDetails);
|
||||
payload.requireLoginForContactDetails = Boolean(
|
||||
body.requireLoginForContactDetails,
|
||||
);
|
||||
}
|
||||
const settings = await updateSiteSettings(payload);
|
||||
|
||||
return NextResponse.json({ settings });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error('Save settings error', error);
|
||||
return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 });
|
||||
console.error("Save settings error", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to save settings" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../../lib/jwt';
|
||||
import { Role, UserStatus } from '@prisma/client';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../../lib/jwt";
|
||||
import { Role, UserStatus } from "@prisma/client";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
|
|
@ -9,22 +9,39 @@ export async function POST(req: Request) {
|
|||
const isAdmin = auth.role === Role.ADMIN;
|
||||
const canApprove = isAdmin || auth.role === Role.USER_MODERATOR;
|
||||
if (!canApprove) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const userId = String(body.userId ?? '');
|
||||
const userId = String(body.userId ?? "");
|
||||
const makeAdmin = Boolean(body.makeAdmin);
|
||||
const newRole = body.newRole as Role | undefined;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'userId is required' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "userId is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin && (makeAdmin || newRole === Role.ADMIN || newRole === Role.USER_MODERATOR || newRole === Role.LISTING_MODERATOR)) {
|
||||
return NextResponse.json({ error: 'Only admins can change roles' }, { status: 403 });
|
||||
if (
|
||||
!isAdmin &&
|
||||
(makeAdmin ||
|
||||
newRole === Role.ADMIN ||
|
||||
newRole === Role.USER_MODERATOR ||
|
||||
newRole === Role.LISTING_MODERATOR)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only admins can change roles" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const roleUpdate = isAdmin && newRole ? { role: newRole } : makeAdmin && isAdmin ? { role: Role.ADMIN } : undefined;
|
||||
const roleUpdate =
|
||||
isAdmin && newRole
|
||||
? { role: newRole }
|
||||
: makeAdmin && isAdmin
|
||||
? { role: Role.ADMIN }
|
||||
: undefined;
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
|
|
@ -43,11 +60,11 @@ export async function POST(req: Request) {
|
|||
|
||||
return NextResponse.json({ ok: true, user: updated });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error('Admin approve user error', error);
|
||||
return NextResponse.json({ error: 'Approval failed' }, { status: 500 });
|
||||
console.error("Admin approve user error", error);
|
||||
return NextResponse.json({ error: "Approval failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,22 +1,26 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../../lib/jwt';
|
||||
import { Role, UserStatus } from '@prisma/client';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../../lib/jwt";
|
||||
import { Role, UserStatus } from "@prisma/client";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const auth = await requireAuth(req);
|
||||
const canReject = auth.role === Role.ADMIN || auth.role === Role.USER_MODERATOR;
|
||||
const canReject =
|
||||
auth.role === Role.ADMIN || auth.role === Role.USER_MODERATOR;
|
||||
if (!canReject) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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({
|
||||
|
|
@ -27,16 +31,22 @@ export async function POST(req: Request) {
|
|||
rejectedReason: reason,
|
||||
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 });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error('Admin reject user error', error);
|
||||
return NextResponse.json({ error: 'Reject failed' }, { status: 500 });
|
||||
console.error("Admin reject user error", error);
|
||||
return NextResponse.json({ error: "Reject failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,25 +1,31 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../../lib/jwt';
|
||||
import { Role, UserStatus } from '@prisma/client';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../../lib/jwt";
|
||||
import { Role, UserStatus } from "@prisma/client";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const auth = await requireAuth(req);
|
||||
if (auth.role !== Role.ADMIN) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'userId is required' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "userId is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
|
|
@ -30,16 +36,22 @@ export async function POST(req: Request) {
|
|||
removedById: auth.userId,
|
||||
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 });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error('Admin remove user error', error);
|
||||
return NextResponse.json({ error: 'Remove failed' }, { status: 500 });
|
||||
console.error("Admin remove user error", error);
|
||||
return NextResponse.json({ error: "Remove failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../../lib/jwt';
|
||||
import { Role } from '@prisma/client';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../../lib/jwt";
|
||||
import { Role } from "@prisma/client";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const auth = await requireAuth(req);
|
||||
if (auth.role !== Role.ADMIN) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const userId = String(body.userId ?? '');
|
||||
const userId = String(body.userId ?? "");
|
||||
const role = body.role as Role | undefined;
|
||||
if (!userId || !role) {
|
||||
return NextResponse.json({ error: 'userId and role are required' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "userId and role are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
|
|
@ -25,11 +28,14 @@ export async function POST(req: Request) {
|
|||
|
||||
return NextResponse.json({ ok: true, user: updated });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error('Update role error', error);
|
||||
return NextResponse.json({ error: 'Failed to update role' }, { status: 500 });
|
||||
console.error("Update role error", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update role" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../lib/jwt';
|
||||
import { Role, UserStatus } from '@prisma/client';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../lib/jwt";
|
||||
import { Role, UserStatus } from "@prisma/client";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const auth = await requireAuth(req);
|
||||
if (auth.role !== Role.ADMIN) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
|
|
@ -21,17 +21,24 @@ export async function GET(req: Request) {
|
|||
approvedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
orderBy: { createdAt: "asc" },
|
||||
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) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error('List users error', error);
|
||||
return NextResponse.json({ error: 'Failed to load users' }, { status: 500 });
|
||||
console.error("List users error", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load users" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,26 +1,31 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { randomToken, addHours } from '../../../../lib/tokens';
|
||||
import { sendPasswordResetEmail } from '../../../../lib/mailer';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { randomToken, addHours } from "../../../../lib/tokens";
|
||||
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) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const email = String(body.email ?? '').trim().toLowerCase();
|
||||
const email = String(body.email ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
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) {
|
||||
const token = randomToken();
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token,
|
||||
type: 'password_reset',
|
||||
type: "password_reset",
|
||||
expiresAt: addHours(2),
|
||||
},
|
||||
});
|
||||
|
|
@ -30,9 +35,9 @@ export async function POST(req: Request) {
|
|||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Forgot password error', error);
|
||||
console.error("Forgot password error", error);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,50 +1,71 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { UserStatus } from '@prisma/client';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { verifyPassword } from '../../../../lib/auth';
|
||||
import { signAccessToken, buildSessionCookie, clearSessionCookie } from '../../../../lib/jwt';
|
||||
import { NextResponse } from "next/server";
|
||||
import { UserStatus } from "@prisma/client";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { verifyPassword } from "../../../../lib/auth";
|
||||
import {
|
||||
signAccessToken,
|
||||
buildSessionCookie,
|
||||
clearSessionCookie,
|
||||
} from "../../../../lib/jwt";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const email = String(body.email ?? '').trim().toLowerCase();
|
||||
const password = String(body.password ?? '');
|
||||
const email = String(body.email ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const password = String(body.password ?? "");
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "Email and password are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
const statusMessage =
|
||||
user.status === UserStatus.REJECTED
|
||||
? 'User access was rejected'
|
||||
? "User access was rejected"
|
||||
: user.status === UserStatus.REMOVED
|
||||
? 'User has been removed'
|
||||
: 'User is not approved yet';
|
||||
? "User has been removed"
|
||||
: "User is not approved yet";
|
||||
return NextResponse.json({ error: statusMessage }, { status: 403 });
|
||||
}
|
||||
|
||||
const token = await signAccessToken({ userId: user.id, role: user.role });
|
||||
const res = NextResponse.json({ token, user: { id: user.id, role: user.role, email: user.email } });
|
||||
res.headers.append('Set-Cookie', buildSessionCookie(token));
|
||||
const res = NextResponse.json({
|
||||
token,
|
||||
user: { id: user.id, role: user.role, email: user.email },
|
||||
});
|
||||
res.headers.append("Set-Cookie", buildSessionCookie(token));
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('Login error', error);
|
||||
const res = NextResponse.json({ error: 'Login failed' }, { status: 500 });
|
||||
res.headers.append('Set-Cookie', clearSessionCookie());
|
||||
console.error("Login error", error);
|
||||
const res = NextResponse.json({ error: "Login failed" }, { status: 500 });
|
||||
res.headers.append("Set-Cookie", clearSessionCookie());
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { clearSessionCookie } from '../../../../lib/jwt';
|
||||
import { NextResponse } from "next/server";
|
||||
import { clearSessionCookie } from "../../../../lib/jwt";
|
||||
|
||||
export async function POST() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.headers.append('Set-Cookie', clearSessionCookie());
|
||||
res.headers.append("Set-Cookie", clearSessionCookie());
|
||||
return res;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,29 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../lib/jwt';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../lib/jwt";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const session = await requireAuth(req);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { id: true, email: true, role: true, status: true, emailVerifiedAt: true, approvedAt: true, name: true, 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 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ user: null }, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,29 +1,40 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { Role, UserStatus } from '@prisma/client';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { hashPassword } from '../../../../lib/auth';
|
||||
import { randomToken, addHours } from '../../../../lib/tokens';
|
||||
import { sendVerificationEmail } from '../../../../lib/mailer';
|
||||
import { NextResponse } from "next/server";
|
||||
import { Role, UserStatus } from "@prisma/client";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { hashPassword } from "../../../../lib/auth";
|
||||
import { randomToken, addHours } from "../../../../lib/tokens";
|
||||
import { sendVerificationEmail } from "../../../../lib/mailer";
|
||||
|
||||
const APP_URL = process.env.APP_URL || 'http://localhost:3000';
|
||||
const APP_URL = process.env.APP_URL || "http://localhost:3000";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const email = String(body.email ?? '').trim().toLowerCase();
|
||||
const password = String(body.password ?? '');
|
||||
const email = String(body.email ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const password = String(body.password ?? "");
|
||||
const name = body.name ? String(body.name).trim() : null;
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "Email and password are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "Password must be at least 8 characters" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Email already registered' }, { status: 409 });
|
||||
return NextResponse.json(
|
||||
{ error: "Email already registered" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
|
@ -43,7 +54,7 @@ export async function POST(req: Request) {
|
|||
data: {
|
||||
userId: user.id,
|
||||
token,
|
||||
type: 'email_verify',
|
||||
type: "email_verify",
|
||||
expiresAt: addHours(24),
|
||||
},
|
||||
});
|
||||
|
|
@ -53,7 +64,7 @@ export async function POST(req: Request) {
|
|||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Register error', error);
|
||||
return NextResponse.json({ error: 'Registration failed' }, { status: 500 });
|
||||
console.error("Register error", error);
|
||||
return NextResponse.json({ error: "Registration failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,63 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { hashPassword } from '../../../../lib/auth';
|
||||
import { addHours } from '../../../../lib/tokens';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { hashPassword } from "../../../../lib/auth";
|
||||
import { addHours } from "../../../../lib/tokens";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const token = String(body.token ?? '').trim();
|
||||
const password = String(body.password ?? '');
|
||||
const token = String(body.token ?? "").trim();
|
||||
const password = String(body.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) {
|
||||
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 } });
|
||||
if (!record || record.type !== 'password_reset' || record.consumedAt || record.expiresAt < new Date()) {
|
||||
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 400 });
|
||||
const record = await prisma.verificationToken.findUnique({
|
||||
where: { token },
|
||||
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);
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({ where: { id: record.userId }, data: { passwordHash } }),
|
||||
prisma.verificationToken.update({ where: { id: record.id }, data: { consumedAt: new Date(), expiresAt: addHours(-1) } }),
|
||||
prisma.user.update({
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error('Password reset error', error);
|
||||
return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 });
|
||||
console.error("Password reset error", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reset password" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,23 +1,29 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const token = String(body.token ?? '').trim();
|
||||
const token = String(body.token ?? "").trim();
|
||||
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) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||
return NextResponse.json({ error: "Invalid token" }, { status: 400 });
|
||||
}
|
||||
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()) {
|
||||
return NextResponse.json({ error: 'Token expired' }, { status: 400 });
|
||||
return NextResponse.json({ error: "Token expired" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
|
|
@ -33,9 +39,9 @@ export async function POST(req: Request) {
|
|||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Verify error', error);
|
||||
return NextResponse.json({ error: 'Verification failed' }, { status: 500 });
|
||||
console.error("Verify error", error);
|
||||
return NextResponse.json({ error: "Verification failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,29 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { NextResponse } from "next/server";
|
||||
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({
|
||||
where: { id: params.id },
|
||||
select: { data: true, mimeType: true, url: true, updatedAt: true },
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (image.data) {
|
||||
const res = new NextResponse(image.data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': image.mimeType || 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
"Content-Type": image.mimeType || "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
if (image.updatedAt) {
|
||||
res.headers.set('Last-Modified', image.updatedAt.toUTCString());
|
||||
res.headers.set("Last-Modified", image.updatedAt.toUTCString());
|
||||
}
|
||||
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.json({ error: 'Image missing' }, { status: 404 });
|
||||
return NextResponse.json({ error: "Image missing" }, { status: 404 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { ListingStatus, UserStatus } from '@prisma/client';
|
||||
import { prisma } from '../../../../../lib/prisma';
|
||||
import { loadN8nBillingApiKey } from '../../../../../lib/apiKeys';
|
||||
import { resolveBillingDetails } from '../../../../../lib/billing';
|
||||
import { NextResponse } from "next/server";
|
||||
import { ListingStatus, UserStatus } from "@prisma/client";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { loadN8nBillingApiKey } from "../../../../../lib/apiKeys";
|
||||
import { resolveBillingDetails } from "../../../../../lib/billing";
|
||||
|
||||
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) {
|
||||
const headerKey = req.headers.get('x-api-key');
|
||||
const headerKey = req.headers.get("x-api-key");
|
||||
if (headerKey) return headerKey.trim();
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth && auth.toLowerCase().startsWith('bearer ')) {
|
||||
const auth = req.headers.get("authorization");
|
||||
if (auth && auth.toLowerCase().startsWith("bearer ")) {
|
||||
return auth.slice(7).trim();
|
||||
}
|
||||
return null;
|
||||
|
|
@ -21,27 +26,36 @@ function extractApiKey(req: Request) {
|
|||
export async function POST(req: Request) {
|
||||
const expectedKey = loadN8nBillingApiKey();
|
||||
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);
|
||||
if (!providedKey || providedKey !== expectedKey) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} 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 listingSlug = typeof body.listingSlug === 'string' ? body.listingSlug.trim() : undefined;
|
||||
const ownerEmailRaw = typeof body.ownerEmail === 'string' ? body.ownerEmail.trim() : undefined;
|
||||
const listingId =
|
||||
typeof body.listingId === "string" ? body.listingId : 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;
|
||||
|
||||
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;
|
||||
|
|
@ -73,21 +87,24 @@ export async function POST(req: Request) {
|
|||
});
|
||||
|
||||
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;
|
||||
if (!listing && listingId) {
|
||||
return NextResponse.json({ error: 'Listing not found' }, { status: 404 });
|
||||
return NextResponse.json({ error: "Listing not found" }, { status: 404 });
|
||||
}
|
||||
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;
|
||||
if (!owner && ownerEmail) {
|
||||
owner = await prisma.user.findFirst({
|
||||
where: { email: { equals: ownerEmail, mode: 'insensitive' } },
|
||||
where: { email: { equals: ownerEmail, mode: "insensitive" } },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
|
|
@ -106,19 +123,27 @@ export async function POST(req: Request) {
|
|||
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) {
|
||||
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 enabled = Boolean(billing.accountName && billing.iban);
|
||||
const listingHasOverride =
|
||||
listing &&
|
||||
(listing.billingAccountName !== null && listing.billingAccountName !== undefined ||
|
||||
listing.billingIban !== null && listing.billingIban !== undefined ||
|
||||
listing.billingIncludeVatLine !== null && listing.billingIncludeVatLine !== undefined);
|
||||
const source = listingHasOverride ? 'listing' : 'user';
|
||||
((listing.billingAccountName !== null &&
|
||||
listing.billingAccountName !== undefined) ||
|
||||
(listing.billingIban !== null && listing.billingIban !== undefined) ||
|
||||
(listing.billingIncludeVatLine !== null &&
|
||||
listing.billingIncludeVatLine !== undefined));
|
||||
const source = listingHasOverride ? "listing" : "user";
|
||||
|
||||
return NextResponse.json({
|
||||
enabled,
|
||||
|
|
@ -126,7 +151,10 @@ export async function POST(req: Request) {
|
|||
listing: listing
|
||||
? {
|
||||
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,
|
||||
}
|
||||
: null,
|
||||
|
|
@ -134,4 +162,4 @@ export async function POST(req: Request) {
|
|||
});
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,20 +1,38 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../../lib/prisma';
|
||||
import { expandBlockedDates, getCalendarRanges } from '../../../../../lib/calendar';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import {
|
||||
expandBlockedDates,
|
||||
getCalendarRanges,
|
||||
} from "../../../../../lib/calendar";
|
||||
|
||||
export async function GET(_: Request, { params }: { params: { id: string } }) {
|
||||
const monthParam = Number(new URL(_.url).searchParams.get('month') ?? new Date().getUTCMonth());
|
||||
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 monthParam = Number(
|
||||
new URL(_.url).searchParams.get("month") ?? new Date().getUTCMonth(),
|
||||
);
|
||||
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 year = Number.isFinite(yearParam) ? yearParam : new Date().getUTCFullYear();
|
||||
const months = Number.isFinite(monthsParam) && monthsParam > 0 ? monthsParam : 1;
|
||||
const month = Number.isFinite(monthParam)
|
||||
? monthParam
|
||||
: 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) {
|
||||
return NextResponse.json({ error: 'Listing not found' }, { status: 404 });
|
||||
return NextResponse.json({ error: "Listing not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const urls = (listing.calendarUrls ?? []).filter(Boolean);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { Role } from '@prisma/client';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../../../lib/jwt';
|
||||
import { Role } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../../lib/prisma";
|
||||
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 {
|
||||
const auth = await requireAuth(req);
|
||||
const listing = await prisma.listing.findUnique({
|
||||
|
|
@ -12,38 +15,48 @@ export async function DELETE(req: Request, { params }: { params: { id: string; i
|
|||
id: true,
|
||||
ownerId: 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) {
|
||||
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 isAdmin = auth.role === Role.ADMIN;
|
||||
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);
|
||||
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) {
|
||||
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 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([
|
||||
prisma.listingImage.delete({ where: { id: params.imageId } }),
|
||||
...remaining.map((img, idx) =>
|
||||
prisma.listingImage.update({
|
||||
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 },
|
||||
select: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' },
|
||||
select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, mimeType: true },
|
||||
orderBy: { order: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
altText: true,
|
||||
order: true,
|
||||
isCover: true,
|
||||
size: true,
|
||||
mimeType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, images: updated?.images ?? [] });
|
||||
} catch (error: any) {
|
||||
console.error('Delete listing image error', error);
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error("Delete listing image error", error);
|
||||
if (String(error).includes("Unauthorized")) {
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -1,40 +1,65 @@
|
|||
import { ListingStatus, Role, UserStatus } from '@prisma/client';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../lib/jwt';
|
||||
import { parsePrice, normalizeCalendarUrls } from '../route'; // reuse helpers
|
||||
import { ListingStatus, Role, UserStatus } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../lib/jwt";
|
||||
import { parsePrice, normalizeCalendarUrls } from "../route"; // reuse helpers
|
||||
|
||||
const MAX_IMAGES = 6;
|
||||
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 {
|
||||
const auth = await requireAuth(_req);
|
||||
const listing = await prisma.listing.findFirst({
|
||||
where: { id: params.id, ownerId: auth.userId, removedAt: null },
|
||||
include: {
|
||||
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) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ listing });
|
||||
} catch (err: any) {
|
||||
const status = err?.message === 'Unauthorized' ? 401 : 500;
|
||||
return NextResponse.json({ error: 'Failed to load listing' }, { status });
|
||||
const status = err?.message === "Unauthorized" ? 401 : 500;
|
||||
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 {
|
||||
const auth = await requireAuth(req);
|
||||
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
|
||||
if (!user || !user.emailVerifiedAt || !user.approvedAt || user.status !== UserStatus.ACTIVE) {
|
||||
return NextResponse.json({ error: 'User not permitted to edit listings' }, { status: 403 });
|
||||
if (
|
||||
!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({
|
||||
|
|
@ -43,81 +68,151 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
|
|||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
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) {
|
||||
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 region = String(body.region ?? existing.region ?? '').trim();
|
||||
const city = String(body.city ?? existing.city ?? '').trim();
|
||||
const streetAddress = String(body.streetAddress ?? existing.streetAddress ?? '').trim();
|
||||
const contactName = String(body.contactName ?? existing.contactName ?? '').trim();
|
||||
const contactEmail = String(body.contactEmail ?? existing.contactEmail ?? '').trim();
|
||||
const country = String(body.country ?? existing.country ?? "").trim();
|
||||
const region = String(body.region ?? existing.region ?? "").trim();
|
||||
const city = String(body.city ?? existing.city ?? "").trim();
|
||||
const streetAddress = String(
|
||||
body.streetAddress ?? existing.streetAddress ?? "",
|
||||
).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 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 maxGuests =
|
||||
body.maxGuests === undefined ||
|
||||
body.maxGuests === null ||
|
||||
body.maxGuests === ""
|
||||
? existing.maxGuests
|
||||
: Number(body.maxGuests);
|
||||
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;
|
||||
type TranslationInput = { locale: string; title: string; description: string; teaser: string | null; slug: string };
|
||||
const translationsInputRaw = Array.isArray(body.translations)
|
||||
? body.translations
|
||||
: null;
|
||||
type TranslationInput = {
|
||||
locale: string;
|
||||
title: string;
|
||||
description: string;
|
||||
teaser: string | null;
|
||||
slug: string;
|
||||
};
|
||||
const translationsInput: TranslationInput[] =
|
||||
translationsInputRaw?.map((item: any) => ({
|
||||
locale: String(item.locale ?? '').toLowerCase(),
|
||||
title: typeof item.title === 'string' ? item.title.trim() : '',
|
||||
description: typeof item.description === 'string' ? item.description.trim() : '',
|
||||
teaser: typeof item.teaser === 'string' ? item.teaser.trim() : null,
|
||||
slug: String(item.slug ?? baseSlug).trim().toLowerCase(),
|
||||
locale: String(item.locale ?? "").toLowerCase(),
|
||||
title: typeof item.title === "string" ? item.title.trim() : "",
|
||||
description:
|
||||
typeof item.description === "string" ? item.description.trim() : "",
|
||||
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 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();
|
||||
const fallbackTranslationTitle =
|
||||
typeof body.title === "string"
|
||||
? body.title.trim()
|
||||
: (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({
|
||||
locale: fallbackLocale,
|
||||
title: fallbackTranslationTitle ?? '',
|
||||
description: fallbackTranslationDescription ?? '',
|
||||
title: fallbackTranslationTitle ?? "",
|
||||
description: fallbackTranslationDescription ?? "",
|
||||
teaser: fallbackTranslationTeaser,
|
||||
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) {
|
||||
const missing: string[] = [];
|
||||
if (!country) missing.push('country');
|
||||
if (!region) missing.push('region');
|
||||
if (!city) missing.push('city');
|
||||
if (!contactEmail) missing.push('contactEmail');
|
||||
if (!contactName) missing.push('contactName');
|
||||
if (!maxGuests) missing.push('maxGuests');
|
||||
if (!bedrooms && bedrooms !== 0) missing.push('bedrooms');
|
||||
if (!beds) missing.push('beds');
|
||||
if (!bathrooms) missing.push('bathrooms');
|
||||
if (!translationsInput.length && !existing.translations.length) missing.push('translations');
|
||||
if (!country) missing.push("country");
|
||||
if (!region) missing.push("region");
|
||||
if (!city) missing.push("city");
|
||||
if (!contactEmail) missing.push("contactEmail");
|
||||
if (!contactName) missing.push("contactName");
|
||||
if (!maxGuests) missing.push("maxGuests");
|
||||
if (!bedrooms && bedrooms !== 0) missing.push("bedrooms");
|
||||
if (!beds) missing.push("beds");
|
||||
if (!bathrooms) missing.push("bathrooms");
|
||||
if (!translationsInput.length && !existing.translations.length)
|
||||
missing.push("translations");
|
||||
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) {
|
||||
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;
|
||||
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) {
|
||||
status = ListingStatus.DRAFT;
|
||||
} else if (existing.status === ListingStatus.PUBLISHED) {
|
||||
|
|
@ -137,14 +232,21 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
|
|||
}[] = [];
|
||||
|
||||
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) {
|
||||
const img = imagesBody[idx];
|
||||
const altText = typeof img.altText === 'string' && img.altText.trim() ? img.altText.trim() : null;
|
||||
const rawMime = typeof img.mimeType === 'string' ? img.mimeType : null;
|
||||
const rawData = typeof img.data === 'string' ? img.data : null;
|
||||
const rawUrl = typeof img.url === 'string' && img.url.trim() ? img.url.trim() : null;
|
||||
const altText =
|
||||
typeof img.altText === "string" && img.altText.trim()
|
||||
? img.altText.trim()
|
||||
: null;
|
||||
const rawMime = typeof img.mimeType === "string" ? img.mimeType : null;
|
||||
const rawData = typeof img.data === "string" ? img.data : null;
|
||||
const rawUrl =
|
||||
typeof img.url === "string" && img.url.trim() ? img.url.trim() : null;
|
||||
let mimeType = rawMime;
|
||||
let buffer: Buffer | null = null;
|
||||
|
||||
|
|
@ -152,15 +254,20 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
|
|||
const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/);
|
||||
if (dataUrlMatch) {
|
||||
mimeType = mimeType || dataUrlMatch[1] || null;
|
||||
buffer = Buffer.from(dataUrlMatch[2], 'base64');
|
||||
buffer = Buffer.from(dataUrlMatch[2], "base64");
|
||||
} else {
|
||||
buffer = Buffer.from(rawData, 'base64');
|
||||
buffer = Buffer.from(rawData, "base64");
|
||||
}
|
||||
}
|
||||
|
||||
const size = buffer ? buffer.length : null;
|
||||
if (size && size > MAX_IMAGE_BYTES) {
|
||||
return NextResponse.json({ error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)` }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!buffer && !rawUrl) {
|
||||
|
|
@ -169,7 +276,7 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
|
|||
|
||||
parsedImages.push({
|
||||
data: buffer ?? undefined,
|
||||
mimeType: mimeType || 'image/jpeg',
|
||||
mimeType: mimeType || "image/jpeg",
|
||||
size,
|
||||
url: buffer ? null : rawUrl,
|
||||
altText,
|
||||
|
|
@ -184,44 +291,107 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
|
|||
}
|
||||
|
||||
const incomingEvChargingOnSite =
|
||||
body.evChargingOnSite === undefined ? existing.evChargingOnSite : Boolean(body.evChargingOnSite);
|
||||
body.evChargingOnSite === undefined
|
||||
? existing.evChargingOnSite
|
||||
: Boolean(body.evChargingOnSite);
|
||||
const incomingEvChargingAvailable =
|
||||
body.evChargingAvailable === undefined ? existing.evChargingAvailable : Boolean(body.evChargingAvailable);
|
||||
const evChargingAvailable = incomingEvChargingOnSite ? true : incomingEvChargingAvailable;
|
||||
const evChargingOnSite = evChargingAvailable ? incomingEvChargingOnSite : false;
|
||||
body.evChargingAvailable === undefined
|
||||
? existing.evChargingAvailable
|
||||
: Boolean(body.evChargingAvailable);
|
||||
const evChargingAvailable = incomingEvChargingOnSite
|
||||
? true
|
||||
: incomingEvChargingAvailable;
|
||||
const evChargingOnSite = evChargingAvailable
|
||||
? incomingEvChargingOnSite
|
||||
: false;
|
||||
|
||||
const updateData: any = {
|
||||
status,
|
||||
approvedAt: status === ListingStatus.PUBLISHED ? existing.approvedAt ?? new Date() : null,
|
||||
approvedById: status === ListingStatus.PUBLISHED && auth.role === Role.ADMIN ? auth.userId : existing.approvedById,
|
||||
approvedAt:
|
||||
status === ListingStatus.PUBLISHED
|
||||
? (existing.approvedAt ?? new Date())
|
||||
: null,
|
||||
approvedById:
|
||||
status === ListingStatus.PUBLISHED && auth.role === Role.ADMIN
|
||||
? auth.userId
|
||||
: existing.approvedById,
|
||||
country: country || null,
|
||||
region: region || null,
|
||||
city: city || null,
|
||||
streetAddress: streetAddress || null,
|
||||
addressNote: body.addressNote ?? existing.addressNote ?? null,
|
||||
latitude: 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,
|
||||
latitude:
|
||||
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,
|
||||
bedrooms,
|
||||
beds,
|
||||
bathrooms,
|
||||
hasSauna: body.hasSauna === undefined ? existing.hasSauna : Boolean(body.hasSauna),
|
||||
hasFireplace: body.hasFireplace === undefined ? existing.hasFireplace : Boolean(body.hasFireplace),
|
||||
hasWifi: body.hasWifi === undefined ? existing.hasWifi : Boolean(body.hasWifi),
|
||||
petsAllowed: body.petsAllowed === undefined ? 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),
|
||||
hasSauna:
|
||||
body.hasSauna === undefined
|
||||
? existing.hasSauna
|
||||
: Boolean(body.hasSauna),
|
||||
hasFireplace:
|
||||
body.hasFireplace === undefined
|
||||
? existing.hasFireplace
|
||||
: Boolean(body.hasFireplace),
|
||||
hasWifi:
|
||||
body.hasWifi === undefined ? existing.hasWifi : Boolean(body.hasWifi),
|
||||
petsAllowed:
|
||||
body.petsAllowed === undefined
|
||||
? 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,
|
||||
evChargingOnSite,
|
||||
wheelchairAccessible:
|
||||
body.wheelchairAccessible === undefined ? existing.wheelchairAccessible : Boolean(body.wheelchairAccessible),
|
||||
body.wheelchairAccessible === undefined
|
||||
? existing.wheelchairAccessible
|
||||
: Boolean(body.wheelchairAccessible),
|
||||
priceWeekdayEuros,
|
||||
priceWeekendEuros,
|
||||
calendarUrls,
|
||||
|
|
@ -236,7 +406,9 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
|
|||
|
||||
if (translationsInput && translationsInput.length) {
|
||||
tx.push(
|
||||
prisma.listingTranslation.deleteMany({ where: { listingId: existing.id } }),
|
||||
prisma.listingTranslation.deleteMany({
|
||||
where: { listingId: existing.id },
|
||||
}),
|
||||
prisma.listingTranslation.createMany({
|
||||
data: translationsInput.map((t) => ({
|
||||
listingId: existing.id,
|
||||
|
|
@ -251,12 +423,14 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
|
|||
}
|
||||
|
||||
if (parsedImages && parsedImages.length) {
|
||||
tx.push(prisma.listingImage.deleteMany({ where: { listingId: existing.id } }));
|
||||
tx.push(
|
||||
prisma.listingImage.deleteMany({ where: { listingId: existing.id } }),
|
||||
);
|
||||
tx.push(
|
||||
prisma.listingImage.createMany({
|
||||
data: parsedImages.map((img) => ({
|
||||
listingId: existing.id,
|
||||
mimeType: img.mimeType || 'image/jpeg',
|
||||
mimeType: img.mimeType || "image/jpeg",
|
||||
size: img.size ?? null,
|
||||
url: img.url ?? 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);
|
||||
|
||||
|
|
@ -276,15 +452,29 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
|
|||
where: { id: existing.id },
|
||||
include: {
|
||||
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 });
|
||||
} catch (error: any) {
|
||||
console.error('Update listing error', error);
|
||||
const message = error?.code === 'P2002' ? 'Slug already exists for this locale' : 'Failed to update listing';
|
||||
const status = error?.message === 'Unauthorized' ? 401 : 400;
|
||||
console.error("Update listing error", error);
|
||||
const message =
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const slug = url.searchParams.get('slug')?.trim().toLowerCase();
|
||||
const slug = url.searchParams.get("slug")?.trim().toLowerCase();
|
||||
|
||||
if (!slug) {
|
||||
return NextResponse.json({ error: 'Missing slug' }, { status: 400 });
|
||||
return NextResponse.json({ error: "Missing slug" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.listingTranslation.findFirst({
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { UserStatus } from '@prisma/client';
|
||||
import { requireAuth } from '../../../../lib/jwt';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { UserStatus } from "@prisma/client";
|
||||
import { requireAuth } from "../../../../lib/jwt";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const session = await requireAuth(req);
|
||||
const user = await prisma.user.findUnique({ where: { id: session.userId }, select: { status: true } });
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (!user || user.status !== UserStatus.ACTIVE) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const listings = await prisma.listing.findMany({
|
||||
where: { ownerId: session.userId },
|
||||
select: { id: true, status: true, translations: { select: { slug: true, title: true, locale: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
translations: { select: { slug: true, title: true, locale: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return NextResponse.json({ listings });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { ListingStatus, Role } from '@prisma/client';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../lib/jwt';
|
||||
import { NextResponse } from "next/server";
|
||||
import { ListingStatus, Role } from "@prisma/client";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../lib/jwt";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const auth = await requireAuth(req);
|
||||
const body = await req.json();
|
||||
const listingId = String(body.listingId ?? '');
|
||||
const listingId = String(body.listingId ?? "");
|
||||
const reason = body.reason ? String(body.reason).slice(0, 500) : null;
|
||||
|
||||
if (!listingId) {
|
||||
return NextResponse.json({ error: 'listingId is required' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "listingId is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const listing = await prisma.listing.findUnique({
|
||||
|
|
@ -19,14 +22,14 @@ export async function POST(req: Request) {
|
|||
select: { id: true, ownerId: true, status: true },
|
||||
});
|
||||
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 canModerate = auth.role === Role.ADMIN;
|
||||
|
||||
if (!isOwner && !canModerate) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (listing.status === ListingStatus.REMOVED) {
|
||||
|
|
@ -40,15 +43,18 @@ export async function POST(req: Request) {
|
|||
published: false,
|
||||
removedAt: new Date(),
|
||||
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 },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, listing: updated });
|
||||
} catch (error) {
|
||||
console.error('Remove listing error', error);
|
||||
return NextResponse.json({ error: 'Failed to remove listing' }, { status: 500 });
|
||||
console.error("Remove listing error", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to remove listing" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { ListingStatus, UserStatus, Prisma } from '@prisma/client';
|
||||
import { prisma } from '../../../lib/prisma';
|
||||
import { requireAuth } from '../../../lib/jwt';
|
||||
import { resolveLocale } from '../../../lib/i18n';
|
||||
import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing';
|
||||
import { getCalendarRanges, isRangeAvailable } from '../../../lib/calendar';
|
||||
import { NextResponse } from "next/server";
|
||||
import { ListingStatus, UserStatus, Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { requireAuth } from "../../../lib/jwt";
|
||||
import { resolveLocale } from "../../../lib/i18n";
|
||||
import { SAMPLE_LISTING_SLUGS } from "../../../lib/sampleListing";
|
||||
import { getCalendarRanges, isRangeAvailable } from "../../../lib/calendar";
|
||||
|
||||
const MAX_IMAGES = 6;
|
||||
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) {
|
||||
return `/api/images/${img.id}`;
|
||||
}
|
||||
return img.url ?? null;
|
||||
}
|
||||
|
||||
function pickTranslation<T extends { locale: string }>(translations: T[], locale: string | null): T | null {
|
||||
function pickTranslation<T extends { locale: string }>(
|
||||
translations: T[],
|
||||
locale: string | null,
|
||||
): T | null {
|
||||
if (!translations.length) return null;
|
||||
if (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[] {
|
||||
if (Array.isArray(input)) {
|
||||
return input
|
||||
.map((u) => (typeof u === 'string' ? u.trim() : ''))
|
||||
.map((u) => (typeof u === "string" ? u.trim() : ""))
|
||||
.filter(Boolean)
|
||||
.slice(0, 5);
|
||||
}
|
||||
if (typeof input === 'string') {
|
||||
if (typeof input === "string") {
|
||||
return input
|
||||
.split(/\n|,/)
|
||||
.map((u) => u.trim())
|
||||
|
|
@ -44,7 +51,7 @@ export function normalizeCalendarUrls(input: unknown): string[] {
|
|||
}
|
||||
|
||||
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);
|
||||
if (Number.isNaN(num)) return null;
|
||||
return Math.round(num);
|
||||
|
|
@ -53,44 +60,63 @@ export function parsePrice(value: unknown): number | null {
|
|||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const searchParams = url.searchParams;
|
||||
const q = searchParams.get('q')?.trim();
|
||||
const city = searchParams.get('city')?.trim();
|
||||
const region = searchParams.get('region')?.trim();
|
||||
const evChargingParam = searchParams.get('evCharging');
|
||||
const evCharging = evChargingParam === 'true' ? true : evChargingParam === 'false' ? 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 q = searchParams.get("q")?.trim();
|
||||
const city = searchParams.get("city")?.trim();
|
||||
const region = searchParams.get("region")?.trim();
|
||||
const evChargingParam = searchParams.get("evCharging");
|
||||
const evCharging =
|
||||
evChargingParam === "true"
|
||||
? true
|
||||
: evChargingParam === "false"
|
||||
? 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 endDate = endDateParam ? new Date(endDateParam) : null;
|
||||
const availabilityFilterActive = Boolean(startDate && endDate && startDate < endDate);
|
||||
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 availabilityFilterActive = Boolean(
|
||||
startDate && endDate && startDate < endDate,
|
||||
);
|
||||
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 = {};
|
||||
if (amenityFilters.includes('sauna')) amenityWhere.hasSauna = true;
|
||||
if (amenityFilters.includes('fireplace')) amenityWhere.hasFireplace = true;
|
||||
if (amenityFilters.includes('wifi')) amenityWhere.hasWifi = true;
|
||||
if (amenityFilters.includes('pets')) amenityWhere.petsAllowed = true;
|
||||
if (amenityFilters.includes('lake')) amenityWhere.byTheLake = true;
|
||||
if (amenityFilters.includes('ac')) amenityWhere.hasAirConditioning = true;
|
||||
if (amenityFilters.includes('kitchen')) amenityWhere.hasKitchen = true;
|
||||
if (amenityFilters.includes('dishwasher')) amenityWhere.hasDishwasher = true;
|
||||
if (amenityFilters.includes('washer')) amenityWhere.hasWashingMachine = true;
|
||||
if (amenityFilters.includes('barbecue')) amenityWhere.hasBarbecue = true;
|
||||
if (amenityFilters.includes('microwave')) amenityWhere.hasMicrowave = true;
|
||||
if (amenityFilters.includes('parking')) amenityWhere.hasFreeParking = true;
|
||||
if (amenityFilters.includes('skipass')) amenityWhere.hasSkiPass = true;
|
||||
if (amenityFilters.includes('accessible')) amenityWhere.wheelchairAccessible = true;
|
||||
if (amenityFilters.includes('ev-onsite')) amenityWhere.evChargingOnSite = true;
|
||||
if (amenityFilters.includes("sauna")) amenityWhere.hasSauna = true;
|
||||
if (amenityFilters.includes("fireplace")) amenityWhere.hasFireplace = true;
|
||||
if (amenityFilters.includes("wifi")) amenityWhere.hasWifi = true;
|
||||
if (amenityFilters.includes("pets")) amenityWhere.petsAllowed = true;
|
||||
if (amenityFilters.includes("lake")) amenityWhere.byTheLake = true;
|
||||
if (amenityFilters.includes("ac")) amenityWhere.hasAirConditioning = true;
|
||||
if (amenityFilters.includes("kitchen")) amenityWhere.hasKitchen = true;
|
||||
if (amenityFilters.includes("dishwasher")) amenityWhere.hasDishwasher = true;
|
||||
if (amenityFilters.includes("washer")) amenityWhere.hasWashingMachine = true;
|
||||
if (amenityFilters.includes("barbecue")) amenityWhere.hasBarbecue = true;
|
||||
if (amenityFilters.includes("microwave")) amenityWhere.hasMicrowave = true;
|
||||
if (amenityFilters.includes("parking")) amenityWhere.hasFreeParking = true;
|
||||
if (amenityFilters.includes("skipass")) amenityWhere.hasSkiPass = true;
|
||||
if (amenityFilters.includes("accessible"))
|
||||
amenityWhere.wheelchairAccessible = true;
|
||||
if (amenityFilters.includes("ev-onsite"))
|
||||
amenityWhere.evChargingOnSite = true;
|
||||
|
||||
const where: Prisma.ListingWhereInput = {
|
||||
status: ListingStatus.PUBLISHED,
|
||||
removedAt: null,
|
||||
city: city ? { contains: city, mode: 'insensitive' } : undefined,
|
||||
region: region ? { contains: region, mode: 'insensitive' } : undefined,
|
||||
city: city ? { contains: city, mode: "insensitive" } : undefined,
|
||||
region: region ? { contains: region, mode: "insensitive" } : undefined,
|
||||
evChargingAvailable: evCharging ?? undefined,
|
||||
evChargingOnSite: evChargingOnSite ?? undefined,
|
||||
...amenityWhere,
|
||||
|
|
@ -98,9 +124,9 @@ export async function GET(req: Request) {
|
|||
? {
|
||||
some: {
|
||||
OR: [
|
||||
{ title: { contains: q, mode: 'insensitive' } },
|
||||
{ description: { contains: q, mode: 'insensitive' } },
|
||||
{ teaser: { contains: q, mode: 'insensitive' } },
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ description: { 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({
|
||||
where,
|
||||
include: {
|
||||
translations: { select: { id: true, locale: true, title: true, slug: true, teaser: true, description: true } },
|
||||
images: { select: { id: true, url: true, altText: true, order: true, isCover: true, size: true }, orderBy: { order: 'asc' } },
|
||||
translations: {
|
||||
select: {
|
||||
id: true,
|
||||
locale: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
teaser: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
images: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
altText: true,
|
||||
order: true,
|
||||
isCover: true,
|
||||
size: true,
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: Number.isNaN(limit) ? 40 : limit,
|
||||
});
|
||||
|
||||
|
|
@ -139,7 +184,9 @@ export async function GET(req: Request) {
|
|||
listing.isSample ||
|
||||
listing.contactEmail === SAMPLE_EMAIL ||
|
||||
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 fallback = listing.translations[0];
|
||||
|
|
@ -147,16 +194,20 @@ export async function GET(req: Request) {
|
|||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return {
|
||||
id: listing.id,
|
||||
title: translation?.title ?? fallback?.title ?? 'Listing',
|
||||
slug: translation?.slug ?? fallback?.slug ?? '',
|
||||
teaser: translation?.teaser ?? translation?.description ?? fallback?.description ?? null,
|
||||
title: translation?.title ?? fallback?.title ?? "Listing",
|
||||
slug: translation?.slug ?? fallback?.slug ?? "",
|
||||
teaser:
|
||||
translation?.teaser ??
|
||||
translation?.description ??
|
||||
fallback?.description ??
|
||||
null,
|
||||
locale: translation?.locale ?? fallback?.locale ?? locale,
|
||||
country: listing.country,
|
||||
region: listing.region,
|
||||
|
|
@ -187,10 +238,15 @@ export async function GET(req: Request) {
|
|||
bathrooms: listing.bathrooms,
|
||||
priceWeekdayEuros: listing.priceWeekdayEuros,
|
||||
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,
|
||||
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 {
|
||||
const auth = await requireAuth(req);
|
||||
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
|
||||
if (!user || !user.emailVerifiedAt || !user.approvedAt || user.status !== UserStatus.ACTIVE) {
|
||||
return NextResponse.json({ error: 'User not permitted to create listings' }, { status: 403 });
|
||||
if (
|
||||
!user ||
|
||||
!user.emailVerifiedAt ||
|
||||
!user.approvedAt ||
|
||||
user.status !== UserStatus.ACTIVE
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not permitted to create listings" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const saveDraft = Boolean(body.saveDraft);
|
||||
const slug = String(body.slug ?? '').trim().toLowerCase();
|
||||
const country = String(body.country ?? '').trim();
|
||||
const region = String(body.region ?? '').trim();
|
||||
const city = String(body.city ?? '').trim();
|
||||
const streetAddress = String(body.streetAddress ?? '').trim();
|
||||
const contactName = String(body.contactName ?? '').trim();
|
||||
const contactEmail = String(body.contactEmail ?? '').trim();
|
||||
const slug = String(body.slug ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const country = String(body.country ?? "").trim();
|
||||
const region = String(body.region ?? "").trim();
|
||||
const city = String(body.city ?? "").trim();
|
||||
const streetAddress = String(body.streetAddress ?? "").trim();
|
||||
const contactName = String(body.contactName ?? "").trim();
|
||||
const contactEmail = String(body.contactEmail ?? "").trim();
|
||||
|
||||
if (!slug) {
|
||||
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 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 maxGuests =
|
||||
body.maxGuests === undefined ||
|
||||
body.maxGuests === null ||
|
||||
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 priceWeekendEuros = parsePrice(body.priceWeekendEuros);
|
||||
const calendarUrls = normalizeCalendarUrls(body.calendarUrls);
|
||||
const translationsInputRaw = Array.isArray(body.translations) ? body.translations : [];
|
||||
type TranslationInput = { locale: string; title: string; description: string; teaser: string | null; slug: string };
|
||||
const translationsInputRaw = Array.isArray(body.translations)
|
||||
? body.translations
|
||||
: [];
|
||||
type TranslationInput = {
|
||||
locale: string;
|
||||
title: string;
|
||||
description: string;
|
||||
teaser: string | null;
|
||||
slug: string;
|
||||
};
|
||||
let translationsInput =
|
||||
translationsInputRaw
|
||||
.map((item: any) => ({
|
||||
locale: String(item.locale ?? '').toLowerCase(),
|
||||
title: typeof item.title === 'string' ? item.title.trim() : '',
|
||||
description: typeof item.description === 'string' ? item.description.trim() : '',
|
||||
teaser: typeof item.teaser === 'string' ? item.teaser.trim() : null,
|
||||
slug: String(item.slug ?? slug).trim().toLowerCase(),
|
||||
locale: String(item.locale ?? "").toLowerCase(),
|
||||
title: typeof item.title === "string" ? item.title.trim() : "",
|
||||
description:
|
||||
typeof item.description === "string" ? item.description.trim() : "",
|
||||
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 fallbackTranslationTitle = typeof body.title === 'string' ? body.title.trim() : '';
|
||||
const fallbackTranslationDescription = typeof body.description === 'string' ? body.description.trim() : '';
|
||||
const fallbackTranslationTeaser = typeof body.teaser === 'string' ? body.teaser.trim() : null;
|
||||
const fallbackLocale = String(body.locale ?? "en").toLowerCase();
|
||||
const fallbackTranslationTitle =
|
||||
typeof body.title === "string" ? body.title.trim() : "";
|
||||
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({
|
||||
locale: fallbackLocale,
|
||||
title: fallbackTranslationTitle ?? '',
|
||||
description: fallbackTranslationDescription ?? '',
|
||||
title: fallbackTranslationTitle ?? "",
|
||||
description: fallbackTranslationDescription ?? "",
|
||||
teaser: fallbackTranslationTeaser,
|
||||
slug,
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
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: {
|
||||
data?: Buffer;
|
||||
|
|
@ -276,10 +391,14 @@ export async function POST(req: Request) {
|
|||
|
||||
for (let idx = 0; idx < images.length; idx += 1) {
|
||||
const img = images[idx];
|
||||
const altText = typeof img.altText === 'string' && img.altText.trim() ? img.altText.trim() : null;
|
||||
const rawMime = typeof img.mimeType === 'string' ? img.mimeType : null;
|
||||
const rawData = typeof img.data === 'string' ? img.data : null;
|
||||
const rawUrl = typeof img.url === 'string' && img.url.trim() ? img.url.trim() : null;
|
||||
const altText =
|
||||
typeof img.altText === "string" && img.altText.trim()
|
||||
? img.altText.trim()
|
||||
: null;
|
||||
const rawMime = typeof img.mimeType === "string" ? img.mimeType : null;
|
||||
const rawData = typeof img.data === "string" ? img.data : null;
|
||||
const rawUrl =
|
||||
typeof img.url === "string" && img.url.trim() ? img.url.trim() : null;
|
||||
let mimeType = rawMime;
|
||||
let buffer: Buffer | null = null;
|
||||
|
||||
|
|
@ -287,15 +406,20 @@ export async function POST(req: Request) {
|
|||
const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/);
|
||||
if (dataUrlMatch) {
|
||||
mimeType = mimeType || dataUrlMatch[1] || null;
|
||||
buffer = Buffer.from(dataUrlMatch[2], 'base64');
|
||||
buffer = Buffer.from(dataUrlMatch[2], "base64");
|
||||
} else {
|
||||
buffer = Buffer.from(rawData, 'base64');
|
||||
buffer = Buffer.from(rawData, "base64");
|
||||
}
|
||||
}
|
||||
|
||||
const size = buffer ? buffer.length : null;
|
||||
if (size && size > MAX_IMAGE_BYTES) {
|
||||
return NextResponse.json({ error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)` }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!buffer && !rawUrl) {
|
||||
|
|
@ -304,7 +428,7 @@ export async function POST(req: Request) {
|
|||
|
||||
parsedImages.push({
|
||||
data: buffer ?? undefined,
|
||||
mimeType: mimeType || 'image/jpeg',
|
||||
mimeType: mimeType || "image/jpeg",
|
||||
size,
|
||||
url: buffer ? null : rawUrl,
|
||||
altText,
|
||||
|
|
@ -319,27 +443,37 @@ export async function POST(req: Request) {
|
|||
|
||||
if (!saveDraft) {
|
||||
const missingFields: string[] = [];
|
||||
if (!country) missingFields.push('country');
|
||||
if (!region) missingFields.push('region');
|
||||
if (!city) missingFields.push('city');
|
||||
if (!contactEmail) missingFields.push('contactEmail');
|
||||
if (!contactName) missingFields.push('contactName');
|
||||
if (!maxGuests) missingFields.push('maxGuests');
|
||||
if (!bedrooms && bedrooms !== 0) missingFields.push('bedrooms');
|
||||
if (!beds) missingFields.push('beds');
|
||||
if (!bathrooms) missingFields.push('bathrooms');
|
||||
if (!translationsInput.length) missingFields.push('translations');
|
||||
if (!parsedImages.length) missingFields.push('images');
|
||||
if (!country) missingFields.push("country");
|
||||
if (!region) missingFields.push("region");
|
||||
if (!city) missingFields.push("city");
|
||||
if (!contactEmail) missingFields.push("contactEmail");
|
||||
if (!contactName) missingFields.push("contactName");
|
||||
if (!maxGuests) missingFields.push("maxGuests");
|
||||
if (!bedrooms && bedrooms !== 0) missingFields.push("bedrooms");
|
||||
if (!beds) missingFields.push("beds");
|
||||
if (!bathrooms) missingFields.push("bathrooms");
|
||||
if (!translationsInput.length) missingFields.push("translations");
|
||||
if (!parsedImages.length) missingFields.push("images");
|
||||
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 status = saveDraft ? ListingStatus.DRAFT : autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING;
|
||||
const isSample = (contactEmail || '').toLowerCase() === SAMPLE_EMAIL;
|
||||
const autoApprove =
|
||||
!saveDraft &&
|
||||
(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 evChargingAvailable = Boolean(body.evChargingAvailable) || evChargingOnSite;
|
||||
const evChargingAvailable =
|
||||
Boolean(body.evChargingAvailable) || evChargingOnSite;
|
||||
const wheelchairAccessible = Boolean(body.wheelchairAccessible);
|
||||
|
||||
const listing = await prisma.listing.create({
|
||||
|
|
@ -347,14 +481,24 @@ export async function POST(req: Request) {
|
|||
ownerId: user.id,
|
||||
status,
|
||||
approvedAt: autoApprove ? new Date() : null,
|
||||
approvedById: autoApprove && auth.role === 'ADMIN' ? user.id : null,
|
||||
approvedById: autoApprove && auth.role === "ADMIN" ? user.id : null,
|
||||
country: country || null,
|
||||
region: region || null,
|
||||
city: city || null,
|
||||
streetAddress: streetAddress || null,
|
||||
addressNote: body.addressNote ?? null,
|
||||
latitude: body.latitude !== undefined && body.latitude !== null && body.latitude !== '' ? Number(body.latitude) : null,
|
||||
longitude: body.longitude !== undefined && body.longitude !== null && body.longitude !== '' ? Number(body.longitude) : null,
|
||||
latitude:
|
||||
body.latitude !== undefined &&
|
||||
body.latitude !== null &&
|
||||
body.latitude !== ""
|
||||
? Number(body.latitude)
|
||||
: null,
|
||||
longitude:
|
||||
body.longitude !== undefined &&
|
||||
body.longitude !== null &&
|
||||
body.longitude !== ""
|
||||
? Number(body.longitude)
|
||||
: null,
|
||||
maxGuests,
|
||||
bedrooms,
|
||||
beds,
|
||||
|
|
@ -401,13 +545,28 @@ export async function POST(req: Request) {
|
|||
}
|
||||
: 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 });
|
||||
} catch (error: any) {
|
||||
console.error('Create listing error', error);
|
||||
const message = error?.code === 'P2002' ? 'Slug already exists for this locale' : 'Failed to create listing';
|
||||
console.error("Create listing error", error);
|
||||
const message =
|
||||
error?.code === "P2002"
|
||||
? "Slug already exists for this locale"
|
||||
: "Failed to create listing";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,29 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { requireAuth } from '../../../../lib/jwt';
|
||||
import type { Locale } from '../../../../lib/i18n';
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { requireAuth } from "../../../../lib/jwt";
|
||||
import type { Locale } from "../../../../lib/i18n";
|
||||
|
||||
type LocaleFields = { title: string; teaser: string; description: string };
|
||||
const SUPPORTED_LOCALES: Locale[] = ['en', 'fi', 'sv'];
|
||||
const SUPPORTED_LOCALES: Locale[] = ["en", "fi", "sv"];
|
||||
|
||||
function loadApiKey() {
|
||||
if (process.env.OPENAI_TRANSLATIONS_KEY) return process.env.OPENAI_TRANSLATIONS_KEY;
|
||||
const newKeyPath = path.join(process.cwd(), 'creds', 'openai-translations.key');
|
||||
if (process.env.OPENAI_TRANSLATIONS_KEY)
|
||||
return process.env.OPENAI_TRANSLATIONS_KEY;
|
||||
const newKeyPath = path.join(
|
||||
process.cwd(),
|
||||
"creds",
|
||||
"openai-translations.key",
|
||||
);
|
||||
try {
|
||||
return fs.readFileSync(newKeyPath, 'utf8').trim();
|
||||
return fs.readFileSync(newKeyPath, "utf8").trim();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
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 {
|
||||
return fs.readFileSync(fallbackPath, 'utf8').trim();
|
||||
return fs.readFileSync(fallbackPath, "utf8").trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -28,34 +33,42 @@ export async function POST(req: Request) {
|
|||
try {
|
||||
await requireAuth(req);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = loadApiKey();
|
||||
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;
|
||||
try {
|
||||
body = await req.json();
|
||||
} 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 currentLocale = (body?.currentLocale as Locale) ?? 'en';
|
||||
const incoming = body?.translations as
|
||||
| Record<Locale, LocaleFields>
|
||||
| undefined;
|
||||
const currentLocale = (body?.currentLocale as Locale) ?? "en";
|
||||
if (!incoming) {
|
||||
return NextResponse.json({ error: 'Missing translations' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "Missing translations" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = SUPPORTED_LOCALES.reduce(
|
||||
(acc, loc) => ({
|
||||
...acc,
|
||||
[loc]: {
|
||||
title: incoming[loc]?.title || '',
|
||||
teaser: incoming[loc]?.teaser || '',
|
||||
description: incoming[loc]?.description || '',
|
||||
title: incoming[loc]?.title || "",
|
||||
teaser: incoming[loc]?.teaser || "",
|
||||
description: incoming[loc]?.description || "",
|
||||
},
|
||||
}),
|
||||
{} as Record<Locale, LocaleFields>,
|
||||
|
|
@ -63,12 +76,12 @@ export async function POST(req: Request) {
|
|||
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
role: "system",
|
||||
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(
|
||||
{
|
||||
sourceLocale: currentLocale,
|
||||
|
|
@ -81,22 +94,22 @@ export async function POST(req: Request) {
|
|||
),
|
||||
},
|
||||
{
|
||||
role: 'system',
|
||||
role: "system",
|
||||
content:
|
||||
'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 {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
model: "gpt-4o-mini",
|
||||
temperature: 0.2,
|
||||
messages,
|
||||
}),
|
||||
|
|
@ -104,33 +117,42 @@ export async function POST(req: Request) {
|
|||
|
||||
if (!res.ok) {
|
||||
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();
|
||||
content = data?.choices?.[0]?.message?.content ?? '';
|
||||
content = data?.choices?.[0]?.message?.content ?? "";
|
||||
} 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;
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
const locales = parsed?.locales || parsed;
|
||||
if (!locales) throw new Error('missing locales');
|
||||
if (!locales) throw new Error("missing locales");
|
||||
const result = SUPPORTED_LOCALES.reduce(
|
||||
(acc, loc) => ({
|
||||
...acc,
|
||||
[loc]: {
|
||||
title: locales[loc]?.title ?? '',
|
||||
teaser: locales[loc]?.teaser ?? '',
|
||||
description: locales[loc]?.description ?? '',
|
||||
title: locales[loc]?.title ?? "",
|
||||
teaser: locales[loc]?.teaser ?? "",
|
||||
description: locales[loc]?.description ?? "",
|
||||
},
|
||||
}),
|
||||
{} as Record<Locale, LocaleFields>,
|
||||
);
|
||||
return NextResponse.json({ translations: result });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Could not parse AI response' }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{ error: "Could not parse AI response" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { UserStatus } from '@prisma/client';
|
||||
import { prisma } from '../../../../lib/prisma';
|
||||
import { requireAuth } from '../../../../lib/jwt';
|
||||
import { normalizeIban, normalizeNullableBoolean, normalizeOptionalString } from '../../../../lib/billing';
|
||||
import { NextResponse } from "next/server";
|
||||
import { UserStatus } from "@prisma/client";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { requireAuth } from "../../../../lib/jwt";
|
||||
import {
|
||||
normalizeIban,
|
||||
normalizeNullableBoolean,
|
||||
normalizeOptionalString,
|
||||
} from "../../../../lib/billing";
|
||||
|
||||
function pickTranslation(translations: { title: string; slug: string; locale: string }[]) {
|
||||
return translations.find((t) => t.locale === 'en') || translations.find((t) => t.locale === 'fi') || translations[0] || null;
|
||||
function pickTranslation(
|
||||
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) {
|
||||
|
|
@ -30,7 +41,7 @@ async function loadState(userId: string) {
|
|||
billingIncludeVatLine: true,
|
||||
translations: { select: { title: true, slug: true, locale: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
return { user, listings };
|
||||
|
|
@ -38,31 +49,43 @@ async function loadState(userId: string) {
|
|||
|
||||
function validateAccountName(name: string | null | undefined) {
|
||||
if (name && name.length > 120) {
|
||||
return 'Account owner name is too long';
|
||||
return "Account owner name is too long";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateIban(iban: string | null | undefined) {
|
||||
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;
|
||||
}
|
||||
|
||||
function normalizeListingOverrides(body: any) {
|
||||
type OverrideInput = { id: string | null; accountName: string | null | undefined; iban: string | null | undefined; includeVatLine: boolean | null | undefined };
|
||||
const overridesRaw: any[] = Array.isArray(body?.listings) ? body.listings : [];
|
||||
type OverrideInput = {
|
||||
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) => ({
|
||||
id: typeof item.id === 'string' ? item.id : null,
|
||||
id: typeof item.id === "string" ? item.id : null,
|
||||
accountName: normalizeOptionalString(item.accountName),
|
||||
iban: normalizeIban(item.iban),
|
||||
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 {
|
||||
settings: {
|
||||
enabled: user.billingEmailsEnabled,
|
||||
|
|
@ -74,9 +97,9 @@ function buildResponsePayload(user: NonNullable<Awaited<ReturnType<typeof loadSt
|
|||
const translation = pickTranslation(listing.translations);
|
||||
return {
|
||||
id: listing.id,
|
||||
title: translation?.title ?? 'Listing',
|
||||
slug: translation?.slug ?? '',
|
||||
locale: translation?.locale ?? 'en',
|
||||
title: translation?.title ?? "Listing",
|
||||
slug: translation?.slug ?? "",
|
||||
locale: translation?.locale ?? "en",
|
||||
billingAccountName: listing.billingAccountName,
|
||||
billingIban: listing.billingIban,
|
||||
billingIncludeVatLine: listing.billingIncludeVatLine,
|
||||
|
|
@ -90,12 +113,15 @@ export async function GET(req: Request) {
|
|||
const session = await requireAuth(req);
|
||||
const { user, listings } = await loadState(session.userId);
|
||||
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));
|
||||
} catch (error) {
|
||||
console.error('Billing settings fetch failed', error);
|
||||
return NextResponse.json({ error: 'Failed to load billing settings' }, { status: 500 });
|
||||
console.error("Billing settings fetch failed", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load billing settings" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,23 +130,33 @@ export async function PATCH(req: Request) {
|
|||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await requireAuth(req);
|
||||
const { user, listings } = await loadState(session.userId);
|
||||
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 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 errors = [validateAccountName(accountName), validateIban(iban)].filter(Boolean) as string[];
|
||||
const errors = [
|
||||
validateAccountName(accountName),
|
||||
validateIban(iban),
|
||||
].filter(Boolean) as string[];
|
||||
for (const override of listingOverrides) {
|
||||
const nameError = validateAccountName(override.accountName);
|
||||
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 (errors.length) {
|
||||
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||
return NextResponse.json({ error: errors.join("; ") }, { status: 400 });
|
||||
}
|
||||
|
||||
const userUpdates: any = {};
|
||||
if (enabled !== undefined) userUpdates.billingEmailsEnabled = enabled;
|
||||
if (accountName !== undefined) userUpdates.billingAccountName = accountName;
|
||||
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 listingUpdates: { id: string; data: Record<string, any> }[] = [];
|
||||
listingOverrides.forEach((override: { id: string; accountName: string | null | undefined; iban: string | null | undefined; includeVatLine: boolean | null | undefined }) => {
|
||||
if (!listingMap.has(override.id!)) return;
|
||||
const data: Record<string, any> = {};
|
||||
if (override.accountName !== undefined) data.billingAccountName = override.accountName;
|
||||
if (override.iban !== undefined) data.billingIban = override.iban;
|
||||
if (override.includeVatLine !== undefined) data.billingIncludeVatLine = override.includeVatLine;
|
||||
if (Object.keys(data).length) {
|
||||
listingUpdates.push({ id: override.id!, data });
|
||||
}
|
||||
});
|
||||
listingOverrides.forEach(
|
||||
(override: {
|
||||
id: string;
|
||||
accountName: string | null | undefined;
|
||||
iban: string | null | undefined;
|
||||
includeVatLine: boolean | null | undefined;
|
||||
}) => {
|
||||
if (!listingMap.has(override.id!)) return;
|
||||
const data: Record<string, any> = {};
|
||||
if (override.accountName !== undefined)
|
||||
data.billingAccountName = override.accountName;
|
||||
if (override.iban !== undefined) data.billingIban = override.iban;
|
||||
if (override.includeVatLine !== undefined)
|
||||
data.billingIncludeVatLine = override.includeVatLine;
|
||||
if (Object.keys(data).length) {
|
||||
listingUpdates.push({ id: override.id!, data });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const targetEnabled = enabled ?? user.billingEmailsEnabled;
|
||||
if (targetEnabled) {
|
||||
const targetAccountName = accountName !== undefined ? accountName : user.billingAccountName;
|
||||
const targetAccountName =
|
||||
accountName !== undefined ? accountName : user.billingAccountName;
|
||||
const targetIban = iban !== undefined ? iban : user.billingIban;
|
||||
const hasGlobalDetails = Boolean(targetAccountName && targetIban);
|
||||
|
||||
|
|
@ -162,9 +209,11 @@ export async function PATCH(req: Request) {
|
|||
const effectiveAccountName =
|
||||
override?.data.billingAccountName !== undefined
|
||||
? override.data.billingAccountName
|
||||
: listing.billingAccountName ?? targetAccountName;
|
||||
: (listing.billingAccountName ?? targetAccountName);
|
||||
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) {
|
||||
const t = pickTranslation(listing.translations);
|
||||
missingFor.push(t?.slug || listing.id);
|
||||
|
|
@ -173,13 +222,18 @@ export async function PATCH(req: Request) {
|
|||
|
||||
if (!hasGlobalDetails && missingFor.length) {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
if (!hasGlobalDetails && listings.length === 0) {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -187,29 +241,43 @@ export async function PATCH(req: Request) {
|
|||
|
||||
const tx: any[] = [];
|
||||
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) => {
|
||||
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) {
|
||||
await prisma.$transaction(tx);
|
||||
} 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) {
|
||||
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) {
|
||||
console.error('Billing settings update failed', error);
|
||||
const status = (error as any)?.message === 'Unauthorized' ? 401 : 500;
|
||||
return NextResponse.json({ error: 'Failed to update billing settings' }, { status });
|
||||
console.error("Billing settings update failed", error);
|
||||
const status = (error as any)?.message === "Unauthorized" ? 401 : 500;
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update billing settings" },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,19 +1,28 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '../../../lib/prisma';
|
||||
import { requireAuth } from '../../../lib/jwt';
|
||||
import { hashPassword } from '../../../lib/auth';
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { requireAuth } from "../../../lib/jwt";
|
||||
import { hashPassword } from "../../../lib/auth";
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const session = await requireAuth(req);
|
||||
const body = await req.json();
|
||||
|
||||
const name = body.name !== undefined && body.name !== null ? String(body.name).trim() : undefined;
|
||||
const phone = body.phone !== undefined && body.phone !== null ? String(body.phone).trim() : undefined;
|
||||
const name =
|
||||
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;
|
||||
|
||||
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 = {};
|
||||
|
|
@ -21,7 +30,10 @@ export async function PATCH(req: Request) {
|
|||
if (phone !== undefined) data.phone = phone || null;
|
||||
if (password) {
|
||||
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);
|
||||
}
|
||||
|
|
@ -29,13 +41,25 @@ export async function PATCH(req: Request) {
|
|||
const user = await prisma.user.update({
|
||||
where: { id: session.userId },
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error('Profile update error', error);
|
||||
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
|
||||
console.error("Profile update error", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update profile" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useI18n } from '../../components/I18nProvider';
|
||||
import { useState } from "react";
|
||||
import { useI18n } from "../../components/I18nProvider";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useI18n();
|
||||
const [email, setEmail] = useState('');
|
||||
const [email, setEmail] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -16,39 +16,49 @@ export default function ForgotPasswordPage() {
|
|||
setSent(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/forgot', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/auth/forgot", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error || t('forgotError'));
|
||||
setError(data.error || t("forgotError"));
|
||||
} else {
|
||||
setSent(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('forgotError'));
|
||||
setError(t("forgotError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 480, margin: '40px auto' }}>
|
||||
<h1>{t('forgotTitle')}</h1>
|
||||
<p style={{ color: '#cbd5e1', marginTop: 6 }}>{t('forgotLead')}</p>
|
||||
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 12, marginTop: 14 }}>
|
||||
<main className="panel" style={{ maxWidth: 480, margin: "40px auto" }}>
|
||||
<h1>{t("forgotTitle")}</h1>
|
||||
<p style={{ color: "#cbd5e1", marginTop: 6 }}>{t("forgotLead")}</p>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
style={{ display: "grid", gap: 12, marginTop: 14 }}
|
||||
>
|
||||
<label>
|
||||
{t('emailLabel')}
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
{t("emailLabel")}
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="button" type="submit" disabled={loading}>
|
||||
{loading ? t('submittingListing') : t('forgotSubmit')}
|
||||
{loading ? t("submittingListing") : t("forgotSubmit")}
|
||||
</button>
|
||||
</form>
|
||||
{sent ? <p style={{ marginTop: 12, color: 'green' }}>{t('forgotSuccess')}</p> : null}
|
||||
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
|
||||
{sent ? (
|
||||
<p style={{ marginTop: 12, color: "green" }}>{t("forgotSuccess")}</p>
|
||||
) : null}
|
||||
{error ? <p style={{ marginTop: 12, color: "red" }}>{error}</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useI18n } from '../../components/I18nProvider';
|
||||
import { useState } from "react";
|
||||
import { useI18n } from "../../components/I18nProvider";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useI18n();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -17,54 +17,66 @@ export default function LoginPage() {
|
|||
setSuccess(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Login failed');
|
||||
setError(data.error || "Login failed");
|
||||
} else {
|
||||
try {
|
||||
setSuccess(true);
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
localStorage.setItem("auth_token", data.token);
|
||||
document.cookie = `auth_token=${data.token}; path=/; SameSite=Lax`;
|
||||
window.location.href = '/';
|
||||
window.location.href = "/";
|
||||
} catch (err) {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Login failed');
|
||||
setError("Login failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 480, margin: '40px auto' }}>
|
||||
<h1>{t('loginTitle')}</h1>
|
||||
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 12 }}>
|
||||
<main className="panel" style={{ maxWidth: 480, margin: "40px auto" }}>
|
||||
<h1>{t("loginTitle")}</h1>
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: 12 }}>
|
||||
<label>
|
||||
{t('emailLabel')}
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
{t("emailLabel")}
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('passwordLabel')}
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
{t("passwordLabel")}
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="button" type="submit" disabled={loading}>
|
||||
{loading ? t('loggingIn') : t('loginButton')}
|
||||
{loading ? t("loggingIn") : t("loginButton")}
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ marginTop: 12 }}>
|
||||
<a href="/auth/forgot" className="button secondary">
|
||||
{t('forgotCta')}
|
||||
{t("forgotCta")}
|
||||
</a>
|
||||
</p>
|
||||
{success ? <p style={{ marginTop: 12, color: 'green' }}>{t('loginSuccess')}</p> : null}
|
||||
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
|
||||
{success ? (
|
||||
<p style={{ marginTop: 12, color: "green" }}>{t("loginSuccess")}</p>
|
||||
) : null}
|
||||
{error ? <p style={{ marginTop: 12, color: "red" }}>{error}</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
/* eslint-disable react/no-unescaped-entities */
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useI18n } from '../../components/I18nProvider';
|
||||
import { useState } from "react";
|
||||
import { useI18n } from "../../components/I18nProvider";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { t } = useI18n();
|
||||
const [email, setEmail] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -19,49 +19,66 @@ export default function RegisterPage() {
|
|||
setMessage(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, name, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Registration failed');
|
||||
setError(data.error || "Registration failed");
|
||||
} else {
|
||||
setMessage(t('registerSuccess'));
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setMessage(t("registerSuccess"));
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Registration failed');
|
||||
setError("Registration failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 480, margin: '40px auto' }}>
|
||||
<h1>{t('registerTitle')}</h1>
|
||||
<p style={{ marginBottom: 16 }}>{t('registerLead')}</p>
|
||||
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 12 }}>
|
||||
<main className="panel" style={{ maxWidth: 480, margin: "40px auto" }}>
|
||||
<h1>{t("registerTitle")}</h1>
|
||||
<p style={{ marginBottom: 16 }}>{t("registerLead")}</p>
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: 12 }}>
|
||||
<label>
|
||||
{t('emailLabel')}
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
{t("emailLabel")}
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('nameOptional')}
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
{t("nameOptional")}
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('passwordHint')}
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} />
|
||||
{t("passwordHint")}
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
<button className="button" type="submit" disabled={loading}>
|
||||
{loading ? t('registering') : t('registerButton')}
|
||||
{loading ? t("registering") : t("registerButton")}
|
||||
</button>
|
||||
</form>
|
||||
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null}
|
||||
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
|
||||
{message ? (
|
||||
<p style={{ marginTop: 12, color: "green" }}>{message}</p>
|
||||
) : null}
|
||||
{error ? <p style={{ marginTop: 12, color: "red" }}>{error}</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useI18n } from '../../components/I18nProvider';
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useI18n } from "../../components/I18nProvider";
|
||||
|
||||
function ResetForm() {
|
||||
const { t } = useI18n();
|
||||
const searchParams = useSearchParams();
|
||||
const [password, setPassword] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [password, setPassword] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const tok = searchParams.get('token') || '';
|
||||
const tok = searchParams.get("token") || "";
|
||||
setToken(tok);
|
||||
}, [searchParams]);
|
||||
|
||||
|
|
@ -23,53 +23,74 @@ function ResetForm() {
|
|||
setMessage(null);
|
||||
setError(null);
|
||||
if (!token) {
|
||||
setError(t('resetMissingToken'));
|
||||
setError(t("resetMissingToken"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/auth/reset", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || t('resetError'));
|
||||
setError(data.error || t("resetError"));
|
||||
} else {
|
||||
setMessage(t('resetSuccess'));
|
||||
setPassword('');
|
||||
setMessage(t("resetSuccess"));
|
||||
setPassword("");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('resetError'));
|
||||
setError(t("resetError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 480, margin: '40px auto' }}>
|
||||
<h1>{t('resetTitle')}</h1>
|
||||
<p style={{ color: '#cbd5e1', marginTop: 6 }}>{t('resetLead')}</p>
|
||||
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 12, marginTop: 14 }}>
|
||||
<main className="panel" style={{ maxWidth: 480, margin: "40px auto" }}>
|
||||
<h1>{t("resetTitle")}</h1>
|
||||
<p style={{ color: "#cbd5e1", marginTop: 6 }}>{t("resetLead")}</p>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
style={{ display: "grid", gap: 12, marginTop: 14 }}
|
||||
>
|
||||
<label>
|
||||
{t('passwordLabel')}
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} required />
|
||||
{t("passwordLabel")}
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="button" type="submit" disabled={loading}>
|
||||
{loading ? t('saving') : t('resetSubmit')}
|
||||
{loading ? t("saving") : t("resetSubmit")}
|
||||
</button>
|
||||
</form>
|
||||
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null}
|
||||
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
|
||||
{!token ? <p style={{ marginTop: 12, color: '#f59e0b' }}>{t('resetMissingToken')}</p> : null}
|
||||
{message ? (
|
||||
<p style={{ marginTop: 12, color: "green" }}>{message}</p>
|
||||
) : null}
|
||||
{error ? <p style={{ marginTop: 12, color: "red" }}>{error}</p> : null}
|
||||
{!token ? (
|
||||
<p style={{ marginTop: 12, color: "#f59e0b" }}>
|
||||
{t("resetMissingToken")}
|
||||
</p>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
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 />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from './I18nProvider';
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "./I18nProvider";
|
||||
|
||||
type MonthView = {
|
||||
label: string;
|
||||
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 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);
|
||||
const year = monthDate.getUTCFullYear();
|
||||
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 startWeekday = firstDay.getUTCDay(); // 0=Sun
|
||||
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) {
|
||||
days.push({ label: '', date: '', blocked: false, isFiller: true });
|
||||
days.push({ label: "", date: "", blocked: false, isFiller: true });
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d += 1) {
|
||||
const date = new Date(Date.UTC(year, month, d));
|
||||
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 });
|
||||
|
|
@ -41,7 +54,13 @@ function buildMonths(monthCount: number, blocked: Set<string>, startYear: number
|
|||
|
||||
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 today = useMemo(() => new Date(), []);
|
||||
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 monthCount = 1;
|
||||
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(() => {
|
||||
if (!hasCalendar) return;
|
||||
|
|
@ -62,20 +84,28 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi
|
|||
month: String(month),
|
||||
year: String(year),
|
||||
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) => {
|
||||
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;
|
||||
})
|
||||
.then((data) => {
|
||||
setBlockedDates(Array.isArray(data.blockedDates) ? data.blockedDates : []);
|
||||
setBlockedDates(
|
||||
Array.isArray(data.blockedDates) ? data.blockedDates : [],
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return;
|
||||
setError(err.message || 'Failed to load availability');
|
||||
if (err.name === "AbortError") return;
|
||||
setError(err.message || "Failed to load availability");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
|
|
@ -93,7 +123,9 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi
|
|||
() =>
|
||||
Array.from({ length: 12 }, (_, 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],
|
||||
);
|
||||
|
|
@ -103,33 +135,88 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi
|
|||
}, [today]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12, opacity: hasCalendar ? 1 : 0.5 }}>
|
||||
<div style={{ 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 style={{ display: "grid", gap: 12, opacity: hasCalendar ? 1 : 0.5 }}>
|
||||
<div
|
||||
style={{
|
||||
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 style={{ 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' }}>
|
||||
<div
|
||||
style={{
|
||||
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 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>
|
||||
</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) => (
|
||||
<div key={monthView.label} className="panel" style={{ padding: 12 }}>
|
||||
<div 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 }}>
|
||||
<div
|
||||
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) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</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) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
|
|
@ -139,15 +226,15 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi
|
|||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d) => (
|
||||
<div key={d} style={{ color: '#94a3b8', fontWeight: 600 }}>
|
||||
{["S", "M", "T", "W", "T", "F", "S"].map((d) => (
|
||||
<div key={d} style={{ color: "#94a3b8", fontWeight: 600 }}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -157,13 +244,25 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi
|
|||
style={{
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: day.isFiller ? 'transparent' : day.blocked ? 'rgba(248,113,113,0.2)' : 'rgba(148,163,184,0.1)',
|
||||
color: day.isFiller ? 'transparent' : day.blocked ? '#ef4444' : '#e2e8f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: day.isFiller
|
||||
? "transparent"
|
||||
: day.blocked
|
||||
? "rgba(248,113,113,0.2)"
|
||||
: "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}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { Locale, MessageKey, resolveLocale, t as translate } from '../../lib/i18n';
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Locale,
|
||||
MessageKey,
|
||||
resolveLocale,
|
||||
t as translate,
|
||||
} from "../../lib/i18n";
|
||||
|
||||
type I18nContextValue = {
|
||||
locale: Locale;
|
||||
|
|
@ -13,14 +18,18 @@ const I18nContext = createContext<I18nContextValue | null>(null);
|
|||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [locale, setLocale] = useState<Locale>(() => {
|
||||
if (typeof window === 'undefined') return 'en';
|
||||
const stored = localStorage.getItem('locale');
|
||||
if (stored === 'fi' || stored === 'en' || stored === 'sv') return stored as Locale;
|
||||
return resolveLocale({ cookieLocale: null, acceptLanguage: navigator.language ?? navigator.languages?.[0] ?? null });
|
||||
if (typeof window === "undefined") return "en";
|
||||
const stored = localStorage.getItem("locale");
|
||||
if (stored === "fi" || stored === "en" || stored === "sv")
|
||||
return stored as Locale;
|
||||
return resolveLocale({
|
||||
cookieLocale: null,
|
||||
acceptLanguage: navigator.language ?? navigator.languages?.[0] ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('locale', locale);
|
||||
localStorage.setItem("locale", locale);
|
||||
document.cookie = `locale=${locale}; path=/; max-age=${60 * 60 * 24 * 365};`;
|
||||
}, [locale]);
|
||||
|
||||
|
|
@ -28,7 +37,8 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
|||
() => ({
|
||||
locale,
|
||||
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],
|
||||
);
|
||||
|
|
@ -39,7 +49,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
|||
export function useI18n() {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useI18n must be used inside I18nProvider');
|
||||
throw new Error("useI18n must be used inside I18nProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useRef, useState, type SVGProps } from 'react';
|
||||
import { useI18n } from './I18nProvider';
|
||||
import logo from '../../img/logo.png';
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState, type SVGProps } from "react";
|
||||
import { useI18n } from "./I18nProvider";
|
||||
import logo from "../../img/logo.png";
|
||||
|
||||
type SessionUser = { id: string; email: string; role: string; status: string };
|
||||
|
||||
|
|
@ -12,21 +12,21 @@ function Icon({ name }: { name: string }) {
|
|||
const common: SVGProps<SVGSVGElement> = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
stroke: 'currentColor',
|
||||
fill: 'none',
|
||||
stroke: "currentColor",
|
||||
fill: "none",
|
||||
strokeWidth: 1.6,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
};
|
||||
switch (name) {
|
||||
case 'profile':
|
||||
case "profile":
|
||||
return (
|
||||
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M5 20c0-3.3 3.1-6 7-6s7 2.7 7 6" />
|
||||
</svg>
|
||||
);
|
||||
case 'list':
|
||||
case "list":
|
||||
return (
|
||||
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M9 6h11" />
|
||||
|
|
@ -37,14 +37,14 @@ function Icon({ name }: { name: string }) {
|
|||
<path d="M4 18h0.01" />
|
||||
</svg>
|
||||
);
|
||||
case 'plus':
|
||||
case "plus":
|
||||
return (
|
||||
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M12 5v14" />
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
case 'logout':
|
||||
case "logout":
|
||||
return (
|
||||
<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" />
|
||||
|
|
@ -52,7 +52,7 @@ function Icon({ name }: { name: string }) {
|
|||
<path d="M21 12H9" />
|
||||
</svg>
|
||||
);
|
||||
case 'login':
|
||||
case "login":
|
||||
return (
|
||||
<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" />
|
||||
|
|
@ -60,7 +60,7 @@ function Icon({ name }: { name: string }) {
|
|||
<path d="M15 12H3" />
|
||||
</svg>
|
||||
);
|
||||
case 'users':
|
||||
case "users":
|
||||
return (
|
||||
<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" />
|
||||
|
|
@ -69,13 +69,13 @@ function Icon({ name }: { name: string }) {
|
|||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
);
|
||||
case 'check':
|
||||
case "check":
|
||||
return (
|
||||
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
case 'globe':
|
||||
case "globe":
|
||||
return (
|
||||
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
||||
<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" />
|
||||
</svg>
|
||||
);
|
||||
case 'monitor':
|
||||
case "monitor":
|
||||
return (
|
||||
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
||||
<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" />
|
||||
</svg>
|
||||
);
|
||||
case 'settings':
|
||||
case "settings":
|
||||
return (
|
||||
<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="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>
|
||||
);
|
||||
case 'admin':
|
||||
case "admin":
|
||||
return (
|
||||
<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="M9.5 12.5l1.8 1.8 3.7-3.7" />
|
||||
</svg>
|
||||
);
|
||||
case 'chevron-down':
|
||||
case "chevron-down":
|
||||
return (
|
||||
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
|
|
@ -127,7 +127,7 @@ export default function NavBar() {
|
|||
|
||||
async function loadUser() {
|
||||
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();
|
||||
if (data.user) setUser(data.user);
|
||||
else setUser(null);
|
||||
|
|
@ -149,31 +149,34 @@ export default function NavBar() {
|
|||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
setPendingCount(0);
|
||||
return;
|
||||
}
|
||||
fetch('/api/admin/pending/count', { cache: 'no-store' })
|
||||
fetch("/api/admin/pending/count", { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data && typeof data.total === 'number') setPendingCount(data.total);
|
||||
if (data && typeof data.total === "number") setPendingCount(data.total);
|
||||
})
|
||||
.catch(() => setPendingCount(0));
|
||||
}, [user]);
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
const isListingMod = user?.role === 'LISTING_MODERATOR';
|
||||
const isUserMod = user?.role === 'USER_MODERATOR';
|
||||
const isAdmin = user?.role === "ADMIN";
|
||||
const isListingMod = user?.role === "LISTING_MODERATOR";
|
||||
const isUserMod = user?.role === "USER_MODERATOR";
|
||||
const showApprovals = Boolean(user && (isAdmin || isListingMod || isUserMod));
|
||||
const showAdminMenu = Boolean(user && (showApprovals || isAdmin));
|
||||
|
||||
|
|
@ -181,37 +184,54 @@ export default function NavBar() {
|
|||
if (!userMenuOpen && !adminMenuOpen) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
const insideAdmin = adminMenuRef.current && target && adminMenuRef.current.contains(target);
|
||||
const insideUser = userMenuRef.current && target && userMenuRef.current.contains(target);
|
||||
const insideAdmin =
|
||||
adminMenuRef.current && target && adminMenuRef.current.contains(target);
|
||||
const insideUser =
|
||||
userMenuRef.current && target && userMenuRef.current.contains(target);
|
||||
if (!insideAdmin) setAdminMenuOpen(false);
|
||||
if (!insideUser) setUserMenuOpen(false);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === "Escape") {
|
||||
setAdminMenuOpen(false);
|
||||
setUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [adminMenuOpen, userMenuOpen]);
|
||||
|
||||
return (
|
||||
<header style={{ padding: '12px 20px', borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<header
|
||||
style={{
|
||||
padding: "12px 20px",
|
||||
borderBottom: "1px solid #eee",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<Link href="/" className="brand">
|
||||
<Image src={logo} alt="Lomavuokraus.fi logo" width={34} height={48} priority style={{ width: 34, height: 'auto' }} />
|
||||
<span className="brand-text">{t('brand')}</span>
|
||||
<Image
|
||||
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 href="/listings" className="button secondary">
|
||||
<Icon name="list" /> {t('navBrowse')}
|
||||
<Icon name="list" /> {t("navBrowse")}
|
||||
</Link>
|
||||
</div>
|
||||
<nav style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<nav style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{user ? (
|
||||
<>
|
||||
<div className="nav-admin" ref={userMenuRef}>
|
||||
|
|
@ -221,24 +241,49 @@ export default function NavBar() {
|
|||
aria-haspopup="menu"
|
||||
aria-expanded={userMenuOpen}
|
||||
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={{ fontSize: 12, opacity: 0.7 }}>{user.role}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.7 }}>
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
<Icon name="chevron-down" />
|
||||
</button>
|
||||
{userMenuOpen ? (
|
||||
<div className="nav-admin-menu" 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')}
|
||||
<div
|
||||
className="nav-admin-menu"
|
||||
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 href="/listings/mine" className="nav-admin-item button secondary" role="menuitem" onClick={() => setUserMenuOpen(false)}>
|
||||
<Icon name="list" /> {t('navMyListings')}
|
||||
<Link
|
||||
href="/listings/mine"
|
||||
className="nav-admin-item button secondary"
|
||||
role="menuitem"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
>
|
||||
<Icon name="list" /> {t("navMyListings")}
|
||||
</Link>
|
||||
<Link href="/listings/new" className="nav-admin-item button secondary" role="menuitem" onClick={() => setUserMenuOpen(false)}>
|
||||
<Icon name="plus" /> {t('navNewListing')}
|
||||
<Link
|
||||
href="/listings/new"
|
||||
className="nav-admin-item button secondary"
|
||||
role="menuitem"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
>
|
||||
<Icon name="plus" /> {t("navNewListing")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -249,7 +294,7 @@ export default function NavBar() {
|
|||
logout();
|
||||
}}
|
||||
>
|
||||
<Icon name="logout" /> {t('navLogout')}
|
||||
<Icon name="logout" /> {t("navLogout")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -262,41 +307,79 @@ export default function NavBar() {
|
|||
aria-haspopup="menu"
|
||||
aria-expanded={adminMenuOpen}
|
||||
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 ? (
|
||||
<span className="nav-admin-badge" aria-label={t('approvalsPending', { count: pendingCount })}>
|
||||
{t('approvalsBadge', { count: pendingCount })}
|
||||
<span
|
||||
className="nav-admin-badge"
|
||||
aria-label={t("approvalsPending", {
|
||||
count: pendingCount,
|
||||
})}
|
||||
>
|
||||
{t("approvalsBadge", { count: pendingCount })}
|
||||
</span>
|
||||
) : null}
|
||||
<Icon name="chevron-down" />
|
||||
</button>
|
||||
{adminMenuOpen ? (
|
||||
<div className="nav-admin-menu" role="menu" aria-label={t('navAdmin')}>
|
||||
<div
|
||||
className="nav-admin-menu"
|
||||
role="menu"
|
||||
aria-label={t("navAdmin")}
|
||||
>
|
||||
{showApprovals ? (
|
||||
<Link href="/admin/pending" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}>
|
||||
<Icon name="check" /> {t('navApprovals')}
|
||||
<Link
|
||||
href="/admin/pending"
|
||||
className="nav-admin-item button secondary"
|
||||
role="menuitem"
|
||||
onClick={() => setAdminMenuOpen(false)}
|
||||
>
|
||||
<Icon name="check" /> {t("navApprovals")}
|
||||
{pendingCount > 0 ? (
|
||||
<span className="nav-admin-badge" aria-label={t('approvalsPending', { count: pendingCount })}>
|
||||
{t('approvalsBadge', { count: pendingCount })}
|
||||
<span
|
||||
className="nav-admin-badge"
|
||||
aria-label={t("approvalsPending", {
|
||||
count: pendingCount,
|
||||
})}
|
||||
>
|
||||
{t("approvalsBadge", { count: pendingCount })}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link href="/admin/users" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}>
|
||||
<Icon name="users" /> {t('navUsers')}
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="nav-admin-item button secondary"
|
||||
role="menuitem"
|
||||
onClick={() => setAdminMenuOpen(false)}
|
||||
>
|
||||
<Icon name="users" /> {t("navUsers")}
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link href="/admin/monitor" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}>
|
||||
<Icon name="monitor" /> {t('navMonitoring')}
|
||||
<Link
|
||||
href="/admin/monitor"
|
||||
className="nav-admin-item button secondary"
|
||||
role="menuitem"
|
||||
onClick={() => setAdminMenuOpen(false)}
|
||||
>
|
||||
<Icon name="monitor" /> {t("navMonitoring")}
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link href="/admin/settings" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}>
|
||||
<Icon name="settings" /> {t('navSettings')}
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="nav-admin-item button secondary"
|
||||
role="menuitem"
|
||||
onClick={() => setAdminMenuOpen(false)}
|
||||
>
|
||||
<Icon name="settings" /> {t("navSettings")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -307,16 +390,16 @@ export default function NavBar() {
|
|||
) : (
|
||||
<>
|
||||
<Link href="/auth/login" className="button secondary">
|
||||
<Icon name="login" /> {t('navLogin')}
|
||||
<Icon name="login" /> {t("navLogin")}
|
||||
</Link>
|
||||
<Link href="/auth/register" className="button">
|
||||
<Icon name="plus" /> {t('navSignup')}
|
||||
<Icon name="plus" /> {t("navSignup")}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<label className="language-wrapper">
|
||||
<select
|
||||
aria-label={t('navLanguage')}
|
||||
aria-label={t("navLanguage")}
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as any)}
|
||||
className="language-select"
|
||||
|
|
|
|||
|
|
@ -1,25 +1,33 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useI18n } from './I18nProvider';
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "./I18nProvider";
|
||||
|
||||
export default function SiteFooter() {
|
||||
const { t } = useI18n();
|
||||
const version = process.env.NEXT_PUBLIC_VERSION || 'dev';
|
||||
const version = process.env.NEXT_PUBLIC_VERSION || "dev";
|
||||
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="footer-row">
|
||||
<span className="footer-text" style={{ 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
|
||||
className="footer-text"
|
||||
style={{
|
||||
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 className="footer-text">
|
||||
Version <code>{version}</code>
|
||||
</span>
|
||||
</div>
|
||||
<p className="footer-cookie">{t('footerCookieNotice')}</p>
|
||||
<p className="footer-cookie">{t("footerCookieNotice")}</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
--bg: #0f172a;
|
||||
|
|
@ -16,9 +16,18 @@
|
|||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Space Grotesk', 'Helvetica Neue', sans-serif;
|
||||
background: radial-gradient(circle at 20% 20%, rgba(34, 211, 238, 0.08), transparent 30%),
|
||||
radial-gradient(circle at 80% 0%, rgba(14, 165, 233, 0.12), transparent 35%),
|
||||
font-family: "Space Grotesk", "Helvetica Neue", sans-serif;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 20% 20%,
|
||||
rgba(34, 211, 238, 0.08),
|
||||
transparent 30%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 0%,
|
||||
rgba(14, 165, 233, 0.12),
|
||||
transparent 35%
|
||||
),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
|
|
@ -129,7 +138,10 @@ p {
|
|||
text-decoration: none;
|
||||
background: var(--accent);
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +258,11 @@ p {
|
|||
}
|
||||
|
||||
.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 {
|
||||
|
|
@ -269,7 +285,9 @@ p {
|
|||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.4);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, background 120ms ease;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
background 120ms ease;
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
|
|
@ -298,7 +316,11 @@ p {
|
|||
inset: 0;
|
||||
display: grid;
|
||||
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);
|
||||
z-index: 1;
|
||||
}
|
||||
|
|
@ -308,7 +330,10 @@ p {
|
|||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
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 {
|
||||
|
|
@ -386,9 +411,17 @@ p {
|
|||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.04));
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.02),
|
||||
rgba(255, 255, 255, 0.04)
|
||||
);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease, background 120ms ease;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
transform 120ms ease,
|
||||
background 120ms ease;
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
|
@ -402,7 +435,11 @@ p {
|
|||
.amenity-option.selected {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 12px 36px rgba(14, 165, 233, 0.2);
|
||||
background: linear-gradient(145deg, rgba(34, 211, 238, 0.08), rgba(14, 165, 233, 0.12));
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(34, 211, 238, 0.08),
|
||||
rgba(14, 165, 233, 0.12)
|
||||
);
|
||||
}
|
||||
|
||||
.amenity-option-meta {
|
||||
|
|
@ -462,7 +499,10 @@ p {
|
|||
color: var(--text);
|
||||
cursor: pointer;
|
||||
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 {
|
||||
|
|
@ -537,7 +577,7 @@ code {
|
|||
background: rgba(148, 163, 184, 0.1);
|
||||
padding: 3px 6px;
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
||||
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
.env-card {
|
||||
|
|
@ -563,7 +603,9 @@ textarea {
|
|||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
select {
|
||||
|
|
@ -577,13 +619,18 @@ select {
|
|||
color: var(--text);
|
||||
font-size: 15px;
|
||||
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;
|
||||
-moz-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%);
|
||||
background-position: right 12px center, right 6px center;
|
||||
background-position:
|
||||
right 12px center,
|
||||
right 6px center;
|
||||
background-size: 7px 7px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import NavBar from './components/NavBar';
|
||||
import { I18nProvider } from './components/I18nProvider';
|
||||
import SiteFooter from './components/SiteFooter';
|
||||
import type { Metadata } from "next/dist/types";
|
||||
import "./globals.css";
|
||||
import NavBar from "./components/NavBar";
|
||||
import { I18nProvider } from "./components/I18nProvider";
|
||||
import SiteFooter from "./components/SiteFooter";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Lomavuokraus.fi',
|
||||
description: 'Modern vacation rentals in Finland.',
|
||||
title: "Lomavuokraus.fi",
|
||||
description: "Modern vacation rentals in Finland.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
|
|||
|
|
@ -1,53 +1,69 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { ListingStatus } from '@prisma/client';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { getListingBySlug, DEFAULT_LOCALE, withResolvedListingImages } from '../../../lib/listings';
|
||||
import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing';
|
||||
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';
|
||||
import type { Metadata } from "next/dist/types";
|
||||
import { ListingStatus } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import {
|
||||
getListingBySlug,
|
||||
DEFAULT_LOCALE,
|
||||
withResolvedListingImages,
|
||||
} from "../../../lib/listings";
|
||||
import { SAMPLE_LISTING_SLUGS } from "../../../lib/sampleListing";
|
||||
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 = {
|
||||
params: { slug: string };
|
||||
};
|
||||
|
||||
const amenityIcons: Record<string, string> = {
|
||||
sauna: '🧖',
|
||||
fireplace: '🔥',
|
||||
wifi: '📶',
|
||||
pets: '🐾',
|
||||
lake: '🌊',
|
||||
ac: '❄️',
|
||||
ev: '⚡',
|
||||
evOnSite: '🔌',
|
||||
kitchen: '🍽️',
|
||||
dishwasher: '🧼',
|
||||
washer: '🧺',
|
||||
barbecue: '🍖',
|
||||
microwave: '🍲',
|
||||
parking: '🅿️',
|
||||
accessible: '♿',
|
||||
ski: '⛷️',
|
||||
sauna: "🧖",
|
||||
fireplace: "🔥",
|
||||
wifi: "📶",
|
||||
pets: "🐾",
|
||||
lake: "🌊",
|
||||
ac: "❄️",
|
||||
ev: "⚡",
|
||||
evOnSite: "🔌",
|
||||
kitchen: "🍽️",
|
||||
dishwasher: "🧼",
|
||||
washer: "🧺",
|
||||
barbecue: "🍖",
|
||||
microwave: "🍲",
|
||||
parking: "🅿️",
|
||||
accessible: "♿",
|
||||
ski: "⛷️",
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: ListingPageProps): Promise<Metadata> {
|
||||
const translation = await getListingBySlug({ slug: params.slug, locale: DEFAULT_LOCALE });
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: ListingPageProps): Promise<Metadata> {
|
||||
const translation = await getListingBySlug({
|
||||
slug: params.slug,
|
||||
locale: DEFAULT_LOCALE,
|
||||
});
|
||||
|
||||
return {
|
||||
title: translation ? `${translation.title} | Lomavuokraus.fi` : `${params.slug} | Lomavuokraus.fi`,
|
||||
title: translation
|
||||
? `${translation.title} | Lomavuokraus.fi`
|
||||
: `${params.slug} | Lomavuokraus.fi`,
|
||||
description: translation?.teaser ?? translation?.description?.slice(0, 140),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ListingPage({ params }: ListingPageProps) {
|
||||
const cookieStore = cookies();
|
||||
const locale = resolveLocale({ cookieLocale: cookieStore.get('locale')?.value, acceptLanguage: headers().get('accept-language') });
|
||||
const t = (key: any, vars?: Record<string, string | number>) => translate(locale, key as any, vars);
|
||||
const sessionToken = cookieStore.get('session_token')?.value;
|
||||
const cookieStore = await cookies();
|
||||
const headerList = await headers();
|
||||
const locale = resolveLocale({
|
||||
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;
|
||||
if (sessionToken) {
|
||||
try {
|
||||
|
|
@ -65,98 +81,184 @@ export default async function ListingPage({ params }: ListingPageProps) {
|
|||
locale: locale ?? DEFAULT_LOCALE,
|
||||
includeOwnerDraftsForUserId: viewerId ?? undefined,
|
||||
});
|
||||
const translation = translationRaw ? withResolvedListingImages(translationRaw) : null;
|
||||
const translation = translationRaw
|
||||
? withResolvedListingImages(translationRaw)
|
||||
: null;
|
||||
|
||||
if (!translation) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { listing, title, description, teaser, locale: translationLocale } = translation;
|
||||
const isSample = listing.isSample || listing.contactEmail === 'host@lomavuokraus.fi' || SAMPLE_LISTING_SLUGS.includes(params.slug);
|
||||
const {
|
||||
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) => {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const hasCalendar = calendarUrls.length > 0;
|
||||
const amenities = [
|
||||
listing.hasSauna ? { icon: amenityIcons.sauna, label: t('amenitySauna') } : null,
|
||||
listing.hasFireplace ? { icon: amenityIcons.fireplace, label: t('amenityFireplace') } : null,
|
||||
listing.hasWifi ? { icon: amenityIcons.wifi, label: t('amenityWifi') } : null,
|
||||
listing.petsAllowed ? { icon: amenityIcons.pets, label: t('amenityPets') } : null,
|
||||
listing.byTheLake ? { icon: amenityIcons.lake, label: t('amenityLake') } : 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,
|
||||
listing.hasSauna
|
||||
? { icon: amenityIcons.sauna, label: t("amenitySauna") }
|
||||
: null,
|
||||
listing.hasFireplace
|
||||
? { icon: amenityIcons.fireplace, label: t("amenityFireplace") }
|
||||
: null,
|
||||
listing.hasWifi
|
||||
? { icon: amenityIcons.wifi, label: t("amenityWifi") }
|
||||
: null,
|
||||
listing.petsAllowed
|
||||
? { icon: amenityIcons.pets, label: t("amenityPets") }
|
||||
: null,
|
||||
listing.byTheLake
|
||||
? { icon: amenityIcons.lake, label: t("amenityLake") }
|
||||
: 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 }[];
|
||||
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 = [
|
||||
listing.maxGuests ? t('capacityGuests', { count: listing.maxGuests }) : 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,
|
||||
listing.maxGuests
|
||||
? t("capacityGuests", { count: listing.maxGuests })
|
||||
: 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[];
|
||||
const capacityLine = capacityParts.length ? capacityParts.join(' · ') : t('capacityUnknown');
|
||||
const contactParts = [listing.contactName, listing.contactEmail, listing.contactPhone].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 capacityLine = capacityParts.length
|
||||
? capacityParts.join(" · ")
|
||||
: t("capacityUnknown");
|
||||
const contactParts = [
|
||||
listing.contactName,
|
||||
listing.contactEmail,
|
||||
listing.contactPhone,
|
||||
].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 =
|
||||
listing.priceWeekdayEuros || listing.priceWeekendEuros
|
||||
? `${startingFromEuros !== null ? t('priceStartingFromShort', { price: startingFromEuros }) : ''}${
|
||||
? `${startingFromEuros !== null ? t("priceStartingFromShort", { price: startingFromEuros }) : ""}${
|
||||
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)
|
||||
.join(' · ')})`
|
||||
: ''
|
||||
.join(" · ")})`
|
||||
: ""
|
||||
}`
|
||||
: t('priceNotSet');
|
||||
: t("priceNotSet");
|
||||
const isDraftOrPending = listing.status !== ListingStatus.PUBLISHED;
|
||||
const isOwnerView = viewerId && listing.ownerId === viewerId;
|
||||
|
||||
return (
|
||||
<main className="listing-shell">
|
||||
<div className="breadcrumb">
|
||||
<Link href="/">{t('homeCrumb')}</Link> / <span>{params.slug}</span>
|
||||
<Link href="/">{t("homeCrumb")}</Link> / <span>{params.slug}</span>
|
||||
</div>
|
||||
<div className="listing-layout">
|
||||
<div className="panel listing-main">
|
||||
{isDraftOrPending ? (
|
||||
<div className="badge warning" style={{ marginBottom: 10, display: 'inline-block' }}>
|
||||
{isOwnerView ? t('statusLabel') : 'Status'}: {listing.status}
|
||||
<div
|
||||
className="badge warning"
|
||||
style={{ marginBottom: 10, display: "inline-block" }}
|
||||
>
|
||||
{isOwnerView ? t("statusLabel") : "Status"}: {listing.status}
|
||||
</div>
|
||||
) : null}
|
||||
{isSample ? (
|
||||
<div className="badge warning" style={{ marginBottom: 10, display: 'inline-block' }}>
|
||||
{t('sampleBadge')}
|
||||
<div
|
||||
className="badge warning"
|
||||
style={{ marginBottom: 10, display: "inline-block" }}
|
||||
>
|
||||
{t("sampleBadge")}
|
||||
</div>
|
||||
) : null}
|
||||
<h1>{title}</h1>
|
||||
<p style={{ marginTop: 8 }}>{teaser ?? description}</p>
|
||||
{listing.addressNote ? (
|
||||
<div style={{ marginTop: 4, color: '#cbd5e1' }}>
|
||||
<div style={{ marginTop: 4, color: "#cbd5e1" }}>
|
||||
<em>{listing.addressNote}</em>
|
||||
</div>
|
||||
) : null}
|
||||
{listing.externalUrl ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<a href={listing.externalUrl} target="_blank" rel="noreferrer" className="button secondary">
|
||||
{t('listingMoreInfo')}
|
||||
<a
|
||||
href={listing.externalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="button secondary"
|
||||
>
|
||||
{t("listingMoreInfo")}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -164,44 +266,60 @@ export default async function ListingPage({ params }: ListingPageProps) {
|
|||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
display: 'grid',
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
gridTemplateColumns: 'minmax(240px, 1.4fr) minmax(240px, 1fr)',
|
||||
alignItems: 'stretch',
|
||||
gridTemplateColumns: "minmax(240px, 1.4fr) minmax(240px, 1fr)",
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
<div className="panel" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div className="panel" style={{ padding: 0, overflow: "hidden" }}>
|
||||
{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
|
||||
src={coverImage.url || ''}
|
||||
src={coverImage.url || ""}
|
||||
alt={coverImage.altText ?? title}
|
||||
style={{ width: '100%', height: 280, objectFit: 'cover' }}
|
||||
style={{ width: "100%", height: 280, objectFit: "cover" }}
|
||||
/>
|
||||
</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 className="panel" style={{ padding: 12 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<AvailabilityCalendar listingId={listing.id} hasCalendar={hasCalendar} />
|
||||
<div style={{ position: "relative" }}>
|
||||
<AvailabilityCalendar
|
||||
listingId={listing.id}
|
||||
hasCalendar={hasCalendar}
|
||||
/>
|
||||
{!hasCalendar ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#cbd5e1',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#cbd5e1",
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
background: 'linear-gradient(135deg, rgba(15,23,42,0.55), rgba(15,23,42,0.65))',
|
||||
textAlign: "center",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(15,23,42,0.55), rgba(15,23,42,0.65))",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
{t('availabilityMissing')}
|
||||
{t("availabilityMissing")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -209,72 +327,126 @@ export default async function ListingPage({ params }: ListingPageProps) {
|
|||
</div>
|
||||
)}
|
||||
{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
|
||||
.filter((img) => Boolean(img.url))
|
||||
.map((img) => (
|
||||
<figure key={img.id} style={{ border: '1px solid rgba(148, 163, 184, 0.25)', borderRadius: 12, overflow: 'hidden', background: 'rgba(255,255,255,0.03)' }}>
|
||||
<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' }} />
|
||||
<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)",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 16, fontSize: 14, color: '#666' }}>
|
||||
{t('localeLabel')}: <code>{translationLocale}</code>
|
||||
<div style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
|
||||
{t("localeLabel")}: <code>{translationLocale}</code>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="panel listing-aside">
|
||||
<div className="fact-row">
|
||||
<span aria-hidden className="amenity-icon">📍</span>
|
||||
<span aria-hidden className="amenity-icon">
|
||||
📍
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingLocation')}</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: 12 }}>
|
||||
{t("listingLocation")}
|
||||
</div>
|
||||
<div>{addressLine}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fact-row">
|
||||
<span aria-hidden className="amenity-icon">👥</span>
|
||||
<span aria-hidden className="amenity-icon">
|
||||
👥
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingCapacity')}</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: 12 }}>
|
||||
{t("listingCapacity")}
|
||||
</div>
|
||||
<div>{capacityLine}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fact-row">
|
||||
<span aria-hidden className="amenity-icon">💶</span>
|
||||
<span aria-hidden className="amenity-icon">
|
||||
💶
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingPrices')}</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: 12 }}>
|
||||
{t("listingPrices")}
|
||||
</div>
|
||||
<div>{priceLine}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fact-row">
|
||||
<span aria-hidden className="amenity-icon">✉️</span>
|
||||
<span aria-hidden className="amenity-icon">
|
||||
✉️
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingContact')}</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: 12 }}>
|
||||
{t("listingContact")}
|
||||
</div>
|
||||
{canViewContact ? (
|
||||
<div>{contactLine}</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Link href={loginRedirectUrl} className="button secondary">
|
||||
{t('contactLoginToView')}
|
||||
{t("contactLoginToView")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="fact-row">
|
||||
<span aria-hidden className="amenity-icon">📅</span>
|
||||
<span aria-hidden className="amenity-icon">
|
||||
📅
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('searchAvailability')}</div>
|
||||
<div>{hasCalendar ? t('calendarConnected') : t('availabilityMissing')}</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: 12 }}>
|
||||
{t("searchAvailability")}
|
||||
</div>
|
||||
<div>
|
||||
{hasCalendar
|
||||
? t("calendarConnected")
|
||||
: t("availabilityMissing")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 ? (
|
||||
<div className="amenity-row" style={{ borderStyle: 'dashed' }}>
|
||||
<div className="amenity-row" style={{ borderStyle: "dashed" }}>
|
||||
<span className="amenity-icon">…</span>
|
||||
<span>{t('listingNoAmenities')}</span>
|
||||
<span>{t("listingNoAmenities")}</span>
|
||||
</div>
|
||||
) : (
|
||||
amenities.map((item) => (
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
|||
'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 Link from "next/link";
|
||||
import { useI18n } from "../../components/I18nProvider";
|
||||
|
||||
type MyListing = {
|
||||
id: string;
|
||||
|
|
@ -19,7 +19,7 @@ export default function MyListingsPage() {
|
|||
const [actionId, setActionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/listings/mine', { cache: 'no-store' })
|
||||
fetch("/api/listings/mine", { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
|
|
@ -28,30 +28,34 @@ export default function MyListingsPage() {
|
|||
setListings(data.listings ?? []);
|
||||
}
|
||||
})
|
||||
.catch(() => setError('Failed to load'))
|
||||
.catch(() => setError("Failed to load"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function removeListing(listingId: string) {
|
||||
if (!window.confirm(t('removeConfirm'))) return;
|
||||
if (!window.confirm(t("removeConfirm"))) return;
|
||||
setActionId(listingId);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/listings/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/listings/remove", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ listingId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to remove listing');
|
||||
setError(data.error || "Failed to remove listing");
|
||||
} else {
|
||||
setMessage(t('removed'));
|
||||
setListings((prev) => prev.map((l) => (l.id === listingId ? { ...l, status: 'REMOVED' } : l)));
|
||||
setMessage(t("removed"));
|
||||
setListings((prev) =>
|
||||
prev.map((l) =>
|
||||
l.id === listingId ? { ...l, status: "REMOVED" } : l,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to remove listing');
|
||||
setError("Failed to remove listing");
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
|
|
@ -59,60 +63,73 @@ export default function MyListingsPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 720, margin: '40px auto' }}>
|
||||
<h1>{t('myListingsTitle')}</h1>
|
||||
<p>{t('loading')}</p>
|
||||
<main className="panel" style={{ maxWidth: 720, margin: "40px auto" }}>
|
||||
<h1>{t("myListingsTitle")}</h1>
|
||||
<p>{t("loading")}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 720, margin: '40px auto' }}>
|
||||
<h1>{t('myListingsTitle')}</h1>
|
||||
<p style={{ color: 'red' }}>{error}</p>
|
||||
<main className="panel" style={{ maxWidth: 720, margin: "40px auto" }}>
|
||||
<h1>{t("myListingsTitle")}</h1>
|
||||
<p style={{ color: "red" }}>{error}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 720, margin: '40px auto' }}>
|
||||
<h1>{t('myListingsTitle')}</h1>
|
||||
{message ? <p style={{ color: 'green' }}>{message}</p> : null}
|
||||
<main className="panel" style={{ maxWidth: 720, margin: "40px auto" }}>
|
||||
<h1>{t("myListingsTitle")}</h1>
|
||||
{message ? <p style={{ color: "green" }}>{message}</p> : null}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Link href="/listings/new" className="button secondary">
|
||||
{t('createNewListing')}
|
||||
{t("createNewListing")}
|
||||
</Link>
|
||||
</div>
|
||||
{listings.length === 0 ? (
|
||||
<p>
|
||||
{t('noListings')}{' '}
|
||||
<Link href="/listings/new">
|
||||
{t('createOne')}
|
||||
</Link>
|
||||
.
|
||||
{t("noListings")} <Link href="/listings/new">{t("createOne")}</Link>.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, display: 'grid', gap: 10 }}>
|
||||
<ul style={{ listStyle: "none", padding: 0, display: "grid", gap: 10 }}>
|
||||
{listings.map((l) => (
|
||||
<li key={l.id} style={{ border: '1px solid #ddd', borderRadius: 8, padding: 12 }}>
|
||||
<li
|
||||
key={l.id}
|
||||
style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12 }}
|
||||
>
|
||||
<div>
|
||||
<strong>{l.translations[0]?.title ?? 'Listing'}</strong> — {t('statusLabel')}: {l.status}
|
||||
<strong>{l.translations[0]?.title ?? "Listing"}</strong> —{" "}
|
||||
{t("statusLabel")}: {l.status}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666' }}>
|
||||
{t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')}
|
||||
<div style={{ fontSize: 12, color: "#666" }}>
|
||||
{t("slugsLabel")}:{" "}
|
||||
{l.translations
|
||||
.map((t) => `${t.slug} (${t.locale})`)
|
||||
.join(", ")}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<Link href={`/listings/edit/${l.id}`} className="button secondary">
|
||||
{t('edit')}
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
|
||||
<Link
|
||||
href={`/listings/edit/${l.id}`}
|
||||
className="button secondary"
|
||||
>
|
||||
{t("edit")}
|
||||
</Link>
|
||||
{l.status !== 'DRAFT' ? (
|
||||
<Link href={`/listings/${l.translations[0]?.slug ?? ''}`} className="button secondary">
|
||||
{t('view')}
|
||||
{l.status !== "DRAFT" ? (
|
||||
<Link
|
||||
href={`/listings/${l.translations[0]?.slug ?? ""}`}
|
||||
className="button secondary"
|
||||
>
|
||||
{t("view")}
|
||||
</Link>
|
||||
) : null}
|
||||
<button className="button secondary" onClick={() => removeListing(l.id)} disabled={actionId === l.id}>
|
||||
{actionId === l.id ? t('removing') : t('remove')}
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => removeListing(l.id)}
|
||||
disabled={actionId === l.id}
|
||||
>
|
||||
{actionId === l.id ? t("removing") : t("remove")}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,9 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../components/I18nProvider';
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../components/I18nProvider";
|
||||
|
||||
type ListingResult = {
|
||||
id: string;
|
||||
|
|
@ -61,41 +61,42 @@ function haversineKm(a: LatLng, b: LatLng) {
|
|||
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> {
|
||||
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;
|
||||
const linkId = 'leaflet-css';
|
||||
const linkId = "leaflet-css";
|
||||
if (!document.getElementById(linkId)) {
|
||||
const link = document.createElement('link');
|
||||
const link = document.createElement("link");
|
||||
link.id = linkId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
const mod: LeafletLib = await import('leaflet');
|
||||
const mod: LeafletLib = await import("leaflet");
|
||||
(window as any).L = mod;
|
||||
return mod;
|
||||
}
|
||||
|
||||
const amenityIcons: Record<string, string> = {
|
||||
sauna: '🧖',
|
||||
fireplace: '🔥',
|
||||
wifi: '📶',
|
||||
pets: '🐾',
|
||||
lake: '🌊',
|
||||
ac: '❄️',
|
||||
kitchen: '🍽️',
|
||||
dishwasher: '🧼',
|
||||
washer: '🧺',
|
||||
barbecue: '🍖',
|
||||
microwave: '🍲',
|
||||
parking: '🅿️',
|
||||
accessible: '♿',
|
||||
ski: '⛷️',
|
||||
ev: '⚡',
|
||||
evOnSite: '🔌',
|
||||
sauna: "🧖",
|
||||
fireplace: "🔥",
|
||||
wifi: "📶",
|
||||
pets: "🐾",
|
||||
lake: "🌊",
|
||||
ac: "❄️",
|
||||
kitchen: "🍽️",
|
||||
dishwasher: "🧼",
|
||||
washer: "🧺",
|
||||
barbecue: "🍖",
|
||||
microwave: "🍲",
|
||||
parking: "🅿️",
|
||||
accessible: "♿",
|
||||
ski: "⛷️",
|
||||
ev: "⚡",
|
||||
evOnSite: "🔌",
|
||||
};
|
||||
|
||||
function ListingsMap({
|
||||
|
|
@ -126,9 +127,12 @@ function ListingsMap({
|
|||
setMapError(null);
|
||||
if (!mapContainerRef.current) return;
|
||||
if (!mapRef.current) {
|
||||
mapRef.current = L.map(mapContainerRef.current).setView([64.5, 26], 5);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
mapRef.current = L.map(mapContainerRef.current).setView(
|
||||
[64.5, 26],
|
||||
5,
|
||||
);
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
maxZoom: 18,
|
||||
}).addTo(mapRef.current);
|
||||
}
|
||||
|
|
@ -139,26 +143,32 @@ function ListingsMap({
|
|||
listings
|
||||
.filter((l) => l.latitude !== null && l.longitude !== null)
|
||||
.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.on('click', () => onSelect(l.id));
|
||||
marker.on("click", () => onSelect(l.id));
|
||||
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) {
|
||||
mapRef.current.setView([center.lat, center.lon], 8);
|
||||
} else if (withCoords.length && mapRef.current) {
|
||||
const group = L.featureGroup(
|
||||
withCoords.map((l) => L.marker([l.latitude as number, l.longitude as number]))
|
||||
withCoords.map((l) =>
|
||||
L.marker([l.latitude as number, l.longitude as number]),
|
||||
),
|
||||
);
|
||||
mapRef.current.fitBounds(group.getBounds().pad(0.25));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Leaflet load failed', err);
|
||||
console.error("Leaflet load failed", err);
|
||||
setReady(false);
|
||||
setMapError('Map could not be loaded right now.');
|
||||
setMapError("Map could not be loaded right now.");
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
|
@ -178,27 +188,27 @@ function ListingsMap({
|
|||
<div className="map-frame">
|
||||
{!ready ? <div className="map-placeholder">{loadingText}</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ListingsIndexPage() {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [region, setRegion] = useState('');
|
||||
const [query, setQuery] = useState("");
|
||||
const [city, setCity] = useState("");
|
||||
const [region, setRegion] = useState("");
|
||||
const [listings, setListings] = useState<ListingResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [addressQuery, setAddressQuery] = useState('');
|
||||
const [addressQuery, setAddressQuery] = useState("");
|
||||
const [addressCenter, setAddressCenter] = useState<LatLng | null>(null);
|
||||
const [radiusKm, setRadiusKm] = useState(50);
|
||||
const [geocoding, setGeocoding] = useState(false);
|
||||
const [geoError, setGeoError] = useState<string | null>(null);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [amenities, setAmenities] = useState<string[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
|
|
@ -206,7 +216,10 @@ export default function ListingsIndexPage() {
|
|||
if (!addressCenter) return listings;
|
||||
return listings.filter((l) => {
|
||||
if (l.latitude === null || l.longitude === null) return false;
|
||||
const d = haversineKm(addressCenter, { lat: l.latitude, lon: l.longitude });
|
||||
const d = haversineKm(addressCenter, {
|
||||
lat: l.latitude,
|
||||
lon: l.longitude,
|
||||
});
|
||||
return d <= radiusKm;
|
||||
});
|
||||
}, [listings, addressCenter, radiusKm]);
|
||||
|
|
@ -214,22 +227,54 @@ export default function ListingsIndexPage() {
|
|||
const filtered = filteredByAddress;
|
||||
|
||||
const amenityOptions = [
|
||||
{ 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: 'pets', label: t('amenityPets'), icon: amenityIcons.pets },
|
||||
{ key: 'lake', label: t('amenityLake'), icon: amenityIcons.lake },
|
||||
{ key: 'ac', label: t('amenityAirConditioning'), icon: amenityIcons.ac },
|
||||
{ key: 'kitchen', label: t('amenityKitchen'), icon: amenityIcons.kitchen },
|
||||
{ key: 'dishwasher', label: t('amenityDishwasher'), icon: amenityIcons.dishwasher },
|
||||
{ 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 },
|
||||
{ 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: "pets", label: t("amenityPets"), icon: amenityIcons.pets },
|
||||
{ key: "lake", label: t("amenityLake"), icon: amenityIcons.lake },
|
||||
{ key: "ac", label: t("amenityAirConditioning"), icon: amenityIcons.ac },
|
||||
{ key: "kitchen", label: t("amenityKitchen"), icon: amenityIcons.kitchen },
|
||||
{
|
||||
key: "dishwasher",
|
||||
label: t("amenityDishwasher"),
|
||||
icon: amenityIcons.dishwasher,
|
||||
},
|
||||
{
|
||||
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() {
|
||||
|
|
@ -237,32 +282,36 @@ export default function ListingsIndexPage() {
|
|||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set('q', query);
|
||||
if (city) params.set('city', city);
|
||||
if (region) params.set('region', region);
|
||||
if (startDate) params.set('availableStart', startDate);
|
||||
if (endDate) params.set('availableEnd', endDate);
|
||||
const evSelected = amenities.includes('ev');
|
||||
if (evSelected) params.set('evCharging', 'true');
|
||||
if (query) params.set("q", query);
|
||||
if (city) params.set("city", city);
|
||||
if (region) params.set("region", region);
|
||||
if (startDate) params.set("availableStart", startDate);
|
||||
if (endDate) params.set("availableEnd", endDate);
|
||||
const evSelected = amenities.includes("ev");
|
||||
if (evSelected) params.set("evCharging", "true");
|
||||
amenities
|
||||
.filter((a) => a !== 'ev')
|
||||
.forEach((a) => params.append('amenity', a));
|
||||
const res = await fetch(`/api/listings?${params.toString()}`, { cache: 'no-store' });
|
||||
.filter((a) => a !== "ev")
|
||||
.forEach((a) => params.append("amenity", a));
|
||||
const res = await fetch(`/api/listings?${params.toString()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) {
|
||||
throw new Error(data.error || 'Failed to load listings');
|
||||
throw new Error(data.error || "Failed to load listings");
|
||||
}
|
||||
setListings(data.listings ?? []);
|
||||
setSelectedId(data.listings?.[0]?.id ?? null);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to load listings');
|
||||
setError(e.message || "Failed to load listings");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
|
|
@ -271,17 +320,20 @@ export default function ListingsIndexPage() {
|
|||
setGeoError(null);
|
||||
try {
|
||||
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();
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
const hit = data[0];
|
||||
setAddressCenter({ lat: parseFloat(hit.lat), lon: parseFloat(hit.lon) });
|
||||
setAddressCenter({
|
||||
lat: parseFloat(hit.lat),
|
||||
lon: parseFloat(hit.lon),
|
||||
});
|
||||
} else {
|
||||
setGeoError(t('addressNotFound'));
|
||||
setGeoError(t("addressNotFound"));
|
||||
}
|
||||
} catch (e) {
|
||||
setGeoError(t('addressLookupFailed'));
|
||||
setGeoError(t("addressLookupFailed"));
|
||||
} finally {
|
||||
setGeocoding(false);
|
||||
}
|
||||
|
|
@ -292,13 +344,15 @@ export default function ListingsIndexPage() {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const countLabel = t('listingsFound', { count: filtered.length });
|
||||
const countLabel = t("listingsFound", { count: filtered.length });
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
const el = document.querySelector<HTMLElement>(`[data-listing-id="${selectedId}"]`);
|
||||
const el = document.querySelector<HTMLElement>(
|
||||
`[data-listing-id="${selectedId}"]`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, [selectedId]);
|
||||
|
||||
|
|
@ -306,47 +360,76 @@ export default function ListingsIndexPage() {
|
|||
<main>
|
||||
<section className="panel">
|
||||
<div className="breadcrumb">
|
||||
<Link href="/">{t('homeCrumb')}</Link> / <span>{t('navBrowse')}</span>
|
||||
<Link href="/">{t("homeCrumb")}</Link> / <span>{t("navBrowse")}</span>
|
||||
</div>
|
||||
<h1>{t('browseListingsTitle')}</h1>
|
||||
<p style={{ marginTop: 8 }}>{t('browseListingsLead')}</p>
|
||||
<h1>{t("browseListingsTitle")}</h1>
|
||||
<p style={{ marginTop: 8 }}>{t("browseListingsLead")}</p>
|
||||
<div className="search-grid" style={{ marginTop: 16 }}>
|
||||
<label>
|
||||
{t('searchLabel')}
|
||||
{t("searchLabel")}
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('cityFilter')}
|
||||
<input value={city} onChange={(e) => setCity(e.target.value)} placeholder={t('cityFilter')} />
|
||||
</label>
|
||||
<label>
|
||||
{t('regionFilter')}
|
||||
<input value={region} onChange={(e) => setRegion(e.target.value)} placeholder={t('regionFilter')} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', marginTop: 12 }}>
|
||||
<label>
|
||||
{t('startDate')}
|
||||
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||
{t("cityFilter")}
|
||||
<input
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder={t("cityFilter")}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('endDate')}
|
||||
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||
{t("regionFilter")}
|
||||
<input
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
placeholder={t("regionFilter")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: '#cbd5e1', fontSize: 13 }}>{t('availabilityOnlyWithCalendar')}</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 10,
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
{t("startDate")}
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("endDate")}
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: "#cbd5e1", fontSize: 13 }}>
|
||||
{t("availabilityOnlyWithCalendar")}
|
||||
</div>
|
||||
<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) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
className={`amenity-option ${amenities.includes(opt.key) ? 'selected' : ''}`}
|
||||
className={`amenity-option ${amenities.includes(opt.key) ? "selected" : ""}`}
|
||||
aria-pressed={amenities.includes(opt.key)}
|
||||
onClick={() => toggleAmenity(opt.key)}
|
||||
>
|
||||
|
|
@ -357,51 +440,68 @@ export default function ListingsIndexPage() {
|
|||
<span className="amenity-name">{opt.label}</span>
|
||||
</div>
|
||||
<span className="amenity-check" aria-hidden>
|
||||
{amenities.includes(opt.key) ? '✓' : ''}
|
||||
{amenities.includes(opt.key) ? "✓" : ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</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}>
|
||||
{loading ? t('loading') : t('searchButton')}
|
||||
{loading ? t("loading") : t("searchButton")}
|
||||
</button>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
setCity('');
|
||||
setRegion('');
|
||||
setQuery("");
|
||||
setCity("");
|
||||
setRegion("");
|
||||
setAddressCenter(null);
|
||||
setAddressQuery('');
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setAddressQuery("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setAmenities([]);
|
||||
}}
|
||||
>
|
||||
{t('clearFilters')}
|
||||
{t("clearFilters")}
|
||||
</button>
|
||||
<span style={{ alignSelf: 'center', color: '#cbd5e1' }}>{countLabel}</span>
|
||||
<span style={{ alignSelf: "center", color: "#cbd5e1" }}>
|
||||
{countLabel}
|
||||
</span>
|
||||
</div>
|
||||
{error ? <p style={{ marginTop: 8, color: '#ef4444' }}>{error}</p> : null}
|
||||
{error ? (
|
||||
<p style={{ marginTop: 8, color: "#ef4444" }}>{error}</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="map-grid" style={{ marginTop: 18 }}>
|
||||
<div className="panel">
|
||||
<div style={{ display: 'grid', gap: 10, marginBottom: 12 }}>
|
||||
<div style={{ display: "grid", gap: 10, marginBottom: 12 }}>
|
||||
<label>
|
||||
{t('addressSearchLabel')}
|
||||
{t("addressSearchLabel")}
|
||||
<input
|
||||
value={addressQuery}
|
||||
onChange={(e) => setAddressQuery(e.target.value)}
|
||||
placeholder={t('addressSearchPlaceholder')}
|
||||
placeholder={t("addressSearchPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button className="button secondary" onClick={locateAddress} disabled={geocoding}>
|
||||
{geocoding ? t('loading') : t('locateAddress')}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={locateAddress}
|
||||
disabled={geocoding}
|
||||
>
|
||||
{geocoding ? t("loading") : t("locateAddress")}
|
||||
</button>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
|
|
@ -411,102 +511,191 @@ export default function ListingsIndexPage() {
|
|||
onChange={(e) => setRadiusKm(Number(e.target.value))}
|
||||
disabled={!addressCenter}
|
||||
/>
|
||||
<span style={{ color: '#cbd5e1' }}>{t('addressRadiusLabel', { km: radiusKm })}</span>
|
||||
<span style={{ color: "#cbd5e1" }}>
|
||||
{t("addressRadiusLabel", { km: radiusKm })}
|
||||
</span>
|
||||
</label>
|
||||
{addressCenter ? (
|
||||
<button className="button secondary" onClick={() => setAddressCenter(null)}>
|
||||
{t('clearFilters')}
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => setAddressCenter(null)}
|
||||
>
|
||||
{t("clearFilters")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{geoError ? <p style={{ color: '#ef4444' }}>{geoError}</p> : null}
|
||||
{geoError ? <p style={{ color: "#ef4444" }}>{geoError}</p> : null}
|
||||
</div>
|
||||
<ListingsMap
|
||||
listings={filtered}
|
||||
center={addressCenter}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
loadingText={t('loadingMap')}
|
||||
loadingText={t("loadingMap")}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
{addressCenter ? (
|
||||
<span className="badge">{t('addressRadiusLabel', { km: radiusKm })}</span>
|
||||
<span className="badge">
|
||||
{t("addressRadiusLabel", { km: radiusKm })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<p>{t('mapNoResults')}</p>
|
||||
<p>{t("mapNoResults")}</p>
|
||||
) : (
|
||||
<div className="results-grid" ref={scrollRef}>
|
||||
{filtered.map((l) => (
|
||||
<article
|
||||
key={l.id}
|
||||
className={`listing-card ${selectedId === l.id ? 'active' : ''}`}
|
||||
className={`listing-card ${selectedId === l.id ? "active" : ""}`}
|
||||
data-listing-id={l.id}
|
||||
onMouseEnter={() => setSelectedId(l.id)}
|
||||
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 ? (
|
||||
<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
|
||||
style={{
|
||||
height: 140,
|
||||
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>
|
||||
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}>
|
||||
<div style={{ display: "grid", gap: 6, marginTop: 8 }}>
|
||||
<h3 style={{ margin: 0 }}>{l.title}</h3>
|
||||
{l.isSample ? (
|
||||
<span className="badge warning" style={{ width: 'fit-content' }}>
|
||||
{t('sampleBadge')}
|
||||
<span
|
||||
className="badge warning"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
{t("sampleBadge")}
|
||||
</span>
|
||||
) : null}
|
||||
<p style={{ margin: 0 }}>{l.teaser ?? ''}</p>
|
||||
<p style={{ margin: 0 }}>{l.teaser ?? ""}</p>
|
||||
{l.priceWeekdayEuros || l.priceWeekendEuros ? (
|
||||
<div style={{ color: '#cbd5e1', fontSize: 14 }}>
|
||||
{t('priceStartingFromShort', {
|
||||
price: Math.min(...([l.priceWeekdayEuros, l.priceWeekendEuros].filter((p): p is number => typeof p === 'number'))),
|
||||
<div style={{ color: "#cbd5e1", fontSize: 14 }}>
|
||||
{t("priceStartingFromShort", {
|
||||
price: Math.min(
|
||||
...[
|
||||
l.priceWeekdayEuros,
|
||||
l.priceWeekendEuros,
|
||||
].filter((p): p is number => typeof p === "number"),
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ color: '#cbd5e1', fontSize: 14 }}>
|
||||
{l.streetAddress ? `${l.streetAddress}, ` : ''}
|
||||
<div style={{ color: "#cbd5e1", fontSize: 14 }}>
|
||||
{l.streetAddress ? `${l.streetAddress}, ` : ""}
|
||||
{l.city}, {l.region}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', fontSize: 13 }}>
|
||||
<span className="badge">{t('capacityGuests', { count: l.maxGuests })}</span>
|
||||
<span className="badge">{t('capacityBedrooms', { count: l.bedrooms })}</span>
|
||||
{l.hasCalendar ? <span className="badge secondary">{t('calendarConnected')}</span> : null}
|
||||
{startDate && endDate && l.availableForDates ? (
|
||||
<span className="badge">{t('availableForDates')}</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
flexWrap: "wrap",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<span className="badge">
|
||||
{t("capacityGuests", { count: l.maxGuests })}
|
||||
</span>
|
||||
<span className="badge">
|
||||
{t("capacityBedrooms", { count: l.bedrooms })}
|
||||
</span>
|
||||
{l.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}
|
||||
{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 style={{ display: 'flex', gap: 8, marginTop: 6 }}>
|
||||
<Link className="button secondary" href={`/listings/${l.slug}`}>
|
||||
{t('openListing')}
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 6 }}>
|
||||
<Link
|
||||
className="button secondary"
|
||||
href={`/listings/${l.slug}`}
|
||||
>
|
||||
{t("openListing")}
|
||||
</Link>
|
||||
<button className="button secondary" onClick={() => setSelectedId(l.id)}>
|
||||
{t('locateAddress')}
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => setSelectedId(l.id)}
|
||||
>
|
||||
{t("locateAddress")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
429
app/me/page.tsx
429
app/me/page.tsx
|
|
@ -1,9 +1,19 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useI18n } from '../components/I18nProvider';
|
||||
import Link from "next/link";
|
||||
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 = {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -18,14 +28,14 @@ export default function ProfilePage() {
|
|||
const { t } = useI18n();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [billingEnabled, setBillingEnabled] = useState(false);
|
||||
const [billingAccountName, setBillingAccountName] = useState('');
|
||||
const [billingIban, setBillingIban] = useState('');
|
||||
const [billingAccountName, setBillingAccountName] = useState("");
|
||||
const [billingIban, setBillingIban] = useState("");
|
||||
const [billingIncludeVatLine, setBillingIncludeVatLine] = useState(false);
|
||||
const [billingListings, setBillingListings] = useState<BillingListing[]>([]);
|
||||
const [billingLoading, setBillingLoading] = useState(false);
|
||||
|
|
@ -34,30 +44,30 @@ export default function ProfilePage() {
|
|||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me', { cache: 'no-store' })
|
||||
fetch("/api/auth/me", { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.user) {
|
||||
setUser(data.user);
|
||||
setName(data.user.name ?? '');
|
||||
setPhone(data.user.phone ?? '');
|
||||
} else setError(t('notLoggedIn'));
|
||||
setName(data.user.name ?? "");
|
||||
setPhone(data.user.phone ?? "");
|
||||
} else setError(t("notLoggedIn"));
|
||||
})
|
||||
.catch(() => setError(t('notLoggedIn')));
|
||||
.catch(() => setError(t("notLoggedIn")));
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
setBillingLoading(true);
|
||||
fetch('/api/me/billing', { cache: 'no-store' })
|
||||
fetch("/api/me/billing", { cache: "no-store" })
|
||||
.then(async (res) => {
|
||||
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;
|
||||
})
|
||||
.then((data) => {
|
||||
setBillingEnabled(Boolean(data.settings?.enabled));
|
||||
setBillingAccountName(data.settings?.accountName ?? '');
|
||||
setBillingIban(data.settings?.iban ?? '');
|
||||
setBillingAccountName(data.settings?.accountName ?? "");
|
||||
setBillingIban(data.settings?.iban ?? "");
|
||||
setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine));
|
||||
setBillingListings(
|
||||
Array.isArray(data.listings)
|
||||
|
|
@ -66,15 +76,15 @@ export default function ProfilePage() {
|
|||
title: l.title,
|
||||
slug: l.slug,
|
||||
locale: l.locale,
|
||||
billingAccountName: l.billingAccountName ?? '',
|
||||
billingIban: l.billingIban ?? '',
|
||||
billingAccountName: l.billingAccountName ?? "",
|
||||
billingIban: l.billingIban ?? "",
|
||||
billingIncludeVatLine: l.billingIncludeVatLine ?? null,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
setBillingError(null);
|
||||
})
|
||||
.catch(() => setBillingError(t('billingLoadFailed')))
|
||||
.catch(() => setBillingError(t("billingLoadFailed")))
|
||||
.finally(() => setBillingLoading(false));
|
||||
}, [t]);
|
||||
|
||||
|
|
@ -84,21 +94,21 @@ export default function ProfilePage() {
|
|||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/me", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, phone, password: password || undefined }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Update failed');
|
||||
setError(data.error || "Update failed");
|
||||
} else {
|
||||
setUser(data.user);
|
||||
setPassword('');
|
||||
setMessage(t('profileUpdated'));
|
||||
setPassword("");
|
||||
setMessage(t("profileUpdated"));
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Update failed');
|
||||
setError("Update failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -110,9 +120,9 @@ export default function ProfilePage() {
|
|||
setBillingError(null);
|
||||
setBillingMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/me/billing', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch("/api/me/billing", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
enabled: billingEnabled,
|
||||
accountName: billingAccountName,
|
||||
|
|
@ -128,12 +138,12 @@ export default function ProfilePage() {
|
|||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setBillingError(data.error || t('billingSaveFailed'));
|
||||
setBillingError(data.error || t("billingSaveFailed"));
|
||||
return;
|
||||
}
|
||||
setBillingEnabled(Boolean(data.settings?.enabled));
|
||||
setBillingAccountName(data.settings?.accountName ?? '');
|
||||
setBillingIban(data.settings?.iban ?? '');
|
||||
setBillingAccountName(data.settings?.accountName ?? "");
|
||||
setBillingIban(data.settings?.iban ?? "");
|
||||
setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine));
|
||||
setBillingListings(
|
||||
Array.isArray(data.listings)
|
||||
|
|
@ -142,176 +152,341 @@ export default function ProfilePage() {
|
|||
title: l.title,
|
||||
slug: l.slug,
|
||||
locale: l.locale,
|
||||
billingAccountName: l.billingAccountName ?? '',
|
||||
billingIban: l.billingIban ?? '',
|
||||
billingAccountName: l.billingAccountName ?? "",
|
||||
billingIban: l.billingIban ?? "",
|
||||
billingIncludeVatLine: l.billingIncludeVatLine ?? null,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
setBillingMessage(t('billingSaved'));
|
||||
setBillingMessage(t("billingSaved"));
|
||||
} catch (err) {
|
||||
setBillingError(t('billingSaveFailed'));
|
||||
setBillingError(t("billingSaveFailed"));
|
||||
} finally {
|
||||
setBillingSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 1040, margin: '32px auto', display: 'grid', gap: 18 }}>
|
||||
<header className="panel" style={{ display: 'grid', gap: 12, padding: 18 }}>
|
||||
<main
|
||||
style={{ maxWidth: 1040, margin: "32px auto", display: "grid", gap: 18 }}
|
||||
>
|
||||
<header
|
||||
className="panel"
|
||||
style={{ display: "grid", gap: 12, padding: 18 }}
|
||||
>
|
||||
<div>
|
||||
<h1 style={{ margin: 0 }}>{t('myProfileTitle')}</h1>
|
||||
{message ? <p style={{ color: 'green', margin: '6px 0 0' }}>{message}</p> : null}
|
||||
<h1 style={{ margin: 0 }}>{t("myProfileTitle")}</h1>
|
||||
{message ? (
|
||||
<p style={{ color: "green", margin: "6px 0 0" }}>{message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{user ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gap: 6, gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}>
|
||||
<div><strong>{t('profileEmail')}:</strong> {user.email}</div>
|
||||
<div><strong>{t('profileName')}:</strong> {user.name ?? '—'}</div>
|
||||
<div><strong>{t('profilePhone')}:</strong> {user.phone ?? '—'}</div>
|
||||
<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
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 6,
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{t("profileEmail")}:</strong> {user.email}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("profileName")}:</strong> {user.name ?? "—"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("profilePhone")}:</strong> {user.phone ?? "—"}
|
||||
</div>
|
||||
<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' }}>
|
||||
<a className="button secondary" href="/listings/mine">
|
||||
{t('navMyListings')}
|
||||
</a>
|
||||
<a className="button secondary" href="/listings/new">
|
||||
{t('navNewListing')}
|
||||
</a>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ color: 'red', margin: 0 }}>{error ?? t('notLoggedIn')}</p>
|
||||
<p style={{ color: "red", margin: 0 }}>{error ?? t("notLoggedIn")}</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<section className="panel" style={{ padding: 18, display: 'grid', gap: 12 }}>
|
||||
<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 }}>
|
||||
<section
|
||||
className="panel"
|
||||
style={{ padding: 18, display: "grid", gap: 12 }}
|
||||
>
|
||||
<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>
|
||||
{t('profileName')}
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
{t("profileName")}
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('profilePhone')}
|
||||
<input value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
{t("profilePhone")}
|
||||
<input
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('passwordLabel')} ({t('passwordHint')})
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} />
|
||||
{t("passwordLabel")} ({t("passwordHint")})
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#666', margin: 0 }}>{t('emailLocked')}</p>
|
||||
<p style={{ fontSize: 12, color: "#666", margin: 0 }}>
|
||||
{t("emailLocked")}
|
||||
</p>
|
||||
<div>
|
||||
<button className="button" type="submit" disabled={saving} style={{ minWidth: 160 }}>
|
||||
{saving ? t('saving') : t('save')}
|
||||
<button
|
||||
className="button"
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{ minWidth: 160 }}
|
||||
>
|
||||
{saving ? t("saving") : t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ padding: 18, display: 'grid', gap: 12 }}>
|
||||
<section
|
||||
className="panel"
|
||||
style={{ padding: 18, display: "grid", gap: 12 }}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>{t('billingSettingsTitle')}</h2>
|
||||
<p style={{ color: '#444', margin: '6px 0 0' }}>{t('billingSettingsLead')}</p>
|
||||
<h2 style={{ margin: 0 }}>{t("billingSettingsTitle")}</h2>
|
||||
<p style={{ color: "#444", margin: "6px 0 0" }}>
|
||||
{t("billingSettingsLead")}
|
||||
</p>
|
||||
</div>
|
||||
{billingMessage ? <p style={{ color: 'green', margin: 0 }}>{billingMessage}</p> : null}
|
||||
{billingError ? <p style={{ color: 'red', margin: 0 }}>{billingError}</p> : null}
|
||||
<form onSubmit={onSaveBilling} style={{ display: 'grid', gap: 14 }}>
|
||||
{billingMessage ? (
|
||||
<p style={{ color: "green", margin: 0 }}>{billingMessage}</p>
|
||||
) : null}
|
||||
{billingError ? (
|
||||
<p style={{ color: "red", margin: 0 }}>{billingError}</p>
|
||||
) : null}
|
||||
<form onSubmit={onSaveBilling} style={{ display: "grid", gap: 14 }}>
|
||||
<label
|
||||
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 className="amenity-name" style={{ fontWeight: 600 }}>{t('billingEnableLabel')}</span>
|
||||
<span aria-hidden className="amenity-emoji">
|
||||
💸
|
||||
</span>
|
||||
<span className="amenity-name" style={{ fontWeight: 600 }}>
|
||||
{t("billingEnableLabel")}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={billingEnabled}
|
||||
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>
|
||||
{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>
|
||||
{t('billingAccountNameLabel')}
|
||||
<input value={billingAccountName} onChange={(e) => setBillingAccountName(e.target.value)} placeholder="Example Oy" />
|
||||
{t("billingAccountNameLabel")}
|
||||
<input
|
||||
value={billingAccountName}
|
||||
onChange={(e) => setBillingAccountName(e.target.value)}
|
||||
placeholder="Example Oy"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('billingIbanLabel')}
|
||||
<input value={billingIban} onChange={(e) => setBillingIban(e.target.value)} placeholder="FI00 1234 5600 0007 85" />
|
||||
{t("billingIbanLabel")}
|
||||
<input
|
||||
value={billingIban}
|
||||
onChange={(e) => setBillingIban(e.target.value)}
|
||||
placeholder="FI00 1234 5600 0007 85"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
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 className="amenity-name" style={{ fontWeight: 600 }}>{t('billingIncludeVat')}</span>
|
||||
<span aria-hidden className="amenity-emoji">
|
||||
🧾
|
||||
</span>
|
||||
<span
|
||||
className="amenity-name"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
{t("billingIncludeVat")}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={billingIncludeVatLine}
|
||||
onChange={(e) => setBillingIncludeVatLine(e.target.checked)}
|
||||
style={{ width: 22, height: 22, margin: 0, justifySelf: 'end' }}
|
||||
onChange={(e) =>
|
||||
setBillingIncludeVatLine(e.target.checked)
|
||||
}
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
margin: 0,
|
||||
justifySelf: "end",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</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 }}>
|
||||
<strong>{t('billingListingsTitle')}</strong>
|
||||
<div style={{ color: '#555', fontSize: 13 }}>{t('billingListingsLead')}</div>
|
||||
<strong>{t("billingListingsTitle")}</strong>
|
||||
<div style={{ color: "#555", fontSize: 13 }}>
|
||||
{t("billingListingsLead")}
|
||||
</div>
|
||||
</div>
|
||||
{billingLoading ? (
|
||||
<p>{t('loading')}</p>
|
||||
<p>{t("loading")}</p>
|
||||
) : 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) => {
|
||||
const vatValue =
|
||||
listing.billingIncludeVatLine === null || listing.billingIncludeVatLine === undefined
|
||||
? 'inherit'
|
||||
listing.billingIncludeVatLine === null ||
|
||||
listing.billingIncludeVatLine === undefined
|
||||
? "inherit"
|
||||
: listing.billingIncludeVatLine
|
||||
? 'yes'
|
||||
: 'no';
|
||||
? "yes"
|
||||
: "no";
|
||||
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 }}>
|
||||
{listing.title} ({listing.slug})
|
||||
</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>
|
||||
{t('billingAccountNameLabel')}
|
||||
{t("billingAccountNameLabel")}
|
||||
<input
|
||||
value={listing.billingAccountName ?? ''}
|
||||
value={listing.billingAccountName ?? ""}
|
||||
onChange={(e) =>
|
||||
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>
|
||||
{t('billingIbanLabel')}
|
||||
{t("billingIbanLabel")}
|
||||
<input
|
||||
value={listing.billingIban ?? ''}
|
||||
value={listing.billingIban ?? ""}
|
||||
onChange={(e) =>
|
||||
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>
|
||||
{t('billingVatChoice')}
|
||||
{t("billingVatChoice")}
|
||||
<select
|
||||
value={vatValue as string}
|
||||
onChange={(e) => {
|
||||
|
|
@ -321,16 +496,25 @@ export default function ProfilePage() {
|
|||
l.id === listing.id
|
||||
? {
|
||||
...l,
|
||||
billingIncludeVatLine: choice === 'inherit' ? null : choice === 'yes',
|
||||
billingIncludeVatLine:
|
||||
choice === "inherit"
|
||||
? null
|
||||
: choice === "yes",
|
||||
}
|
||||
: l,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="inherit">{t('billingVatInherit')}</option>
|
||||
<option value="yes">{t('billingVatYes')}</option>
|
||||
<option value="no">{t('billingVatNo')}</option>
|
||||
<option value="inherit">
|
||||
{t("billingVatInherit")}
|
||||
</option>
|
||||
<option value="yes">
|
||||
{t("billingVatYes")}
|
||||
</option>
|
||||
<option value="no">
|
||||
{t("billingVatNo")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -342,11 +526,18 @@ export default function ProfilePage() {
|
|||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ color: '#666', margin: 0 }}>{t('billingDisabledHint')}</p>
|
||||
<p style={{ color: "#666", margin: 0 }}>
|
||||
{t("billingDisabledHint")}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<button className="button" type="submit" disabled={billingSaving} style={{ minWidth: 160 }}>
|
||||
{billingSaving ? t('saving') : t('save')}
|
||||
<button
|
||||
className="button"
|
||||
type="submit"
|
||||
disabled={billingSaving}
|
||||
style={{ minWidth: 160 }}
|
||||
>
|
||||
{billingSaving ? t("saving") : t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
117
app/page.tsx
117
app/page.tsx
|
|
@ -1,8 +1,8 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useI18n } from './components/I18nProvider';
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "./components/I18nProvider";
|
||||
|
||||
type LatestListing = {
|
||||
id: string;
|
||||
|
|
@ -17,7 +17,7 @@ type LatestListing = {
|
|||
priceWeekendEuros: number | null;
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -28,7 +28,7 @@ export default function HomePage() {
|
|||
|
||||
useEffect(() => {
|
||||
setLoadingLatest(true);
|
||||
fetch('/api/listings?limit=8', { cache: 'no-store' })
|
||||
fetch("/api/listings?limit=8", { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => setLatest((data.listings ?? []).slice(0, 5)))
|
||||
.catch(() => setLatest([]))
|
||||
|
|
@ -57,61 +57,114 @@ export default function HomePage() {
|
|||
return (
|
||||
<main>
|
||||
<section className="hero">
|
||||
<span className="eyebrow">{t('heroEyebrow')}</span>
|
||||
<h1>{t('heroTitle')}</h1>
|
||||
<p>{t('heroBody')}</p>
|
||||
<span className="eyebrow">{t("heroEyebrow")}</span>
|
||||
<h1>{t("heroTitle")}</h1>
|
||||
<p>{t("heroBody")}</p>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<h2 style={{ margin: 0 }}>{t('latestListingsTitle')}</h2>
|
||||
<p style={{ marginTop: 4 }}>{t('latestListingsLead')}</p>
|
||||
<h2 style={{ margin: 0 }}>{t("latestListingsTitle")}</h2>
|
||||
<p style={{ marginTop: 4 }}>{t("latestListingsLead")}</p>
|
||||
</div>
|
||||
<Link className="button secondary" href="/listings">
|
||||
{t('ctaBrowse')}
|
||||
{t("ctaBrowse")}
|
||||
</Link>
|
||||
</div>
|
||||
{loadingLatest ? (
|
||||
<p style={{ color: '#cbd5e1', marginTop: 10 }}>{t('loading')}</p>
|
||||
<p style={{ color: "#cbd5e1", marginTop: 10 }}>{t("loading")}</p>
|
||||
) : !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-track" style={{ transform: `translateX(-${activeIndex * 100}%)` }}>
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
|
||||
>
|
||||
{latest.map((item) => (
|
||||
<article key={item.id} className="carousel-slide">
|
||||
{item.coverImage ? (
|
||||
<a href={`/listings/${item.slug}`} className="latest-cover-link" aria-label={item.title}>
|
||||
<img src={item.coverImage} alt={item.title} className="latest-cover" />
|
||||
<a
|
||||
href={`/listings/${item.slug}`}
|
||||
className="latest-cover-link"
|
||||
aria-label={item.title}
|
||||
>
|
||||
<img
|
||||
src={item.coverImage}
|
||||
alt={item.title}
|
||||
className="latest-cover"
|
||||
/>
|
||||
</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" />
|
||||
</a>
|
||||
)}
|
||||
<div className="latest-meta">
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<div
|
||||
style={{ display: "flex", gap: 6, flexWrap: "wrap" }}
|
||||
>
|
||||
<span className="badge">
|
||||
{item.city}, {item.region}
|
||||
</span>
|
||||
{item.isSample ? <span className="badge warning">{t('sampleBadge')}</span> : null}
|
||||
{item.isSample ? (
|
||||
<span className="badge warning">
|
||||
{t("sampleBadge")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 style={{ margin: '6px 0 4px' }}>{item.title}</h3>
|
||||
<h3 style={{ margin: "6px 0 4px" }}>{item.title}</h3>
|
||||
<p style={{ margin: 0 }}>{item.teaser}</p>
|
||||
{item.priceWeekdayEuros || item.priceWeekendEuros ? (
|
||||
<div style={{ color: '#cbd5e1', fontSize: 14, marginTop: 2 }}>
|
||||
{t('priceStartingFromShort', {
|
||||
<div
|
||||
style={{
|
||||
color: "#cbd5e1",
|
||||
fontSize: 14,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{t("priceStartingFromShort", {
|
||||
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>
|
||||
) : null}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
|
||||
<Link className="button secondary" href={`/listings/${item.slug}`}>
|
||||
{t('openListing')}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
marginTop: 10,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
className="button secondary"
|
||||
href={`/listings/${item.slug}`}
|
||||
>
|
||||
{t("openListing")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -121,7 +174,11 @@ export default function HomePage() {
|
|||
</div>
|
||||
<div className="dot-row">
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useI18n } from '../components/I18nProvider';
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "../components/I18nProvider";
|
||||
|
||||
const pricing = [
|
||||
{
|
||||
keyTitle: 'pricingMonthly',
|
||||
price: '10€',
|
||||
interval: 'pricingPerMonth',
|
||||
keyBody: 'pricingMonthlyBody',
|
||||
keyTitle: "pricingMonthly",
|
||||
price: "10€",
|
||||
interval: "pricingPerMonth",
|
||||
keyBody: "pricingMonthlyBody",
|
||||
},
|
||||
{
|
||||
keyTitle: 'pricingAnnual',
|
||||
price: '100€',
|
||||
interval: 'pricingPerYear',
|
||||
keyBody: 'pricingAnnualBody',
|
||||
keyTitle: "pricingAnnual",
|
||||
price: "100€",
|
||||
interval: "pricingPerYear",
|
||||
keyBody: "pricingAnnualBody",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -25,33 +25,46 @@ export default function PricingPage() {
|
|||
<main>
|
||||
<section className="panel">
|
||||
<div className="breadcrumb">
|
||||
<Link href="/">{t('homeCrumb')}</Link> / <span>{t('pricingTitle')}</span>
|
||||
<Link href="/">{t("homeCrumb")}</Link> /{" "}
|
||||
<span>{t("pricingTitle")}</span>
|
||||
</div>
|
||||
<h1>{t('pricingTitle')}</h1>
|
||||
<p style={{ marginTop: 8 }}>{t('pricingLead')}</p>
|
||||
<h1>{t("pricingTitle")}</h1>
|
||||
<p style={{ marginTop: 8 }}>{t("pricingLead")}</p>
|
||||
</section>
|
||||
|
||||
<div className="cards" style={{ marginTop: 18 }}>
|
||||
{pricing.map((item) => (
|
||||
<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 }}>
|
||||
{t(item.keyTitle as any)}
|
||||
</h3>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 26, fontWeight: 700 }}>{item.price}</div>
|
||||
<div style={{ color: '#cbd5e1' }}>{t(item.interval as any)}</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontSize: 26, fontWeight: 700 }}>
|
||||
{item.price}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1" }}>
|
||||
{t(item.interval as any)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<section className="panel" style={{ marginTop: 18 }}>
|
||||
<h2 className="card-title">{t('pricingNotesTitle')}</h2>
|
||||
<p style={{ marginTop: 8 }}>{t('pricingNotesBody')}</p>
|
||||
<h2 className="card-title">{t("pricingNotesTitle")}</h2>
|
||||
<p style={{ marginTop: 8 }}>{t("pricingNotesBody")}</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,69 +1,73 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useI18n } from '../components/I18nProvider';
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "../components/I18nProvider";
|
||||
|
||||
export default function PrivacyPage() {
|
||||
const { t } = useI18n();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
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">
|
||||
<Link href="/">{t('homeCrumb')}</Link> / <span>{t('privacyTitle')}</span>
|
||||
<Link href="/">{t("homeCrumb")}</Link> /{" "}
|
||||
<span>{t("privacyTitle")}</span>
|
||||
</div>
|
||||
<h1>{t('privacyTitle')}</h1>
|
||||
<p style={{ color: '#cbd5e1' }}>{t('privacyUpdated', { date: today })}</p>
|
||||
<h1>{t("privacyTitle")}</h1>
|
||||
<p style={{ color: "#cbd5e1" }}>{t("privacyUpdated", { date: today })}</p>
|
||||
|
||||
<section className="privacy-block">
|
||||
<h3>{t('privacyCollectTitle')}</h3>
|
||||
<h3>{t("privacyCollectTitle")}</h3>
|
||||
<ul>
|
||||
<li>{t('privacyCollectAccounts')}</li>
|
||||
<li>{t('privacyCollectListings')}</li>
|
||||
<li>{t('privacyCollectLogs')}</li>
|
||||
<li>{t("privacyCollectAccounts")}</li>
|
||||
<li>{t("privacyCollectListings")}</li>
|
||||
<li>{t("privacyCollectLogs")}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="privacy-block">
|
||||
<h3>{t('privacyUseTitle')}</h3>
|
||||
<h3>{t("privacyUseTitle")}</h3>
|
||||
<ul>
|
||||
<li>{t('privacyUseAuth')}</li>
|
||||
<li>{t('privacyUseListings')}</li>
|
||||
<li>{t('privacyUseMail')}</li>
|
||||
<li>{t('privacyUseLegal')}</li>
|
||||
<li>{t("privacyUseAuth")}</li>
|
||||
<li>{t("privacyUseListings")}</li>
|
||||
<li>{t("privacyUseMail")}</li>
|
||||
<li>{t("privacyUseLegal")}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="privacy-block">
|
||||
<h3>{t('privacyStoreTitle')}</h3>
|
||||
<h3>{t("privacyStoreTitle")}</h3>
|
||||
<ul>
|
||||
<li>{t('privacyStoreDb')}</li>
|
||||
<li>{t('privacyStoreBackups')}</li>
|
||||
<li>{t("privacyStoreDb")}</li>
|
||||
<li>{t("privacyStoreBackups")}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="privacy-block">
|
||||
<h3>{t('privacyCookiesTitle')}</h3>
|
||||
<h3>{t("privacyCookiesTitle")}</h3>
|
||||
<ul>
|
||||
<li>{t('privacyCookiesSession')}</li>
|
||||
<li>{t('privacyCookiesNoTracking')}</li>
|
||||
<li>{t("privacyCookiesSession")}</li>
|
||||
<li>{t("privacyCookiesNoTracking")}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="privacy-block">
|
||||
<h3>{t('privacySharingTitle')}</h3>
|
||||
<h3>{t("privacySharingTitle")}</h3>
|
||||
<ul>
|
||||
<li>{t('privacySharingAds')}</li>
|
||||
<li>{t('privacySharingOps')}</li>
|
||||
<li>{t("privacySharingAds")}</li>
|
||||
<li>{t("privacySharingOps")}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="privacy-block">
|
||||
<h3>{t('privacyRightsTitle')}</h3>
|
||||
<h3>{t("privacyRightsTitle")}</h3>
|
||||
<ul>
|
||||
<li>{t('privacyRightsAccess')}</li>
|
||||
<li>{t('privacyRightsConsent')}</li>
|
||||
<li>{t('privacyRightsContact')}</li>
|
||||
<li>{t("privacyRightsAccess")}</li>
|
||||
<li>{t("privacyRightsConsent")}</li>
|
||||
<li>{t("privacyRightsContact")}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { notFound } from 'next/navigation';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { resolveLocale, t } from '../../lib/i18n';
|
||||
import { notFound } from "next/navigation";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { resolveLocale, t } from "../../lib/i18n";
|
||||
|
||||
type Props = {
|
||||
searchParams: { token?: string };
|
||||
|
|
@ -11,24 +11,32 @@ export default async function VerifyPage({ searchParams }: Props) {
|
|||
if (!token) {
|
||||
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 res = await fetch(`${process.env.APP_URL ?? 'http://localhost:3000'}/api/auth/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const res = await fetch(
|
||||
`${process.env.APP_URL ?? "http://localhost:3000"}/api/auth/verify`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
const ok = res.ok;
|
||||
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 520, margin: '40px auto' }}>
|
||||
<h1>{translate('verifyTitle')}</h1>
|
||||
<main className="panel" style={{ maxWidth: 520, margin: "40px auto" }}>
|
||||
<h1>{translate("verifyTitle")}</h1>
|
||||
{ok ? (
|
||||
<p>{translate('verifyOk')}</p>
|
||||
<p>{translate("verifyOk")}</p>
|
||||
) : (
|
||||
<p style={{ color: 'red' }}>{translate('verifyFail')}</p>
|
||||
<p style={{ color: "red" }}>{translate("verifyFail")}</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
apiVersion: v1
|
||||
clusters:
|
||||
- cluster:
|
||||
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdGMyVnkKZG1WeUxXTmhRREUzTmpNNE1qSTNOakF3SGhjTk1qVXhNVEl5TVRRME5qQXdXaGNOTXpVeE1USXdNVFEwTmpBdwpXakFqTVNFd0h3WURWUVFEREJock0zTXRjMlZ5ZG1WeUxXTmhRREUzTmpNNE1qSTNOakF3V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFSRVlQd3pheHhFRkdPak8xL2N0NnBFaHlNaXNGVytLWDBCQjcvcmthTVIKRmhNRkxWTDFwVEtvMitrd1ZVbUVBZEpoTHErZkdYMFlMMjZJK1dXcTBVZkdvMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVUovTDJkNDF6UTlRK3QvM3hiRmdYCm1KRHBpbjR3Q2dZSUtvWkl6ajBFQXdJRFNBQXdSUUlnUDlQY2lCNzZSRlA0WmVrWjRTNy9QNFVaRnBjLzJRTTYKUll2M2pBcFpGWHNDSVFEeGg2QTdIakdjampwbS9tbmVpQzRpUFJJaDBaamdzWHlYdGxwelNDRUlmUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
|
||||
server: https://157.180.66.64:6443
|
||||
name: default
|
||||
- cluster:
|
||||
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdGMyVnkKZG1WeUxXTmhRREUzTmpNNE1qSTNOakF3SGhjTk1qVXhNVEl5TVRRME5qQXdXaGNOTXpVeE1USXdNVFEwTmpBdwpXakFqTVNFd0h3WURWUVFEREJock0zTXRjMlZ5ZG1WeUxXTmhRREUzTmpNNE1qSTNOakF3V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFSRVlQd3pheHhFRkdPak8xL2N0NnBFaHlNaXNGVytLWDBCQjcvcmthTVIKRmhNRkxWTDFwVEtvMitrd1ZVbUVBZEpoTHErZkdYMFlMMjZJK1dXcTBVZkdvMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVUovTDJkNDF6UTlRK3QvM3hiRmdYCm1KRHBpbjR3Q2dZSUtvWkl6ajBFQXdJRFNBQXdSUUlnUDlQY2lCNzZSRlA0WmVrWjRTNy9QNFVaRnBjLzJRTTYKUll2M2pBcFpGWHNDSVFEeGg2QTdIakdjampwbS9tbmVpQzRpUFJJaDBaamdzWHlYdGxwelNDRUlmUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
|
||||
server: https://157.180.66.64:6443
|
||||
name: default
|
||||
contexts:
|
||||
- context:
|
||||
cluster: default
|
||||
user: default
|
||||
name: default
|
||||
- context:
|
||||
cluster: default
|
||||
user: default
|
||||
name: default
|
||||
current-context: default
|
||||
kind: Config
|
||||
preferences: {}
|
||||
users:
|
||||
- name: default
|
||||
user:
|
||||
client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJqekNDQVRlZ0F3SUJBZ0lJREl2MWE5MUFRVDR3Q2dZSUtvWkl6ajBFQXdJd0l6RWhNQjhHQTFVRUF3d1kKYXpOekxXTnNhV1Z1ZEMxallVQXhOell6T0RJeU56WXdNQjRYRFRJMU1URXlNakUwTkRZd01Gb1hEVEkyTVRFeQpNakUwTkRZd01Gb3dNREVYTUJVR0ExVUVDaE1PYzNsemRHVnRPbTFoYzNSbGNuTXhGVEFUQmdOVkJBTVRESE41CmMzUmxiVHBoWkcxcGJqQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VIQTBJQUJENTJtL2xZNThLQko2aUMKc0JLbldHeUY2cytyQ3BESG5vZkQySVpkVmVIMGc5MWw4Qms2aGFsOUZyQ2tPOXN2T2RGOTdCU28xVlI4M1NlRwpLdXdpaGZHalNEQkdNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBakFmCkJnTlZIU01FR0RBV2dCU3c1d2xoTjd3Zm9jRSs0ZThkV3NvWjBmdmh2REFLQmdncWhrak9QUVFEQWdOR0FEQkQKQWlCYWNTQTZ4VGp5Z2twSnBhSE1HbG1iSmJxWlJhK2ExN091QmsyK0dIeU9GZ0lmVitsK1N5S1NiZmVvTkVFSgp3bmRkQUM5L3d3ZHRxOVFZekp1eVJTdHlIdz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0KLS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJlRENDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdFkyeHAKWlc1MExXTmhRREUzTmpNNE1qSTNOakF3SGhjTk1qVXhNVEl5TVRRME5qQXdXaGNOTXpVeE1USXdNVFEwTmpBdwpXakFqTVNFd0h3WURWUVFEREJock0zTXRZMnhwWlc1MExXTmhRREUzTmpNNE1qSTNOakF3V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFRSDIyUThDNVlRWi9jdGFDeFUra20rbWJOdmhXbTZBV3BBQ0lySnV4SjkKRmQydmQweTlNelNnSjV0WHJobmRDTzRaZG92QUVSSTZVcklleDRnWGswdU9vMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVXNPY0pZVGU4SDZIQlB1SHZIVnJLCkdkSDc0Ynd3Q2dZSUtvWkl6ajBFQXdJRFNRQXdSZ0loQU5TcEVORDdZaXBqRW1QL2d4TlN5akJEVy9XaDlLZVQKOVFtQ1BCTTZQMGp3QWlFQW1ER2ZHL2dFUEpRd2Yrd0I5Tk1KS2VTbEZNOVVUc0x1NEFmTWZZZGx6MWs9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
|
||||
client-key-data: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSU9MS2RKUTJXdFQybThIU2dOUmhzcjFZZVc2S3duTTE3YzIyckZPZlExWStvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFUG5hYitWam53b0VucUlLd0VxZFliSVhxejZzS2tNZWVoOFBZaGwxVjRmU0QzV1h3R1RxRgpxWDBXc0tRNzJ5ODUwWDNzRktqVlZIemRKNFlxN0NLRjhRPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=
|
||||
- name: default
|
||||
user:
|
||||
client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJqekNDQVRlZ0F3SUJBZ0lJREl2MWE5MUFRVDR3Q2dZSUtvWkl6ajBFQXdJd0l6RWhNQjhHQTFVRUF3d1kKYXpOekxXTnNhV1Z1ZEMxallVQXhOell6T0RJeU56WXdNQjRYRFRJMU1URXlNakUwTkRZd01Gb1hEVEkyTVRFeQpNakUwTkRZd01Gb3dNREVYTUJVR0ExVUVDaE1PYzNsemRHVnRPbTFoYzNSbGNuTXhGVEFUQmdOVkJBTVRESE41CmMzUmxiVHBoWkcxcGJqQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VIQTBJQUJENTJtL2xZNThLQko2aUMKc0JLbldHeUY2cytyQ3BESG5vZkQySVpkVmVIMGc5MWw4Qms2aGFsOUZyQ2tPOXN2T2RGOTdCU28xVlI4M1NlRwpLdXdpaGZHalNEQkdNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBakFmCkJnTlZIU01FR0RBV2dCU3c1d2xoTjd3Zm9jRSs0ZThkV3NvWjBmdmh2REFLQmdncWhrak9QUVFEQWdOR0FEQkQKQWlCYWNTQTZ4VGp5Z2twSnBhSE1HbG1iSmJxWlJhK2ExN091QmsyK0dIeU9GZ0lmVitsK1N5S1NiZmVvTkVFSgp3bmRkQUM5L3d3ZHRxOVFZekp1eVJTdHlIdz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0KLS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJlRENDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdFkyeHAKWlc1MExXTmhRREUzTmpNNE1qSTNOakF3SGhjTk1qVXhNVEl5TVRRME5qQXdXaGNOTXpVeE1USXdNVFEwTmpBdwpXakFqTVNFd0h3WURWUVFEREJock0zTXRZMnhwWlc1MExXTmhRREUzTmpNNE1qSTNOakF3V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFRSDIyUThDNVlRWi9jdGFDeFUra20rbWJOdmhXbTZBV3BBQ0lySnV4SjkKRmQydmQweTlNelNnSjV0WHJobmRDTzRaZG92QUVSSTZVcklleDRnWGswdU9vMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVXNPY0pZVGU4SDZIQlB1SHZIVnJLCkdkSDc0Ynd3Q2dZSUtvWkl6ajBFQXdJRFNRQXdSZ0loQU5TcEVORDdZaXBqRW1QL2d4TlN5akJEVy9XaDlLZVQKOVFtQ1BCTTZQMGp3QWlFQW1ER2ZHL2dFUEpRd2Yrd0I5Tk1KS2VTbEZNOVVUc0x1NEFmTWZZZGx6MWs9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
|
||||
client-key-data: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSU9MS2RKUTJXdFQybThIU2dOUmhzcjFZZVc2S3duTTE3YzIyckZPZlExWStvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFUG5hYitWam53b0VucUlLd0VxZFliSVhxejZzS2tNZWVoOFBZaGwxVjRmU0QzV1h3R1RxRgpxWDBXc0tRNzJ5ODUwWDNzRktqVlZIemRKNFlxN0NLRjhRPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=
|
||||
sops:
|
||||
age:
|
||||
- recipient: age1ducvqxdzdhhluftu5hv4f2xsppmn803uh8tnnqj92v4n7nf6lprq9h3dqp
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNYk93aHdHMXBtaENuVFFq
|
||||
TlcrQjdBOXhmenBHRGhQT1NlbkYzSTQ4ZmlnCjRQV3FBakdhUERsblBCWXVVaWdp
|
||||
dENyMkhsTUZDQmdja3hYU3daQ0Jpcm8KLS0tIDgzdi9menlJQ1k5VjNTR29qUGEy
|
||||
RDZyQURucHdncit1WVZqakJaSlNQVVUKtt/O9q3mc/8Q0b7ruG3djq60Tdjr2ZjS
|
||||
YhRaBIC5KJlQjEdNoez6m03yAwV+u10iiLKiKKfxAlBcp1nhvYeqsA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
- recipient: age1hkehkc2rryjl975c2mg5cghmjr54n4wjshncl292h2eg5l394fhs4uydrh
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJUUxMQ05ENjZQeHRLWjhX
|
||||
dzBxUjJlaHUrOGRLb3FHUU1UempUZWhVdVdnCjBQNUx1NkJXL2NJNkhRN3dRcm9G
|
||||
Sy9ZWmFQY3FUQWdSOWVIRXlXa2xSeW8KLS0tIE00bHNackxTVFFXYkxIWGZVakxL
|
||||
SHd5Q243Z2txRlo1R21LREFpUFV5ZUUKNvT8IQCdxGtAXASfrcDxK5OLzcXTRV9G
|
||||
0SoZ74sO+cWOs42tJBPxMIXaTmw2tQHqjOdtUJSZWhSqKlGsDFt/Aw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2025-12-13T21:30:04Z"
|
||||
mac: ENC[AES256_GCM,data:EzGg5/wMDyjFvRGnVO+MqnLFH03EeSHABOIzPK2zDSXijjSyqFhRmLLVldJGXRMMs+yHOLYPsMcumgf2GGHANun1P3hCZe2NW3wXnpW3JQg3nrdgBMUGxwqqjGpHKNd9ZegJ4xadncS6EzYF/SPpm7UUzGTl0PRmNyFoxPbM1Hg=,iv:Lor3ifJgvh/KQsm6Kh8xavLICxNH8PWajKGTSOTylro=,tag:fCvONoPAwnVHsm290yJVTg==,type:str]
|
||||
encrypted_regex: ^(AUTH_SECRET|DATABASE_URL|DB_.*|APP_URL|SMTP_.*|DKIM_.*|AUTO_APPROVE_LISTINGS|OPENAI_.*|H(ETZNER|CLOUD)_TOKEN|JOKER_DYNDNS_.*|REGISTRY_.*|NETDATA_.*|ADMIN_.*)$
|
||||
version: 3.11.0
|
||||
age:
|
||||
- recipient: age1ducvqxdzdhhluftu5hv4f2xsppmn803uh8tnnqj92v4n7nf6lprq9h3dqp
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNYk93aHdHMXBtaENuVFFq
|
||||
TlcrQjdBOXhmenBHRGhQT1NlbkYzSTQ4ZmlnCjRQV3FBakdhUERsblBCWXVVaWdp
|
||||
dENyMkhsTUZDQmdja3hYU3daQ0Jpcm8KLS0tIDgzdi9menlJQ1k5VjNTR29qUGEy
|
||||
RDZyQURucHdncit1WVZqakJaSlNQVVUKtt/O9q3mc/8Q0b7ruG3djq60Tdjr2ZjS
|
||||
YhRaBIC5KJlQjEdNoez6m03yAwV+u10iiLKiKKfxAlBcp1nhvYeqsA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
- recipient: age1hkehkc2rryjl975c2mg5cghmjr54n4wjshncl292h2eg5l394fhs4uydrh
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJUUxMQ05ENjZQeHRLWjhX
|
||||
dzBxUjJlaHUrOGRLb3FHUU1UempUZWhVdVdnCjBQNUx1NkJXL2NJNkhRN3dRcm9G
|
||||
Sy9ZWmFQY3FUQWdSOWVIRXlXa2xSeW8KLS0tIE00bHNackxTVFFXYkxIWGZVakxL
|
||||
SHd5Q243Z2txRlo1R21LREFpUFV5ZUUKNvT8IQCdxGtAXASfrcDxK5OLzcXTRV9G
|
||||
0SoZ74sO+cWOs42tJBPxMIXaTmw2tQHqjOdtUJSZWhSqKlGsDFt/Aw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2025-12-13T21:30:04Z"
|
||||
mac: ENC[AES256_GCM,data:EzGg5/wMDyjFvRGnVO+MqnLFH03EeSHABOIzPK2zDSXijjSyqFhRmLLVldJGXRMMs+yHOLYPsMcumgf2GGHANun1P3hCZe2NW3wXnpW3JQg3nrdgBMUGxwqqjGpHKNd9ZegJ4xadncS6EzYF/SPpm7UUzGTl0PRmNyFoxPbM1Hg=,iv:Lor3ifJgvh/KQsm6Kh8xavLICxNH8PWajKGTSOTylro=,tag:fCvONoPAwnVHsm290yJVTg==,type:str]
|
||||
encrypted_regex: ^(AUTH_SECRET|DATABASE_URL|DB_.*|APP_URL|SMTP_.*|DKIM_.*|AUTO_APPROVE_LISTINGS|OPENAI_.*|H(ETZNER|CLOUD)_TOKEN|JOKER_DYNDNS_.*|REGISTRY_.*|NETDATA_.*|ADMIN_.*)$
|
||||
version: 3.11.0
|
||||
|
|
|
|||
|
|
@ -1,27 +1,30 @@
|
|||
Deploying to k3s (Hetzner)
|
||||
==========================
|
||||
# Deploying to k3s (Hetzner)
|
||||
|
||||
Prereqs
|
||||
|
||||
- `kubectl` installed locally.
|
||||
- Access to the cluster kubeconfig.
|
||||
- Secrets loaded (dotenv via `scripts/load-secrets.sh`).
|
||||
|
||||
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`.
|
||||
- Recommended flow for new devs:
|
||||
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.
|
||||
3) Verify access: `kubectl get ns` (you should see `lomavuokraus-test/staging/prod`).
|
||||
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.
|
||||
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:
|
||||
- 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`
|
||||
|
||||
Deploy commands
|
||||
|
||||
- Test: `./deploy/deploy-test.sh`
|
||||
- Staging (default): `./deploy/deploy-staging.sh` or `TARGET=staging ./deploy/deploy.sh`
|
||||
- Prod: `./deploy/deploy-prod.sh`
|
||||
|
||||
Notes
|
||||
|
||||
- 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`.
|
||||
- `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).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
|
@ -8,13 +8,16 @@
|
|||
<body>
|
||||
<header>
|
||||
<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>
|
||||
<main class="grid">
|
||||
<section class="card">
|
||||
<h2>Component map</h2>
|
||||
<div class="diagram">
|
||||
<pre class="mermaid">
|
||||
<h2>Component map</h2>
|
||||
<div class="diagram">
|
||||
<pre class="mermaid">
|
||||
flowchart LR
|
||||
Browser["Client browser"] -->|"HTTPS"| Traefik["Traefik ingress"]
|
||||
Traefik --> Varnish["Varnish cache\n(static + /api/images/*)"]
|
||||
|
|
@ -25,14 +28,17 @@ flowchart LR
|
|||
Next --> Mailer["SMTP mailer\nsmtp.lomavuokraus.fi (CNAME) + DKIM"]
|
||||
Admin["Admins & moderators"] --> Traefik
|
||||
</pre>
|
||||
</div>
|
||||
<div class="callout">Edit the Mermaid block above to evolve the architecture.</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="callout">
|
||||
Edit the Mermaid block above to evolve the architecture.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Domain model</h2>
|
||||
<div class="diagram">
|
||||
<pre class="mermaid">erDiagram
|
||||
<pre class="mermaid">
|
||||
erDiagram
|
||||
USER ||--o{ LISTING : owns
|
||||
USER ||--o{ LISTING : approves
|
||||
LISTING ||--|{ LISTINGTRANSLATION : has
|
||||
|
|
@ -78,35 +84,94 @@ flowchart LR
|
|||
boolean isCover
|
||||
int order
|
||||
}
|
||||
</pre>
|
||||
</pre
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Key notes</h2>
|
||||
<ul>
|
||||
<li><strong>Web</strong>: Next.js app (App Router), server-rendered pages, client hooks for auth state.</li>
|
||||
<li><strong>API routes</strong>: Authentication, admin approvals, listings CRUD (soft-delete), profile update.</li>
|
||||
<li><strong>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>
|
||||
<li>
|
||||
<strong>Web</strong>: Next.js app (App Router), server-rendered
|
||||
pages, client hooks for auth state.
|
||||
</li>
|
||||
<li>
|
||||
<strong>API routes</strong>: Authentication, admin approvals,
|
||||
listings CRUD (soft-delete), profile update.
|
||||
</li>
|
||||
<li>
|
||||
<strong>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>
|
||||
|
||||
<h3>How the Next.js App Router is wired here</h3>
|
||||
<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><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>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
|
||||
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
|
||||
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||
mermaid.initialize({ startOnLoad: true, theme: "dark" });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
|
@ -8,15 +8,28 @@
|
|||
<body>
|
||||
<header>
|
||||
<h1>Build & 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>
|
||||
<main class="grid">
|
||||
<section class="card">
|
||||
<h2>Local prerequisites (macOS)</h2>
|
||||
<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>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>
|
||||
<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>
|
||||
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>
|
||||
</section>
|
||||
|
||||
|
|
@ -37,16 +50,28 @@ flowchart LR
|
|||
DeployProd --> RolloutProd["kubectl apply + rollout\n(prod)"]
|
||||
</pre>
|
||||
</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 class="card">
|
||||
<h2>Build Inputs</h2>
|
||||
<ul>
|
||||
<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>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>
|
||||
<li>
|
||||
Env: <code>.env</code> (local), K8s Secret
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
|
@ -54,17 +79,24 @@ flowchart LR
|
|||
<h2>NPM Scripts</h2>
|
||||
<ul>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Docker Image</h2>
|
||||
<ul>
|
||||
<li>Multi-stage Dockerfile:
|
||||
<li>
|
||||
Multi-stage Dockerfile:
|
||||
<ul>
|
||||
<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>
|
||||
</ul>
|
||||
</li>
|
||||
|
|
@ -76,33 +108,55 @@ flowchart LR
|
|||
<section class="card">
|
||||
<h2>Deploy Scripts</h2>
|
||||
<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/deploy.sh</code> → envsubst <code>k8s/app.yaml</code>, kubectl apply, rollout.</li>
|
||||
<li>Environment wrappers:
|
||||
<li>
|
||||
<code>deploy/deploy.sh</code> → envsubst <code>k8s/app.yaml</code>,
|
||||
kubectl apply, rollout.
|
||||
</li>
|
||||
<li>
|
||||
Environment wrappers:
|
||||
<ul>
|
||||
<li><code>deploy/deploy-staging.sh</code></li>
|
||||
<li><code>deploy/deploy-prod.sh</code></li>
|
||||
<li><code>deploy/deploy-test.sh</code></li>
|
||||
</ul>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Config & Env Vars</h2>
|
||||
<ul>
|
||||
<li>From ConfigMap (public): <code>NEXT_PUBLIC_SITE_URL</code>, <code>NEXT_PUBLIC_API_BASE</code>, <code>APP_ENV</code>.</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>
|
||||
<li>
|
||||
From ConfigMap (public): <code>NEXT_PUBLIC_SITE_URL</code>,
|
||||
<code>NEXT_PUBLIC_API_BASE</code>, <code>APP_ENV</code>.
|
||||
</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>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
|
||||
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
|
||||
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||
mermaid.initialize({ startOnLoad: true, theme: "dark" });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
|
@ -8,14 +8,28 @@
|
|||
<body>
|
||||
<header>
|
||||
<h1>Git Workflow & 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>
|
||||
<main class="grid">
|
||||
<section class="card">
|
||||
<h2>Remotes</h2>
|
||||
<ul>
|
||||
<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>
|
||||
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>
|
||||
</ul>
|
||||
</section>
|
||||
|
|
@ -23,40 +37,81 @@
|
|||
<section class="card">
|
||||
<h2>Daily flow</h2>
|
||||
<ul>
|
||||
<li>Sync master: <code>git checkout master && git pull --rebase</code></li>
|
||||
<li>Create feature branch: <code>git checkout -b feature/<name></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/<name></code></li>
|
||||
<li>
|
||||
Sync master: <code>git checkout master && git pull --rebase</code>
|
||||
</li>
|
||||
<li>
|
||||
Create feature branch:
|
||||
<code>git checkout -b feature/<name></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/<name></code>
|
||||
</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/<name></code></li>
|
||||
<li>
|
||||
After merge:
|
||||
<code
|
||||
>git checkout master && git pull --rebase && git branch -d
|
||||
feature/<name></code
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Branch protection</h2>
|
||||
<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>Restrict who can push to protected branches to admins/maintainers.</li>
|
||||
<li>
|
||||
Restrict who can push to protected branches to admins/maintainers.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Local config</h2>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Troubleshooting</h2>
|
||||
<ul>
|
||||
<li>Divergent pull: set pull strategy, rerun <code>git pull --rebase</code> or <code>git pull</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>
|
||||
<li>
|
||||
Divergent pull: set pull strategy, rerun
|
||||
<code>git pull --rebase</code> or <code>git pull</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>
|
||||
</section>
|
||||
|
||||
|
|
@ -71,8 +126,15 @@
|
|||
<section class="card">
|
||||
<h2>Secrets & kubeconfig</h2>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,38 +1,40 @@
|
|||
Git Workflow and Branch Protection
|
||||
==================================
|
||||
# Git Workflow and Branch Protection
|
||||
|
||||
Goal
|
||||
|
||||
- Keep `master` protected; changes land via pull requests with review and passing checks.
|
||||
|
||||
Remotes
|
||||
|
||||
- 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`
|
||||
- Verify: `git remote -v`
|
||||
|
||||
Daily flow
|
||||
1) Sync `master`:
|
||||
|
||||
1. Sync `master`:
|
||||
```
|
||||
git checkout master
|
||||
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>
|
||||
```
|
||||
3) Commit locally:
|
||||
3. Commit locally:
|
||||
```
|
||||
git status
|
||||
git add <files>
|
||||
git commit -m "Your change"
|
||||
```
|
||||
4) Push branch to Forgejo:
|
||||
4. Push branch to Forgejo:
|
||||
```
|
||||
git push -u origin feature/<short-name>
|
||||
```
|
||||
5) Open a PR targeting `master` in Forgejo.
|
||||
6) Get review + approvals; ensure CI passes.
|
||||
7) Merge via the PR; master stays protected.
|
||||
8) After merge, sync local master:
|
||||
5. Open a PR targeting `master` in Forgejo.
|
||||
6. Get review + approvals; ensure CI passes.
|
||||
7. Merge via the PR; master stays protected.
|
||||
8. After merge, sync local master:
|
||||
```
|
||||
git checkout master
|
||||
git pull --rebase
|
||||
|
|
@ -40,6 +42,7 @@ Daily flow
|
|||
```
|
||||
|
||||
Branch protection (set in Forgejo UI)
|
||||
|
||||
- Settings → Branches → Protect `master`.
|
||||
- Enable:
|
||||
- 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).
|
||||
|
||||
Recommended local config
|
||||
|
||||
- Default pull strategy (pick one):
|
||||
- Rebase pulls: `git config pull.rebase true`
|
||||
- Merge pulls: `git config pull.rebase false`
|
||||
|
|
@ -60,6 +64,7 @@ Recommended local config
|
|||
```
|
||||
|
||||
Common troubleshooting
|
||||
|
||||
- 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).
|
||||
- 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>`.
|
||||
|
||||
CI
|
||||
|
||||
- Workflows live under `.forgejo/workflows/`. Ensure CI passes before merging.
|
||||
|
||||
Secrets and 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`.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
|
@ -8,7 +8,10 @@
|
|||
<body>
|
||||
<header>
|
||||
<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>
|
||||
<main class="grid">
|
||||
<section class="card">
|
||||
|
|
@ -26,15 +29,21 @@
|
|||
<section class="card">
|
||||
<h3>How diagrams work</h3>
|
||||
<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>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>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
|
||||
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
|
||||
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||
mermaid.initialize({ startOnLoad: true, theme: "dark" });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
154
docs/infra.html
154
docs/infra.html
|
|
@ -1,4 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
|
@ -8,13 +8,16 @@
|
|||
<body>
|
||||
<header>
|
||||
<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>
|
||||
<main class="grid">
|
||||
<section class="card">
|
||||
<h2>Traffic flow</h2>
|
||||
<div class="diagram">
|
||||
<pre class="mermaid">
|
||||
<div class="diagram">
|
||||
<pre class="mermaid">
|
||||
flowchart LR
|
||||
DNS["lomavuokraus.fi\nstaging.lomavuokraus.fi\napi.lomavuokraus.fi"] --> Traefik["Traefik ingress\n(class: traefik)"]
|
||||
User["User browser"] -->|"HTTPS"| Traefik
|
||||
|
|
@ -31,7 +34,10 @@ flowchart LR
|
|||
Registry["registry.halla-aho.net/thalla/lomavuokraus-web"] -->|"pull"| Pod
|
||||
</pre>
|
||||
</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 class="card">
|
||||
|
|
@ -68,70 +74,152 @@ flowchart TB
|
|||
<section class="card">
|
||||
<h2>Cluster & Namespaces</h2>
|
||||
<ul>
|
||||
<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>
|
||||
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>cert-manager v1.15.3 with ClusterIssuers:
|
||||
<li>
|
||||
cert-manager v1.15.3 with ClusterIssuers:
|
||||
<ul>
|
||||
<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>
|
||||
</li>
|
||||
<li>Service points to a Varnish sidecar (port 8080) in each pod before the Next.js container (3000) to cache <code>/api/images/*</code> and static assets.</li>
|
||||
<li>Cache policy: images cached 24h with <code>Cache-Control: public, max-age=86400, immutable</code>; <code>_next/static</code> cached 7d; non-GET traffic and health checks bypass cache.</li>
|
||||
<li>DNS: <code>lomavuokraus.fi</code>, <code>staging.lomavuokraus.fi</code>, <code>api.lomavuokraus.fi</code> -> cluster IP.</li>
|
||||
<li>
|
||||
Service points to a Varnish sidecar (port 8080) in each pod before
|
||||
the Next.js container (3000) to cache <code>/api/images/*</code> and
|
||||
static assets.
|
||||
</li>
|
||||
<li>
|
||||
Cache policy: images cached 24h with
|
||||
<code>Cache-Control: public, max-age=86400, immutable</code>;
|
||||
<code>_next/static</code> cached 7d; non-GET traffic and health
|
||||
checks bypass cache.
|
||||
</li>
|
||||
<li>
|
||||
DNS: <code>lomavuokraus.fi</code>,
|
||||
<code>staging.lomavuokraus.fi</code>,
|
||||
<code>api.lomavuokraus.fi</code> -> cluster IP.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Registry</h2>
|
||||
<ul>
|
||||
<li>Private registry: <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>
|
||||
<li>
|
||||
Private registry:
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>App Manifests</h2>
|
||||
<ul>
|
||||
<li><code>k8s/app.yaml</code> templated via envsubst in deploy scripts.</li>
|
||||
<li>Objects:
|
||||
<li>
|
||||
<code>k8s/app.yaml</code> templated via envsubst in deploy scripts.
|
||||
</li>
|
||||
<li>
|
||||
Objects:
|
||||
<ul>
|
||||
<li>ConfigMap: <code>lomavuokraus-web-config</code> (public env).</li>
|
||||
<li>Deployment: 2 replicas, Varnish sidecar on port 8080 in front of the Next.js container (3000), liveness/readiness on <code>/api/health</code> via Varnish.</li>
|
||||
<li>Service: ClusterIP on port 80 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>
|
||||
<li>
|
||||
ConfigMap: <code>lomavuokraus-web-config</code> (public env).
|
||||
</li>
|
||||
<li>
|
||||
Deployment: 2 replicas, Varnish sidecar on port 8080 in front of
|
||||
the Next.js container (3000), liveness/readiness on
|
||||
<code>/api/health</code> via Varnish.
|
||||
</li>
|
||||
<li>
|
||||
Service: ClusterIP on port 80 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>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Runtime Environment</h2>
|
||||
<ul>
|
||||
<li>Next.js 14.2.33 (App Router) running via Node.js 20 in Docker.</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>
|
||||
<li>
|
||||
Next.js 14.2.33 (App Router) running via Node.js 20 in Docker.
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Emergency shutdown</h2>
|
||||
<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>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>
|
||||
<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>
|
||||
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>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
|
||||
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
|
||||
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||
mermaid.initialize({ startOnLoad: true, theme: "dark" });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ We ship a lightweight logging stack into the cluster so API/UI logs are searchab
|
|||
## Install / upgrade
|
||||
|
||||
Prereqs:
|
||||
|
||||
- `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`).
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ bash deploy/install-logging.sh
|
|||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. Ensures Helm is available.
|
||||
2. Installs/updates Loki, Promtail, and Grafana in the logging namespace.
|
||||
3. Creates a Grafana ingress with TLS via the chosen ClusterIssuer.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
|
@ -8,41 +8,87 @@
|
|||
<body>
|
||||
<header>
|
||||
<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>
|
||||
<main class="grid">
|
||||
<section class="card">
|
||||
<h2>Setup</h2>
|
||||
<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>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>
|
||||
<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>
|
||||
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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Automatic tickets on failures</h2>
|
||||
<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>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>
|
||||
<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>
|
||||
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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>CLI tools</h2>
|
||||
<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>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>
|
||||
<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>
|
||||
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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Notes</h2>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Secrets workflow (sops + age)
|
||||
|
||||
## Files
|
||||
|
||||
- `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.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`.
|
||||
|
||||
## Editing secrets
|
||||
|
||||
```bash
|
||||
# Ensure sops+age binaries are available
|
||||
sops creds/secrets.enc.env
|
||||
```
|
||||
|
||||
Sops will decrypt, open in $EDITOR, and re-encrypt on save. The age recipient is configured in `.sops.yaml`.
|
||||
|
||||
## Loading secrets locally
|
||||
|
||||
```bash
|
||||
source scripts/load-secrets.sh
|
||||
```
|
||||
|
||||
This decrypts `creds/secrets.enc.env` to `creds/secrets.env` if needed (requires sops) and exports all variables.
|
||||
|
||||
## Adding developers
|
||||
|
||||
- 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.
|
||||
|
||||
## Deploys/CI
|
||||
|
||||
- `deploy/deploy.sh` sources `scripts/load-secrets.sh`, so providing `creds/secrets.enc.env` + age key is enough for secret env injection.
|
||||
|
||||
## Rotating keys
|
||||
|
||||
- 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.
|
||||
- Re-encrypt: `SOPS_AGE_KEY_FILE=creds/age-key.txt sops --encrypt --in-place creds/secrets.enc.env`.
|
||||
|
||||
## n8n billing 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`.
|
||||
- Rotate by replacing the file/env value and restarting the app/n8n caller with the new key.
|
||||
|
||||
## Per-user age keys
|
||||
|
||||
- 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`.
|
||||
- Remove: `./scripts/manage-age-key.sh remove alice` deletes the key file and strips the recipient (re-encrypt afterwards).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
|
@ -8,48 +8,99 @@
|
|||
<body>
|
||||
<header>
|
||||
<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>
|
||||
<main class="grid">
|
||||
<section class="card">
|
||||
<h2>Baseline scan</h2>
|
||||
<ul>
|
||||
<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>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>
|
||||
<li>
|
||||
Default target: <code>https://test.lomavuokraus.fi</code> (override
|
||||
with <code>TARGET</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>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Full test suite</h2>
|
||||
<ul>
|
||||
<li>Script: <code>scripts/run-test-suite.sh</code></li>
|
||||
<li>Runs: <code>npm audit</code> (high), Trivy fs scan, ZAP baseline.</li>
|
||||
<li>Outputs: <code>reports/runs/<timestamp>/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:
|
||||
<li>
|
||||
Runs: <code>npm audit</code> (high), Trivy fs scan, ZAP baseline.
|
||||
</li>
|
||||
<li>
|
||||
Outputs:
|
||||
<code>reports/runs/<timestamp>/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>
|
||||
<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><code>ZAP_IMAGE</code>: override container image if needed.</li>
|
||||
<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>
|
||||
</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>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Auth considerations</h2>
|
||||
<ul>
|
||||
<li>The baseline scan is unauthenticated; it covers public pages and 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>
|
||||
<li>
|
||||
The baseline scan is unauthenticated; it covers public pages and
|
||||
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>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Next steps</h2>
|
||||
<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>Track findings in issues; rerun after auth/role changes.</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
|
@ -8,7 +8,9 @@
|
|||
<body>
|
||||
<header>
|
||||
<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>
|
||||
<main class="grid">
|
||||
<section class="card">
|
||||
|
|
@ -99,14 +101,17 @@ sequenceDiagram
|
|||
<section class="card">
|
||||
<h2>Rendering instructions</h2>
|
||||
<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>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
|
||||
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
|
||||
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||
mermaid.initialize({ startOnLoad: true, theme: "dark" });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
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;
|
||||
padding: 0;
|
||||
background: #0f172a;
|
||||
|
|
@ -50,7 +56,9 @@ pre {
|
|||
border: 1px solid #1f2937;
|
||||
}
|
||||
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;
|
||||
}
|
||||
.diagram {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
What’s included
|
||||
|
||||
- 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.
|
||||
- SSH for git is exposed on host port 2223 (mapped to container 22); change in compose if that port is taken.
|
||||
|
||||
Prereqs
|
||||
|
||||
- Docker installed on halla-aho.net.
|
||||
- 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.
|
||||
|
||||
Deploy Forgejo
|
||||
1) Create host dirs for data:
|
||||
|
||||
1. Create host dirs for data:
|
||||
```
|
||||
sudo mkdir -p /srv/forgejo/data /srv/forgejo/runner
|
||||
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
|
||||
```
|
||||
|
||||
- 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/`.
|
||||
- TLS files: `/etc/apache2/ssl/git.halla-aho.net.{crt,key,chain.crt}` (update if different).
|
||||
- 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.
|
||||
- Configure SMTP in the admin UI (Mail settings).
|
||||
- Set `ROOT_URL`/`SSH_DOMAIN` if you change ports/domains.
|
||||
|
||||
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 \
|
||||
forgejo-runner register \
|
||||
|
|
@ -45,11 +51,12 @@ Register the Actions runner
|
|||
--labels docker \
|
||||
--config /data/config.yaml
|
||||
```
|
||||
3) Start the runner:
|
||||
3. Start the runner:
|
||||
```
|
||||
docker compose -f forgejo/docker-compose.yml up -d runner
|
||||
```
|
||||
|
||||
CI workflow for this repo
|
||||
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ services:
|
|||
- /srv/forgejo/data:/data
|
||||
ports:
|
||||
- "3200:3000" # HTTP (Apache will reverse proxy)
|
||||
- "2223:22" # SSH for git
|
||||
- "2223:22" # SSH for git
|
||||
|
||||
runner:
|
||||
image: codeberg.org/forgejo/runner:4
|
||||
|
|
|
|||
10
k8s/app.yaml
10
k8s/app.yaml
|
|
@ -125,7 +125,15 @@ spec:
|
|||
ports:
|
||||
- containerPort: 8080
|
||||
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:
|
||||
- name: varnish-vcl
|
||||
mountPath: /etc/varnish/default.vcl
|
||||
|
|
|
|||
|
|
@ -44,4 +44,3 @@ spec:
|
|||
port: 80
|
||||
targetPort: 3000
|
||||
type: ClusterIP
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export function loadN8nBillingApiKey() {
|
||||
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 {
|
||||
return fs.readFileSync(keyPath, 'utf8').trim();
|
||||
return fs.readFileSync(keyPath, "utf8").trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const DEFAULT_ROUNDS = 12;
|
||||
|
||||
|
|
@ -11,7 +11,10 @@ export async function hashPassword(password: string): Promise<string> {
|
|||
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;
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Listing, User } from '@prisma/client';
|
||||
import type { Listing, User } from "@prisma/client";
|
||||
|
||||
export type BillingConfig = {
|
||||
accountName: string | null;
|
||||
|
|
@ -6,10 +6,18 @@ export type BillingConfig = {
|
|||
includeVatLine: boolean;
|
||||
};
|
||||
|
||||
type BillingUser = Pick<User, 'billingAccountName' | 'billingIban' | 'billingIncludeVatLine'>;
|
||||
type BillingListing = Pick<Listing, 'billingAccountName' | 'billingIban' | 'billingIncludeVatLine'>;
|
||||
type BillingUser = Pick<
|
||||
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 === null) return null;
|
||||
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 {
|
||||
if (input === undefined) return undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
export function normalizeNullableBoolean(input: unknown): boolean | null | undefined {
|
||||
export function normalizeNullableBoolean(
|
||||
input: unknown,
|
||||
): boolean | null | undefined {
|
||||
if (input === undefined) return undefined;
|
||||
if (input === null) return null;
|
||||
return Boolean(input);
|
||||
}
|
||||
|
||||
export function resolveBillingDetails(user: BillingUser, listing?: BillingListing | null): BillingConfig {
|
||||
const accountName = listing?.billingAccountName ?? user.billingAccountName ?? null;
|
||||
export function resolveBillingDetails(
|
||||
user: BillingUser,
|
||||
listing?: BillingListing | null,
|
||||
): BillingConfig {
|
||||
const accountName =
|
||||
listing?.billingAccountName ?? user.billingAccountName ?? null;
|
||||
const iban = listing?.billingIban ?? user.billingIban ?? null;
|
||||
const includeVatLine =
|
||||
listing?.billingIncludeVatLine !== null && listing?.billingIncludeVatLine !== undefined
|
||||
listing?.billingIncludeVatLine !== null &&
|
||||
listing?.billingIncludeVatLine !== undefined
|
||||
? Boolean(listing.billingIncludeVatLine)
|
||||
: Boolean(user.billingIncludeVatLine);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ type CacheEntry = { expiresAt: number; ranges: TimeRange[] };
|
|||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
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;
|
||||
|
||||
function parseDateValue(raw: string): Date | null {
|
||||
|
|
@ -21,7 +22,10 @@ function parseDateValue(raw: string): Date | null {
|
|||
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;
|
||||
let end = endRaw ? parseDateValue(endRaw) : null;
|
||||
|
||||
|
|
@ -43,17 +47,21 @@ function normalizeRange(startRaw: string | null, endRaw: string | null): TimeRan
|
|||
function parseIcs(text: string): TimeRange[] {
|
||||
const lines = text.split(/\r?\n/);
|
||||
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) {
|
||||
const line = rawLine.trim();
|
||||
if (line === 'BEGIN:VEVENT') {
|
||||
if (line === "BEGIN:VEVENT") {
|
||||
current = {};
|
||||
continue;
|
||||
}
|
||||
if (line === 'END:VEVENT') {
|
||||
if (current && current.status !== 'CANCELLED') {
|
||||
const range = normalizeRange(current.dtstart || null, current.dtend || null);
|
||||
if (line === "END:VEVENT") {
|
||||
if (current && current.status !== "CANCELLED") {
|
||||
const range = normalizeRange(
|
||||
current.dtstart || null,
|
||||
current.dtend || null,
|
||||
);
|
||||
if (range) ranges.push(range);
|
||||
}
|
||||
current = null;
|
||||
|
|
@ -61,14 +69,14 @@ function parseIcs(text: string): TimeRange[] {
|
|||
}
|
||||
if (!current) continue;
|
||||
|
||||
if (line.startsWith('DTSTART')) {
|
||||
const [, value] = line.split(':');
|
||||
if (line.startsWith("DTSTART")) {
|
||||
const [, value] = line.split(":");
|
||||
current.dtstart = value;
|
||||
} else if (line.startsWith('DTEND')) {
|
||||
const [, value] = line.split(':');
|
||||
} else if (line.startsWith("DTEND")) {
|
||||
const [, value] = line.split(":");
|
||||
current.dtend = value;
|
||||
} else if (line.startsWith('STATUS')) {
|
||||
const [, value] = line.split(':');
|
||||
} else if (line.startsWith("STATUS")) {
|
||||
const [, value] = line.split(":");
|
||||
current.status = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +84,10 @@ function parseIcs(text: string): TimeRange[] {
|
|||
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 now = Date.now();
|
||||
if (!forceRefresh && cached && cached.expiresAt > now) {
|
||||
|
|
@ -84,28 +95,40 @@ async function fetchCalendarUrl(url: string, forceRefresh = false): Promise<Time
|
|||
}
|
||||
|
||||
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})`);
|
||||
const text = await res.text();
|
||||
const ranges = parseIcs(text);
|
||||
globalCache.set(url, { expiresAt: now + CACHE_TTL_MS, ranges });
|
||||
return ranges;
|
||||
} 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: [] });
|
||||
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 unique = Array.from(new Set(urls.filter(Boolean)));
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
if (r.start < end && r.end > start) {
|
||||
return false;
|
||||
|
|
@ -114,7 +137,11 @@ export function isRangeAvailable(ranges: TimeRange[], start: Date, end: Date): b
|
|||
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 cursor = new Date(from);
|
||||
while (cursor <= to) {
|
||||
|
|
|
|||
1769
lib/i18n.ts
1769
lib/i18n.ts
File diff suppressed because it is too large
Load diff
47
lib/jwt.ts
47
lib/jwt.ts
|
|
@ -1,39 +1,50 @@
|
|||
import { SignJWT, jwtVerify } from 'jose';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const ALGORITHM = 'HS256';
|
||||
const ALGORITHM = "HS256";
|
||||
const TOKEN_EXP_HOURS = 24;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export async function signAccessToken(payload: { userId: string; role: string }) {
|
||||
export async function signAccessToken(payload: {
|
||||
userId: string;
|
||||
role: string;
|
||||
}) {
|
||||
const secret = getSecret();
|
||||
const exp = Math.floor(Date.now() / 1000) + TOKEN_EXP_HOURS * 3600;
|
||||
return new SignJWT(payload).setProtectedHeader({ alg: ALGORITHM }).setExpirationTime(exp).sign(secret);
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: ALGORITHM })
|
||||
.setExpirationTime(exp)
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string) {
|
||||
const secret = getSecret();
|
||||
const { payload } = await jwtVerify(token, secret, { algorithms: [ALGORITHM] });
|
||||
const { payload } = await jwtVerify(token, secret, {
|
||||
algorithms: [ALGORITHM],
|
||||
});
|
||||
return payload as { userId: string; role: string };
|
||||
}
|
||||
|
||||
export async function getAuthFromRequest(request: Request | NextRequest) {
|
||||
let token: string | null = null;
|
||||
|
||||
const header = request.headers.get('authorization');
|
||||
if (header?.startsWith('Bearer ')) {
|
||||
token = header.slice('Bearer '.length);
|
||||
const header = request.headers.get("authorization");
|
||||
if (header?.startsWith("Bearer ")) {
|
||||
token = header.slice("Bearer ".length);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const cookieHeader = request.headers.get('cookie') ?? '';
|
||||
const match = cookieHeader.split(';').map((c) => c.trim()).find((c) => c.startsWith('session_token='));
|
||||
const cookieHeader = request.headers.get("cookie") ?? "";
|
||||
const match = cookieHeader
|
||||
.split(";")
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith("session_token="));
|
||||
if (match) {
|
||||
token = decodeURIComponent(match.split('=')[1]);
|
||||
token = decodeURIComponent(match.split("=")[1]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,17 +61,19 @@ export async function getAuthFromRequest(request: Request | NextRequest) {
|
|||
export async function requireAuth(request: Request | NextRequest) {
|
||||
const auth = await getAuthFromRequest(request);
|
||||
if (!auth) {
|
||||
throw new Error('Unauthorized');
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
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;
|
||||
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() {
|
||||
return 'session_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0;';
|
||||
return "session_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0;";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
import { Prisma, ListingStatus } from '@prisma/client';
|
||||
import { prisma } from './prisma';
|
||||
import { DEFAULT_LOCALE, SAMPLE_LISTING_SLUG } from './sampleListing';
|
||||
import { Prisma, ListingStatus } from "@prisma/client";
|
||||
import { prisma } from "./prisma";
|
||||
import { DEFAULT_LOCALE, SAMPLE_LISTING_SLUG } from "./sampleListing";
|
||||
|
||||
export type ListingWithTranslations = Prisma.ListingTranslationGetPayload<{
|
||||
include: {
|
||||
listing: {
|
||||
include: {
|
||||
images: { 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;
|
||||
};
|
||||
};
|
||||
|
|
@ -19,7 +29,11 @@ type FetchOptions = {
|
|||
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) {
|
||||
return `/api/images/${img.id}`;
|
||||
}
|
||||
|
|
@ -30,26 +44,43 @@ function resolveImageUrl(img: { id: string; url: string | null; size: number | n
|
|||
* Fetch a listing translation by slug and locale.
|
||||
* Falls back to any locale if the requested locale is missing.
|
||||
*/
|
||||
export async function getListingBySlug({ slug, locale, includeOwnerDraftsForUserId }: FetchOptions): Promise<ListingWithTranslations | null> {
|
||||
export async function getListingBySlug({
|
||||
slug,
|
||||
locale,
|
||||
includeOwnerDraftsForUserId,
|
||||
}: FetchOptions): Promise<ListingWithTranslations | null> {
|
||||
const targetLocale = locale ?? DEFAULT_LOCALE;
|
||||
|
||||
const listingWhere: Prisma.ListingWhereInput =
|
||||
includeOwnerDraftsForUserId
|
||||
? {
|
||||
removedAt: null,
|
||||
OR: [
|
||||
{ status: ListingStatus.PUBLISHED },
|
||||
{ ownerId: includeOwnerDraftsForUserId, status: { in: [ListingStatus.DRAFT, ListingStatus.PENDING] } },
|
||||
],
|
||||
}
|
||||
: { status: ListingStatus.PUBLISHED, removedAt: null };
|
||||
const listingWhere: Prisma.ListingWhereInput = includeOwnerDraftsForUserId
|
||||
? {
|
||||
removedAt: null,
|
||||
OR: [
|
||||
{ status: ListingStatus.PUBLISHED },
|
||||
{
|
||||
ownerId: includeOwnerDraftsForUserId,
|
||||
status: { in: [ListingStatus.DRAFT, ListingStatus.PENDING] },
|
||||
},
|
||||
],
|
||||
}
|
||||
: { status: ListingStatus.PUBLISHED, removedAt: null };
|
||||
|
||||
const translation = await prisma.listingTranslation.findFirst({
|
||||
where: { slug, locale: targetLocale, listing: listingWhere },
|
||||
include: {
|
||||
listing: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
|
|
@ -66,23 +97,36 @@ export async function getListingBySlug({ slug, locale, includeOwnerDraftsForUser
|
|||
include: {
|
||||
listing: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export function withResolvedListingImages(translation: ListingWithTranslations): ListingWithTranslations {
|
||||
export function withResolvedListingImages(
|
||||
translation: ListingWithTranslations,
|
||||
): ListingWithTranslations {
|
||||
const images = translation.listing.images
|
||||
.map((img) => {
|
||||
const url = resolveImageUrl(img);
|
||||
if (!url) return null;
|
||||
return { ...img, url };
|
||||
})
|
||||
.filter(Boolean) as ListingWithTranslations['listing']['images'];
|
||||
.filter(Boolean) as ListingWithTranslations["listing"]["images"];
|
||||
|
||||
return {
|
||||
...translation,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
|
||||
function parseDotenv(contents: string) {
|
||||
contents
|
||||
.split('\n')
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'))
|
||||
.filter((line) => line && !line.startsWith("#"))
|
||||
.forEach((line) => {
|
||||
const idx = line.indexOf('=');
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) return;
|
||||
const key = line.slice(0, idx).trim();
|
||||
let value = line.slice(idx + 1).trim();
|
||||
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);
|
||||
}
|
||||
process.env[key] = value;
|
||||
|
|
@ -22,12 +25,12 @@ function parseDotenv(contents: string) {
|
|||
|
||||
export function loadLocalSecrets() {
|
||||
const root = process.cwd();
|
||||
const plainPath = path.join(root, 'creds', 'secrets.env');
|
||||
const encPath = path.join(root, 'creds', 'secrets.enc.env');
|
||||
const plainPath = path.join(root, "creds", "secrets.env");
|
||||
const encPath = path.join(root, "creds", "secrets.enc.env");
|
||||
|
||||
if (fs.existsSync(plainPath)) {
|
||||
try {
|
||||
parseDotenv(fs.readFileSync(plainPath, 'utf8'));
|
||||
parseDotenv(fs.readFileSync(plainPath, "utf8"));
|
||||
return;
|
||||
} catch {
|
||||
// ignore and try encrypted
|
||||
|
|
@ -36,7 +39,9 @@ export function loadLocalSecrets() {
|
|||
|
||||
if (fs.existsSync(encPath) && !process.env.SKIP_SOPS_AUTOLOAD) {
|
||||
try {
|
||||
const output = execFileSync('sops', ['-d', encPath], { encoding: 'utf8' });
|
||||
const output = execFileSync("sops", ["-d", encPath], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
parseDotenv(output);
|
||||
} catch {
|
||||
// silent fail if sops/key not available
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import fs from 'fs';
|
||||
import nodemailer from 'nodemailer';
|
||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
import path from 'path';
|
||||
import fs from "fs";
|
||||
import nodemailer from "nodemailer";
|
||||
import path from "path";
|
||||
|
||||
type MailOptions = {
|
||||
to: string;
|
||||
|
|
@ -26,13 +25,15 @@ async function createTransport() {
|
|||
} = process.env;
|
||||
|
||||
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 requireTLS = SMTP_TLS === 'true';
|
||||
const secure = SMTP_SSL === "true";
|
||||
const requireTLS = SMTP_TLS === "true";
|
||||
|
||||
const transporterOptions: SMTPTransport.Options = {
|
||||
const transporterOptions: Record<string, any> = {
|
||||
host: SMTP_HOST,
|
||||
port: Number(SMTP_PORT),
|
||||
secure,
|
||||
|
|
@ -40,7 +41,7 @@ async function createTransport() {
|
|||
auth: { user: SMTP_USER, pass: SMTP_PASS },
|
||||
};
|
||||
|
||||
if (SMTP_REJECT_UNAUTHORIZED === 'false') {
|
||||
if (SMTP_REJECT_UNAUTHORIZED === "false") {
|
||||
transporterOptions.tls = { rejectUnauthorized: false };
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +51,7 @@ async function createTransport() {
|
|||
transporterOptions.dkim = {
|
||||
domainName: DKIM_DOMAIN,
|
||||
keySelector: DKIM_SELECTOR,
|
||||
privateKey: fs.readFileSync(keyPath, 'utf8'),
|
||||
privateKey: fs.readFileSync(keyPath, "utf8"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +59,7 @@ async function createTransport() {
|
|||
return nodemailer.createTransport(transporterOptions);
|
||||
}
|
||||
|
||||
let cachedTransport: nodemailer.Transporter | null = null;
|
||||
let cachedTransport: any = null;
|
||||
|
||||
async function getTransport() {
|
||||
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) {
|
||||
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 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 });
|
||||
}
|
||||
|
||||
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 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 });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
import { prisma } from './prisma';
|
||||
import fs from "fs";
|
||||
import https from "https";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
type HetznerServerSummary = {
|
||||
id: number;
|
||||
|
|
@ -80,7 +80,7 @@ export type DbStatus = {
|
|||
|
||||
function readFileSafe(path: string): string | null {
|
||||
try {
|
||||
return fs.readFileSync(path, 'utf8');
|
||||
return fs.readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -89,17 +89,24 @@ function readFileSafe(path: string): string | null {
|
|||
export async function fetchHetznerServers(): Promise<HetznerStatus> {
|
||||
const token = process.env.HCLOUD_TOKEN ?? process.env.HETZNER_TOKEN;
|
||||
if (!token) {
|
||||
return { ok: false, missingToken: true, error: 'HCLOUD_TOKEN not configured' };
|
||||
return {
|
||||
ok: false,
|
||||
missingToken: true,
|
||||
error: "HCLOUD_TOKEN not configured",
|
||||
};
|
||||
}
|
||||
|
||||
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}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
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 servers =
|
||||
|
|
@ -108,22 +115,30 @@ export async function fetchHetznerServers(): Promise<HetznerStatus> {
|
|||
name: s.name,
|
||||
status: s.status,
|
||||
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,
|
||||
privateIp: s.private_net?.[0]?.ip,
|
||||
created: s.created,
|
||||
})) ?? [];
|
||||
return { ok: true, servers };
|
||||
} 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 {
|
||||
const token = readFileSafe('/var/run/secrets/kubernetes.io/serviceaccount/token');
|
||||
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';
|
||||
const token = readFileSafe(
|
||||
"/var/run/secrets/kubernetes.io/serviceaccount/token",
|
||||
);
|
||||
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) {
|
||||
return {
|
||||
|
|
@ -136,10 +151,10 @@ function loadKubernetesConfig(): KubernetesClientConfig | null {
|
|||
if (process.env.K8S_API_SERVER && process.env.K8S_BEARER_TOKEN) {
|
||||
const caCert = process.env.K8S_CA_CERT;
|
||||
return {
|
||||
server: process.env.K8S_API_SERVER.replace(/\/$/, ''),
|
||||
server: process.env.K8S_API_SERVER.replace(/\/$/, ""),
|
||||
token: process.env.K8S_BEARER_TOKEN,
|
||||
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> {
|
||||
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}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
headers,
|
||||
ca: cfg.ca,
|
||||
rejectUnauthorized: cfg.insecureSkipTlsVerify ? false : true,
|
||||
|
|
@ -163,11 +178,15 @@ function k8sRequest(path: string, cfg: KubernetesClientConfig): Promise<any> {
|
|||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (d) => chunks.push(typeof d === 'string' ? Buffer.from(d) : d));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
res.on("data", (d) =>
|
||||
chunks.push(typeof d === "string" ? Buffer.from(d) : d),
|
||||
);
|
||||
res.on("end", () => {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
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;
|
||||
}
|
||||
try {
|
||||
|
|
@ -178,8 +197,10 @@ function k8sRequest(path: string, cfg: KubernetesClientConfig): Promise<any> {
|
|||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('Kubernetes request timed out')));
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () =>
|
||||
req.destroy(new Error("Kubernetes request timed out")),
|
||||
);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
|
@ -189,15 +210,24 @@ function parseK8sNodes(data: any): KubernetesNodeSummary[] {
|
|||
return items.map((node) => {
|
||||
const labels = node?.metadata?.labels ?? {};
|
||||
const roles = Object.keys(labels)
|
||||
.filter((key) => key.startsWith('node-role.kubernetes.io/'))
|
||||
.map((key) => key.replace('node-role.kubernetes.io/', '') || 'control-plane');
|
||||
const readyCondition = (node?.status?.conditions ?? []).find((c: any) => c.type === 'Ready');
|
||||
.filter((key) => key.startsWith("node-role.kubernetes.io/"))
|
||||
.map(
|
||||
(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 internal = addresses.find((a: any) => a.type === 'InternalIP')?.address;
|
||||
const internal = addresses.find(
|
||||
(a: any) => a.type === "InternalIP",
|
||||
)?.address;
|
||||
return {
|
||||
name: node?.metadata?.name ?? 'unknown',
|
||||
ready: readyCondition?.status === 'True',
|
||||
status: readyCondition?.status === 'True' ? 'Ready' : readyCondition?.reason ?? 'NotReady',
|
||||
name: node?.metadata?.name ?? "unknown",
|
||||
ready: readyCondition?.status === "True",
|
||||
status:
|
||||
readyCondition?.status === "True"
|
||||
? "Ready"
|
||||
: (readyCondition?.reason ?? "NotReady"),
|
||||
roles,
|
||||
internalIp: internal,
|
||||
kubeletVersion: node?.status?.nodeInfo?.kubeletVersion,
|
||||
|
|
@ -208,33 +238,37 @@ function parseK8sNodes(data: any): KubernetesNodeSummary[] {
|
|||
}
|
||||
|
||||
function parseContainerState(status: any): string {
|
||||
if (!status) return 'unknown';
|
||||
if (status.state?.running) return 'running';
|
||||
if (!status) return "unknown";
|
||||
if (status.state?.running) return "running";
|
||||
if (status.state?.waiting?.reason) return status.state.waiting.reason;
|
||||
if (status.state?.terminated?.reason) return status.state.terminated.reason;
|
||||
if (status.state?.waiting) return 'waiting';
|
||||
if (status.state?.terminated) return 'terminated';
|
||||
return 'unknown';
|
||||
if (status.state?.waiting) return "waiting";
|
||||
if (status.state?.terminated) return "terminated";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function parseLastState(status: any): string | null {
|
||||
if (!status?.lastState) return null;
|
||||
const { lastState } = status;
|
||||
if (lastState.terminated?.reason) return `terminated: ${lastState.terminated.reason}`;
|
||||
if (lastState.terminated?.exitCode !== undefined) return `terminated: code ${lastState.terminated.exitCode}`;
|
||||
if (lastState.terminated?.reason)
|
||||
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}`;
|
||||
return 'previous state recorded';
|
||||
return "previous state recorded";
|
||||
}
|
||||
|
||||
function parseK8sPods(data: any): KubernetesPodSummary[] {
|
||||
const items: any[] = data?.items ?? [];
|
||||
return items
|
||||
.filter((pod) => {
|
||||
const ns = pod?.metadata?.namespace ?? '';
|
||||
return ns.startsWith('lomavuokraus-') || ns === 'default';
|
||||
const ns = pod?.metadata?.namespace ?? "";
|
||||
return ns.startsWith("lomavuokraus-") || ns === "default";
|
||||
})
|
||||
.map((pod) => {
|
||||
const containers: KubernetesPodContainer[] = (pod?.status?.containerStatuses ?? []).map((c: any) => ({
|
||||
const containers: KubernetesPodContainer[] = (
|
||||
pod?.status?.containerStatuses ?? []
|
||||
).map((c: any) => ({
|
||||
name: c.name,
|
||||
ready: Boolean(c.ready),
|
||||
restartCount: Number(c.restartCount ?? 0),
|
||||
|
|
@ -244,9 +278,9 @@ function parseK8sPods(data: any): KubernetesPodSummary[] {
|
|||
const readyCount = containers.filter((c) => c.ready).length;
|
||||
const restarts = containers.reduce((sum, c) => sum + c.restartCount, 0);
|
||||
return {
|
||||
name: pod?.metadata?.name ?? 'pod',
|
||||
namespace: pod?.metadata?.namespace ?? 'unknown',
|
||||
phase: pod?.status?.phase ?? 'Unknown',
|
||||
name: pod?.metadata?.name ?? "pod",
|
||||
namespace: pod?.metadata?.namespace ?? "unknown",
|
||||
phase: pod?.status?.phase ?? "Unknown",
|
||||
reason: pod?.status?.reason ?? null,
|
||||
message: pod?.status?.message ?? null,
|
||||
readyCount,
|
||||
|
|
@ -264,21 +298,30 @@ function parseK8sPods(data: any): KubernetesPodSummary[] {
|
|||
export async function fetchKubernetesStatus(): Promise<KubernetesStatus> {
|
||||
const config = loadKubernetesConfig();
|
||||
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 {
|
||||
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) };
|
||||
} 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> {
|
||||
try {
|
||||
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
|
||||
`,
|
||||
prisma.$queryRaw<{ state: string | null; count: number }[]>`
|
||||
|
|
@ -287,10 +330,20 @@ export async function fetchDbStatus(): Promise<DbStatus> {
|
|||
]);
|
||||
|
||||
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 sizeNumber = typeof sizeVal === 'bigint' ? Number(sizeVal) : Number(sizeVal);
|
||||
const connections = activity?.map((a) => ({ state: a.state ?? 'unknown', count: Number(a.count ?? 0) })) ?? [];
|
||||
const sizeNumber =
|
||||
typeof sizeVal === "bigint" ? Number(sizeVal) : Number(sizeVal);
|
||||
const connections =
|
||||
activity?.map((a) => ({
|
||||
state: a.state ?? "unknown",
|
||||
count: Number(a.count ?? 0),
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
|
@ -300,6 +353,6 @@ export async function fetchDbStatus(): Promise<DbStatus> {
|
|||
connections,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return { ok: false, error: error?.message ?? 'Database query failed' };
|
||||
return { ok: false, error: error?.message ?? "Database query failed" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { Pool } from 'pg';
|
||||
import { loadLocalSecrets } from './loadSecrets';
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { Pool } from "pg";
|
||||
import { loadLocalSecrets } from "./loadSecrets";
|
||||
|
||||
loadLocalSecrets();
|
||||
|
||||
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;
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
const adapter = new PrismaPg(pool);
|
||||
|
|
@ -16,9 +18,12 @@ export const prisma =
|
|||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
'saimaa-lakeside-cabin',
|
||||
'helsinki-design-loft',
|
||||
'turku-riverside-apartment',
|
||||
'rovaniemi-aurora-cabin',
|
||||
'tampere-sauna-studio',
|
||||
'vaasa-seaside-villa',
|
||||
'kuopio-lakeside-apartment',
|
||||
'porvoo-river-loft',
|
||||
'oulu-tech-apartment',
|
||||
'mariehamn-harbor-flat',
|
||||
"saimaa-lakeside-cabin",
|
||||
"helsinki-design-loft",
|
||||
"turku-riverside-apartment",
|
||||
"rovaniemi-aurora-cabin",
|
||||
"tampere-sauna-studio",
|
||||
"vaasa-seaside-villa",
|
||||
"kuopio-lakeside-apartment",
|
||||
"porvoo-river-loft",
|
||||
"oulu-tech-apartment",
|
||||
"mariehamn-harbor-flat",
|
||||
];
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
export const DEFAULT_LOCALE = "en";
|
||||
|
|
|
|||
|
|
@ -1,36 +1,46 @@
|
|||
import { prisma } from './prisma';
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export type SiteSettings = {
|
||||
requireLoginForContactDetails: boolean;
|
||||
};
|
||||
|
||||
const SETTINGS_ID = 'default';
|
||||
const SETTINGS_ID = "default";
|
||||
|
||||
const DEFAULT_SETTINGS: SiteSettings = {
|
||||
requireLoginForContactDetails: true,
|
||||
};
|
||||
|
||||
function mergeSettings(input: Partial<SiteSettings> | null | undefined): SiteSettings {
|
||||
function mergeSettings(
|
||||
input: Partial<SiteSettings> | null | undefined,
|
||||
): SiteSettings {
|
||||
return {
|
||||
requireLoginForContactDetails:
|
||||
input?.requireLoginForContactDetails ?? DEFAULT_SETTINGS.requireLoginForContactDetails,
|
||||
input?.requireLoginForContactDetails ??
|
||||
DEFAULT_SETTINGS.requireLoginForContactDetails,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSiteSettings(): Promise<SiteSettings> {
|
||||
try {
|
||||
const existing = await prisma.siteSettings.findUnique({ where: { id: SETTINGS_ID } });
|
||||
const existing = await prisma.siteSettings.findUnique({
|
||||
where: { id: SETTINGS_ID },
|
||||
});
|
||||
if (!existing) {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
return mergeSettings(existing);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSiteSettings(input: Partial<SiteSettings>): Promise<SiteSettings> {
|
||||
export async function updateSiteSettings(
|
||||
input: Partial<SiteSettings>,
|
||||
): Promise<SiteSettings> {
|
||||
const current = await getSiteSettings();
|
||||
const data = mergeSettings({ ...current, ...input });
|
||||
const saved = await prisma.siteSettings.upsert({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import crypto from 'crypto';
|
||||
import crypto from "crypto";
|
||||
|
||||
export function randomToken(bytes = 32): string {
|
||||
return crypto.randomBytes(bytes).toString('base64url');
|
||||
return crypto.randomBytes(bytes).toString("base64url");
|
||||
}
|
||||
|
||||
export function addHours(hours: number): Date {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthFromRequest } from './lib/jwt';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuthFromRequest } from "./lib/jwt";
|
||||
|
||||
const ADMIN_ONLY_PATHS = ['/admin/users', '/admin/monitor', '/admin/settings'];
|
||||
const MODERATOR_PATHS = ['/admin/pending'];
|
||||
const ADMIN_ONLY_PATHS = ["/admin/users", "/admin/monitor", "/admin/settings"];
|
||||
const MODERATOR_PATHS = ["/admin/pending"];
|
||||
|
||||
function buildLoginRedirect(req: NextRequest) {
|
||||
const url = new URL('/auth/login', req.url);
|
||||
url.searchParams.set('redirect', req.nextUrl.pathname + req.nextUrl.search);
|
||||
const url = new URL("/auth/login", req.url);
|
||||
url.searchParams.set("redirect", req.nextUrl.pathname + req.nextUrl.search);
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
if (!pathname.startsWith('/admin')) {
|
||||
if (!pathname.startsWith("/admin")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
|
@ -23,18 +23,25 @@ export async function middleware(req: NextRequest) {
|
|||
|
||||
const role = session.role;
|
||||
const isAdminOnly = ADMIN_ONLY_PATHS.some((p) => pathname.startsWith(p));
|
||||
if (isAdminOnly && role !== 'ADMIN') {
|
||||
return NextResponse.redirect(new URL('/', req.url));
|
||||
if (isAdminOnly && role !== "ADMIN") {
|
||||
return NextResponse.redirect(new URL("/", req.url));
|
||||
}
|
||||
|
||||
const isModeratorPath = MODERATOR_PATHS.some((p) => pathname.startsWith(p));
|
||||
if (isModeratorPath && !(role === 'ADMIN' || role === 'USER_MODERATOR' || role === 'LISTING_MODERATOR')) {
|
||||
return NextResponse.redirect(new URL('/', req.url));
|
||||
if (
|
||||
isModeratorPath &&
|
||||
!(
|
||||
role === "ADMIN" ||
|
||||
role === "USER_MODERATOR" ||
|
||||
role === "LISTING_MODERATOR"
|
||||
)
|
||||
) {
|
||||
return NextResponse.redirect(new URL("/", req.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/admin/:path*'],
|
||||
matcher: ["/admin/:path*"],
|
||||
};
|
||||
|
|
|
|||
3
next-env.d.ts
vendored
3
next-env.d.ts
vendored
|
|
@ -1,5 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
typedRoutes: true
|
||||
}
|
||||
typedRoutes: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
2475
package-lock.json
generated
2475
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -13,27 +13,26 @@
|
|||
"test": "echo \"No tests yet\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.0.0",
|
||||
"@prisma/client": "^7.0.0",
|
||||
"@prisma/adapter-pg": "^7.3.0",
|
||||
"@prisma/client": "^7.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"jose": "^6.1.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^14.2.32",
|
||||
"next": "^15.5.11",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pg": "^8.16.3",
|
||||
"prisma": "^7.0.0",
|
||||
"prisma": "^7.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/pg": "^8.15.6",
|
||||
"@types/react": "^18.2.67",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.2.32",
|
||||
"eslint-config-next": "^15.5.11",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
// This file was generated by Prisma and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import 'dotenv/config';
|
||||
import { defineConfig } from 'prisma/config';
|
||||
import { loadLocalSecrets } from './lib/loadSecrets';
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
import { loadLocalSecrets } from "./lib/loadSecrets";
|
||||
|
||||
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({
|
||||
schema: 'prisma/schema.prisma',
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
// Fallback to a local dev URL so builds/linting can run without secrets
|
||||
|
|
|
|||
549
prisma/seed.js
549
prisma/seed.js
|
|
@ -1,35 +1,44 @@
|
|||
/* eslint-disable no-console */
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
if (fs.existsSync(path.join(__dirname, '..', 'creds', '.env'))) {
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', 'creds', '.env') });
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
require("dotenv").config({ path: path.join(__dirname, "..", ".env") });
|
||||
if (fs.existsSync(path.join(__dirname, "..", "creds", ".env"))) {
|
||||
require("dotenv").config({
|
||||
path: path.join(__dirname, "..", "creds", ".env"),
|
||||
});
|
||||
}
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { PrismaClient, Role, UserStatus, ListingStatus } = require('@prisma/client');
|
||||
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
const { Pool } = require('pg');
|
||||
const bcrypt = require("bcryptjs");
|
||||
const {
|
||||
PrismaClient,
|
||||
Role,
|
||||
UserStatus,
|
||||
ListingStatus,
|
||||
} = require("@prisma/client");
|
||||
const { PrismaPg } = require("@prisma/adapter-pg");
|
||||
const { Pool } = require("pg");
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('DATABASE_URL is not set; cannot seed.');
|
||||
console.error("DATABASE_URL is not set; cannot seed.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg(new Pool({ connectionString: process.env.DATABASE_URL })),
|
||||
adapter: new PrismaPg(
|
||||
new Pool({ connectionString: process.env.DATABASE_URL }),
|
||||
),
|
||||
});
|
||||
|
||||
const SAMPLE_SLUG = 'saimaa-lakeside-cabin';
|
||||
const DEFAULT_LOCALE = 'en';
|
||||
const SAMPLE_EMAIL = 'host@lomavuokraus.fi';
|
||||
const SAMPLE_IMAGE_DIR = path.join(__dirname, '..', 'sampleimages');
|
||||
const SAMPLE_SLUG = "saimaa-lakeside-cabin";
|
||||
const DEFAULT_LOCALE = "en";
|
||||
const SAMPLE_EMAIL = "host@lomavuokraus.fi";
|
||||
const SAMPLE_IMAGE_DIR = path.join(__dirname, "..", "sampleimages");
|
||||
|
||||
function detectMimeType(fileName) {
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
if (ext === '.png') return 'image/png';
|
||||
if (ext === '.webp') return 'image/webp';
|
||||
if (ext === '.gif') return 'image/gif';
|
||||
return 'image/jpeg';
|
||||
if (ext === ".png") return "image/png";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
if (ext === ".gif") return "image/gif";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
function loadSampleImage(fileName) {
|
||||
|
|
@ -58,7 +67,7 @@ async function main() {
|
|||
data: coverFile?.data,
|
||||
mimeType: coverFile?.mimeType || (item.cover.url ? null : undefined),
|
||||
size: coverFile?.size ?? null,
|
||||
url: coverFile ? null : item.cover.url ?? null,
|
||||
url: coverFile ? null : (item.cover.url ?? null),
|
||||
altText: item.cover.altText ?? null,
|
||||
order: 1,
|
||||
isCover: true,
|
||||
|
|
@ -73,7 +82,7 @@ async function main() {
|
|||
data: file?.data,
|
||||
mimeType: file?.mimeType || (img.url ? null : undefined),
|
||||
size: file?.size ?? null,
|
||||
url: file ? null : img.url ?? null,
|
||||
url: file ? null : (img.url ?? null),
|
||||
altText: img.altText ?? null,
|
||||
order: results.length + 1,
|
||||
isCover: false,
|
||||
|
|
@ -84,7 +93,9 @@ async function main() {
|
|||
}
|
||||
|
||||
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;
|
||||
|
|
@ -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({
|
||||
where: { email: SAMPLE_EMAIL },
|
||||
update: {
|
||||
name: 'Sample Host',
|
||||
phone: '+358401234567',
|
||||
role: 'USER',
|
||||
name: "Sample Host",
|
||||
phone: "+358401234567",
|
||||
role: "USER",
|
||||
passwordHash: sampleHostHash,
|
||||
status: UserStatus.ACTIVE,
|
||||
emailVerifiedAt: new Date(),
|
||||
|
|
@ -124,9 +135,9 @@ async function main() {
|
|||
},
|
||||
create: {
|
||||
email: SAMPLE_EMAIL,
|
||||
name: 'Sample Host',
|
||||
phone: '+358401234567',
|
||||
role: 'USER',
|
||||
name: "Sample Host",
|
||||
phone: "+358401234567",
|
||||
role: "USER",
|
||||
passwordHash: sampleHostHash,
|
||||
status: UserStatus.ACTIVE,
|
||||
emailVerifiedAt: new Date(),
|
||||
|
|
@ -138,11 +149,11 @@ async function main() {
|
|||
{
|
||||
slug: SAMPLE_SLUG,
|
||||
isSample: true,
|
||||
city: 'Punkaharju',
|
||||
region: 'South Karelia',
|
||||
country: 'Finland',
|
||||
streetAddress: 'Saimaan rantatie 12',
|
||||
addressNote: 'Lakeside trail, 5 min from main road',
|
||||
city: "Punkaharju",
|
||||
region: "South Karelia",
|
||||
country: "Finland",
|
||||
streetAddress: "Saimaan rantatie 12",
|
||||
addressNote: "Lakeside trail, 5 min from main road",
|
||||
latitude: 61.756,
|
||||
longitude: 29.328,
|
||||
maxGuests: 6,
|
||||
|
|
@ -166,31 +177,39 @@ async function main() {
|
|||
priceWeekdayEuros: 145,
|
||||
priceWeekendEuros: 165,
|
||||
cover: {
|
||||
file: 'saimaa-lakeside-cabin-cover.jpg',
|
||||
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
|
||||
altText: 'Lakeside cabin with sauna',
|
||||
file: "saimaa-lakeside-cabin-cover.jpg",
|
||||
url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
|
||||
altText: "Lakeside cabin with sauna",
|
||||
},
|
||||
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",
|
||||
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",
|
||||
},
|
||||
],
|
||||
titleEn: 'Saimaa lakeside cabin with sauna',
|
||||
teaserEn: 'Sauna, lake view, private dock, and cozy 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.',
|
||||
"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.',
|
||||
"Perinteinen hirsimökki Saimaan rannalla. Puusauna, oma laituri ja lyhyt metsäreitti kylään. Sopii täydellisesti viikonloppuihin ja juhannukseen.",
|
||||
},
|
||||
{
|
||||
slug: 'helsinki-design-loft',
|
||||
slug: "helsinki-design-loft",
|
||||
isSample: true,
|
||||
city: 'Helsinki',
|
||||
region: 'Uusimaa',
|
||||
country: 'Finland',
|
||||
streetAddress: 'Katajanokanranta 4',
|
||||
addressNote: 'Buzz 12B, elevator to 5th floor',
|
||||
city: "Helsinki",
|
||||
region: "Uusimaa",
|
||||
country: "Finland",
|
||||
streetAddress: "Katajanokanranta 4",
|
||||
addressNote: "Buzz 12B, elevator to 5th floor",
|
||||
latitude: 60.1675,
|
||||
longitude: 24.9529,
|
||||
maxGuests: 4,
|
||||
|
|
@ -214,29 +233,39 @@ async function main() {
|
|||
priceWeekdayEuros: 165,
|
||||
priceWeekendEuros: 185,
|
||||
cover: {
|
||||
file: 'helsinki-design-loft-cover.jpg',
|
||||
url: 'https://images.unsplash.com/photo-1505693415763-3bd1620f58c3?auto=format&fit=crop&w=1600&q=80',
|
||||
altText: 'Modern loft living room',
|
||||
file: "helsinki-design-loft-cover.jpg",
|
||||
url: "https://images.unsplash.com/photo-1505693415763-3bd1620f58c3?auto=format&fit=crop&w=1600&q=80",
|
||||
altText: "Modern loft living room",
|
||||
},
|
||||
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",
|
||||
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",
|
||||
},
|
||||
],
|
||||
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.',
|
||||
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',
|
||||
slug: "turku-riverside-apartment",
|
||||
isSample: true,
|
||||
city: 'Turku',
|
||||
region: 'Varsinais-Suomi',
|
||||
country: 'Finland',
|
||||
streetAddress: 'Läntinen Rantakatu 10',
|
||||
addressNote: 'Self check-in lockbox',
|
||||
city: "Turku",
|
||||
region: "Varsinais-Suomi",
|
||||
country: "Finland",
|
||||
streetAddress: "Läntinen Rantakatu 10",
|
||||
addressNote: "Self check-in lockbox",
|
||||
latitude: 60.4518,
|
||||
longitude: 22.2666,
|
||||
maxGuests: 3,
|
||||
|
|
@ -260,28 +289,34 @@ async function main() {
|
|||
priceWeekdayEuros: 110,
|
||||
priceWeekendEuros: 125,
|
||||
cover: {
|
||||
file: 'turku-riverside-apartment-cover.jpg',
|
||||
url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80',
|
||||
altText: 'Apartment living room',
|
||||
file: "turku-riverside-apartment-cover.jpg",
|
||||
url: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80",
|
||||
altText: "Apartment living room",
|
||||
},
|
||||
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',
|
||||
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.',
|
||||
titleFi: 'Aurajoen varrella, lemmikkiystävällinen',
|
||||
teaserFi: 'Aurajoen kupeessa, lemmikit sallittu.',
|
||||
descFi: 'Kompakti yksiö Aurajoen varrella. Kahvilat vieressä, lemmikit sallittu, kuitunetti etätöihin.',
|
||||
titleEn: "Riverside apartment in Turku",
|
||||
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.",
|
||||
titleFi: "Aurajoen varrella, lemmikkiystävällinen",
|
||||
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,
|
||||
city: 'Rovaniemi',
|
||||
region: 'Lapland',
|
||||
country: 'Finland',
|
||||
streetAddress: 'Ounasjoenkuja 8',
|
||||
addressNote: 'Snow tires required in winter',
|
||||
city: "Rovaniemi",
|
||||
region: "Lapland",
|
||||
country: "Finland",
|
||||
streetAddress: "Ounasjoenkuja 8",
|
||||
addressNote: "Snow tires required in winter",
|
||||
latitude: 66.5039,
|
||||
longitude: 25.7294,
|
||||
maxGuests: 5,
|
||||
|
|
@ -306,28 +341,34 @@ async function main() {
|
|||
priceWeekdayEuros: 189,
|
||||
priceWeekendEuros: 215,
|
||||
cover: {
|
||||
file: 'rovaniemi-aurora-cabin-cover.jpg',
|
||||
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80',
|
||||
altText: 'Aurora cabin by the river',
|
||||
file: "rovaniemi-aurora-cabin-cover.jpg",
|
||||
url: "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80",
|
||||
altText: "Aurora cabin by the river",
|
||||
},
|
||||
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',
|
||||
teaserEn: 'Sauna, fireplace, river views, EV charging.',
|
||||
descEn: 'Timber cabin on the Ounasjoki riverside. Wood sauna, fireplace, glass lounge for auroras, free EV charging.',
|
||||
titleFi: 'Revontulikämppä joen rannalla',
|
||||
teaserFi: 'Sauna, takka, jokinäkymä ja ilmainen lataus.',
|
||||
descFi: 'Hirsimökki Ounasjoen rannalla. Puusauna, takka ja lasikuisti revontulien katseluun, ilmainen sähköauton lataus.',
|
||||
titleEn: "Aurora riverside cabin",
|
||||
teaserEn: "Sauna, fireplace, river views, EV charging.",
|
||||
descEn:
|
||||
"Timber cabin on the Ounasjoki riverside. Wood sauna, fireplace, glass lounge for auroras, free EV charging.",
|
||||
titleFi: "Revontulikämppä joen rannalla",
|
||||
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,
|
||||
city: 'Tampere',
|
||||
region: 'Pirkanmaa',
|
||||
country: 'Finland',
|
||||
streetAddress: 'Hämeenkatu 25',
|
||||
addressNote: 'Key pickup from lobby',
|
||||
city: "Tampere",
|
||||
region: "Pirkanmaa",
|
||||
country: "Finland",
|
||||
streetAddress: "Hämeenkatu 25",
|
||||
addressNote: "Key pickup from lobby",
|
||||
latitude: 61.4981,
|
||||
longitude: 23.7608,
|
||||
maxGuests: 2,
|
||||
|
|
@ -351,26 +392,34 @@ async function main() {
|
|||
priceWeekdayEuros: 95,
|
||||
priceWeekendEuros: 110,
|
||||
cover: {
|
||||
file: 'tampere-sauna-studio-cover.jpg',
|
||||
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
|
||||
altText: 'Studio interior',
|
||||
file: "tampere-sauna-studio-cover.jpg",
|
||||
url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
|
||||
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' }],
|
||||
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ä.',
|
||||
images: [
|
||||
{
|
||||
file: "tampere-sauna-studio-sauna.jpg",
|
||||
url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
|
||||
altText: "Private sauna",
|
||||
},
|
||||
],
|
||||
titleEn: "Tampere studio with private sauna",
|
||||
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,
|
||||
city: 'Vaasa',
|
||||
region: 'Ostrobothnia',
|
||||
country: 'Finland',
|
||||
streetAddress: 'Rantakatu 3',
|
||||
addressNote: 'Parking for 3 cars',
|
||||
city: "Vaasa",
|
||||
region: "Ostrobothnia",
|
||||
country: "Finland",
|
||||
streetAddress: "Rantakatu 3",
|
||||
addressNote: "Parking for 3 cars",
|
||||
latitude: 63.096,
|
||||
longitude: 21.6158,
|
||||
maxGuests: 8,
|
||||
|
|
@ -395,26 +444,34 @@ async function main() {
|
|||
priceWeekdayEuros: 245,
|
||||
priceWeekendEuros: 275,
|
||||
cover: {
|
||||
file: 'vaasa-seaside-villa-cover.jpg',
|
||||
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
|
||||
altText: 'Seaside villa deck',
|
||||
file: "vaasa-seaside-villa-cover.jpg",
|
||||
url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
|
||||
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' }],
|
||||
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.',
|
||||
images: [
|
||||
{
|
||||
file: "vaasa-seaside-villa-lounge.jpg",
|
||||
url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
|
||||
altText: "Seaside villa lounge",
|
||||
},
|
||||
],
|
||||
titleEn: "Seaside villa in Vaasa",
|
||||
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,
|
||||
city: 'Kuopio',
|
||||
region: 'Northern Savonia',
|
||||
country: 'Finland',
|
||||
streetAddress: 'Satamakatu 7',
|
||||
addressNote: 'Underground parking',
|
||||
city: "Kuopio",
|
||||
region: "Northern Savonia",
|
||||
country: "Finland",
|
||||
streetAddress: "Satamakatu 7",
|
||||
addressNote: "Underground parking",
|
||||
latitude: 62.8924,
|
||||
longitude: 27.6783,
|
||||
maxGuests: 4,
|
||||
|
|
@ -439,26 +496,34 @@ async function main() {
|
|||
priceWeekdayEuros: 129,
|
||||
priceWeekendEuros: 149,
|
||||
cover: {
|
||||
file: 'kuopio-lakeside-apartment-cover.jpg',
|
||||
url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80',
|
||||
altText: 'Lake view balcony',
|
||||
file: "kuopio-lakeside-apartment-cover.jpg",
|
||||
url: "https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80",
|
||||
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' }],
|
||||
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.',
|
||||
images: [
|
||||
{
|
||||
file: "kuopio-lakeside-apartment-sauna.jpg",
|
||||
url: "https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80",
|
||||
altText: "Apartment sauna",
|
||||
},
|
||||
],
|
||||
titleEn: "Kuopio lakeside apartment with sauna",
|
||||
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,
|
||||
city: 'Porvoo',
|
||||
region: 'Uusimaa',
|
||||
country: 'Finland',
|
||||
streetAddress: 'Mannerheiminkatu 12',
|
||||
addressNote: 'Historic building, stairs only',
|
||||
city: "Porvoo",
|
||||
region: "Uusimaa",
|
||||
country: "Finland",
|
||||
streetAddress: "Mannerheiminkatu 12",
|
||||
addressNote: "Historic building, stairs only",
|
||||
latitude: 60.3943,
|
||||
longitude: 25.6659,
|
||||
maxGuests: 2,
|
||||
|
|
@ -482,26 +547,34 @@ async function main() {
|
|||
priceWeekdayEuros: 99,
|
||||
priceWeekendEuros: 115,
|
||||
cover: {
|
||||
file: 'porvoo-river-loft-cover.jpg',
|
||||
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
|
||||
altText: 'Loft interior',
|
||||
file: "porvoo-river-loft-cover.jpg",
|
||||
url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
|
||||
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' }],
|
||||
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.',
|
||||
images: [
|
||||
{
|
||||
file: "porvoo-river-loft-fireplace.jpg",
|
||||
url: "https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80",
|
||||
altText: "Fireplace corner",
|
||||
},
|
||||
],
|
||||
titleEn: "Porvoo old town river loft",
|
||||
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,
|
||||
city: 'Oulu',
|
||||
region: 'Northern Ostrobothnia',
|
||||
country: 'Finland',
|
||||
streetAddress: 'Technopolis 2',
|
||||
addressNote: 'Smart lock entry',
|
||||
city: "Oulu",
|
||||
region: "Northern Ostrobothnia",
|
||||
country: "Finland",
|
||||
streetAddress: "Technopolis 2",
|
||||
addressNote: "Smart lock entry",
|
||||
latitude: 65.0121,
|
||||
longitude: 25.4651,
|
||||
maxGuests: 2,
|
||||
|
|
@ -525,26 +598,34 @@ async function main() {
|
|||
priceWeekdayEuros: 105,
|
||||
priceWeekendEuros: 120,
|
||||
cover: {
|
||||
file: 'oulu-tech-apartment-cover.jpg',
|
||||
url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80',
|
||||
altText: 'Modern apartment',
|
||||
file: "oulu-tech-apartment-cover.jpg",
|
||||
url: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80",
|
||||
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' }],
|
||||
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.',
|
||||
images: [
|
||||
{
|
||||
file: "oulu-tech-apartment-desk.jpg",
|
||||
url: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80",
|
||||
altText: "Work desk in apartment",
|
||||
},
|
||||
],
|
||||
titleEn: "Smart apartment in Oulu",
|
||||
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,
|
||||
city: 'Mariehamn',
|
||||
region: 'Åland',
|
||||
country: 'Finland',
|
||||
streetAddress: 'Hamngatan 5',
|
||||
addressNote: 'Ferry terminal 10 min walk',
|
||||
city: "Mariehamn",
|
||||
region: "Åland",
|
||||
country: "Finland",
|
||||
streetAddress: "Hamngatan 5",
|
||||
addressNote: "Ferry terminal 10 min walk",
|
||||
latitude: 60.0973,
|
||||
longitude: 19.9348,
|
||||
maxGuests: 3,
|
||||
|
|
@ -568,30 +649,41 @@ async function main() {
|
|||
priceWeekdayEuros: 115,
|
||||
priceWeekendEuros: 130,
|
||||
cover: {
|
||||
file: 'mariehamn-harbor-flat-cover.jpg',
|
||||
url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80',
|
||||
altText: 'Harbor view',
|
||||
file: "mariehamn-harbor-flat-cover.jpg",
|
||||
url: "https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80",
|
||||
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' }],
|
||||
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.',
|
||||
images: [
|
||||
{
|
||||
file: "mariehamn-harbor-flat-living.jpg",
|
||||
url: "https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80",
|
||||
altText: "Harbor-facing living room",
|
||||
},
|
||||
],
|
||||
titleEn: "Harbor flat in Mariehamn",
|
||||
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
|
||||
const randBool = (p = 0.5) => Math.random() < p;
|
||||
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 evChargingAvailable = item.evChargingAvailable ?? evChargingOnSite ?? randBool(0.4);
|
||||
const evChargingAvailable =
|
||||
item.evChargingAvailable ?? evChargingOnSite ?? randBool(0.4);
|
||||
return {
|
||||
...item,
|
||||
priceWeekdayEuros: weekdayPrice,
|
||||
priceWeekendEuros: item.priceWeekendEuros ?? (weekdayPrice ? weekdayPrice + 15 : null),
|
||||
priceWeekendEuros:
|
||||
item.priceWeekendEuros ?? (weekdayPrice ? weekdayPrice + 15 : null),
|
||||
hasKitchen: item.hasKitchen ?? randBool(0.9),
|
||||
hasDishwasher: item.hasDishwasher ?? randBool(0.6),
|
||||
hasWashingMachine: item.hasWashingMachine ?? randBool(0.6),
|
||||
|
|
@ -606,7 +698,10 @@ async function main() {
|
|||
});
|
||||
|
||||
for (const item of listings) {
|
||||
const existing = await prisma.listingTranslation.findFirst({ where: { slug: item.slug }, select: { listingId: true } });
|
||||
const existing = await prisma.listingTranslation.findFirst({
|
||||
where: { slug: item.slug },
|
||||
select: { listingId: true },
|
||||
});
|
||||
const imageCreates = buildImageCreates(item);
|
||||
if (!existing) {
|
||||
const created = await prisma.listing.create({
|
||||
|
|
@ -639,27 +734,40 @@ async function main() {
|
|||
hasFreeParking: item.hasFreeParking ?? false,
|
||||
petsAllowed: item.petsAllowed,
|
||||
byTheLake: item.byTheLake,
|
||||
evChargingAvailable: item.evChargingAvailable ?? item.evChargingOnSite ?? false,
|
||||
evChargingAvailable:
|
||||
item.evChargingAvailable ?? item.evChargingOnSite ?? false,
|
||||
evChargingOnSite: item.evChargingOnSite ?? false,
|
||||
wheelchairAccessible: item.wheelchairAccessible ?? false,
|
||||
priceWeekdayEuros: item.priceWeekdayEuros,
|
||||
priceWeekendEuros: item.priceWeekendEuros,
|
||||
contactName: 'Sample Host',
|
||||
contactName: "Sample Host",
|
||||
contactEmail: SAMPLE_EMAIL,
|
||||
contactPhone: owner.phone,
|
||||
published: true,
|
||||
translations: {
|
||||
createMany: {
|
||||
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,
|
||||
},
|
||||
});
|
||||
console.log('Seeded listing:', created.id, item.slug);
|
||||
console.log("Seeded listing:", created.id, item.slug);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -691,12 +799,13 @@ async function main() {
|
|||
hasFreeParking: item.hasFreeParking ?? false,
|
||||
petsAllowed: item.petsAllowed,
|
||||
byTheLake: item.byTheLake,
|
||||
evChargingAvailable: item.evChargingAvailable ?? item.evChargingOnSite ?? false,
|
||||
evChargingAvailable:
|
||||
item.evChargingAvailable ?? item.evChargingOnSite ?? false,
|
||||
evChargingOnSite: item.evChargingOnSite ?? false,
|
||||
wheelchairAccessible: item.wheelchairAccessible ?? false,
|
||||
priceWeekdayEuros: item.priceWeekdayEuros,
|
||||
priceWeekendEuros: item.priceWeekendEuros,
|
||||
contactName: 'Sample Host',
|
||||
contactName: "Sample Host",
|
||||
contactEmail: SAMPLE_EMAIL,
|
||||
contactPhone: owner.phone,
|
||||
published: true,
|
||||
|
|
@ -706,14 +815,36 @@ async function main() {
|
|||
});
|
||||
|
||||
await prisma.listingTranslation.upsert({
|
||||
where: { slug_locale: { slug: item.slug, locale: 'en' } },
|
||||
create: { 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 },
|
||||
where: { slug_locale: { slug: item.slug, locale: "en" } },
|
||||
create: {
|
||||
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({
|
||||
where: { slug_locale: { slug: item.slug, locale: 'fi' } },
|
||||
create: { 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 },
|
||||
where: { slug_locale: { slug: item.slug, locale: "fi" } },
|
||||
create: {
|
||||
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 } });
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const [command, ...argv] = process.argv.slice(2);
|
||||
|
||||
|
|
@ -9,10 +9,10 @@ const parseArgs = (args) => {
|
|||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const arg = args[i];
|
||||
if (arg.startsWith('--')) {
|
||||
const key = arg.replace(/^--/, '');
|
||||
if (arg.startsWith("--")) {
|
||||
const key = arg.replace(/^--/, "");
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
if (next && !next.startsWith("--")) {
|
||||
parsed[key] = next;
|
||||
i += 2;
|
||||
} else {
|
||||
|
|
@ -39,22 +39,22 @@ const ensureEnv = (key, optional = false) => {
|
|||
};
|
||||
|
||||
const readConfig = () => {
|
||||
const baseUrl = ensureEnv('REDMINE_URL');
|
||||
const url = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
const baseUrl = ensureEnv("REDMINE_URL");
|
||||
const url = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
return {
|
||||
url,
|
||||
apiKey: ensureEnv('REDMINE_API_KEY'),
|
||||
projectId: ensureEnv('REDMINE_PROJECT_ID'),
|
||||
trackerBugId: ensureEnv('REDMINE_TRACKER_BUG_ID'),
|
||||
apiKey: ensureEnv("REDMINE_API_KEY"),
|
||||
projectId: ensureEnv("REDMINE_PROJECT_ID"),
|
||||
trackerBugId: ensureEnv("REDMINE_TRACKER_BUG_ID"),
|
||||
trackerSecurityId: process.env.REDMINE_TRACKER_SECURITY_ID,
|
||||
assigneeId: process.env.REDMINE_ASSIGNEE_ID,
|
||||
};
|
||||
};
|
||||
|
||||
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]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
|
@ -65,8 +65,8 @@ const fetchJson = async (config, path, options = {}, params = {}) => {
|
|||
const url = redmineUrl(config, path, params);
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'X-Redmine-API-Key': config.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
"X-Redmine-API-Key": config.apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
|
@ -92,14 +92,14 @@ const fetchAllOpenIssues = async (config) => {
|
|||
while (total === null || offset < total) {
|
||||
const data = await fetchJson(
|
||||
config,
|
||||
'/issues.json',
|
||||
"/issues.json",
|
||||
{},
|
||||
{
|
||||
project_id: config.projectId,
|
||||
status_id: 'open',
|
||||
status_id: "open",
|
||||
limit,
|
||||
offset,
|
||||
sort: 'updated_on:desc',
|
||||
sort: "updated_on:desc",
|
||||
},
|
||||
);
|
||||
if (Array.isArray(data.issues)) {
|
||||
|
|
@ -126,15 +126,15 @@ const findExistingIssue = async (config, fingerprint) => {
|
|||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const readFailures = (opts) => {
|
||||
const failures = [];
|
||||
if (opts['failures-file']) {
|
||||
if (opts["failures-file"]) {
|
||||
try {
|
||||
const text = fs.readFileSync(opts['failures-file'], 'utf8');
|
||||
const text = fs.readFileSync(opts["failures-file"], "utf8");
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
|
|
@ -150,7 +150,7 @@ const readFailures = (opts) => {
|
|||
}
|
||||
|
||||
if (failures.length === 0) {
|
||||
failures.push('Test failure (no details provided)');
|
||||
failures.push("Test failure (no details provided)");
|
||||
}
|
||||
|
||||
return failures;
|
||||
|
|
@ -170,8 +170,8 @@ const createIssue = async (config, payload) => {
|
|||
body.issue.assigned_to_id = config.assigneeId;
|
||||
}
|
||||
|
||||
const res = await fetchJson(config, '/issues.json', {
|
||||
method: 'POST',
|
||||
const res = await fetchJson(config, "/issues.json", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.issue || res;
|
||||
|
|
@ -179,24 +179,26 @@ const createIssue = async (config, payload) => {
|
|||
|
||||
const handleCreateTestIssue = async () => {
|
||||
const config = readConfig();
|
||||
const suite = args.suite || 'tests';
|
||||
const tracker = (args.tracker || 'bug').toLowerCase();
|
||||
const suite = args.suite || "tests";
|
||||
const tracker = (args.tracker || "bug").toLowerCase();
|
||||
const trackerId =
|
||||
tracker === 'security'
|
||||
tracker === "security"
|
||||
? config.trackerSecurityId || config.trackerBugId
|
||||
: config.trackerBugId;
|
||||
|
||||
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 fingerprintSeed =
|
||||
args['fingerprint-seed'] ||
|
||||
`${suite}|${args.target || ''}|${failCount}|${failures.join('|')}`;
|
||||
args["fingerprint-seed"] ||
|
||||
`${suite}|${args.target || ""}|${failCount}|${failures.join("|")}`;
|
||||
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 = [
|
||||
`Suite: ${suite}`,
|
||||
|
|
@ -205,8 +207,8 @@ const handleCreateTestIssue = async () => {
|
|||
`Tracker: ${tracker}`,
|
||||
`Failures (${failures.length}):`,
|
||||
...failures.map((line) => `- ${line}`),
|
||||
args['summary-file'] ? `Summary: ${args['summary-file']}` : null,
|
||||
args['failures-file'] ? `Log: ${args['failures-file']}` : null,
|
||||
args["summary-file"] ? `Summary: ${args["summary-file"]}` : null,
|
||||
args["failures-file"] ? `Log: ${args["failures-file"]}` : null,
|
||||
`Fingerprint: ${fingerprint}`,
|
||||
].filter(Boolean);
|
||||
|
||||
|
|
@ -221,7 +223,7 @@ const handleCreateTestIssue = async () => {
|
|||
const created = await createIssue(config, {
|
||||
trackerId,
|
||||
subject,
|
||||
description: descriptionLines.join('\n'),
|
||||
description: descriptionLines.join("\n"),
|
||||
});
|
||||
|
||||
console.log(`Created Redmine issue #${created.id}: ${subject}`);
|
||||
|
|
@ -231,12 +233,12 @@ const handleListOpen = async () => {
|
|||
const config = readConfig();
|
||||
const issues = await fetchAllOpenIssues(config);
|
||||
if (!issues.length) {
|
||||
console.log('No open issues found for the configured project.');
|
||||
console.log("No open issues found for the configured project.");
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = issues.reduce((acc, issue) => {
|
||||
const tracker = issue.tracker?.name || 'Unknown';
|
||||
const tracker = issue.tracker?.name || "Unknown";
|
||||
acc[tracker] = acc[tracker] || [];
|
||||
acc[tracker].push(issue);
|
||||
return acc;
|
||||
|
|
@ -245,11 +247,11 @@ const handleListOpen = async () => {
|
|||
Object.entries(groups).forEach(([trackerName, trackerIssues]) => {
|
||||
console.log(`${trackerName} (${trackerIssues.length})`);
|
||||
trackerIssues.forEach((issue) => {
|
||||
const status = issue.status?.name ? ` [${issue.status.name}]` : '';
|
||||
const priority = issue.priority?.name ? ` (${issue.priority.name})` : '';
|
||||
const status = issue.status?.name ? ` [${issue.status.name}]` : "";
|
||||
const priority = issue.priority?.name ? ` (${issue.priority.name})` : "";
|
||||
console.log(`- #${issue.id}${status}${priority}: ${issue.subject}`);
|
||||
});
|
||||
console.log('');
|
||||
console.log("");
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -271,14 +273,14 @@ Env:
|
|||
const main = async () => {
|
||||
try {
|
||||
switch (command) {
|
||||
case 'create-test-issue':
|
||||
case "create-test-issue":
|
||||
await handleCreateTestIssue();
|
||||
break;
|
||||
case 'list-open':
|
||||
case "list-open":
|
||||
await handleListOpen();
|
||||
break;
|
||||
case '-h':
|
||||
case '--help':
|
||||
case "-h":
|
||||
case "--help":
|
||||
case undefined:
|
||||
printHelp();
|
||||
process.exit(command ? 0 : 1);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
/* Reset admin password for thallaa@gmail.com to a known value.
|
||||
Usage: node scripts/reset-admin-password.js
|
||||
*/
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
const { PrismaClient, Role, UserStatus } = require('@prisma/client');
|
||||
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
const { Pool } = require('pg');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require("path");
|
||||
require("dotenv").config({ path: path.join(__dirname, "..", ".env") });
|
||||
const { PrismaClient, Role, UserStatus } = require("@prisma/client");
|
||||
const { PrismaPg } = require("@prisma/adapter-pg");
|
||||
const { Pool } = require("pg");
|
||||
const bcrypt = require("bcryptjs");
|
||||
|
||||
async function main() {
|
||||
const email = process.env.ADMIN_EMAIL || 'thallaa@gmail.com';
|
||||
const email = process.env.ADMIN_EMAIL || "thallaa@gmail.com";
|
||||
const newPassword = process.env.ADMIN_INITIAL_PASSWORD;
|
||||
if (!newPassword) throw new Error('ADMIN_INITIAL_PASSWORD not set in env');
|
||||
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL not set');
|
||||
if (!newPassword) throw new Error("ADMIN_INITIAL_PASSWORD not set in env");
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL not set");
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg(new Pool({ connectionString: process.env.DATABASE_URL })),
|
||||
adapter: new PrismaPg(
|
||||
new Pool({ connectionString: process.env.DATABASE_URL }),
|
||||
),
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue