diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index e9106aa..77c8d58 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -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, { diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index d6f29e8..bcdc91c 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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); diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js index 2862da4..ee7d2f4 100644 --- a/backend/src/services/auth.js +++ b/backend/src/services/auth.js @@ -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) { diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -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' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -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' diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index e599620..c604ded 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -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([]); + const [selectedHouse, setSelectedHouse] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [tenantApplications, setTenantApplications] = useState([]); + const [tenantTenancy, setTenantTenancy] = useState(null); + const [tenantPayments, setTenantPayments] = useState([]); + const [landlordListings, setLandlordListings] = useState([]); + const [landlordApplications, setLandlordApplications] = useState([]); + 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) => ( - - ); + 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 ( -
- - -
) + 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 ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Home')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+

Boarding House Finder

+

Simple rental search for tenants and landlords

+
+ +
+ {currentUser?.id ? ( + <> + + {roleName || 'Member'} + + + + ) : ( + <> + + + + )} +
+
+ +
+ +
+
+ + Search, apply, and manage your stay in one place + +
+

+ Find the right boarding house faster, then track everything from your profile. +

+

+ Tenants can search available rooms, review prices and rules, and apply online. Landlords can publish listings, manage applications, share announcements, and monitor payments. +

+
+ +
+
+ + setSearchTerm(event.target.value)} + /> +
+ +
+ {currentUser?.id ? ( + setSelectedHouse(filteredListings[0] || null)} /> + ) : ( + + )} +
+
+
+ +
+
+

For tenants

+

Apply to a room and track your payment history.

+
+
+

For landlords

+

Manage listings, accept tenants, and review incoming applications.

+
+
- - - + + + {!currentUser?.id ? ( +
+
+

Get started

+

Choose how you want to use the website

+
+
+
+

Tenant

+

Search available boarding houses, view the details, apply online, and track your status.

+
+
+

Landlord

+

Add and update listings, review applications, post announcements, and check payments.

+
+
+
+ + +
+
+ ) : ( +
+
+

Welcome back

+

+ {currentUser.firstName ? `${currentUser.firstName}, here is your ${isLandlord ? 'landlord' : 'tenant'} view.` : 'Your personalized home page'} +

+
+ +
+ {quickLinks.map((item) => ( + + + {item.label} + + ))} +
+ + {isTenant && ( +
+

Tenant snapshot

+

+ {tenantApplications.length} application(s), {tenantPayments.length} recent payment record(s), and {tenantTenancy ? 'an active tenancy' : 'no active tenancy yet'}. +

+
+ )} + + {isLandlord && ( +
+

Landlord snapshot

+

+ {landlordListings.length} listing(s) and {landlordApplications.length} incoming application(s) currently visible. +

+
+ )} +
+ )} +
+
+ + {currentUser?.id && ( +
+
+
+

Available boarding houses

+

Recommended places for you

+
+

{filteredListings.length} result(s)

+
+ + {loadingListings ? ( + Loading available boarding houses... + ) : filteredListings.length === 0 ? ( + + No boarding houses matched your search yet. + + ) : ( +
+ {filteredListings.map((listing) => ( + { + setSelectedHouse(listing); + setApplicationNotice(''); + }} + > +
+ {listing.title +
+
+
+

{listing.title || 'Untitled boarding house'}

+
+ + {[listing.location_name, listing.city, listing.state_province].filter(Boolean).join(', ') || 'Location not set'} +
+
+ + {listing.status || 'available'} + +
+ +
+
+

Monthly price

+

+ {formatCurrency(listing.monthly_price, listing.currency_code)} +

+
+
+

Available rooms

+

{listing.available_rooms ?? 0}

+
+
+ +

+ {listing.description || 'This listing does not have a description yet.'} +

+ + +
+
+
+ ))} +
+ )} +
+ )} + + {selectedHouse && currentUser?.id && ( +
+ +
+ +
+ {selectedHouse.title - - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+

Price

+

+ {formatCurrency(selectedHouse.monthly_price, selectedHouse.currency_code)} +

+
+
+

Rooms

+

+ {selectedHouse.available_rooms ?? 0} available / {selectedHouse.total_rooms ?? 0} total +

+
+
+

Location

+

+ {[selectedHouse.location_name, selectedHouse.city, selectedHouse.state_province].filter(Boolean).join(', ') || 'Location not set'} +

+
+
+

Available from

+

+ {formatDate(selectedHouse.available_from)} +

+
+
-
+
+

Description

+

+ {selectedHouse.description || 'No description provided yet.'} +

+
+ +
+
+

Rules

+

+ {selectedHouse.house_rules || 'No house rules listed yet.'} +

+
+
+

Amenities

+

+ {selectedHouse.amenities || 'Amenities were not specified.'} +

+
+
+ +
+

Owner / contact details

+
+
+ + {selectedHouse.contact_name || selectedHouse.landlord?.firstName || 'Owner not specified'} +
+
+ + {selectedHouse.contact_phone || 'Phone not provided'} +
+
+ + {selectedHouse.contact_email || selectedHouse.landlord?.email || 'Email not provided'} +
+
+
+ + {isTenant && ( +
+
+
+

Apply for this boarding house

+

Fill in a simple application and send it directly to the landlord.

+
+ {selectedHouseApplication && !['declined', 'withdrawn'].includes(selectedHouseApplication.status) && ( + + + Applied ({selectedHouseApplication.status}) + + )} +
+ +
+ + + +
+ +