This commit is contained in:
Flatlogic Bot 2026-05-21 14:59:52 +00:00
parent e9dce70d2c
commit 2ccdbb91c9
9 changed files with 1214 additions and 378 deletions

View File

@ -475,10 +475,6 @@ module.exports = class UsersDBApi {
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [
{
@ -818,8 +814,13 @@ module.exports = class UsersDBApi {
{ transaction },
);
const allowedRoleNames = ['Tenant', 'Landlord'];
const requestedRoleName = allowedRoleNames.includes(data.requestedRole)
? data.requestedRole
: config.roles?.user || 'User';
const app_role = await db.roles.findOne({
where: { name: config.roles?.user || "User" },
where: { name: requestedRoleName },
});
if (app_role?.id) {
await users.setApp_role(app_role?.id || null, {

View File

@ -144,8 +144,7 @@ router.post('/signup', wrapAsync(async (req, res) => {
const payload = await AuthService.signup(
req.body.email,
req.body.password,
req,
{ requestedRole: req.body.requestedRole },
link.host,
)
res.status(200).send(payload);

View File

@ -8,6 +8,7 @@ const PasswordResetEmail = require('./email/list/passwordReset');
const EmailSender = require('./email');
const config = require('../config');
const helpers = require('../helpers');
const db = require('../db/models');
class Auth {
static async signup(email, password, options = {}, host) {
@ -59,7 +60,7 @@ class Auth {
firstName: email.split('@')[0],
password: hashedPassword,
email: email,
requestedRole: options?.requestedRole,
},
options,
);
@ -81,7 +82,7 @@ class Auth {
return helpers.jwtSign(data);
}
static async signin(email, password, options = {}) {
static async signin(email, password) {
const user = await UsersDBApi.findBy({email});
if (!user) {

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -1,166 +1,736 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import {
mdiAccountCircle,
mdiBullhornOutline,
mdiCashMultiple,
mdiCheckCircleOutline,
mdiClose,
mdiFileDocumentOutline,
mdiHomeCity,
mdiMapMarker,
mdiMessageTextOutline,
mdiPhoneOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import React, { useEffect, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import { hasPermission } from '../helpers/userPermissions';
import { findMe } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const formatCurrency = (amount: number | string | null | undefined, currency = 'USD') => {
if (amount === null || amount === undefined || amount === '') {
return 'Price on request';
}
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const numericAmount = Number(amount);
const title = 'Boarding House Finder'
if (Number.isNaN(numericAmount)) {
return `${amount}`;
}
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD',
maximumFractionDigits: 0,
}).format(numericAmount);
};
const formatDate = (date?: string | Date | null) => {
if (!date) {
return 'Not set';
}
const parsed = new Date(date);
if (Number.isNaN(parsed.getTime())) {
return 'Not set';
}
return parsed.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const getListingImage = (listing: any) => {
return (
listing?.photos?.[0]?.publicUrl ||
listing?.photos?.[0]?.downloadUrl ||
'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1200&q=80'
);
};
export default function HomePage() {
const dispatch = useAppDispatch();
const { currentUser } = useAppSelector((state) => state.auth);
const [loadingListings, setLoadingListings] = useState(false);
const [listings, setListings] = useState<any[]>([]);
const [selectedHouse, setSelectedHouse] = useState<any | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [tenantApplications, setTenantApplications] = useState<any[]>([]);
const [tenantTenancy, setTenantTenancy] = useState<any | null>(null);
const [tenantPayments, setTenantPayments] = useState<any[]>([]);
const [landlordListings, setLandlordListings] = useState<any[]>([]);
const [landlordApplications, setLandlordApplications] = useState<any[]>([]);
const [applicationLoading, setApplicationLoading] = useState(false);
const [applicationNotice, setApplicationNotice] = useState('');
const [applicationForm, setApplicationForm] = useState({
move_in_preference: '',
requested_months: '6',
occupants_count: '1',
message_to_landlord: '',
});
const roleName = currentUser?.app_role?.name || '';
const isTenant = roleName === 'Tenant';
const isLandlord = roleName === 'Landlord';
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const token = localStorage.getItem('token');
if (token && !currentUser?.id) {
dispatch(findMe());
}
}, [currentUser?.id, dispatch]);
useEffect(() => {
const loadHomeData = async () => {
if (!currentUser?.id || !hasPermission(currentUser, 'READ_BOARDING_HOUSES')) {
return;
}
setLoadingListings(true);
try {
const { data } = await axios.get('/boarding_houses', {
params: {
limit: 12,
page: 0,
status: 'published',
},
});
const nextListings = Array.isArray(data?.rows) ? data.rows : [];
setListings(nextListings);
setSelectedHouse((currentSelected) => currentSelected || nextListings[0] || null);
if (isTenant) {
const applicationsResponse = await axios.get('/house_applications', {
params: {
limit: 10,
page: 0,
tenant: currentUser.id,
},
});
const nextApplications = Array.isArray(applicationsResponse.data?.rows)
? applicationsResponse.data.rows
: [];
setTenantApplications(nextApplications);
const tenancyResponse = await axios.get('/tenancies', {
params: {
limit: 1,
page: 0,
tenant: currentUser.id,
},
});
const nextTenancy = Array.isArray(tenancyResponse.data?.rows)
? tenancyResponse.data.rows[0] || null
: null;
setTenantTenancy(nextTenancy);
if (nextTenancy?.id && hasPermission(currentUser, 'READ_PAYMENTS')) {
const paymentsResponse = await axios.get('/payments', {
params: {
limit: 5,
page: 0,
tenancy: nextTenancy.id,
},
});
setTenantPayments(Array.isArray(paymentsResponse.data?.rows) ? paymentsResponse.data.rows : []);
} else {
setTenantPayments([]);
}
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
if (isLandlord) {
const landlordListingsResponse = await axios.get('/boarding_houses', {
params: {
limit: 50,
page: 0,
landlord: currentUser.id,
},
});
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
const myListings = Array.isArray(landlordListingsResponse.data?.rows)
? landlordListingsResponse.data.rows
: [];
setLandlordListings(myListings);
const listingIds = myListings.map((item: any) => item.id).filter(Boolean);
if (listingIds.length > 0 && hasPermission(currentUser, 'READ_HOUSE_APPLICATIONS')) {
const landlordApplicationsResponse = await axios.get('/house_applications', {
params: {
limit: 25,
page: 0,
boarding_house: listingIds.join('|'),
},
});
setLandlordApplications(
Array.isArray(landlordApplicationsResponse.data?.rows)
? landlordApplicationsResponse.data.rows
: [],
);
} else {
setLandlordApplications([]);
}
}
} catch (error) {
console.error('Failed to load home page data', error);
} finally {
setLoadingListings(false);
}
};
loadHomeData();
}, [currentUser, isLandlord, isTenant]);
const filteredListings = useMemo(() => {
if (!searchTerm.trim()) {
return listings;
}
const normalizedSearch = searchTerm.toLowerCase();
return listings.filter((listing) => {
const searchableText = [
listing?.title,
listing?.location_name,
listing?.city,
listing?.state_province,
listing?.address_line,
listing?.amenities,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return searchableText.includes(normalizedSearch);
});
}, [listings, searchTerm]);
const selectedHouseApplication = tenantApplications.find(
(application) => application?.boarding_house?.id === selectedHouse?.id,
);
const handleApply = async () => {
if (!selectedHouse?.id || !currentUser?.id) {
setApplicationNotice('Please log in as a tenant first.');
return;
}
if (!isTenant) {
setApplicationNotice('Only tenant accounts can apply for a boarding house.');
return;
}
if (!hasPermission(currentUser, 'CREATE_HOUSE_APPLICATIONS')) {
setApplicationNotice('Your account does not have permission to submit an application.');
return;
}
if (selectedHouseApplication && !['declined', 'withdrawn'].includes(selectedHouseApplication.status)) {
setApplicationNotice('You already have an active application for this boarding house.');
return;
}
setApplicationLoading(true);
setApplicationNotice('');
try {
await axios.post('/house_applications', {
data: {
status: 'submitted',
submitted_at: new Date().toISOString(),
move_in_preference: applicationForm.move_in_preference || null,
requested_months: Number(applicationForm.requested_months) || null,
occupants_count: Number(applicationForm.occupants_count) || null,
message_to_landlord: applicationForm.message_to_landlord || null,
boarding_house: selectedHouse.id,
tenant: currentUser.id,
},
});
const optimisticApplication = {
id: `temp-${selectedHouse.id}`,
status: 'submitted',
submitted_at: new Date().toISOString(),
move_in_preference: applicationForm.move_in_preference,
requested_months: Number(applicationForm.requested_months) || null,
occupants_count: Number(applicationForm.occupants_count) || null,
message_to_landlord: applicationForm.message_to_landlord,
boarding_house: selectedHouse,
};
setTenantApplications((currentApplications) => [optimisticApplication, ...currentApplications]);
setApplicationForm({
move_in_preference: '',
requested_months: '6',
occupants_count: '1',
message_to_landlord: '',
});
setApplicationNotice('Application submitted successfully. You can track it from your profile.');
} catch (error) {
console.error('Failed to submit house application', error);
setApplicationNotice('Something went wrong while submitting your application. Please try again.');
} finally {
setApplicationLoading(false);
}
};
const quickLinks = isLandlord
? [
{ href: '/boarding_houses/boarding_houses-list', label: 'Manage listings', icon: mdiHomeCity },
{ href: '/house_applications/house_applications-list', label: 'Review applications', icon: mdiFileDocumentOutline },
{ href: '/announcements/announcements-list', label: 'Post announcements', icon: mdiBullhornOutline },
{ href: '/payments/payments-list', label: 'Check tenant payments', icon: mdiCashMultiple },
]
: [
{ href: '/profile', label: 'My profile', icon: mdiAccountCircle },
{ href: '/house_applications/house_applications-list', label: 'My applications', icon: mdiFileDocumentOutline },
{ href: '/payments/payments-list', label: 'Payment history', icon: mdiCashMultiple },
{ href: '/messages/messages-list', label: 'Messages', icon: mdiMessageTextOutline },
];
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Home')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Boarding House Finder app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className='min-h-screen bg-slate-50 text-slate-900'>
<div className='mx-auto flex max-w-7xl items-center justify-between px-6 py-5'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.3em] text-sky-600'>Boarding House Finder</p>
<h1 className='text-2xl font-bold'>Simple rental search for tenants and landlords</h1>
</div>
<div className='flex items-center gap-3'>
{currentUser?.id ? (
<>
<span className='hidden rounded-full bg-sky-100 px-3 py-1 text-sm font-medium text-sky-700 md:inline-flex'>
{roleName || 'Member'}
</span>
<BaseButton href='/profile' label='Profile' color='info' />
</>
) : (
<>
<BaseButton href='/login' label='Login' color='white' outline />
<BaseButton href='/register' label='Get Started' color='info' />
</>
)}
</div>
</div>
<div className='mx-auto grid max-w-7xl gap-6 px-6 pb-10 lg:grid-cols-[1.3fr_0.7fr]'>
<CardBox className='border-0 bg-gradient-to-br from-sky-600 via-cyan-600 to-emerald-500 text-white shadow-xl'>
<div className='grid gap-8 lg:grid-cols-[1.3fr_0.7fr]'>
<div className='space-y-5'>
<span className='inline-flex rounded-full bg-white/15 px-4 py-1 text-sm font-medium'>
Search, apply, and manage your stay in one place
</span>
<div className='space-y-3'>
<h2 className='text-4xl font-bold leading-tight'>
Find the right boarding house faster, then track everything from your profile.
</h2>
<p className='max-w-2xl text-base text-sky-50'>
Tenants can search available rooms, review prices and rules, and apply online. Landlords can publish listings, manage applications, share announcements, and monitor payments.
</p>
</div>
<div className='flex flex-col gap-3 rounded-2xl bg-white p-4 text-slate-900 shadow-sm md:flex-row md:items-center'>
<div className='flex-1'>
<label className='mb-2 block text-sm font-semibold text-slate-600'>Search boarding houses</label>
<input
className='w-full rounded-xl border border-slate-200 px-4 py-3 outline-none transition focus:border-sky-500'
placeholder='Search by place, address, or amenities'
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</div>
<div className='flex gap-3 md:self-end'>
{currentUser?.id ? (
<BaseButton label='Browse Homes' color='info' onClick={() => setSelectedHouse(filteredListings[0] || null)} />
) : (
<BaseButton href='/register' label='Get Started' color='info' />
)}
</div>
</div>
</div>
<div className='grid gap-4'>
<div className='rounded-2xl bg-white/10 p-5 backdrop-blur-sm'>
<p className='text-sm uppercase tracking-wide text-sky-100'>For tenants</p>
<p className='mt-2 text-lg font-semibold'>Apply to a room and track your payment history.</p>
</div>
<div className='rounded-2xl bg-white/10 p-5 backdrop-blur-sm'>
<p className='text-sm uppercase tracking-wide text-sky-100'>For landlords</p>
<p className='mt-2 text-lg font-semibold'>Manage listings, accept tenants, and review incoming applications.</p>
</div>
</div>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
</CardBox>
<CardBox className='border-0 bg-white shadow-lg'>
{!currentUser?.id ? (
<div className='space-y-5'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.25em] text-sky-600'>Get started</p>
<h3 className='mt-2 text-2xl font-bold'>Choose how you want to use the website</h3>
</div>
<div className='space-y-4'>
<div className='rounded-2xl border border-slate-200 p-4'>
<p className='font-semibold'>Tenant</p>
<p className='mt-1 text-sm text-slate-600'>Search available boarding houses, view the details, apply online, and track your status.</p>
</div>
<div className='rounded-2xl border border-slate-200 p-4'>
<p className='font-semibold'>Landlord</p>
<p className='mt-1 text-sm text-slate-600'>Add and update listings, review applications, post announcements, and check payments.</p>
</div>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<BaseButton href='/register' label='Create account' color='info' className='justify-center' />
<BaseButton href='/login' label='Log in' color='white' outline className='justify-center' />
</div>
</div>
) : (
<div className='space-y-5'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.25em] text-sky-600'>Welcome back</p>
<h3 className='mt-2 text-2xl font-bold'>
{currentUser.firstName ? `${currentUser.firstName}, here is your ${isLandlord ? 'landlord' : 'tenant'} view.` : 'Your personalized home page'}
</h3>
</div>
<div className='grid gap-3'>
{quickLinks.map((item) => (
<Link
key={item.href}
href={item.href}
className='flex items-center gap-3 rounded-2xl border border-slate-200 px-4 py-3 transition hover:border-sky-300 hover:bg-sky-50'
>
<BaseIcon path={item.icon} size={20} className='text-sky-600' />
<span className='font-medium'>{item.label}</span>
</Link>
))}
</div>
{isTenant && (
<div className='rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800'>
<p className='font-semibold'>Tenant snapshot</p>
<p className='mt-1'>
{tenantApplications.length} application(s), {tenantPayments.length} recent payment record(s), and {tenantTenancy ? 'an active tenancy' : 'no active tenancy yet'}.
</p>
</div>
)}
{isLandlord && (
<div className='rounded-2xl border border-violet-200 bg-violet-50 p-4 text-sm text-violet-800'>
<p className='font-semibold'>Landlord snapshot</p>
<p className='mt-1'>
{landlordListings.length} listing(s) and {landlordApplications.length} incoming application(s) currently visible.
</p>
</div>
)}
</div>
)}
</CardBox>
</div>
{currentUser?.id && (
<div className='mx-auto max-w-7xl px-6 pb-12'>
<div className='mb-6 flex items-center justify-between gap-4'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.25em] text-sky-600'>Available boarding houses</p>
<h2 className='mt-2 text-3xl font-bold'>Recommended places for you</h2>
</div>
<p className='text-sm text-slate-500'>{filteredListings.length} result(s)</p>
</div>
{loadingListings ? (
<CardBox className='border border-slate-200 bg-white text-center shadow-sm'>Loading available boarding houses...</CardBox>
) : filteredListings.length === 0 ? (
<CardBox className='border border-slate-200 bg-white text-center shadow-sm'>
No boarding houses matched your search yet.
</CardBox>
) : (
<div className='grid gap-6 md:grid-cols-2 xl:grid-cols-3'>
{filteredListings.map((listing) => (
<CardBox
key={listing.id}
className='cursor-pointer overflow-hidden border border-slate-200 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-lg'
onClick={() => {
setSelectedHouse(listing);
setApplicationNotice('');
}}
>
<div className='space-y-4'>
<img
src={getListingImage(listing)}
alt={listing.title || 'Boarding house'}
className='h-52 w-full rounded-2xl object-cover'
/>
<div className='space-y-3'>
<div className='flex items-start justify-between gap-4'>
<div>
<h3 className='text-xl font-semibold'>{listing.title || 'Untitled boarding house'}</h3>
<div className='mt-2 flex items-center gap-2 text-sm text-slate-500'>
<BaseIcon path={mdiMapMarker} size={18} className='text-sky-600' />
<span>{[listing.location_name, listing.city, listing.state_province].filter(Boolean).join(', ') || 'Location not set'}</span>
</div>
</div>
<span className='rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold uppercase text-sky-700'>
{listing.status || 'available'}
</span>
</div>
<div className='grid grid-cols-2 gap-3 text-sm text-slate-600'>
<div className='rounded-2xl bg-slate-50 p-3'>
<p className='text-xs uppercase tracking-wide text-slate-400'>Monthly price</p>
<p className='mt-1 font-semibold text-slate-900'>
{formatCurrency(listing.monthly_price, listing.currency_code)}
</p>
</div>
<div className='rounded-2xl bg-slate-50 p-3'>
<p className='text-xs uppercase tracking-wide text-slate-400'>Available rooms</p>
<p className='mt-1 font-semibold text-slate-900'>{listing.available_rooms ?? 0}</p>
</div>
</div>
<p className='line-clamp-3 text-sm text-slate-600'>
{listing.description || 'This listing does not have a description yet.'}
</p>
<BaseButton label='View details' color='info' className='justify-center' />
</div>
</div>
</CardBox>
))}
</div>
)}
</div>
)}
{selectedHouse && currentUser?.id && (
<div className='fixed inset-0 z-40 bg-slate-950/40'>
<button
type='button'
aria-label='Close details panel'
className='absolute inset-0 h-full w-full cursor-default'
onClick={() => setSelectedHouse(null)}
/>
<aside className='absolute left-0 top-0 h-full w-full max-w-2xl overflow-y-auto bg-white shadow-2xl'>
<div className='sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white px-6 py-4'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.25em] text-sky-600'>Boarding house details</p>
<h3 className='mt-1 text-2xl font-bold'>{selectedHouse.title || 'Boarding house'}</h3>
</div>
<button
type='button'
className='rounded-full border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-100'
onClick={() => setSelectedHouse(null)}
>
<BaseIcon path={mdiClose} size={22} />
</button>
</div>
<div className='space-y-6 px-6 py-6'>
<img
src={getListingImage(selectedHouse)}
alt={selectedHouse.title || 'Boarding house'}
className='h-72 w-full rounded-3xl object-cover'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='rounded-2xl bg-slate-50 p-4'>
<p className='text-xs uppercase tracking-wide text-slate-400'>Price</p>
<p className='mt-1 text-lg font-semibold text-slate-900'>
{formatCurrency(selectedHouse.monthly_price, selectedHouse.currency_code)}
</p>
</div>
<div className='rounded-2xl bg-slate-50 p-4'>
<p className='text-xs uppercase tracking-wide text-slate-400'>Rooms</p>
<p className='mt-1 text-lg font-semibold text-slate-900'>
{selectedHouse.available_rooms ?? 0} available / {selectedHouse.total_rooms ?? 0} total
</p>
</div>
<div className='rounded-2xl bg-slate-50 p-4'>
<p className='text-xs uppercase tracking-wide text-slate-400'>Location</p>
<p className='mt-1 text-lg font-semibold text-slate-900'>
{[selectedHouse.location_name, selectedHouse.city, selectedHouse.state_province].filter(Boolean).join(', ') || 'Location not set'}
</p>
</div>
<div className='rounded-2xl bg-slate-50 p-4'>
<p className='text-xs uppercase tracking-wide text-slate-400'>Available from</p>
<p className='mt-1 text-lg font-semibold text-slate-900'>
{formatDate(selectedHouse.available_from)}
</p>
</div>
</div>
</div>
<div>
<h4 className='text-lg font-semibold'>Description</h4>
<p className='mt-2 whitespace-pre-line text-slate-600'>
{selectedHouse.description || 'No description provided yet.'}
</p>
</div>
<div className='grid gap-6 md:grid-cols-2'>
<div>
<h4 className='text-lg font-semibold'>Rules</h4>
<p className='mt-2 whitespace-pre-line text-slate-600'>
{selectedHouse.house_rules || 'No house rules listed yet.'}
</p>
</div>
<div>
<h4 className='text-lg font-semibold'>Amenities</h4>
<p className='mt-2 whitespace-pre-line text-slate-600'>
{selectedHouse.amenities || 'Amenities were not specified.'}
</p>
</div>
</div>
<div className='rounded-3xl border border-slate-200 p-5'>
<h4 className='text-lg font-semibold'>Owner / contact details</h4>
<div className='mt-4 space-y-3 text-sm text-slate-600'>
<div className='flex items-center gap-3'>
<BaseIcon path={mdiAccountCircle} size={18} className='text-sky-600' />
<span>{selectedHouse.contact_name || selectedHouse.landlord?.firstName || 'Owner not specified'}</span>
</div>
<div className='flex items-center gap-3'>
<BaseIcon path={mdiPhoneOutline} size={18} className='text-sky-600' />
<span>{selectedHouse.contact_phone || 'Phone not provided'}</span>
</div>
<div className='flex items-center gap-3'>
<BaseIcon path={mdiMessageTextOutline} size={18} className='text-sky-600' />
<span>{selectedHouse.contact_email || selectedHouse.landlord?.email || 'Email not provided'}</span>
</div>
</div>
</div>
{isTenant && (
<div className='rounded-3xl border border-sky-200 bg-sky-50 p-5'>
<div className='flex items-center justify-between gap-4'>
<div>
<h4 className='text-lg font-semibold text-slate-900'>Apply for this boarding house</h4>
<p className='mt-1 text-sm text-slate-600'>Fill in a simple application and send it directly to the landlord.</p>
</div>
{selectedHouseApplication && !['declined', 'withdrawn'].includes(selectedHouseApplication.status) && (
<span className='inline-flex items-center gap-2 rounded-full bg-emerald-100 px-3 py-1 text-sm font-medium text-emerald-700'>
<BaseIcon path={mdiCheckCircleOutline} size={18} />
Applied ({selectedHouseApplication.status})
</span>
)}
</div>
<div className='mt-5 grid gap-4 md:grid-cols-2'>
<label className='block'>
<span className='mb-2 block text-sm font-medium text-slate-700'>Move-in preference</span>
<input
className='w-full rounded-xl border border-slate-200 px-4 py-3 outline-none transition focus:border-sky-500'
value={applicationForm.move_in_preference}
onChange={(event) => setApplicationForm((current) => ({ ...current, move_in_preference: event.target.value }))}
placeholder='Example: June 2026'
/>
</label>
<label className='block'>
<span className='mb-2 block text-sm font-medium text-slate-700'>Requested months</span>
<input
type='number'
min='1'
className='w-full rounded-xl border border-slate-200 px-4 py-3 outline-none transition focus:border-sky-500'
value={applicationForm.requested_months}
onChange={(event) => setApplicationForm((current) => ({ ...current, requested_months: event.target.value }))}
/>
</label>
<label className='block'>
<span className='mb-2 block text-sm font-medium text-slate-700'>Occupants</span>
<input
type='number'
min='1'
className='w-full rounded-xl border border-slate-200 px-4 py-3 outline-none transition focus:border-sky-500'
value={applicationForm.occupants_count}
onChange={(event) => setApplicationForm((current) => ({ ...current, occupants_count: event.target.value }))}
/>
</label>
</div>
<label className='mt-4 block'>
<span className='mb-2 block text-sm font-medium text-slate-700'>Message to landlord</span>
<textarea
className='min-h-[120px] w-full rounded-xl border border-slate-200 px-4 py-3 outline-none transition focus:border-sky-500'
value={applicationForm.message_to_landlord}
onChange={(event) => setApplicationForm((current) => ({ ...current, message_to_landlord: event.target.value }))}
placeholder='Introduce yourself briefly and share anything helpful for the landlord.'
/>
</label>
{applicationNotice && (
<div className='mt-4 rounded-2xl bg-white px-4 py-3 text-sm text-slate-700 shadow-sm'>
{applicationNotice}
</div>
)}
<div className='mt-4 flex flex-wrap gap-3'>
<BaseButton
label={applicationLoading ? 'Submitting...' : 'Apply for boarding house'}
color='info'
onClick={handleApply}
disabled={applicationLoading}
/>
<BaseButton href='/profile' label='Open my profile' color='white' outline />
</div>
</div>
)}
{isLandlord && (
<div className='rounded-3xl border border-violet-200 bg-violet-50 p-5'>
<h4 className='text-lg font-semibold text-slate-900'>Landlord actions</h4>
<p className='mt-1 text-sm text-slate-600'>You can manage this listing, review applications, and check tenant payments from your landlord tools.</p>
<div className='mt-4 flex flex-wrap gap-3'>
<BaseButton href='/boarding_houses/boarding_houses-list' label='Manage listings' color='info' />
<BaseButton href='/house_applications/house_applications-list' label='Review applications' color='white' outline />
</div>
</div>
)}
</div>
</aside>
</div>
)}
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -65,7 +65,7 @@ export default function Login() {
// Redirect to dashboard if user is logged in
useEffect(() => {
if (currentUser?.id) {
router.push('/dashboard');
router.push('/');
}
}, [currentUser?.id, router]);
// Show error message if there is one

View File

@ -1,180 +1,427 @@
import {
mdiChartTimelineVariant,
mdiUpload,
} from '@mdi/js';
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import { Field, Form, Formik } from 'formik';
import { ToastContainer, toast } from 'react-toastify';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import BaseDivider from '../components/BaseDivider';
import CardBox from '../components/CardBox';
import LayoutAuthenticated from '../layouts/Authenticated';
import FormField from '../components/FormField';
import FormImagePicker from '../components/FormImagePicker';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import BaseButton from '../components/BaseButton';
import FormCheckRadio from '../components/FormCheckRadio';
import FormCheckRadioGroup from '../components/FormCheckRadioGroup';
import FormImagePicker from '../components/FormImagePicker';
import { SwitchField } from '../components/SwitchField';
import { SelectField } from '../components/SelectField';
import { update, fetch } from '../stores/users/usersSlice';
import LayoutAuthenticated from '../layouts/Authenticated';
import { findMe } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
import {findMe} from "../stores/authSlice";
import { update } from '../stores/users/usersSlice';
const EditUsers = () => {
const { currentUser, isFetching, token } = useAppSelector(
(state) => state.auth,
);
const router = useRouter();
const dispatch = useAppDispatch();
const notify = (type, msg) => toast(msg, { type });
const initVals = {
firstName: '',
lastName: '',
phoneNumber: '',
email: '',
app_role: '',
disabled: false,
avatar: [],
password: ''
};
const [initialValues, setInitialValues] = useState(initVals);
const formatCurrency = (amount: number | string | null | undefined, currency = 'USD') => {
if (amount === null || amount === undefined || amount === '') {
return '—';
}
useEffect(() => {
if (currentUser?.id && typeof currentUser === 'object') {
const newInitialVal = { ...initVals };
const numericAmount = Number(amount);
Object.keys(initVals).forEach(
(el) => (newInitialVal[el] = currentUser[el]),
);
if (Number.isNaN(numericAmount)) {
return `${amount}`;
}
setInitialValues(newInitialVal);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD',
maximumFractionDigits: 0,
}).format(numericAmount);
};
const formatDate = (date?: string | Date | null) => {
if (!date) {
return 'Not set';
}
const parsed = new Date(date);
if (Number.isNaN(parsed.getTime())) {
return 'Not set';
}
return parsed.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const ProfilePage = () => {
const dispatch = useAppDispatch();
const { currentUser } = useAppSelector((state) => state.auth);
const [loadingSummary, setLoadingSummary] = useState(false);
const [tenantApplications, setTenantApplications] = useState<any[]>([]);
const [tenantTenancy, setTenantTenancy] = useState<any | null>(null);
const [tenantPayments, setTenantPayments] = useState<any[]>([]);
const [landlordListings, setLandlordListings] = useState<any[]>([]);
const [landlordApplications, setLandlordApplications] = useState<any[]>([]);
const initVals = {
firstName: '',
lastName: '',
phoneNumber: '',
email: '',
avatar: [],
password: '',
};
const [initialValues, setInitialValues] = useState(initVals);
const roleName = currentUser?.app_role?.name || '';
const isTenant = roleName === 'Tenant';
const isLandlord = roleName === 'Landlord';
useEffect(() => {
if (currentUser?.id && typeof currentUser === 'object') {
setInitialValues({
firstName: currentUser.firstName || '',
lastName: currentUser.lastName || '',
phoneNumber: currentUser.phoneNumber || '',
email: currentUser.email || '',
avatar: currentUser.avatar || [],
password: '',
});
}
}, [currentUser]);
useEffect(() => {
const loadSummary = async () => {
if (!currentUser?.id) {
return;
}
setLoadingSummary(true);
try {
if (isTenant) {
const applicationsResponse = await axios.get('/house_applications', {
params: { limit: 10, page: 0, tenant: currentUser.id },
});
const applications = Array.isArray(applicationsResponse.data?.rows)
? applicationsResponse.data.rows
: [];
setTenantApplications(applications);
const tenancyResponse = await axios.get('/tenancies', {
params: { limit: 1, page: 0, tenant: currentUser.id },
});
const tenancy = Array.isArray(tenancyResponse.data?.rows)
? tenancyResponse.data.rows[0] || null
: null;
setTenantTenancy(tenancy);
if (tenancy?.id) {
const paymentsResponse = await axios.get('/payments', {
params: { limit: 10, page: 0, tenancy: tenancy.id },
});
setTenantPayments(Array.isArray(paymentsResponse.data?.rows) ? paymentsResponse.data.rows : []);
} else {
setTenantPayments([]);
}
}
}, [currentUser]);
const handleSubmit = async (data) => {
await dispatch(update({ id: currentUser.id, data }));
await dispatch(findMe());
await router.push('/users/users-list');
notify('success', 'Profile was updated!');
if (isLandlord) {
const listingsResponse = await axios.get('/boarding_houses', {
params: { limit: 50, page: 0, landlord: currentUser.id },
});
const listings = Array.isArray(listingsResponse.data?.rows) ? listingsResponse.data.rows : [];
setLandlordListings(listings);
const listingIds = listings.map((item: any) => item.id).filter(Boolean);
if (listingIds.length > 0) {
const applicationsResponse = await axios.get('/house_applications', {
params: {
limit: 25,
page: 0,
boarding_house: listingIds.join('|'),
},
});
setLandlordApplications(Array.isArray(applicationsResponse.data?.rows) ? applicationsResponse.data.rows : []);
} else {
setLandlordApplications([]);
}
}
} catch (error) {
console.error('Failed to load profile summary', error);
} finally {
setLoadingSummary(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Edit profile')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='Edit profile'
main
>
{''}
</SectionTitleLineWithButton>
loadSummary();
}, [currentUser, isLandlord, isTenant]);
const handleSubmit = async (data: any) => {
if (!currentUser?.id) {
return;
}
const payload: any = {
firstName: data.firstName,
lastName: data.lastName,
phoneNumber: data.phoneNumber,
email: currentUser.email,
avatar: data.avatar,
disabled: currentUser.disabled,
app_role: currentUser?.app_role?.id,
};
if (data.password) {
payload.password = data.password;
}
await dispatch(update({ id: currentUser.id, data: payload }));
await dispatch(findMe());
toast('Profile was updated successfully!', { type: 'success' });
};
return (
<>
<Head>
<title>{getPageTitle('Profile')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='My profile' main>
{''}
</SectionTitleLineWithButton>
<div className='grid gap-6 xl:grid-cols-[1.15fr_0.85fr]'>
<CardBox>
{currentUser?.avatar?.[0]?.publicUrl && (
<div className='mb-6 flex justify-center'>
<div className='inline-flex h-48 w-48 items-center justify-center overflow-hidden rounded-full border-2'>
<img className='h-full w-full object-cover object-center' src={currentUser.avatar[0].publicUrl} alt='Avatar' />
</div>
</div>
)}
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
<Form>
<FormField>
<Field
label='Avatar'
color='info'
icon={mdiUpload}
path='users/avatar'
name='avatar'
id='avatar'
schema={{ size: undefined, formats: undefined }}
component={FormImagePicker}
/>
</FormField>
<FormField label='First Name'>
<Field name='firstName' placeholder='First Name' />
</FormField>
<FormField label='Last Name'>
<Field name='lastName' placeholder='Last Name' />
</FormField>
<FormField label='Phone Number'>
<Field name='phoneNumber' placeholder='Phone Number' />
</FormField>
<FormField label='Email'>
<Field name='email' placeholder='E-Mail' disabled />
</FormField>
<FormField label='Account Type'>
<div className='rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700'>
{roleName || 'User'}
</div>
</FormField>
<FormField label='New Password'>
<Field name='password' placeholder='Leave blank to keep your current password' type='password' />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Save profile' />
<BaseButton type='reset' color='white' outline label='Reset' />
</BaseButtons>
</Form>
</Formik>
</CardBox>
<div className='space-y-6'>
<CardBox>
<div className='space-y-2'>
<p className='text-sm font-semibold uppercase tracking-[0.25em] text-sky-600'>Summary</p>
<h2 className='text-2xl font-bold'>Your account overview</h2>
<p className='text-sm text-slate-500'>Use this page to keep your personal details updated and monitor your latest boarding-house activity.</p>
</div>
</CardBox>
{loadingSummary ? (
<CardBox>Loading your latest profile activity...</CardBox>
) : null}
{isTenant && (
<>
<CardBox>
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}>
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8">
<img className="w-80 h-80 max-w-full max-h-full object-cover object-center" src={`${currentUser?.avatar[0]?.publicUrl}`} alt="Avatar" />
</div>
</div>}
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField>
<Field
label='Avatar'
color='info'
icon={mdiUpload}
path={'users/avatar'}
name='avatar'
id='avatar'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker}
></Field>
</FormField>
<FormField label='First Name'>
<Field name='firstName' placeholder='First Name' />
</FormField>
<div className='space-y-4'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.25em] text-sky-600'>Tenant activity</p>
<h3 className='mt-2 text-xl font-bold'>My boarding house and applications</h3>
</div>
<FormField label='Last Name'>
<Field name='lastName' placeholder='Last Name' />
</FormField>
<div className='grid gap-4 md:grid-cols-2'>
<div className='rounded-2xl bg-slate-50 p-4'>
<p className='text-xs uppercase tracking-wide text-slate-400'>Current tenancy</p>
<p className='mt-2 font-semibold text-slate-900'>
{tenantTenancy?.boarding_house?.title || 'No active tenancy yet'}
</p>
<p className='mt-1 text-sm text-slate-500'>
Start: {formatDate(tenantTenancy?.start_at)}
</p>
</div>
<div className='rounded-2xl bg-slate-50 p-4'>
<p className='text-xs uppercase tracking-wide text-slate-400'>Latest payment status</p>
<p className='mt-2 font-semibold text-slate-900'>
{tenantPayments[0]?.status || 'No payment records yet'}
</p>
<p className='mt-1 text-sm text-slate-500'>
Amount: {formatCurrency(tenantPayments[0]?.amount, tenantPayments[0]?.currency_code || 'USD')}
</p>
</div>
</div>
<FormField label='Phone Number'>
<Field name='phoneNumber' placeholder='Phone Number' />
</FormField>
<div>
<h4 className='font-semibold'>Recent applications</h4>
<div className='mt-3 space-y-3'>
{tenantApplications.length > 0 ? tenantApplications.slice(0, 3).map((application) => (
<div key={application.id} className='rounded-2xl border border-slate-200 p-4'>
<div className='flex items-center justify-between gap-3'>
<p className='font-medium'>{application?.boarding_house?.title || 'Boarding house'}</p>
<span className='rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold uppercase text-sky-700'>
{application.status}
</span>
</div>
<p className='mt-2 text-sm text-slate-500'>Submitted: {formatDate(application.submitted_at)}</p>
</div>
)) : <p className='text-sm text-slate-500'>You have not submitted any applications yet.</p>}
</div>
</div>
<FormField label='E-Mail'>
<Field name='email' placeholder='E-Mail' disabled />
</FormField>
<FormField label='App Role' labelFor='app_role'>
<Field
name='app_role'
id='app_role'
component={SelectField}
options={initialValues.app_role}
itemRef={'roles'}
showField={'name'}
></Field>
</FormField>
<FormField label='Disabled' labelFor='disabled'>
<Field
name='disabled'
id='disabled'
component={SwitchField}
></Field>
</FormField>
<FormField
label="Password"
>
<Field
name="password"
placeholder="password"
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />
<BaseButton type='reset' color='info' outline label='Reset' />
<BaseButton
type='reset'
color='danger'
outline
label='Cancel'
onClick={() => router.push('/users/users-list')}
/>
</BaseButtons>
</Form>
</Formik>
<BaseButtons>
<BaseButton href='/' label='Browse houses' color='info' />
<BaseButton href='/payments/payments-list' label='Open payment history' color='white' outline />
</BaseButtons>
</div>
</CardBox>
</SectionMain>
</>
);
<CardBox>
<div className='space-y-3'>
<p className='text-sm font-semibold uppercase tracking-[0.25em] text-sky-600'>Payment tracking</p>
<h3 className='text-xl font-bold'>Recent payments</h3>
{tenantPayments.length > 0 ? tenantPayments.slice(0, 5).map((payment) => (
<div key={payment.id} className='rounded-2xl border border-slate-200 p-4'>
<div className='flex items-center justify-between gap-3'>
<p className='font-medium'>{payment.type || 'Payment'}</p>
<span className='rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold uppercase text-emerald-700'>
{payment.status}
</span>
</div>
<p className='mt-2 text-sm text-slate-500'>
{formatCurrency(payment.amount, payment.currency_code || 'USD')} Due {formatDate(payment.due_at)}
</p>
</div>
)) : <p className='text-sm text-slate-500'>No payment history yet.</p>}
</div>
</CardBox>
</>
)}
{isLandlord && (
<>
<CardBox>
<div className='space-y-4'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.25em] text-sky-600'>Landlord activity</p>
<h3 className='mt-2 text-xl font-bold'>My listings and incoming applications</h3>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='rounded-2xl bg-slate-50 p-4'>
<p className='text-xs uppercase tracking-wide text-slate-400'>Listings</p>
<p className='mt-2 font-semibold text-slate-900'>{landlordListings.length}</p>
</div>
<div className='rounded-2xl bg-slate-50 p-4'>
<p className='text-xs uppercase tracking-wide text-slate-400'>Applications</p>
<p className='mt-2 font-semibold text-slate-900'>{landlordApplications.length}</p>
</div>
</div>
<div>
<h4 className='font-semibold'>Latest listings</h4>
<div className='mt-3 space-y-3'>
{landlordListings.length > 0 ? landlordListings.slice(0, 3).map((listing) => (
<div key={listing.id} className='rounded-2xl border border-slate-200 p-4'>
<div className='flex items-center justify-between gap-3'>
<p className='font-medium'>{listing.title || 'Untitled boarding house'}</p>
<span className='rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold uppercase text-violet-700'>
{listing.status || 'draft'}
</span>
</div>
<p className='mt-2 text-sm text-slate-500'>
{listing.city || listing.location_name || 'Location not set'} {formatCurrency(listing.monthly_price, listing.currency_code || 'USD')}
</p>
</div>
)) : <p className='text-sm text-slate-500'>You have not created any listings yet.</p>}
</div>
</div>
<BaseButtons>
<BaseButton href='/boarding_houses/boarding_houses-list' label='Manage listings' color='info' />
<BaseButton href='/house_applications/house_applications-list' label='Review applications' color='white' outline />
</BaseButtons>
</div>
</CardBox>
<CardBox>
<div className='space-y-3'>
<p className='text-sm font-semibold uppercase tracking-[0.25em] text-sky-600'>Tenant applications</p>
<h3 className='text-xl font-bold'>Recent requests</h3>
{landlordApplications.length > 0 ? landlordApplications.slice(0, 5).map((application) => (
<div key={application.id} className='rounded-2xl border border-slate-200 p-4'>
<div className='flex items-center justify-between gap-3'>
<p className='font-medium'>{application?.tenant?.firstName || 'Tenant'} {application?.boarding_house?.title || 'Boarding house'}</p>
<span className='rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold uppercase text-amber-700'>
{application.status}
</span>
</div>
<p className='mt-2 text-sm text-slate-500'>Submitted: {formatDate(application.submitted_at)}</p>
</div>
)) : <p className='text-sm text-slate-500'>No incoming applications yet.</p>}
</div>
</CardBox>
</>
)}
</div>
</div>
</SectionMain>
<ToastContainer />
</>
);
};
EditUsers.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
ProfilePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default EditUsers;
export default ProfilePage;

View File

@ -1,92 +1,112 @@
import React from 'react';
import type { ReactElement } from 'react';
import { ToastContainer, toast } from 'react-toastify';
import Head from 'next/head';
import { Field, Form, Formik } from 'formik';
import axios from 'axios';
import jwt from 'jsonwebtoken';
import { ToastContainer, toast } from 'react-toastify';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import BaseDivider from '../components/BaseDivider';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config';
import axios from "axios";
import { useRouter } from 'next/router';
export default function Register() {
const [loading, setLoading] = React.useState(false);
const router = useRouter();
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
const [loading, setLoading] = React.useState(false);
const router = useRouter();
const notify = (type: 'success' | 'error', msg: string) => toast(msg, { type, position: 'bottom-center' });
const handleSubmit = async (value: Record<string, string>) => {
setLoading(true);
const handleSubmit = async (value) => {
setLoading(true)
try {
const { data: response } = await axios.post('/auth/signup',value);
await router.push('/login')
setLoading(false)
notify('success', 'Please check your email for verification link')
} catch (error) {
setLoading(false)
console.log('error: ', error)
notify('error', 'Something was wrong. Try again')
}
};
try {
const { confirm, ...payload } = value;
return (
<>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
if (payload.password !== confirm) {
setLoading(false);
notify('error', 'Passwords do not match.');
return;
}
<SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
<Formik
initialValues={{
email: '',
password: '',
confirm: ''
}}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='Email' help='Please enter your email'>
<Field type='email' name='email' />
</FormField>
<FormField label='Password' help='Please enter your password'>
<Field type='password' name='password' />
</FormField>
<FormField label='Confirm Password' help='Please confirm your password'>
<Field type='password' name='confirm' />
</FormField>
const { data: token } = await axios.post('/auth/signup', payload);
const user = jwt.decode(token);
<BaseDivider />
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
axios.defaults.headers.common.Authorization = `Bearer ${token}`;
<BaseButtons>
<BaseButton
type='submit'
label={loading ? 'Loading...' : 'Register' }
color='info'
/>
<BaseButton
href={'/login'}
label={'Login'}
color='info'
/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionFullScreen>
<ToastContainer />
</>
);
notify('success', 'Your account is ready. Redirecting you to the home page...');
await router.push('/');
} catch (error) {
console.error('Failed to register user', error);
notify('error', 'Something went wrong. Please try again.');
} finally {
setLoading(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Register')}</title>
</Head>
<SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-8/12 lg:w-6/12 xl:w-5/12'>
<div className='mb-6 text-center'>
<p className='text-sm font-semibold uppercase tracking-[0.25em] text-sky-600'>Boarding House Finder</p>
<h1 className='mt-2 text-3xl font-bold'>Create your account</h1>
<p className='mt-2 text-sm text-slate-500'>Choose whether you are joining as a tenant or a landlord.</p>
</div>
<Formik
initialValues={{
email: '',
password: '',
confirm: '',
requestedRole: 'Tenant',
}}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='I am joining as'>
<Field as='select' name='requestedRole'>
<option value='Tenant'>Tenant</option>
<option value='Landlord'>Landlord</option>
</Field>
</FormField>
<FormField label='Email' help='Please enter your email'>
<Field type='email' name='email' />
</FormField>
<FormField label='Password' help='Please enter your password'>
<Field type='password' name='password' />
</FormField>
<FormField label='Confirm Password' help='Please confirm your password'>
<Field type='password' name='confirm' />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' label={loading ? 'Creating account...' : 'Register'} color='info' disabled={loading} />
<BaseButton href='/login' label='Login' color='white' outline />
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionFullScreen>
<ToastContainer />
</>
);
}
Register.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
return <LayoutGuest>{page}</LayoutGuest>;
};