feat: add profile phone and seed more listings

This commit is contained in:
Tero Halla-aho 2025-11-24 18:21:02 +02:00
parent 66650d63ac
commit f8162ecba4
8 changed files with 410 additions and 6 deletions

1
.gitignore vendored
View file

@ -35,3 +35,4 @@ tsconfig.tsbuildinfo
openai.key
docs.tgz
CACHEDIR.TAG
docs/drawio/.cache/

View file

@ -7,7 +7,7 @@ export async function GET(req: Request) {
const session = await requireAuth(req);
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: { id: true, email: true, role: true, status: true, emailVerifiedAt: true, approvedAt: true, name: true },
select: { id: true, email: true, role: true, status: true, emailVerifiedAt: true, approvedAt: true, name: true, phone: true },
});
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ user });

View file

@ -9,14 +9,16 @@ export async function PATCH(req: Request) {
const body = await req.json();
const name = body.name !== undefined && body.name !== null ? String(body.name).trim() : undefined;
const phone = body.phone !== undefined && body.phone !== null ? String(body.phone).trim() : undefined;
const password = body.password ? String(body.password) : undefined;
if (name === undefined && !password) {
if (name === undefined && phone === undefined && !password) {
return NextResponse.json({ error: 'No updates provided' }, { status: 400 });
}
const data: any = {};
if (name !== undefined) data.name = name || null;
if (phone !== undefined) data.phone = phone || null;
if (password) {
if (password.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
@ -27,7 +29,7 @@ export async function PATCH(req: Request) {
const user = await prisma.user.update({
where: { id: session.userId },
data,
select: { id: true, email: true, name: true, role: true, status: true, emailVerifiedAt: true, approvedAt: true },
select: { id: true, email: true, name: true, phone: true, role: true, status: true, emailVerifiedAt: true, approvedAt: true },
});
return NextResponse.json({ ok: true, user });

View file

@ -3,13 +3,14 @@
import { useEffect, useState } from 'react';
import { useI18n } from '../components/I18nProvider';
type User = { id: string; email: string; role: string; status: string; emailVerifiedAt: string | null; approvedAt: string | null; name: string | null };
type User = { id: string; email: string; role: string; status: string; emailVerifiedAt: string | null; approvedAt: string | null; name: string | null; phone: string | null };
export default function ProfilePage() {
const { t } = useI18n();
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
@ -21,6 +22,7 @@ export default function ProfilePage() {
if (data.user) {
setUser(data.user);
setName(data.user.name ?? '');
setPhone(data.user.phone ?? '');
} else setError(t('notLoggedIn'));
})
.catch(() => setError(t('notLoggedIn')));
@ -35,7 +37,7 @@ export default function ProfilePage() {
const res = await fetch('/api/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, password: password || undefined }),
body: JSON.stringify({ name, phone, password: password || undefined }),
});
const data = await res.json();
if (!res.ok) {
@ -65,6 +67,9 @@ export default function ProfilePage() {
<li>
<strong>{t('profileName')}:</strong> {user.name ?? '—'}
</li>
<li>
<strong>{t('profilePhone')}:</strong> {user.phone ?? '—'}
</li>
<li>
<strong>{t('profileRole')}:</strong> {user.role}
</li>
@ -91,6 +96,10 @@ export default function ProfilePage() {
{t('profileName')}
<input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label>
{t('profilePhone')}
<input value={phone} onChange={(e) => setPhone(e.target.value)} />
</label>
<label>
{t('passwordLabel')} ({t('passwordHint')})
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} />

View file

@ -79,6 +79,7 @@ const allMessages = {
notLoggedIn: 'Not logged in',
profileEmail: 'Email',
profileName: 'Name',
profilePhone: 'Phone',
profileRole: 'Role',
profileStatus: 'Status',
profileEmailVerified: 'Email verified',
@ -246,6 +247,7 @@ const allMessages = {
notLoggedIn: 'Et ole kirjautunut',
profileEmail: 'Sähköposti',
profileName: 'Nimi',
profilePhone: 'Puhelin',
profileRole: 'Rooli',
profileStatus: 'Tila',
profileEmailVerified: 'Sähköposti vahvistettu',

View file

@ -0,0 +1,2 @@
-- Add phone number for user profiles
ALTER TABLE "User" ADD COLUMN "phone" TEXT;

View file

@ -40,6 +40,7 @@ model User {
email String @unique
passwordHash String @default("")
name String?
phone String?
role Role @default(USER)
status UserStatus @default(PENDING)
emailVerifiedAt DateTime?

View file

@ -63,10 +63,19 @@ async function main() {
const owner = await prisma.user.upsert({
where: { email: SAMPLE_EMAIL },
update: { name: 'Sample Host', role: 'USER', passwordHash: sampleHostHash, status: UserStatus.ACTIVE, emailVerifiedAt: new Date(), approvedAt: new Date() },
update: {
name: 'Sample Host',
phone: '+358401234567',
role: 'USER',
passwordHash: sampleHostHash,
status: UserStatus.ACTIVE,
emailVerifiedAt: new Date(),
approvedAt: new Date(),
},
create: {
email: SAMPLE_EMAIL,
name: 'Sample Host',
phone: '+358401234567',
role: 'USER',
passwordHash: sampleHostHash,
status: UserStatus.ACTIVE,
@ -153,6 +162,384 @@ async function main() {
],
});
const extraListings = [
{
slug: 'helsinki-design-loft',
city: 'Helsinki',
region: 'Uusimaa',
country: 'Finland',
streetAddress: 'Katajanokanranta 4',
addressNote: 'Buzz 12B, elevator to 5th floor',
latitude: 60.1675,
longitude: 24.9529,
maxGuests: 4,
bedrooms: 1,
beds: 2,
bathrooms: 1,
hasSauna: false,
hasFireplace: false,
hasWifi: true,
hasAirConditioning: true,
petsAllowed: false,
byTheLake: false,
evCharging: 'PAID',
priceHintPerNightCents: 16500,
cover: {
url: 'https://images.unsplash.com/photo-1505693415763-3bd1620f58c3?auto=format&fit=crop&w=1600&q=80',
altText: 'Modern loft living room',
},
images: [
{ url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Balcony view' },
{ url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Cozy bedroom' },
],
titleEn: 'Helsinki design loft with AC',
teaserEn: 'Top-floor loft, AC, fast Wi-Fi, tram at the door.',
descEn: 'Bright design loft near the harbor. Air conditioning, fiber Wi-Fi, and views over Katajanokka. Perfect city break base.',
titleFi: 'Helsingin design-lofti ilmastoinnilla',
teaserFi: 'Ylimmän kerroksen loft, ilmastointi ja nopea netti.',
descFi: 'Valoisa loft Katajanokalla. Ilmastointi, kuitunetti ja näkymä merelle. Täydellinen kaupunkiloma.',
},
{
slug: 'turku-riverside-apartment',
city: 'Turku',
region: 'Varsinais-Suomi',
country: 'Finland',
streetAddress: 'Läntinen Rantakatu 10',
addressNote: 'Self check-in lockbox',
latitude: 60.4518,
longitude: 22.2666,
maxGuests: 3,
bedrooms: 1,
beds: 2,
bathrooms: 1,
hasSauna: false,
hasFireplace: false,
hasWifi: true,
hasAirConditioning: false,
petsAllowed: true,
byTheLake: false,
evCharging: 'NONE',
priceHintPerNightCents: 11000,
cover: {
url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80',
altText: 'Apartment living room',
},
images: [
{ url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80', altText: 'Kitchen area' },
],
titleEn: 'Riverside apartment in Turku',
teaserEn: 'By the Aura river, pet-friendly, cozy base.',
descEn: 'Compact one-bedroom next to the Aura river. Cafés outside, pet-friendly, fiber internet for workations.',
titleFi: 'Aurajoen varrella, lemmikkiystävällinen',
teaserFi: 'Aurajoen kupeessa, lemmikit sallittu.',
descFi: 'Kompakti yksiö Aurajoen varrella. Kahvilat vieressä, lemmikit sallittu, kuitunetti etätöihin.',
},
{
slug: 'rovaniemi-aurora-cabin',
city: 'Rovaniemi',
region: 'Lapland',
country: 'Finland',
streetAddress: 'Ounasjoenkuja 8',
addressNote: 'Snow tires required in winter',
latitude: 66.5039,
longitude: 25.7294,
maxGuests: 5,
bedrooms: 2,
beds: 4,
bathrooms: 1,
hasSauna: true,
hasFireplace: true,
hasWifi: true,
hasAirConditioning: false,
petsAllowed: false,
byTheLake: true,
evCharging: 'FREE',
priceHintPerNightCents: 18900,
cover: {
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80',
altText: 'Aurora cabin by the river',
},
images: [
{ url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80', altText: 'Fireplace lounge' },
],
titleEn: 'Aurora riverside cabin',
teaserEn: 'Sauna, fireplace, river views, EV charging.',
descEn: 'Timber cabin on the Ounasjoki riverside. Wood sauna, fireplace, glass lounge for auroras, free EV charging.',
titleFi: 'Revontulikämppä joen rannalla',
teaserFi: 'Sauna, takka, jokinäkymä ja ilmainen lataus.',
descFi: 'Hirsimökki Ounasjoen rannalla. Puusauna, takka ja lasikuisti revontulien katseluun, ilmainen sähköauton lataus.',
},
{
slug: 'tampere-sauna-studio',
city: 'Tampere',
region: 'Pirkanmaa',
country: 'Finland',
streetAddress: 'Hämeenkatu 25',
addressNote: 'Key pickup from lobby',
latitude: 61.4981,
longitude: 23.7608,
maxGuests: 2,
bedrooms: 0,
beds: 1,
bathrooms: 1,
hasSauna: true,
hasFireplace: false,
hasWifi: true,
hasAirConditioning: true,
petsAllowed: false,
byTheLake: false,
evCharging: 'NONE',
priceHintPerNightCents: 9500,
cover: {
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
altText: 'Studio interior',
},
images: [],
titleEn: 'Tampere studio with private sauna',
teaserEn: 'City center, private sauna, AC and fiber.',
descEn: 'Compact studio on Hämeenkatu with private electric sauna, air conditioning, and fiber internet. Steps from tram.',
titleFi: 'Tampereen keskustastudio saunalla',
teaserFi: 'Yksityinen sauna, ilmastointi, kuitu.',
descFi: 'Kompakti studio Hämeenkadulla. Oma sähkösauna, ilmastointi ja kuitunetti. Ratikka vieressä.',
},
{
slug: 'vaasa-seaside-villa',
city: 'Vaasa',
region: 'Ostrobothnia',
country: 'Finland',
streetAddress: 'Rantakatu 3',
addressNote: 'Parking for 3 cars',
latitude: 63.096,
longitude: 21.6158,
maxGuests: 8,
bedrooms: 4,
beds: 6,
bathrooms: 2,
hasSauna: true,
hasFireplace: true,
hasWifi: true,
hasAirConditioning: false,
petsAllowed: true,
byTheLake: true,
evCharging: 'PAID',
priceHintPerNightCents: 24500,
cover: {
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
altText: 'Seaside villa deck',
},
images: [],
titleEn: 'Seaside villa in Vaasa',
teaserEn: 'Deck, sauna, pet-friendly, paid EV charging.',
descEn: 'Spacious villa on the coast with large deck, wood sauna, fireplace lounge. Pets welcome; paid EV charger on site.',
titleFi: 'Rantahuvila Vaasassa',
teaserFi: 'Terassi, sauna, lemmikit ok, maksullinen lataus.',
descFi: 'Tilava huvila meren rannalla, iso terassi, puusauna ja takkahuone. Lemmikit sallittu; maksullinen latauspiste.',
},
{
slug: 'kuopio-lakeside-apartment',
city: 'Kuopio',
region: 'Northern Savonia',
country: 'Finland',
streetAddress: 'Satamakatu 7',
addressNote: 'Underground parking',
latitude: 62.8924,
longitude: 27.6783,
maxGuests: 4,
bedrooms: 2,
beds: 3,
bathrooms: 1,
hasSauna: true,
hasFireplace: false,
hasWifi: true,
hasAirConditioning: false,
petsAllowed: false,
byTheLake: true,
evCharging: 'FREE',
priceHintPerNightCents: 12900,
cover: {
url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80',
altText: 'Lake view balcony',
},
images: [],
titleEn: 'Kuopio lakeside apartment with sauna',
teaserEn: 'Balcony to Kallavesi, sauna, free EV charging.',
descEn: 'Two-bedroom apartment overlooking Lake Kallavesi. Glass balcony, electric sauna, underground parking with free EV charging.',
titleFi: 'Kuopion järvinäkymä ja sauna',
teaserFi: 'Parveke Kallavedelle, sauna, ilmainen lataus.',
descFi: 'Kaksio Kallaveden rannalla. Lasitettu parveke, sähkösauna, hallipaikka ja ilmainen sähköauton lataus.',
},
{
slug: 'porvoo-river-loft',
city: 'Porvoo',
region: 'Uusimaa',
country: 'Finland',
streetAddress: 'Mannerheiminkatu 12',
addressNote: 'Historic building, stairs only',
latitude: 60.3943,
longitude: 25.6659,
maxGuests: 2,
bedrooms: 1,
beds: 1,
bathrooms: 1,
hasSauna: false,
hasFireplace: true,
hasWifi: true,
hasAirConditioning: false,
petsAllowed: false,
byTheLake: false,
evCharging: 'NONE',
priceHintPerNightCents: 9900,
cover: {
url: 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?auto=format&fit=crop&w=1600&q=80',
altText: 'Loft interior',
},
images: [],
titleEn: 'Porvoo old town river loft',
teaserEn: 'Historic charm, fireplace, steps from river.',
descEn: 'Cozy loft in Porvoo old town. Brick walls, fireplace, and views toward the riverside warehouses.',
titleFi: 'Porvoon jokilofti',
teaserFi: 'Takka ja vanhan kaupungin tunnelma.',
descFi: 'Kotoisa loft Porvoon vanhassa kaupungissa. Tiiliseinät, takka ja näkymä jokirantaan.',
},
{
slug: 'oulu-tech-apartment',
city: 'Oulu',
region: 'Northern Ostrobothnia',
country: 'Finland',
streetAddress: 'Technopolis 2',
addressNote: 'Smart lock entry',
latitude: 65.0121,
longitude: 25.4651,
maxGuests: 2,
bedrooms: 1,
beds: 1,
bathrooms: 1,
hasSauna: false,
hasFireplace: false,
hasWifi: true,
hasAirConditioning: true,
petsAllowed: false,
byTheLake: false,
evCharging: 'FREE',
priceHintPerNightCents: 10500,
cover: {
url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80',
altText: 'Modern apartment',
},
images: [],
titleEn: 'Smart apartment in Oulu',
teaserEn: 'AC, smart lock, free EV charging in garage.',
descEn: 'Modern one-bedroom near Technopolis. Air conditioning, smart lock, desk for work, garage with free EV chargers.',
titleFi: 'Moderni Oulun yksiö',
teaserFi: 'Ilmastointi, älylukko, ilmainen lataus.',
descFi: 'Moderni yksiö Technopoliksen lähellä. Ilmastointi, älylukko, työpiste ja ilmaiset latauspaikat hallissa.',
},
{
slug: 'mariehamn-harbor-flat',
city: 'Mariehamn',
region: 'Åland',
country: 'Finland',
streetAddress: 'Hamngatan 5',
addressNote: 'Ferry terminal 10 min walk',
latitude: 60.0973,
longitude: 19.9348,
maxGuests: 3,
bedrooms: 1,
beds: 2,
bathrooms: 1,
hasSauna: false,
hasFireplace: false,
hasWifi: true,
hasAirConditioning: false,
petsAllowed: false,
byTheLake: true,
evCharging: 'PAID',
priceHintPerNightCents: 11500,
cover: {
url: 'https://images.unsplash.com/photo-1470246973918-29a93221c455?auto=format&fit=crop&w=1600&q=80',
altText: 'Harbor view',
},
images: [],
titleEn: 'Harbor flat in Mariehamn',
teaserEn: 'Walk to ferries, harbor views, paid EV nearby.',
descEn: 'Bright flat near the harbor. Walk to ferries and restaurants, harbor-facing balcony, paid EV charging at the public lot.',
titleFi: 'Satamahuoneisto Maarianhaminassa',
teaserFi: 'Satamanäkymä, kävely lautoille.',
descFi: 'Valoisa huoneisto sataman tuntumassa. Parveke satamaan, ravintolat lähellä, maksullinen lataus viereisellä parkkipaikalla.',
},
];
for (const item of extraListings) {
const created = await prisma.listing.create({
data: {
ownerId: owner.id,
status: ListingStatus.PUBLISHED,
approvedAt: new Date(),
approvedById: adminUser ? adminUser.id : owner.id,
country: item.country,
region: item.region,
city: item.city,
streetAddress: item.streetAddress,
addressNote: item.addressNote,
latitude: item.latitude,
longitude: item.longitude,
maxGuests: item.maxGuests,
bedrooms: item.bedrooms,
beds: item.beds,
bathrooms: item.bathrooms,
hasSauna: item.hasSauna,
hasFireplace: item.hasFireplace,
hasWifi: item.hasWifi,
hasAirConditioning: item.hasAirConditioning,
petsAllowed: item.petsAllowed,
byTheLake: item.byTheLake,
evCharging: item.evCharging,
priceHintPerNightCents: item.priceHintPerNightCents,
contactName: 'Sample Host',
contactEmail: SAMPLE_EMAIL,
contactPhone: owner.phone,
published: true,
translations: {
createMany: {
data: [
{
locale: 'en',
slug: item.slug,
title: item.titleEn,
teaser: item.teaserEn,
description: item.descEn,
},
{
locale: 'fi',
slug: item.slug,
title: item.titleFi,
teaser: item.teaserFi,
description: item.descFi,
},
],
},
},
images: {
create: [
{
url: item.cover.url,
altText: item.cover.altText,
order: 1,
isCover: true,
},
...item.images.map((img, idx) => ({
url: img.url,
altText: img.altText ?? null,
order: idx + 2,
isCover: false,
})),
],
},
},
});
console.log('Seeded listing:', created.id, item.slug);
}
console.log('Seeded sample listing at slug:', SAMPLE_SLUG);
}