Add ski pass amenity and simplify EV charging availability

This commit is contained in:
Tero Halla-aho 2025-12-06 23:17:56 +02:00
parent 6cc5efc9e5
commit 27fc8ee2d1
9 changed files with 108 additions and 66 deletions

View file

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { ListingStatus, UserStatus, EvCharging, Prisma } from '@prisma/client';
import { ListingStatus, UserStatus, Prisma } from '@prisma/client';
import { prisma } from '../../../lib/prisma';
import { requireAuth } from '../../../lib/jwt';
import { resolveLocale } from '../../../lib/i18n';
@ -10,13 +10,6 @@ const MAX_IMAGES = 6;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image
const SAMPLE_EMAIL = 'host@lomavuokraus.fi';
function normalizeEvCharging(input?: string | null): EvCharging {
const value = String(input ?? 'NONE').toUpperCase();
if (value === 'FREE') return EvCharging.FREE;
if (value === 'PAID') return EvCharging.PAID;
return EvCharging.NONE;
}
function resolveImageUrl(img: { id: string; url: string | null; size: number | null }) {
if (img.size && img.size > 0) {
return `/api/images/${img.id}`;
@ -64,7 +57,7 @@ export async function GET(req: Request) {
const city = searchParams.get('city')?.trim();
const region = searchParams.get('region')?.trim();
const evChargingParam = searchParams.get('evCharging');
const evCharging = evChargingParam ? normalizeEvCharging(evChargingParam) : null;
const evCharging = evChargingParam === 'true' ? true : evChargingParam === 'false' ? false : null;
const startDateParam = searchParams.get('availableStart');
const endDateParam = searchParams.get('availableEnd');
const startDate = startDateParam ? new Date(startDateParam) : null;
@ -87,13 +80,14 @@ export async function GET(req: Request) {
if (amenityFilters.includes('barbecue')) amenityWhere.hasBarbecue = true;
if (amenityFilters.includes('microwave')) amenityWhere.hasMicrowave = true;
if (amenityFilters.includes('parking')) amenityWhere.hasFreeParking = true;
if (amenityFilters.includes('skipass')) amenityWhere.hasSkiPass = true;
const where: Prisma.ListingWhereInput = {
status: ListingStatus.PUBLISHED,
removedAt: null,
city: city ? { contains: city, mode: 'insensitive' } : undefined,
region: region ? { contains: region, mode: 'insensitive' } : undefined,
evCharging: evCharging ?? undefined,
evChargingAvailable: evCharging ?? undefined,
...amenityWhere,
translations: q
? {
@ -327,6 +321,8 @@ 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 =
body.evChargingAvailable === undefined || body.evChargingAvailable === null ? null : Boolean(body.evChargingAvailable);
const listing = await prisma.listing.create({
data: {
@ -357,7 +353,8 @@ export async function POST(req: Request) {
hasBarbecue: Boolean(body.hasBarbecue),
hasMicrowave: Boolean(body.hasMicrowave),
hasFreeParking: Boolean(body.hasFreeParking),
evCharging: normalizeEvCharging(body.evCharging),
hasSkiPass: Boolean(body.hasSkiPass),
evChargingAvailable,
priceWeekdayEuros,
priceWeekendEuros,
calendarUrls,

View file

@ -26,6 +26,7 @@ const amenityIcons: Record<string, string> = {
barbecue: '🍖',
microwave: '🍲',
parking: '🅿️',
ski: '⛷️',
};
export async function generateMetadata({ params }: ListingPageProps): Promise<Metadata> {
@ -66,8 +67,8 @@ 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.evCharging === 'FREE' ? { icon: amenityIcons.ev, label: t('amenityEvFree') } : null,
listing.evCharging === 'PAID' ? { icon: amenityIcons.ev, label: t('amenityEvPaid') } : null,
listing.evChargingAvailable ? { icon: amenityIcons.ev, label: t('amenityEvNearby') } : 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,
listing.hasWashingMachine ? { icon: amenityIcons.washer, label: t('amenityWashingMachine') } : null,

View file

@ -53,7 +53,7 @@ export default function NewListingPage() {
const [hasBarbecue, setHasBarbecue] = useState(false);
const [hasMicrowave, setHasMicrowave] = useState(false);
const [hasFreeParking, setHasFreeParking] = useState(false);
const [evCharging, setEvCharging] = useState<'NONE' | 'FREE' | 'PAID'>('NONE');
const [evChargingAvailable, setEvChargingAvailable] = useState<boolean>(false);
const [calendarUrls, setCalendarUrls] = useState('');
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
const [coverImageIndex, setCoverImageIndex] = useState(1);
@ -135,6 +135,7 @@ export default function NewListingPage() {
{ 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 },
];
function updateTranslation(locale: Locale, field: keyof LocaleFields, value: string) {
@ -369,7 +370,7 @@ export default function NewListingPage() {
hasBarbecue,
hasMicrowave,
hasFreeParking,
evCharging,
evChargingAvailable,
coverImageIndex,
images: parseImages(),
calendarUrls,
@ -704,17 +705,17 @@ export default function NewListingPage() {
))}
<div className="amenity-ev">
<div className="amenity-ev-label">{t('evChargingLabel')}</div>
<div style={{ color: '#cbd5e1', fontSize: 12, marginBottom: 6 }}>{t('evChargingExplain')}</div>
<div className="ev-toggle-group">
{[
{ value: 'NONE', label: t('evChargingNone'), icon: '🚗' },
{ value: 'FREE', label: t('evChargingFree'), icon: '⚡' },
{ value: 'PAID', label: t('evChargingPaid'), icon: '💳' },
{ value: true, label: t('evChargingYes'), icon: '⚡' },
{ value: false, label: t('evChargingNo'), icon: '🚗' },
].map((opt) => (
<button
key={opt.value}
key={String(opt.value)}
type="button"
className={`ev-toggle ${evCharging === opt.value ? 'active' : ''}`}
onClick={() => setEvCharging(opt.value as typeof evCharging)}
className={`ev-toggle ${evChargingAvailable === opt.value ? 'active' : ''}`}
onClick={() => setEvChargingAvailable(opt.value)}
>
<span aria-hidden className="amenity-emoji">
{opt.icon}

View file

@ -30,7 +30,7 @@ type ListingResult = {
hasBarbecue: boolean;
hasMicrowave: boolean;
hasFreeParking: boolean;
evCharging: 'NONE' | 'FREE' | 'PAID';
evChargingAvailable: boolean;
maxGuests: number;
bedrooms: number;
beds: number;
@ -89,6 +89,7 @@ const amenityIcons: Record<string, string> = {
barbecue: '🍖',
microwave: '🍲',
parking: '🅿️',
ski: '⛷️',
};
function ListingsMap({
@ -181,7 +182,7 @@ export default function ListingsIndexPage() {
const [query, setQuery] = useState('');
const [city, setCity] = useState('');
const [region, setRegion] = useState('');
const [evCharging, setEvCharging] = useState<'ALL' | 'FREE' | 'PAID' | 'NONE'>('ALL');
const [evCharging, setEvCharging] = useState<'ALL' | 'YES' | 'NO'>('ALL');
const [listings, setListings] = useState<ListingResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -207,7 +208,7 @@ export default function ListingsIndexPage() {
const filtered = useMemo(() => {
if (evCharging === 'ALL') return filteredByAddress;
return filteredByAddress.filter((l) => l.evCharging === evCharging);
return filteredByAddress.filter((l) => (evCharging === 'YES' ? l.evChargingAvailable : !l.evChargingAvailable));
}, [filteredByAddress, evCharging]);
const amenityOptions = [
@ -223,6 +224,7 @@ export default function ListingsIndexPage() {
{ key: 'barbecue', label: t('amenityBarbecue'), icon: amenityIcons.barbecue },
{ key: 'microwave', label: t('amenityMicrowave'), icon: amenityIcons.microwave },
{ key: 'parking', label: t('amenityFreeParking'), icon: amenityIcons.parking },
{ key: 'skipass', label: t('amenitySkiPass'), icon: amenityIcons.ski },
];
async function fetchListings() {
@ -233,7 +235,7 @@ export default function ListingsIndexPage() {
if (query) params.set('q', query);
if (city) params.set('city', city);
if (region) params.set('region', region);
if (evCharging !== 'ALL') params.set('evCharging', evCharging);
if (evCharging !== 'ALL') params.set('evCharging', evCharging === 'YES' ? 'true' : 'false');
if (startDate) params.set('availableStart', startDate);
if (endDate) params.set('availableEnd', endDate);
amenities.forEach((a) => params.append('amenity', a));
@ -322,9 +324,8 @@ export default function ListingsIndexPage() {
{t('evChargingLabel')}
<select value={evCharging} onChange={(e) => setEvCharging(e.target.value as any)}>
<option value="ALL">{t('evChargingAny')}</option>
<option value="FREE">{t('evChargingFree')}</option>
<option value="PAID">{t('evChargingPaid')}</option>
<option value="NONE">{t('evChargingNone')}</option>
<option value="YES">{t('evChargingYes')}</option>
<option value="NO">{t('evChargingNo')}</option>
</select>
</label>
</div>
@ -482,8 +483,8 @@ export default function ListingsIndexPage() {
{startDate && endDate && l.availableForDates ? (
<span className="badge">{t('availableForDates')}</span>
) : null}
{l.evCharging === 'FREE' ? <span className="badge">{t('amenityEvFree')}</span> : null}
{l.evCharging === 'PAID' ? <span className="badge">{t('amenityEvPaid')}</span> : null}
{l.evChargingAvailable ? <span className="badge">{t('amenityEvAvailable')}</span> : null}
{l.hasSkiPass ? <span className="badge">{t('amenitySkiPass')}</span> : null}
{l.hasAirConditioning ? <span className="badge">{t('amenityAirConditioning')}</span> : null}
{l.hasKitchen ? <span className="badge">{t('amenityKitchen')}</span> : null}
{l.hasDishwasher ? <span className="badge">{t('amenityDishwasher')}</span> : null}

View file

@ -224,12 +224,13 @@ const baseMessages = {
amenityBarbecue: 'Barbecue grill',
amenityMicrowave: 'Microwave',
amenityFreeParking: 'Free parking',
amenityEvFree: 'EV charging (free)',
amenityEvPaid: 'EV charging (paid)',
evChargingLabel: 'EV charging',
evChargingNone: 'Not available',
evChargingFree: 'Free for guests',
evChargingPaid: 'Paid on-site',
amenityEvAvailable: 'EV charging nearby',
amenitySkiPass: 'Ski pass included',
evChargingLabel: 'EV charging nearby',
evChargingYes: 'Charging nearby',
evChargingNo: 'No charging nearby',
evChargingAny: 'Any',
evChargingExplain: 'Is there EV charging available on-site or nearby?',
capacityGuests: '{count} guests',
capacityBedrooms: '{count} bedrooms',
capacityBeds: '{count} beds',
@ -512,12 +513,13 @@ const baseMessages = {
amenityBarbecue: 'Grilli',
amenityMicrowave: 'Mikroaaltouuni',
amenityFreeParking: 'Maksuton pysäköinti',
amenityEvFree: 'Sähköauton lataus (ilmainen)',
amenityEvPaid: 'Sähköauton lataus (maksullinen)',
evChargingLabel: 'Sähköauton lataus',
evChargingNone: 'Ei saatavilla',
evChargingFree: 'Ilmainen asiakkaille',
evChargingPaid: 'Maksullinen',
amenityEvAvailable: 'Sähköauton lataus lähellä',
amenitySkiPass: 'Hissilippu sisältyy',
evChargingLabel: 'Sähköauton lataus lähellä',
evChargingYes: 'Latausta lähellä',
evChargingNo: 'Ei latausta lähellä',
evChargingAny: 'Kaikki',
evChargingExplain: 'Onko kohteessa tai lähistöllä sähköauton latausmahdollisuus?',
capacityGuests: '{count} vierasta',
capacityBedrooms: '{count} makuuhuonetta',
capacityBeds: '{count} vuodetta',
@ -636,8 +638,15 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
priceWeekendShort: '{price}€ helg',
priceNotSet: 'Ej angivet',
listingPrices: 'Priser',
amenityEvAvailable: 'EV-laddning i närheten',
amenitySkiPass: 'Liftkort ingår',
amenityMicrowave: 'Mikrovågsugn',
amenityFreeParking: 'Gratis parkering',
evChargingLabel: 'EV-laddning i närheten',
evChargingYes: 'Laddning i närheten',
evChargingNo: 'Ingen laddning i närheten',
evChargingAny: 'Alla',
evChargingExplain: 'Finns det EV-laddning på plats eller i närheten?',
};
export const messages = { ...baseMessages, sv: svMessages } as const;

View file

@ -0,0 +1,31 @@
-- Add ski pass amenity and convert EV charging to boolean availability
ALTER TABLE "Listing"
ADD COLUMN IF NOT EXISTS "hasSkiPass" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "evChargingAvailable" BOOLEAN NOT NULL DEFAULT false;
-- Backfill evChargingAvailable from legacy enum if present
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'Listing' AND column_name = 'evCharging'
) THEN
UPDATE "Listing"
SET "evChargingAvailable" = CASE WHEN "evCharging" IS NULL OR "evCharging" = 'NONE' THEN false ELSE true END;
END IF;
END $$;
-- Drop legacy enum column/type if present
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'Listing' AND column_name = 'evCharging'
) THEN
ALTER TABLE "Listing" DROP COLUMN "evCharging";
END IF;
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'EvCharging') THEN
DROP TYPE "EvCharging";
END IF;
END $$;

View file

@ -1,10 +1,6 @@
-- Add missing address and EV charging fields to listings
DO $$
BEGIN
CREATE TYPE "EvCharging" AS ENUM ('NONE', 'FREE', 'PAID');
EXCEPTION
WHEN duplicate_object THEN NULL;
END$$;
-- Add address fields and EV charging enum column (legacy)
ALTER TABLE "Listing" ADD COLUMN IF NOT EXISTS "streetAddress" TEXT;
ALTER TABLE "Listing" ADD COLUMN IF NOT EXISTS "addressNote" TEXT;
ALTER TABLE "Listing" ADD COLUMN IF NOT EXISTS "latitude" DOUBLE PRECISION;
ALTER TABLE "Listing" ADD COLUMN IF NOT EXISTS "longitude" DOUBLE PRECISION;
ALTER TABLE "Listing" ADD COLUMN IF NOT EXISTS "evCharging" "EvCharging" NOT NULL DEFAULT 'NONE';

View file

@ -29,12 +29,6 @@ enum ListingStatus {
REMOVED
}
enum EvCharging {
NONE
FREE
PAID
}
model User {
id String @id @default(cuid())
email String @unique
@ -94,7 +88,8 @@ model Listing {
hasBarbecue Boolean @default(false)
hasMicrowave Boolean @default(false)
hasFreeParking Boolean @default(false)
evCharging EvCharging @default(NONE)
hasSkiPass Boolean @default(false)
evChargingAvailable Boolean @default(false)
calendarUrls String[] @db.Text @default([])
priceWeekdayEuros Int?
priceWeekendEuros Int?

View file

@ -161,7 +161,8 @@ async function main() {
hasFreeParking: true,
petsAllowed: false,
byTheLake: true,
evCharging: 'FREE',
evChargingAvailable: true,
hasSkiPass: false,
priceWeekdayEuros: 145,
priceWeekendEuros: 165,
cover: {
@ -208,7 +209,8 @@ async function main() {
hasFreeParking: false,
petsAllowed: false,
byTheLake: false,
evCharging: 'PAID',
evChargingAvailable: true,
hasSkiPass: false,
priceWeekdayEuros: 165,
priceWeekendEuros: 185,
cover: {
@ -253,7 +255,8 @@ async function main() {
hasFreeParking: true,
petsAllowed: true,
byTheLake: false,
evCharging: 'NONE',
evChargingAvailable: false,
hasSkiPass: false,
priceWeekdayEuros: 110,
priceWeekendEuros: 125,
cover: {
@ -297,7 +300,8 @@ async function main() {
hasFreeParking: true,
petsAllowed: false,
byTheLake: true,
evCharging: 'FREE',
evChargingAvailable: true,
hasSkiPass: true,
priceWeekdayEuros: 189,
priceWeekendEuros: 215,
cover: {
@ -341,7 +345,8 @@ async function main() {
hasFreeParking: false,
petsAllowed: false,
byTheLake: false,
evCharging: 'NONE',
evChargingAvailable: false,
hasSkiPass: false,
priceWeekdayEuros: 95,
priceWeekendEuros: 110,
cover: {
@ -383,7 +388,8 @@ async function main() {
hasFreeParking: true,
petsAllowed: true,
byTheLake: true,
evCharging: 'PAID',
evChargingAvailable: true,
hasSkiPass: true,
priceWeekdayEuros: 245,
priceWeekendEuros: 275,
cover: {
@ -425,7 +431,8 @@ async function main() {
hasFreeParking: true,
petsAllowed: false,
byTheLake: true,
evCharging: 'FREE',
evChargingAvailable: true,
hasSkiPass: true,
priceWeekdayEuros: 129,
priceWeekendEuros: 149,
cover: {
@ -467,7 +474,8 @@ async function main() {
hasFreeParking: false,
petsAllowed: false,
byTheLake: false,
evCharging: 'NONE',
evChargingAvailable: false,
hasSkiPass: false,
priceWeekdayEuros: 99,
priceWeekendEuros: 115,
cover: {
@ -551,7 +559,8 @@ async function main() {
hasFreeParking: true,
petsAllowed: false,
byTheLake: true,
evCharging: 'PAID',
evChargingAvailable: true,
hasSkiPass: true,
priceWeekdayEuros: 115,
priceWeekendEuros: 130,
cover: {
@ -583,6 +592,8 @@ async function main() {
hasBarbecue: item.hasBarbecue ?? randBool(0.5),
hasMicrowave: item.hasMicrowave ?? randBool(0.7),
hasFreeParking: item.hasFreeParking ?? randBool(0.6),
evChargingAvailable: item.evChargingAvailable ?? randBool(0.4),
hasSkiPass: item.hasSkiPass ?? randBool(0.2),
};
});