lomavuokraus/app/me/page.tsx
Tero Halla-aho 0bb709d9c5
Some checks failed
CI / checks (push) Has been cancelled
chore: fix audit alerts and formatting
2026-02-04 12:43:03 +02:00

549 lines
20 KiB
TypeScript

"use client";
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 BillingListing = {
id: string;
title: string;
slug: string;
locale: string;
billingAccountName: string | null;
billingIban: string | null;
billingIncludeVatLine: boolean | null;
};
export default function ProfilePage() {
const { t } = useI18n();
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState("");
const [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 [billingIncludeVatLine, setBillingIncludeVatLine] = useState(false);
const [billingListings, setBillingListings] = useState<BillingListing[]>([]);
const [billingLoading, setBillingLoading] = useState(false);
const [billingSaving, setBillingSaving] = useState(false);
const [billingMessage, setBillingMessage] = useState<string | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/auth/me", { cache: "no-store" })
.then((res) => res.json())
.then((data) => {
if (data.user) {
setUser(data.user);
setName(data.user.name ?? "");
setPhone(data.user.phone ?? "");
} else setError(t("notLoggedIn"));
})
.catch(() => setError(t("notLoggedIn")));
}, [t]);
useEffect(() => {
setBillingLoading(true);
fetch("/api/me/billing", { cache: "no-store" })
.then(async (res) => {
const data = await res.json();
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 ?? "");
setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine));
setBillingListings(
Array.isArray(data.listings)
? data.listings.map((l: any) => ({
id: l.id,
title: l.title,
slug: l.slug,
locale: l.locale,
billingAccountName: l.billingAccountName ?? "",
billingIban: l.billingIban ?? "",
billingIncludeVatLine: l.billingIncludeVatLine ?? null,
}))
: [],
);
setBillingError(null);
})
.catch(() => setBillingError(t("billingLoadFailed")))
.finally(() => setBillingLoading(false));
}, [t]);
async function onSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setMessage(null);
try {
const res = await fetch("/api/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, phone, password: password || undefined }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Update failed");
} else {
setUser(data.user);
setPassword("");
setMessage(t("profileUpdated"));
}
} catch (err) {
setError("Update failed");
} finally {
setSaving(false);
}
}
async function onSaveBilling(e: React.FormEvent) {
e.preventDefault();
setBillingSaving(true);
setBillingError(null);
setBillingMessage(null);
try {
const res = await fetch("/api/me/billing", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
enabled: billingEnabled,
accountName: billingAccountName,
iban: billingIban,
includeVatLine: billingIncludeVatLine,
listings: billingListings.map((l) => ({
id: l.id,
accountName: l.billingAccountName,
iban: l.billingIban,
includeVatLine: l.billingIncludeVatLine,
})),
}),
});
const data = await res.json();
if (!res.ok) {
setBillingError(data.error || t("billingSaveFailed"));
return;
}
setBillingEnabled(Boolean(data.settings?.enabled));
setBillingAccountName(data.settings?.accountName ?? "");
setBillingIban(data.settings?.iban ?? "");
setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine));
setBillingListings(
Array.isArray(data.listings)
? data.listings.map((l: any) => ({
id: l.id,
title: l.title,
slug: l.slug,
locale: l.locale,
billingAccountName: l.billingAccountName ?? "",
billingIban: l.billingIban ?? "",
billingIncludeVatLine: l.billingIncludeVatLine ?? null,
}))
: [],
);
setBillingMessage(t("billingSaved"));
} catch (err) {
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 }}
>
<div>
<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>
<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>
)}
</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,
}}
>
<label>
{t("profileName")}
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
</label>
<label>
{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}
/>
</label>
</div>
<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>
</div>
</form>
</section>
<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>
</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 }}>
<label
className="amenity-option"
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>
<input
type="checkbox"
checked={billingEnabled}
onChange={(e) => setBillingEnabled(e.target.checked)}
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,
}}
>
<label>
{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"
/>
</label>
<label
className="amenity-option"
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>
<input
type="checkbox"
checked={billingIncludeVatLine}
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={{ marginBottom: 8 }}>
<strong>{t("billingListingsTitle")}</strong>
<div style={{ color: "#555", fontSize: 13 }}>
{t("billingListingsLead")}
</div>
</div>
{billingLoading ? (
<p>{t("loading")}</p>
) : billingListings.length === 0 ? (
<p style={{ color: "#666" }}>{t("billingNoListings")}</p>
) : (
<div style={{ display: "grid", gap: 12 }}>
{billingListings.map((listing) => {
const vatValue =
listing.billingIncludeVatLine === null ||
listing.billingIncludeVatLine === undefined
? "inherit"
: listing.billingIncludeVatLine
? "yes"
: "no";
return (
<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))",
}}
>
<label>
{t("billingAccountNameLabel")}
<input
value={listing.billingAccountName ?? ""}
onChange={(e) =>
setBillingListings((prev) =>
prev.map((l) =>
l.id === listing.id
? {
...l,
billingAccountName:
e.target.value,
}
: l,
),
)
}
placeholder={
billingAccountName ||
t("billingAccountPlaceholder")
}
/>
</label>
<label>
{t("billingIbanLabel")}
<input
value={listing.billingIban ?? ""}
onChange={(e) =>
setBillingListings((prev) =>
prev.map((l) =>
l.id === listing.id
? {
...l,
billingIban: e.target.value,
}
: l,
),
)
}
placeholder={
billingIban || t("billingIbanPlaceholder")
}
/>
</label>
<label>
{t("billingVatChoice")}
<select
value={vatValue as string}
onChange={(e) => {
const choice = e.target.value;
setBillingListings((prev) =>
prev.map((l) =>
l.id === listing.id
? {
...l,
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>
</select>
</label>
</div>
</div>
);
})}
</div>
)}
</div>
</>
) : (
<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>
</div>
</form>
</section>
</>
) : null}
</main>
);
}