lomavuokraus/app/me/page.tsx
Tero Halla-aho 1882d58474
Some checks are pending
CI / checks (push) Waiting to run
Align billing toggles with flex layout
2025-12-21 14:01:57 +02:00

362 lines
16 KiB
TypeScript

'use client';
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' }}>
<a className="button secondary" href="/listings/mine">
{t('navMyListings')}
</a>
<a className="button secondary" href="/listings/new">
{t('navNewListing')}
</a>
</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: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}
>
<div className="amenity-option-meta" style={{ fontWeight: 600, minWidth: 0 }}>
<span aria-hidden className="amenity-emoji">💸</span>
<span className="amenity-name">{t('billingEnableLabel')}</span>
</div>
<input
type="checkbox"
checked={billingEnabled}
onChange={(e) => setBillingEnabled(e.target.checked)}
style={{ width: 22, height: 22 }}
/>
</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: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}
>
<div className="amenity-option-meta" style={{ fontWeight: 600, minWidth: 0 }}>
<span aria-hidden className="amenity-emoji">🧾</span>
<span className="amenity-name">{t('billingIncludeVat')}</span>
</div>
<input
type="checkbox"
checked={billingIncludeVatLine}
onChange={(e) => setBillingIncludeVatLine(e.target.checked)}
style={{ width: 22, height: 22 }}
/>
</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>
);
}