feature/amenities-ev-accessibility #8

Merged
thalla merged 6 commits from feature/amenities-ev-accessibility into master 2025-12-17 13:50:34 +02:00
2 changed files with 72 additions and 45 deletions
Showing only changes of commit c93cabb8ad - Show all commits

View file

@ -65,6 +65,7 @@
- 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.
- 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.

View file

@ -255,6 +255,23 @@ export default function EditListingPage({ params }: { params: { id: string } })
}
}
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('amenityEvAvailable'), icon: '⚡', checked: evChargingAvailable, toggle: setEvChargingAvailable },
];
async function checkSlugAvailability() {
const value = slug.trim().toLowerCase();
if (!value) {
@ -644,15 +661,38 @@ export default function EditListingPage({ params }: { params: { id: string } })
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
<label>
{t('priceWeekdayLabel')}
<input type="number" value={priceWeekday} onChange={(e) => setPriceWeekday(e.target.value === '' ? '' : Number(e.target.value))} />
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHint')}</div>
<input
type="number"
value={priceWeekday}
onChange={(e) => setPriceWeekday(e.target.value === '' ? '' : Number(e.target.value))}
min={0}
step="10"
placeholder="120"
/>
</label>
<label>
{t('priceWeekendLabel')}
<input type="number" value={priceWeekend} onChange={(e) => setPriceWeekend(e.target.value === '' ? '' : Number(e.target.value))} />
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHint')}</div>
<input
type="number"
value={priceWeekend}
onChange={(e) => setPriceWeekend(e.target.value === '' ? '' : Number(e.target.value))}
min={0}
step="10"
placeholder="140"
/>
</label>
</div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHintHelp')}</div>
<label style={{ gridColumn: '1 / -1' }}>
{t('calendarUrlsLabel')}
<textarea
value={calendarUrls}
onChange={(e) => setCalendarUrls(e.target.value)}
rows={3}
placeholder="https://example.com/calendar.ics"
/>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('calendarUrlsHelp')}</div>
</label>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<label>
{t('latitudeLabel')}
@ -663,28 +703,27 @@ export default function EditListingPage({ params }: { params: { id: string } })
<input type="number" value={longitude} onChange={(e) => setLongitude(e.target.value === '' ? '' : Number(e.target.value))} step="0.000001" />
</label>
</div>
<div className="panel" style={{ display: 'grid', gap: 8, border: '1px solid rgba(148,163,184,0.3)', background: 'rgba(255,255,255,0.02)' }}>
<h3 style={{ margin: 0 }}>{t('amenities')}</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 8 }}>
{[
{ label: t('amenitySauna'), state: hasSauna, set: setHasSauna },
{ label: t('amenityFireplace'), state: hasFireplace, set: setHasFireplace },
{ label: t('amenityWifi'), state: hasWifi, set: setHasWifi },
{ label: t('amenityPets'), state: petsAllowed, set: setPetsAllowed },
{ label: t('amenityLake'), state: byTheLake, set: setByTheLake },
{ label: t('amenityAirConditioning'), state: hasAirConditioning, set: setHasAirConditioning },
{ label: t('amenityKitchen'), state: hasKitchen, set: setHasKitchen },
{ label: t('amenityDishwasher'), state: hasDishwasher, set: setHasDishwasher },
{ label: t('amenityWashingMachine'), state: hasWashingMachine, set: setHasWashingMachine },
{ label: t('amenityBarbecue'), state: hasBarbecue, set: setHasBarbecue },
{ label: t('amenityMicrowave'), state: hasMicrowave, set: setHasMicrowave },
{ label: t('amenityFreeParking'), state: hasFreeParking, set: setHasFreeParking },
{ label: t('amenitySkiPass'), state: hasSkiPass, set: setHasSkiPass },
{ label: t('amenityEvAvailable'), state: evChargingAvailable, set: setEvChargingAvailable },
].map((item) => (
<label key={item.label} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={item.state} onChange={(e) => item.set(e.target.checked)} /> {item.label}
</label>
<div className="panel" style={{ display: 'grid', gap: 10, border: '1px solid rgba(148,163,184,0.3)', background: 'rgba(255,255,255,0.02)' }}>
<h3 style={{ margin: 0 }}>{t('listingAmenities')}</h3>
<div className="amenity-grid">
{amenityOptions.map((option) => (
<button
key={option.key}
type="button"
className={`amenity-option ${option.checked ? 'selected' : ''}`}
aria-pressed={option.checked}
onClick={() => option.toggle(!option.checked)}
>
<div className="amenity-option-meta">
<span aria-hidden className="amenity-emoji">
{option.icon}
</span>
<span className="amenity-name">{option.label}</span>
</div>
<span className="amenity-check" aria-hidden>
{option.checked ? '✓' : ''}
</span>
</button>
))}
</div>
</div>
@ -708,26 +747,13 @@ export default function EditListingPage({ params }: { params: { id: string } })
<div style={{ fontSize: 12, color: '#cbd5e1' }}>
{(img.size / 1024).toFixed(0)} KB · {img.mimeType || 'image/jpeg'}
</div>
<div style={{ fontSize: 12, marginTop: 4 }}>{t('coverChoice', { index: idx + 1 })}</div>
</div>
))}
</div>
) : (
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('imagesHelp', { count: MAX_IMAGES, sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })}</div>
)}
<div style={{ display: 'grid', gap: 8 }}>
<label>
{t('calendarUrlsLabel')}
<textarea
value={calendarUrls}
onChange={(e) => setCalendarUrls(e.target.value)}
rows={4}
placeholder="https://example.com/availability.ics"
style={{ fontFamily: 'monospace' }}
/>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('calendarUrlsHelp')}</div>
</label>
<div style={{ fontSize: 12, marginTop: 4 }}>{t('coverChoice', { index: idx + 1 })}</div>
</div>
))}
</div>
) : (
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('imagesHelp', { count: MAX_IMAGES, sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })}</div>
)}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button className="button secondary" type="button" disabled={loading} onClick={(e) => submitListing(true, e)}>
{loading ? t('saving') : t('saveDraft')}