feature/amenities-ev-accessibility #8
21 changed files with 500 additions and 69 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div>
|
||||
<strong>{l.translations[0]?.title ?? 'Listing'}</strong> — owner: {l.owner.email}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{l.evChargingOnSite ? <span className="badge">{t('amenityEvOnSite')}</span> : null}
|
||||
{l.evChargingAvailable && !l.evChargingOnSite ? <span className="badge">{t('amenityEvNearby')}</span> : null}
|
||||
{l.wheelchairAccessible ? <span className="badge">{t('amenityWheelchairAccessible')}</span> : null}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666' }}>
|
||||
{t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@ const amenityIcons: Record<string, string> = {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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<boolean>(false);
|
||||
const [evChargingOnSite, setEvChargingOnSite] = useState(false);
|
||||
const [wheelchairAccessible, setWheelchairAccessible] = useState(false);
|
||||
const [calendarUrls, setCalendarUrls] = useState('');
|
||||
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
|
||||
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 } })
|
|||
<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 +726,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 +770,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')}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ export default function NewListingPage() {
|
|||
const [hasFreeParking, setHasFreeParking] = useState(false);
|
||||
const [hasSkiPass, setHasSkiPass] = useState(false);
|
||||
const [evChargingAvailable, setEvChargingAvailable] = useState<boolean>(false);
|
||||
const [evChargingOnSite, setEvChargingOnSite] = useState(false);
|
||||
const [wheelchairAccessible, setWheelchairAccessible] = useState(false);
|
||||
const [calendarUrls, setCalendarUrls] = useState('');
|
||||
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
|
||||
const [coverImageIndex, setCoverImageIndex] = useState(1);
|
||||
|
|
@ -123,6 +125,20 @@ export default function NewListingPage() {
|
|||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
|
|
@ -137,7 +153,9 @@ export default function NewListingPage() {
|
|||
{ 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 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
function updateTranslation(locale: Locale, field: keyof LocaleFields, value: string) {
|
||||
|
|
@ -372,7 +390,10 @@ export default function NewListingPage() {
|
|||
hasBarbecue,
|
||||
hasMicrowave,
|
||||
hasFreeParking,
|
||||
hasSkiPass,
|
||||
evChargingAvailable,
|
||||
evChargingOnSite,
|
||||
wheelchairAccessible,
|
||||
coverImageIndex,
|
||||
images: parseImages(),
|
||||
calendarUrls,
|
||||
|
|
@ -412,6 +433,10 @@ export default function NewListingPage() {
|
|||
setHasBarbecue(false);
|
||||
setHasMicrowave(false);
|
||||
setHasFreeParking(false);
|
||||
setHasSkiPass(false);
|
||||
setEvChargingAvailable(false);
|
||||
setEvChargingOnSite(false);
|
||||
setWheelchairAccessible(false);
|
||||
setRegion('');
|
||||
setCity('');
|
||||
setStreetAddress('');
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ type ListingResult = {
|
|||
hasMicrowave: boolean;
|
||||
hasFreeParking: boolean;
|
||||
evChargingAvailable: boolean;
|
||||
evChargingOnSite: boolean;
|
||||
wheelchairAccessible: boolean;
|
||||
hasSkiPass: boolean;
|
||||
maxGuests: number;
|
||||
bedrooms: number;
|
||||
|
|
@ -90,8 +92,10 @@ const amenityIcons: Record<string, string> = {
|
|||
barbecue: '🍖',
|
||||
microwave: '🍲',
|
||||
parking: '🅿️',
|
||||
accessible: '♿',
|
||||
ski: '⛷️',
|
||||
ev: '⚡',
|
||||
evOnSite: '🔌',
|
||||
};
|
||||
|
||||
function ListingsMap({
|
||||
|
|
@ -222,8 +226,10 @@ 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: 'accessible', label: t('amenityWheelchairAccessible'), icon: amenityIcons.accessible },
|
||||
{ key: 'skipass', label: t('amenitySkiPass'), icon: amenityIcons.ski },
|
||||
{ key: 'ev', label: t('amenityEvAvailable'), icon: amenityIcons.ev },
|
||||
{ key: 'ev', label: t('amenityEvNearby'), icon: amenityIcons.ev },
|
||||
{ key: 'ev-onsite', label: t('amenityEvOnSite'), icon: amenityIcons.evOnSite },
|
||||
];
|
||||
|
||||
async function fetchListings() {
|
||||
|
|
@ -476,7 +482,9 @@ export default function ListingsIndexPage() {
|
|||
{startDate && endDate && l.availableForDates ? (
|
||||
<span className="badge">{t('availableForDates')}</span>
|
||||
) : null}
|
||||
{l.evChargingAvailable ? <span className="badge">{t('amenityEvAvailable')}</span> : null}
|
||||
{l.evChargingOnSite ? <span className="badge">{t('amenityEvOnSite')}</span> : null}
|
||||
{l.evChargingAvailable && !l.evChargingOnSite ? <span className="badge">{t('amenityEvNearby')}</span> : null}
|
||||
{l.wheelchairAccessible ? <span className="badge">{t('amenityWheelchairAccessible')}</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}
|
||||
|
|
|
|||
56
deploy/install-logging.sh
Normal file
56
deploy/install-logging.sh
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [[ -f scripts/load-secrets.sh ]]; then
|
||||
source scripts/load-secrets.sh
|
||||
fi
|
||||
|
||||
LOGGING_NAMESPACE="${LOGGING_NAMESPACE:-logging}"
|
||||
LOGS_HOST="${LOGS_HOST:-logs.lomavuokraus.fi}"
|
||||
GRAFANA_CLUSTER_ISSUER="${GRAFANA_CLUSTER_ISSUER:-letsencrypt-prod}"
|
||||
: "${GRAFANA_ADMIN_PASSWORD:?Set GRAFANA_ADMIN_PASSWORD to provision Grafana.}"
|
||||
|
||||
HELM_BIN="${HELM_BIN:-$(command -v helm || true)}"
|
||||
|
||||
ensure_helm() {
|
||||
if [[ -n "$HELM_BIN" && -x "$HELM_BIN" ]]; then
|
||||
return
|
||||
fi
|
||||
echo "Helm not found, downloading to a temp dir..."
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
curl -fsSL https://get.helm.sh/helm-v3.16.1-linux-amd64.tar.gz | tar -xz -C "$TMP_DIR"
|
||||
HELM_BIN="$TMP_DIR/linux-amd64/helm"
|
||||
}
|
||||
|
||||
ensure_helm
|
||||
|
||||
echo "Using helm at: $HELM_BIN"
|
||||
|
||||
$HELM_BIN repo add grafana https://grafana.github.io/helm-charts
|
||||
$HELM_BIN repo update
|
||||
|
||||
export LOGS_HOST GRAFANA_CLUSTER_ISSUER GRAFANA_ADMIN_PASSWORD
|
||||
|
||||
LOKI_TMP=$(mktemp)
|
||||
PROMTAIL_TMP=$(mktemp)
|
||||
GRAFANA_TMP=$(mktemp)
|
||||
|
||||
cat k8s/logging/loki-values.yaml >"$LOKI_TMP"
|
||||
cat k8s/logging/promtail-values.yaml >"$PROMTAIL_TMP"
|
||||
envsubst < k8s/logging/grafana-values.yaml >"$GRAFANA_TMP"
|
||||
|
||||
echo "Installing/Upgrading Loki..."
|
||||
$HELM_BIN upgrade --install loki grafana/loki -n "$LOGGING_NAMESPACE" -f "$LOKI_TMP" --create-namespace
|
||||
|
||||
echo "Installing/Upgrading Promtail..."
|
||||
$HELM_BIN upgrade --install promtail grafana/promtail -n "$LOGGING_NAMESPACE" -f "$PROMTAIL_TMP"
|
||||
|
||||
echo "Installing/Upgrading Grafana..."
|
||||
$HELM_BIN upgrade --install grafana grafana/grafana -n "$LOGGING_NAMESPACE" -f "$GRAFANA_TMP"
|
||||
|
||||
echo "Resources in $LOGGING_NAMESPACE:"
|
||||
kubectl get pods,svc,ingress -n "$LOGGING_NAMESPACE"
|
||||
|
||||
echo "Done. Grafana ingress host: https://${LOGS_HOST}"
|
||||
24
deploy/update-logs-dns.sh
Normal file
24
deploy/update-logs-dns.sh
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
AUTH_FILE="creds/joker_com_dyndns_creds.txt"
|
||||
if [[ -f "$AUTH_FILE" ]]; then
|
||||
JOKER_AUTH="$(cat "$AUTH_FILE")"
|
||||
elif [[ -n "${JOKER_DYNDNS_USERNAME:-}" && -n "${JOKER_DYNDNS_PASSWORD:-}" ]]; then
|
||||
JOKER_AUTH="${JOKER_DYNDNS_USERNAME}:${JOKER_DYNDNS_PASSWORD}"
|
||||
else
|
||||
echo "Joker DYNDNS credentials missing (file $AUTH_FILE or env JOKER_DYNDNS_USERNAME/PASSWORD)" >&2
|
||||
exit 1
|
||||
fi
|
||||
TARGET_IP="${TARGET_IP:-157.180.66.64}"
|
||||
LOGS_HOST="${LOGS_HOST:-logs.lomavuokraus.fi}"
|
||||
|
||||
echo "Updating $LOGS_HOST -> $TARGET_IP"
|
||||
resp="$(curl -sS -u "$JOKER_AUTH" "https://svc.joker.com/nic/update?hostname=${LOGS_HOST}&myip=${TARGET_IP}")"
|
||||
echo "$resp"
|
||||
if [[ "$resp" != good* && "$resp" != nochg* ]]; then
|
||||
echo "DNS update failed for $LOGS_HOST (response: $resp)" >&2
|
||||
exit 1
|
||||
fi
|
||||
52
docs/logging.md
Normal file
52
docs/logging.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Centralized logging (Loki + Promtail + Grafana)
|
||||
|
||||
We ship a lightweight logging stack into the cluster so API/UI logs are searchable.
|
||||
|
||||
- **Loki** (single-binary) stores logs with 14d retention by default, on a PVC.
|
||||
- **Promtail** DaemonSet tails container logs and ships them to Loki with `namespace`, `pod`, and `app` labels.
|
||||
- **Grafana** provides the UI with a pre-wired Loki data source and TLS ingress.
|
||||
|
||||
## Install / upgrade
|
||||
|
||||
Prereqs:
|
||||
- `kubectl`/`helm` access to the cluster (the script downloads Helm if missing).
|
||||
- Environment: `GRAFANA_ADMIN_PASSWORD` (required), optional `LOGS_HOST` (default `logs.lomavuokraus.fi`), `GRAFANA_CLUSTER_ISSUER` (default `letsencrypt-prod`), `LOGGING_NAMESPACE` (default `logging`).
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
LOGS_HOST=logs.lomavuokraus.fi \
|
||||
GRAFANA_ADMIN_PASSWORD='change-me' \
|
||||
GRAFANA_CLUSTER_ISSUER=letsencrypt-prod \
|
||||
bash deploy/install-logging.sh
|
||||
```
|
||||
|
||||
The script:
|
||||
1. Ensures Helm is available.
|
||||
2. Installs/updates Loki, Promtail, and Grafana in the logging namespace.
|
||||
3. Creates a Grafana ingress with TLS via the chosen ClusterIssuer.
|
||||
|
||||
## Access
|
||||
|
||||
- Grafana: `https://<LOGS_HOST>` (admin user `admin`, password from `GRAFANA_ADMIN_PASSWORD`).
|
||||
- Loki endpoint (internal): `http://loki.logging.svc.cluster.local:3100`.
|
||||
|
||||
## Querying
|
||||
|
||||
Example LogQL in Grafana Explore:
|
||||
|
||||
```
|
||||
{namespace="lomavuokraus-test", app="lomavuokraus-web"}
|
||||
```
|
||||
|
||||
Filter by pod:
|
||||
|
||||
```
|
||||
{namespace="lomavuokraus-test", app="lomavuokraus-web", pod=~".*"} |= "ERROR"
|
||||
```
|
||||
|
||||
## Tuning
|
||||
|
||||
- Retention: `k8s/logging/loki-values.yaml` (`limits_config.retention_period`).
|
||||
- PVC sizes: adjust `persistence.size` in `k8s/logging/loki-values.yaml` and `k8s/logging/grafana-values.yaml`.
|
||||
- Ingress issuer/host: override via environment when running `deploy/install-logging.sh`.
|
||||
45
k8s/logging/grafana-values.yaml
Normal file
45
k8s/logging/grafana-values.yaml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
adminUser: admin
|
||||
adminPassword: "${GRAFANA_ADMIN_PASSWORD}"
|
||||
|
||||
initChownData:
|
||||
enabled: false
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 10Gi
|
||||
storageClassName: ""
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
ingressClassName: traefik
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "${GRAFANA_CLUSTER_ISSUER}"
|
||||
hosts:
|
||||
- "${LOGS_HOST}"
|
||||
tls:
|
||||
- hosts:
|
||||
- "${LOGS_HOST}"
|
||||
secretName: grafana-logs-tls
|
||||
|
||||
datasources:
|
||||
datasources.yaml:
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://loki.logging.svc.cluster.local:3100
|
||||
isDefault: true
|
||||
jsonData:
|
||||
timeout: 60
|
||||
maxLines: 5000
|
||||
|
||||
grafana.ini:
|
||||
server:
|
||||
root_url: https://${LOGS_HOST}
|
||||
analytics:
|
||||
reporting_enabled: false
|
||||
check_for_updates: false
|
||||
61
k8s/logging/loki-values.yaml
Normal file
61
k8s/logging/loki-values.yaml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
deploymentMode: SingleBinary
|
||||
|
||||
singleBinary:
|
||||
replicas: 1
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 20Gi
|
||||
storageClass: ""
|
||||
|
||||
# Disable simple scalable targets to satisfy chart validation
|
||||
write:
|
||||
replicas: 0
|
||||
read:
|
||||
replicas: 0
|
||||
backend:
|
||||
replicas: 0
|
||||
|
||||
loki:
|
||||
auth_enabled: false
|
||||
commonConfig:
|
||||
replication_factor: 1
|
||||
storage:
|
||||
type: filesystem
|
||||
filesystem:
|
||||
chunks_directory: /var/loki/chunks
|
||||
rules_directory: /var/loki/rules
|
||||
limits_config:
|
||||
retention_period: 0s
|
||||
compactor:
|
||||
retention_enabled: false
|
||||
retention_delete_delay: 2h
|
||||
retention_delete_worker_count: 1
|
||||
ruler:
|
||||
storage:
|
||||
type: local
|
||||
local:
|
||||
directory: /rules
|
||||
schemaConfig:
|
||||
configs:
|
||||
- from: "2024-01-01"
|
||||
store: tsdb
|
||||
object_store: filesystem
|
||||
schema: v13
|
||||
index:
|
||||
prefix: loki_index_
|
||||
period: 24h
|
||||
storage_config:
|
||||
filesystem:
|
||||
directory: /var/loki/chunks
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
|
||||
chunksCache:
|
||||
enabled: false
|
||||
|
||||
resultsCache:
|
||||
enabled: false
|
||||
25
k8s/logging/promtail-values.yaml
Normal file
25
k8s/logging/promtail-values.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
config:
|
||||
lokiAddress: http://loki.logging.svc.cluster.local:3100/loki/api/v1/push
|
||||
snippets:
|
||||
extraRelabelConfigs:
|
||||
# Skip Promtail's own logs to reduce noise
|
||||
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
|
||||
action: drop
|
||||
regex: promtail
|
||||
# Drop logs from the logging stack itself
|
||||
- source_labels: [__meta_kubernetes_namespace]
|
||||
action: drop
|
||||
regex: logging
|
||||
# Promote common labels for easier querying
|
||||
- action: replace
|
||||
replacement: $1
|
||||
source_labels: [__meta_kubernetes_namespace]
|
||||
target_label: namespace
|
||||
- action: replace
|
||||
replacement: $1
|
||||
source_labels: [__meta_kubernetes_pod_name]
|
||||
target_label: pod
|
||||
- action: replace
|
||||
replacement: $1
|
||||
source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
|
||||
target_label: app
|
||||
|
|
@ -12,3 +12,8 @@ apiVersion: v1
|
|||
kind: Namespace
|
||||
metadata:
|
||||
name: lomavuokraus-test
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: logging
|
||||
|
|
|
|||
33
lib/i18n.ts
33
lib/i18n.ts
|
|
@ -252,12 +252,15 @@ const baseMessages = {
|
|||
amenityMicrowave: 'Microwave',
|
||||
amenityFreeParking: 'Free parking',
|
||||
amenityEvAvailable: 'EV charging nearby',
|
||||
amenityEvNearby: 'EV charging nearby',
|
||||
amenityEvOnSite: 'EV charging on-site',
|
||||
amenityWheelchairAccessible: 'Wheelchair accessible',
|
||||
amenitySkiPass: 'Ski pass included',
|
||||
evChargingLabel: 'EV charging nearby',
|
||||
evChargingYes: 'Charging nearby',
|
||||
evChargingNo: 'No charging nearby',
|
||||
evChargingLabel: 'EV charging',
|
||||
evChargingYes: 'EV charging available',
|
||||
evChargingNo: 'No EV charging',
|
||||
evChargingAny: 'Any',
|
||||
evChargingExplain: 'Is there EV charging available on-site or nearby?',
|
||||
evChargingExplain: 'Is there EV charging available at the property?',
|
||||
capacityGuests: '{count} guests',
|
||||
capacityBedrooms: '{count} bedrooms',
|
||||
capacityBeds: '{count} beds',
|
||||
|
|
@ -568,12 +571,15 @@ const baseMessages = {
|
|||
amenityMicrowave: 'Mikroaaltouuni',
|
||||
amenityFreeParking: 'Maksuton pysäköinti',
|
||||
amenityEvAvailable: 'Sähköauton lataus lähellä',
|
||||
amenityEvNearby: 'Sähköauton lataus lähellä',
|
||||
amenityEvOnSite: 'Sähköauton lataus kohteessa',
|
||||
amenityWheelchairAccessible: 'Esteetön / pyörätuolilla',
|
||||
amenitySkiPass: 'Hissilippu sisältyy',
|
||||
evChargingLabel: 'Sähköauton lataus lähellä',
|
||||
evChargingYes: 'Latausta lähellä',
|
||||
evChargingNo: 'Ei latausta lähellä',
|
||||
evChargingLabel: 'Sähköauton lataus',
|
||||
evChargingYes: 'Latausmahdollisuus',
|
||||
evChargingNo: 'Ei latausta',
|
||||
evChargingAny: 'Kaikki',
|
||||
evChargingExplain: 'Onko kohteessa tai lähistöllä sähköauton latausmahdollisuus?',
|
||||
evChargingExplain: 'Onko kohteessa sähköauton latausmahdollisuus?',
|
||||
capacityGuests: '{count} vierasta',
|
||||
capacityBedrooms: '{count} makuuhuonetta',
|
||||
capacityBeds: '{count} vuodetta',
|
||||
|
|
@ -719,14 +725,17 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
|
|||
listingPrices: 'Priser',
|
||||
capacityUnknown: 'Kapacitet ej angiven',
|
||||
amenityEvAvailable: 'EV-laddning i närheten',
|
||||
amenityEvNearby: 'EV-laddning i närheten',
|
||||
amenityEvOnSite: 'EV-laddning på plats',
|
||||
amenityWheelchairAccessible: 'Rullstolsanpassat',
|
||||
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',
|
||||
evChargingLabel: 'EV-laddning',
|
||||
evChargingYes: 'EV-laddning finns',
|
||||
evChargingNo: 'Ingen EV-laddning',
|
||||
evChargingAny: 'Alla',
|
||||
evChargingExplain: 'Finns det EV-laddning på plats eller i närheten?',
|
||||
evChargingExplain: 'Finns det EV-laddning på plats?',
|
||||
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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
-- Add wheelchair accessibility amenity
|
||||
ALTER TABLE "Listing" ADD COLUMN IF NOT EXISTS "wheelchairAccessible" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-- Add on-site EV charging amenity (in addition to nearby charging)
|
||||
ALTER TABLE "Listing" ADD COLUMN IF NOT EXISTS "evChargingOnSite" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
|
|
@ -90,6 +90,8 @@ model Listing {
|
|||
hasFreeParking Boolean @default(false)
|
||||
hasSkiPass Boolean @default(false)
|
||||
evChargingAvailable Boolean @default(false)
|
||||
evChargingOnSite Boolean @default(false)
|
||||
wheelchairAccessible Boolean @default(false)
|
||||
calendarUrls String[] @db.Text @default([])
|
||||
priceWeekdayEuros Int?
|
||||
priceWeekendEuros Int?
|
||||
|
|
|
|||
|
|
@ -301,6 +301,7 @@ async function main() {
|
|||
petsAllowed: false,
|
||||
byTheLake: true,
|
||||
evChargingAvailable: true,
|
||||
evChargingOnSite: true,
|
||||
hasSkiPass: true,
|
||||
priceWeekdayEuros: 189,
|
||||
priceWeekendEuros: 215,
|
||||
|
|
@ -389,6 +390,7 @@ async function main() {
|
|||
petsAllowed: true,
|
||||
byTheLake: true,
|
||||
evChargingAvailable: true,
|
||||
evChargingOnSite: true,
|
||||
hasSkiPass: true,
|
||||
priceWeekdayEuros: 245,
|
||||
priceWeekendEuros: 275,
|
||||
|
|
@ -432,6 +434,7 @@ async function main() {
|
|||
petsAllowed: false,
|
||||
byTheLake: true,
|
||||
evChargingAvailable: true,
|
||||
evChargingOnSite: true,
|
||||
hasSkiPass: true,
|
||||
priceWeekdayEuros: 129,
|
||||
priceWeekendEuros: 149,
|
||||
|
|
@ -517,7 +520,8 @@ async function main() {
|
|||
hasFreeParking: true,
|
||||
petsAllowed: false,
|
||||
byTheLake: false,
|
||||
evCharging: 'FREE',
|
||||
evChargingAvailable: true,
|
||||
evChargingOnSite: true,
|
||||
priceWeekdayEuros: 105,
|
||||
priceWeekendEuros: 120,
|
||||
cover: {
|
||||
|
|
@ -582,6 +586,8 @@ async function main() {
|
|||
const randBool = (p = 0.5) => Math.random() < p;
|
||||
listings = listings.map((item) => {
|
||||
const weekdayPrice = item.priceWeekdayEuros ?? Math.round(Math.random() * (220 - 90) + 90);
|
||||
const evChargingOnSite = item.evChargingOnSite ?? randBool(0.15);
|
||||
const evChargingAvailable = item.evChargingAvailable ?? evChargingOnSite ?? randBool(0.4);
|
||||
return {
|
||||
...item,
|
||||
priceWeekdayEuros: weekdayPrice,
|
||||
|
|
@ -592,7 +598,9 @@ 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),
|
||||
evChargingOnSite,
|
||||
evChargingAvailable,
|
||||
wheelchairAccessible: item.wheelchairAccessible ?? randBool(0.25),
|
||||
hasSkiPass: item.hasSkiPass ?? randBool(0.2),
|
||||
};
|
||||
});
|
||||
|
|
@ -631,7 +639,9 @@ async function main() {
|
|||
hasFreeParking: item.hasFreeParking ?? false,
|
||||
petsAllowed: item.petsAllowed,
|
||||
byTheLake: item.byTheLake,
|
||||
evCharging: item.evCharging,
|
||||
evChargingAvailable: item.evChargingAvailable ?? item.evChargingOnSite ?? false,
|
||||
evChargingOnSite: item.evChargingOnSite ?? false,
|
||||
wheelchairAccessible: item.wheelchairAccessible ?? false,
|
||||
priceWeekdayEuros: item.priceWeekdayEuros,
|
||||
priceWeekendEuros: item.priceWeekendEuros,
|
||||
contactName: 'Sample Host',
|
||||
|
|
@ -681,7 +691,9 @@ async function main() {
|
|||
hasFreeParking: item.hasFreeParking ?? false,
|
||||
petsAllowed: item.petsAllowed,
|
||||
byTheLake: item.byTheLake,
|
||||
evCharging: item.evCharging,
|
||||
evChargingAvailable: item.evChargingAvailable ?? item.evChargingOnSite ?? false,
|
||||
evChargingOnSite: item.evChargingOnSite ?? false,
|
||||
wheelchairAccessible: item.wheelchairAccessible ?? false,
|
||||
priceWeekdayEuros: item.priceWeekdayEuros,
|
||||
priceWeekendEuros: item.priceWeekendEuros,
|
||||
contactName: 'Sample Host',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue