20 |
--------------------------------------------------------------------------------
/lib/analytics/types.ts:
--------------------------------------------------------------------------------
1 | import { AnalyticsResponseInterface } from '@/util/interfaces/interfaces'
2 | export interface ReviewQuery {
3 | page?: number
4 | limit?: number
5 | search?: string
6 | sort?: 'az' | 'za' | 'new' | 'old' | 'high' | 'low'
7 | state?: string
8 | country?: string
9 | city?: string
10 | zip?: string
11 | }
12 |
13 | export interface AnalyticsResponse {
14 | avgRatingT90: number
15 | avgRatingT180: number
16 | avgRatingT365: number
17 | medianRentT90: number
18 | medianRentT180: number
19 | medianRentT365: number
20 | }
21 |
22 | export interface AnalyticsChartResponse {
23 | avgRatingChartData: AnalyticsResponseInterface[]
24 | medianChartData: AnalyticsResponseInterface[]
25 | }
26 |
--------------------------------------------------------------------------------
/migrations/001-users.ts:
--------------------------------------------------------------------------------
1 | exports.up = async function (DB) {
2 | const tableExists = await DB`
3 | SELECT EXISTS (
4 | SELECT 1
5 | FROM information_schema.tables
6 | WHERE table_name = 'users'
7 | )`
8 | if (!tableExists[0].exists) {
9 | await DB`
10 | CREATE TABLE users (
11 | id SERIAL PRIMARY KEY,
12 | name TEXT,
13 | email TEXT NOT NULL,
14 | password TEXT,
15 | blocked BOOLEAN,
16 | role TEXT,
17 | UNIQUE (email),
18 | login_attempts numeric DEFAULT 0,
19 | login_lockout BOOLEAN,
20 | last_login_attempt TIMESTAMP DEFAULT now(),
21 | lockout_time TIMESTAMP DEFAULT now()
22 | );
23 | `
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/pages/api/admin/get-flagged.tsx:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
4 | import { getFlagged } from '@/lib/review/flagged'
5 |
6 | const getReviews = async (req: NextApiRequest, res: NextApiResponse) => {
7 | const session = await getSession(req, res)
8 | const user = session?.user
9 | await runMiddleware(req, res)
10 |
11 | if (user && user.role === 'ADMIN') {
12 | const reviews = await getFlagged()
13 | res.status(200).json(reviews)
14 | } else {
15 | res.status(401).json({ error: 'UNAUTHORIZED' })
16 | }
17 | }
18 |
19 | export default withApiAuthRequired(getReviews)
20 |
--------------------------------------------------------------------------------
/pages/api/location/get-location.tsx:
--------------------------------------------------------------------------------
1 | import { getLocations } from '@/lib/location/location'
2 | import { runMiddleware } from '@/util/cors'
3 | import { Options } from '@/util/interfaces/interfaces'
4 | import rateLimitMiddleware from '@/util/rateLimit'
5 | import { NextApiRequest, NextApiResponse } from 'next'
6 |
7 | const getLocationApi = async (req: NextApiRequest, res: NextApiResponse) => {
8 | await runMiddleware(req, res)
9 |
10 | const { zipCodes, country_code } = req.body as {
11 | zipCodes: Options[]
12 | country_code: string
13 | }
14 |
15 | const locations = await getLocations(zipCodes, country_code)
16 |
17 | res.status(200).json(locations)
18 | }
19 |
20 | export default rateLimitMiddleware(getLocationApi)
21 |
--------------------------------------------------------------------------------
/pages/api/admin/get-recent.tsx:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
4 | import { getRecentReviews } from '@/lib/review/review-text-match'
5 |
6 | const handler = async (req: NextApiRequest, res: NextApiResponse) => {
7 | const session = await getSession(req, res)
8 | const user = session?.user
9 | await runMiddleware(req, res)
10 |
11 | if (user && user.role === 'ADMIN') {
12 | const reviews = await getRecentReviews()
13 | res.status(200).json(reviews)
14 | } else {
15 | res.status(401).json({ error: 'UNAUTHORIZED' })
16 | }
17 | }
18 |
19 | export default withApiAuthRequired(handler)
20 |
--------------------------------------------------------------------------------
/messages/en-CA/home.json:
--------------------------------------------------------------------------------
1 | {
2 | "home": {
3 | "icon": {
4 | "title": "Keep your community informed.",
5 | "anonymity": "Anonymity",
6 | "anon-sub": "Share your rental experience with confidence.",
7 | "solidarity": "Solidarity",
8 | "sol-sub": "Join fellow Tenants by creating an informed community.",
9 | "transparency": "Transparency",
10 | "trans-sub": "Empower others to make decisions about housing."
11 | },
12 | "home": {
13 | "hero": {
14 | "title": "Share information with tenants like you.",
15 | "body": "We are a community platform that elevates tenant voices to promote landlord accountability.",
16 | "submit": "Submit a Review",
17 | "read": "Read Reviews"
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/pages/api/admin/get-deleted.tsx:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
4 | import { getDeleted } from '@/lib/review/models/admin-delete-review'
5 |
6 | const getReviews = async (req: NextApiRequest, res: NextApiResponse) => {
7 | const session = await getSession(req, res)
8 | const user = session?.user
9 | await runMiddleware(req, res)
10 |
11 | if (user && user.role === 'ADMIN') {
12 | const reviews = await getDeleted()
13 | res.status(200).json(reviews)
14 | } else {
15 | res.status(401).json({ error: 'UNAUTHORIZED' })
16 | }
17 | }
18 |
19 | export default withApiAuthRequired(getReviews)
20 |
--------------------------------------------------------------------------------
/check-tests.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Get the list of staged files
4 | STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.tsx$')
5 |
6 | # Loop through each staged file and check for test files and a11y tests
7 | for file in $STAGED_FILES; do
8 |
9 | if [[ "$file" != *"components/"* ]]; then
10 | continue
11 | fi
12 |
13 | if [[ "$file" != *".test.tsx" ]]; then
14 | test_file="${file%.tsx}.test.tsx"
15 | if [ ! -f "$test_file" ]; then
16 | echo "Test file missing for: $file"
17 | exit 1
18 | fi
19 | else
20 | # Check for a11y test in the test file
21 | if ! grep -q "a11y" "$file"; then
22 | echo "A11y test missing in: $file"
23 | exit 1
24 | fi
25 | fi
26 | done
--------------------------------------------------------------------------------
/components/about/aboutUs.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, screen } from '@/test-utils'
6 | import AboutUs from './aboutUs'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | expect.extend(toHaveNoViolations)
9 |
10 | test('renders about section with info items', () => {
11 | render()
12 | const aboutSection = screen.getByTestId('about-aboutus-1')
13 | expect(aboutSection).toBeInTheDocument()
14 |
15 | expect(screen.getByText('about.about-us.about')).toBeInTheDocument()
16 | })
17 |
18 | it('Should not have a11y violation', async () => {
19 | const { container } = render()
20 | const result = await axe(container)
21 | expect(result).toHaveNoViolations()
22 | })
23 |
--------------------------------------------------------------------------------
/components/about/privacy.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import { useTranslations } from 'next-intl'
3 |
4 | const Privacy = () => {
5 | const t = useTranslations('about')
6 | return (
7 |
8 |
9 |
10 | {t('privacy.privacy')}
11 |
12 |
13 | {t('privacy.info')}
14 |
15 |
16 | {t('privacy.readmore')}
17 |
18 |
19 |
20 | )
21 | }
22 |
23 | export default Privacy
24 |
--------------------------------------------------------------------------------
/components/analytics/analytics.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render } from '@/test-utils'
6 | import { axe, toHaveNoViolations } from 'jest-axe'
7 | import AnalyticsComponent from './analytics'
8 | import { ISortOptions } from '../reviews/review'
9 | expect.extend(toHaveNoViolations)
10 |
11 | const mockData = {
12 | sort: 'az' as ISortOptions,
13 | state: 'on',
14 | country: 'ca',
15 | city: 'toronto',
16 | zip: 'h0h0h0',
17 | search: '',
18 | limit: '1000',
19 | }
20 |
21 | it('Should not have a11y violation', async () => {
22 | const { container } = render()
23 | const result = await axe(container)
24 | expect(result).toHaveNoViolations()
25 | })
26 |
--------------------------------------------------------------------------------
/components/svg/logo/logo.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function Logo({ styling }: { styling: string }) {
4 | return (
5 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/components/icons/HouseIcon.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render } from '@testing-library/react'
6 | import { HouseIcon } from './HouseIcon'
7 | import { axe } from 'jest-axe'
8 |
9 | describe('HouseIcon', () => {
10 | it('should render the SVG with fill set to none', () => {
11 | const { container } = render()
12 | const svgElement = container.querySelector('svg')
13 | expect(svgElement).toBeInTheDocument()
14 | expect(svgElement).toHaveAttribute('fill', 'none')
15 | })
16 |
17 | it('Should not have a11y violation', async () => {
18 | const { container } = render()
19 | const result = await axe(container)
20 | expect(result).toHaveNoViolations()
21 | })
22 | })
23 |
--------------------------------------------------------------------------------
/components/admin/components/StateStats.tsx:
--------------------------------------------------------------------------------
1 | const StateStats = ({
2 | states,
3 | }: {
4 | states?: {
5 | key: string
6 | total: string
7 | }[]
8 | }) => {
9 | const sortedStates = states?.sort((a, b) => Number(b.total) - Number(a.total))
10 | return (
11 |
12 | {sortedStates?.map((state) => {
13 | return (
14 |
18 |
19 | {state.key}
20 |
21 |
{state.total}
22 |
23 | )
24 | })}
25 |
26 | )
27 | }
28 |
29 | export default StateStats
30 |
--------------------------------------------------------------------------------
/pages/api/review/get-landlord-suggestions.tsx:
--------------------------------------------------------------------------------
1 | import { getLandlordSuggestions } from '@/lib/review/landlords'
2 | import { runMiddleware } from '@/util/cors'
3 | import { NextApiRequest, NextApiResponse } from 'next'
4 |
5 | export const removeSpecialChars = (input: string) => {
6 | const specialCharsRegex = /[/@#$%^*<>?[\]{}|]/g
7 | return input.replace(specialCharsRegex, '')
8 | }
9 |
10 | const handler = async (req: NextApiRequest, res: NextApiResponse) => {
11 | await runMiddleware(req, res)
12 |
13 | const { body } = req as { body: { input: string } }
14 | const sanitizedLandlord = removeSpecialChars(body.input)
15 |
16 | const landlords = await getLandlordSuggestions(sanitizedLandlord)
17 |
18 | res.status(200).json(landlords)
19 | }
20 |
21 | export default handler
22 |
--------------------------------------------------------------------------------
/components/ui/RatingStars.tsx:
--------------------------------------------------------------------------------
1 | import { classNames } from '@/util/helpers/helper-functions'
2 | import { StarIcon } from '@heroicons/react/solid'
3 |
4 | interface IProps {
5 | value: number
6 | testid: string
7 | size?: string
8 | }
9 |
10 | const RatingStars = ({ value, testid, size = '5' }: IProps) => {
11 | const starSize = `h-${size} w-${size}`
12 | return (
13 |
14 | {[0, 1, 2, 3, 4].map((rating) => (
15 | rating ? 'text-yellow-400' : 'text-gray-300',
19 | 'flex-shrink-0',
20 | starSize,
21 | )}
22 | aria-hidden='true'
23 | />
24 | ))}
25 |
26 | )
27 | }
28 |
29 | export default RatingStars
30 |
--------------------------------------------------------------------------------
/components/about/privacy.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render, screen } from '@/test-utils'
6 | import Privacy from './privacy'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | expect.extend(toHaveNoViolations)
9 |
10 | describe('Privacy', () => {
11 | it('renders privacy information correctly', () => {
12 | render()
13 |
14 | const heading = screen.getByRole('heading', { name: /privacy/i })
15 |
16 | expect(heading).toBeInTheDocument()
17 |
18 | expect(heading).toHaveTextContent('about.privacy.privacy')
19 | })
20 |
21 | it('Should not have a11y violation', async () => {
22 | const { container } = render()
23 | const result = await axe(container)
24 | expect(result).toHaveNoViolations()
25 | })
26 | })
27 |
--------------------------------------------------------------------------------
/migrations/002-tenant_resources.ts:
--------------------------------------------------------------------------------
1 | exports.up = async function (DB) {
2 | const tableExists = await DB`
3 | SELECT EXISTS (
4 | SELECT 1
5 | FROM information_schema.tables
6 | WHERE table_name = 'tenant_resource'
7 | )`
8 | if (!tableExists[0].exists) {
9 | await DB`
10 | CREATE TABLE tenant_resource (
11 | id SERIAL PRIMARY KEY,
12 | name TEXT,
13 | country_code VARCHAR(2),
14 | city TEXT,
15 | state TEXT,
16 | address TEXT,
17 | phone_number TEXT,
18 | date_added TIMESTAMP DEFAULT now(),
19 | description TEXT,
20 | href TEXT
21 | );
22 | `
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/components/analytics/components/sidebar.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render } from '@/test-utils'
6 | import { axe, toHaveNoViolations } from 'jest-axe'
7 | import Sidebar from './sidebar'
8 | import { AnalyticsResponse } from '@/lib/analytics/types'
9 | expect.extend(toHaveNoViolations)
10 |
11 | const mockData: AnalyticsResponse = {
12 | avgRatingT90: 1,
13 | avgRatingT180: 2,
14 | avgRatingT365: 3,
15 | medianRentT90: 4,
16 | medianRentT180: 5,
17 | medianRentT365: 6,
18 | }
19 |
20 | it('Should not have a11y violation', async () => {
21 | const { container } = render(
22 | jest.fn()} />,
23 | )
24 | const result = await axe(container)
25 | expect(result).toHaveNoViolations()
26 | })
27 |
--------------------------------------------------------------------------------
/components/resources/resourcesInfo.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, screen } from '@/test-utils'
6 | import ResourcesInfo from './resourcesInfo'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | expect.extend(toHaveNoViolations)
9 |
10 | describe('ResourcesInfo', () => {
11 | it('renders ResourcesInfo component correctly', () => {
12 | render()
13 |
14 | // Verify that the title is rendered
15 | const title = screen.getByText('resources.title')
16 | expect(title).toBeInTheDocument()
17 | })
18 | it('Should not have a11y violation', async () => {
19 | const { container } = render()
20 | const result = await axe(container)
21 | expect(result).toHaveNoViolations()
22 | })
23 | })
24 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { Head, Html, Main, NextScript } from 'next/document'
2 |
3 | const isProd = process.env.NEXT_PUBLIC_ENVIRONMENT === 'production' || false
4 |
5 | export default class MyDocument extends Document {
6 | render(): JSX.Element {
7 | return (
8 |
9 |
10 | {!isProd && }
11 |
12 |
13 |
14 |
15 | {/* Google Ads */}
16 | {isProd && (
17 | <>
18 |
23 | >
24 | )}
25 |
26 |
27 | )
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/components/modal/success-modal.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, screen } from '@/test-utils'
6 | import { axe, toHaveNoViolations } from 'jest-axe'
7 | expect.extend(toHaveNoViolations)
8 |
9 | import SuccessModal from './success-modal'
10 |
11 | describe('SuccessModal', () => {
12 | test('renders', () => {
13 | render()
14 |
15 | // Check if the success modal is rendered
16 | const successModalElement = screen.getByTestId('SuccessModalComponent')
17 | expect(successModalElement).toBeInTheDocument()
18 | })
19 | it('Should not have a11y violation', async () => {
20 | const { container } = render()
21 | const result = await axe(container)
22 | expect(result).toHaveNoViolations()
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/components/svg/icons/privacy.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function Privacy({ styling }: { styling: string }) {
4 | return (
5 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/messages/fr-CA/alerts.json:
--------------------------------------------------------------------------------
1 | {
2 | "alerts": {
3 | "error": "Échec : Une erreur s'est produite, veuillez réessayer.",
4 | "city-validation": "Le champ 'Ville' ne peut pas être vide.",
5 | "landlord-validation": "Le champ 'Propriétaire' ne peut pas être vide."
6 | },
7 | "cookie": {
8 | "body-1": "🍪 Nous utilisons des cookies pour améliorer votre expérience de navigation et analyser le trafic du site. En cliquant sur \"Accepter\", vous consentez à l'utilisation de cookies comme indiqué dans notre politique de confidentialité. Nous ne vendons ni ne transférons vos données à des tiers. Si vous préférez refuser, seuls les cookies essentiels seront utilisés. Voir notre ",
9 | "body-2": ". 🍪",
10 | "privacy": "politique de confidentialité",
11 | "accept": "Accepter",
12 | "decline": "Déclin"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/components/admin/sections/Stats.tsx:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr'
2 | import { fetcher } from '@/util/helpers/fetcher'
3 | import Spinner from '@/components/ui/Spinner'
4 | import TotalStats from '../components/TotalStats'
5 | import { IStats } from '../types/types'
6 |
7 | const Stats = () => {
8 | const { data, error } = useSWR(
9 | `/api/admin/get-stats`,
10 | fetcher,
11 | )
12 |
13 | if (error) return failed to load
14 | if (!data) return
15 |
16 | return (
17 |
18 |
19 |
20 | Total Reviews: {data.total_stats.total_reviews}
21 |
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
29 | export default Stats
30 |
--------------------------------------------------------------------------------
/messages/en-CA/resources.json:
--------------------------------------------------------------------------------
1 | {
2 | "resources": {
3 | "title": "Resources",
4 | "description": "Need support? Consider joining your local tenant union!",
5 | "info-1": "Tenant unions and advocacy groups play an important role in empowering renters by providing support, information, and resources to help them understand their rights and responsibilities. By joining a local tenant union, you can connect with others in your community and work together to address common issues and concerns.",
6 | "info-2": "To join a tenant union, begin by searching for local organizations in your state/province/region or territory.",
7 | "contribute": "If you have a helpful resource you think should be on our site, send us an email at contact@ratethelandlord.org",
8 | "address": "Address",
9 | "phone": "Phone Number"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/pages/api/user-update/delete.ts:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import rateLimitMiddleware from '@/util/rateLimit'
4 | import { userDeleteReview } from '@/lib/review/models/user-delete-review-layer'
5 |
6 | interface Body {
7 | id: number
8 | user_code: string
9 | }
10 |
11 | const UserEditReview = async (req: NextApiRequest, res: NextApiResponse) => {
12 | await runMiddleware(req, res)
13 |
14 | const { body } = req as { body: Body }
15 |
16 | const { id, user_code } = body
17 | if (!id || !user_code) {
18 | res.status(400).json({ message: 'Missing Data' })
19 | } else {
20 | const result = await userDeleteReview(id, user_code)
21 | res.status(200).json(result)
22 | }
23 | }
24 |
25 | export default rateLimitMiddleware(UserEditReview)
26 |
--------------------------------------------------------------------------------
/components/home/icon-section.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import React from 'react'
6 | import { render, screen } from '@/test-utils'
7 | import '@testing-library/jest-dom/extend-expect'
8 | import IconSection from './icon-section'
9 | import { axe, toHaveNoViolations } from 'jest-axe'
10 | expect.extend(toHaveNoViolations)
11 |
12 | describe('IconSection', () => {
13 | test('IconSection renders correctly', () => {
14 | render()
15 |
16 | // Ensure the component renders
17 | const section = screen.getByTestId('home-icon-section-1')
18 | expect(section).toBeInTheDocument()
19 | })
20 | it('Should not have a11y violation', async () => {
21 | const { container } = render()
22 | const result = await axe(container)
23 | expect(result).toHaveNoViolations()
24 | })
25 | })
26 |
--------------------------------------------------------------------------------
/messages/en-CA/filters.json:
--------------------------------------------------------------------------------
1 | {
2 | "filters": {
3 | "title": "Filters",
4 | "no-results": "No results found",
5 | "no-body": "Sorry, we couldn't find any results for those filters.",
6 | "resources": "Search Resources",
7 | "sort": "Sort By",
8 | "country": "Country",
9 | "state": "State / Province",
10 | "city": "City",
11 | "landlord": "Landlord",
12 | "apply": "Apply Filters",
13 | "clear": "Clear Filters",
14 | "search": "Search",
15 | "loading": "Loading...",
16 | "search-for": "Search for",
17 | "search-placeholder": "Search for your Landlord",
18 | "not-found": "No Landlord Found - Submit first review!"
19 | },
20 | "sort": {
21 | "name_az": "Name A-Z",
22 | "name_za": "Name Z-A",
23 | "newest": "Newest",
24 | "oldest": "Oldest",
25 | "highest": "Highest",
26 | "lowest": "Lowest"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/messages/fr-CA/home.json:
--------------------------------------------------------------------------------
1 | {
2 | "home": {
3 | "icon": {
4 | "title": "Gardez votre communauté informée.",
5 | "anonymity": "Anonymat",
6 | "anon-sub": "Partagez votre expérience locative en toute confiance.",
7 | "solidarity": "Solidarité",
8 | "sol-sub": "Rejoignez d'autres locataires pour créer une communauté informée.",
9 | "transparency": "Transparence",
10 | "trans-sub": "Aidez les autres à prendre des décisions éclairées en matière de logement."
11 | },
12 | "home": {
13 | "hero": {
14 | "title": "Partagez de l'information avec d'autres locataires comme vous.",
15 | "body": "Nous sommes une plateforme de communauté qui amplifie les voix des locataires afin de responsabiliser les propriétaires.",
16 | "submit": "Soumettre un avis",
17 | "read": "Lire les avis"
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/util/cors.ts:
--------------------------------------------------------------------------------
1 | import Cors from 'cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 |
4 | // Initializing the cors middleware
5 | // You can read more about the available options here: https://github.com/expressjs/cors#configuration-options
6 | const cors = Cors({
7 | methods: ['POST', 'GET', 'HEAD', 'DELETE', 'PUT'],
8 | origin: process.env.ORIGIN_URL as string,
9 | })
10 |
11 | // Helper method to wait for a middleware to execute before continuing
12 | // And to throw an error when an error happens in a middleware
13 | export function runMiddleware(req: NextApiRequest, res: NextApiResponse) {
14 | return new Promise((resolve, reject) => {
15 | cors(req, res, (result: unknown) => {
16 | if (result instanceof Error) {
17 | return reject(result)
18 | }
19 |
20 | return resolve(result)
21 | })
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/components/ui/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import { classNames } from '@/util/helpers/helper-functions'
2 |
3 | interface IProps {
4 | height?: string
5 | width?: string
6 | colour?: string
7 | }
8 |
9 | const Spinner = ({
10 | height = 'h-8',
11 | width = 'w-8',
12 | colour = 'text-teal-600',
13 | }: IProps) => {
14 | return (
15 |
24 |
25 | Loading...
26 |
27 |
28 | )
29 | }
30 |
31 | export default Spinner
32 |
--------------------------------------------------------------------------------
/util/helpers/getCountryCodes.ts:
--------------------------------------------------------------------------------
1 | import countries from '@/util/countries/countries.json'
2 | import { Options } from '../interfaces/interfaces'
3 |
4 | export const country_codes: string[] = Object.keys(countries).filter(
5 | (c) =>
6 | c === 'CA' ||
7 | c === 'US' ||
8 | c === 'GB' ||
9 | c === 'AU' ||
10 | c === 'NZ' ||
11 | c === 'IE' ||
12 | c === 'NO' ||
13 | c === 'DE',
14 | )
15 |
16 | export const countryOptions: Options[] = country_codes.map(
17 | (item: string, ind: number): Options => {
18 | return {
19 | id: ind + 1,
20 | name: countries[item as keyof typeof countries],
21 | value: item,
22 | }
23 | },
24 | )
25 |
26 | export const countryName = (countryCode: string): string =>
27 | countries[
28 | Object.keys(countries).find(
29 | (c) => c === countryCode,
30 | ) as keyof typeof countries
31 | ]
32 |
--------------------------------------------------------------------------------
/components/city/CityInfo.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render } from '@/test-utils'
5 | import '@testing-library/jest-dom'
6 | import { axe, toHaveNoViolations } from 'jest-axe'
7 | import CityInfo from './CityInfo'
8 | expect.extend(toHaveNoViolations)
9 |
10 | describe('CityInfo Component', () => {
11 | const mockProps = {
12 | city: 'Toronto',
13 | state: 'Ontario',
14 | country: 'Canada',
15 | average: 4.2,
16 | total: 120,
17 | averages: {
18 | avg_repair: 3.5,
19 | avg_health: 4.0,
20 | avg_stability: 2.5,
21 | avg_privacy: 3.0,
22 | avg_respect: 4.5,
23 | },
24 | }
25 | it('Should not have a11y violation', async () => {
26 | const { container } = render()
27 | const result = await axe(container)
28 | expect(result).toHaveNoViolations()
29 | })
30 | })
31 |
--------------------------------------------------------------------------------
/pages/api/review/submit-review.tsx:
--------------------------------------------------------------------------------
1 | import { verifyToken } from '@/lib/captcha/verifyToken'
2 | import { create } from '@/lib/review/models/review-data-layer'
3 | import { Review } from '@/util/interfaces/interfaces'
4 | import ReviewRateLimitMiddleware from '@/util/reviewRateLimit'
5 | import { NextApiRequest, NextApiResponse } from 'next'
6 |
7 | interface IBody {
8 | captchaToken: string
9 | review: Review
10 | }
11 |
12 | const SubmitReview = async (req: NextApiRequest, res: NextApiResponse) => {
13 | const { body } = req as { body: IBody }
14 |
15 | const captcha = await verifyToken(body.captchaToken)
16 |
17 | if (captcha) {
18 | const review = await create(body.review)
19 | res.status(200).json(review)
20 | } else {
21 | res.status(401).json('UNAUTHORIZED')
22 | }
23 | }
24 |
25 | export default ReviewRateLimitMiddleware(SubmitReview)
26 |
--------------------------------------------------------------------------------
/util/helpers/fetchFilterOptions.ts:
--------------------------------------------------------------------------------
1 | import { FilterOptions } from '../interfaces/interfaces'
2 |
3 | export async function fetchFilterOptions(
4 | country?: string,
5 | state?: string,
6 | city?: string,
7 | zip?: string,
8 | ): Promise {
9 | const url = `/api/review/get-filter-options`
10 |
11 | try {
12 | const response = await fetch(url, {
13 | method: 'POST',
14 | headers: {
15 | 'Content-Type': 'application/json',
16 | },
17 | body: JSON.stringify({ country, state, city, zip }),
18 | })
19 |
20 | if (!response.ok) {
21 | throw new Error('Network response was not ok')
22 | }
23 |
24 | const data = (await response.json()) as FilterOptions
25 | return data
26 | } catch {
27 | console.error('Error fetching filter options')
28 | return { countries: [], states: [], cities: [], zips: [] }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/components/reviews/components/ReviewHero.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, screen } from '@/test-utils'
6 | import ReviewHero from './ReviewHero'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | expect.extend(toHaveNoViolations)
9 |
10 | describe('ReviewHero', () => {
11 | it('renders the hero header', () => {
12 | render()
13 | expect(screen.getByText('reviews.hero_header')).toBeInTheDocument()
14 | })
15 |
16 | it('renders the hero body', () => {
17 | render()
18 | expect(screen.getByText('reviews.hero_body')).toBeInTheDocument()
19 | })
20 | it('Should not have a11y violation', async () => {
21 | const { container } = render()
22 | const result = await axe(container)
23 | expect(result).toHaveNoViolations()
24 | })
25 | })
26 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use the official Bun image
2 | # See all versions at https://hub.docker.com/r/oven/bun/tags
3 | FROM oven/bun:latest AS base
4 |
5 | ARG PORT=3000 # Default value if no PORT is provided
6 | ENV PORT=$PORT
7 |
8 | # Set working directory
9 | WORKDIR /usr/src/app
10 |
11 | # Create app directory
12 | RUN mkdir -p /app
13 |
14 | # Set /app as the working directory
15 | WORKDIR /app
16 |
17 | # Disable telemetry
18 | ENV NEXT_TELEMETRY_DISABLED=1
19 |
20 | # Copy package.json and package-lock.json
21 | COPY package*.json /app/
22 |
23 | # Install dependencies
24 | RUN bun install
25 |
26 | # Copy the rest of the app files into /app
27 | COPY . /app
28 |
29 | # Set environment variables
30 |
31 | # Build the Next.js app
32 | RUN bun run build
33 |
34 | # Expose the app port
35 | EXPOSE ${PORT}
36 |
37 | # Start the app
38 | CMD ["bun", "run", "start"]
39 |
--------------------------------------------------------------------------------
/components/home/hero.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render, screen } from '@/test-utils'
5 | import Hero from '@/components/home/hero'
6 | import IconSection from '@/components/home/icon-section'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | expect.extend(toHaveNoViolations)
9 |
10 | describe('Homepage', () => {
11 | test('Hero component renders', () => {
12 | render()
13 | expect(screen.getByTestId('home-hero-1')).toBeInTheDocument()
14 | })
15 | test('Icon section component renders', () => {
16 | render()
17 | expect(screen.getByTestId('home-icon-section-1')).toBeInTheDocument()
18 | })
19 | it('Should not have a11y violation', async () => {
20 | const { container } = render()
21 | const result = await axe(container)
22 | expect(result).toHaveNoViolations()
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/pages/api/review/delete-review.tsx:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
4 | import { deleteReview } from '@/lib/review/models/admin-delete-review'
5 |
6 | interface IBody {
7 | id: number
8 | }
9 |
10 | const handle = async (req: NextApiRequest, res: NextApiResponse) => {
11 | const session = await getSession(req, res)
12 | const user = session?.user
13 | await runMiddleware(req, res)
14 |
15 | const { body } = req as { body: IBody }
16 |
17 | const id = body.id
18 |
19 | if (user && user.role === 'ADMIN') {
20 | await deleteReview(id)
21 | res.status(200).json('Review Deleted')
22 | } else {
23 | res.status(401).json({ error: 'UNAUTHORIZED' })
24 | }
25 | }
26 |
27 | export default withApiAuthRequired(handle)
28 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | /** @type {import('ts-jest').JestConfigWithTsJest} */
3 |
4 | module.exports = {
5 | preset: 'ts-jest',
6 | testEnvironment: 'node',
7 | setupFilesAfterEnv: ['/setupTests.ts'],
8 | // globals tsConfig deprecated, relevant SO issue: https://stackoverflow.com/questions/68656057/why-isnt-ts-jest-loading-my-custom-tsconfig-file
9 | transform: {
10 | // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest`
11 | // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest`
12 | '^.+\\.[tj]sx?$': [
13 | 'ts-jest',
14 | {
15 | tsconfig: 'tsconfig.jest.json',
16 | },
17 | ],
18 | },
19 | moduleNameMapper: {
20 | // "@/*": ["./*"]
21 | '^@/(.*)$': ['/$1'],
22 | },
23 | transformIgnorePatterns: [`/node_modules/(?!next-recaptcha-v3)`],
24 | globals: {
25 | fetch: global.fetch,
26 | },
27 | }
28 |
--------------------------------------------------------------------------------
/lib/captcha/verifyToken.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
2 | interface CaptchaPayload {
3 | secret: string
4 | response: string
5 | }
6 |
7 | const VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'
8 |
9 | export async function verifyToken(token: string): Promise {
10 | const data: CaptchaPayload = {
11 | secret: process.env.CAPTCHA_SECRET_KEY as string,
12 | response: token,
13 | }
14 |
15 | const req = await fetch(
16 | `${VERIFY_URL}?secret=${data.secret}&response=${token}`,
17 | {
18 | method: 'POST',
19 | headers: {
20 | 'Content-Type': 'application/x-www-form-urlencoded',
21 | },
22 | },
23 | )
24 |
25 | const response = await req.json()
26 |
27 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
28 | const success: boolean = await response.success
29 |
30 | return success
31 | }
32 |
--------------------------------------------------------------------------------
/components/city/CityMobileFilters.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render } from '@/test-utils'
6 | import { axe, toHaveNoViolations } from 'jest-axe'
7 | import { Provider } from 'react-redux'
8 | import { store } from '@/redux/store'
9 | import CityMobileFilters from './CityMobileFilters'
10 | expect.extend(toHaveNoViolations)
11 |
12 | describe('CityMobileFilter Component', () => {
13 | it('Should not have a11y violation', async () => {
14 | const { container } = render(
15 |
16 | jest.fn()}
19 | zipFilter={{ id: 1, name: 'test', value: '12345' }}
20 | updateParams={() => jest.fn()}
21 | />
22 | ,
23 | )
24 | const result = await axe(container)
25 | expect(result).toHaveNoViolations()
26 | })
27 | })
28 |
--------------------------------------------------------------------------------
/pages/api/review/edit-review.tsx:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
4 | import { Review } from '@/util/interfaces/interfaces'
5 | import { updateReview } from '@/lib/review/models/admin-update-review'
6 |
7 | const EditReview = async (req: NextApiRequest, res: NextApiResponse) => {
8 | const session = await getSession(req, res)
9 | const user = session?.user
10 | await runMiddleware(req, res)
11 |
12 | const { body } = req as { body: Review }
13 |
14 | const id = body.id
15 |
16 | if (user && user.role === 'ADMIN') {
17 | const reviews = await updateReview(id || 0, body)
18 | res.status(200).json(reviews)
19 | } else {
20 | res.status(401).json({ error: 'UNAUTHORIZED' })
21 | }
22 | }
23 |
24 | export default withApiAuthRequired(EditReview)
25 |
--------------------------------------------------------------------------------
/components/resources/resourcesInfo.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslations } from 'next-intl'
2 |
3 | const ResourcesInfo = () => {
4 | const t = useTranslations('resources')
5 | const keys = ['info-1', 'info-2'] as const
6 | return (
7 |
8 |
9 |
10 |
11 | {t('title')}
12 |
13 |
14 |
15 | {t('description')}
16 |
17 | {keys.map((item, i) => {
18 | return (
19 |
20 | {t(item)}
21 |
22 | )
23 | })}
24 |
25 |
26 | )
27 | }
28 |
29 | export default ResourcesInfo
30 |
--------------------------------------------------------------------------------
/components/Map/CustomMarker.tsx:
--------------------------------------------------------------------------------
1 | import { IZipLocations } from '@/lib/location/types'
2 |
3 | interface IProps {
4 | location: IZipLocations | null
5 | selectedPoint: IZipLocations | null
6 | setSelectedPoint: (loc: IZipLocations | null) => void
7 | }
8 |
9 | const CustomMarker = ({
10 | location,
11 | selectedPoint,
12 | setSelectedPoint,
13 | }: IProps) => {
14 | return (
15 | setSelectedPoint(location)}
17 | aria-hidden='true'
18 | className='relative z-50 flex h-5 w-5 flex-shrink-0 cursor-pointer items-center justify-center'
19 | >
20 |
27 |
28 |
29 | )
30 | }
31 |
32 | export default CustomMarker
33 |
--------------------------------------------------------------------------------
/lib/review/types/Queries.ts:
--------------------------------------------------------------------------------
1 | import { UserReview } from '@/util/interfaces/interfaces'
2 |
3 | export interface ReviewQuery {
4 | page?: number
5 | limit?: number
6 | search?: string
7 | sort?: 'az' | 'za' | 'new' | 'old' | 'high' | 'low'
8 | state?: string
9 | country?: string
10 | city?: string
11 | zip?: string
12 | }
13 |
14 | export interface ILandlordReviews {
15 | reviews: UserReview[]
16 | average: number
17 | total: number
18 | catAverages: {
19 | avg_repair: number
20 | avg_health: number
21 | avg_stability: number
22 | avg_privacy: number
23 | avg_respect: number
24 | }
25 | }
26 |
27 | export interface ICityQuery {
28 | city: string
29 | state: string
30 | country_code: string
31 | offset?: string
32 | sort?: 'az' | 'za' | 'new' | 'old' | 'high' | 'low'
33 | }
34 |
35 | export interface ZipQuery {
36 | zip: string
37 | state: string
38 | country_code: string
39 | }
40 |
--------------------------------------------------------------------------------
/components/about/aboutUs.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslations } from 'next-intl'
2 |
3 | const AboutUs = () => {
4 | const t = useTranslations('about')
5 | const keys = [
6 | 'about-us.info-1',
7 | 'about-us.info-2',
8 | 'about-us.info-3',
9 | 'about-us.info-4',
10 | ] as const
11 |
12 | return (
13 |
14 |
15 |
16 |
17 | {t('about-us.about')}
18 |
19 |
20 | {keys.map((item, i) => {
21 | return (
22 |
26 | {t(item)}
27 |
28 | )
29 | })}
30 |
31 |
32 | )
33 | }
34 |
35 | export default AboutUs
36 |
--------------------------------------------------------------------------------
/pages/api/review/flag-review.tsx:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { verifyToken } from '@/lib/captcha/verifyToken'
4 | import rateLimitMiddleware from '@/util/rateLimit'
5 | import { report } from '@/lib/review/models/user-report-review'
6 |
7 | interface IBody {
8 | id: number
9 | flagged_reason: string
10 | captchaToken: string
11 | }
12 |
13 | const FlagReview = async (req: NextApiRequest, res: NextApiResponse) => {
14 | await runMiddleware(req, res)
15 |
16 | const { body } = req as { body: IBody }
17 |
18 | const captcha = await verifyToken(body.captchaToken)
19 |
20 | if (captcha) {
21 | const reviews = await report(body.id, body.flagged_reason)
22 | res.status(200).json(reviews)
23 | } else {
24 | res.status(401).json({ error: 'UNAUTHORIZED' })
25 | }
26 | }
27 |
28 | export default rateLimitMiddleware(FlagReview)
29 |
--------------------------------------------------------------------------------
/pages/api/flagged-keywords/add-flagged-keyword.tsx:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
4 | import { create } from '@/lib/flagged-keywords/flagged-keywords'
5 |
6 | interface IBody {
7 | keyword: string
8 | reason: string
9 | }
10 |
11 | const handler = async (req: NextApiRequest, res: NextApiResponse) => {
12 | await runMiddleware(req, res)
13 | const session = await getSession(req, res)
14 | const user = session?.user
15 |
16 | const { body } = req as { body: IBody }
17 | if (user && user.role === 'ADMIN' && user.admin_id === 'rtl-001') {
18 | const keyword = await create(body)
19 |
20 | res.status(keyword.status).json(keyword.message)
21 | } else {
22 | res.status(401).json({ error: 'UNAUTHORIZED' })
23 | }
24 | }
25 |
26 | export default withApiAuthRequired(handler)
27 |
--------------------------------------------------------------------------------
/pages/api/tenant-resources/delete-resource.tsx:
--------------------------------------------------------------------------------
1 | import { deleteResource } from '@/lib/tenant-resource/resource'
2 | import { runMiddleware } from '@/util/cors'
3 | import { NextApiRequest, NextApiResponse } from 'next'
4 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
5 |
6 | interface IBody {
7 | id: number
8 | }
9 |
10 | const deleteResourceAPI = async (req: NextApiRequest, res: NextApiResponse) => {
11 | await runMiddleware(req, res)
12 | const session = await getSession(req, res)
13 | const user = session?.user
14 |
15 | const { body } = req as { body: IBody }
16 |
17 | const id = body.id
18 |
19 | if (user && user.role === 'ADMIN') {
20 | const resource = await deleteResource(id)
21 |
22 | res.status(resource.status).json(resource.message)
23 | } else {
24 | res.status(401).json({ error: 'UNAUTHORIZED' })
25 | }
26 | }
27 |
28 | export default withApiAuthRequired(deleteResourceAPI)
29 |
--------------------------------------------------------------------------------
/util/countries/canada/provinces.json:
--------------------------------------------------------------------------------
1 | [
2 | {"short": "AB", "name": "Alberta", "country": "CA"},
3 | {"short": "BC", "name": "British Columbia", "country": "CA"},
4 | {"short": "MB", "name": "Manitoba", "country": "CA"},
5 | {"short": "NB", "name": "New Brunswick", "country": "CA"},
6 | {
7 | "short": "NL",
8 | "name": "Newfoundland and Labrador",
9 | "country": "CA",
10 | "alt": ["Newfoundland", "Labrador"]
11 | },
12 | {"short": "NS", "name": "Nova Scotia", "country": "CA"},
13 | {"short": "NU", "name": "Nunavut", "country": "CA"},
14 | {"short": "NT", "name": "Northwest Territories", "country": "CA"},
15 | {"short": "ON", "name": "Ontario", "country": "CA"},
16 | {"short": "PE", "name": "Prince Edward Island", "country": "CA"},
17 | {"short": "QC", "name": "Quebec", "country": "CA"},
18 | {"short": "SK", "name": "Saskatchewan", "country": "CA"},
19 | {"short": "YT", "name": "Yukon", "country": "CA"}
20 | ]
21 |
--------------------------------------------------------------------------------
/components/resources/resource-mobile-filters.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render } from '@/test-utils'
6 | import '@testing-library/jest-dom/extend-expect'
7 | import ResourceMobileFilters from './resource-mobile-filters'
8 | import { axe, toHaveNoViolations } from 'jest-axe'
9 | expect.extend(toHaveNoViolations)
10 |
11 | describe('ResourceMobileFilters', () => {
12 | const defaultProps = {
13 | mobileFiltersOpen: true,
14 | setMobileFiltersOpen: jest.fn(),
15 | countryFilter: null,
16 | stateFilter: null,
17 | cityFilter: null,
18 | cityOptions: [],
19 | stateOptions: [],
20 | updateParams: jest.fn(),
21 | }
22 |
23 | it('Should not have a11y violation', async () => {
24 | const { container } = render()
25 | const result = await axe(container)
26 | expect(result).toHaveNoViolations()
27 | })
28 | })
29 |
--------------------------------------------------------------------------------
/util/countries/ca-province-location.json:
--------------------------------------------------------------------------------
1 | {
2 | "ALBERTA": { "latitude": 53.9333, "longitude": -116.5765 },
3 | "BRITISH COLUMBIA": { "latitude": 53.7267, "longitude": -127.6476 },
4 | "MANITOBA": { "latitude": 49.8951, "longitude": -97.1384 },
5 | "NEW BRUNSWICK": { "latitude": 46.5653, "longitude": -66.4619 },
6 | "NEWFOUNDLAND AND LABRADOR": { "latitude": 53.1355, "longitude": -57.6604 },
7 | "NORTHWEST TERRITORIES": { "latitude": 64.8255, "longitude": -124.8457 },
8 | "NOVA SCOTIA": { "latitude": 44.682, "longitude": -63.7443 },
9 | "NUNAVUT": { "latitude": 70.2998, "longitude": -83.1076 },
10 | "ONTARIO": { "latitude": 51.2538, "longitude": -85.3232 },
11 | "PRINCE EDWARD ISLAND": { "latitude": 46.5107, "longitude": -63.4168 },
12 | "QUEBEC": { "latitude": 52.9399, "longitude": -73.5491 },
13 | "SASKATCHEWAN": { "latitude": 52.9399, "longitude": -106.4509 },
14 | "YUKON": { "latitude": 64.2823, "longitude": -135.0 }
15 | }
16 |
--------------------------------------------------------------------------------
/components/admin/sections/FlaggedReviews.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render, screen } from '@/test-utils'
5 | import useSWR from 'swr'
6 | import { axe } from 'jest-axe'
7 | import FlaggedReviews from './FlaggedReviews'
8 |
9 | jest.mock('swr')
10 | jest.mock('@/components/ui/Spinner', () => Loading...
)
11 |
12 | describe('FlaggedReviews', () => {
13 | ;(useSWR as jest.Mock).mockReturnValue({
14 | data: [],
15 | error: null,
16 | })
17 | it('renders', () => {
18 | render()
19 |
20 | expect(screen.getByTestId('flagged-reviews')).toBeInTheDocument()
21 | })
22 |
23 | it('Should not have a11y violation', async () => {
24 | ;(useSWR as jest.Mock).mockReturnValue({
25 | data: [],
26 | error: null,
27 | })
28 | const { container } = render()
29 | const result = await axe(container)
30 | expect(result).toHaveNoViolations()
31 | })
32 | })
33 |
--------------------------------------------------------------------------------
/messages/fr-CA/filters.json:
--------------------------------------------------------------------------------
1 | {
2 | "filters": {
3 | "title": "Filtres",
4 | "no-results": "Aucun résultat trouvé.",
5 | "no-body": "Désolé, aucun résultat n'a été trouvé pour ces filtres.",
6 | "resources": "Rechercher des ressources",
7 | "sort": "Classer par",
8 | "country": "Pays",
9 | "state": "État / Province",
10 | "city": "Ville",
11 | "landlord": "Propriétaire",
12 | "apply": "Appliquer les filtres",
13 | "clear": "Rafraichir les filtres",
14 | "search": "Rechercher",
15 | "loading": "Chargement en cours...",
16 | "search-for": "Rechercher pour",
17 | "search-placeholder": "Recherche votre propriétaire",
18 | "not-found": "Aucun propriétaire trouvé - Soumettez votre premier avis !"
19 | },
20 | "sort": {
21 | "name_az": "Nom A-Z",
22 | "name_za": "Nom Z-A",
23 | "newest": "Le plus récent",
24 | "oldest": "Le plus ancien",
25 | "highest": "Le plus haut",
26 | "lowest": "Le plus bas"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/components/admin/sections/RecentReviews.tsx:
--------------------------------------------------------------------------------
1 | import Spinner from '@/components/ui/Spinner'
2 | import { fetcher } from '@/util/helpers/fetcher'
3 | import { RecentReviews as IRecentReviews } from '@/util/interfaces/interfaces'
4 | import dayjs from 'dayjs'
5 | import useSWR from 'swr'
6 |
7 | const RecentReviews = () => {
8 | const { data, error } = useSWR, unknown>(
9 | '/api/admin/get-recent',
10 | fetcher,
11 | )
12 | if (error) return Error Loading...
13 | if (!data) return
14 | return (
15 |
16 | {data.map((item, i) => (
17 |
21 |
{i + 1}
22 |
{item.landlord}
23 |
{dayjs(item.created_at).format('DD/MM/YYYY HH:mm:ss')}
24 |
25 | ))}
26 |
27 | )
28 | }
29 |
30 | export default RecentReviews
31 |
--------------------------------------------------------------------------------
/pages/api/flagged-keywords/delete-flagged-keyword.tsx:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
4 | import { deleteKeyword } from '@/lib/flagged-keywords/flagged-keywords'
5 |
6 | interface IBody {
7 | id: number
8 | }
9 |
10 | const handler = async (req: NextApiRequest, res: NextApiResponse) => {
11 | await runMiddleware(req, res)
12 | const session = await getSession(req, res)
13 | const user = session?.user
14 |
15 | const { body } = req as { body: IBody }
16 |
17 | const id = body.id
18 |
19 | if (user && user.role === 'ADMIN' && user.admin_id === 'rtl-001') {
20 | const keyword = await deleteKeyword(id)
21 |
22 | res.status(keyword.status).json(keyword.message)
23 | } else {
24 | res.status(401).json({ error: 'UNAUTHORIZED' })
25 | }
26 | }
27 |
28 | export default withApiAuthRequired(handler)
29 |
--------------------------------------------------------------------------------
/pages/api/suspicious-landlords/add-suspicious-landlord.tsx:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
4 | import { create } from '@/lib/suspicious-landlords/suspicious-landlords'
5 |
6 | interface IBody {
7 | landlord: string
8 | message: string
9 | }
10 |
11 | const AddResource = async (req: NextApiRequest, res: NextApiResponse) => {
12 | await runMiddleware(req, res)
13 | const session = await getSession(req, res)
14 | const user = session?.user
15 |
16 | const { body } = req as { body: IBody }
17 | if (user && user.role === 'ADMIN' && user.admin_id === 'rtl-001') {
18 | const resource = await create(body)
19 |
20 | res.status(resource.status).json(resource.message)
21 | } else {
22 | res.status(401).json({ error: 'UNAUTHORIZED' })
23 | }
24 | }
25 |
26 | export default withApiAuthRequired(AddResource)
27 |
--------------------------------------------------------------------------------
/pages/api/user-update/update.ts:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { UserUpdatedReview } from '@/util/interfaces/interfaces'
4 | import rateLimitMiddleware from '@/util/rateLimit'
5 | import { userUpdateReview } from '@/lib/review/models/user-update-review-layer'
6 |
7 | interface Body {
8 | review: UserUpdatedReview
9 | id: number
10 | user_code: string
11 | }
12 |
13 | const UserEditReview = async (req: NextApiRequest, res: NextApiResponse) => {
14 | await runMiddleware(req, res)
15 |
16 | const { body } = req as { body: Body }
17 |
18 | const { id, review, user_code } = body
19 | if (!id || !review || !user_code) {
20 | res.status(400).json({ message: 'Missing Data' })
21 | } else {
22 | const result = await userUpdateReview(id, review, user_code)
23 | res.status(200).json(result)
24 | }
25 | }
26 |
27 | export default rateLimitMiddleware(UserEditReview)
28 |
--------------------------------------------------------------------------------
/components/ui/CloseButton.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, screen, fireEvent } from '@/test-utils'
6 | import CloseButton from './CloseButton'
7 | import { axe } from 'jest-axe'
8 |
9 | describe('CloseButton', () => {
10 | it('renders CloseButton component', () => {
11 | render( {}} />)
12 | expect(screen.getByText('Close')).toBeInTheDocument()
13 | })
14 |
15 | it('calls onClick when button is clicked', () => {
16 | const handleClick = jest.fn()
17 | render()
18 | const button = screen.getByRole('button')
19 | fireEvent.click(button)
20 | expect(handleClick).toHaveBeenCalledTimes(1)
21 | })
22 | it('Should not have a11y violation', async () => {
23 | const { container } = render( {}} />)
24 | const result = await axe(container)
25 | expect(result).toHaveNoViolations()
26 | })
27 | })
28 |
--------------------------------------------------------------------------------
/components/about/moderation.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslations } from 'next-intl'
2 |
3 | const Moderation = () => {
4 | const t = useTranslations('about')
5 | const keys = [
6 | 'moderation.info-1',
7 | 'moderation.info-2',
8 | 'moderation.info-3',
9 | 'moderation.info-4',
10 | 'moderation.info-5',
11 | 'moderation.info-6',
12 | ] as const
13 |
14 | return (
15 |
16 |
17 |
18 | {t('moderation.moderation')}
19 |
20 | {keys.map((p, i) => {
21 | return (
22 |
27 | {t(p)}
28 |
29 | )
30 | })}
31 |
32 |
33 | )
34 | }
35 |
36 | export default Moderation
37 |
--------------------------------------------------------------------------------
/components/city/CityFilters.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render } from '@/test-utils'
6 | import { axe, toHaveNoViolations } from 'jest-axe'
7 | import CityFilters from './CityFilters'
8 | import { Provider } from 'react-redux'
9 | import { store } from '@/redux/store'
10 | expect.extend(toHaveNoViolations)
11 |
12 | describe('CityFilter Component', () => {
13 | it('Should not have a11y violation', async () => {
14 | const { container } = render(
15 |
16 | jest.fn()}
20 | zipFilter={{ id: 1, name: 'test', value: '12345' }}
21 | updateParams={() => jest.fn()}
22 | loading={false}
23 | />
24 | ,
25 | )
26 | const result = await axe(container)
27 | expect(result).toHaveNoViolations()
28 | })
29 | })
30 |
--------------------------------------------------------------------------------
/setupTests.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/extend-expect'
2 | import '@testing-library/jest-dom'
3 | import { toHaveNoViolations } from 'jest-axe'
4 | expect.extend(toHaveNoViolations)
5 |
6 | global.ResizeObserver = jest.fn().mockImplementation(() => ({
7 | observe: jest.fn(),
8 | unobserve: jest.fn(),
9 | disconnect: jest.fn(),
10 | }))
11 |
12 | jest.mock('next-recaptcha-v3', () => ({
13 | // Mock the useReCaptcha hook correctly
14 | useReCaptcha: jest.fn().mockReturnValue({
15 | executeRecaptcha: jest.fn().mockResolvedValue('mock-token'),
16 | resetRecaptcha: jest.fn(),
17 | }),
18 | // If needed, you can also mock ReCaptcha component, but it may not be necessary for your tests
19 | ReCaptcha: () => null,
20 | }))
21 |
22 | jest.mock('next/router', () => ({
23 | useRouter: jest.fn(() => ({
24 | route: '/',
25 | pathname: '/',
26 | query: {},
27 | asPath: '/',
28 | push: jest.fn(),
29 | replace: jest.fn(),
30 | back: jest.fn(),
31 | })),
32 | }))
33 |
--------------------------------------------------------------------------------
/pages/api/suspicious-landlords/delete-suspicious-landlord.tsx:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
4 | import { deleteLandlord } from '@/lib/suspicious-landlords/suspicious-landlords'
5 |
6 | interface IBody {
7 | id: number
8 | }
9 |
10 | const deleteLandlordAPI = async (req: NextApiRequest, res: NextApiResponse) => {
11 | await runMiddleware(req, res)
12 | const session = await getSession(req, res)
13 | const user = session?.user
14 |
15 | const { body } = req as { body: IBody }
16 |
17 | const id = body.id
18 |
19 | if (user && user.role === 'ADMIN' && user.admin_id === 'rtl-001') {
20 | const landlord = await deleteLandlord(id)
21 |
22 | res.status(landlord.status).json(landlord.message)
23 | } else {
24 | res.status(401).json({ error: 'UNAUTHORIZED' })
25 | }
26 | }
27 |
28 | export default withApiAuthRequired(deleteLandlordAPI)
29 |
--------------------------------------------------------------------------------
/components/admin/components/StateStats.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render, screen } from '@/test-utils'
5 | import StateStats from './StateStats'
6 | import { axe } from 'jest-axe'
7 |
8 | describe('StateStats', () => {
9 | const states = [
10 | { key: 'State 1', total: '100' },
11 | { key: 'State 2', total: '200' },
12 | { key: 'State 3', total: '300' },
13 | ]
14 | it('should render state stats correctly', () => {
15 | render()
16 |
17 | states.forEach((state) => {
18 | const stateName = screen.queryByText(state.key)
19 | const stateTotal = screen.queryByText(state.total)
20 |
21 | expect(stateName).toBeInTheDocument()
22 | expect(stateTotal).toBeInTheDocument()
23 | })
24 | })
25 | it('Should not have a11y violation', async () => {
26 | const { container } = render()
27 | const result = await axe(container)
28 | expect(result).toHaveNoViolations()
29 | })
30 | })
31 |
--------------------------------------------------------------------------------
/components/about/contact.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, screen } from '@/test-utils'
6 | import Contact from './contact'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | expect.extend(toHaveNoViolations)
9 |
10 | test('renders contact section with title and email', () => {
11 | render()
12 | const contactSection = screen.getByTestId('about-contact-1')
13 | expect(contactSection).toBeInTheDocument()
14 |
15 | expect(screen.getByText('about.contact.title')).toBeInTheDocument()
16 |
17 | const emailLink = contactSection.querySelector(
18 | "a[href='mailto:contact@ratethelandlord.org']",
19 | )
20 | expect(emailLink).toBeInTheDocument()
21 | expect(emailLink).toHaveTextContent('about.contact.email')
22 | })
23 |
24 | it('Should not have a11y violation', async () => {
25 | const { container } = render()
26 | const result = await axe(container)
27 | expect(result).toHaveNoViolations()
28 | })
29 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
--------------------------------------------------------------------------------
/components/svg/icons/Solidarity.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function Solidarity({ styling }: { styling: string }) {
4 | return (
5 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
3 | import js from '@eslint/js'
4 | import tseslint from 'typescript-eslint'
5 | import nextPlugin from '@next/eslint-plugin-next'
6 | import prettierConfigRecommended from 'eslint-plugin-prettier/recommended'
7 |
8 | export default tseslint.config({
9 | extends: [
10 | js.configs.recommended,
11 | tseslint.configs.recommendedTypeChecked,
12 | prettierConfigRecommended,
13 | ],
14 | plugins: {
15 | '@next/next': nextPlugin,
16 | },
17 | rules: {
18 | ...nextPlugin.configs['core-web-vitals'].rules,
19 | },
20 | ignores: [
21 | 'node_modules/*',
22 | 'dist',
23 | 'coverage',
24 | '.next/*',
25 | 'out/*',
26 | '*.json',
27 | '*.lock',
28 | '*.css',
29 | '*.scss',
30 | 'next-env.d.ts',
31 | 'util/countries',
32 | 'migrations/*',
33 | ],
34 | languageOptions: {
35 | parserOptions: {
36 | projectService: true,
37 | },
38 | },
39 | })
40 |
--------------------------------------------------------------------------------
/pages/api/tenant-resources/add-resource.tsx:
--------------------------------------------------------------------------------
1 | import { create } from '@/lib/tenant-resource/resource'
2 | import { runMiddleware } from '@/util/cors'
3 | import { NextApiRequest, NextApiResponse } from 'next'
4 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
5 |
6 | interface IBody {
7 | name: string
8 | country_code: string
9 | city: string
10 | state: string
11 | address?: string
12 | phone_number?: string
13 | description: string
14 | href: string
15 | }
16 |
17 | const AddResource = async (req: NextApiRequest, res: NextApiResponse) => {
18 | await runMiddleware(req, res)
19 | const session = await getSession(req, res)
20 | const user = session?.user
21 |
22 | const { body } = req as { body: IBody }
23 | if (user && user.role === 'ADMIN') {
24 | const resource = await create(body)
25 |
26 | res.status(resource.status).json(resource.message)
27 | } else {
28 | res.status(401).json({ error: 'UNAUTHORIZED' })
29 | }
30 | }
31 |
32 | export default withApiAuthRequired(AddResource)
33 |
--------------------------------------------------------------------------------
/components/poster/Poster.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 |
3 | const Poster = () => {
4 | return (
5 |
28 | )
29 | }
30 |
31 | export default Poster
32 |
--------------------------------------------------------------------------------
/migrations/000-review.ts:
--------------------------------------------------------------------------------
1 | exports.up = async function (DB) {
2 | const tableExists = await DB`
3 | SELECT EXISTS (
4 | SELECT 1
5 | FROM information_schema.tables
6 | WHERE table_name = 'review'
7 | )`
8 | if (!tableExists[0].exists) {
9 | await DB`
10 | CREATE TABLE review (
11 | id SERIAL PRIMARY KEY,
12 | landlord TEXT,
13 | country_code VARCHAR(2),
14 | city TEXT,
15 | state TEXT,
16 | zip TEXT,
17 | review TEXT,
18 | repair numeric CHECK (repair >= 1 AND repair <= 5),
19 | health numeric CHECK (health >= 1 AND health <= 5),
20 | stability numeric CHECK (stability >= 1 AND stability <= 5),
21 | privacy numeric CHECK (privacy >= 1 AND privacy <= 5),
22 | respect numeric CHECK (respect >= 1 AND respect <= 5),
23 | date_added TIMESTAMP DEFAULT now(),
24 | flagged BOOLEAN,
25 | flagged_reason TEXT,
26 | admin_approved BOOLEAN,
27 | admin_edited BOOLEAN
28 | );
29 | `
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/pages/api/suspicious-landlords/edit-suspicious-landlord.tsx:
--------------------------------------------------------------------------------
1 | import { runMiddleware } from '@/util/cors'
2 | import { NextApiRequest, NextApiResponse } from 'next'
3 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
4 | import { update } from '@/lib/suspicious-landlords/suspicious-landlords'
5 |
6 | interface IBody {
7 | id: number
8 | landlord: string
9 | message: string
10 | date_added?: Date // auto-generated by db
11 | }
12 |
13 | const EditSuspiciousLandlordAPI = async (
14 | req: NextApiRequest,
15 | res: NextApiResponse,
16 | ) => {
17 | const session = await getSession(req, res)
18 | const user = session?.user
19 | await runMiddleware(req, res)
20 |
21 | const { body } = req as { body: IBody }
22 |
23 | if (user && user.role === 'ADMIN' && user.admin_id === 'rtl-001') {
24 | const edit = await update(body.id, body)
25 | res.status(edit.status).json(edit.message)
26 | } else {
27 | res.status(401).json({ error: 'UNAUTHORIZED' })
28 | }
29 | }
30 |
31 | export default withApiAuthRequired(EditSuspiciousLandlordAPI)
32 |
--------------------------------------------------------------------------------
/components/reviews/read-reviews.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render } from '@/test-utils'
6 | import '@testing-library/jest-dom/extend-expect'
7 | import { Provider } from 'react-redux'
8 | import ReviewForm from './read-reviews'
9 | import { axe, toHaveNoViolations } from 'jest-axe'
10 | import { store } from '@/redux/store'
11 | expect.extend(toHaveNoViolations)
12 |
13 | jest.mock('./review', () => {
14 | return {
15 | __esModule: true,
16 | default: () => Review
,
17 | }
18 | })
19 |
20 | jest.mock('next/router', () => ({
21 | useRouter() {
22 | return {
23 | route: '/',
24 | pathname: '',
25 | query: '',
26 | asPath: '',
27 | }
28 | },
29 | }))
30 |
31 | describe('ReviewForm', () => {
32 | it('Should not have a11y violation', async () => {
33 | const { container } = render(
34 |
35 |
36 | ,
37 | )
38 | const result = await axe(container)
39 | expect(result).toHaveNoViolations()
40 | })
41 | })
42 |
--------------------------------------------------------------------------------
/pages/api/tenant-resources/edit-resource.tsx:
--------------------------------------------------------------------------------
1 | import { update } from '@/lib/tenant-resource/resource'
2 | import { runMiddleware } from '@/util/cors'
3 | import { NextApiRequest, NextApiResponse } from 'next'
4 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0'
5 |
6 | interface IBody {
7 | id: number
8 | name: string
9 | country_code: string
10 | city: string
11 | state: string
12 | address?: string
13 | phone_number?: string
14 | date_added?: Date // auto-generated by db
15 | description: string
16 | href: string
17 | }
18 |
19 | const EditResourceAPI = async (req: NextApiRequest, res: NextApiResponse) => {
20 | const session = await getSession(req, res)
21 | const user = session?.user
22 | await runMiddleware(req, res)
23 |
24 | const { body } = req as { body: IBody }
25 |
26 | if (user && user.role === 'ADMIN') {
27 | const edit = await update(body.id, body)
28 | res.status(edit.status).json(edit.message)
29 | } else {
30 | res.status(401).json({ error: 'UNAUTHORIZED' })
31 | }
32 | }
33 |
34 | export default withApiAuthRequired(EditResourceAPI)
35 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/util/countries/germany/states.json:
--------------------------------------------------------------------------------
1 | [
2 | { "short": "BW", "name": "Baden-Württemberg", "country": "DE" },
3 | { "short": "BA", "name": "Bavaria", "country": "DE" },
4 | { "short": "BE", "name": "Berlin", "country": "DE" },
5 | { "short": "BR", "name": "Brandenburg", "country": "DE" },
6 | {
7 | "short": "BRE",
8 | "name": "Bremen",
9 | "country": "DE"
10 | },
11 | { "short": "HA", "name": "Hamburg", "country": "DE" },
12 | { "short": "HE", "name": "Hesse", "country": "DE" },
13 | { "short": "MV", "name": "Mecklenburg-Vorpommern", "country": "DE" },
14 | { "short": "LS", "name": "Lower Saxony", "country": "DE" },
15 | { "short": "NRW", "name": "North Rhine-Westphalia", "country": "DE" },
16 | { "short": "RP", "name": "Rhineland-Palatinate", "country": "DE" },
17 | { "short": "SA", "name": "Saarland", "country": "DE" },
18 | { "short": "SX", "name": "Saxony", "country": "DE" },
19 | { "short": "SXA", "name": "Saxony-Anhalt", "country": "DE" },
20 | { "short": "SH", "name": "Schleswig-Holstein", "country": "DE" },
21 | { "short": "TH", "name": "Thuringia", "country": "DE" }
22 | ]
23 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { classNames } from '@/util/helpers/helper-functions'
2 | import React from 'react'
3 |
4 | interface IProps {
5 | children: string
6 | disabled?: boolean
7 | onClick?: () => void
8 | size?: 'small' | 'medium' | 'large'
9 | }
10 |
11 | function Button({
12 | children,
13 | disabled = false,
14 | onClick,
15 | size = 'small',
16 | }: IProps): JSX.Element {
17 | return (
18 |
34 | )
35 | }
36 |
37 | export default Button
38 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["./*"]
6 | },
7 | "target": "ES2022",
8 | "lib": ["dom", "dom.iterable", "esnext"],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "noImplicitAny": true,
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "jsx": "preserve",
21 | "incremental": true,
22 | "plugins": [
23 | {
24 | "name": "next"
25 | }
26 | ]
27 | },
28 | "include": [
29 | "next-env.d.ts",
30 | "**/*.ts",
31 | "**/*.tsx",
32 | "**/*.js",
33 | "next-i18next.config.js",
34 | "next.config.js",
35 | "tailwind.config.mjs",
36 | "postcss.config.mjs",
37 | "prettier.config.mjs",
38 | "eslint.config.mjs"
39 | ],
40 | "exclude": [
41 | "**/node_modules",
42 | "**/.next",
43 | "**/.vercel",
44 | "**/out",
45 | "**/migrations/",
46 | "**/util/countries",
47 | ".next"
48 | ]
49 | }
50 |
--------------------------------------------------------------------------------
/components/landlord/LandlordBanner.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import React from 'react'
6 | import { render, screen } from '@/test-utils'
7 | import '@testing-library/jest-dom/extend-expect'
8 | import { axe, toHaveNoViolations } from 'jest-axe'
9 | import { SuspiciousLandlord } from '@/util/interfaces/interfaces'
10 | import LandlordBanner from './LandlordBanner'
11 | expect.extend(toHaveNoViolations)
12 |
13 | describe('LandlordBanner', () => {
14 | const landlord: SuspiciousLandlord = {
15 | landlord: 'Test Landlord',
16 | message: 'This is a suspicious landlord message',
17 | }
18 | it('renders the landlord message', () => {
19 | render()
20 |
21 | const messageElement = screen.getByText(
22 | 'This is a suspicious landlord message',
23 | )
24 | expect(messageElement).toBeInTheDocument()
25 | })
26 | it('Should not have a11y violation', async () => {
27 | const { container } = render()
28 | const result = await axe(container)
29 | expect(result).toHaveNoViolations()
30 | })
31 | })
32 |
--------------------------------------------------------------------------------
/components/about/moderation.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import React from 'react'
6 | import { render, screen } from '@/test-utils'
7 | import Moderation from './moderation'
8 | import { axe, toHaveNoViolations } from 'jest-axe'
9 | expect.extend(toHaveNoViolations)
10 |
11 | test('renders moderation section with title and info paragraphs', () => {
12 | render()
13 | const moderationSection = screen.getByTestId('about-moderation-1')
14 | expect(moderationSection).toBeInTheDocument()
15 |
16 | expect(screen.getByText('about.moderation.moderation')).toBeInTheDocument()
17 |
18 | const infoParagraphs = moderationSection.querySelectorAll(
19 | "p[role='paragraph']",
20 | )
21 | expect(infoParagraphs).toHaveLength(6)
22 | expect(infoParagraphs[0]).toHaveTextContent('about.moderation.info-1')
23 | expect(infoParagraphs[1]).toHaveTextContent('about.moderation.info-2')
24 | })
25 |
26 | it('Should not have a11y violation', async () => {
27 | const { container } = render()
28 | const result = await axe(container)
29 | expect(result).toHaveNoViolations()
30 | })
31 |
--------------------------------------------------------------------------------
/components/zip/ZipInfo.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render, screen } from '@/test-utils'
5 | import '@testing-library/jest-dom'
6 | import ZipInfo from './ZipInfo'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | expect.extend(toHaveNoViolations)
9 |
10 | describe('ZipInfo Component', () => {
11 | const mockProps = {
12 | city: 'Test City',
13 | state: 'Test State',
14 | country: 'Test Country',
15 | average: 4.5,
16 | total: 100,
17 | averages: {
18 | avg_repair: 4.0,
19 | avg_health: 4.2,
20 | avg_stability: 4.3,
21 | avg_privacy: 4.1,
22 | avg_respect: 4.4,
23 | },
24 | zip: '12345',
25 | }
26 |
27 | it('renders the ZipInfo component with correct data', () => {
28 | render()
29 |
30 | expect(
31 | screen.getByText('12345, Test State, TEST COUNTRY'),
32 | ).toBeInTheDocument()
33 | })
34 |
35 | it('Should not have a11y violation', async () => {
36 | const { container } = render()
37 | const result = await axe(container)
38 | expect(result).toHaveNoViolations()
39 | })
40 | })
41 |
--------------------------------------------------------------------------------
/messages/fr-CA/resources.json:
--------------------------------------------------------------------------------
1 | {
2 | "resources": {
3 | "title": "Ressources",
4 | "description": "Besoin d'aide ? Envisagez rejoindre votre syndicat local de locataires, votre comité logement ou votre association de locataires !",
5 | "info-1": "Les syndicats de locataires, les comités logement, les associations de locataires et les groupes de défense jouent un rôle important en donnant aux locataires les moyens de s'informer, de s'entraider et d'accéder aux ressources disponibles pour mieux comprendre leurs droits et responsabilités. En rejoignant un groupe de défense des droits des locataires, vous pouvez entrer en contact avec d'autres membres de votre communauté et travailler ensemble pour faire valoir vos droits.",
6 | "info-2": "Pour rejoindre un groupe de défense des droits des locataires, commencez par rechercher des organisations locales dans votre état, province, région ou territoire.",
7 | "contribute": "Si vous avez une ressource qui, selon vous, devrait figurer sur notre site, envoyez-bous un courriel à contact@ratethelandlord.org",
8 | "address": "Adresse",
9 | "phone": "Numéro de téléphone"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/components/ui/ChangeLanguage.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render, screen } from '@/test-utils'
5 | import '@testing-library/jest-dom'
6 | import ChangeLanguage from './ChangeLanguage'
7 | import { useRouter } from 'next/router'
8 | import { axe, toHaveNoViolations } from 'jest-axe'
9 | expect.extend(toHaveNoViolations)
10 |
11 | jest.mock('next/router', () => ({
12 | useRouter: jest.fn(),
13 | }))
14 |
15 | describe('ChangeLanguage component', () => {
16 | const mockPush = jest.fn()
17 | const mockRouter = {
18 | locale: 'en-CA',
19 | locales: ['en-CA', 'fr-CA'],
20 | asPath: '/',
21 | push: mockPush,
22 | }
23 |
24 | beforeEach(() => {
25 | ;(useRouter as jest.Mock).mockReturnValue(mockRouter)
26 | })
27 |
28 | it('renders correctly with initial locale', () => {
29 | render()
30 | expect(screen.getByText('English')).toBeInTheDocument()
31 | })
32 |
33 | it('Should not have a11y violation', async () => {
34 | const { container } = render()
35 | const result = await axe(container)
36 | expect(result).toHaveNoViolations()
37 | })
38 | })
39 |
--------------------------------------------------------------------------------
/components/ui/button-light.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import React from 'react'
6 | import { render, fireEvent } from '@/test-utils'
7 | import ButtonLight from './button-light'
8 | import { axe } from 'jest-axe'
9 |
10 | describe('ButtonLight', () => {
11 | it('renders button text correctly', () => {
12 | const buttonText = 'Click me'
13 | const { getByText } = render({buttonText})
14 | const buttonElement = getByText(buttonText)
15 | expect(buttonElement).toBeInTheDocument()
16 | })
17 |
18 | it('calls onClick handler when clicked', () => {
19 | const onClickMock = jest.fn()
20 | const { getByTestId } = render(
21 | Click me,
22 | )
23 | const buttonElement = getByTestId('light-button')
24 | fireEvent.click(buttonElement)
25 | expect(onClickMock).toHaveBeenCalled()
26 | })
27 | it('Should not have a11y violation', async () => {
28 | const { container } = render(Click me)
29 | const result = await axe(container)
30 | expect(result).toHaveNoViolations()
31 | })
32 | })
33 |
--------------------------------------------------------------------------------
/util/rateLimit.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
2 |
3 | import { NextApiRequest, NextApiResponse } from 'next'
4 |
5 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
6 | const rateLimitMap = new Map()
7 |
8 | export default function rateLimitMiddleware(
9 | handler: (req: NextApiRequest, res: NextApiResponse) => Promise,
10 | ) {
11 | return (req: NextApiRequest, res: NextApiResponse) => {
12 | const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress
13 | const limit = 20 // Limiting requests to 5 per minute per IP
14 | const windowMs = 60 * 1000 //1 minute
15 |
16 | if (!rateLimitMap.has(ip)) {
17 | rateLimitMap.set(ip, {
18 | count: 0,
19 | lastReset: Date.now(),
20 | })
21 | }
22 |
23 | const ipData = rateLimitMap.get(ip)
24 |
25 | if (Date.now() - ipData.lastReset > windowMs) {
26 | ipData.count = 0
27 | ipData.lastReset = Date.now()
28 | }
29 |
30 | if (ipData.count >= limit) {
31 | return res.status(429).send('Too Many Requests')
32 | }
33 |
34 | ipData.count += 1
35 |
36 | return handler(req, res)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/components/adsense/Adsense.tsx:
--------------------------------------------------------------------------------
1 | import AdSense from 'react-adsense'
2 |
3 | const isProd = process.env.NEXT_PUBLIC_ENVIRONMENT === 'production'
4 |
5 | interface IProps {
6 | slot: string
7 | format?: string
8 | layout?: string
9 | layoutKey?: string
10 | }
11 |
12 | const AdsComponent = ({
13 | slot,
14 | format = 'horizontal,auto',
15 | layout = '',
16 | layoutKey = '',
17 | }: IProps) => {
18 | if (isProd) {
19 | return (
20 |
32 | )
33 | } else {
34 | // For development environment, don't render the ad unit
35 | return (
36 |
37 | AD
38 |
39 | )
40 | }
41 | }
42 |
43 | export default AdsComponent
44 |
--------------------------------------------------------------------------------
/util/reviewRateLimit.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
2 |
3 | import { NextApiRequest, NextApiResponse } from 'next'
4 |
5 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
6 | const rateLimitMap = new Map()
7 |
8 | export default function ReviewRateLimitMiddleware(
9 | handler: (req: NextApiRequest, res: NextApiResponse) => Promise,
10 | ) {
11 | return (req: NextApiRequest, res: NextApiResponse) => {
12 | const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress
13 | const limit = 2 // Limiting requests to 5 per minute per IP
14 | const windowMs = 604800000 //1 week
15 |
16 | if (!rateLimitMap.has(ip)) {
17 | rateLimitMap.set(ip, {
18 | count: 0,
19 | lastReset: Date.now(),
20 | })
21 | }
22 |
23 | const ipData = rateLimitMap.get(ip)
24 |
25 | if (Date.now() - ipData.lastReset > windowMs) {
26 | ipData.count = 0
27 | ipData.lastReset = Date.now()
28 | }
29 |
30 | if (ipData.count >= limit) {
31 | return res.status(429).send('Too Many Requests')
32 | }
33 |
34 | ipData.count += 1
35 |
36 | return handler(req, res)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/lib/review/types/review.ts:
--------------------------------------------------------------------------------
1 | import { Options, UserReview } from '@/util/interfaces/interfaces'
2 |
3 | export interface OtherLandlord {
4 | name: string
5 | avgrating: number
6 | topCity: string
7 | reviewcount: number
8 | }
9 |
10 | export interface FilterOptions {
11 | countries: Options[]
12 | cities: Options[]
13 | zips: Options[]
14 | }
15 |
16 | export interface ICityReviews {
17 | reviews: UserReview[]
18 | average: number
19 | total: number
20 | catAverages: {
21 | avg_repair: number
22 | avg_health: number
23 | avg_stability: number
24 | avg_privacy: number
25 | avg_respect: number
26 | }
27 | zips: string[]
28 | }
29 |
30 | export interface IZipReviews {
31 | reviews: UserReview[]
32 | average: number
33 | total: number
34 | catAverages: {
35 | avg_repair: number
36 | avg_health: number
37 | avg_stability: number
38 | avg_privacy: number
39 | avg_respect: number
40 | }
41 | }
42 | export interface IZipStats {
43 | average: number
44 | total: number
45 | catAverages: {
46 | avg_repair: number
47 | avg_health: number
48 | avg_stability: number
49 | avg_privacy: number
50 | avg_respect: number
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/components/ui/link-button-lg.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { fireEvent, render } from '@/test-utils'
6 | import LinkButtonLG from './link-button-lg'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | expect.extend(toHaveNoViolations)
9 |
10 | describe('LinkButtonLG', () => {
11 | it('renders button text correctly', () => {
12 | const buttonText = 'Click me'
13 | const { getByText } = render(
14 | {buttonText},
15 | )
16 | const buttonElement = getByText(buttonText)
17 | expect(buttonElement).toBeInTheDocument()
18 | })
19 |
20 | it('navigates to the correct URL when clicked', () => {
21 | const href = '/about'
22 | const { getByTestId } = render(
23 | Click Me,
24 | )
25 | const buttonElement = getByTestId('home-hero-submit-btn-1')
26 | fireEvent.click(buttonElement)
27 | })
28 | it('Should not have a11y violation', async () => {
29 | const { container } = render(Test)
30 | const result = await axe(container)
31 | expect(result).toHaveNoViolations()
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/util/hooks/useLandlordSuggestions.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { useDebounce } from './useDebounce'
3 |
4 | export const useLandlordSuggestions = (landlord: string) => {
5 | const [landlordSuggestions, setLandlordSuggestions] = useState([])
6 | const [isSearching, setIsSearching] = useState(false)
7 |
8 | const debouncedSearchString = useDebounce(landlord, 500)
9 |
10 | useEffect(() => {
11 | if (landlord) {
12 | setIsSearching(true)
13 | const fetchData = () => {
14 | fetch('/api/review/get-landlord-suggestions', {
15 | method: 'POST',
16 | headers: {
17 | 'Content-Type': 'application/json',
18 | },
19 | body: JSON.stringify({ input: landlord }),
20 | })
21 | .then((res) => {
22 | if (!res.ok) {
23 | throw new Error()
24 | }
25 | return res.json()
26 | })
27 | .then((data: string[]) => {
28 | setLandlordSuggestions(data)
29 | })
30 | .catch((err) => console.log(err))
31 | .finally(() => setIsSearching(false))
32 | }
33 | void fetchData()
34 | }
35 | }, [debouncedSearchString, landlord])
36 |
37 | return { isSearching, landlordSuggestions }
38 | }
39 |
--------------------------------------------------------------------------------
/lib/suspicious-landlords/models/suspicious-landlord-data-layer.ts:
--------------------------------------------------------------------------------
1 | import sql from '@/lib/db'
2 | import { SuspiciousLandlord } from '@/util/interfaces/interfaces'
3 |
4 | export async function createLandlord(
5 | suspiciousLandlord: SuspiciousLandlord,
6 | ): Promise {
7 | try {
8 | suspiciousLandlord.landlord = suspiciousLandlord.landlord
9 | .substring(0, 150)
10 | .toLocaleUpperCase()
11 |
12 | const id = await sql<{ id: number }[]>`
13 | INSERT INTO spam_landlords
14 | (landlord, message)
15 | VALUES
16 | (${suspiciousLandlord.landlord}, ${suspiciousLandlord.message}) RETURNING id;
17 | `
18 |
19 | suspiciousLandlord.id = id[0].id
20 |
21 | return suspiciousLandlord
22 | } catch {
23 | console.error('Failed to create Suspicious Landlord')
24 | }
25 | }
26 |
27 | export async function updateLandlord(
28 | id: number,
29 | suspiciousLandlord: SuspiciousLandlord,
30 | ): Promise {
31 | await sql`
32 | UPDATE spam_landlords
33 | SET landlord = ${suspiciousLandlord.landlord},
34 | message = ${suspiciousLandlord.message}
35 | WHERE id = ${id};
36 | `
37 | return suspiciousLandlord
38 | }
39 |
--------------------------------------------------------------------------------
/components/layout/MobileNav.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render } from '@/test-utils'
6 | import { Disclosure } from '@headlessui/react'
7 | import MobileNav from './MobileNav'
8 | import { INav } from '@/util/interfaces/interfaces'
9 | import { axe, toHaveNoViolations } from 'jest-axe'
10 | expect.extend(toHaveNoViolations)
11 |
12 | describe('MobileNav', () => {
13 | const navigation: INav[] = [
14 | {
15 | href: '/reviews',
16 | name: 'layout.nav.reviews',
17 | },
18 | {
19 | href: '/resources',
20 | name: 'layout.nav.resources',
21 | },
22 | {
23 | href: '/about',
24 | name: 'layout.nav.about',
25 | },
26 | ]
27 |
28 | it('renders MobileNav component correctly', () => {
29 | render(
30 |
31 |
32 | ,
33 | )
34 | })
35 | it('Should not have a11y violation', async () => {
36 | const { container } = render(
37 |
38 |
39 | ,
40 | )
41 | const result = await axe(container)
42 | expect(result).toHaveNoViolations()
43 | })
44 | })
45 |
--------------------------------------------------------------------------------
/tailwind.config.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-require-imports */
2 | /* eslint-disable no-undef */
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | content: [
6 | './app/**/*.{js,ts,jsx,tsx,mdx}',
7 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
8 | './components/**/*.{js,ts,jsx,tsx,mdx}',
9 |
10 | // Or if using `src` directory:
11 | './src/**/*.{js,ts,jsx,tsx,mdx}',
12 | ],
13 | theme: {
14 | extend: {
15 | height: {
16 | 128: '41rem', // Adds a custom height of 32rem (512px)
17 | },
18 | fontFamily: {
19 | 'montserrat-regular': ['Montserrat-Regular', 'sans-serif'],
20 | 'montserrat-medium': ['Montserrat-Medium', 'sans-serif'],
21 | 'montserrat-bold': ['Montserrat-Bold', 'sans-serif'],
22 | 'montserrat-extra-bold': ['Montserrat-Extra-Bold', 'sans-serif'],
23 | },
24 | animation: {
25 | 'fade-in': 'fadeIn 4s ease-in',
26 | },
27 | keyframes: {
28 | fadeIn: {
29 | '0%': { opacity: '0' },
30 | '100%': { opacity: '1' },
31 | },
32 | },
33 | },
34 | },
35 | plugins: [
36 | require('@tailwindcss/typography'),
37 | require('@tailwindcss/forms'),
38 | require('@tailwindcss/aspect-ratio'),
39 | ],
40 | }
41 |
--------------------------------------------------------------------------------
/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import LinkButtonLG from '@/components/ui/link-button-lg'
2 |
3 | export default function Custom404() {
4 | return (
5 |
6 |
7 |
404
8 |
9 | Page not found
10 |
11 |
12 | Sorry, we couldn't find the page you're looking for.
13 |
14 |
15 | Go back home
16 |
17 |
18 |
19 | )
20 | }
21 |
22 | export async function getStaticProps({ locale }: { locale: string }) {
23 | const layoutMessages = (await import(
24 | `@/messages/${locale}/layout.json`
25 | )) as Record
26 | const alertsMessages = (await import(
27 | `@/messages/${locale}/alerts.json`
28 | )) as Record
29 |
30 | return {
31 | props: {
32 | messages: {
33 | ...layoutMessages,
34 | ...alertsMessages,
35 | },
36 | },
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/components/ui/StateSelector.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, screen } from '@/test-utils'
6 | import '@testing-library/jest-dom/extend-expect'
7 | import StateSelector from './StateSelector'
8 | import { axe, toHaveNoViolations } from 'jest-axe'
9 | expect.extend(toHaveNoViolations)
10 |
11 | describe('StateSelector Component', () => {
12 | const mockProps = {
13 | country: 'CA', // Replace with your desired country code
14 | setValue: jest.fn(),
15 | }
16 |
17 | test('renders StateSelector component for Canada', () => {
18 | render(
19 | ,
24 | )
25 | const selectElement = screen.getByTestId('state-selector')
26 |
27 | // Ensure that the select element is rendered
28 | expect(selectElement).toBeInTheDocument()
29 | })
30 |
31 | it('Should not have a11y violation', async () => {
32 | const { container } = render(
33 | ,
38 | )
39 | const result = await axe(container)
40 | expect(result).toHaveNoViolations()
41 | })
42 | })
43 |
--------------------------------------------------------------------------------
/components/ui/link-button-light-lg.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { fireEvent, render } from '@/test-utils'
6 | import LinkButtonLightLG from './link-button-light-lg'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | expect.extend(toHaveNoViolations)
9 |
10 | describe('LinkButtonLightLG', () => {
11 | it('renders button text correctly', () => {
12 | const buttonText = 'Click me'
13 | const { getByText } = render(
14 | {buttonText},
15 | )
16 | const buttonElement = getByText(buttonText)
17 | expect(buttonElement).toBeInTheDocument()
18 | })
19 |
20 | it('navigates to the correct URL when clicked', () => {
21 | const href = '/about'
22 | const { getByTestId } = render(
23 | Click Me,
24 | )
25 | const buttonElement = getByTestId('home-hero-read-btn-1')
26 | fireEvent.click(buttonElement)
27 | })
28 |
29 | it('Should not have a11y violation', async () => {
30 | const { container } = render(
31 | Test,
32 | )
33 | const result = await axe(container)
34 | expect(result).toHaveNoViolations()
35 | })
36 | })
37 |
--------------------------------------------------------------------------------
/components/create-review/ratings-radio.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, fireEvent } from '@/test-utils'
6 | import RatingsRadio from './ratings-radio'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | expect.extend(toHaveNoViolations)
9 |
10 | describe('RatingsRadio component', () => {
11 | const setRatingMock = jest.fn()
12 | const title = 'Rating Title'
13 | const rating = 3
14 | const tooltip = 'Tooltip text'
15 | test('should handle rating changes correctly', () => {
16 | const { getByText } = render(
17 | ,
24 | )
25 |
26 | fireEvent.click(getByText('4'))
27 |
28 | expect(setRatingMock).toHaveBeenCalledWith(4)
29 | })
30 | it('Should not have a11y violation', async () => {
31 | const { container } = render(
32 | ,
39 | )
40 | const result = await axe(container)
41 | expect(result).toHaveNoViolations()
42 | })
43 | })
44 |
--------------------------------------------------------------------------------
/test-utils.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render } from '@testing-library/react'
5 | import { ReactNode } from 'react'
6 | import { Provider } from 'react-redux'
7 | import { store } from './redux/store'
8 | import { UserProvider } from '@auth0/nextjs-auth0/client'
9 | import { NextIntlClientProvider } from 'next-intl'
10 |
11 | interface AllProvidersProps {
12 | children: ReactNode
13 | messages?: Record // Optional messages for translations
14 | }
15 |
16 | const AllProviders = ({ children, messages }: AllProvidersProps) => {
17 | const locale = 'en-CA'
18 |
19 | return (
20 |
21 |
22 | {children}
23 |
24 |
25 | )
26 | }
27 |
28 | // Custom render function that allows passing `messages` for translations
29 | const customRender = (
30 | ui: React.ReactElement,
31 | { messages, ...options }: { messages?: Record } = {},
32 | ) =>
33 | render(ui, {
34 | wrapper: (props) => ,
35 | ...options,
36 | })
37 |
38 | export * from '@testing-library/react'
39 | export { customRender as render }
40 |
--------------------------------------------------------------------------------
/components/reviews/components/Hero.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render, screen } from '@/test-utils'
5 | import '@testing-library/jest-dom'
6 | import Hero from './Hero'
7 | import { Options } from '@/util/interfaces/interfaces'
8 | import { axe, toHaveNoViolations } from 'jest-axe'
9 | expect.extend(toHaveNoViolations)
10 |
11 | describe('Hero Component', () => {
12 | const countryFilter: Options = { value: 'US', id: 1, name: 'United States' }
13 | const stateFilter: Options = { value: 'CA', id: 2, name: 'California' }
14 |
15 | it('renders without crashing', () => {
16 | render()
17 | expect(screen.getByTestId('submit-button-1')).toBeInTheDocument()
18 | })
19 |
20 | it('enables the button if both country and state are selected', () => {
21 | render()
22 | const button = screen.getByTestId('submit-button-1')
23 | expect(button).toBeEnabled()
24 | })
25 |
26 | it('Should not have a11y violation', async () => {
27 | const { container } = render(
28 | ,
29 | )
30 | const result = await axe(container)
31 | expect(result).toHaveNoViolations()
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/components/layout/navbar.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, screen } from '@/test-utils'
6 | import Navbar from './navbar'
7 | import { Provider } from 'react-redux'
8 | import { store } from '@/redux/store'
9 | import { UserProvider } from '@auth0/nextjs-auth0/client'
10 | import { axe, toHaveNoViolations } from 'jest-axe'
11 | expect.extend(toHaveNoViolations)
12 |
13 | jest.mock('next/router', () => ({
14 | useRouter: () => ({
15 | pathname: '/',
16 | }),
17 | }))
18 |
19 | jest.mock('@/redux/hooks', () => ({
20 | useAppSelector: jest.fn(),
21 | useAppDispatch: jest.fn(),
22 | }))
23 |
24 | describe('Navbar', () => {
25 | test('renders Navbar component correctly', () => {
26 | // Mock the user object with jwt property
27 |
28 | render()
29 |
30 | // Check if the Navbar title is rendered
31 | const titleElement = screen.getByText('layout.nav.title')
32 | expect(titleElement).toBeInTheDocument()
33 | })
34 | it('Should not have a11y violation', async () => {
35 | const { container } = render(
36 |
37 |
38 |
39 |
40 | ,
41 | )
42 | const result = await axe(container)
43 | expect(result).toHaveNoViolations()
44 | })
45 | })
46 |
--------------------------------------------------------------------------------
/components/about/revenue.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import Revenue from './revenue'
6 | import { axe, toHaveNoViolations } from 'jest-axe'
7 | import { render, RenderResult } from '@/test-utils'
8 | expect.extend(toHaveNoViolations)
9 |
10 | describe('Revenue', () => {
11 | let renderResult: RenderResult
12 |
13 | beforeEach(() => {
14 | renderResult = render()
15 | })
16 |
17 | test('renders the component with translated content', () => {
18 | const { getByTestId, getByText } = renderResult
19 |
20 | // Check if the component renders correctly
21 | const contributingElement = getByTestId('about-revenue-1')
22 | expect(contributingElement).toBeInTheDocument()
23 |
24 | // Check if the contributing title is displayed correctly
25 | const titleElement = getByText('about.revenue.title')
26 | expect(titleElement).toBeInTheDocument()
27 |
28 | // Check if the contributing paragraph is displayed correctly
29 | const paragraphElement = getByText('about.revenue.info')
30 | expect(paragraphElement).toBeInTheDocument()
31 | })
32 |
33 | it('Should not have a11y violation', async () => {
34 | const { container } = render()
35 | const result = await axe(container)
36 | expect(result).toHaveNoViolations()
37 | })
38 | })
39 |
--------------------------------------------------------------------------------
/pages/api/cron.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-misused-promises */
2 | import cron from 'node-cron'
3 | import dayjs from 'dayjs'
4 | import { NextApiRequest, NextApiResponse } from 'next'
5 | import {
6 | deleteReview,
7 | getDeleted,
8 | } from '@/lib/review/models/admin-delete-review'
9 |
10 | const readyToDelete = (delete_date: string | null): boolean => {
11 | if (delete_date && delete_date.length > 0) {
12 | const [day, month, year] = delete_date.split('/').map(Number)
13 | if (day && month && year) {
14 | const deleteDate = dayjs(`${year}-${month}-${day}`).startOf('day')
15 | const today = dayjs().startOf('day')
16 | return deleteDate.isBefore(today) || deleteDate.isSame(today)
17 | }
18 | }
19 | return false
20 | }
21 |
22 | // Schedule a cron job to run every day
23 |
24 | cron.schedule('0 0 * * *', async () => {
25 | const reviews = await getDeleted()
26 |
27 | for (const review of reviews) {
28 | if (readyToDelete(dayjs(review.delete_date).format('DD/MM/YYYY'))) {
29 | if (review.id) {
30 | await deleteReview(review.id)
31 | console.log(`Review with ID ${review.id} has been deleted.`)
32 | }
33 | }
34 | }
35 | })
36 |
37 | export default function handler(req: NextApiRequest, res: NextApiResponse) {
38 | res.status(200).json({ message: 'Cron job API is running!' })
39 | }
40 |
--------------------------------------------------------------------------------
/components/reviews/ui/searchbar.tsx:
--------------------------------------------------------------------------------
1 | import { XIcon } from '@heroicons/react/solid'
2 | import React from 'react'
3 | import { useTranslations } from 'next-intl'
4 |
5 | interface SearchProps {
6 | setSearchState: (str: string) => void
7 | searchTitle?: string
8 | value?: string
9 | }
10 |
11 | export default function SearchBar({
12 | setSearchState,
13 | searchTitle,
14 | value,
15 | }: SearchProps) {
16 | const t = useTranslations('filters')
17 | return (
18 |
19 |
22 |
23 | setSearchState(e.target.value)}
28 | className='block w-full rounded-md border-gray-300 p-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm'
29 | value={value}
30 | placeholder={
31 | searchTitle ? searchTitle : `${t('search')} ${t('landlord')}`
32 | }
33 | />
34 | {value?.length ? (
35 |
41 | ) : null}
42 |
43 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/util/countries/norway/counties.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "short": "NO-03",
4 | "name": "Oslo",
5 | "country": "NO"
6 | },
7 | {
8 | "short": "NO-11",
9 | "name": "Rogaland",
10 | "country": "NO"
11 | },
12 | {
13 | "short": "NO-15",
14 | "name": "Møre og Romsdal",
15 | "country": "NO"
16 | },
17 | {
18 | "short": "NO-18",
19 | "name": "Nordland",
20 | "country": "NO"
21 | },
22 | {
23 | "short": "NO-31",
24 | "name": "Østfold",
25 | "country": "NO"
26 | },
27 | {
28 | "short": "NO-32",
29 | "name": "Akershus",
30 | "country": "NO"
31 | },
32 | {
33 | "short": "NO-33",
34 | "name": "Buskerud",
35 | "country": "NO"
36 | },
37 | {
38 | "short": "NO-34",
39 | "name": "Innlandet",
40 | "country": "NO"
41 | },
42 | {
43 | "short": "NO-39",
44 | "name": "Vestfold",
45 | "country": "NO"
46 | },
47 | {
48 | "short": "NO-40",
49 | "name": "Telemark",
50 | "country": "NO"
51 | },
52 | {
53 | "short": "NO-42",
54 | "name": "Agder",
55 | "country": "NO"
56 | },
57 | {
58 | "short": "NO-46",
59 | "name": "Vestland",
60 | "country": "NO"
61 | },
62 | {
63 | "short": "NO-50",
64 | "name": "Trøndelag",
65 | "country": "NO"
66 | },
67 | {
68 | "short": "NO-55",
69 | "name": "Troms",
70 | "country": "NO"
71 | },
72 | {
73 | "short": "NO-56",
74 | "name": "Finnmark",
75 | "country": "NO"
76 | }
77 | ]
78 |
--------------------------------------------------------------------------------
/components/admin/sections/FlaggedKeywords.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render, screen } from '@/test-utils'
5 | import '@testing-library/jest-dom'
6 | import FlaggedKeywords from './FlaggedKeywords'
7 | import { SWRConfig } from 'swr'
8 | import { fetchWithBody } from '@/util/helpers/fetcher'
9 | import { axe } from 'jest-axe'
10 |
11 | jest.mock('@/util/helpers/fetcher')
12 | jest.mock('react-toastify', () => ({
13 | toast: {
14 | success: jest.fn(),
15 | error: jest.fn(),
16 | },
17 | }))
18 |
19 | const mockedFetchWithBody = fetchWithBody as jest.Mock
20 |
21 | describe('FlaggedKeywords', () => {
22 | beforeEach(() => {
23 | mockedFetchWithBody.mockImplementation(() =>
24 | Promise.resolve({
25 | keywords: [{ id: 1, keyword: 'test', reason: 'test reason' }],
26 | }),
27 | )
28 | })
29 |
30 | it('renders without crashing', async () => {
31 | render(
32 |
33 |
34 | ,
35 | )
36 | expect(await screen.findByText('test')).toBeInTheDocument()
37 | })
38 |
39 | it('Should not have a11y violation', async () => {
40 | const { container } = render(
41 |
42 |
43 | ,
44 | )
45 | const result = await axe(container)
46 | expect(result).toHaveNoViolations()
47 | })
48 | })
49 |
--------------------------------------------------------------------------------
/components/about/contributing.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { RenderResult } from '@/test-utils'
6 | import Contributing from './contributing'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | import { render } from '@/test-utils'
9 | expect.extend(toHaveNoViolations)
10 |
11 | describe('Contributing Test Suite', () => {
12 | let renderResult: RenderResult
13 |
14 | beforeEach(() => {
15 | renderResult = render()
16 | })
17 |
18 | it('renders the component with translated content', () => {
19 | const { getByTestId, getByText } = renderResult
20 |
21 | // Check if the component renders correctly
22 | const contributingElement = getByTestId('about-contributing-1')
23 | expect(contributingElement).toBeInTheDocument()
24 |
25 | // Check if the contributing title is displayed correctly
26 | const titleElement = getByText('about.contributing.contributing')
27 | expect(titleElement).toBeInTheDocument()
28 |
29 | // Check if the contributing paragraph is displayed correctly
30 | const paragraphElement = getByText('about.contributing.info')
31 | expect(paragraphElement).toBeInTheDocument()
32 | })
33 |
34 | it('Should not have a11y violation', async () => {
35 | const { container } = render()
36 | const result = await axe(container)
37 | expect(result).toHaveNoViolations()
38 | })
39 | })
40 |
--------------------------------------------------------------------------------
/components/ui/LargeTextInput.tsx:
--------------------------------------------------------------------------------
1 | interface IProps {
2 | title: string
3 | id: string
4 | value?: string
5 | setValue: (str: string) => void
6 | rows?: number
7 | placeHolder?: string
8 | testid?: string
9 | length?: number
10 | limitText?: string
11 | }
12 |
13 | const LargeTextInput = ({
14 | title,
15 | id,
16 | value,
17 | setValue,
18 | rows = 4,
19 | placeHolder,
20 | testid = '',
21 | length,
22 | limitText,
23 | }: IProps) => {
24 | return (
25 |
26 |
29 |
52 |
53 | )
54 | }
55 |
56 | export default LargeTextInput
57 |
--------------------------------------------------------------------------------
/components/city/CityPage.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render, screen } from '@/test-utils'
5 | import CityPage from './CityPage'
6 | import { Provider } from 'react-redux'
7 | import { store } from '@/redux/store'
8 | import { axe } from 'jest-axe'
9 | import { ICityReviews } from '@/lib/review/types/review'
10 |
11 | const mockData: ICityReviews = {
12 | reviews: [],
13 | average: 0,
14 | total: 0,
15 | catAverages: {
16 | avg_repair: 0,
17 | avg_health: 0,
18 | avg_stability: 0,
19 | avg_privacy: 0,
20 | avg_respect: 0,
21 | },
22 |
23 | zips: [],
24 | }
25 |
26 | describe('CityPage', () => {
27 | it('should render', () => {
28 | render(
29 |
30 |
36 | ,
37 | )
38 |
39 | // Check if the sort option is rendered correctly
40 | expect(screen.getByTestId('city-page')).toBeInTheDocument()
41 | })
42 | it('Should not have a11y violation', async () => {
43 | const { container } = render(
44 |
45 |
51 | ,
52 | )
53 | const result = await axe(container)
54 | expect(result).toHaveNoViolations()
55 | })
56 | })
57 |
--------------------------------------------------------------------------------
/components/ui/TextInput.tsx:
--------------------------------------------------------------------------------
1 | interface IProps {
2 | title: string
3 | value: string | number | undefined | null
4 | setValue: (str: string) => void
5 | placeHolder?: string
6 | id: string
7 | error?: boolean
8 | errorText?: string
9 | testid?: string
10 | type?: string
11 | }
12 |
13 | const TextInput = ({
14 | title,
15 | value,
16 | setValue,
17 | placeHolder,
18 | id,
19 | error = false,
20 | errorText,
21 | testid,
22 | type = 'text',
23 | }: IProps) => {
24 | return (
25 |
29 |
32 |
33 |
34 | setValue(e.target.value)}
37 | type={type}
38 | name={id}
39 | id={id}
40 | value={value || ''}
41 | placeholder={placeHolder}
42 | data-testid={testid + 'input' || ''}
43 | className={`block w-full rounded-md border-gray-300 p-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
44 | error ? 'border-red-400' : ''
45 | }`}
46 | />
47 |
48 | {error &&
{errorText}
}
49 |
50 |
51 | )
52 | }
53 |
54 | export default TextInput
55 |
--------------------------------------------------------------------------------
/components/layout/MobileNav.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import { Disclosure, DisclosureButton } from '@headlessui/react'
3 | import { INav } from '@/util/interfaces/interfaces'
4 | import { useTranslations } from 'next-intl'
5 |
6 | interface IProps {
7 | navigation: INav[]
8 | activeTab: string
9 | }
10 | const MobileNav = ({ navigation, activeTab }: IProps) => {
11 | const t = useTranslations('layout')
12 | return (
13 |
14 |
15 | {navigation.map((link) => (
16 |
17 |
25 | {t(link.name)}
26 |
27 |
28 | ))}
29 |
30 |
36 | {t('nav.submit')}
37 |
38 |
39 |
40 |
41 | )
42 | }
43 |
44 | export default MobileNav
45 |
--------------------------------------------------------------------------------
/lib/flagged-keywords/flagged-keywords.ts:
--------------------------------------------------------------------------------
1 | import sql from '../db'
2 | import { Keywords } from '@/util/interfaces/interfaces'
3 | import { createKeyword } from './models/flagged-keywords-data-layer'
4 | import { getFlaggedKeywordsResponse, IResponse } from './types'
5 |
6 | export async function getFlaggedKeywords(): Promise {
7 | // Fetch Keywords
8 | const keywords = await sql`SELECT *
9 | FROM keyword_flags;`
10 |
11 | // Fetch Total Number of Landlords
12 | const totalResult = await sql`SELECT COUNT(*) as count FROM keyword_flags;`
13 | const total = totalResult[0].count as number
14 |
15 | // Return object
16 | return {
17 | keywords,
18 | total,
19 | }
20 | }
21 |
22 | export async function create(inputKeyword: Keywords): Promise {
23 | try {
24 | const landlord = await createKeyword(inputKeyword)
25 | if (landlord) return { status: 200, message: 'Created Landlord' }
26 | throw new Error()
27 | } catch {
28 | return { status: 500, message: 'Failed to create Landlord' }
29 | }
30 | }
31 |
32 | export async function deleteKeyword(id: number): Promise {
33 | try {
34 | const deleteResource = await sql`
35 | DELETE
36 | FROM keyword_flags
37 | WHERE id = ${id};
38 | `
39 | if (deleteResource) return { status: 200, message: 'Deleted Keyword' }
40 | throw new Error()
41 | } catch {
42 | return { status: 500, message: 'Failed to Delete Keyword' }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/pages/500.tsx:
--------------------------------------------------------------------------------
1 | import LinkButtonLG from '@/components/ui/link-button-lg'
2 | import LinkButtonLightLG from '@/components/ui/link-button-light-lg'
3 |
4 | export default function Custom500() {
5 | return (
6 |
7 |
8 |
500
9 |
10 | Oops, something went wrong.
11 |
12 |
13 | An error occurred between somewhere on our end. If you continue to see
14 | this page, please contact us.
15 |
16 |
17 | Go back home
18 |
19 | Contact Us
20 |
21 |
22 |
23 |
24 | )
25 | }
26 |
27 | export async function getStaticProps({ locale }: { locale: string }) {
28 | const layoutMessages = (await import(
29 | `@/messages/${locale}/layout.json`
30 | )) as Record
31 | const alertsMessages = (await import(
32 | `@/messages/${locale}/alerts.json`
33 | )) as Record
34 | return {
35 | props: {
36 | messages: {
37 | ...layoutMessages,
38 | ...alertsMessages,
39 | },
40 | },
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/components/admin/types/types.ts:
--------------------------------------------------------------------------------
1 | export interface ITabs {
2 | name: string
3 | component: JSX.Element
4 | }
5 |
6 | export interface ICountryStats {
7 | total_reviews: number
8 | countryStats: {
9 | CA?: {
10 | total: string
11 | states: {
12 | key: string
13 | total: string
14 | }[]
15 | }
16 | US?: {
17 | total: string
18 | states: {
19 | key: string
20 | total: string
21 | }[]
22 | }
23 | AU?: {
24 | total: string
25 | states: {
26 | key: string
27 | total: string
28 | }[]
29 | }
30 | GB?: {
31 | total: string
32 | states: {
33 | key: string
34 | total: string
35 | }[]
36 | }
37 | NZ?: {
38 | total: string
39 | states: {
40 | key: string
41 | total: string
42 | }[]
43 | }
44 | DE?: {
45 | total: string
46 | states: {
47 | key: string
48 | total: string
49 | }[]
50 | }
51 | IE?: {
52 | total: string
53 | states: {
54 | key: string
55 | total: string
56 | }[]
57 | }
58 | NO?: {
59 | total: string
60 | states: {
61 | key: string
62 | total: string
63 | }[]
64 | }
65 | }
66 | }
67 |
68 | interface IDetailedStats {
69 | date: string
70 | country_codes: Record
71 | cities: Record
72 | state: Record
73 | zip: Record
74 | total: string
75 | }
76 |
77 | export interface IStats {
78 | detailed_stats: IDetailedStats[]
79 | total_stats: ICountryStats
80 | }
81 |
--------------------------------------------------------------------------------
/components/poster/Poster.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render, screen } from '@/test-utils'
5 | import Poster from './Poster'
6 | import { axe, toHaveNoViolations } from 'jest-axe'
7 | expect.extend(toHaveNoViolations)
8 |
9 | describe('Poster component', () => {
10 | it('renders the poster image with correct attributes', () => {
11 | render()
12 | const image = screen.getByAltText('Poster')
13 | expect(image).toBeInTheDocument()
14 | expect(image).toHaveAttribute(
15 | 'src',
16 | '/_next/image?url=%2Fposter_picture.webp&w=640&q=75',
17 | )
18 | expect(image).toHaveAttribute('width', '270')
19 | expect(image).toHaveAttribute('height', '384')
20 | })
21 |
22 | it('renders the download link with correct attributes', () => {
23 | render()
24 | const link = screen.getByRole('link', { name: /download pdf/i })
25 | expect(link).toBeInTheDocument()
26 | expect(link).toHaveAttribute('href', '/poster/rtl_poster.pdf')
27 | expect(link).toHaveAttribute('download', '/poster/rtl_poster.pdf')
28 | })
29 |
30 | it('renders the download and share text', () => {
31 | render()
32 | const text = screen.getByText(/download and share our poster!/i)
33 | expect(text).toBeInTheDocument()
34 | expect(text).toHaveClass('mt-2 text-sm text-gray-600')
35 | })
36 | it('Should not have a11y violation', async () => {
37 | const { container } = render()
38 | const result = await axe(container)
39 | expect(result).toHaveNoViolations()
40 | })
41 | })
42 |
--------------------------------------------------------------------------------
/components/layout/footer.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, screen } from '@/test-utils'
6 | import Footer from './footer'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | expect.extend(toHaveNoViolations)
9 |
10 | describe('Footer', () => {
11 | test('renders the footer links', () => {
12 | render()
13 |
14 | const instagramLink = screen.getByRole('link', { name: 'Instagram' })
15 | expect(instagramLink).toBeInTheDocument()
16 | expect(instagramLink).toHaveAttribute(
17 | 'href',
18 | 'https://www.instagram.com/ratethelandlord',
19 | )
20 |
21 | const twitterLink = screen.getByRole('link', { name: 'Twitter' })
22 | expect(twitterLink).toBeInTheDocument()
23 | expect(twitterLink).toHaveAttribute(
24 | 'href',
25 | 'https://twitter.com/r8thelandlord',
26 | )
27 |
28 | const tiktokLink = screen.getByRole('link', { name: 'TikTok' })
29 | expect(tiktokLink).toBeInTheDocument()
30 | expect(tiktokLink).toHaveAttribute(
31 | 'href',
32 | 'https://www.tiktok.com/@ratethelandlord',
33 | )
34 |
35 | const githubLink = screen.getByRole('link', { name: 'Github' })
36 | expect(githubLink).toBeInTheDocument()
37 | expect(githubLink).toHaveAttribute(
38 | 'href',
39 | 'https://github.com/RateTheLandlord',
40 | )
41 | })
42 | it('Should not have a11y violation', async () => {
43 | const { container } = render()
44 | const result = await axe(container)
45 | expect(result).toHaveNoViolations()
46 | })
47 | })
48 |
--------------------------------------------------------------------------------
/components/resources/ResourceList.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, screen } from '@/test-utils'
6 | import '@testing-library/jest-dom/extend-expect'
7 | import ResourceList from './ResourceList'
8 | import { Provider } from 'react-redux'
9 | import { store } from '@/redux/store'
10 | import { ResourceResponse } from '@/util/interfaces/interfaces'
11 | import { sortOptions } from '@/util/helpers/filter-options'
12 | import { axe, toHaveNoViolations } from 'jest-axe'
13 | expect.extend(toHaveNoViolations)
14 |
15 | // Mock data
16 | const mockData: ResourceResponse = {
17 | resources: [],
18 | total: '0',
19 | countries: [],
20 | states: [],
21 | cities: [],
22 | limit: 1,
23 | }
24 |
25 | describe('ResourceList Component', () => {
26 | it('renders without crashing', () => {
27 | render(
28 |
29 |
30 | ,
31 | )
32 | expect(screen.getByTestId('ResourceListTest')).toBeInTheDocument()
33 | })
34 |
35 | it('filters sort options correctly', () => {
36 | const filteredSortOptions = sortOptions.filter((r) => r.id < 5)
37 | expect(filteredSortOptions.length).toBeLessThanOrEqual(sortOptions.length)
38 | })
39 | it('Should not have a11y violation', async () => {
40 | const { container } = render(
41 |
42 |
43 | ,
44 | )
45 | const result = await axe(container)
46 | expect(result).toHaveNoViolations()
47 | })
48 | })
49 |
--------------------------------------------------------------------------------
/components/home/hero.tsx:
--------------------------------------------------------------------------------
1 | import LinkButtonLG from '../ui/link-button-lg'
2 | import LinkButtonLightLG from '../ui/link-button-light-lg'
3 | import { HouseIcon } from '../icons/HouseIcon'
4 | import { useTranslations } from 'next-intl'
5 |
6 | function Hero(): JSX.Element {
7 | const t = useTranslations('home')
8 | return (
9 |
13 |
14 |
15 | {t('home.hero.title')}
16 |
17 |
18 | {t('home.hero.body')}
19 |
20 |
21 |
22 | {t('home.hero.submit')}
23 |
24 |
25 | {t('home.hero.read')}
26 |
27 |
28 |
29 |
34 |
35 | )
36 | }
37 |
38 | export default Hero
39 |
--------------------------------------------------------------------------------
/components/reviews/review-table.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render, screen } from '@/test-utils'
6 | import ReviewTable from './review-table'
7 | import { Provider } from 'react-redux'
8 | import { store } from '@/redux/store'
9 | import { UserProvider } from '@auth0/nextjs-auth0/client'
10 | import { axe, toHaveNoViolations } from 'jest-axe'
11 | expect.extend(toHaveNoViolations)
12 |
13 | describe('ReviewTable', () => {
14 | it('renders review table with no data', () => {
15 | render(
16 |
17 |
18 |
26 |
27 | ,
28 | )
29 |
30 | // Check if no data message is rendered
31 | expect(screen.getByTestId('review-table-1-no-data')).toBeInTheDocument()
32 | })
33 | it('Should not have a11y violation', async () => {
34 | const { container } = render(
35 |
36 |
37 |
45 |
46 | ,
47 | )
48 | const result = await axe(container)
49 | expect(result).toHaveNoViolations()
50 | })
51 | })
52 |
--------------------------------------------------------------------------------
/components/landlord/LandlordInfo.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render, screen } from '@/test-utils'
5 | import LandlordInfo from './LandlordInfo'
6 | import { useRouter } from 'next/router'
7 | import { axe, toHaveNoViolations } from 'jest-axe'
8 | import { ILandlordReviews } from '@/lib/review/types/Queries'
9 | expect.extend(toHaveNoViolations)
10 |
11 | jest.mock('next/router', () => ({
12 | useRouter: jest.fn(),
13 | }))
14 |
15 | describe('LandlordInfo', () => {
16 | const pushMock = jest.fn()
17 |
18 | beforeEach(() => {
19 | return (useRouter as jest.Mock).mockImplementation(() => ({
20 | push: pushMock,
21 | }))
22 | })
23 |
24 | afterEach(() => {
25 | jest.clearAllMocks()
26 | })
27 |
28 | const name = 'John Doe'
29 | const data: ILandlordReviews = {
30 | reviews: [],
31 | average: 5,
32 | total: 5,
33 | catAverages: {
34 | avg_health: 5,
35 | avg_privacy: 5,
36 | avg_repair: 5,
37 | avg_respect: 5,
38 | avg_stability: 5,
39 | },
40 | }
41 | it('renders with correct name, average, and total', () => {
42 | render()
43 |
44 | const landlordName = screen.getByText(name)
45 | // const reviewCount = screen.getByText(`Based on ${data.total} reviews`)
46 |
47 | expect(landlordName).toBeInTheDocument()
48 | // expect(reviewCount).toBeInTheDocument()
49 | })
50 | it('Should not have a11y violation', async () => {
51 | const { container } = render()
52 | const result = await axe(container)
53 | expect(result).toHaveNoViolations()
54 | })
55 | })
56 |
--------------------------------------------------------------------------------
/.github/workflows/publish-release.yml:
--------------------------------------------------------------------------------
1 | name: Publish Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - production
7 |
8 | jobs:
9 | publish-release:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout repository
13 | uses: actions/checkout@v4
14 | with:
15 | fetch-depth: 0
16 |
17 | - name: Get latest release tag
18 | id: latest_tag
19 | run: |
20 | LATEST_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+' | head -n 1)
21 | echo "Latest tag: $LATEST_TAG"
22 | echo "latest_tag=$LATEST_TAG" >> $GITHUB_ENV
23 |
24 | - name: Publish draft release
25 | uses: actions/github-script@v7
26 | env:
27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28 | with:
29 | script: |
30 | const latestTag = process.env.latest_tag;
31 | const { data: releases } = await github.rest.repos.listReleases({
32 | owner: context.repo.owner,
33 | repo: context.repo.repo
34 | });
35 |
36 | const draftRelease = releases.find(r => r.tag_name === latestTag && r.draft);
37 | if (draftRelease) {
38 | await github.rest.repos.updateRelease({
39 | owner: context.repo.owner,
40 | repo: context.repo.repo,
41 | release_id: draftRelease.id,
42 | draft: false
43 | });
44 | console.log(`Published release: ${latestTag}`);
45 | } else {
46 | console.log("No draft release found");
47 | }
48 |
--------------------------------------------------------------------------------
/components/layout/layout.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import React from 'react'
6 | import { render } from '@/test-utils'
7 | import Layout from './layout'
8 | import { axe, toHaveNoViolations } from 'jest-axe'
9 | import { UserProvider } from '@auth0/nextjs-auth0/client'
10 | import { Provider } from 'react-redux'
11 | import { store } from '@/redux/store'
12 | expect.extend(toHaveNoViolations)
13 |
14 | import { useRouter } from 'next/router'
15 |
16 | jest.mock('next/router', () => ({
17 | useRouter: jest.fn(),
18 | }))
19 |
20 | describe('Layout component', () => {
21 | beforeEach(() => {
22 | ;(useRouter as jest.Mock).mockReturnValue({
23 | route: '/test-route',
24 | pathname: '/test-pathname',
25 | query: { id: '123' },
26 | asPath: '/test-route',
27 | })
28 | })
29 | it('should render the Navbar, children, Banner, and Footer', () => {
30 | const { getByTestId } = render(
31 |
32 |
33 |
34 | Child Component
35 |
36 |
37 | ,
38 | )
39 |
40 | expect(getByTestId('layout-1')).toBeInTheDocument()
41 | expect(getByTestId('layout-1').textContent).toContain('Child Component')
42 | })
43 | it('Should not have a11y violation', async () => {
44 | const { container } = render(
45 |
46 |
47 | Child Component
48 |
49 | ,
50 | )
51 | const result = await axe(container)
52 | expect(result).toHaveNoViolations()
53 | })
54 | })
55 |
--------------------------------------------------------------------------------
/components/layout/footer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTranslations } from 'next-intl'
3 | import { socialLinks } from './links'
4 | import Github from '../svg/social/github'
5 | import Link from 'next/link'
6 |
7 | function Footer(): JSX.Element {
8 | const date = new Date()
9 | const year = date.getFullYear()
10 | const t = useTranslations('layout')
11 | return (
12 |
44 | )
45 | }
46 |
47 | export default Footer
48 |
--------------------------------------------------------------------------------
/components/admin/sections/RecentReviews.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { render, screen } from '@testing-library/react'
5 | import RecentReviews from './RecentReviews'
6 | import useSWR from 'swr'
7 | import dayjs from 'dayjs'
8 | import { axe } from 'jest-axe'
9 |
10 | // Mock useSWR
11 | jest.mock('swr')
12 | const mockedUseSWR = useSWR as jest.Mock
13 |
14 | describe('RecentReviews Component', () => {
15 | it('renders error message when there is an error', () => {
16 | mockedUseSWR.mockReturnValue({ data: null, error: true })
17 | render()
18 | expect(screen.getByText('Error Loading...')).toBeInTheDocument()
19 | })
20 |
21 | it('renders the list of recent reviews when data is available', () => {
22 | const mockData = [
23 | { id: '1', landlord: 'John Doe', created_at: '2023-01-01T12:00:00Z' },
24 | { id: '2', landlord: 'Jane Smith', created_at: '2023-01-02T15:30:00Z' },
25 | ]
26 | mockedUseSWR.mockReturnValue({ data: mockData, error: null })
27 | render()
28 |
29 | mockData.forEach((item, index) => {
30 | expect(screen.getByText(index + 1)).toBeInTheDocument()
31 | expect(screen.getByText(item.landlord)).toBeInTheDocument()
32 | expect(
33 | screen.getByText(dayjs(item.created_at).format('DD/MM/YYYY HH:mm:ss')),
34 | ).toBeInTheDocument()
35 | })
36 | })
37 |
38 | it('Should not have a11y violation', async () => {
39 | ;(useSWR as jest.Mock).mockReturnValue({
40 | data: [],
41 | error: null,
42 | })
43 | const { container } = render()
44 | const result = await axe(container)
45 | expect(result).toHaveNoViolations()
46 | })
47 | })
48 |
--------------------------------------------------------------------------------
/messages/en-CA/support.json:
--------------------------------------------------------------------------------
1 | {
2 | "support": {
3 | "header": "Support Rate The Landlord in our journey",
4 | "support-us": "Support Us",
5 | "body-1": "Rate The Landlord is on a mission to empower renters, promote transparency, and build a global community dedicated to fostering positive landlord-tenant relationships. As we continue to grow and enhance our platform, we're faced with various operational costs—from server maintenance to development resources.",
6 | "body-2": "Until now, the site has been supported by Ad Revenue, but it doesn't always cover our monthly costs to keep the site running. If we want to continue to grow the site and offer more resources, we need the support of the community.",
7 | "body-3": "Rest assured though that we are NOT using this platform as a way to enrich ourselves. Any money left over after overhead costs will be put right back into site through various means such as advertising, updating our UI/UX design, or a number of other ways to help enhance the Tenant experience. We'll also periodically donate to local Tenant Union's and resources to help spread the support!",
8 | "features": {
9 | "platform": {
10 | "title": "Platform Enhancements",
11 | "description": "Supporting ongoing development to introduce new features and improve user experience."
12 | },
13 | "maintenance": {
14 | "title": "Server Maintenance",
15 | "description": "Ensuring our platform stays accessible and reliable for users worldwide."
16 | },
17 | "community": {
18 | "title": "Community Initiatives",
19 | "description": "Enabling us to host events and campaigns that bring our community together."
20 | }
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/components/Map/CustomMarker.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import React from 'react'
5 | import { render } from '@/test-utils'
6 | import '@testing-library/jest-dom/extend-expect'
7 | import CustomMarker from './CustomMarker'
8 | import { axe, toHaveNoViolations } from 'jest-axe'
9 | import { IZipLocations } from '@/lib/location/types'
10 | expect.extend(toHaveNoViolations)
11 |
12 | describe('CustomMarker', () => {
13 | const mockLocation: IZipLocations = {
14 | zip: '12345',
15 | latitude: '0',
16 | longitude: '0',
17 | }
18 | const mockSetSelectedPoint = jest.fn()
19 |
20 | it('renders correctly, with no selected point', () => {
21 | const { container } = render(
22 | ,
27 | )
28 | expect(container.firstChild).toHaveClass('cursor-pointer')
29 | expect(container.querySelector('.bg-teal-200')).toBeInTheDocument()
30 | })
31 |
32 | it('renders correctly with selected point', () => {
33 | const { container } = render(
34 | ,
39 | )
40 | expect(container.querySelector('.bg-white')).toBeInTheDocument()
41 | })
42 |
43 | it('Should not have a11y violation', async () => {
44 | const { container } = render(
45 | ,
50 | )
51 | const result = await axe(container)
52 | expect(result).toHaveNoViolations()
53 | })
54 | })
55 |
--------------------------------------------------------------------------------
/util/helpers/fetchReviews.ts:
--------------------------------------------------------------------------------
1 | import { QueryParams, ReviewsResponse } from '@/components/reviews/review'
2 | import { ResourceResponse } from '../interfaces/interfaces'
3 | import { ResourceQuery } from '@/lib/tenant-resource/types'
4 |
5 | export async function fetchReviews(queryParams?: QueryParams) {
6 | const url = `/api/review/get-reviews`
7 |
8 | try {
9 | const response = await fetch(url, {
10 | method: 'POST',
11 | headers: {
12 | 'Content-Type': 'application/json',
13 | },
14 | body: JSON.stringify({ queryParams }),
15 | })
16 |
17 | if (!response.ok) {
18 | throw new Error('Network response was not ok')
19 | }
20 |
21 | const data: ReviewsResponse = (await response.json()) as ReviewsResponse
22 | return data
23 | } catch {
24 | console.error('Error fetching reviews')
25 | return {
26 | reviews: [],
27 | total: 0,
28 | }
29 | }
30 | }
31 |
32 | export async function fetchResources(
33 | queryParams?: ResourceQuery,
34 | ): Promise {
35 | const url = `/api/tenant-resources/get-resources`
36 |
37 | try {
38 | const response = await fetch(url, {
39 | method: 'POST',
40 | headers: {
41 | 'Content-Type': 'application/json',
42 | },
43 | body: JSON.stringify({ queryParams }),
44 | })
45 |
46 | if (!response.ok) {
47 | throw new Error('Network response was not ok')
48 | }
49 |
50 | const data: ResourceResponse = (await response.json()) as ResourceResponse
51 | return data
52 | } catch {
53 | console.error('Error fetching Resources')
54 | return {
55 | resources: [],
56 | total: '1',
57 | countries: [],
58 | limit: 25,
59 | states: [],
60 | cities: [],
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/components/ui/button.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import React from 'react'
6 | import { render, fireEvent } from '@/test-utils'
7 | import Button from './button'
8 | import { axe, toHaveNoViolations } from 'jest-axe'
9 | expect.extend(toHaveNoViolations)
10 |
11 | describe('Button', () => {
12 | it('renders button text correctly', () => {
13 | const buttonText = 'Click me'
14 | const { getByText } = render()
15 | const buttonElement = getByText(buttonText)
16 | expect(buttonElement).toBeInTheDocument()
17 | })
18 |
19 | it('calls onClick handler when clicked', () => {
20 | const onClickMock = jest.fn()
21 | const { getByTestId } = render(
22 | ,
23 | )
24 | const buttonElement = getByTestId('submit-button-1')
25 | fireEvent.click(buttonElement)
26 | expect(onClickMock).toHaveBeenCalled()
27 | })
28 |
29 | it('renders with disabled styles when disabled prop is true', () => {
30 | const { getByTestId } = render()
31 | const buttonElement = getByTestId('submit-button-1')
32 | expect(buttonElement).toHaveClass('bg-teal-200')
33 | })
34 |
35 | it('renders with enabled styles when disabled prop is false', () => {
36 | const { getByTestId } = render()
37 | const buttonElement = getByTestId('submit-button-1')
38 | expect(buttonElement).toHaveClass('bg-teal-600 hover:bg-teal-700')
39 | })
40 | it('Should not have a11y violation', async () => {
41 | const { container } = render()
42 | const result = await axe(container)
43 | expect(result).toHaveNoViolations()
44 | })
45 | })
46 |
--------------------------------------------------------------------------------
/lib/review/models/admin-update-review.ts:
--------------------------------------------------------------------------------
1 | import sql from '@/lib/db'
2 | import { Review } from '@/util/interfaces/interfaces'
3 |
4 | export async function updateReview(
5 | id: number,
6 | review: Review,
7 | ): Promise {
8 | await sql`UPDATE review
9 | SET landlord = ${review.landlord
10 | .substring(0, 150)
11 | .toLocaleUpperCase()},
12 | country_code = ${review.country_code.toLocaleUpperCase()},
13 | city = ${review.city.substring(0, 150).toLocaleUpperCase()},
14 | state = ${review.state.toLocaleUpperCase()},
15 | zip = ${review.zip
16 | .substring(0, 50)
17 | .toLocaleUpperCase()
18 | .replace(' ', '')},
19 | review = ${review.review},
20 | repair = ${review.repair},
21 | health = ${review.health},
22 | stability = ${review.stability},
23 | privacy = ${review.privacy},
24 | respect = ${review.respect},
25 | flagged = ${review.flagged},
26 | flagged_reason = ${review.flagged_reason},
27 | admin_approved = ${review.admin_approved},
28 | admin_edited = ${review.admin_edited},
29 | rent = ${review.rent || null},
30 | moderation_reason = ${review.moderation_reason || null},
31 | moderator = ${review.moderator},
32 | delete_date = ${review.delete_date},
33 | delete_reason = ${review.delete_reason},
34 | deleted_by = ${review.deleted_by},
35 | restore_date = ${review.restore_date},
36 | restore_reason = ${review.restore_reason},
37 | restored_by = ${review.restored_by}
38 | WHERE id = ${id};`
39 |
40 | return review
41 | }
42 |
--------------------------------------------------------------------------------
/components/create-review/components/CreateReviewHero.tsx:
--------------------------------------------------------------------------------
1 | import { HouseIcon } from '@/components/icons/HouseIcon'
2 | import Button from '@/components/ui/button'
3 | import { classNames } from '@/util/helpers/helper-functions'
4 | import { useTranslations } from 'next-intl'
5 |
6 | interface IProps {
7 | getStarted: boolean
8 | setGetStarted: (bool: boolean) => void
9 | setLandlordOpen: (bool: boolean) => void
10 | }
11 |
12 | const ReviewHero = ({ getStarted, setGetStarted, setLandlordOpen }: IProps) => {
13 | const t = useTranslations('createreview')
14 | return (
15 |
22 |
23 | {getStarted ? null : (
24 |
32 | )}
33 |
{t('hero.title')}
34 |
35 |
{t('hero.body')}
36 |
37 | {getStarted ? null : (
38 |
47 | )}
48 |
49 |
50 | )
51 | }
52 |
53 | export default ReviewHero
54 |
--------------------------------------------------------------------------------