Add wheelchair accessibility amenity

This commit is contained in:
Tero Halla-aho 2025-12-17 13:26:49 +02:00
parent c63d4e543b
commit 6674f95856
11 changed files with 54 additions and 19 deletions

View file

@ -86,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. - 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). - 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/`. - 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 wheelchair accessibility flag and clarified EV charging amenity wording to mean on-site charging.

View file

@ -212,6 +212,8 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
hasFreeParking: body.hasFreeParking === undefined ? existing.hasFreeParking : Boolean(body.hasFreeParking), hasFreeParking: body.hasFreeParking === undefined ? existing.hasFreeParking : Boolean(body.hasFreeParking),
hasSkiPass: body.hasSkiPass === undefined ? existing.hasSkiPass : Boolean(body.hasSkiPass), hasSkiPass: body.hasSkiPass === undefined ? existing.hasSkiPass : Boolean(body.hasSkiPass),
evChargingAvailable: body.evChargingAvailable === undefined ? existing.evChargingAvailable : Boolean(body.evChargingAvailable), evChargingAvailable: body.evChargingAvailable === undefined ? existing.evChargingAvailable : Boolean(body.evChargingAvailable),
wheelchairAccessible:
body.wheelchairAccessible === undefined ? existing.wheelchairAccessible : Boolean(body.wheelchairAccessible),
priceWeekdayEuros, priceWeekdayEuros,
priceWeekendEuros, priceWeekendEuros,
calendarUrls, calendarUrls,

View file

@ -81,6 +81,7 @@ export async function GET(req: Request) {
if (amenityFilters.includes('microwave')) amenityWhere.hasMicrowave = true; if (amenityFilters.includes('microwave')) amenityWhere.hasMicrowave = true;
if (amenityFilters.includes('parking')) amenityWhere.hasFreeParking = true; if (amenityFilters.includes('parking')) amenityWhere.hasFreeParking = true;
if (amenityFilters.includes('skipass')) amenityWhere.hasSkiPass = true; if (amenityFilters.includes('skipass')) amenityWhere.hasSkiPass = true;
if (amenityFilters.includes('accessible')) amenityWhere.wheelchairAccessible = true;
const where: Prisma.ListingWhereInput = { const where: Prisma.ListingWhereInput = {
status: ListingStatus.PUBLISHED, status: ListingStatus.PUBLISHED,
@ -174,6 +175,7 @@ export async function GET(req: Request) {
hasFreeParking: listing.hasFreeParking, hasFreeParking: listing.hasFreeParking,
hasSkiPass: listing.hasSkiPass, hasSkiPass: listing.hasSkiPass,
evChargingAvailable: listing.evChargingAvailable, evChargingAvailable: listing.evChargingAvailable,
wheelchairAccessible: listing.wheelchairAccessible,
maxGuests: listing.maxGuests, maxGuests: listing.maxGuests,
bedrooms: listing.bedrooms, bedrooms: listing.bedrooms,
beds: listing.beds, beds: listing.beds,
@ -332,6 +334,7 @@ export async function POST(req: Request) {
const status = saveDraft ? ListingStatus.DRAFT : autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING; const status = saveDraft ? ListingStatus.DRAFT : autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING;
const isSample = (contactEmail || '').toLowerCase() === SAMPLE_EMAIL; const isSample = (contactEmail || '').toLowerCase() === SAMPLE_EMAIL;
const evChargingAvailable = Boolean(body.evChargingAvailable); const evChargingAvailable = Boolean(body.evChargingAvailable);
const wheelchairAccessible = Boolean(body.wheelchairAccessible);
const listing = await prisma.listing.create({ const listing = await prisma.listing.create({
data: { data: {
@ -364,6 +367,7 @@ export async function POST(req: Request) {
hasFreeParking: Boolean(body.hasFreeParking), hasFreeParking: Boolean(body.hasFreeParking),
hasSkiPass: Boolean(body.hasSkiPass), hasSkiPass: Boolean(body.hasSkiPass),
evChargingAvailable, evChargingAvailable,
wheelchairAccessible,
priceWeekdayEuros, priceWeekdayEuros,
priceWeekendEuros, priceWeekendEuros,
calendarUrls, calendarUrls,

View file

@ -28,6 +28,7 @@ const amenityIcons: Record<string, string> = {
barbecue: '🍖', barbecue: '🍖',
microwave: '🍲', microwave: '🍲',
parking: '🅿️', parking: '🅿️',
accessible: '♿',
ski: '⛷️', ski: '⛷️',
}; };
@ -91,7 +92,8 @@ export default async function ListingPage({ params }: ListingPageProps) {
listing.petsAllowed ? { icon: amenityIcons.pets, label: t('amenityPets') } : null, listing.petsAllowed ? { icon: amenityIcons.pets, label: t('amenityPets') } : null,
listing.byTheLake ? { icon: amenityIcons.lake, label: t('amenityLake') } : null, listing.byTheLake ? { icon: amenityIcons.lake, label: t('amenityLake') } : null,
listing.hasAirConditioning ? { icon: amenityIcons.ac, label: t('amenityAirConditioning') } : null, listing.hasAirConditioning ? { icon: amenityIcons.ac, label: t('amenityAirConditioning') } : null,
listing.evChargingAvailable ? { icon: amenityIcons.ev, label: t('amenityEvNearby') } : null, listing.evChargingAvailable ? { icon: amenityIcons.ev, label: t('amenityEvAvailable') } : null,
listing.wheelchairAccessible ? { icon: amenityIcons.accessible, label: t('amenityWheelchairAccessible') } : null,
listing.hasSkiPass ? { icon: amenityIcons.ski, label: t('amenitySkiPass') } : null, listing.hasSkiPass ? { icon: amenityIcons.ski, label: t('amenitySkiPass') } : null,
listing.hasKitchen ? { icon: amenityIcons.kitchen, label: t('amenityKitchen') } : null, listing.hasKitchen ? { icon: amenityIcons.kitchen, label: t('amenityKitchen') } : null,
listing.hasDishwasher ? { icon: amenityIcons.dishwasher, label: t('amenityDishwasher') } : null, listing.hasDishwasher ? { icon: amenityIcons.dishwasher, label: t('amenityDishwasher') } : null,

View file

@ -59,6 +59,7 @@ export default function EditListingPage({ params }: { params: { id: string } })
const [hasFreeParking, setHasFreeParking] = useState(false); const [hasFreeParking, setHasFreeParking] = useState(false);
const [hasSkiPass, setHasSkiPass] = useState(false); const [hasSkiPass, setHasSkiPass] = useState(false);
const [evChargingAvailable, setEvChargingAvailable] = useState<boolean>(false); const [evChargingAvailable, setEvChargingAvailable] = useState<boolean>(false);
const [wheelchairAccessible, setWheelchairAccessible] = useState(false);
const [calendarUrls, setCalendarUrls] = useState(''); const [calendarUrls, setCalendarUrls] = useState('');
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]); const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
const [coverImageIndex, setCoverImageIndex] = useState(1); const [coverImageIndex, setCoverImageIndex] = useState(1);
@ -138,6 +139,7 @@ export default function EditListingPage({ params }: { params: { id: string } })
setHasFreeParking(listing.hasFreeParking); setHasFreeParking(listing.hasFreeParking);
setHasSkiPass(listing.hasSkiPass); setHasSkiPass(listing.hasSkiPass);
setEvChargingAvailable(listing.evChargingAvailable); setEvChargingAvailable(listing.evChargingAvailable);
setWheelchairAccessible(Boolean(listing.wheelchairAccessible));
setCalendarUrls((listing.calendarUrls || []).join('\n')); setCalendarUrls((listing.calendarUrls || []).join('\n'));
if (listing.images?.length) { if (listing.images?.length) {
const coverIdx = listing.images.find((img: any) => img.isCover)?.order ?? 1; const coverIdx = listing.images.find((img: any) => img.isCover)?.order ?? 1;
@ -270,6 +272,7 @@ export default function EditListingPage({ params }: { params: { id: string } })
{ key: 'parking', label: t('amenityFreeParking'), icon: '🅿️', checked: hasFreeParking, toggle: setHasFreeParking }, { key: 'parking', label: t('amenityFreeParking'), icon: '🅿️', checked: hasFreeParking, toggle: setHasFreeParking },
{ key: 'ski', label: t('amenitySkiPass'), icon: '⛷️', checked: hasSkiPass, toggle: setHasSkiPass }, { key: 'ski', label: t('amenitySkiPass'), icon: '⛷️', checked: hasSkiPass, toggle: setHasSkiPass },
{ key: 'ev', label: t('amenityEvAvailable'), icon: '⚡', checked: evChargingAvailable, toggle: setEvChargingAvailable }, { key: 'ev', label: t('amenityEvAvailable'), icon: '⚡', checked: evChargingAvailable, toggle: setEvChargingAvailable },
{ key: 'accessible', label: t('amenityWheelchairAccessible'), icon: '♿', checked: wheelchairAccessible, toggle: setWheelchairAccessible },
]; ];
async function checkSlugAvailability() { async function checkSlugAvailability() {
@ -439,7 +442,9 @@ export default function EditListingPage({ params }: { params: { id: string } })
hasBarbecue, hasBarbecue,
hasMicrowave, hasMicrowave,
hasFreeParking, hasFreeParking,
hasSkiPass,
evChargingAvailable, evChargingAvailable,
wheelchairAccessible,
coverImageIndex, coverImageIndex,
images: selectedImages.length ? parseImages() : undefined, images: selectedImages.length ? parseImages() : undefined,
calendarUrls, calendarUrls,

View file

@ -55,6 +55,7 @@ export default function NewListingPage() {
const [hasFreeParking, setHasFreeParking] = useState(false); const [hasFreeParking, setHasFreeParking] = useState(false);
const [hasSkiPass, setHasSkiPass] = useState(false); const [hasSkiPass, setHasSkiPass] = useState(false);
const [evChargingAvailable, setEvChargingAvailable] = useState<boolean>(false); const [evChargingAvailable, setEvChargingAvailable] = useState<boolean>(false);
const [wheelchairAccessible, setWheelchairAccessible] = useState(false);
const [calendarUrls, setCalendarUrls] = useState(''); const [calendarUrls, setCalendarUrls] = useState('');
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]); const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
const [coverImageIndex, setCoverImageIndex] = useState(1); const [coverImageIndex, setCoverImageIndex] = useState(1);
@ -138,6 +139,7 @@ export default function NewListingPage() {
{ key: 'parking', label: t('amenityFreeParking'), icon: '🅿️', checked: hasFreeParking, toggle: setHasFreeParking }, { key: 'parking', label: t('amenityFreeParking'), icon: '🅿️', checked: hasFreeParking, toggle: setHasFreeParking },
{ key: 'ski', label: t('amenitySkiPass'), icon: '⛷️', checked: hasSkiPass, toggle: setHasSkiPass }, { key: 'ski', label: t('amenitySkiPass'), icon: '⛷️', checked: hasSkiPass, toggle: setHasSkiPass },
{ key: 'ev', label: t('amenityEvAvailable'), icon: '⚡', checked: evChargingAvailable, toggle: setEvChargingAvailable }, { key: 'ev', label: t('amenityEvAvailable'), icon: '⚡', checked: evChargingAvailable, toggle: setEvChargingAvailable },
{ key: 'accessible', label: t('amenityWheelchairAccessible'), icon: '♿', checked: wheelchairAccessible, toggle: setWheelchairAccessible },
]; ];
function updateTranslation(locale: Locale, field: keyof LocaleFields, value: string) { function updateTranslation(locale: Locale, field: keyof LocaleFields, value: string) {
@ -372,7 +374,9 @@ export default function NewListingPage() {
hasBarbecue, hasBarbecue,
hasMicrowave, hasMicrowave,
hasFreeParking, hasFreeParking,
hasSkiPass,
evChargingAvailable, evChargingAvailable,
wheelchairAccessible,
coverImageIndex, coverImageIndex,
images: parseImages(), images: parseImages(),
calendarUrls, calendarUrls,
@ -412,6 +416,9 @@ export default function NewListingPage() {
setHasBarbecue(false); setHasBarbecue(false);
setHasMicrowave(false); setHasMicrowave(false);
setHasFreeParking(false); setHasFreeParking(false);
setHasSkiPass(false);
setEvChargingAvailable(false);
setWheelchairAccessible(false);
setRegion(''); setRegion('');
setCity(''); setCity('');
setStreetAddress(''); setStreetAddress('');

View file

@ -31,6 +31,7 @@ type ListingResult = {
hasMicrowave: boolean; hasMicrowave: boolean;
hasFreeParking: boolean; hasFreeParking: boolean;
evChargingAvailable: boolean; evChargingAvailable: boolean;
wheelchairAccessible: boolean;
hasSkiPass: boolean; hasSkiPass: boolean;
maxGuests: number; maxGuests: number;
bedrooms: number; bedrooms: number;
@ -90,6 +91,7 @@ const amenityIcons: Record<string, string> = {
barbecue: '🍖', barbecue: '🍖',
microwave: '🍲', microwave: '🍲',
parking: '🅿️', parking: '🅿️',
accessible: '♿',
ski: '⛷️', ski: '⛷️',
ev: '⚡', ev: '⚡',
}; };
@ -222,6 +224,7 @@ export default function ListingsIndexPage() {
{ key: 'barbecue', label: t('amenityBarbecue'), icon: amenityIcons.barbecue }, { key: 'barbecue', label: t('amenityBarbecue'), icon: amenityIcons.barbecue },
{ key: 'microwave', label: t('amenityMicrowave'), icon: amenityIcons.microwave }, { key: 'microwave', label: t('amenityMicrowave'), icon: amenityIcons.microwave },
{ key: 'parking', label: t('amenityFreeParking'), icon: amenityIcons.parking }, { key: 'parking', label: t('amenityFreeParking'), icon: amenityIcons.parking },
{ key: 'accessible', label: t('amenityWheelchairAccessible'), icon: amenityIcons.accessible },
{ key: 'skipass', label: t('amenitySkiPass'), icon: amenityIcons.ski }, { key: 'skipass', label: t('amenitySkiPass'), icon: amenityIcons.ski },
{ key: 'ev', label: t('amenityEvAvailable'), icon: amenityIcons.ev }, { key: 'ev', label: t('amenityEvAvailable'), icon: amenityIcons.ev },
]; ];
@ -477,6 +480,7 @@ export default function ListingsIndexPage() {
<span className="badge">{t('availableForDates')}</span> <span className="badge">{t('availableForDates')}</span>
) : null} ) : null}
{l.evChargingAvailable ? <span className="badge">{t('amenityEvAvailable')}</span> : null} {l.evChargingAvailable ? <span className="badge">{t('amenityEvAvailable')}</span> : null}
{l.wheelchairAccessible ? <span className="badge">{t('amenityWheelchairAccessible')}</span> : null}
{l.hasSkiPass ? <span className="badge">{t('amenitySkiPass')}</span> : null} {l.hasSkiPass ? <span className="badge">{t('amenitySkiPass')}</span> : null}
{l.hasAirConditioning ? <span className="badge">{t('amenityAirConditioning')}</span> : null} {l.hasAirConditioning ? <span className="badge">{t('amenityAirConditioning')}</span> : null}
{l.hasKitchen ? <span className="badge">{t('amenityKitchen')}</span> : null} {l.hasKitchen ? <span className="badge">{t('amenityKitchen')}</span> : null}

View file

@ -251,13 +251,14 @@ const baseMessages = {
amenityBarbecue: 'Barbecue grill', amenityBarbecue: 'Barbecue grill',
amenityMicrowave: 'Microwave', amenityMicrowave: 'Microwave',
amenityFreeParking: 'Free parking', amenityFreeParking: 'Free parking',
amenityEvAvailable: 'EV charging nearby', amenityEvAvailable: 'EV charging',
amenityWheelchairAccessible: 'Wheelchair accessible',
amenitySkiPass: 'Ski pass included', amenitySkiPass: 'Ski pass included',
evChargingLabel: 'EV charging nearby', evChargingLabel: 'EV charging',
evChargingYes: 'Charging nearby', evChargingYes: 'EV charging available',
evChargingNo: 'No charging nearby', evChargingNo: 'No EV charging',
evChargingAny: 'Any', evChargingAny: 'Any',
evChargingExplain: 'Is there EV charging available on-site or nearby?', evChargingExplain: 'Is there EV charging available at the property?',
capacityGuests: '{count} guests', capacityGuests: '{count} guests',
capacityBedrooms: '{count} bedrooms', capacityBedrooms: '{count} bedrooms',
capacityBeds: '{count} beds', capacityBeds: '{count} beds',
@ -567,13 +568,14 @@ const baseMessages = {
amenityBarbecue: 'Grilli', amenityBarbecue: 'Grilli',
amenityMicrowave: 'Mikroaaltouuni', amenityMicrowave: 'Mikroaaltouuni',
amenityFreeParking: 'Maksuton pysäköinti', amenityFreeParking: 'Maksuton pysäköinti',
amenityEvAvailable: 'Sähköauton lataus lähellä', amenityEvAvailable: 'Sähköauton lataus',
amenityWheelchairAccessible: 'Esteetön / pyörätuolilla',
amenitySkiPass: 'Hissilippu sisältyy', amenitySkiPass: 'Hissilippu sisältyy',
evChargingLabel: 'Sähköauton lataus lähellä', evChargingLabel: 'Sähköauton lataus',
evChargingYes: 'Latausta lähellä', evChargingYes: 'Latausmahdollisuus',
evChargingNo: 'Ei latausta lähellä', evChargingNo: 'Ei latausta',
evChargingAny: 'Kaikki', evChargingAny: 'Kaikki',
evChargingExplain: 'Onko kohteessa tai lähistöllä sähköauton latausmahdollisuus?', evChargingExplain: 'Onko kohteessa sähköauton latausmahdollisuus?',
capacityGuests: '{count} vierasta', capacityGuests: '{count} vierasta',
capacityBedrooms: '{count} makuuhuonetta', capacityBedrooms: '{count} makuuhuonetta',
capacityBeds: '{count} vuodetta', capacityBeds: '{count} vuodetta',
@ -718,15 +720,16 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
priceNotSet: 'Ej angivet', priceNotSet: 'Ej angivet',
listingPrices: 'Priser', listingPrices: 'Priser',
capacityUnknown: 'Kapacitet ej angiven', capacityUnknown: 'Kapacitet ej angiven',
amenityEvAvailable: 'EV-laddning i närheten', amenityEvAvailable: 'EV-laddning',
amenityWheelchairAccessible: 'Rullstolsanpassat',
amenitySkiPass: 'Liftkort ingår', amenitySkiPass: 'Liftkort ingår',
amenityMicrowave: 'Mikrovågsugn', amenityMicrowave: 'Mikrovågsugn',
amenityFreeParking: 'Gratis parkering', amenityFreeParking: 'Gratis parkering',
evChargingLabel: 'EV-laddning i närheten', evChargingLabel: 'EV-laddning',
evChargingYes: 'Laddning i närheten', evChargingYes: 'EV-laddning finns',
evChargingNo: 'Ingen laddning i närheten', evChargingNo: 'Ingen EV-laddning',
evChargingAny: 'Alla', evChargingAny: 'Alla',
evChargingExplain: 'Finns det EV-laddning på plats eller i närheten?', evChargingExplain: 'Finns det EV-laddning på plats?',
footerCookieNotice: footerCookieNotice:
'Vi använder endast nödvändiga cookies för inloggning och säkerhet. Genom att använda sajten godkänner du cookies; om du inte gör det, använd inte webbplatsen.', 'Vi använder endast nödvändiga cookies för inloggning och säkerhet. Genom att använda sajten godkänner du cookies; om du inte gör det, använd inte webbplatsen.',
}; };

View file

@ -0,0 +1,3 @@
-- Add wheelchair accessibility amenity
ALTER TABLE "Listing" ADD COLUMN IF NOT EXISTS "wheelchairAccessible" BOOLEAN NOT NULL DEFAULT false;

View file

@ -90,6 +90,7 @@ model Listing {
hasFreeParking Boolean @default(false) hasFreeParking Boolean @default(false)
hasSkiPass Boolean @default(false) hasSkiPass Boolean @default(false)
evChargingAvailable Boolean @default(false) evChargingAvailable Boolean @default(false)
wheelchairAccessible Boolean @default(false)
calendarUrls String[] @db.Text @default([]) calendarUrls String[] @db.Text @default([])
priceWeekdayEuros Int? priceWeekdayEuros Int?
priceWeekendEuros Int? priceWeekendEuros Int?

View file

@ -517,7 +517,7 @@ async function main() {
hasFreeParking: true, hasFreeParking: true,
petsAllowed: false, petsAllowed: false,
byTheLake: false, byTheLake: false,
evCharging: 'FREE', evChargingAvailable: true,
priceWeekdayEuros: 105, priceWeekdayEuros: 105,
priceWeekendEuros: 120, priceWeekendEuros: 120,
cover: { cover: {
@ -593,6 +593,7 @@ async function main() {
hasMicrowave: item.hasMicrowave ?? randBool(0.7), hasMicrowave: item.hasMicrowave ?? randBool(0.7),
hasFreeParking: item.hasFreeParking ?? randBool(0.6), hasFreeParking: item.hasFreeParking ?? randBool(0.6),
evChargingAvailable: item.evChargingAvailable ?? randBool(0.4), evChargingAvailable: item.evChargingAvailable ?? randBool(0.4),
wheelchairAccessible: item.wheelchairAccessible ?? randBool(0.25),
hasSkiPass: item.hasSkiPass ?? randBool(0.2), hasSkiPass: item.hasSkiPass ?? randBool(0.2),
}; };
}); });
@ -631,7 +632,8 @@ async function main() {
hasFreeParking: item.hasFreeParking ?? false, hasFreeParking: item.hasFreeParking ?? false,
petsAllowed: item.petsAllowed, petsAllowed: item.petsAllowed,
byTheLake: item.byTheLake, byTheLake: item.byTheLake,
evCharging: item.evCharging, evChargingAvailable: item.evChargingAvailable ?? false,
wheelchairAccessible: item.wheelchairAccessible ?? false,
priceWeekdayEuros: item.priceWeekdayEuros, priceWeekdayEuros: item.priceWeekdayEuros,
priceWeekendEuros: item.priceWeekendEuros, priceWeekendEuros: item.priceWeekendEuros,
contactName: 'Sample Host', contactName: 'Sample Host',
@ -681,7 +683,8 @@ async function main() {
hasFreeParking: item.hasFreeParking ?? false, hasFreeParking: item.hasFreeParking ?? false,
petsAllowed: item.petsAllowed, petsAllowed: item.petsAllowed,
byTheLake: item.byTheLake, byTheLake: item.byTheLake,
evCharging: item.evCharging, evChargingAvailable: item.evChargingAvailable ?? false,
wheelchairAccessible: item.wheelchairAccessible ?? false,
priceWeekdayEuros: item.priceWeekdayEuros, priceWeekdayEuros: item.priceWeekdayEuros,
priceWeekendEuros: item.priceWeekendEuros, priceWeekendEuros: item.priceWeekendEuros,
contactName: 'Sample Host', contactName: 'Sample Host',