diff --git a/package.json b/package.json
index 66b26e6..d477a0c 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/public/css/style.css b/public/css/style.css
index 9b91adc..b223537 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -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 {
diff --git a/src/app/(main)/blog/[slug]/page.tsx b/src/app/(main)/blog/[slug]/page.tsx
index 204a1f0..8287882 100644
--- a/src/app/(main)/blog/[slug]/page.tsx
+++ b/src/app/(main)/blog/[slug]/page.tsx
@@ -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
Share this post
-
+
-
+
-
+
-
+
+
diff --git a/src/app/(main)/globals.css b/src/app/(main)/globals.css
index 08bd10e..29b5ab8 100644
--- a/src/app/(main)/globals.css
+++ b/src/app/(main)/globals.css
@@ -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 {
diff --git a/src/app/(main)/listings-for-rent/page.tsx b/src/app/(main)/listings-for-rent/page.tsx
new file mode 100644
index 0000000..d7c17fe
--- /dev/null
+++ b/src/app/(main)/listings-for-rent/page.tsx
@@ -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 {
+ 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 (
+ <>
+
+
+
+
+
+
+
+
+ {isEmpty && (
+
+
No Properties Found
+
Looks like we couldn’t find any listings that match your search.
+
+ )}
+ {!isEmpty && (
+
+ {propertiesData.formattedData.map((p, idx) => (
+
+ ))}
+
+ )}
+
+
+ {/* Pagination */}
+ {propertiesData.totalPages > 1 && (
+
+ )}
+ {/* End Pagination */}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(main)/listings-for-sale/page.tsx b/src/app/(main)/listings-for-sale/page.tsx
new file mode 100644
index 0000000..b9757ba
--- /dev/null
+++ b/src/app/(main)/listings-for-sale/page.tsx
@@ -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 {
+ 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 (
+ <>
+
+
+
+
+
+
+
+
+ {isEmpty && (
+
+
No Properties Found
+
Looks like we couldn’t find any listings that match your search.
+
+ )}
+ {!isEmpty && (
+
+ {propertiesData.formattedData.map((p, idx) => (
+
+ ))}
+
+ )}
+
+
+ {/* Pagination */}
+ {propertiesData.totalPages > 1 && (
+
+ )}
+ {/* End Pagination */}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(main)/property/[slug]/page.tsx b/src/app/(main)/property/[slug]/page.tsx
new file mode 100644
index 0000000..c2e0c13
--- /dev/null
+++ b/src/app/(main)/property/[slug]/page.tsx
@@ -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 {
+ 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 (
+ <>
+
+
+
+
+
+
+
+
+ {formattedData.price}
+ {data.property_type === "rent" && "/mo"}
+
+
+ {formattedData.images.map((img, idx) => (
+
+ ))}
+
+
+ {formattedData.images.map((img, idx) => (
+
+ ))}
+
+
+
+
+
+
+ {!!data?.aboutGroup?.bathrooms_count && (
+ -
+
+ {data.aboutGroup.bathrooms_count} Bathrooms
+
+ )}
+ {!!data?.aboutGroup?.bedrooms_count && (
+ -
+
+ {data.aboutGroup.bedrooms_count} Bedrooms
+
+ )}
+ {!!data?.aboutGroup?.area && (
+ -
+
+ {data.aboutGroup.area} Sq Ft
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Address:
+ - {data?.addressGroup?.address ?? ""}
+
+
+ - State/County:
+ - {data?.addressGroup?.state_code ?? ""}
+
+
+ - City:
+ - {data?.addressGroup?.city_code ?? ""}
+
+
+ - Zip:
+ - {data?.addressGroup?.zip_code ?? ""}
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.isArray(data.features) &&
+ data.features.length > 0 &&
+ data.features.map((ft, idx) => - {typeof ft !== "number" && ft.name}
)}
+
+
+
+
+
+
+ {data.property_type === "rent" && (
+
+
+
+
+
+
+
+ - Base Rent
+ - {formattedData.price}
+
+
+
+ {formattedData.additionalPrice.map((p, idx) => (
+
+
+ - {p.name}
+ - {p.price}
+
+
+ ))}
+
+
+ - Est. total monthly*
+ - {formattedData.totalPrice}
+
+
+
+
+
+
+ )}
+
+ {isEmbedMapUrlValid && (
+
+ )}
+
+
+
+
+
+ -
+ Share this post
+
+ -
+
+
+
+
+
+
+ {similarPropertiesData.formattedData.length > 0 && (
+
+
Other Properties
+
+ {similarPropertiesData.formattedData.map((p, idx) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/collections/Properties.ts b/src/collections/Properties.ts
new file mode 100644
index 0000000..72d34eb
--- /dev/null
+++ b/src/collections/Properties.ts
@@ -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",
+ },
+};
diff --git a/src/collections/PropertyFeatures.ts b/src/collections/PropertyFeatures.ts
new file mode 100644
index 0000000..2e3fef8
--- /dev/null
+++ b/src/collections/PropertyFeatures.ts
@@ -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",
+ },
+};
diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx
new file mode 100644
index 0000000..01ca49f
--- /dev/null
+++ b/src/components/Pagination.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/Select.tsx b/src/components/Select.tsx
new file mode 100644
index 0000000..30a28e8
--- /dev/null
+++ b/src/components/Select.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { ComponentProps } from "react";
+import ReactSelect from "react-select";
+
+export default function Select(props: ComponentProps) {
+ return ;
+}
diff --git a/src/components/blogs/CardBlog.tsx b/src/components/blogs/CardBlog.tsx
index 52e7afa..0d41600 100644
--- a/src/components/blogs/CardBlog.tsx
+++ b/src/components/blogs/CardBlog.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ {data.title}
+
+
+ {isDescriptionVisible && !!data?.description && (
+ <>
+
+
+ >
+ )}
+
+
+ {data.posted_at}
+
+
+
+
+ );
+ }
+
return (
@@ -23,10 +58,14 @@ export default function CardBlog({ data }: CardBlogProps) {
{data.title}
-
-
+ {isDescriptionVisible && !!data?.description && (
+ <>
+
+
+ >
+ )}
{data.posted_at}
diff --git a/src/components/blogs/ListOfBlog.tsx b/src/components/blogs/ListOfBlog.tsx
index 12a2da8..010f516 100644
--- a/src/components/blogs/ListOfBlog.tsx
+++ b/src/components/blogs/ListOfBlog.tsx
@@ -36,7 +36,7 @@ export default function ListOfBlog({ searchKeyword }: ListOfBlogProps) {
{blogQuery.isFetching &&
}
- {blogQuery.hasNext && (
+ {!blogQuery.isFetching && blogQuery.hasNext && (
diff --git a/src/components/blogs/ListOfRecentBlog.tsx b/src/components/blogs/ListOfRecentBlog.tsx
new file mode 100644
index 0000000..591c1e5
--- /dev/null
+++ b/src/components/blogs/ListOfRecentBlog.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (recentBlogQuery.data.length <= 0) return <>>;
+
+ return (
+ <>
+
+
+
Recent Posts
+
+
+
+ {recentBlogQuery.data.map((blog) => (
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/src/components/layouts/Header.tsx b/src/components/layouts/Header.tsx
index 455da0c..304c89e 100644
--- a/src/components/layouts/Header.tsx
+++ b/src/components/layouts/Header.tsx
@@ -56,7 +56,7 @@ export default function Header() {
- Login
+ Login
@@ -113,12 +113,12 @@ export default function Header() {
-
+
LISTINGS FOR SALE
-
+
LISTINGS FOR RENT
diff --git a/src/components/payload-custom/InputCountry.tsx b/src/components/payload-custom/InputCountry.tsx
new file mode 100644
index 0000000..b56d7b2
--- /dev/null
+++ b/src/components/payload-custom/InputCountry.tsx
@@ -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 (
+
+ {/* @ts-ignore */}
+
jancok
+ {/*
*/}
+
+
+
+ {/* {field.admin &&
}
+ {showError &&
} */}
+
+ );
+};
+
+export default InputCountry;
diff --git a/src/components/properties/CardProperty.tsx b/src/components/properties/CardProperty.tsx
new file mode 100644
index 0000000..7bfce0e
--- /dev/null
+++ b/src/components/properties/CardProperty.tsx
@@ -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 (
+
+
+
+
+ {Array.isArray(data.images) &&
+ data.images.map((img, idx) => (
+
+
+
+ ))}
+
+
+
+ {formatCurrency(data.price)}
+ {data.propertyType === "rent" && `/mo`}
+
+
+
+
+ {data.title}
+
+
+
+ {!!data.area && (
+ -
+
+ {data.area} Sq Ft
+
+ )}
+ {!!data.bathrooms_count && (
+ -
+
+ {data.bathrooms_count} Bathrooms
+
+ )}
+ {!!data.bedrooms_count && (
+ -
+
+ {data.bedrooms_count} Bedrooms
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/properties/FilterProperty.tsx b/src/components/properties/FilterProperty.tsx
new file mode 100644
index 0000000..e2ce402
--- /dev/null
+++ b/src/components/properties/FilterProperty.tsx
@@ -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 (
+ <>
+
+
Find Your Property
+
+
+ >
+ );
+}
diff --git a/src/payload-types.ts b/src/payload-types.ts
index 6ea9a7e..6670958 100644
--- a/src/payload-types.ts
+++ b/src/payload-types.ts
@@ -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 | BlogTagsSelect;
blogCategories: BlogCategoriesSelect | BlogCategoriesSelect;
blogs: BlogsSelect | BlogsSelect;
+ propertyFeatures: PropertyFeaturesSelect | PropertyFeaturesSelect;
+ properties: PropertiesSelect | PropertiesSelect;
'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect;
'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect;
'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect;
@@ -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 {
createdAt?: T;
_status?: T;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "propertyFeatures_select".
+ */
+export interface PropertyFeaturesSelect {
+ name?: T;
+ updatedAt?: T;
+ createdAt?: T;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "properties_select".
+ */
+export interface PropertiesSelect {
+ 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".
diff --git a/src/payload.config.ts b/src/payload.config.ts
index c53d1fc..9d87b61 100644
--- a/src/payload.config.ts
+++ b/src/payload.config.ts
@@ -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: {
diff --git a/src/schema/blog.ts b/src/schema/blog.ts
index 6349fc2..ea26548 100644
--- a/src/schema/blog.ts
+++ b/src/schema/blog.ts
@@ -1,7 +1,7 @@
export type BlogData = {
slug?: string | null;
title: string;
- description: string;
+ description?: string;
img?: { url: string; alt?: string };
posted_at: string;
};
diff --git a/src/schema/property.ts b/src/schema/property.ts
new file mode 100644
index 0000000..a903adb
--- /dev/null
+++ b/src/schema/property.ts
@@ -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";
+};
diff --git a/src/schema/services/blog.ts b/src/schema/services/blog.ts
index ab91539..16e2b5d 100644
--- a/src/schema/services/blog.ts
+++ b/src/schema/services/blog.ts
@@ -4,3 +4,7 @@ export type FetchBlogParams = {
categoryId?: number;
tagId?: number;
};
+
+export type FetchRecentBlogParams = {
+ currentBlogId: number;
+};
diff --git a/src/schema/services/property.ts b/src/schema/services/property.ts
new file mode 100644
index 0000000..098ac82
--- /dev/null
+++ b/src/schema/services/property.ts
@@ -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;
+};
diff --git a/src/services/hooks/blog.ts b/src/services/hooks/blog.ts
index ba9f31e..dc8a757 100644
--- a/src/services/hooks/blog.ts
+++ b/src/services/hooks/blog.ts
@@ -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([]);
@@ -14,7 +14,13 @@ export function useBlogQuery() {
setFetching(false);
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);
}
@@ -26,3 +32,24 @@ export function useBlogQuery() {
hasNext,
};
}
+
+export function useRecentBlogQuery() {
+ const [data, setData] = useState([]);
+ 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,
+ };
+}
diff --git a/src/services/payload/blog.ts b/src/services/payload/blog.ts
index 4e57278..de75adb 100644
--- a/src/services/payload/blog.ts
+++ b/src/services/payload/blog.ts
@@ -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),
};
});
diff --git a/src/services/payload/property.ts b/src/services/payload/property.ts
new file mode 100644
index 0000000..0f1062a
--- /dev/null
+++ b/src/services/payload/property.ts
@@ -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,
+ },
+ };
+}
diff --git a/src/services/rest/blog.ts b/src/services/rest/blog.ts
index 1ea4527..19d5b76 100644
--- a/src/services/rest/blog.ts
+++ b/src/services/rest/blog.ts
@@ -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;
+ 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;
+ }
+}
diff --git a/src/utils/general.ts b/src/utils/general.ts
index 294ef90..5e09779 100644
--- a/src/utils/general.ts
+++ b/src/utils/general.ts
@@ -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);
+}
diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts
index 34432f6..8c5a8b0 100644
--- a/src/utils/sanitize.ts
+++ b/src/utils/sanitize.ts
@@ -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}...`;
}
diff --git a/yarn.lock b/yarn.lock
index 7579afd..7605fe5 100644
--- a/yarn.lock
+++ b/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"