From 68d37597e135b13eb6154aef0190cfd4982030e7 Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Thu, 18 Dec 2025 21:20:18 +0200 Subject: [PATCH 1/2] Add admin settings and hide listing contact info for guests --- PROGRESS.md | 1 + app/admin/settings/page.tsx | 121 ++++++++++++++++++ app/api/admin/settings/route.ts | 48 +++++++ app/components/NavBar.tsx | 12 ++ app/listings/[slug]/page.tsx | 18 ++- lib/i18n.ts | 33 ++++- lib/settings.ts | 42 ++++++ middleware.ts | 2 +- .../20260310_site_settings/migration.sql | 12 ++ prisma/schema.prisma | 7 + 10 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 app/admin/settings/page.tsx create mode 100644 app/api/admin/settings/route.ts create mode 100644 lib/settings.ts create mode 100644 prisma/migrations/20260310_site_settings/migration.sql diff --git a/PROGRESS.md b/PROGRESS.md index 7ec40fa..3b73a29 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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. diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx new file mode 100644 index 0000000..4e21d4a --- /dev/null +++ b/app/admin/settings/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(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) { + 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 ( +
+

{t('adminSettingsTitle')}

+

{t('adminSettingsLead')}

+ {loading ?

{t('loading')}

: null} + {message ?

{message}

: null} + {error ?

{error}

: null} + + {!loading && settings ? ( +
+
+
+

{t('settingContactVisibilityTitle')}

+

{t('settingContactVisibilityHelp')}

+
+ +
+
+ ) : null} +
+ ); +} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts new file mode 100644 index 0000000..de29f4b --- /dev/null +++ b/app/api/admin/settings/route.ts @@ -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[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'; diff --git a/app/components/NavBar.tsx b/app/components/NavBar.tsx index aea646d..7ad410f 100644 --- a/app/components/NavBar.tsx +++ b/app/components/NavBar.tsx @@ -91,6 +91,13 @@ function Icon({ name }: { name: string }) { ); + case 'settings': + return ( + + + + + ); case 'admin': return ( @@ -242,6 +249,11 @@ export default function NavBar() { {t('navMonitoring')} ) : null} + {isAdmin ? ( + setAdminMenuOpen(false)}> + {t('navSettings')} + + ) : null} ) : null} diff --git a/app/listings/[slug]/page.tsx b/app/listings/[slug]/page.tsx index f418f82..a681e93 100644 --- a/app/listings/[slug]/page.tsx +++ b/app/listings/[slug]/page.tsx @@ -9,6 +9,7 @@ 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'; type ListingPageProps = { params: { slug: string }; @@ -57,6 +58,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 +115,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 = `/auth/login?redirect=${encodeURIComponent(`/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 +257,15 @@ export default async function ListingPage({ params }: ListingPageProps) { ✉️
{t('listingContact')}
-
{contactLine}
+ {canViewContact ? ( +
{contactLine}
+ ) : ( +
+ + {t('contactLoginToView')} + +
+ )}
diff --git a/lib/i18n.ts b/lib/i18n.ts index d6450c5..6ac8f21 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -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 = { 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 = { 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 = { 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 = { 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', diff --git a/lib/settings.ts b/lib/settings.ts new file mode 100644 index 0000000..c51f38c --- /dev/null +++ b/lib/settings.ts @@ -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 | null | undefined): SiteSettings { + return { + requireLoginForContactDetails: + input?.requireLoginForContactDetails ?? DEFAULT_SETTINGS.requireLoginForContactDetails, + }; +} + +export async function getSiteSettings(): Promise { + 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): Promise { + 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); +} diff --git a/middleware.ts b/middleware.ts index e4a7641..b0e0b80 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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) { diff --git a/prisma/migrations/20260310_site_settings/migration.sql b/prisma/migrations/20260310_site_settings/migration.sql new file mode 100644 index 0000000..5ae8352 --- /dev/null +++ b/prisma/migrations/20260310_site_settings/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 44913ba..91bfa04 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 -- 2.45.3 From 733c45d061cda8dee493e05d6d0034f65a413bd4 Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Thu, 18 Dec 2025 21:28:04 +0200 Subject: [PATCH 2/2] Fix login link type for contact visibility --- app/listings/[slug]/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/listings/[slug]/page.tsx b/app/listings/[slug]/page.tsx index a681e93..c97aafd 100644 --- a/app/listings/[slug]/page.tsx +++ b/app/listings/[slug]/page.tsx @@ -10,6 +10,7 @@ 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 }; @@ -118,7 +119,7 @@ export default async function ListingPage({ params }: ListingPageProps) { 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 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; -- 2.45.3