bh1.0
This commit is contained in:
parent
e9dce70d2c
commit
2ccdbb91c9
@ -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, {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user