feat: listings for rent FE integration

This commit is contained in:
RizqiSyahrendra 2025-04-23 11:57:06 +07:00
parent 2dbf9e1ad4
commit 1acb297818
12 changed files with 291 additions and 316 deletions

View File

@ -25,6 +25,7 @@
"qs-esm": "^7.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-select": "^5.10.1",
"swiper": "^11.2.6"
},
"devDependencies": {

View File

@ -5,7 +5,6 @@ import { formatCurrency } from "@/utils/general";
const similarPropertiesData: CardPropertyData[] = [
{
id: 1,
title: "401 Biscayne Boulevard, Miami",
slug: "401-biscayne-boulevard",
images: [
@ -16,23 +15,20 @@ const similarPropertiesData: CardPropertyData[] = [
price: 5000,
propertyType: "rent",
posted_at: "",
area: 480,
bathrooms_count: 2,
bedrooms_count: 2,
is_available: true,
area: "480",
bathrooms_count: "2",
bedrooms_count: "2",
},
{
id: 2,
title: "402 Biscayne Boulevard, Miami",
slug: "402-biscayne-boulevard",
images: [{ url: "/images/featured-properties-01-480x287.jpg", alt: "biscayne boulevard" }],
price: 5000,
propertyType: "rent",
posted_at: "",
area: 480,
bathrooms_count: 2,
bedrooms_count: 2,
is_available: true,
area: "480",
bathrooms_count: "2",
bedrooms_count: "2",
},
];
@ -367,8 +363,8 @@ export default function ListingsForRentDetail() {
<div className="block-group-item">
<h3>Similar Properties</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{similarPropertiesData.map((p) => (
<CardProperty key={p.id} data={p} />
{similarPropertiesData.map((p, idx) => (
<CardProperty key={idx} data={p} />
))}
</div>
</div>

View File

@ -1,8 +1,12 @@
import CardProperty from "@/components/CardProperty";
import HeroImage from "@/components/HeroImage";
import Pagination from "@/components/Pagination";
import { CardPropertyData } from "@/schema/property";
import Select from "@/components/Select";
import { FetchPropertyParams } from "@/schema/services/property";
import { fetchProperty } from "@/services/payload/property";
import { getDefaultMetadata } from "@/utils/metadata";
import { sanitizeNumber, sanitizePageNumber } from "@/utils/sanitize";
import { State } from "country-state-city";
import { Metadata } from "next";
const metaDesc = "Explore the latest properties on the Dynamic Realty.";
@ -15,66 +19,28 @@ export async function generateMetadata(): Promise<Metadata> {
return metadata;
}
const propertiesData: CardPropertyData[] = [
{
id: 1,
title: "401 Biscayne Boulevard, Miami",
slug: "401-biscayne-boulevard",
images: [
{ url: "/images/featured-properties-01-480x287.jpg", alt: "biscayne boulevard" },
{ url: "/images/featured-properties-01-480x287.jpg", alt: "biscayne boulevard" },
{ url: "/images/featured-properties-01-480x287.jpg", alt: "biscayne boulevard" },
],
price: 5000,
propertyType: "rent",
posted_at: "",
area: 480,
bathrooms_count: 2,
bedrooms_count: 2,
is_available: true,
},
{
id: 2,
title: "402 Biscayne Boulevard, Miami",
slug: "402-biscayne-boulevard",
images: [{ url: "/images/featured-properties-01-480x287.jpg", alt: "biscayne boulevard" }],
price: 5000,
propertyType: "rent",
posted_at: "",
area: 480,
bathrooms_count: 2,
bedrooms_count: 2,
is_available: true,
},
{
id: 3,
title: "403 Biscayne Boulevard, Miami",
slug: "403-biscayne-boulevard",
images: [{ url: "/images/featured-properties-01-480x287.jpg", alt: "biscayne boulevard" }],
price: 5000,
propertyType: "rent",
posted_at: "",
area: 480,
bathrooms_count: 2,
bedrooms_count: 2,
is_available: true,
},
{
id: 4,
title: "404 Biscayne Boulevard, Miami",
slug: "404-biscayne-boulevard",
images: [{ url: "/images/featured-properties-01-480x287.jpg", alt: "biscayne boulevard" }],
price: 5000,
propertyType: "rent",
posted_at: "",
area: 480,
bathrooms_count: 2,
bedrooms_count: 2,
is_available: true,
},
];
export default async function ListingsForRent(props: {
searchParams?: Promise<{ [P in keyof FetchPropertyParams]: string }>;
}) {
const searchParams = await props?.searchParams;
const page = sanitizePageNumber(searchParams?.page);
const minPrice = sanitizeNumber(searchParams?.min_price);
const maxPrice = sanitizeNumber(searchParams?.max_price);
const minArea = sanitizeNumber(searchParams?.min_area);
const maxArea = sanitizeNumber(searchParams?.max_area);
const propertiesData = await fetchProperty({
page,
name: searchParams?.name,
min_price: minPrice,
max_price: maxPrice,
min_area: minArea,
max_area: maxArea,
location: searchParams?.location,
});
const isEmpty = propertiesData.formattedData.length <= 0;
const statesData = State.getStatesOfCountry("US").map((st) => ({ value: st.name, label: st.name }));
export default function ListingsForRent() {
return (
<>
<HeroImage title="Listings For Rent" />
@ -85,67 +51,33 @@ export default function ListingsForRent() {
<div className="col-lg-7 col-xl-8">
<div className="row row-30">
<div className="col-12">
<ul className="block-info-1">
<li>
<div className="form-wrap-group-1">
<div className="form-wrap">
<select
className="form-input select-filter"
data-style="modern"
data-placeholder="Publication Date"
data-minimum-results-for-search="Infinity"
data-constraints="@Required"
>
<option label="placeholder"></option>
<option value="2">Monday</option>
<option value="3">Tuesday</option>
<option value="4">Wednesday</option>
<option value="5">Thursday</option>
<option value="6">Friday</option>
<option value="7">Saturday</option>
<option value="8">Sunday</option>
</select>
{isEmpty && (
<div className="text-center mt-40">
<h3 className="text-spacing-20">No Properties Found</h3>
<p className="heading-5 mt-3">Looks like we couldnt find any listings that match your search.</p>
</div>
<div className="form-wrap">
<select
className="form-input select-filter"
data-style="modern"
data-placeholder="Price Low to High"
data-minimum-results-for-search="Infinity"
data-constraints="@Required"
>
<option label="placeholder"></option>
<option value="1">Price Low to High</option>
<option value="2">Price High to Low</option>
<option value="3">Most Popular</option>
<option value="4">Top Rated</option>
<option value="5">Best Sellers</option>
</select>
</div>
</div>
</li>
</ul>
</div>
<div className="col-12">
)}
{!isEmpty && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{propertiesData.map((p) => (
<CardProperty key={p.id} data={p} />
{propertiesData.formattedData.map((p, idx) => (
<CardProperty key={idx} data={p} />
))}
</div>
)}
</div>
{/* Pagination */}
{propertiesData.totalPages > 1 && (
<div className="col-12">
{/* <ul className="pagination-custom">
<li>
<a className="active" href="#">
1
</a>
</li>
<li>
<a href="#">2</a>
</li>
</ul> */}
<Pagination hasNextPage={true} hasPreviousPage={true} totalPages={10} page={3} />
<Pagination
page={propertiesData.page ?? 1}
hasNextPage={propertiesData.hasNextPage}
hasPreviousPage={propertiesData.hasPrevPage}
totalPages={propertiesData.totalPages}
/>
</div>
)}
{/* End Pagination */}
</div>
</div>
<div className="col-lg-5 col-xl-4">
@ -153,125 +85,55 @@ export default function ListingsForRent() {
<div className="col-md-6 col-lg-12">
<div className="block-info">
<h3>Find Your Property</h3>
<form
className="rd-mailform form-select"
data-form-output="form-output-global"
data-form-type="contact"
method="post"
action="bat/rd-mailform.php"
>
<form className="form-select">
<div className="form-wrap form-wrap-validation">
<select
className="form-input select-filter"
data-style="modern"
data-placeholder="Choose Location"
data-minimum-results-for-search="Infinity"
data-constraints="@Required"
>
<option label="placeholder"></option>
<option value="2">Alaska</option>
<option value="3">Arizona</option>
<option value="4">Arkansas</option>
<option value="5">California</option>
<option value="6">Colorado</option>
<option value="7">Connecticut</option>
<option value="8">Delaware</option>
<option value="9">Florida</option>
</select>
<span className="select-arrow"></span>
<input className="form-input" placeholder="Name" name="s" defaultValue={searchParams?.name} />
</div>
<div className="form-wrap form-wrap-validation">
<select
className="form-input select-filter"
data-style="modern"
data-placeholder="Property Status"
data-minimum-results-for-search="Infinity"
data-constraints="@Required"
>
<option label="placeholder"></option>
<option value="2">Low</option>
<option value="3">Middle</option>
<option value="4">Primary</option>
</select>
<span className="select-arrow"></span>
</div>
<div className="form-wrap form-wrap-validation">
<select
className="form-input select-filter"
data-style="modern"
data-placeholder="Property Type"
data-minimum-results-for-search="Infinity"
data-constraints="@Required"
>
<option label="placeholder"></option>
<option value="2">Low</option>
<option value="3">Middle</option>
<option value="4">Primary</option>
</select>
<span className="select-arrow"></span>
<Select
name="location"
placeholder="Choose Location"
options={statesData}
defaultInputValue={searchParams?.location}
defaultValue={searchParams?.location}
isSearchable
isClearable
/>
</div>
<div className="form-wrap-group">
<div className="form-wrap form-wrap-validation">
<select
className="form-input select-filter"
data-style="modern"
data-placeholder="Min Price"
data-minimum-results-for-search="Infinity"
data-constraints="@Required"
>
<option label="placeholder"></option>
<option value="2">100 $</option>
<option value="3">200 $</option>
<option value="4">300 $</option>
</select>
<span className="select-arrow"></span>
<input
className="form-input"
placeholder="Min Area Sqft"
name="min_area"
defaultValue={searchParams?.min_area}
/>
</div>
<div className="form-wrap form-wrap-validation">
<select
className="form-input select-filter"
data-style="modern"
data-placeholder="Max Price"
data-minimum-results-for-search="Infinity"
data-constraints="@Required"
>
<option label="placeholder"></option>
<option value="2">1000 $</option>
<option value="3">2000 $</option>
<option value="4">3000 $</option>
</select>
<span className="select-arrow"></span>
<input
className="form-input"
placeholder="Max Area Sqft"
name="max_area"
defaultValue={searchParams?.max_area}
/>
</div>
</div>
<div className="form-wrap-group">
<div className="form-wrap form-wrap-validation">
<select
className="form-input select-filter"
data-style="modern"
data-placeholder="Min Area"
data-minimum-results-for-search="Infinity"
data-constraints="@Required"
>
<option label="placeholder"></option>
<option value="2">100 Sq Ft</option>
<option value="3">200 Sq Ft</option>
<option value="4">300 Sq Ft</option>
</select>
<span className="select-arrow"></span>
<input
className="form-input"
placeholder="Min Price ($)"
name="min_price"
defaultValue={searchParams?.min_price}
/>
</div>
<div className="form-wrap form-wrap-validation">
<select
className="form-input select-filter"
data-style="modern"
data-placeholder="Max Area"
data-minimum-results-for-search="Infinity"
data-constraints="@Required"
>
<option label="placeholder"></option>
<option value="2">1000 Sq Ft</option>
<option value="3">2000 Sq Ft</option>
<option value="4">3000 Sq Ft</option>
</select>
<span className="select-arrow"></span>
<input
className="form-input"
placeholder="Max Price ($)"
name="max_price"
defaultValue={searchParams?.max_price}
/>
</div>
</div>
<div className="form-button">

View File

@ -4,6 +4,11 @@ import type { CollectionConfig } from "payload";
export const Properties: CollectionConfig = {
slug: "properties",
labels: { plural: "Properties", singular: "Property" },
versions: {
drafts: {
validate: true,
},
},
fields: [
{
name: "propertyType",
@ -76,14 +81,7 @@ export const Properties: CollectionConfig = {
{
name: "country_code",
label: "Country",
type: "select",
options: [
{
label: "United States",
value: "US",
},
],
required: true,
type: "text",
// admin: {
// components: {
// Field: {
@ -95,26 +93,12 @@ export const Properties: CollectionConfig = {
{
name: "state_code",
label: "State",
type: "select",
options: [
{
label: "Washington",
value: "WA",
},
],
required: true,
type: "text",
},
{
name: "city_code",
label: "City",
type: "select",
options: [
{
label: "Davenport",
value: "Davenport",
},
],
required: true,
type: "text",
},
{
name: "zip_code",

View File

@ -9,7 +9,6 @@ type CardPropertyProps = {
export default function CardProperty({ data }: CardPropertyProps) {
return (
<>
<div>
<article className="product-classic">
<div className="product-classic-media">
@ -24,10 +23,12 @@ export default function CardProperty({ data }: CardPropertyProps) {
>
{Array.isArray(data.images) &&
data.images.map((img, idx) => (
<Image key={idx} src={img.url} alt={img.alt ?? ""} width="480" height="287" />
<div key={idx} className="w-full h-52 bg-colorImgPlaceholder">
<Image src={img.url} alt={img.alt ?? ""} fill className="object-cover" />
</div>
))}
</div>
<div className="product-classic-price bg-colorPriceTag/80!">
<div className="product-classic-price bg-colorPriceTag/90!">
<span>
{formatCurrency(data.price)}
{data.propertyType === "rent" && `/mo`}
@ -58,6 +59,5 @@ export default function CardProperty({ data }: CardPropertyProps) {
</ul>
</article>
</div>
</>
);
}

View File

@ -0,0 +1,8 @@
"use client";
import { ComponentProps } from "react";
import ReactSelect from "react-select";
export default function Select(props: ComponentProps<typeof ReactSelect>) {
return <ReactSelect {...props} />;
}

View File

@ -264,9 +264,9 @@ export interface Property {
bedrooms_count: string;
};
addressGroup: {
country_code: 'US';
state_code: 'WA';
city_code: 'Davenport';
country_code?: string | null;
state_code?: string | null;
city_code?: string | null;
zip_code: string;
address: string;
};
@ -282,6 +282,7 @@ export interface Property {
embed_map_url?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@ -489,6 +490,7 @@ export interface PropertiesSelect<T extends boolean = true> {
embed_map_url?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@ -1,16 +1,14 @@
export type CardPropertyData = {
id: number;
slug?: string | null;
slug: string;
title: string;
price: number;
images?: { url: string; alt?: string }[];
/**
* in sqft
*/
area: number;
bedrooms_count?: number;
bathrooms_count?: number;
area: string;
bedrooms_count?: string;
bathrooms_count?: string;
posted_at: string;
propertyType: "rent" | "sell";
is_available: boolean;
};

View File

@ -0,0 +1,9 @@
export type FetchPropertyParams = {
page?: number;
name?: string;
min_area?: number;
max_area?: number;
min_price?: number;
max_price?: number;
location?: string;
};

View File

@ -0,0 +1,81 @@
import payloadConfig from "@/payload.config";
import { CardPropertyData } from "@/schema/property";
import { FetchPropertyParams } from "@/schema/services/property";
import { formatDate } from "@/utils/datetime";
import { getPayload, Where } from "payload";
export async function fetchProperty({
page,
name = "",
location,
min_price,
max_price,
min_area,
max_area,
}: FetchPropertyParams = {}) {
const payload = await getPayload({ config: payloadConfig });
const queryCondition: Where = {
_status: { equals: "published" },
};
if (!!name) {
queryCondition["name"] = {
contains: name,
};
}
if (!!min_price) {
queryCondition["base_price"] = {
greater_than_equal: min_price,
};
}
if (!!max_price) {
queryCondition["base_price"] = {
less_than_equal: max_price,
};
}
if (!!min_area) {
queryCondition["aboutGroup.area"] = {
greater_than_equal: min_area,
};
}
if (!!max_area) {
queryCondition["aboutGroup.area"] = {
less_than_equal: max_area,
};
}
if (!!location) {
queryCondition["addressGroup.state_code"] = {
equals: location,
};
}
const dataQuery = await payload.find({
collection: "properties",
page,
pagination: true,
limit: 10,
where: queryCondition,
});
const formattedData: CardPropertyData[] = dataQuery.docs.map((item) => {
return {
slug: "",
title: item.name,
price: item.base_price,
area: item.aboutGroup.area,
propertyType: item.propertyType,
bathrooms_count: item.aboutGroup.bathrooms_count,
bedrooms_count: item.aboutGroup.bedrooms_count,
images: item.images.map((img) =>
typeof img !== "number" ? { url: img?.url ?? "", alt: img.alt } : { url: "", alt: "" }
),
posted_at: formatDate(item.createdAt),
};
});
return {
...dataQuery,
formattedData,
};
}

View File

@ -1,5 +1,18 @@
import { Blog } from "@/payload-types";
export function sanitizeNumber(input: string | undefined | null): number {
if (!input) return 0;
const sanitized = parseFloat(input.replace(/[^0-9.-]+/g, ""));
// Check if the result is a valid number and not NaN or Infinity
if (isNaN(sanitized) || !isFinite(sanitized)) {
return 0;
}
return sanitized;
}
export function sanitizePageNumber(page: any, defaultPage = 1): number {
const parsedPage = Number(page);

View File

@ -5042,6 +5042,7 @@ __metadata:
qs-esm: "npm:^7.0.2"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
react-select: "npm:^5.10.1"
swiper: "npm:^11.2.6"
tailwindcss: "npm:^4"
typescript: "npm:^5"
@ -8817,6 +8818,26 @@ __metadata:
languageName: node
linkType: hard
"react-select@npm:^5.10.1":
version: 5.10.1
resolution: "react-select@npm:5.10.1"
dependencies:
"@babel/runtime": "npm:^7.12.0"
"@emotion/cache": "npm:^11.4.0"
"@emotion/react": "npm:^11.8.1"
"@floating-ui/dom": "npm:^1.0.1"
"@types/react-transition-group": "npm:^4.4.0"
memoize-one: "npm:^6.0.0"
prop-types: "npm:^15.6.0"
react-transition-group: "npm:^4.3.0"
use-isomorphic-layout-effect: "npm:^1.2.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/0d10a249b96150bd648f2575d59c848b8fac7f4d368a97ae84e4aaba5bbc1035deba4cdc82e49a43904b79ec50494505809618b0e98022b2d51e7629551912ed
languageName: node
linkType: hard
"react-transition-group@npm:4.4.5, react-transition-group@npm:^4.3.0":
version: 4.4.5
resolution: "react-transition-group@npm:4.4.5"