diff --git a/PROGRESS.md b/PROGRESS.md index d367160..f8d47f2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -65,6 +65,9 @@ - Added `generate_images.py` and committed sample image assets for reseeding/rebuilds. - Price hint now stored in euros (schema field `priceHintPerNightEuros`); Prisma migration added to convert from cents, seeds and API/UI updated, and build now runs `prisma generate` automatically. - Listing creation amenities UI improved with toggle cards and EV button group. +- Edit listing form now matches the create form styling, including amenity icon grid and price helpers. +- Centralized logging stack scaffolded (Loki + Promtail + Grafana) with Helm values and install script; Grafana ingress defaults to `logs.lomavuokraus.fi`. +- Logging: Loki+Promtail+Grafana deployed to `logging` namespace; DNS updated for `logs.lomavuokraus.fi`; Grafana admin password reset due to PVC-stored credentials overriding the secret. - Mermaid docs fixed: all sequence diagrams declare their participants and avoid “->” inside message text; the listing creation diagram message was rewritten to prevent parse errors. Use mermaid.live or browser console to debug future syntax issues (errors flag the offending line/column). - New amenities added: kitchen, dishwasher, washing machine, barbecue; API/UI/i18n updated and seeds randomized to populate missing prices/amenities. Prisma migration `20250210_more_amenities` applied to shared DB; registry pull secret added to k8s Deployment to avoid image pull errors in prod. - Added About and Pricing pages (FI/EN), moved highlights/runtime config to About, and linked footer navigation. @@ -83,3 +86,4 @@ - Netdata installed on k3s node (`node1.lomavuokraus.fi:8443`) and DB host (`db1.lomavuokraus.fi:8443`) behind self-signed TLS + basic auth; DB Netdata includes Postgres metrics via dedicated `netdata` role. - Footer now includes a minimal cookie usage statement (essential cookies only; site requires acceptance). - Forgejo deployment scaffolding added: Docker Compose + runner config guidance and Apache vhost for git.halla-aho.net, plus CI workflow placeholder under `.forgejo/workflows/`. +- Amenities: added separate EV charging flags (on-site vs nearby) plus wheelchair accessibility, including browse filters and admin approvals view badges. diff --git a/app/admin/pending/page.tsx b/app/admin/pending/page.tsx index bfb3e8d..168e4a0 100644 --- a/app/admin/pending/page.tsx +++ b/app/admin/pending/page.tsx @@ -4,7 +4,16 @@ import { useEffect, useState } from 'react'; import { useI18n } from '../../components/I18nProvider'; type PendingUser = { id: string; email: string; status: string; emailVerifiedAt: string | null; approvedAt: string | null; role: string }; -type PendingListing = { id: string; status: string; createdAt: string; owner: { email: string }; translations: { title: string; slug: string; locale: string }[] }; +type PendingListing = { + id: string; + status: string; + createdAt: string; + owner: { email: string }; + translations: { title: string; slug: string; locale: string }[]; + evChargingAvailable: boolean; + evChargingOnSite: boolean; + wheelchairAccessible: boolean; +}; export default function PendingAdminPage() { const { t } = useI18n(); @@ -150,6 +159,11 @@ export default function PendingAdminPage() {
{l.translations[0]?.title ?? 'Listing'} — owner: {l.owner.email}
+
+ {l.evChargingOnSite ? {t('amenityEvOnSite')} : null} + {l.evChargingAvailable && !l.evChargingOnSite ? {t('amenityEvNearby')} : null} + {l.wheelchairAccessible ? {t('amenityWheelchairAccessible')} : null} +
{t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')}
diff --git a/app/api/admin/pending/route.ts b/app/api/admin/pending/route.ts index 5a9248f..9b7b666 100644 --- a/app/api/admin/pending/route.ts +++ b/app/api/admin/pending/route.ts @@ -28,7 +28,16 @@ export async function GET(req: Request) { wantsListings ? prisma.listing.findMany({ where: { status: ListingStatus.PENDING, removedAt: null }, - select: { id: true, status: true, createdAt: true, owner: { select: { email: true } }, translations: { select: { title: true, slug: true, locale: true } } }, + select: { + id: true, + status: true, + createdAt: true, + evChargingAvailable: true, + evChargingOnSite: true, + wheelchairAccessible: true, + owner: { select: { email: true } }, + translations: { select: { title: true, slug: true, locale: true } }, + }, orderBy: { createdAt: 'asc' }, take: 50, }) diff --git a/app/api/listings/[id]/route.ts b/app/api/listings/[id]/route.ts index 45554b5..30d1be1 100644 --- a/app/api/listings/[id]/route.ts +++ b/app/api/listings/[id]/route.ts @@ -183,6 +183,13 @@ export async function PUT(req: Request, { params }: { params: { id: string } }) } } + const incomingEvChargingOnSite = + body.evChargingOnSite === undefined ? existing.evChargingOnSite : Boolean(body.evChargingOnSite); + const incomingEvChargingAvailable = + body.evChargingAvailable === undefined ? existing.evChargingAvailable : Boolean(body.evChargingAvailable); + const evChargingAvailable = incomingEvChargingOnSite ? true : incomingEvChargingAvailable; + const evChargingOnSite = evChargingAvailable ? incomingEvChargingOnSite : false; + const updateData: any = { status, approvedAt: status === ListingStatus.PUBLISHED ? existing.approvedAt ?? new Date() : null, @@ -211,7 +218,10 @@ export async function PUT(req: Request, { params }: { params: { id: string } }) hasMicrowave: body.hasMicrowave === undefined ? existing.hasMicrowave : Boolean(body.hasMicrowave), hasFreeParking: body.hasFreeParking === undefined ? existing.hasFreeParking : Boolean(body.hasFreeParking), hasSkiPass: body.hasSkiPass === undefined ? existing.hasSkiPass : Boolean(body.hasSkiPass), - evChargingAvailable: body.evChargingAvailable === undefined ? existing.evChargingAvailable : Boolean(body.evChargingAvailable), + evChargingAvailable, + evChargingOnSite, + wheelchairAccessible: + body.wheelchairAccessible === undefined ? existing.wheelchairAccessible : Boolean(body.wheelchairAccessible), priceWeekdayEuros, priceWeekendEuros, calendarUrls, diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index cf38991..63ccfc7 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -58,6 +58,8 @@ export async function GET(req: Request) { const region = searchParams.get('region')?.trim(); const evChargingParam = searchParams.get('evCharging'); const evCharging = evChargingParam === 'true' ? true : evChargingParam === 'false' ? false : null; + const evChargingOnSiteParam = searchParams.get('evChargingOnSite'); + const evChargingOnSite = evChargingOnSiteParam === 'true' ? true : evChargingOnSiteParam === 'false' ? false : null; const startDateParam = searchParams.get('availableStart'); const endDateParam = searchParams.get('availableEnd'); const startDate = startDateParam ? new Date(startDateParam) : null; @@ -81,6 +83,8 @@ export async function GET(req: Request) { if (amenityFilters.includes('microwave')) amenityWhere.hasMicrowave = true; if (amenityFilters.includes('parking')) amenityWhere.hasFreeParking = true; if (amenityFilters.includes('skipass')) amenityWhere.hasSkiPass = true; + if (amenityFilters.includes('accessible')) amenityWhere.wheelchairAccessible = true; + if (amenityFilters.includes('ev-onsite')) amenityWhere.evChargingOnSite = true; const where: Prisma.ListingWhereInput = { status: ListingStatus.PUBLISHED, @@ -88,6 +92,7 @@ export async function GET(req: Request) { city: city ? { contains: city, mode: 'insensitive' } : undefined, region: region ? { contains: region, mode: 'insensitive' } : undefined, evChargingAvailable: evCharging ?? undefined, + evChargingOnSite: evChargingOnSite ?? undefined, ...amenityWhere, translations: q ? { @@ -174,6 +179,8 @@ export async function GET(req: Request) { hasFreeParking: listing.hasFreeParking, hasSkiPass: listing.hasSkiPass, evChargingAvailable: listing.evChargingAvailable, + evChargingOnSite: listing.evChargingOnSite, + wheelchairAccessible: listing.wheelchairAccessible, maxGuests: listing.maxGuests, bedrooms: listing.bedrooms, beds: listing.beds, @@ -331,7 +338,9 @@ export async function POST(req: Request) { const autoApprove = !saveDraft && (process.env.AUTO_APPROVE_LISTINGS === 'true' || auth.role === 'ADMIN'); const status = saveDraft ? ListingStatus.DRAFT : autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING; const isSample = (contactEmail || '').toLowerCase() === SAMPLE_EMAIL; - const evChargingAvailable = Boolean(body.evChargingAvailable); + const evChargingOnSite = Boolean(body.evChargingOnSite); + const evChargingAvailable = Boolean(body.evChargingAvailable) || evChargingOnSite; + const wheelchairAccessible = Boolean(body.wheelchairAccessible); const listing = await prisma.listing.create({ data: { @@ -364,6 +373,8 @@ export async function POST(req: Request) { hasFreeParking: Boolean(body.hasFreeParking), hasSkiPass: Boolean(body.hasSkiPass), evChargingAvailable, + evChargingOnSite, + wheelchairAccessible, priceWeekdayEuros, priceWeekendEuros, calendarUrls, diff --git a/app/listings/[slug]/page.tsx b/app/listings/[slug]/page.tsx index 3d1986d..4b9032b 100644 --- a/app/listings/[slug]/page.tsx +++ b/app/listings/[slug]/page.tsx @@ -22,12 +22,14 @@ const amenityIcons: Record = { lake: '🌊', ac: '❄️', ev: '⚡', + evOnSite: '🔌', kitchen: '🍽️', dishwasher: '🧼', washer: '🧺', barbecue: '🍖', microwave: '🍲', parking: '🅿️', + accessible: '♿', ski: '⛷️', }; @@ -91,7 +93,9 @@ export default async function ListingPage({ params }: ListingPageProps) { listing.petsAllowed ? { icon: amenityIcons.pets, label: t('amenityPets') } : null, listing.byTheLake ? { icon: amenityIcons.lake, label: t('amenityLake') } : null, listing.hasAirConditioning ? { icon: amenityIcons.ac, label: t('amenityAirConditioning') } : null, - listing.evChargingAvailable ? { icon: amenityIcons.ev, label: t('amenityEvNearby') } : null, + listing.evChargingOnSite ? { icon: amenityIcons.evOnSite, label: t('amenityEvOnSite') } : null, + listing.evChargingAvailable && !listing.evChargingOnSite ? { icon: amenityIcons.ev, label: t('amenityEvNearby') } : null, + listing.wheelchairAccessible ? { icon: amenityIcons.accessible, label: t('amenityWheelchairAccessible') } : null, listing.hasSkiPass ? { icon: amenityIcons.ski, label: t('amenitySkiPass') } : null, listing.hasKitchen ? { icon: amenityIcons.kitchen, label: t('amenityKitchen') } : null, listing.hasDishwasher ? { icon: amenityIcons.dishwasher, label: t('amenityDishwasher') } : null, diff --git a/app/listings/edit/[id]/page.tsx b/app/listings/edit/[id]/page.tsx index 5646703..5c90ade 100644 --- a/app/listings/edit/[id]/page.tsx +++ b/app/listings/edit/[id]/page.tsx @@ -59,6 +59,8 @@ export default function EditListingPage({ params }: { params: { id: string } }) const [hasFreeParking, setHasFreeParking] = useState(false); const [hasSkiPass, setHasSkiPass] = useState(false); const [evChargingAvailable, setEvChargingAvailable] = useState(false); + const [evChargingOnSite, setEvChargingOnSite] = useState(false); + const [wheelchairAccessible, setWheelchairAccessible] = useState(false); const [calendarUrls, setCalendarUrls] = useState(''); const [selectedImages, setSelectedImages] = useState([]); const [coverImageIndex, setCoverImageIndex] = useState(1); @@ -138,6 +140,8 @@ export default function EditListingPage({ params }: { params: { id: string } }) setHasFreeParking(listing.hasFreeParking); setHasSkiPass(listing.hasSkiPass); setEvChargingAvailable(listing.evChargingAvailable); + setEvChargingOnSite(Boolean(listing.evChargingOnSite)); + setWheelchairAccessible(Boolean(listing.wheelchairAccessible)); setCalendarUrls((listing.calendarUrls || []).join('\n')); if (listing.images?.length) { const coverIdx = listing.images.find((img: any) => img.isCover)?.order ?? 1; @@ -255,6 +259,39 @@ export default function EditListingPage({ params }: { params: { id: string } }) } } + function toggleEvChargingNearby(next: boolean) { + setEvChargingAvailable(next); + if (!next) { + setEvChargingOnSite(false); + } + } + + function toggleEvChargingOnSite(next: boolean) { + setEvChargingOnSite(next); + if (next) { + setEvChargingAvailable(true); + } + } + + const amenityOptions = [ + { key: 'sauna', label: t('amenitySauna'), icon: '🧖', checked: hasSauna, toggle: setHasSauna }, + { key: 'fireplace', label: t('amenityFireplace'), icon: '🔥', checked: hasFireplace, toggle: setHasFireplace }, + { key: 'wifi', label: t('amenityWifi'), icon: '📶', checked: hasWifi, toggle: setHasWifi }, + { key: 'pets', label: t('amenityPets'), icon: '🐾', checked: petsAllowed, toggle: setPetsAllowed }, + { key: 'lake', label: t('amenityLake'), icon: '🌊', checked: byTheLake, toggle: setByTheLake }, + { key: 'ac', label: t('amenityAirConditioning'), icon: '❄️', checked: hasAirConditioning, toggle: setHasAirConditioning }, + { key: 'kitchen', label: t('amenityKitchen'), icon: '🍽️', checked: hasKitchen, toggle: setHasKitchen }, + { key: 'dishwasher', label: t('amenityDishwasher'), icon: '🧼', checked: hasDishwasher, toggle: setHasDishwasher }, + { key: 'washer', label: t('amenityWashingMachine'), icon: '🧺', checked: hasWashingMachine, toggle: setHasWashingMachine }, + { key: 'barbecue', label: t('amenityBarbecue'), icon: '🍖', checked: hasBarbecue, toggle: setHasBarbecue }, + { key: 'microwave', label: t('amenityMicrowave'), icon: '🍲', checked: hasMicrowave, toggle: setHasMicrowave }, + { key: 'parking', label: t('amenityFreeParking'), icon: '🅿️', checked: hasFreeParking, toggle: setHasFreeParking }, + { key: 'ski', label: t('amenitySkiPass'), icon: '⛷️', checked: hasSkiPass, toggle: setHasSkiPass }, + { key: 'ev', label: t('amenityEvNearby'), icon: '⚡', checked: evChargingAvailable, toggle: toggleEvChargingNearby }, + { key: 'ev-onsite', label: t('amenityEvOnSite'), icon: '🔌', checked: evChargingOnSite, toggle: toggleEvChargingOnSite }, + { key: 'accessible', label: t('amenityWheelchairAccessible'), icon: '♿', checked: wheelchairAccessible, toggle: setWheelchairAccessible }, + ]; + async function checkSlugAvailability() { const value = slug.trim().toLowerCase(); if (!value) { @@ -422,7 +459,10 @@ export default function EditListingPage({ params }: { params: { id: string } }) hasBarbecue, hasMicrowave, hasFreeParking, + hasSkiPass, evChargingAvailable, + evChargingOnSite, + wheelchairAccessible, coverImageIndex, images: selectedImages.length ? parseImages() : undefined, calendarUrls, @@ -644,15 +684,38 @@ export default function EditListingPage({ params }: { params: { id: string } })
+
{t('priceHintHelp')}
+