549 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|