Merge pull request 'dev' (#9) from dev into main

Reviewed-on: #9
This commit is contained in:
RizqiSyahrendra 2025-04-23 11:00:56 +00:00
commit 9e63d7cb82
31 changed files with 1768 additions and 78 deletions

View File

@ -17,6 +17,7 @@
"@payloadcms/payload-cloud": "^3.35.1", "@payloadcms/payload-cloud": "^3.35.1",
"@payloadcms/richtext-lexical": "^3.35.1", "@payloadcms/richtext-lexical": "^3.35.1",
"@payloadcms/storage-s3": "^3.35.1", "@payloadcms/storage-s3": "^3.35.1",
"country-state-city": "^3.2.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"graphql": "^16.8.1", "graphql": "^16.8.1",
"next": "15.3.0", "next": "15.3.0",
@ -24,6 +25,7 @@
"qs-esm": "^7.0.2", "qs-esm": "^7.0.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-select": "^5.10.1",
"swiper": "^11.2.6" "swiper": "^11.2.6"
}, },
"devDependencies": { "devDependencies": {

View File

@ -588,9 +588,9 @@ a:hover {
color: #967244; color: #967244;
} }
a[href*='tel'], a[href*='mailto'] { /* a[href*='tel'], a[href*='mailto'] {
white-space: nowrap; white-space: nowrap;
} } */
.link-default, .link-default:active, .link-default:focus { .link-default, .link-default:active, .link-default:focus {
color: #424445; color: #424445;
@ -1177,7 +1177,7 @@ a.privacy-link {
padding: 12px 11px; padding: 12px 11px;
color: #9cc1ff; color: #9cc1ff;
letter-spacing: 0; letter-spacing: 0;
background-color: #31323c; background-color: var(--color-colorContactForm);
} }
.block-callboard a, .block-callboard a:focus, .block-callboard a:active { .block-callboard a, .block-callboard a:focus, .block-callboard a:active {

View File

@ -1,3 +1,4 @@
import ListOfRecentBlog from "@/components/blogs/ListOfRecentBlog";
import HeroImage from "@/components/HeroImage"; import HeroImage from "@/components/HeroImage";
import { fetchBlogDetail } from "@/services/payload/blog"; import { fetchBlogDetail } from "@/services/payload/blog";
import { getDefaultMetadata } from "@/utils/metadata"; import { getDefaultMetadata } from "@/utils/metadata";
@ -110,65 +111,31 @@ export default async function BlogDetail(props: { params: Promise<{ slug: string
<span>Share this post</span> <span>Share this post</span>
</li> </li>
<li> <li>
<a className="icon icon-circle icon-rounded icon-5 fa-facebook" href={shareUrl.facebook}></a> <a
target="_blank"
className="icon icon-circle icon-rounded icon-5 fa-facebook"
href={shareUrl.facebook}
></a>
</li> </li>
<li> <li>
<a className="icon icon-circle icon-rounded icon-6 fa-twitter" href={shareUrl.twitter}></a> <a
target="_blank"
className="icon icon-circle icon-rounded icon-6 fa-twitter"
href={shareUrl.twitter}
></a>
</li> </li>
<li> <li>
<a className="icon icon-circle icon-rounded icon-4 fa-linkedin" href={shareUrl.linkedin}></a> <a
target="_blank"
className="icon icon-circle icon-rounded icon-4 fa-linkedin"
href={shareUrl.linkedin}
></a>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="post-simple-group">
<div className="post-simple-group-title"> <ListOfRecentBlog currentBlogId={blog?.data?.id} />
<h6>Recent Posts</h6>
</div>
<div className="post-simple-group-divider"></div>
<div className="row row-30">
<div className="col-sm-6">
<article className="post-simple">
<div className="post-simple-img">
<Image src="/images/blog-post-03-736x540.jpg" alt="" width="736" height="540" />
</div>
<div className="post-simple-body">
<div className="post-simple-title">
<h4>
<a href="#">Turks and Caicos Villa to be Sold for Record $7.6M</a>
</h4>
</div>
<div className="post-simple-time">
<span className="icon mdi mdi-clock"></span>
<a className="time" href="#">
March 15, 2021
</a>
</div>
</div>
</article>
</div>
<div className="col-sm-6">
<article className="post-simple">
<div className="post-simple-img">
<Image src="/images/blog-post-04-736x540.jpg" alt="" width="736" height="540" />
</div>
<div className="post-simple-body">
<div className="post-simple-title">
<h4>
<a href="#">How We Build a Better LA for Fifth Year in a Row</a>
</h4>
</div>
<div className="post-simple-time">
<span className="icon mdi mdi-clock"></span>
<a className="time" href="#">
March 15, 2021
</a>
</div>
</div>
</article>
</div>
</div>
</div>
</article> </article>
</div> </div>

View File

@ -34,6 +34,7 @@
--color-colorText1: var(--color-colorExt10); --color-colorText1: var(--color-colorExt10);
--color-colorText2: var(--color-colorExt20); --color-colorText2: var(--color-colorExt20);
--color-colorLoaderBackground: var(--color-colorExt20); --color-colorLoaderBackground: var(--color-colorExt20);
--color-colorPriceTag: var(--color-colorExt30);
} }
@layer components { @layer components {

View File

@ -0,0 +1,95 @@
import HeroImage from "@/components/HeroImage";
import Pagination from "@/components/Pagination";
import CardProperty from "@/components/properties/CardProperty";
import FilterProperty from "@/components/properties/FilterProperty";
import { FetchPropertyParams } from "@/schema/services/property";
import { fetchProperty } from "@/services/payload/property";
import { getDefaultMetadata } from "@/utils/metadata";
import { sanitizeNumber, sanitizePageNumber } from "@/utils/sanitize";
import { Metadata } from "next";
const metaDesc = "Explore the latest properties on the Dynamic Realty.";
export async function generateMetadata(): Promise<Metadata> {
const metadata = await getDefaultMetadata();
metadata.title = `Listings For Rent - ${metadata.openGraph?.siteName}`;
metadata.description = metaDesc;
return metadata;
}
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({
property_type: "rent",
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;
return (
<>
<HeroImage title="Listings For Rent" />
<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="row row-30">
<div className="col-12">
{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>
)}
{!isEmpty && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{propertiesData.formattedData.map((p, idx) => (
<CardProperty key={idx} data={p} />
))}
</div>
)}
</div>
{/* Pagination */}
{propertiesData.totalPages > 1 && (
<div className="col-12">
<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">
<div className="row row-50">
<div className="col-md-6 col-lg-12">
<FilterProperty propertyType="rent" searchParams={searchParams} />
</div>
</div>
</div>
</div>
</div>
</section>
</>
);
}

View File

@ -0,0 +1,95 @@
import HeroImage from "@/components/HeroImage";
import Pagination from "@/components/Pagination";
import CardProperty from "@/components/properties/CardProperty";
import FilterProperty from "@/components/properties/FilterProperty";
import { FetchPropertyParams } from "@/schema/services/property";
import { fetchProperty } from "@/services/payload/property";
import { getDefaultMetadata } from "@/utils/metadata";
import { sanitizeNumber, sanitizePageNumber } from "@/utils/sanitize";
import { Metadata } from "next";
const metaDesc = "Explore the latest properties on the Dynamic Realty.";
export async function generateMetadata(): Promise<Metadata> {
const metadata = await getDefaultMetadata();
metadata.title = `Listings For Sale - ${metadata.openGraph?.siteName}`;
metadata.description = metaDesc;
return metadata;
}
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({
property_type: "sell",
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;
return (
<>
<HeroImage title="Listings For Sale" />
<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="row row-30">
<div className="col-12">
{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>
)}
{!isEmpty && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{propertiesData.formattedData.map((p, idx) => (
<CardProperty key={idx} data={p} />
))}
</div>
)}
</div>
{/* Pagination */}
{propertiesData.totalPages > 1 && (
<div className="col-12">
<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">
<div className="row row-50">
<div className="col-md-6 col-lg-12">
<FilterProperty propertyType="rent" searchParams={searchParams} />
</div>
</div>
</div>
</div>
</div>
</section>
</>
);
}

View File

@ -0,0 +1,473 @@
import CardProperty from "@/components/properties/CardProperty";
import HeroImage from "@/components/HeroImage";
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";
import FilterProperty from "@/components/properties/FilterProperty";
import { getDefaultMetadata } from "@/utils/metadata";
import { sanitizeBlogContentIntoStringPreview } from "@/utils/sanitize";
import { Metadata } from "next";
export async function generateMetadata(props: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const metadata = await getDefaultMetadata();
const params = await props.params;
let title = "Page";
let description = "Page";
let publishedAt = "";
let updatedAt = "";
let imgUrl = "";
let createdByName = "";
const property = await fetchPropertyDetail({ slug: params.slug });
if (!!property) {
// check for property data
title = `${!!property.data?.name ? property.data?.name : ""} - ${metadata.openGraph?.siteName}`;
description = sanitizeBlogContentIntoStringPreview(property.data.aboutGroup.description, 50);
imgUrl = property.formattedData.images.length > 0 ? property.formattedData.images[0].url : "";
publishedAt = property.data.createdAt;
updatedAt = property.data.updatedAt;
if (!!property?.data?.createdBy && typeof property.data.createdBy !== "number") {
createdByName = property.data.createdBy?.name ?? "";
}
}
metadata.title = title;
metadata.description = description;
if (!!metadata.openGraph) {
// @ts-ignore
metadata.openGraph.type = "article";
metadata.openGraph.title = title;
metadata.openGraph.description = description;
metadata.openGraph.images = !!imgUrl ? [imgUrl] : undefined;
}
metadata.twitter = {
card: "summary_large_image",
title: title,
description: description,
images: !!imgUrl ? [imgUrl] : undefined,
};
metadata.other = {
"article:published_time": publishedAt,
"article:modified_time": updatedAt,
"twitter:label1": "Written by",
"twitter:data1": !!createdByName ? createdByName : "Admin",
"twitter:label2": "Est. reading time",
"twitter:data2": "3 minutes",
};
return metadata;
}
export default async function PropertyDetail({ 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();
return (
<>
<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 bg-colorPriceTag/90!">
{formattedData.price}
{data.property_type === "rent" && "/mo"}
</div>
<div
className="slick-slider carousel-parent"
id="parent-carousel"
data-arrows="true"
data-loop="true"
data-dots="false"
data-swipe="true"
data-fade="true"
data-items="1"
data-child="#child-carousel"
data-for="#child-carousel"
>
{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"
id="child-carousel"
data-arrows="true"
data-loop="true"
data-dots="false"
data-swipe="true"
data-items="1"
data-sm-items="3"
data-md-items="4"
data-lg-items="4"
data-xl-items="5"
data-slide-to-scroll="1"
data-for="#parent-carousel"
>
{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">
{!!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="#">
<span className="icon mdi mdi-heart-outline"></span>Add to Favorites
</a> */}
</div>
</div>
</div>
<div className="mt-4">
<RichText data={data.aboutGroup.description} />
</div>
<div
className="card-group-custom card-group-corporate"
id="accordion1"
role="tablist"
aria-multiselectable="false"
>
<article className="card card-custom card-corporate">
<div className="card-header" id="accordion1-heading-1" role="tab">
<div className="card-title">
<a
className="card-link"
role="button"
data-toggle="collapse"
href="#accordion1-collapse-1"
aria-controls="accordion1-collapse-1"
aria-expanded="true"
>
<span>Address</span>
<div className="card-arrow"></div>
</a>
</div>
</div>
<div
className="show visible!"
id="accordion1-collapse-1"
role="tabpanel"
aria-labelledby="accordion1-heading-1"
data-parent="#accordion1"
>
<div className="card-body">
<div className="layout-1">
<dl className="list-terms-inline">
<dt>Address:</dt>
<dd>{data?.addressGroup?.address ?? ""}</dd>
</dl>
<dl className="list-terms-inline">
<dt>State/County:</dt>
<dd>{data?.addressGroup?.state_code ?? ""}</dd>
</dl>
<dl className="list-terms-inline">
<dt>City:</dt>
<dd>{data?.addressGroup?.city_code ?? ""}</dd>
</dl>
<dl className="list-terms-inline">
<dt>Zip:</dt>
<dd>{data?.addressGroup?.zip_code ?? ""}</dd>
</dl>
</div>
</div>
</div>
</article>
</div>
<div
className="card-group-custom card-group-corporate"
id="accordion2"
role="tablist"
aria-multiselectable="false"
>
<article className="card card-custom card-corporate">
<div className="card-header" id="accordion2-heading-1" role="tab">
<div className="card-title">
<a
className="card-link"
role="button"
data-toggle="collapse"
href="#accordion2-collapse-1"
aria-controls="accordion2-collapse-1"
aria-expanded="true"
>
<span>Features</span>
<div className="card-arrow"></div>
</a>
</div>
</div>
<div
className="show visible!"
id="accordion2-collapse-1"
role="tabpanel"
aria-labelledby="accordion2-heading-1"
data-parent="#accordion2"
>
<div className="card-body">
<ul className="list-marked-2 layout-2">
{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>
{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 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="#">{formattedData.postedAt}</a>
</li>
</ul>
</div>
<div className="blog-post-solo-footer-right">
<ul className="blog-post-solo-footer-list-1">
<li>
<span>Share this post</span>
</li>
<li>
<ul className="list-inline-1">
<li>
<a target="_blank" className="icon link-default fa-facebook" href={shareUrl.facebook}></a>
</li>
<li>
<a target="_blank" className="icon link-default fa-twitter" href={shareUrl.twitter}></a>
</li>
<li>
<a target="_blank" className="icon link-default fa-linkedin" href={shareUrl.linkedin}></a>
</li>
</ul>
</li>
</ul>
</div>
</div>
{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 className="col-lg-5 col-xl-4">
<div className="row row-50">
<div className="col-md-6 col-lg-12">
<FilterProperty propertyType={data.property_type} />
</div>
<div className="col-md-6 col-lg-12">
<article className="block-callboard">
<div className="block-callboard-body">
<h3 className="block-callboard-title">Request a Showing</h3>
<form
className="rd-form rd-mailform"
data-form-output="form-output-global"
data-form-type="contact"
method="post"
action="bat/rd-mailform.php"
>
<div className="row row-20">
<div className="col-12">
<div className="form-wrap">
<input
className="form-input"
id="contact-name"
type="text"
name="name"
data-constraints="@Required"
/>
<label className="form-label" htmlFor="contact-name">
Your Name
</label>
</div>
</div>
<div className="col-12">
<div className="form-wrap">
<input
className="form-input"
id="contact-email"
type="email"
name="email"
data-constraints="@Email @Required"
/>
<label className="form-label" htmlFor="contact-email">
E-mail
</label>
</div>
</div>
<div className="col-12">
<div className="form-wrap">
<input
className="form-input"
id="contact-phone"
type="text"
name="phone"
data-constraints="@PhoneNumber"
/>
<label className="form-label" htmlFor="contact-phone">
Phone
</label>
</div>
</div>
<div className="col-12">
<div className="form-wrap">
<label className="form-label" htmlFor="contact-message">
Message
</label>
<textarea
className="form-input"
id="contact-message"
name="message"
data-constraints="@Required"
></textarea>
</div>
</div>
<div className="col-12">
<button className="button button-block button-secondary" type="submit">
Send message
</button>
</div>
</div>
</form>
</div>
</article>
</div>
</div>
</div>
</div>
</div>
</section>
</>
);
}

View File

@ -0,0 +1,175 @@
import formatSlug from "@/utils/payload/formatSlug";
import setAuthor from "@/utils/payload/setAuthor";
import type { CollectionConfig } from "payload";
export const Properties: CollectionConfig = {
slug: "properties",
labels: { plural: "Properties", singular: "Property" },
versions: {
drafts: {
validate: true,
},
},
fields: [
{
name: "property_type",
label: "Type",
type: "select",
options: [
{ label: "Rent", value: "rent" },
{ label: "Sell", value: "sell" },
],
required: true,
},
{
name: "name",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
admin: {
position: "sidebar",
},
hooks: {
beforeValidate: [formatSlug("name")],
},
},
{
name: "images",
type: "upload",
relationTo: "media",
hasMany: true,
minRows: 1,
required: true,
},
{
name: "aboutGroup",
label: "About",
type: "group",
fields: [
{
name: "description",
type: "richText",
required: true,
},
{
name: "area",
label: "Area (Sqft)",
type: "number",
required: true,
},
{
name: "bathrooms_count",
label: "Total Bathrooms",
type: "number",
},
{
name: "bedrooms_count",
label: "Total Bedrooms",
type: "number",
},
],
},
{
name: "addressGroup",
label: "Address",
type: "group",
fields: [
{
name: "state_code",
label: "State",
type: "text",
// admin: {
// components: {
// Field: {
// path: "/components/payload-custom/InputCountry",
// },
// },
// },
},
{
name: "city_code",
label: "City",
type: "text",
},
{
name: "zip_code",
label: "Zip",
type: "text",
required: true,
},
{
name: "address",
label: "Address",
type: "text",
required: true,
},
],
},
{
name: "features",
type: "relationship",
relationTo: "propertyFeatures",
required: true,
hasMany: true,
minRows: 1,
},
{
name: "base_price",
label: "Price",
type: "number",
required: true,
},
{
name: "additional_price",
label: "Additional Price",
type: "array",
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "price",
type: "number",
required: true,
},
],
},
{
name: "embed_map_url",
label: "Embed Google Map URL",
type: "text",
},
{
name: "createdBy",
type: "relationship",
relationTo: "users",
hooks: {
beforeChange: [setAuthor],
},
admin: {
hidden: true,
},
},
{
name: "updatedBy",
type: "relationship",
relationTo: "users",
hooks: {
beforeChange: [setAuthor],
},
admin: {
hidden: true,
},
},
],
admin: {
hideAPIURL: true,
group: "Properties",
useAsTitle: "name",
},
};

View File

@ -0,0 +1,18 @@
import type { CollectionConfig } from "payload";
export const PropertyFeatures: CollectionConfig = {
slug: "propertyFeatures",
labels: { plural: "Property Features", singular: "Property Feature" },
fields: [
{
name: "name",
type: "text",
required: true,
},
],
admin: {
hideAPIURL: true,
group: "Properties",
useAsTitle: "name",
},
};

View File

@ -0,0 +1,120 @@
"use client";
import { usePathname } from "next/navigation";
interface PaginationProps {
page: number;
hasPreviousPage: boolean;
hasNextPage: boolean;
totalPages: number;
usePathParams?: boolean;
}
export default function Pagination({
page,
hasPreviousPage,
hasNextPage,
totalPages,
usePathParams = false,
}: PaginationProps) {
const activePage = page;
const pathName = usePathname();
// Function to handle page change
const handlePageChange = (page: string | number) => {
if (typeof page === "string") return;
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
const searchParams = new URLSearchParams(url.search);
if (usePathParams) {
let updatedPath = "";
if (pathName.includes("/page")) {
updatedPath = pathName.replace(/\/page\/\d+/, `/page/${page}`);
} else {
updatedPath = `${pathName}/page/${page}`;
}
window.location.href = `${updatedPath}?${searchParams}`;
} else {
searchParams.set("page", `${page}`);
window.location.href = `${pathName}/?${searchParams}`;
}
};
const getPageNumbers = () => {
const pages = [];
const showEllipsisStart = activePage > 4;
const showEllipsisEnd = activePage < totalPages - 3;
if (totalPages <= 7) {
// Show all pages if total is 7 or less
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (showEllipsisStart) {
pages.push("...");
}
// Show pages around current page
const start = showEllipsisStart ? Math.max(2, activePage - 1) : 2;
const end = showEllipsisEnd ? Math.min(totalPages - 1, activePage + 1) : totalPages - 1;
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (showEllipsisEnd) {
pages.push("...");
}
// Always show last page
pages.push(totalPages);
}
return pages;
};
return (
<ul className={"pagination-custom"}>
{/* Previous Page Button */}
{hasPreviousPage && (
<>
<li className="cursor-pointer">
<a
onClick={() => activePage > 1 && handlePageChange(activePage - 1)}
className={activePage === 1 ? "disabled" : ""}
>
{"<"}
</a>
</li>
</>
)}
{getPageNumbers().map((page, key) => (
<li key={key} className="cursor-pointer">
<a onClick={() => handlePageChange(page)} className={activePage === page ? "active" : ""}>
{page}
</a>
</li>
))}
{/* Next Page Button */}
{hasNextPage && (
<>
<li className="cursor-pointer">
<a
onClick={() => activePage < totalPages && handlePageChange(activePage + 1)}
className={activePage === totalPages ? "disabled" : ""}
>
{">"}
</a>
</li>
</>
)}
</ul>
);
}

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

@ -3,12 +3,47 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
type CardBlogProps = { type CardBlogProps = {
colorPreset?: 1 | 2;
isDescriptionVisible?: boolean;
data: BlogData; data: BlogData;
}; };
export default function CardBlog({ data }: CardBlogProps) { export default function CardBlog({ data, colorPreset = 1, isDescriptionVisible = true }: CardBlogProps) {
const linkDetail = `/blog/${data.slug}`; const linkDetail = `/blog/${data.slug}`;
if (colorPreset === 2) {
return (
<div>
<article className="post-simple">
<div className="h-64 relative">
<Link href={linkDetail}>
<Image src={data.img?.url ?? ""} alt={data.img?.alt ?? ""} fill />
</Link>
</div>
<div className="post-simple-body">
<div className="post-simple-title">
<h4>
<Link href={linkDetail}>{data.title}</Link>
</h4>
</div>
{isDescriptionVisible && !!data?.description && (
<>
<div className="post-simple-divider"></div>
<div className="post-simple-text">
<p>{data.description}</p>
</div>
</>
)}
<div className="post-simple-time">
<span className="icon mdi mdi-clock"></span>
<span>{data.posted_at}</span>
</div>
</div>
</article>
</div>
);
}
return ( return (
<div> <div>
<article className="post-default"> <article className="post-default">
@ -23,10 +58,14 @@ export default function CardBlog({ data }: CardBlogProps) {
<Link href={linkDetail}>{data.title}</Link> <Link href={linkDetail}>{data.title}</Link>
</h4> </h4>
</div> </div>
<div className="post-default-divider"></div> {isDescriptionVisible && !!data?.description && (
<div className="post-default-text"> <>
<p>{data.description}</p> <div className="post-default-divider"></div>
</div> <div className="post-default-text">
<p>{data.description}</p>
</div>
</>
)}
<div className="post-default-time"> <div className="post-default-time">
<span className="icon mdi mdi-clock"></span> <span className="icon mdi mdi-clock"></span>
<span>{data.posted_at}</span> <span>{data.posted_at}</span>

View File

@ -36,7 +36,7 @@ export default function ListOfBlog({ searchKeyword }: ListOfBlogProps) {
</div> </div>
<div className="mt-5"> <div className="mt-5">
{blogQuery.isFetching && <Loader />} {blogQuery.isFetching && <Loader />}
{blogQuery.hasNext && ( {!blogQuery.isFetching && blogQuery.hasNext && (
<button onClick={fetchMore} className="button button-primary"> <button onClick={fetchMore} className="button button-primary">
LOAD MORE... LOAD MORE...
</button> </button>

View File

@ -0,0 +1,48 @@
"use client";
import Loader from "@/components/loaders/Loader";
import { useRecentBlogQuery } from "@/services/hooks/blog";
import { useEffect } from "react";
import CardBlog from "./CardBlog";
type ListOfRecentBlogProps = {
currentBlogId?: number;
};
export default function ListOfRecentBlog({ currentBlogId }: ListOfRecentBlogProps) {
const recentBlogQuery = useRecentBlogQuery();
useEffect(() => {
if (!!currentBlogId) {
recentBlogQuery._fetch({
currentBlogId,
});
}
}, [currentBlogId]);
if (recentBlogQuery.isFetching) {
return (
<div className="mt-5 w-full">
<Loader />
</div>
);
}
if (recentBlogQuery.data.length <= 0) return <></>;
return (
<>
<div className="post-simple-group">
<div className="post-simple-group-title">
<h6>Recent Posts</h6>
</div>
<div className="post-simple-group-divider"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{recentBlogQuery.data.map((blog) => (
<CardBlog key={blog.slug} data={blog} colorPreset={2} isDescriptionVisible={false} />
))}
</div>
</div>
</>
);
}

View File

@ -56,7 +56,7 @@ export default function Header() {
<span className="icon text-middle mdi mdi-login"></span> <span className="icon text-middle mdi mdi-login"></span>
</span> </span>
<span className="unit-body"> <span className="unit-body">
<a href="/login">Login</a> <a href="/admin/login">Login</a>
</span> </span>
</div> </div>
</div> </div>
@ -113,12 +113,12 @@ export default function Header() {
</a> </a>
</li> </li>
<li className="rd-nav-item"> <li className="rd-nav-item">
<a className="rd-nav-link rd-nav-link-custom" href="/"> <a className="rd-nav-link rd-nav-link-custom" href="/listings-for-sale">
LISTINGS FOR SALE LISTINGS FOR SALE
</a> </a>
</li> </li>
<li className="rd-nav-item"> <li className="rd-nav-item">
<a className="rd-nav-link rd-nav-link-custom" href="/"> <a className="rd-nav-link rd-nav-link-custom" href="/listings-for-rent">
LISTINGS FOR RENT LISTINGS FOR RENT
</a> </a>
</li> </li>

View File

@ -0,0 +1,37 @@
"use client";
import React from "react";
import { useField } from "@payloadcms/ui";
import { TextFieldClientComponent } from "payload";
const InputCountry: TextFieldClientComponent = ({ path, field }) => {
const { value, setValue } = useField({ path });
const { showError } = useField();
return (
<div className={`field-type select ${showError ? "has-error" : ""}`}>
{/* @ts-ignore */}
<span>jancok</span>
{/* <label htmlFor={field.name} required={field.required}></label> */}
<div className="select-input-wrapper">
<select
name={field.name}
// @ts-ignore
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={field.admin?.readOnly}
>
<option value="">-- Select --</option>
{/* {field.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))} */}
</select>
</div>
{/* {field.admin && <FieldDescription value={admin.description} />}
{showError && <Error message={errorMessage} />} */}
</div>
);
};
export default InputCountry;

View File

@ -0,0 +1,66 @@
import { CardPropertyData } from "@/schema/property";
import { formatCurrency } from "@/utils/general";
import Image from "next/image";
import Link from "next/link";
type CardPropertyProps = {
data: CardPropertyData;
};
export default function CardProperty({ data }: CardPropertyProps) {
const href = data?.propertyType === "rent" ? `/property/${data.slug}` : `/property/${data.slug}`;
return (
<div>
<article className="product-classic">
<div className="product-classic-media">
<div
className="owl-carousel"
data-items="1"
data-nav="true"
data-stage-padding="0"
data-loop="false"
data-margin="0"
data-mouse-drag="false"
>
{Array.isArray(data.images) &&
data.images.map((img, idx) => (
<div key={idx} className="w-full h-52 bg-colorImgPlaceholder/90">
<Image src={img.url} alt={img.alt ?? ""} fill className="object-cover" />
</div>
))}
</div>
<div className="product-classic-price bg-colorPriceTag/90!">
<span>
{formatCurrency(data.price)}
{data.propertyType === "rent" && `/mo`}
</span>
</div>
</div>
<h4 className="product-classic-title">
<Link href={href}>{data.title}</Link>
</h4>
<div className="product-classic-divider"></div>
<ul className="product-classic-list">
{!!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 && (
<li>
<span className="icon hotel-icon-05"></span>
<span>{data.bedrooms_count} Bedrooms</span>
</li>
)}
</ul>
</article>
</div>
);
}

View File

@ -0,0 +1,79 @@
import { State } from "country-state-city";
import Select from "@/components/Select";
import { FetchPropertyParams } from "@/schema/services/property";
type FilterPropertyProps = {
propertyType: "sell" | "rent";
searchParams?: {
[P in keyof FetchPropertyParams]: string | undefined;
};
};
export default function FilterProperty({ propertyType, searchParams }: FilterPropertyProps) {
const statesData = State.getStatesOfCountry("US").map((st) => ({ value: st.name, label: st.name }));
return (
<>
<div className="block-info">
<h3>Find Your Property</h3>
<form className="form-select" action={propertyType === "rent" ? "/listings-for-rent" : "/listings-for-sell"}>
<div className="form-wrap form-wrap-validation">
<input className="form-input" placeholder="Name" name="name" defaultValue={searchParams?.name} />
</div>
<div className="form-wrap form-wrap-validation">
<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">
<input
className="form-input"
placeholder="Min Area Sqft"
name="min_area"
defaultValue={searchParams?.min_area}
/>
</div>
<div className="form-wrap form-wrap-validation">
<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">
<input
className="form-input"
placeholder="Min Price ($)"
name="min_price"
defaultValue={searchParams?.min_price}
/>
</div>
<div className="form-wrap form-wrap-validation">
<input
className="form-input"
placeholder="Max Price ($)"
name="max_price"
defaultValue={searchParams?.max_price}
/>
</div>
</div>
<div className="form-button">
<button className="button button-block button-primary" type="submit">
Search
</button>
</div>
</form>
</div>
</>
);
}

View File

@ -72,6 +72,8 @@ export interface Config {
blogTags: BlogTag; blogTags: BlogTag;
blogCategories: BlogCategory; blogCategories: BlogCategory;
blogs: Blog; blogs: Blog;
propertyFeatures: PropertyFeature;
properties: Property;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration; 'payload-migrations': PayloadMigration;
@ -83,6 +85,8 @@ export interface Config {
blogTags: BlogTagsSelect<false> | BlogTagsSelect<true>; blogTags: BlogTagsSelect<false> | BlogTagsSelect<true>;
blogCategories: BlogCategoriesSelect<false> | BlogCategoriesSelect<true>; blogCategories: BlogCategoriesSelect<false> | BlogCategoriesSelect<true>;
blogs: BlogsSelect<false> | BlogsSelect<true>; blogs: BlogsSelect<false> | BlogsSelect<true>;
propertyFeatures: PropertyFeaturesSelect<false> | PropertyFeaturesSelect<true>;
properties: PropertiesSelect<false> | PropertiesSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@ -219,6 +223,68 @@ export interface Blog {
createdAt: string; createdAt: string;
_status?: ('draft' | 'published') | null; _status?: ('draft' | 'published') | null;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "propertyFeatures".
*/
export interface PropertyFeature {
id: number;
name: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "properties".
*/
export interface Property {
id: number;
property_type: 'rent' | 'sell';
name: string;
slug?: string | null;
images: (number | Media)[];
aboutGroup: {
description: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
};
area: number;
bathrooms_count?: number | null;
bedrooms_count?: number | null;
};
addressGroup: {
state_code?: string | null;
city_code?: string | null;
zip_code: string;
address: string;
};
features: (number | PropertyFeature)[];
base_price: number;
additional_price?:
| {
name: string;
price: number;
id?: string | null;
}[]
| null;
embed_map_url?: string | null;
createdBy?: (number | null) | User;
updatedBy?: (number | null) | User;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents". * via the `definition` "payload-locked-documents".
@ -245,6 +311,14 @@ export interface PayloadLockedDocument {
| ({ | ({
relationTo: 'blogs'; relationTo: 'blogs';
value: number | Blog; value: number | Blog;
} | null)
| ({
relationTo: 'propertyFeatures';
value: number | PropertyFeature;
} | null)
| ({
relationTo: 'properties';
value: number | Property;
} | null); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user: {
@ -370,6 +444,56 @@ export interface BlogsSelect<T extends boolean = true> {
createdAt?: T; createdAt?: T;
_status?: T; _status?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "propertyFeatures_select".
*/
export interface PropertyFeaturesSelect<T extends boolean = true> {
name?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "properties_select".
*/
export interface PropertiesSelect<T extends boolean = true> {
property_type?: T;
name?: T;
slug?: T;
images?: T;
aboutGroup?:
| T
| {
description?: T;
area?: T;
bathrooms_count?: T;
bedrooms_count?: T;
};
addressGroup?:
| T
| {
state_code?: T;
city_code?: T;
zip_code?: T;
address?: T;
};
features?: T;
base_price?: T;
additional_price?:
| T
| {
name?: T;
price?: T;
id?: T;
};
embed_map_url?: T;
createdBy?: T;
updatedBy?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select". * via the `definition` "payload-locked-documents_select".

View File

@ -13,13 +13,13 @@ import { Media } from "@/collections/Media";
import { BlogTags } from "@/collections/BlogTags"; import { BlogTags } from "@/collections/BlogTags";
import { BlogCategories } from "@/collections/BlogCategories"; import { BlogCategories } from "@/collections/BlogCategories";
import { Blogs } from "@/collections/Blogs"; import { Blogs } from "@/collections/Blogs";
import { PropertyFeatures } from "@/collections/PropertyFeatures";
import { Properties } from "./collections/Properties";
const filename = fileURLToPath(import.meta.url); const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename); const dirname = path.dirname(filename);
export default buildConfig({ export default buildConfig({
cors: [process.env.SITE_URL || ""],
csrf: [process.env.SITE_URL || ""],
admin: { admin: {
user: Users.slug, user: Users.slug,
importMap: { importMap: {
@ -39,7 +39,7 @@ export default buildConfig({
}, },
theme: "dark", theme: "dark",
}, },
collections: [Users, Media, BlogTags, BlogCategories, Blogs], collections: [Users, Media, BlogTags, BlogCategories, Blogs, PropertyFeatures, Properties],
editor: lexicalEditor(), editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || "", secret: process.env.PAYLOAD_SECRET || "",
typescript: { typescript: {

View File

@ -1,7 +1,7 @@
export type BlogData = { export type BlogData = {
slug?: string | null; slug?: string | null;
title: string; title: string;
description: string; description?: string;
img?: { url: string; alt?: string }; img?: { url: string; alt?: string };
posted_at: string; posted_at: string;
}; };

14
src/schema/property.ts Normal file
View File

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

View File

@ -4,3 +4,7 @@ export type FetchBlogParams = {
categoryId?: number; categoryId?: number;
tagId?: number; tagId?: number;
}; };
export type FetchRecentBlogParams = {
currentBlogId: number;
};

View File

@ -0,0 +1,14 @@
export type FetchPropertyParams = {
page?: number;
name?: string;
min_area?: number;
max_area?: number;
min_price?: number;
max_price?: number;
location?: string;
property_type?: "rent" | "sell";
};
export type FetchPropertyDetailParams = {
slug: string;
};

View File

@ -1,7 +1,7 @@
import { BlogData } from "@/schema/blog"; import { BlogData } from "@/schema/blog";
import { FetchBlogParams } from "@/schema/services/blog"; import { FetchBlogParams, FetchRecentBlogParams } from "@/schema/services/blog";
import { useState } from "react"; import { useState } from "react";
import { fetchBlogREST } from "../rest/blog"; import { fetchBlogREST, fetchRecentBlogREST } from "../rest/blog";
export function useBlogQuery() { export function useBlogQuery() {
const [data, setData] = useState<BlogData[]>([]); const [data, setData] = useState<BlogData[]>([]);
@ -14,7 +14,13 @@ export function useBlogQuery() {
setFetching(false); setFetching(false);
if (Array.isArray(res?.formattedData)) { if (Array.isArray(res?.formattedData)) {
setData(res.formattedData); if (!!params.page && params.page > 1) {
setData((currentData) => {
return [...currentData, ...res.formattedData];
});
} else {
setData(res.formattedData);
}
} }
setHasNext(res?.hasNextPage ?? false); setHasNext(res?.hasNextPage ?? false);
} }
@ -26,3 +32,24 @@ export function useBlogQuery() {
hasNext, hasNext,
}; };
} }
export function useRecentBlogQuery() {
const [data, setData] = useState<BlogData[]>([]);
const [isFetching, setFetching] = useState(false);
async function _fetch(params: FetchRecentBlogParams) {
setFetching(true);
const res = await fetchRecentBlogREST(params);
setFetching(false);
if (Array.isArray(res?.formattedData)) {
setData(res.formattedData);
}
}
return {
_fetch,
data,
isFetching,
};
}

View File

@ -37,11 +37,13 @@ export async function fetchBlog({ page, search = "", categoryId, tagId }: FetchB
where: queryCondition, where: queryCondition,
}); });
const formattedData = blogDataQuery.docs.map((item) => { const formattedData: BlogData[] = blogDataQuery.docs.map((item) => {
return { return {
...item, slug: item.slug,
imgFormatted: typeof item.img !== "number" ? { url: item?.img?.url ?? "", alt: item.img.alt } : undefined, title: item.title,
createdAtFormatted: formatDate(item.createdAt), description: sanitizeBlogContentIntoStringPreview(item.content),
img: typeof item.img !== "number" ? { url: item?.img?.url ?? "", alt: item.img.alt } : undefined,
posted_at: formatDate(item.createdAt),
}; };
}); });

View File

@ -0,0 +1,181 @@
import payloadConfig from "@/payload.config";
import { CardPropertyData } from "@/schema/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({
page,
name = "",
location,
min_price,
max_price,
min_area,
max_area,
property_type,
}: FetchPropertyParams = {}) {
const payload = await getPayload({ config: payloadConfig });
const queryCondition: Where = {
_status: { equals: "published" },
};
if (!!property_type) {
queryCondition["property_type"] = {
equals: property_type,
};
}
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: 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 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);
const 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,
},
};
}

View File

@ -1,6 +1,6 @@
import { Blog } from "@/payload-types"; import { Blog } from "@/payload-types";
import { BlogData } from "@/schema/blog"; import { BlogData } from "@/schema/blog";
import { FetchBlogParams } from "@/schema/services/blog"; import { FetchBlogParams, FetchRecentBlogParams } from "@/schema/services/blog";
import { formatDate } from "@/utils/datetime"; import { formatDate } from "@/utils/datetime";
import { sanitizeBlogContentIntoStringPreview } from "@/utils/sanitize"; import { sanitizeBlogContentIntoStringPreview } from "@/utils/sanitize";
import { PaginatedDocs, Where } from "payload"; import { PaginatedDocs, Where } from "payload";
@ -59,3 +59,43 @@ export async function fetchBlogREST({ page, search = "", categoryId, tagId }: Fe
return null; return null;
} }
} }
export async function fetchRecentBlogREST({ currentBlogId }: FetchRecentBlogParams) {
const queryCondition: Where = {
_status: { equals: "published" },
id: {
not_equals: currentBlogId,
},
};
const queryParams = stringify(
{
pagination: true,
limit: 2,
where: queryCondition,
},
{ addQueryPrefix: true }
);
const blogRequest = await fetch(`/api/blogs${queryParams}`);
if (blogRequest.ok) {
const resData = (await blogRequest.json()) as PaginatedDocs<Blog>;
const formattedData: BlogData[] = resData.docs.map((item) => {
return {
slug: item.slug,
title: item.title,
description: sanitizeBlogContentIntoStringPreview(item.content),
img: typeof item.img !== "number" ? { url: item?.img?.url ?? "", alt: item.img.alt } : undefined,
posted_at: formatDate(item.createdAt),
};
});
return {
...resData,
formattedData,
};
} else {
return null;
}
}

View File

@ -5,3 +5,22 @@ export function limitString(text: string) {
export function getRandomNumber(range: number): number { export function getRandomNumber(range: number): number {
return Math.floor(Math.random() * range) + 1; return Math.floor(Math.random() * range) + 1;
} }
export function formatCurrency(num: number): string {
return Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
// These options can be used to round to whole numbers.
trailingZeroDisplay: "stripIfInteger", // This is probably what most people
// want. It will only stop printing
// the fraction when the input
// amount is a round number (int)
// already. If that's not what you
// need, have a look at the options
// below.
//minimumFractionDigits: 0, // This suffices for whole numbers, but will
// print 2500.10 as $2,500.1
//maximumFractionDigits: 0, // Causes 2500.99 to be printed as $2,501
}).format(num);
}

View File

@ -1,5 +1,18 @@
import { Blog } from "@/payload-types"; 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 { export function sanitizePageNumber(page: any, defaultPage = 1): number {
const parsedPage = Number(page); const parsedPage = Number(page);
@ -10,7 +23,7 @@ export function sanitizePageNumber(page: any, defaultPage = 1): number {
return parsedPage; return parsedPage;
} }
export function sanitizeBlogContentIntoStringPreview(data: Blog["content"]) { export function sanitizeBlogContentIntoStringPreview(data: Blog["content"], limit = 100) {
// Find the first paragraph that has children with text // Find the first paragraph that has children with text
const firstParagraph = data.root.children.find( const firstParagraph = data.root.children.find(
(node) => (node) =>
@ -27,6 +40,6 @@ export function sanitizeBlogContentIntoStringPreview(data: Blog["content"]) {
// @ts-ignore // @ts-ignore
const text = firstParagraph.children?.[0]?.text ?? ""; const text = firstParagraph.children?.[0]?.text ?? "";
// Limit to 100 characters // Limit characters
return `${text.length > 100 ? text.slice(0, 100) : text}...`; return `${text.length > limit ? text.slice(0, limit) : text}...`;
} }

View File

@ -4652,6 +4652,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"country-state-city@npm:^3.2.1":
version: 3.2.1
resolution: "country-state-city@npm:3.2.1"
checksum: 10c0/2545a000c207345514de31c20ed8a331bba9796f36ab1e6e4019ebb319bca37894180e4ade40eef9d7f2443aa60045ec3f317bfdb3f4c7b99b9c711e4688d8cb
languageName: node
linkType: hard
"croner@npm:9.0.0": "croner@npm:9.0.0":
version: 9.0.0 version: 9.0.0
resolution: "croner@npm:9.0.0" resolution: "croner@npm:9.0.0"
@ -5022,6 +5029,7 @@ __metadata:
"@types/node": "npm:^20" "@types/node": "npm:^20"
"@types/react": "npm:^19" "@types/react": "npm:^19"
"@types/react-dom": "npm:^19" "@types/react-dom": "npm:^19"
country-state-city: "npm:^3.2.1"
dayjs: "npm:^1.11.13" dayjs: "npm:^1.11.13"
eslint: "npm:^9" eslint: "npm:^9"
eslint-config-next: "npm:15.3.0" eslint-config-next: "npm:15.3.0"
@ -5034,6 +5042,7 @@ __metadata:
qs-esm: "npm:^7.0.2" qs-esm: "npm:^7.0.2"
react: "npm:^19.0.0" react: "npm:^19.0.0"
react-dom: "npm:^19.0.0" react-dom: "npm:^19.0.0"
react-select: "npm:^5.10.1"
swiper: "npm:^11.2.6" swiper: "npm:^11.2.6"
tailwindcss: "npm:^4" tailwindcss: "npm:^4"
typescript: "npm:^5" typescript: "npm:^5"
@ -8809,6 +8818,26 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "react-transition-group@npm:4.4.5, react-transition-group@npm:^4.3.0":
version: 4.4.5 version: 4.4.5
resolution: "react-transition-group@npm:4.4.5" resolution: "react-transition-group@npm:4.4.5"