diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index a97b968..81d07ce 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -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, diff --git a/app/listings/[slug]/page.tsx b/app/listings/[slug]/page.tsx index ebac9e1..98d152e 100644 --- a/app/listings/[slug]/page.tsx +++ b/app/listings/[slug]/page.tsx @@ -26,6 +26,7 @@ const amenityIcons: Record = { barbecue: '🍖', microwave: '🍲', parking: '🅿️', + ski: '⛷️', }; export async function generateMetadata({ params }: ListingPageProps): Promise { @@ -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, diff --git a/app/listings/new/page.tsx b/app/listings/new/page.tsx index da64feb..eab3e4a 100644 --- a/app/listings/new/page.tsx +++ b/app/listings/new/page.tsx @@ -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(false); const [calendarUrls, setCalendarUrls] = useState(''); const [selectedImages, setSelectedImages] = useState([]); 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() { ))}
{t('evChargingLabel')}
+
{t('evChargingExplain')}
{[ - { 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) => (
@@ -482,8 +483,8 @@ export default function ListingsIndexPage() { {startDate && endDate && l.availableForDates ? ( {t('availableForDates')} ) : null} - {l.evCharging === 'FREE' ? {t('amenityEvFree')} : null} - {l.evCharging === 'PAID' ? {t('amenityEvPaid')} : null} + {l.evChargingAvailable ? {t('amenityEvAvailable')} : null} + {l.hasSkiPass ? {t('amenitySkiPass')} : null} {l.hasAirConditioning ? {t('amenityAirConditioning')} : null} {l.hasKitchen ? {t('amenityKitchen')} : null} {l.hasDishwasher ? {t('amenityDishwasher')} : null} diff --git a/lib/i18n.ts b/lib/i18n.ts index 86f527e..77642f5 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -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 = { 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; diff --git a/prisma/migrations/20250303_ski_pass_and_ev_bool/migration.sql b/prisma/migrations/20250303_ski_pass_and_ev_bool/migration.sql new file mode 100644 index 0000000..970b9d1 --- /dev/null +++ b/prisma/migrations/20250303_ski_pass_and_ev_bool/migration.sql @@ -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 $$; diff --git a/prisma/migrations/20251124205200_listing_address_ev/migration.sql b/prisma/migrations/20251124205200_listing_address_ev/migration.sql index 15740d2..f03ebd6 100644 --- a/prisma/migrations/20251124205200_listing_address_ev/migration.sql +++ b/prisma/migrations/20251124205200_listing_address_ev/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f1997af..0c6fe37 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/prisma/seed.js b/prisma/seed.js index 4ec7070..05a3df0 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -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), }; });