Add admin settings and hide listing contact info for guests
This commit is contained in:
parent
3694298429
commit
68d37597e1
10 changed files with 289 additions and 7 deletions
|
|
@ -89,3 +89,4 @@
|
||||||
- Amenities: added separate EV charging flags (on-site vs nearby) plus wheelchair accessibility, including browse filters and admin approvals view badges.
|
- Amenities: added separate EV charging flags (on-site vs nearby) plus wheelchair accessibility, including browse filters and admin approvals view badges.
|
||||||
- Navbar: combined admin actions (approvals/users/monitoring) under a single “Admin” dropdown menu.
|
- Navbar: combined admin actions (approvals/users/monitoring) under a single “Admin” dropdown menu.
|
||||||
- Pricing copy: treat listing prices as indicative “starting from” values and show starting-from line on browse cards + home latest carousel.
|
- Pricing copy: treat listing prices as indicative “starting from” values and show starting-from line on browse cards + home latest carousel.
|
||||||
|
- Site settings page added with a toggle to require login for listing contact details; contact info is now hidden from logged-out visitors.
|
||||||
|
|
|
||||||
121
app/admin/settings/page.tsx
Normal file
121
app/admin/settings/page.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useI18n } from '../../components/I18nProvider';
|
||||||
|
|
||||||
|
type SiteSettings = {
|
||||||
|
requireLoginForContactDetails: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminSettingsPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [settings, setSettings] = useState<SiteSettings | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const meRes = await fetch('/api/auth/me', { cache: 'no-store' });
|
||||||
|
const me = await meRes.json();
|
||||||
|
if (me.user?.role !== 'ADMIN') {
|
||||||
|
setError(t('adminRequired'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAdmin(true);
|
||||||
|
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');
|
||||||
|
} else {
|
||||||
|
setSettings(data.settings);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError('Failed to load settings');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
async function save(next: Partial<SiteSettings>) {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
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');
|
||||||
|
} else {
|
||||||
|
setSettings(data.settings);
|
||||||
|
setMessage(t('settingsSaved'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError('Failed to save settings');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRequireLoginForContactDetails() {
|
||||||
|
if (!settings) return;
|
||||||
|
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}
|
||||||
|
|
||||||
|
{!loading && settings ? (
|
||||||
|
<div style={{ display: 'grid', gap: 16, marginTop: 16 }}>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
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))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: 520 }}>
|
||||||
|
<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 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.requireLoginForContactDetails}
|
||||||
|
onChange={toggleRequireLoginForContactDetails}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<span>{t('settingRequireLoginForContact')}</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
app/api/admin/settings/route.ts
Normal file
48
app/api/admin/settings/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getSiteSettings();
|
||||||
|
return NextResponse.json({ settings });
|
||||||
|
} catch (error) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAuth(req);
|
||||||
|
if (auth.role !== Role.ADMIN) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
const settings = await updateSiteSettings(payload);
|
||||||
|
|
||||||
|
return NextResponse.json({ settings });
|
||||||
|
} catch (error) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
@ -91,6 +91,13 @@ function Icon({ name }: { name: string }) {
|
||||||
<path d="M9 14l2-3 2 2 2-4" />
|
<path d="M9 14l2-3 2 2 2-4" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'settings':
|
||||||
|
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 (
|
return (
|
||||||
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
||||||
|
|
@ -242,6 +249,11 @@ export default function NavBar() {
|
||||||
<Icon name="monitor" /> {t('navMonitoring')}
|
<Icon name="monitor" /> {t('navMonitoring')}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isAdmin ? (
|
||||||
|
<Link href="/admin/settings" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}>
|
||||||
|
<Icon name="settings" /> {t('navSettings')}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { resolveLocale, t as translate } from '../../../lib/i18n';
|
||||||
import { getCalendarRanges, expandBlockedDates } from '../../../lib/calendar';
|
import { getCalendarRanges, expandBlockedDates } from '../../../lib/calendar';
|
||||||
import { verifyAccessToken } from '../../../lib/jwt';
|
import { verifyAccessToken } from '../../../lib/jwt';
|
||||||
import AvailabilityCalendar from '../../components/AvailabilityCalendar';
|
import AvailabilityCalendar from '../../components/AvailabilityCalendar';
|
||||||
|
import { getSiteSettings } from '../../../lib/settings';
|
||||||
|
|
||||||
type ListingPageProps = {
|
type ListingPageProps = {
|
||||||
params: { slug: string };
|
params: { slug: string };
|
||||||
|
|
@ -57,6 +58,8 @@ export default async function ListingPage({ params }: ListingPageProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const siteSettings = await getSiteSettings();
|
||||||
|
|
||||||
const translationRaw = await getListingBySlug({
|
const translationRaw = await getListingBySlug({
|
||||||
slug: params.slug,
|
slug: params.slug,
|
||||||
locale: locale ?? DEFAULT_LOCALE,
|
locale: locale ?? DEFAULT_LOCALE,
|
||||||
|
|
@ -112,7 +115,10 @@ export default async function ListingPage({ params }: ListingPageProps) {
|
||||||
listing.bathrooms ? t('capacityBathrooms', { count: listing.bathrooms }) : null,
|
listing.bathrooms ? t('capacityBathrooms', { count: listing.bathrooms }) : null,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
const capacityLine = capacityParts.length ? capacityParts.join(' · ') : t('capacityUnknown');
|
const capacityLine = capacityParts.length ? capacityParts.join(' · ') : t('capacityUnknown');
|
||||||
const contactLine = `${listing.contactName} · ${listing.contactEmail}${listing.contactPhone ? ` · ${listing.contactPhone}` : ''}`;
|
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 = `/auth/login?redirect=${encodeURIComponent(`/listings/${params.slug}`)}`;
|
||||||
const coverImage = listing.images.find((img) => img.isCover) ?? listing.images[0] ?? null;
|
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 priceCandidates = [listing.priceWeekdayEuros, listing.priceWeekendEuros].filter((p): p is number => typeof p === 'number');
|
||||||
const startingFromEuros = priceCandidates.length ? Math.min(...priceCandidates) : null;
|
const startingFromEuros = priceCandidates.length ? Math.min(...priceCandidates) : null;
|
||||||
|
|
@ -251,7 +257,15 @@ export default async function ListingPage({ params }: ListingPageProps) {
|
||||||
<span aria-hidden className="amenity-icon">✉️</span>
|
<span aria-hidden className="amenity-icon">✉️</span>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingContact')}</div>
|
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingContact')}</div>
|
||||||
|
{canViewContact ? (
|
||||||
<div>{contactLine}</div>
|
<div>{contactLine}</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Link href={loginRedirectUrl} className="button secondary">
|
||||||
|
{t('contactLoginToView')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="fact-row">
|
<div className="fact-row">
|
||||||
|
|
|
||||||
25
lib/i18n.ts
25
lib/i18n.ts
|
|
@ -10,6 +10,7 @@ const baseMessages = {
|
||||||
navApprovals: 'Approvals',
|
navApprovals: 'Approvals',
|
||||||
navUsers: 'Users',
|
navUsers: 'Users',
|
||||||
navMonitoring: 'Monitoring',
|
navMonitoring: 'Monitoring',
|
||||||
|
navSettings: 'Settings',
|
||||||
navLogout: 'Logout',
|
navLogout: 'Logout',
|
||||||
navLogin: 'Login',
|
navLogin: 'Login',
|
||||||
navSignup: 'Sign up',
|
navSignup: 'Sign up',
|
||||||
|
|
@ -118,6 +119,8 @@ const baseMessages = {
|
||||||
monitorLoadFailed: 'Failed to load monitoring data.',
|
monitorLoadFailed: 'Failed to load monitoring data.',
|
||||||
monitorCreated: 'Created',
|
monitorCreated: 'Created',
|
||||||
monitorLastReady: 'Last Ready transition',
|
monitorLastReady: 'Last Ready transition',
|
||||||
|
adminSettingsTitle: 'Admin: settings',
|
||||||
|
adminSettingsLead: 'Site-wide feature toggles and policies.',
|
||||||
tableEmail: 'Email',
|
tableEmail: 'Email',
|
||||||
tableRole: 'Role',
|
tableRole: 'Role',
|
||||||
tableStatus: 'Status',
|
tableStatus: 'Status',
|
||||||
|
|
@ -142,6 +145,7 @@ const baseMessages = {
|
||||||
profileEmailVerified: 'Email verified',
|
profileEmailVerified: 'Email verified',
|
||||||
profileApproved: 'Approved',
|
profileApproved: 'Approved',
|
||||||
profileUpdated: 'Profile updated',
|
profileUpdated: 'Profile updated',
|
||||||
|
settingsSaved: 'Settings saved.',
|
||||||
emailLocked: 'Email cannot be changed',
|
emailLocked: 'Email cannot be changed',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saving: 'Saving…',
|
saving: 'Saving…',
|
||||||
|
|
@ -157,6 +161,7 @@ const baseMessages = {
|
||||||
listingAmenities: 'Amenities',
|
listingAmenities: 'Amenities',
|
||||||
listingNoAmenities: 'No amenities listed yet.',
|
listingNoAmenities: 'No amenities listed yet.',
|
||||||
listingContact: 'Contact',
|
listingContact: 'Contact',
|
||||||
|
contactLoginToView: 'Log in to view contact details for this listing.',
|
||||||
listingMoreInfo: 'More info',
|
listingMoreInfo: 'More info',
|
||||||
availabilityTitle: 'Availability calendar',
|
availabilityTitle: 'Availability calendar',
|
||||||
availabilityLegendBooked: 'Booked',
|
availabilityLegendBooked: 'Booked',
|
||||||
|
|
@ -188,6 +193,9 @@ const baseMessages = {
|
||||||
aiApply: 'Apply AI response',
|
aiApply: 'Apply AI response',
|
||||||
aiApplyError: 'Could not read AI response. Please ensure it is valid JSON with a locales object.',
|
aiApplyError: 'Could not read AI response. Please ensure it is valid JSON with a locales object.',
|
||||||
aiApplySuccess: 'Translations updated from AI response.',
|
aiApplySuccess: 'Translations updated from AI response.',
|
||||||
|
settingContactVisibilityTitle: 'Listing contact visibility',
|
||||||
|
settingContactVisibilityHelp: 'Hide host contact details from anonymous visitors so only logged-in users can see them.',
|
||||||
|
settingRequireLoginForContact: 'Require login to see contact details',
|
||||||
translationMissing: 'Add at least one language with a title and description.',
|
translationMissing: 'Add at least one language with a title and description.',
|
||||||
saveDraft: 'Save draft',
|
saveDraft: 'Save draft',
|
||||||
missingFields: 'Missing: {fields}',
|
missingFields: 'Missing: {fields}',
|
||||||
|
|
@ -331,6 +339,7 @@ const baseMessages = {
|
||||||
navApprovals: 'Tarkastettavat',
|
navApprovals: 'Tarkastettavat',
|
||||||
navUsers: 'Käyttäjät',
|
navUsers: 'Käyttäjät',
|
||||||
navMonitoring: 'Valvonta',
|
navMonitoring: 'Valvonta',
|
||||||
|
navSettings: 'Asetukset',
|
||||||
navLogout: 'Kirjaudu ulos',
|
navLogout: 'Kirjaudu ulos',
|
||||||
navLogin: 'Kirjaudu',
|
navLogin: 'Kirjaudu',
|
||||||
navSignup: 'Rekisteröidy',
|
navSignup: 'Rekisteröidy',
|
||||||
|
|
@ -364,6 +373,9 @@ const baseMessages = {
|
||||||
aiApply: 'Käytä AI-vastausta',
|
aiApply: 'Käytä AI-vastausta',
|
||||||
aiApplyError: 'Vastausta ei voitu lukea. Varmista, että se on kelvollista JSONia ja sisältää locales-avaimen.',
|
aiApplyError: 'Vastausta ei voitu lukea. Varmista, että se on kelvollista JSONia ja sisältää locales-avaimen.',
|
||||||
aiApplySuccess: 'Käännökset päivitetty AI-vastauksesta.',
|
aiApplySuccess: 'Käännökset päivitetty AI-vastauksesta.',
|
||||||
|
settingContactVisibilityTitle: 'Ilmoitusten yhteystiedot',
|
||||||
|
settingContactVisibilityHelp: 'Piilota isännän yhteystiedot kirjautumattomilta, näytä ne vain kirjautuneille käyttäjille.',
|
||||||
|
settingRequireLoginForContact: 'Vaadi kirjautuminen yhteystietoihin',
|
||||||
translationMissing: 'Täytä vähintään yhden kielen otsikko ja kuvaus.',
|
translationMissing: 'Täytä vähintään yhden kielen otsikko ja kuvaus.',
|
||||||
saveDraft: 'Tallenna luonnos',
|
saveDraft: 'Tallenna luonnos',
|
||||||
missingFields: 'Puuttuu: {fields}',
|
missingFields: 'Puuttuu: {fields}',
|
||||||
|
|
@ -466,6 +478,8 @@ const baseMessages = {
|
||||||
monitorLoadFailed: 'Valvontadatan haku epäonnistui.',
|
monitorLoadFailed: 'Valvontadatan haku epäonnistui.',
|
||||||
monitorCreated: 'Luotu',
|
monitorCreated: 'Luotu',
|
||||||
monitorLastReady: 'Viimeisin Ready-muutos',
|
monitorLastReady: 'Viimeisin Ready-muutos',
|
||||||
|
adminSettingsTitle: 'Ylläpito: asetukset',
|
||||||
|
adminSettingsLead: 'Sivuston laajuiset ominaisuusasetukset ja käytännöt.',
|
||||||
tableEmail: 'Sähköposti',
|
tableEmail: 'Sähköposti',
|
||||||
tableRole: 'Rooli',
|
tableRole: 'Rooli',
|
||||||
tableStatus: 'Tila',
|
tableStatus: 'Tila',
|
||||||
|
|
@ -490,6 +504,7 @@ const baseMessages = {
|
||||||
profileEmailVerified: 'Sähköposti vahvistettu',
|
profileEmailVerified: 'Sähköposti vahvistettu',
|
||||||
profileApproved: 'Hyväksytty',
|
profileApproved: 'Hyväksytty',
|
||||||
profileUpdated: 'Profiili päivitetty',
|
profileUpdated: 'Profiili päivitetty',
|
||||||
|
settingsSaved: 'Asetukset tallennettu.',
|
||||||
emailLocked: 'Sähköpostia ei voi vaihtaa',
|
emailLocked: 'Sähköpostia ei voi vaihtaa',
|
||||||
save: 'Tallenna',
|
save: 'Tallenna',
|
||||||
saving: 'Tallennetaan…',
|
saving: 'Tallennetaan…',
|
||||||
|
|
@ -505,6 +520,7 @@ const baseMessages = {
|
||||||
listingAmenities: 'Varustelu',
|
listingAmenities: 'Varustelu',
|
||||||
listingNoAmenities: 'Varustelua ei ole listattu.',
|
listingNoAmenities: 'Varustelua ei ole listattu.',
|
||||||
listingContact: 'Yhteystiedot',
|
listingContact: 'Yhteystiedot',
|
||||||
|
contactLoginToView: 'Kirjaudu sisään nähdäksesi tämän ilmoituksen yhteystiedot.',
|
||||||
listingMoreInfo: 'Lisätietoja',
|
listingMoreInfo: 'Lisätietoja',
|
||||||
availabilityTitle: 'Saatavuuskalenteri',
|
availabilityTitle: 'Saatavuuskalenteri',
|
||||||
availabilityLegendBooked: 'Varattu',
|
availabilityLegendBooked: 'Varattu',
|
||||||
|
|
@ -654,6 +670,7 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
|
||||||
navApprovals: 'Godkännanden',
|
navApprovals: 'Godkännanden',
|
||||||
navUsers: 'Användare',
|
navUsers: 'Användare',
|
||||||
navMonitoring: 'Övervakning',
|
navMonitoring: 'Övervakning',
|
||||||
|
navSettings: 'Inställningar',
|
||||||
navLogout: 'Logga ut',
|
navLogout: 'Logga ut',
|
||||||
navLogin: 'Logga in',
|
navLogin: 'Logga in',
|
||||||
navSignup: 'Registrera dig',
|
navSignup: 'Registrera dig',
|
||||||
|
|
@ -683,6 +700,8 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
|
||||||
monitorLoadFailed: 'Kunde inte hämta övervakningsdata.',
|
monitorLoadFailed: 'Kunde inte hämta övervakningsdata.',
|
||||||
monitorCreated: 'Skapad',
|
monitorCreated: 'Skapad',
|
||||||
monitorLastReady: 'Senaste Ready-ändring',
|
monitorLastReady: 'Senaste Ready-ändring',
|
||||||
|
adminSettingsTitle: 'Admin: inställningar',
|
||||||
|
adminSettingsLead: 'Webbplatsövergripande funktioner och policyer.',
|
||||||
slugHelp: 'Hitta på en kort och enkel länk; använd små bokstäver och bindestreck (t.ex. sjo-stuga).',
|
slugHelp: 'Hitta på en kort och enkel länk; använd små bokstäver och bindestreck (t.ex. sjo-stuga).',
|
||||||
slugPreview: 'Länk till annonsen: {url}',
|
slugPreview: 'Länk till annonsen: {url}',
|
||||||
heroTitle: 'Hitta ditt nästa finska getaway',
|
heroTitle: 'Hitta ditt nästa finska getaway',
|
||||||
|
|
@ -713,6 +732,10 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
|
||||||
aiApply: 'Använd AI-svar',
|
aiApply: 'Använd AI-svar',
|
||||||
aiApplyError: 'Kunde inte läsa AI-svaret. Se till att det är giltig JSON med locales-nyckel.',
|
aiApplyError: 'Kunde inte läsa AI-svaret. Se till att det är giltig JSON med locales-nyckel.',
|
||||||
aiApplySuccess: 'Översättningar uppdaterades från AI-svaret.',
|
aiApplySuccess: 'Översättningar uppdaterades från AI-svaret.',
|
||||||
|
settingContactVisibilityTitle: 'Synlighet för kontaktuppgifter',
|
||||||
|
settingContactVisibilityHelp: 'Dölj värdens kontaktuppgifter för anonyma besökare så att bara inloggade ser dem.',
|
||||||
|
settingRequireLoginForContact: 'Kräv inloggning för att se kontaktuppgifter',
|
||||||
|
settingsSaved: 'Inställningar sparade.',
|
||||||
translationMissing: 'Lägg till minst ett språk med titel och beskrivning.',
|
translationMissing: 'Lägg till minst ett språk med titel och beskrivning.',
|
||||||
saveDraft: 'Spara utkast',
|
saveDraft: 'Spara utkast',
|
||||||
missingFields: 'Saknas: {fields}',
|
missingFields: 'Saknas: {fields}',
|
||||||
|
|
@ -729,6 +752,8 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
|
||||||
priceWeekendShort: '{price}€ helg',
|
priceWeekendShort: '{price}€ helg',
|
||||||
priceNotSet: 'Ej angivet',
|
priceNotSet: 'Ej angivet',
|
||||||
listingPrices: 'Pris (från)',
|
listingPrices: 'Pris (från)',
|
||||||
|
listingContact: 'Kontakt',
|
||||||
|
contactLoginToView: 'Logga in för att se kontaktuppgifter för denna annons.',
|
||||||
capacityUnknown: 'Kapacitet ej angiven',
|
capacityUnknown: 'Kapacitet ej angiven',
|
||||||
amenityEvAvailable: 'EV-laddning i närheten',
|
amenityEvAvailable: 'EV-laddning i närheten',
|
||||||
amenityEvNearby: 'EV-laddning i närheten',
|
amenityEvNearby: 'EV-laddning i närheten',
|
||||||
|
|
|
||||||
42
lib/settings.ts
Normal file
42
lib/settings.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { prisma } from './prisma';
|
||||||
|
|
||||||
|
export type SiteSettings = {
|
||||||
|
requireLoginForContactDetails: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SETTINGS_ID = 'default';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: SiteSettings = {
|
||||||
|
requireLoginForContactDetails: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergeSettings(input: Partial<SiteSettings> | null | undefined): SiteSettings {
|
||||||
|
return {
|
||||||
|
requireLoginForContactDetails:
|
||||||
|
input?.requireLoginForContactDetails ?? DEFAULT_SETTINGS.requireLoginForContactDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSiteSettings(): Promise<SiteSettings> {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSiteSettings(input: Partial<SiteSettings>): Promise<SiteSettings> {
|
||||||
|
const current = await getSiteSettings();
|
||||||
|
const data = mergeSettings({ ...current, ...input });
|
||||||
|
const saved = await prisma.siteSettings.upsert({
|
||||||
|
where: { id: SETTINGS_ID },
|
||||||
|
update: data,
|
||||||
|
create: { id: SETTINGS_ID, ...data },
|
||||||
|
});
|
||||||
|
return mergeSettings(saved);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getAuthFromRequest } from './lib/jwt';
|
import { getAuthFromRequest } from './lib/jwt';
|
||||||
|
|
||||||
const ADMIN_ONLY_PATHS = ['/admin/users', '/admin/monitor'];
|
const ADMIN_ONLY_PATHS = ['/admin/users', '/admin/monitor', '/admin/settings'];
|
||||||
const MODERATOR_PATHS = ['/admin/pending'];
|
const MODERATOR_PATHS = ['/admin/pending'];
|
||||||
|
|
||||||
function buildLoginRedirect(req: NextRequest) {
|
function buildLoginRedirect(req: NextRequest) {
|
||||||
|
|
|
||||||
12
prisma/migrations/20260310_site_settings/migration.sql
Normal file
12
prisma/migrations/20260310_site_settings/migration.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- Site-wide settings to toggle features like contact visibility
|
||||||
|
CREATE TABLE IF NOT EXISTS "SiteSettings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"requireLoginForContactDetails" BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "SiteSettings" ("id", "requireLoginForContactDetails")
|
||||||
|
VALUES ('default', TRUE)
|
||||||
|
ON CONFLICT ("id") DO NOTHING;
|
||||||
|
|
@ -138,6 +138,13 @@ model ListingImage {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SiteSettings {
|
||||||
|
id String @id @default("default")
|
||||||
|
requireLoginForContactDetails Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model VerificationToken {
|
model VerificationToken {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue