Compare commits

...

3 commits

Author SHA1 Message Date
f0f94b66bb Merge pull request 'feature/admin-settings-visibility' (#11) from feature/admin-settings-visibility into master
Some checks are pending
CI / checks (push) Waiting to run
Reviewed-on: #11
2025-12-18 21:37:14 +02:00
Tero Halla-aho
733c45d061 Fix login link type for contact visibility
Some checks failed
CI / checks (push) Has been cancelled
CI / checks (pull_request) Has been cancelled
2025-12-18 21:28:04 +02:00
Tero Halla-aho
68d37597e1 Add admin settings and hide listing contact info for guests 2025-12-18 21:20:18 +02:00
10 changed files with 290 additions and 7 deletions

View file

@ -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.
- 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.
- 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
View 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>
);
}

View 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';

View file

@ -91,6 +91,13 @@ function Icon({ name }: { name: string }) {
<path d="M9 14l2-3 2 2 2-4" />
</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':
return (
<svg {...common} viewBox="0 0 24 24" aria-hidden>
@ -242,6 +249,11 @@ export default function NavBar() {
<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>
) : null}
</div>
) : null}
</div>

View file

@ -9,6 +9,8 @@ import { resolveLocale, t as translate } from '../../../lib/i18n';
import { getCalendarRanges, expandBlockedDates } from '../../../lib/calendar';
import { verifyAccessToken } from '../../../lib/jwt';
import AvailabilityCalendar from '../../components/AvailabilityCalendar';
import { getSiteSettings } from '../../../lib/settings';
import type { UrlObject } from 'url';
type ListingPageProps = {
params: { slug: string };
@ -57,6 +59,8 @@ export default async function ListingPage({ params }: ListingPageProps) {
}
}
const siteSettings = await getSiteSettings();
const translationRaw = await getListingBySlug({
slug: params.slug,
locale: locale ?? DEFAULT_LOCALE,
@ -112,7 +116,10 @@ export default async function ListingPage({ params }: ListingPageProps) {
listing.bathrooms ? t('capacityBathrooms', { count: listing.bathrooms }) : null,
].filter(Boolean) as string[];
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: 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;
@ -251,7 +258,15 @@ export default async function ListingPage({ params }: ListingPageProps) {
<span aria-hidden className="amenity-icon"></span>
<div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingContact')}</div>
<div>{contactLine}</div>
{canViewContact ? (
<div>{contactLine}</div>
) : (
<div style={{ marginTop: 4 }}>
<Link href={loginRedirectUrl} className="button secondary">
{t('contactLoginToView')}
</Link>
</div>
)}
</div>
</div>
<div className="fact-row">

View file

@ -10,6 +10,7 @@ const baseMessages = {
navApprovals: 'Approvals',
navUsers: 'Users',
navMonitoring: 'Monitoring',
navSettings: 'Settings',
navLogout: 'Logout',
navLogin: 'Login',
navSignup: 'Sign up',
@ -118,6 +119,8 @@ const baseMessages = {
monitorLoadFailed: 'Failed to load monitoring data.',
monitorCreated: 'Created',
monitorLastReady: 'Last Ready transition',
adminSettingsTitle: 'Admin: settings',
adminSettingsLead: 'Site-wide feature toggles and policies.',
tableEmail: 'Email',
tableRole: 'Role',
tableStatus: 'Status',
@ -142,6 +145,7 @@ const baseMessages = {
profileEmailVerified: 'Email verified',
profileApproved: 'Approved',
profileUpdated: 'Profile updated',
settingsSaved: 'Settings saved.',
emailLocked: 'Email cannot be changed',
save: 'Save',
saving: 'Saving…',
@ -157,6 +161,7 @@ const baseMessages = {
listingAmenities: 'Amenities',
listingNoAmenities: 'No amenities listed yet.',
listingContact: 'Contact',
contactLoginToView: 'Log in to view contact details for this listing.',
listingMoreInfo: 'More info',
availabilityTitle: 'Availability calendar',
availabilityLegendBooked: 'Booked',
@ -188,6 +193,9 @@ const baseMessages = {
aiApply: 'Apply AI response',
aiApplyError: 'Could not read AI response. Please ensure it is valid JSON with a locales object.',
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.',
saveDraft: 'Save draft',
missingFields: 'Missing: {fields}',
@ -331,6 +339,7 @@ const baseMessages = {
navApprovals: 'Tarkastettavat',
navUsers: 'Käyttäjät',
navMonitoring: 'Valvonta',
navSettings: 'Asetukset',
navLogout: 'Kirjaudu ulos',
navLogin: 'Kirjaudu',
navSignup: 'Rekisteröidy',
@ -360,10 +369,13 @@ const baseMessages = {
aiCopyPrompt: 'Kopioi prompti',
aiPromptCopied: 'Kopioitu',
aiCopyError: 'Kopiointi epäonnistui',
aiResponseLabel: 'Liitä tekoälyn vastaus (JSON)',
aiApply: 'Käytä AI-vastausta',
aiApplyError: 'Vastausta ei voitu lukea. Varmista, että se on kelvollista JSONia ja sisältää locales-avaimen.',
aiApplySuccess: 'Käännökset päivitetty AI-vastauksesta.',
aiResponseLabel: 'Liitä tekoälyn vastaus (JSON)',
aiApply: 'Käytä AI-vastausta',
aiApplyError: 'Vastausta ei voitu lukea. Varmista, että se on kelvollista JSONia ja sisältää locales-avaimen.',
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.',
saveDraft: 'Tallenna luonnos',
missingFields: 'Puuttuu: {fields}',
@ -466,6 +478,8 @@ const baseMessages = {
monitorLoadFailed: 'Valvontadatan haku epäonnistui.',
monitorCreated: 'Luotu',
monitorLastReady: 'Viimeisin Ready-muutos',
adminSettingsTitle: 'Ylläpito: asetukset',
adminSettingsLead: 'Sivuston laajuiset ominaisuusasetukset ja käytännöt.',
tableEmail: 'Sähköposti',
tableRole: 'Rooli',
tableStatus: 'Tila',
@ -490,6 +504,7 @@ const baseMessages = {
profileEmailVerified: 'Sähköposti vahvistettu',
profileApproved: 'Hyväksytty',
profileUpdated: 'Profiili päivitetty',
settingsSaved: 'Asetukset tallennettu.',
emailLocked: 'Sähköpostia ei voi vaihtaa',
save: 'Tallenna',
saving: 'Tallennetaan…',
@ -505,6 +520,7 @@ const baseMessages = {
listingAmenities: 'Varustelu',
listingNoAmenities: 'Varustelua ei ole listattu.',
listingContact: 'Yhteystiedot',
contactLoginToView: 'Kirjaudu sisään nähdäksesi tämän ilmoituksen yhteystiedot.',
listingMoreInfo: 'Lisätietoja',
availabilityTitle: 'Saatavuuskalenteri',
availabilityLegendBooked: 'Varattu',
@ -654,6 +670,7 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
navApprovals: 'Godkännanden',
navUsers: 'Användare',
navMonitoring: 'Övervakning',
navSettings: 'Inställningar',
navLogout: 'Logga ut',
navLogin: 'Logga in',
navSignup: 'Registrera dig',
@ -683,6 +700,8 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
monitorLoadFailed: 'Kunde inte hämta övervakningsdata.',
monitorCreated: 'Skapad',
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).',
slugPreview: 'Länk till annonsen: {url}',
heroTitle: 'Hitta ditt nästa finska getaway',
@ -713,6 +732,10 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
aiApply: 'Använd AI-svar',
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.',
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.',
saveDraft: 'Spara utkast',
missingFields: 'Saknas: {fields}',
@ -729,6 +752,8 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
priceWeekendShort: '{price}€ helg',
priceNotSet: 'Ej angivet',
listingPrices: 'Pris (från)',
listingContact: 'Kontakt',
contactLoginToView: 'Logga in för att se kontaktuppgifter för denna annons.',
capacityUnknown: 'Kapacitet ej angiven',
amenityEvAvailable: 'EV-laddning i närheten',
amenityEvNearby: 'EV-laddning i närheten',

42
lib/settings.ts Normal file
View 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);
}

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
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'];
function buildLoginRedirect(req: NextRequest) {

View 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;

View file

@ -138,6 +138,13 @@ model ListingImage {
updatedAt DateTime @updatedAt
}
model SiteSettings {
id String @id @default("default")
requireLoginForContactDetails Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VerificationToken {
id String @id @default(cuid())
userId String