feat: property detail FE integration

This commit is contained in:
RizqiSyahrendra 2025-04-23 16:22:30 +07:00
parent 6fbbb73e05
commit e483f8f281
8 changed files with 293 additions and 237 deletions

View File

@ -1,48 +1,41 @@
import CardProperty from "@/components/CardProperty";
import HeroImage from "@/components/HeroImage";
import { CardPropertyData } from "@/schema/property";
import { formatCurrency } from "@/utils/general";
import { fetchPropertyDetail, fetchPropertySuggestion } from "@/services/payload/property";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { headers } from "next/headers";
import Image from "next/image";
import { notFound } from "next/navigation";
const similarPropertiesData: CardPropertyData[] = [
{
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",
},
{
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",
},
];
export default async function ListingsForRentDetail({ params }: { params: Promise<{ slug: string }> }) {
const slug = (await params).slug;
const propertyDetail = await fetchPropertyDetail({ slug });
if (!propertyDetail) return notFound();
const { data, formattedData } = propertyDetail;
const isEmbedMapUrlValid = !!data?.embed_map_url && data.embed_map_url.includes("www.google.com/maps/embed");
const headersList = await headers();
const fullUrl = headersList.get("x-full-url");
const shareUrl = {
facebook: `https://www.facebook.com/sharer/sharer.php?u=${fullUrl}`,
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${fullUrl}`,
twitter: `https://twitter.com/intent/tweet?url=${fullUrl}`,
};
const similarPropertiesData = await fetchPropertySuggestion();
export default function ListingsForRentDetail() {
return (
<>
<HeroImage title="Lorem Ipsum, Dolor" />
<HeroImage title={data.name} />
<section className="section section-md bg-gray-12">
<div className="container">
<div className="row row-50">
<div className="col-lg-7 col-xl-8">
<div className="slick-slider-1">
<div className="slick-slider-price">$5000\mo</div>
<div className="slick-slider-price bg-colorPriceTag/90!">
{formattedData.price}
{data.property_type === "rent" && "/mo"}
</div>
<div
className="slick-slider carousel-parent"
id="parent-carousel"
@ -55,24 +48,13 @@ export default function ListingsForRentDetail() {
data-child="#child-carousel"
data-for="#child-carousel"
>
<div className="item">
<img src="/images/single-property-1-763x443.jpg" alt="" width="763" height="443" />
</div>
<div className="item">
<img src="/images/single-property-2-763x443.jpg" alt="" width="763" height="443" />
</div>
<div className="item">
<img src="/images/single-property-3-763x443.jpg" alt="" width="763" height="443" />
</div>
<div className="item">
<img src="/images/single-property-4-763x443.jpg" alt="" width="763" height="443" />
</div>
<div className="item">
<img src="/images/single-property-5-763x443.jpg" alt="" width="763" height="443" />
</div>
<div className="item">
<img src="/images/single-property-6-763x443.jpg" alt="" width="763" height="443" />
</div>
{formattedData.images.map((img, idx) => (
<div key={idx} className="item">
<div className="bg-colorImgPlaceholder/90 w-full! h-[443px]! relative">
<Image src={img.url} alt={img.alt} fill />
</div>
</div>
))}
</div>
<div
className="slick-slider carousel-child"
@ -89,62 +71,50 @@ export default function ListingsForRentDetail() {
data-slide-to-scroll="1"
data-for="#parent-carousel"
>
<div>
<div className="slick-slide-inner bg-[url(/images/single-property-1-763x443.jpg)]"></div>
</div>
<div>
<div className="slick-slide-inner bg-[url(/images/single-property-1-763x443.jpg)]"></div>
</div>
<div>
<div className="slick-slide-inner bg-[url(/images/single-property-1-763x443.jpg)]"></div>
</div>
<div>
<div className="slick-slide-inner bg-[url(/images/single-property-1-763x443.jpg)]"></div>
</div>
<div>
<div className="slick-slide-inner bg-[url(/images/single-property-1-763x443.jpg)]"></div>
</div>
<div>
<div className="slick-slide-inner bg-[url(/images/single-property-1-763x443.jpg)]"></div>
</div>
{formattedData.images.map((img, idx) => (
<div key={idx}>
<div className="bg-colorImgPlaceholder/90 w-[135px]! h-[89px]! relative">
<Image src={img.url} alt={img.alt} fill />
</div>
</div>
))}
</div>
</div>
<div className="features-block">
<div className="features-block-inner">
<div className="features-block-item">
<ul className="features-block-list">
<li>
<span className="icon hotel-icon-10"></span>
<span>2 Bathrooms</span>
</li>
<li>
<span className="icon hotel-icon-05"></span>
<span>2 Bedrooms</span>
</li>
<li>
<span className="icon mdi mdi-vector-square"></span>
<span>480 Sq Ft</span>
</li>
{!!data?.aboutGroup?.bathrooms_count && (
<li>
<span className="icon hotel-icon-10"></span>
<span>{data.aboutGroup.bathrooms_count} Bathrooms</span>
</li>
)}
{!!data?.aboutGroup?.bedrooms_count && (
<li>
<span className="icon hotel-icon-05"></span>
<span>{data.aboutGroup.bedrooms_count} Bedrooms</span>
</li>
)}
{!!data?.aboutGroup?.area && (
<li>
<span className="icon mdi mdi-vector-square"></span>
<span>{data.aboutGroup.area} Sq Ft</span>
</li>
)}
</ul>
</div>
<div className="features-block-item">
<a className="link link-1" href="#">
{/* <a className="link link-1" href="#">
<span className="icon mdi mdi-heart-outline"></span>Add to Favorites
</a>
</a> */}
</div>
</div>
</div>
<p>
Choose this property if you are looking for a modern house near the ocean shore. With 2 bathrooms and 2
bedrooms as well as a single garage, it is a perfect option for a small family.
</p>
<p>
This home has been completely renovated within the past year and features amazing views and sunsets of
the local lake, solid wood cabinets (and loads of them), granite counters with colored glass backsplash,
sliding glass doors across the entire family room allowing beautiful views of the lake etc. Its
affordable price serves as a great bonus for a family looking for an opportunity to save money on Miami
MyHome.
</p>
<div className="mt-4">
<RichText data={data.aboutGroup.description} />
</div>
<div
className="card-group-custom card-group-corporate"
@ -179,27 +149,19 @@ export default function ListingsForRentDetail() {
<div className="layout-1">
<dl className="list-terms-inline">
<dt>Address:</dt>
<dd>Biscayne Blvd</dd>
<dd>{data?.addressGroup?.address ?? ""}</dd>
</dl>
<dl className="list-terms-inline">
<dt>State/County:</dt>
<dd>Florida</dd>
<dd>{data?.addressGroup?.state_code ?? ""}</dd>
</dl>
<dl className="list-terms-inline">
<dt>City:</dt>
<dd>Miami</dd>
<dd>{data?.addressGroup?.city_code ?? ""}</dd>
</dl>
<dl className="list-terms-inline">
<dt>Zip:</dt>
<dd>8322</dd>
</dl>
<dl className="list-terms-inline">
<dt>Country:</dt>
<dd>United States</dd>
</dl>
<dl className="list-terms-inline">
<dt>Area:</dt>
<dd>Lake Worth</dd>
<dd>{data?.addressGroup?.zip_code ?? ""}</dd>
</dl>
</div>
</div>
@ -237,101 +199,97 @@ export default function ListingsForRentDetail() {
>
<div className="card-body">
<ul className="list-marked-2 layout-2">
<li>2 Stories</li>
<li>Basketball Court</li>
<li>Lawn</li>
<li>Gym</li>
<li>Fireplace</li>
<li>Sprinklers</li>
<li>Private Space</li>
<li>Balcony</li>
<li>Laundry</li>
<li>Ocean View</li>
{Array.isArray(data.features) &&
data.features.length > 0 &&
data.features.map((ft, idx) => <li key={idx}>{typeof ft !== "number" && ft.name}</li>)}
</ul>
</div>
</div>
</article>
</div>
<div
className="card-group-custom card-group-corporate"
id="accordion0"
role="tablist"
aria-multiselectable="false"
>
<article className="card card-custom card-corporate">
<div className="card-header" id="accordion0-heading-0" role="tab">
<div className="card-title">
<a
className="card-link"
role="button"
data-toggle="collapse"
href="#accordion0-collapse-0"
aria-controls="accordion0-collapse-0"
aria-expanded="true"
>
<span>Pricing Detail</span>
<div className="card-arrow"></div>
</a>
</div>
</div>
<div
className="show visible!"
id="accordion0-collapse-0"
role="tabpanel"
aria-labelledby="accordion0-heading-0"
data-parent="#accordion0"
>
<div className="card-body">
<div className="layout-1 columns-1!">
<dl className="list-terms-inline w-full flex justify-between">
<dt>Base Rent</dt>
<dd>{formatCurrency(2700)}</dd>
</dl>
</div>
<div className="layout-1 columns-1!">
<dl className="list-terms-inline w-full flex justify-between">
<dt>Smart Home</dt>
<dd>{formatCurrency(20)}</dd>
</dl>
</div>
<div className="layout-1 columns-1!">
<dl className="list-terms-inline w-full flex justify-between">
<dt>Utility Service</dt>
<dd>{formatCurrency(5)}</dd>
</dl>
</div>
<div className="layout-1 columns-1! mt-2">
<dl className="list-terms-inline w-full flex justify-between">
<dd className="font-semibold!">Est. total monthly*</dd>
<dd className="font-semibold!">{formatCurrency(2900)}</dd>
</dl>
</div>
</div>
</div>
</article>
</div>
<div className="block-group-item">
<h3>Property Map</h3>
<div className="row row-30">
<div className="col-12">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3360.4823498818855!2d-83.68565822483802!3d32.61997607372962!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x88f3e6ce99781991%3A0xabfd803ad30f6d12!2s100%20N%20Houston%20Lake%20Blvd%2C%20Centerville%2C%20GA%2031028%2C%20USA!5e0!3m2!1sen!2sid!4v1744883077476!5m2!1sen!2sid"
width={"100%"}
height={450}
style={{ border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
{data.property_type === "rent" && (
<div
className="card-group-custom card-group-corporate"
id="accordion0"
role="tablist"
aria-multiselectable="false"
>
<article className="card card-custom card-corporate">
<div className="card-header" id="accordion0-heading-0" role="tab">
<div className="card-title">
<a
className="card-link"
role="button"
data-toggle="collapse"
href="#accordion0-collapse-0"
aria-controls="accordion0-collapse-0"
aria-expanded="true"
>
<span>Pricing Detail</span>
<div className="card-arrow"></div>
</a>
</div>
</div>
<div
className="show visible!"
id="accordion0-collapse-0"
role="tabpanel"
aria-labelledby="accordion0-heading-0"
data-parent="#accordion0"
>
<div className="card-body">
<div className="layout-1 columns-1!">
<dl className="list-terms-inline w-full flex justify-between">
<dt>Base Rent</dt>
<dd>{formattedData.price}</dd>
</dl>
</div>
{formattedData.additionalPrice.map((p, idx) => (
<div className="layout-1 columns-1!" key={idx}>
<dl className="list-terms-inline w-full flex justify-between">
<dt>{p.name}</dt>
<dd>{p.price}</dd>
</dl>
</div>
))}
<div className="layout-1 columns-1! mt-2">
<dl className="list-terms-inline w-full flex justify-between">
<dd className="font-semibold!">Est. total monthly*</dd>
<dd className="font-semibold!">{formattedData.totalPrice}</dd>
</dl>
</div>
</div>
</div>
</article>
</div>
)}
{isEmbedMapUrlValid && (
<div className="block-group-item">
<h3>Property Map</h3>
<div className="row row-30">
<div className="col-12">
<iframe
src={data.embed_map_url ?? ""}
width={"100%"}
height={450}
style={{ border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
</div>
</div>
</div>
)}
<div className="blog-post-solo-footer mt-20">
<div className="blog-post-solo-footer-left">
<ul className="blog-post-solo-footer-list">
<li>
<span className="icon mdi mdi-clock"></span>
<a href="#">February 10, 2021</a>
<a href="#">{formattedData.postedAt}</a>
</li>
</ul>
</div>
@ -343,16 +301,13 @@ export default function ListingsForRentDetail() {
<li>
<ul className="list-inline-1">
<li>
<a className="icon link-default fa-facebook" href="#"></a>
<a target="_blank" className="icon link-default fa-facebook" href={shareUrl.facebook}></a>
</li>
<li>
<a className="icon link-default fa-twitter" href="#"></a>
<a target="_blank" className="icon link-default fa-twitter" href={shareUrl.twitter}></a>
</li>
<li>
<a className="icon link-default fa-google-plus" href="#"></a>
</li>
<li>
<a className="icon link-default fa-pinterest-p" href="#"></a>
<a target="_blank" className="icon link-default fa-linkedin" href={shareUrl.linkedin}></a>
</li>
</ul>
</li>
@ -360,14 +315,16 @@ export default function ListingsForRentDetail() {
</div>
</div>
<div className="block-group-item">
<h3>Similar Properties</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{similarPropertiesData.map((p, idx) => (
<CardProperty key={idx} data={p} />
))}
{similarPropertiesData.formattedData.length > 0 && (
<div className="block-group-item">
<h3>Other Properties</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{similarPropertiesData.formattedData.map((p, idx) => (
<CardProperty key={idx} data={p} />
))}
</div>
</div>
</div>
)}
</div>
<div className="col-lg-5 col-xl-4">
<div className="row row-50">

View File

@ -30,6 +30,7 @@ export default async function ListingsForRent(props: {
const maxArea = sanitizeNumber(searchParams?.max_area);
const propertiesData = await fetchProperty({
property_type: "rent",
page,
name: searchParams?.name,
min_price: minPrice,

View File

@ -11,7 +11,7 @@ export const Properties: CollectionConfig = {
},
fields: [
{
name: "propertyType",
name: "property_type",
label: "Type",
type: "select",
options: [
@ -56,20 +56,18 @@ export const Properties: CollectionConfig = {
{
name: "area",
label: "Area (Sqft)",
type: "text",
type: "number",
required: true,
},
{
name: "bathrooms_count",
label: "Total Bathrooms",
type: "text",
required: true,
type: "number",
},
{
name: "bedrooms_count",
label: "Total Bedrooms",
type: "text",
required: true,
type: "number",
},
],
},
@ -79,8 +77,8 @@ export const Properties: CollectionConfig = {
type: "group",
fields: [
{
name: "country_code",
label: "Country",
name: "state_code",
label: "State",
type: "text",
// admin: {
// components: {
@ -90,11 +88,6 @@ export const Properties: CollectionConfig = {
// },
// },
},
{
name: "state_code",
label: "State",
type: "text",
},
{
name: "city_code",
label: "City",
@ -124,7 +117,7 @@ export const Properties: CollectionConfig = {
},
{
name: "base_price",
label: "Base Price",
label: "Price",
type: "number",
required: true,
},
@ -147,7 +140,7 @@ export const Properties: CollectionConfig = {
},
{
name: "embed_map_url",
label: "Embed Map URL",
label: "Embed Google Map URL",
type: "text",
},
],

View File

@ -8,7 +8,7 @@ type CardPropertyProps = {
};
export default function CardProperty({ data }: CardPropertyProps) {
const href = data?.propertyType === "sell" ? `/listings-for-rent/${data.slug}` : `/listings-for-sell/${data.slug}`;
const href = data?.propertyType === "rent" ? `/listings-for-rent/${data.slug}` : `/listings-for-sell/${data.slug}`;
return (
<div>
<article className="product-classic">
@ -24,7 +24,7 @@ export default function CardProperty({ data }: CardPropertyProps) {
>
{Array.isArray(data.images) &&
data.images.map((img, idx) => (
<div key={idx} className="w-full h-52 bg-colorImgPlaceholder">
<div key={idx} className="w-full h-52 bg-colorImgPlaceholder/90">
<Image src={img.url} alt={img.alt ?? ""} fill className="object-cover" />
</div>
))}
@ -41,17 +41,19 @@ export default function CardProperty({ data }: CardPropertyProps) {
</h4>
<div className="product-classic-divider"></div>
<ul className="product-classic-list">
<li>
<span className="icon mdi mdi-vector-square"></span>
<span>{data.area} Sq Ft</span>
</li>
{data.bathrooms_count && (
{!!data.area && (
<li>
<span className="icon mdi mdi-vector-square"></span>
<span>{data.area} Sq Ft</span>
</li>
)}
{!!data.bathrooms_count && (
<li>
<span className="icon hotel-icon-10"></span>
<span>{data.bathrooms_count} Bathrooms</span>
</li>
)}
{data.bedrooms_count && (
{!!data.bedrooms_count && (
<li>
<span className="icon hotel-icon-05"></span>
<span>{data.bedrooms_count} Bedrooms</span>

View File

@ -239,7 +239,7 @@ export interface PropertyFeature {
*/
export interface Property {
id: number;
propertyType: 'rent' | 'sell';
property_type: 'rent' | 'sell';
name: string;
slug?: string | null;
images: (number | Media)[];
@ -259,12 +259,11 @@ export interface Property {
};
[k: string]: unknown;
};
area: string;
bathrooms_count: string;
bedrooms_count: string;
area: number;
bathrooms_count?: number | null;
bedrooms_count?: number | null;
};
addressGroup: {
country_code?: string | null;
state_code?: string | null;
city_code?: string | null;
zip_code: string;
@ -457,7 +456,7 @@ export interface PropertyFeaturesSelect<T extends boolean = true> {
* via the `definition` "properties_select".
*/
export interface PropertiesSelect<T extends boolean = true> {
propertyType?: T;
property_type?: T;
name?: T;
slug?: T;
images?: T;
@ -472,7 +471,6 @@ export interface PropertiesSelect<T extends boolean = true> {
addressGroup?:
| T
| {
country_code?: T;
state_code?: T;
city_code?: T;
zip_code?: T;

View File

@ -6,9 +6,9 @@ export type CardPropertyData = {
/**
* in sqft
*/
area: string;
bedrooms_count?: string;
bathrooms_count?: string;
area?: number | null;
bedrooms_count?: number | null;
bathrooms_count?: number | null;
posted_at: string;
propertyType: "rent" | "sell";
};

View File

@ -6,4 +6,9 @@ export type FetchPropertyParams = {
min_price?: number;
max_price?: number;
location?: string;
property_type?: "rent" | "sell";
};
export type FetchPropertyDetailParams = {
slug: string;
};

View File

@ -1,7 +1,8 @@
import payloadConfig from "@/payload.config";
import { CardPropertyData } from "@/schema/property";
import { FetchPropertyParams } from "@/schema/services/property";
import { FetchPropertyDetailParams, FetchPropertyParams } from "@/schema/services/property";
import { formatDate } from "@/utils/datetime";
import { formatCurrency, getRandomNumber } from "@/utils/general";
import { getPayload, Where } from "payload";
export async function fetchProperty({
@ -12,6 +13,7 @@ export async function fetchProperty({
max_price,
min_area,
max_area,
property_type,
}: FetchPropertyParams = {}) {
const payload = await getPayload({ config: payloadConfig });
@ -19,6 +21,11 @@ export async function fetchProperty({
_status: { equals: "published" },
};
if (!!property_type) {
queryCondition["property_type"] = {
equals: property_type,
};
}
if (!!name) {
queryCondition["name"] = {
contains: name,
@ -60,11 +67,11 @@ export async function fetchProperty({
const formattedData: CardPropertyData[] = dataQuery.docs.map((item) => {
return {
slug: "",
slug: item.slug ?? "",
title: item.name,
price: item.base_price,
area: item.aboutGroup.area,
propertyType: item.propertyType,
propertyType: item.property_type,
bathrooms_count: item.aboutGroup.bathrooms_count,
bedrooms_count: item.aboutGroup.bedrooms_count,
images: item.images.map((img) =>
@ -79,3 +86,96 @@ export async function fetchProperty({
formattedData,
};
}
export async function fetchPropertySuggestion() {
const payload = await getPayload({ config: payloadConfig });
const limitPerPage = 2;
const countrQuery = await payload.count({
collection: "properties",
where: { _status: { equals: "published" } },
});
// randomize page
let page = 1;
const totalDocs = countrQuery.totalDocs;
if (totalDocs > limitPerPage) {
const totalPage = Math.ceil(totalDocs / limitPerPage);
page = getRandomNumber(totalPage);
}
const dataQuery = await payload.find({
collection: "properties",
page,
limit: limitPerPage,
});
const formattedData: CardPropertyData[] = dataQuery.docs.map((item) => {
return {
slug: item.slug ?? "",
title: item.name,
price: item.base_price,
area: item.aboutGroup.area,
propertyType: item.property_type,
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,
};
}
export async function fetchPropertyDetail({ slug }: FetchPropertyDetailParams) {
const payload = await getPayload({ config: payloadConfig });
const queryCondition: Where = {
_status: { equals: "published" },
slug: { equals: slug },
};
const dataQuery = await payload.find({
collection: "properties",
where: queryCondition,
limit: 1,
pagination: false,
});
if (!dataQuery?.docs?.[0]) return null;
const data = dataQuery?.docs?.[0];
const postedAt = formatDate(data.createdAt);
const images = data.images.map((img) =>
typeof img !== "number" ? { url: img?.url ?? "", alt: img.alt } : { url: "", alt: "" }
);
const formattedBasePrice = formatCurrency(data.base_price);
let additionalPrice: { name: string; price: string }[] = [];
let totalPrice = 0;
if (Array.isArray(data.additional_price)) {
for (const p of data.additional_price) {
additionalPrice.push({
name: p.name,
price: formatCurrency(p.price),
});
totalPrice += p.price;
}
}
const formattedTotalPrice = formatCurrency(data.base_price + totalPrice);
return {
data,
formattedData: {
price: formattedBasePrice,
additionalPrice,
totalPrice: formattedTotalPrice,
images,
postedAt,
},
};
}