400 lines
15 KiB
TypeScript
400 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useI18n } from '../../components/I18nProvider';
|
|
import type { Locale } from '../../../lib/i18n';
|
|
|
|
type ImageInput = { data: string; mimeType: string; altText?: string };
|
|
type SelectedImage = {
|
|
name: string;
|
|
size: number;
|
|
mimeType: string;
|
|
dataUrl: string;
|
|
};
|
|
|
|
const MAX_IMAGES = 6;
|
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image
|
|
|
|
export default function NewListingPage() {
|
|
const { t, locale: uiLocale } = useI18n();
|
|
const [slug, setSlug] = useState('');
|
|
const [locale, setLocale] = useState(uiLocale);
|
|
const [title, setTitle] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [teaser, setTeaser] = useState('');
|
|
const [country, setCountry] = useState('Finland');
|
|
const [region, setRegion] = useState('');
|
|
const [city, setCity] = useState('');
|
|
const [streetAddress, setStreetAddress] = useState('');
|
|
const [addressNote, setAddressNote] = useState('');
|
|
const [latitude, setLatitude] = useState<number | ''>('');
|
|
const [longitude, setLongitude] = useState<number | ''>('');
|
|
const [contactName, setContactName] = useState('');
|
|
const [contactEmail, setContactEmail] = useState('');
|
|
const [maxGuests, setMaxGuests] = useState(4);
|
|
const [bedrooms, setBedrooms] = useState(2);
|
|
const [beds, setBeds] = useState(3);
|
|
const [bathrooms, setBathrooms] = useState(1);
|
|
const [price, setPrice] = useState<number | ''>('');
|
|
const [hasSauna, setHasSauna] = useState(true);
|
|
const [hasFireplace, setHasFireplace] = useState(true);
|
|
const [hasWifi, setHasWifi] = useState(true);
|
|
const [petsAllowed, setPetsAllowed] = useState(false);
|
|
const [byTheLake, setByTheLake] = useState(false);
|
|
const [hasAirConditioning, setHasAirConditioning] = useState(false);
|
|
const [evCharging, setEvCharging] = useState<'NONE' | 'FREE' | 'PAID'>('NONE');
|
|
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
|
|
const [coverImageIndex, setCoverImageIndex] = useState(1);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [isAuthed, setIsAuthed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setLocale(uiLocale);
|
|
}, [uiLocale]);
|
|
|
|
useEffect(() => {
|
|
// simple check if session exists
|
|
fetch('/api/auth/me', { cache: 'no-store' })
|
|
.then((res) => res.json())
|
|
.then((data) => setIsAuthed(Boolean(data.user)))
|
|
.catch(() => setIsAuthed(false));
|
|
}, []);
|
|
|
|
function readFileAsDataUrl(file: File): Promise<SelectedImage> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
resolve({
|
|
name: file.name,
|
|
size: file.size,
|
|
mimeType: file.type || 'image/jpeg',
|
|
dataUrl: String(reader.result),
|
|
});
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const files = Array.from(e.target.files ?? []);
|
|
setError(null);
|
|
setMessage(null);
|
|
if (!files.length) return;
|
|
|
|
if (files.length > MAX_IMAGES) {
|
|
setError(t('imagesTooMany', { count: MAX_IMAGES }));
|
|
return;
|
|
}
|
|
|
|
const tooLarge = files.find((f) => f.size > MAX_IMAGE_BYTES);
|
|
if (tooLarge) {
|
|
setError(t('imagesTooLarge', { sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsed = await Promise.all(files.map(readFileAsDataUrl));
|
|
setSelectedImages(parsed);
|
|
setCoverImageIndex(1);
|
|
} catch (err) {
|
|
setError(t('imagesReadFailed'));
|
|
}
|
|
}
|
|
|
|
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 },
|
|
];
|
|
|
|
function parseImages(): ImageInput[] {
|
|
return selectedImages.map((img) => ({
|
|
data: img.dataUrl,
|
|
mimeType: img.mimeType,
|
|
altText: img.name.replace(/[-_]/g, ' '),
|
|
}));
|
|
}
|
|
|
|
async function onSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setMessage(null);
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
if (selectedImages.length === 0) {
|
|
setError(t('imagesRequired'));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const res = await fetch('/api/listings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
slug,
|
|
locale,
|
|
title,
|
|
description,
|
|
teaser,
|
|
country,
|
|
region,
|
|
city,
|
|
streetAddress,
|
|
addressNote,
|
|
latitude: latitude === '' ? null : latitude,
|
|
longitude: longitude === '' ? null : longitude,
|
|
contactName,
|
|
contactEmail,
|
|
maxGuests,
|
|
bedrooms,
|
|
beds,
|
|
bathrooms,
|
|
priceHintPerNightEuros: price === '' ? null : Math.round(Number(price)),
|
|
hasSauna,
|
|
hasFireplace,
|
|
hasWifi,
|
|
petsAllowed,
|
|
byTheLake,
|
|
hasAirConditioning,
|
|
evCharging,
|
|
coverImageIndex,
|
|
images: parseImages(),
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setError(data.error || 'Failed to create listing');
|
|
} else {
|
|
setMessage(t('createListingSuccess', { id: data.listing.id, status: data.listing.status }));
|
|
setSlug('');
|
|
setTitle('');
|
|
setDescription('');
|
|
setTeaser('');
|
|
setRegion('');
|
|
setCity('');
|
|
setStreetAddress('');
|
|
setAddressNote('');
|
|
setLatitude('');
|
|
setLongitude('');
|
|
setContactName('');
|
|
setContactEmail('');
|
|
setSelectedImages([]);
|
|
setCoverImageIndex(1);
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to create listing');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
if (!isAuthed) {
|
|
return (
|
|
<main className="panel" style={{ maxWidth: 720, margin: '40px auto' }}>
|
|
<h1>{t('createListingTitle')}</h1>
|
|
<p>{t('loginToCreate')}</p>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main className="panel" style={{ maxWidth: 720, margin: '40px auto' }}>
|
|
<h1>{t('createListingTitle')}</h1>
|
|
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 10 }}>
|
|
<label>
|
|
{t('slugLabel')}
|
|
<input value={slug} onChange={(e) => setSlug(e.target.value)} required />
|
|
</label>
|
|
<label>
|
|
{t('localeInput')}
|
|
<input value={locale} onChange={(e) => setLocale(e.target.value as Locale)} required />
|
|
</label>
|
|
<label>
|
|
{t('titleLabel')}
|
|
<input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
|
</label>
|
|
<label>
|
|
{t('descriptionLabel')}
|
|
<textarea value={description} onChange={(e) => setDescription(e.target.value)} required rows={4} />
|
|
</label>
|
|
<label>
|
|
{t('teaserLabel')}
|
|
<input value={teaser} onChange={(e) => setTeaser(e.target.value)} />
|
|
</label>
|
|
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
|
<label>
|
|
{t('countryLabel')}
|
|
<input value={country} onChange={(e) => setCountry(e.target.value)} required />
|
|
</label>
|
|
<label>
|
|
{t('regionLabel')}
|
|
<input value={region} onChange={(e) => setRegion(e.target.value)} required />
|
|
</label>
|
|
<label>
|
|
{t('cityLabel')}
|
|
<input value={city} onChange={(e) => setCity(e.target.value)} required />
|
|
</label>
|
|
</div>
|
|
<label>
|
|
{t('streetAddressLabel')}
|
|
<input value={streetAddress} onChange={(e) => setStreetAddress(e.target.value)} required />
|
|
</label>
|
|
<label>
|
|
{t('addressNoteLabel')}
|
|
<input value={addressNote} onChange={(e) => setAddressNote(e.target.value)} placeholder={t('addressNotePlaceholder')} />
|
|
</label>
|
|
<label>
|
|
{t('contactNameLabel')}
|
|
<input value={contactName} onChange={(e) => setContactName(e.target.value)} required />
|
|
</label>
|
|
<label>
|
|
{t('contactEmailLabel')}
|
|
<input type="email" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} required />
|
|
</label>
|
|
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
|
|
<label>
|
|
{t('maxGuestsLabel')}
|
|
<select value={maxGuests} onChange={(e) => setMaxGuests(Number(e.target.value))}>
|
|
{[2, 4, 6, 8, 10, 12, 14, 16].map((n) => (
|
|
<option key={n} value={n}>
|
|
{n}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
{t('bedroomsLabel')}
|
|
<select value={bedrooms} onChange={(e) => setBedrooms(Number(e.target.value))}>
|
|
{[0, 1, 2, 3, 4, 5, 6].map((n) => (
|
|
<option key={n} value={n}>
|
|
{n}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
{t('bedsLabel')}
|
|
<select value={beds} onChange={(e) => setBeds(Number(e.target.value))}>
|
|
{[1, 2, 3, 4, 5, 6, 8, 10].map((n) => (
|
|
<option key={n} value={n}>
|
|
{n}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
{t('bathroomsLabel')}
|
|
<select value={bathrooms} onChange={(e) => setBathrooms(Number(e.target.value))}>
|
|
{[1, 2, 3, 4].map((n) => (
|
|
<option key={n} value={n}>
|
|
{n}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
{t('priceHintLabel')}
|
|
<input
|
|
type="number"
|
|
value={price}
|
|
onChange={(e) => setPrice(e.target.value === '' ? '' : Number(e.target.value))}
|
|
min={0}
|
|
step="10"
|
|
placeholder="e.g. 120"
|
|
/>
|
|
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHintHelp')}</div>
|
|
</label>
|
|
</div>
|
|
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
|
|
<label>
|
|
{t('latitudeLabel')}
|
|
<input type="number" value={latitude} onChange={(e) => setLatitude(e.target.value === '' ? '' : Number(e.target.value))} step="0.000001" />
|
|
</label>
|
|
<label>
|
|
{t('longitudeLabel')}
|
|
<input type="number" value={longitude} onChange={(e) => setLongitude(e.target.value === '' ? '' : Number(e.target.value))} step="0.000001" />
|
|
</label>
|
|
</div>
|
|
<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 className="amenity-ev">
|
|
<div className="amenity-ev-label">{t('evChargingLabel')}</div>
|
|
<div className="ev-toggle-group">
|
|
{[
|
|
{ value: 'NONE', label: t('evChargingNone'), icon: '🚗' },
|
|
{ value: 'FREE', label: t('evChargingFree'), icon: '⚡' },
|
|
{ value: 'PAID', label: t('evChargingPaid'), icon: '💳' },
|
|
].map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
className={`ev-toggle ${evCharging === opt.value ? 'active' : ''}`}
|
|
onClick={() => setEvCharging(opt.value as typeof evCharging)}
|
|
>
|
|
<span aria-hidden className="amenity-emoji">
|
|
{opt.icon}
|
|
</span>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
|
|
<label>
|
|
{t('imagesLabel')}
|
|
<input type="file" accept="image/*" multiple onChange={handleFileChange} />
|
|
<div style={{ color: '#cbd5e1', fontSize: 12, marginTop: 4 }}>{t('imagesHelp', { count: MAX_IMAGES, sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })}</div>
|
|
</label>
|
|
<label>
|
|
{t('coverImageLabel')}
|
|
<input type="number" min={1} max={selectedImages.length || 1} value={coverImageIndex} onChange={(e) => setCoverImageIndex(Number(e.target.value))} />
|
|
<div style={{ color: '#cbd5e1', fontSize: 12, marginTop: 4 }}>{t('coverImageHelp')}</div>
|
|
</label>
|
|
</div>
|
|
{selectedImages.length > 0 ? (
|
|
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
|
{selectedImages.map((img, idx) => (
|
|
<div key={img.name + idx} style={{ border: '1px solid rgba(148,163,184,0.3)', padding: 8, borderRadius: 8 }}>
|
|
<div style={{ fontWeight: 600 }}>{img.name}</div>
|
|
<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>
|
|
) : null}
|
|
<button className="button" type="submit" disabled={loading}>
|
|
{loading ? t('submittingListing') : t('submitListing')}
|
|
</button>
|
|
</form>
|
|
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null}
|
|
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
|
|
</main>
|
|
);
|
|
}
|