commit
9e63d7cb82
@ -17,6 +17,7 @@
|
||||
"@payloadcms/payload-cloud": "^3.35.1",
|
||||
"@payloadcms/richtext-lexical": "^3.35.1",
|
||||
"@payloadcms/storage-s3": "^3.35.1",
|
||||
"country-state-city": "^3.2.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"graphql": "^16.8.1",
|
||||
"next": "15.3.0",
|
||||
@ -24,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": {
|
||||
|
@ -588,9 +588,9 @@ a:hover {
|
||||
color: #967244;
|
||||
}
|
||||
|
||||
a[href*='tel'], a[href*='mailto'] {
|
||||
/* a[href*='tel'], a[href*='mailto'] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
} */
|
||||
|
||||
.link-default, .link-default:active, .link-default:focus {
|
||||
color: #424445;
|
||||
@ -1177,7 +1177,7 @@ a.privacy-link {
|
||||
padding: 12px 11px;
|
||||
color: #9cc1ff;
|
||||
letter-spacing: 0;
|
||||
background-color: #31323c;
|
||||
background-color: var(--color-colorContactForm);
|
||||
}
|
||||
|
||||
.block-callboard a, .block-callboard a:focus, .block-callboard a:active {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import ListOfRecentBlog from "@/components/blogs/ListOfRecentBlog";
|
||||
import HeroImage from "@/components/HeroImage";
|
||||
import { fetchBlogDetail } from "@/services/payload/blog";
|
||||
import { getDefaultMetadata } from "@/utils/metadata";
|
||||
@ -110,65 +111,31 @@ export default async function BlogDetail(props: { params: Promise<{ slug: string
|
||||
<span>Share this post</span>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<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="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>
|
||||
|
||||
<ListOfRecentBlog currentBlogId={blog?.data?.id} />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
|
@ -34,6 +34,7 @@
|
||||
--color-colorText1: var(--color-colorExt10);
|
||||
--color-colorText2: var(--color-colorExt20);
|
||||
--color-colorLoaderBackground: var(--color-colorExt20);
|
||||
--color-colorPriceTag: var(--color-colorExt30);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
95
src/app/(main)/listings-for-rent/page.tsx
Normal file
95
src/app/(main)/listings-for-rent/page.tsx
Normal 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 couldn’t 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>
|
||||
</>
|
||||
);
|
||||
}
|
95
src/app/(main)/listings-for-sale/page.tsx
Normal file
95
src/app/(main)/listings-for-sale/page.tsx
Normal 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 couldn’t 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>
|
||||
</>
|
||||
);
|
||||
}
|
473
src/app/(main)/property/[slug]/page.tsx
Normal file
473
src/app/(main)/property/[slug]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
175
src/collections/Properties.ts
Normal file
175
src/collections/Properties.ts
Normal 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",
|
||||
},
|
||||
};
|
18
src/collections/PropertyFeatures.ts
Normal file
18
src/collections/PropertyFeatures.ts
Normal 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",
|
||||
},
|
||||
};
|
120
src/components/Pagination.tsx
Normal file
120
src/components/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
8
src/components/Select.tsx
Normal file
8
src/components/Select.tsx
Normal 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} />;
|
||||
}
|
@ -3,12 +3,47 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
type CardBlogProps = {
|
||||
colorPreset?: 1 | 2;
|
||||
isDescriptionVisible?: boolean;
|
||||
data: BlogData;
|
||||
};
|
||||
|
||||
export default function CardBlog({ data }: CardBlogProps) {
|
||||
export default function CardBlog({ data, colorPreset = 1, isDescriptionVisible = true }: CardBlogProps) {
|
||||
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 (
|
||||
<div>
|
||||
<article className="post-default">
|
||||
@ -23,10 +58,14 @@ export default function CardBlog({ data }: CardBlogProps) {
|
||||
<Link href={linkDetail}>{data.title}</Link>
|
||||
</h4>
|
||||
</div>
|
||||
{isDescriptionVisible && !!data?.description && (
|
||||
<>
|
||||
<div className="post-default-divider"></div>
|
||||
<div className="post-default-text">
|
||||
<p>{data.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="post-default-time">
|
||||
<span className="icon mdi mdi-clock"></span>
|
||||
<span>{data.posted_at}</span>
|
||||
|
@ -36,7 +36,7 @@ export default function ListOfBlog({ searchKeyword }: ListOfBlogProps) {
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
{blogQuery.isFetching && <Loader />}
|
||||
{blogQuery.hasNext && (
|
||||
{!blogQuery.isFetching && blogQuery.hasNext && (
|
||||
<button onClick={fetchMore} className="button button-primary">
|
||||
LOAD MORE...
|
||||
</button>
|
||||
|
48
src/components/blogs/ListOfRecentBlog.tsx
Normal file
48
src/components/blogs/ListOfRecentBlog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -56,7 +56,7 @@ export default function Header() {
|
||||
<span className="icon text-middle mdi mdi-login"></span>
|
||||
</span>
|
||||
<span className="unit-body">
|
||||
<a href="/login">Login</a>
|
||||
<a href="/admin/login">Login</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -113,12 +113,12 @@ export default function Header() {
|
||||
</a>
|
||||
</li>
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
|
37
src/components/payload-custom/InputCountry.tsx
Normal file
37
src/components/payload-custom/InputCountry.tsx
Normal 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;
|
66
src/components/properties/CardProperty.tsx
Normal file
66
src/components/properties/CardProperty.tsx
Normal 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>
|
||||
);
|
||||
}
|
79
src/components/properties/FilterProperty.tsx
Normal file
79
src/components/properties/FilterProperty.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -72,6 +72,8 @@ export interface Config {
|
||||
blogTags: BlogTag;
|
||||
blogCategories: BlogCategory;
|
||||
blogs: Blog;
|
||||
propertyFeatures: PropertyFeature;
|
||||
properties: Property;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
@ -83,6 +85,8 @@ export interface Config {
|
||||
blogTags: BlogTagsSelect<false> | BlogTagsSelect<true>;
|
||||
blogCategories: BlogCategoriesSelect<false> | BlogCategoriesSelect<true>;
|
||||
blogs: BlogsSelect<false> | BlogsSelect<true>;
|
||||
propertyFeatures: PropertyFeaturesSelect<false> | PropertyFeaturesSelect<true>;
|
||||
properties: PropertiesSelect<false> | PropertiesSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
@ -219,6 +223,68 @@ export interface Blog {
|
||||
createdAt: string;
|
||||
_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
|
||||
* via the `definition` "payload-locked-documents".
|
||||
@ -245,6 +311,14 @@ export interface PayloadLockedDocument {
|
||||
| ({
|
||||
relationTo: 'blogs';
|
||||
value: number | Blog;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'propertyFeatures';
|
||||
value: number | PropertyFeature;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'properties';
|
||||
value: number | Property;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
@ -370,6 +444,56 @@ export interface BlogsSelect<T extends boolean = true> {
|
||||
createdAt?: 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
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
|
@ -13,13 +13,13 @@ import { Media } from "@/collections/Media";
|
||||
import { BlogTags } from "@/collections/BlogTags";
|
||||
import { BlogCategories } from "@/collections/BlogCategories";
|
||||
import { Blogs } from "@/collections/Blogs";
|
||||
import { PropertyFeatures } from "@/collections/PropertyFeatures";
|
||||
import { Properties } from "./collections/Properties";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
cors: [process.env.SITE_URL || ""],
|
||||
csrf: [process.env.SITE_URL || ""],
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
importMap: {
|
||||
@ -39,7 +39,7 @@ export default buildConfig({
|
||||
},
|
||||
theme: "dark",
|
||||
},
|
||||
collections: [Users, Media, BlogTags, BlogCategories, Blogs],
|
||||
collections: [Users, Media, BlogTags, BlogCategories, Blogs, PropertyFeatures, Properties],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || "",
|
||||
typescript: {
|
||||
|
@ -1,7 +1,7 @@
|
||||
export type BlogData = {
|
||||
slug?: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
img?: { url: string; alt?: string };
|
||||
posted_at: string;
|
||||
};
|
||||
|
14
src/schema/property.ts
Normal file
14
src/schema/property.ts
Normal 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";
|
||||
};
|
@ -4,3 +4,7 @@ export type FetchBlogParams = {
|
||||
categoryId?: number;
|
||||
tagId?: number;
|
||||
};
|
||||
|
||||
export type FetchRecentBlogParams = {
|
||||
currentBlogId: number;
|
||||
};
|
||||
|
14
src/schema/services/property.ts
Normal file
14
src/schema/services/property.ts
Normal 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;
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
import { BlogData } from "@/schema/blog";
|
||||
import { FetchBlogParams } from "@/schema/services/blog";
|
||||
import { FetchBlogParams, FetchRecentBlogParams } from "@/schema/services/blog";
|
||||
import { useState } from "react";
|
||||
import { fetchBlogREST } from "../rest/blog";
|
||||
import { fetchBlogREST, fetchRecentBlogREST } from "../rest/blog";
|
||||
|
||||
export function useBlogQuery() {
|
||||
const [data, setData] = useState<BlogData[]>([]);
|
||||
@ -14,8 +14,14 @@ export function useBlogQuery() {
|
||||
setFetching(false);
|
||||
|
||||
if (Array.isArray(res?.formattedData)) {
|
||||
if (!!params.page && params.page > 1) {
|
||||
setData((currentData) => {
|
||||
return [...currentData, ...res.formattedData];
|
||||
});
|
||||
} else {
|
||||
setData(res.formattedData);
|
||||
}
|
||||
}
|
||||
setHasNext(res?.hasNextPage ?? false);
|
||||
}
|
||||
|
||||
@ -26,3 +32,24 @@ export function useBlogQuery() {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
@ -37,11 +37,13 @@ export async function fetchBlog({ page, search = "", categoryId, tagId }: FetchB
|
||||
where: queryCondition,
|
||||
});
|
||||
|
||||
const formattedData = blogDataQuery.docs.map((item) => {
|
||||
const formattedData: BlogData[] = blogDataQuery.docs.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
imgFormatted: typeof item.img !== "number" ? { url: item?.img?.url ?? "", alt: item.img.alt } : undefined,
|
||||
createdAtFormatted: formatDate(item.createdAt),
|
||||
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),
|
||||
};
|
||||
});
|
||||
|
||||
|
181
src/services/payload/property.ts
Normal file
181
src/services/payload/property.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { Blog } from "@/payload-types";
|
||||
import { BlogData } from "@/schema/blog";
|
||||
import { FetchBlogParams } from "@/schema/services/blog";
|
||||
import { FetchBlogParams, FetchRecentBlogParams } from "@/schema/services/blog";
|
||||
import { formatDate } from "@/utils/datetime";
|
||||
import { sanitizeBlogContentIntoStringPreview } from "@/utils/sanitize";
|
||||
import { PaginatedDocs, Where } from "payload";
|
||||
@ -59,3 +59,43 @@ export async function fetchBlogREST({ page, search = "", categoryId, tagId }: Fe
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -5,3 +5,22 @@ export function limitString(text: string) {
|
||||
export function getRandomNumber(range: number): number {
|
||||
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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
@ -10,7 +23,7 @@ export function sanitizePageNumber(page: any, defaultPage = 1): number {
|
||||
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
|
||||
const firstParagraph = data.root.children.find(
|
||||
(node) =>
|
||||
@ -27,6 +40,6 @@ export function sanitizeBlogContentIntoStringPreview(data: Blog["content"]) {
|
||||
// @ts-ignore
|
||||
const text = firstParagraph.children?.[0]?.text ?? "";
|
||||
|
||||
// Limit to 100 characters
|
||||
return `${text.length > 100 ? text.slice(0, 100) : text}...`;
|
||||
// Limit characters
|
||||
return `${text.length > limit ? text.slice(0, limit) : text}...`;
|
||||
}
|
||||
|
29
yarn.lock
29
yarn.lock
@ -4652,6 +4652,13 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 9.0.0
|
||||
resolution: "croner@npm:9.0.0"
|
||||
@ -5022,6 +5029,7 @@ __metadata:
|
||||
"@types/node": "npm:^20"
|
||||
"@types/react": "npm:^19"
|
||||
"@types/react-dom": "npm:^19"
|
||||
country-state-city: "npm:^3.2.1"
|
||||
dayjs: "npm:^1.11.13"
|
||||
eslint: "npm:^9"
|
||||
eslint-config-next: "npm:15.3.0"
|
||||
@ -5034,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"
|
||||
@ -8809,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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user