classify web
This commit is contained in:
39
.env
Normal file
39
.env
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Web Version (Do not Change)
|
||||||
|
NEXT_PUBLIC_WEB_VERSION="2.10.1"
|
||||||
|
|
||||||
|
|
||||||
|
# Admin panle url
|
||||||
|
NEXT_PUBLIC_API_URL="https://eclassify.wrteam.me"
|
||||||
|
|
||||||
|
|
||||||
|
# Website URL
|
||||||
|
NEXT_PUBLIC_WEB_URL="https://eclassifyweb.wrteam.me"
|
||||||
|
|
||||||
|
# API ENDPOINT (Do not change)
|
||||||
|
NEXT_PUBLIC_END_POINT="/api/"
|
||||||
|
|
||||||
|
# Firebase config
|
||||||
|
NEXT_PUBLIC_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
NEXT_PUBLIC_AUTH_DOMAIN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
NEXT_PUBLIC_PROJECT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
NEXT_PUBLIC_STORAGE_BUCKET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
NEXT_PUBLIC_MESSAGING_SENDER_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
NEXT_PUBLIC_APP_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
NEXT_PUBLIC_MEASUREMENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
|
||||||
|
# Vapid api key
|
||||||
|
NEXT_PUBLIC_VAPID_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
|
||||||
|
# here you get default country list (https://developers.google.com/hotels/hotel-prices/dev-guide/country-codes)
|
||||||
|
# enter in small case like (india :- in)
|
||||||
|
#DEFAULT COUNTRY
|
||||||
|
NEXT_PUBLIC_DEFAULT_COUNTRY=in
|
||||||
|
NEXT_PUBLIC_SEO = true
|
||||||
|
|
||||||
|
NEXT_PUBLIC_SEO_REVALIDATE_MINUTES = 60
|
||||||
|
|
||||||
|
NEXT_PUBLIC_META_TITLE="eClassify - Buy & Sell Marketplace | Find Everything You Need"
|
||||||
|
NEXT_PUBLIC_META_DESCRIPTION="Discover eClassify, your ultimate online marketplace for buying and selling a wide range of products. Enjoy a seamless shopping experience with diverse listings, secure transactions, and excellent customer support. Join eClassify today and start exploring!"
|
||||||
|
NEXT_PUBLIC_META_kEYWORDS="buy and sell marketplace, online marketplace, eClassify, buy and sell online, online shopping, sell products online, buy products online, secure transactions, online listings, customer support, e-commerce, digital marketplace, shop online, sell online, online buying and selling"
|
||||||
|
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
/public/sitemap.xml
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
15
.htaccess
Normal file
15
.htaccess
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
|
||||||
|
RewriteRule ^/.well-known/acme-challenge/(.*) /.well-known/acme-challenge/$1 [L]
|
||||||
|
|
||||||
|
RewriteRule ^_next/(.*) /.next/$1 [L]
|
||||||
|
# Allow access to static files with specific extensions
|
||||||
|
RewriteCond %{REQUEST_URI} \.(js|css|svg|jpg|jpeg|png|gif|ico|woff2)$
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
RewriteRule ^index.html http://127.0.0.1:8004/$1 [P]
|
||||||
|
RewriteRule ^index.php http://127.0.0.1:8004/$1 [P]
|
||||||
|
RewriteRule ^/?(.*)$ http://127.0.0.1:8004/$1 [P]
|
||||||
|
</IfModule>
|
||||||
14
.well-known/apple-app-site-association
Normal file
14
.well-known/apple-app-site-association
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"applinks": {
|
||||||
|
"apps": [],
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"appID": "QA5UX5B4B6.com.eclassify.wrteam",
|
||||||
|
"paths": [
|
||||||
|
"/ad-details/*",
|
||||||
|
"/seller/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
26
.well-known/assetlinks.json
Normal file
26
.well-known/assetlinks.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": [
|
||||||
|
"delegate_permission/common.handle_all_urls"
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.eclassify.wrteam",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"B4:B3:AD:26:FA:94:07:B0:EA:CC:30:7E:65:B3:AB:14:B9:98:BB:AB:2F:2C:FF:81:73:9A:6C:22:DF:7C:8C:42"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"relation": [
|
||||||
|
"delegate_permission/common.handle_all_urls"
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.eclassify.wrteam",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"C8:7B:75:BD:A2:AF:6B:E2:93:50:AA:20:43:83:68:DE:AE:87:4B:1C:7A:B3:17:9E:CA:53:F9:BC:A7:9B:F2:39"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
59
HOC/Checkauth.jsx
Normal file
59
HOC/Checkauth.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import Loader from "@/components/Common/Loader";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useNavigate } from "@/components/Common/useNavigate";
|
||||||
|
|
||||||
|
const Checkauth = (WrappedComponent) => {
|
||||||
|
const Wrapper = (props) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { navigate } = useNavigate();
|
||||||
|
const user = useSelector((state) => state.UserSignup.data);
|
||||||
|
const [isAuthorized, setIsAuthorized] = useState(false);
|
||||||
|
const [authChecked, setAuthChecked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// List of routes that require authentication
|
||||||
|
const privateRoutes = [
|
||||||
|
"/profile",
|
||||||
|
"/ad-listing",
|
||||||
|
"/notifications",
|
||||||
|
"/chat",
|
||||||
|
"/user-subscription",
|
||||||
|
"/my-ads",
|
||||||
|
"/favorites",
|
||||||
|
"/transactions",
|
||||||
|
"/reviews",
|
||||||
|
"/edit-listing",
|
||||||
|
"/user-verification",
|
||||||
|
"/job-applications",
|
||||||
|
];
|
||||||
|
const isPrivateRoute = privateRoutes.some(
|
||||||
|
(route) => pathname === route || pathname.startsWith(`${route}/`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// If it's a private route and user is not authenticated
|
||||||
|
if (isPrivateRoute && !user) {
|
||||||
|
navigate("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is authenticated or it's not a private route
|
||||||
|
setIsAuthorized(true);
|
||||||
|
setAuthChecked(true);
|
||||||
|
}, [user, pathname]);
|
||||||
|
|
||||||
|
// Show loader until auth check completes
|
||||||
|
if (!authChecked) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only render the component if user is authorized
|
||||||
|
return isAuthorized ? <WrappedComponent {...props} /> : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return Wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Checkauth;
|
||||||
50
api/AxiosInterceptors.jsx
Normal file
50
api/AxiosInterceptors.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { logoutSuccess } from "@/redux/reducer/authSlice";
|
||||||
|
import { setIsUnauthorized } from "@/redux/reducer/globalStateSlice";
|
||||||
|
import { store } from "@/redux/store";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const Api = axios.create({
|
||||||
|
baseURL: `${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
let isUnauthorizedToastShown = false;
|
||||||
|
|
||||||
|
Api.interceptors.request.use(function (config) {
|
||||||
|
let token = undefined;
|
||||||
|
let langCode = undefined;
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const state = store.getState();
|
||||||
|
token = state?.UserSignup?.data?.token;
|
||||||
|
langCode = state?.CurrentLanguage?.language?.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) config.headers.authorization = `Bearer ${token}`;
|
||||||
|
if (langCode) config.headers["Content-Language"] = langCode;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a response interceptor
|
||||||
|
Api.interceptors.response.use(
|
||||||
|
function (response) {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
function (error) {
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
// Call the logout function if the status code is 401
|
||||||
|
logoutSuccess();
|
||||||
|
if (!isUnauthorizedToastShown) {
|
||||||
|
store.dispatch(setIsUnauthorized(true));
|
||||||
|
isUnauthorizedToastShown = true;
|
||||||
|
// Reset the flag after a certain period
|
||||||
|
setTimeout(() => {
|
||||||
|
isUnauthorizedToastShown = false;
|
||||||
|
}, 3000); // 3 seconds delay before allowing another toast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Api;
|
||||||
48
app/about-us/page.jsx
Normal file
48
app/about-us/page.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import AboutUs from "@/components/PagesComponent/StaticPages/AboutUs";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ searchParams }) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const params = await searchParams;
|
||||||
|
const langCode = params?.lang || "en";
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}seo-settings?page=about-us`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const aboutUs = data?.data?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: aboutUs?.translated_title || process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description:
|
||||||
|
aboutUs?.translated_description ||
|
||||||
|
process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: aboutUs?.image ? [aboutUs?.image] : [],
|
||||||
|
},
|
||||||
|
keywords:
|
||||||
|
aboutUs?.translated_keywords || process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const AboutUsPage = () => {
|
||||||
|
return <AboutUs />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutUsPage;
|
||||||
104
app/ad-details/[slug]/page.jsx
Normal file
104
app/ad-details/[slug]/page.jsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import StructuredData from "@/components/Layout/StructuredData";
|
||||||
|
import ProductDetail from "@/components/PagesComponent/ProductDetail/ProductDetails";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
import { generateKeywords } from "@/utils/generateKeywords";
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ params, searchParams }) => {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
try {
|
||||||
|
const { slug } = await params;
|
||||||
|
const langCode = (await searchParams)?.lang || "en";
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}get-item?slug=${slug}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const item = data?.data?.data?.[0];
|
||||||
|
const title = item?.translated_item?.name;
|
||||||
|
const description = item?.translated_item?.description;
|
||||||
|
const keywords = generateKeywords(item?.translated_item?.description);
|
||||||
|
const image = item?.image;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title || process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description: description || process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: image ? [image] : [],
|
||||||
|
},
|
||||||
|
keywords: keywords,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemData = async (slug, langCode) => {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}get-item?slug=${slug}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const item = data?.data?.data?.[0];
|
||||||
|
return item;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching item data:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductDetailPage = async ({ params, searchParams }) => {
|
||||||
|
const { slug } = await params;
|
||||||
|
const langCode = (await searchParams).lang || "en";
|
||||||
|
const product = await getItemData(slug, langCode);
|
||||||
|
const jsonLd = product
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Product",
|
||||||
|
productID: product?.id,
|
||||||
|
name: product?.translated_item?.name,
|
||||||
|
description: product?.translated_item?.description,
|
||||||
|
image: product?.image,
|
||||||
|
url: `${process.env.NEXT_PUBLIC_WEB_URL}/ad-details/${product?.slug}`,
|
||||||
|
category: {
|
||||||
|
"@type": "Thing",
|
||||||
|
name: product?.category?.translated_name || "General Category", // Default category name
|
||||||
|
},
|
||||||
|
...(product?.price && {
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
price: product.price,
|
||||||
|
priceCurrency: "USD",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
countryOfOrigin: product?.translated_item?.country,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StructuredData data={jsonLd} />
|
||||||
|
<ProductDetail slug={slug} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductDetailPage;
|
||||||
5
app/ad-listing/page.jsx
Normal file
5
app/ad-listing/page.jsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdsListing from "@/components/PagesComponent/AdsListing/AdsListing";
|
||||||
|
const AdListingPage = () => {
|
||||||
|
return <AdsListing />;
|
||||||
|
};
|
||||||
|
export default AdListingPage;
|
||||||
215
app/ads/page.jsx
Normal file
215
app/ads/page.jsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import StructuredData from "@/components/Layout/StructuredData";
|
||||||
|
import Products from "@/components/PagesComponent/Ads/Ads";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
import { generateKeywords } from "@/utils/generateKeywords";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|
||||||
|
const buildIndexableParams = (searchParams) => {
|
||||||
|
const indexableFilters = [
|
||||||
|
"category",
|
||||||
|
"query",
|
||||||
|
"country",
|
||||||
|
"state",
|
||||||
|
"city",
|
||||||
|
"areaId",
|
||||||
|
"sortBy",
|
||||||
|
"min_price",
|
||||||
|
"max_price",
|
||||||
|
"date_posted",
|
||||||
|
];
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
const { country, state, city, areaId } = searchParams || {};
|
||||||
|
|
||||||
|
const locationPriority = areaId
|
||||||
|
? "areaId"
|
||||||
|
: city
|
||||||
|
? "city"
|
||||||
|
: state
|
||||||
|
? "state"
|
||||||
|
: country
|
||||||
|
? "country"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
indexableFilters.forEach((key) => {
|
||||||
|
let value = searchParams[key];
|
||||||
|
if (value === undefined || value === "") return;
|
||||||
|
|
||||||
|
// 🧹 Skip non-selected location levels
|
||||||
|
if (["country", "state", "city", "areaId"].includes(key)) {
|
||||||
|
if (key !== locationPriority) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["areaId", "min_price", "max_price"].includes(key))
|
||||||
|
value = Number(value);
|
||||||
|
if (key === "category") params.append("category_slug", value);
|
||||||
|
else if (key === "query") params.append("search", value);
|
||||||
|
else if (key === "date_posted") params.append("posted_since", value);
|
||||||
|
else params.append(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return params.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCanonicalParams = (searchParams) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
const { category, query, lang } = searchParams || {};
|
||||||
|
|
||||||
|
if (category) params.append("category", category);
|
||||||
|
if (query) params.append("search", query);
|
||||||
|
// Add lang to canonical params for consistency with sitemap
|
||||||
|
if (lang) params.append("lang", lang);
|
||||||
|
|
||||||
|
return params.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ searchParams }) => {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const originalSearchParams = await searchParams;
|
||||||
|
const langCode = originalSearchParams?.lang || "en";
|
||||||
|
const slug = originalSearchParams?.category || ""; // change to your param name if needed
|
||||||
|
|
||||||
|
let title = process.env.NEXT_PUBLIC_META_TITLE;
|
||||||
|
let description = process.env.NEXT_PUBLIC_META_DESCRIPTION;
|
||||||
|
let keywords = process.env.NEXT_PUBLIC_META_kEYWORDS;
|
||||||
|
let image = "";
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
// Fetch category-specific SEO
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}get-categories?slug=${slug}`,
|
||||||
|
{
|
||||||
|
headers: { "Content-Language": langCode || "en" },
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const selfCategory = data?.self_category;
|
||||||
|
|
||||||
|
title = selfCategory?.translated_name || title;
|
||||||
|
description = selfCategory?.translated_description || description;
|
||||||
|
keywords =
|
||||||
|
generateKeywords(selfCategory?.translated_description) || keywords;
|
||||||
|
image = selfCategory?.image || image;
|
||||||
|
} else {
|
||||||
|
// Fetch default ad listing SEO
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}seo-settings?page=ad-listing`,
|
||||||
|
{
|
||||||
|
headers: { "Content-Language": langCode || "en" },
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
const adListing = data?.data?.[0];
|
||||||
|
|
||||||
|
title = adListing?.translated_title || title;
|
||||||
|
description = adListing?.translated_description || description;
|
||||||
|
keywords = adListing?.translated_keywords || keywords;
|
||||||
|
image = adListing?.image || image;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_WEB_URL;
|
||||||
|
const paramsStr = buildCanonicalParams(originalSearchParams);
|
||||||
|
const canonicalUrl = `${baseUrl}/ads${paramsStr ? `?${paramsStr}` : ""}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
images: image ? [image] : [],
|
||||||
|
},
|
||||||
|
keywords,
|
||||||
|
alternates: {
|
||||||
|
canonical: canonicalUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllItems = async (langCode, searchParams) => {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryString = buildIndexableParams(searchParams)
|
||||||
|
? buildIndexableParams(searchParams)
|
||||||
|
: "";
|
||||||
|
const url = `${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT
|
||||||
|
}get-item?page=1${queryString ? `&${queryString}` : ""}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.data?.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Product Items Data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdsPage = async ({ searchParams }) => {
|
||||||
|
const originalSearchParams = await searchParams;
|
||||||
|
const langCode = originalSearchParams?.lang || "en";
|
||||||
|
const AllItems = await getAllItems(langCode, originalSearchParams);
|
||||||
|
|
||||||
|
const jsonLd = AllItems
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ItemList",
|
||||||
|
itemListElement: AllItems.map((product, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: index + 1, // Position starts at 1
|
||||||
|
item: {
|
||||||
|
"@type": "Product",
|
||||||
|
productID: product?.id,
|
||||||
|
name: product?.translated_item?.name || "",
|
||||||
|
description: product?.translated_item?.description || "",
|
||||||
|
image: product?.image || "",
|
||||||
|
url: `${process.env.NEXT_PUBLIC_WEB_URL}/ad-details/${product?.slug}`,
|
||||||
|
category: {
|
||||||
|
"@type": "Thing",
|
||||||
|
name: product?.category?.translated_name || "",
|
||||||
|
},
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
price: product.price || undefined,
|
||||||
|
priceCurrency: product?.price ? "USD" : undefined,
|
||||||
|
availability: product?.price
|
||||||
|
? "https://schema.org/InStock"
|
||||||
|
: "https://schema.org/PreOrder",
|
||||||
|
},
|
||||||
|
countryOfOrigin: product?.translated_item?.country || "",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StructuredData data={jsonLd} />
|
||||||
|
<Products searchParams={originalSearchParams} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default AdsPage;
|
||||||
119
app/blogs/[slug]/page.jsx
Normal file
119
app/blogs/[slug]/page.jsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import StructuredData from "@/components/Layout/StructuredData";
|
||||||
|
import BlogDetailPage from "@/components/PagesComponent/BlogDetail/BlogDetailPage";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
const stripHtml = (html) => {
|
||||||
|
return html.replace(/<[^>]*>/g, ""); // Regular expression to remove HTML tags
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to format the date correctly (ISO 8601)
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
// Remove microseconds and ensure it follows ISO 8601 format
|
||||||
|
const validDateString = dateString.slice(0, 19) + "Z"; // Remove microseconds and add 'Z' for UTC
|
||||||
|
return validDateString;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ params, searchParams }) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const slugParams = await params;
|
||||||
|
const langParams = await searchParams;
|
||||||
|
const langCode = langParams?.lang || "en";
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}blogs?slug=${slugParams?.slug}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch metadata");
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
const data = responseData?.data?.data[0];
|
||||||
|
|
||||||
|
const plainTextDescription = data?.translated_description?.replace(
|
||||||
|
/<\/?[^>]+(>|$)/g,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data?.translated_title || process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description: plainTextDescription
|
||||||
|
? plainTextDescription
|
||||||
|
: process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: data?.image ? [data?.image] : [],
|
||||||
|
},
|
||||||
|
keywords: data?.translated_tags || process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSingleBlogItem = async (slug, langCode) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const url = `${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}blogs?slug=${slug}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch blog data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
return responseData?.data?.data[0] || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Blog Items Data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlogPage = async ({ params, searchParams }) => {
|
||||||
|
const { slug } = await params;
|
||||||
|
const langCode = (await searchParams).lang || "en";
|
||||||
|
const singleBlog = await fetchSingleBlogItem(slug, langCode);
|
||||||
|
|
||||||
|
const jsonLd = singleBlog
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BlogPosting",
|
||||||
|
headline: singleBlog?.translated_title,
|
||||||
|
description: singleBlog?.translated_description
|
||||||
|
? stripHtml(singleBlog.translated_description)
|
||||||
|
: "No description available", // Strip HTML from description
|
||||||
|
url: `${process.env.NEXT_PUBLIC_WEB_URL}/blogs/${singleBlog?.slug}`,
|
||||||
|
image: singleBlog?.image,
|
||||||
|
datePublished: singleBlog?.created_at
|
||||||
|
? formatDate(singleBlog.created_at)
|
||||||
|
: "", // Format date to ISO 8601
|
||||||
|
keywords: singleBlog?.translated_tags
|
||||||
|
? singleBlog.translated_tags.join(", ")
|
||||||
|
: "", // Adding tags as keywords
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StructuredData data={jsonLd} />
|
||||||
|
<BlogDetailPage slug={slug} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogPage;
|
||||||
123
app/blogs/page.jsx
Normal file
123
app/blogs/page.jsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import StructuredData from "@/components/Layout/StructuredData";
|
||||||
|
import Blogs from "@/components/PagesComponent/Blogs/Blogs";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ searchParams }) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const params = await searchParams;
|
||||||
|
const langCode = params?.lang || "en";
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}seo-settings?page=blogs`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch blogs metadata");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const blogs = data?.data[0];
|
||||||
|
return {
|
||||||
|
title: blogs?.translated_title || process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description:
|
||||||
|
blogs?.translated_description ||
|
||||||
|
process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: blogs?.image ? [blogs?.image] : [],
|
||||||
|
},
|
||||||
|
keywords:
|
||||||
|
blogs?.translated_keywords || process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripHtml = (html) => {
|
||||||
|
return html.replace(/<[^>]*>/g, ""); // Regular expression to remove HTML tags
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to format the date correctly (ISO 8601)
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
// Remove microseconds and ensure it follows ISO 8601 format
|
||||||
|
const validDateString = dateString.slice(0, 19) + "Z"; // Remove microseconds and add 'Z' for UTC
|
||||||
|
return validDateString;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBlogItems = async (langCode, tag) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
let url = `${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}blogs`;
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
url += `?tag=${encodeURIComponent(tag)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch blogs json-ld data");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data?.data?.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Blog Items Data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlogsPage = async ({ searchParams }) => {
|
||||||
|
const params = await searchParams;
|
||||||
|
const langCode = params?.lang || "en";
|
||||||
|
const tag = params?.tag || null;
|
||||||
|
const blogItems = await fetchBlogItems(langCode, tag);
|
||||||
|
|
||||||
|
const jsonLd = blogItems
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ItemList",
|
||||||
|
itemListElement: blogItems.map((blog, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: index + 1,
|
||||||
|
item: {
|
||||||
|
"@type": "BlogPosting",
|
||||||
|
headline: blog?.translated_title,
|
||||||
|
description: blog?.translated_description
|
||||||
|
? stripHtml(blog.translated_description)
|
||||||
|
: "No description available", // Strip HTML from description
|
||||||
|
url: `${process.env.NEXT_PUBLIC_WEB_URL}/blogs/${blog?.slug}`,
|
||||||
|
image: blog?.image,
|
||||||
|
datePublished: blog?.created_at ? formatDate(blog.created_at) : "", // Format date to ISO 8601
|
||||||
|
keywords: blog?.translated_tags
|
||||||
|
? blog.translated_tags.join(", ")
|
||||||
|
: "", // Adding tags as keywords
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StructuredData data={jsonLd} />
|
||||||
|
<Blogs />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogsPage;
|
||||||
7
app/chat/page.jsx
Normal file
7
app/chat/page.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import ProfileDashboard from "@/components/PagesComponent/ProfileDashboard/ProfileDashboard";
|
||||||
|
|
||||||
|
const ChatPage = () => {
|
||||||
|
return <ProfileDashboard />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatPage;
|
||||||
46
app/contact-us/page.jsx
Normal file
46
app/contact-us/page.jsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import ContactUs from "@/components/PagesComponent/Contact/ContactUs";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ searchParams }) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const params = await searchParams;
|
||||||
|
const langCode = params?.lang || "en";
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}seo-settings?page=contact-us`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
const contactUs = data?.data?.[0];
|
||||||
|
return {
|
||||||
|
title: contactUs?.translated_title || process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description:
|
||||||
|
contactUs?.translated_description ||
|
||||||
|
process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: contactUs?.image ? [contactUs?.image] : [],
|
||||||
|
},
|
||||||
|
keywords:
|
||||||
|
contactUs?.translated_keywords || process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContactUsPage = () => {
|
||||||
|
return <ContactUs />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactUsPage;
|
||||||
9
app/edit-listing/[id]/page.jsx
Normal file
9
app/edit-listing/[id]/page.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import EditListing from "@/components/PagesComponent/EditListing/EditListing";
|
||||||
|
|
||||||
|
const EditListingPage = async (props) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const id = await params.id;
|
||||||
|
return <EditListing id={id} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditListingPage;
|
||||||
37
app/error.jsx
Normal file
37
app/error.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client"; // Error components must be Client Components
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import somthingWrong from "../public/assets/something_went_wrong.svg";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import CustomImage from "@/components/Common/CustomImage";
|
||||||
|
import { useNavigate } from "@/components/Common/useNavigate";
|
||||||
|
|
||||||
|
export default function Error({ error }) {
|
||||||
|
const { navigate } = useNavigate();
|
||||||
|
useEffect(() => {
|
||||||
|
// Log the error to an error reporting service
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
const navigateHome = () => {
|
||||||
|
navigate("/");
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 items-center justify-center h-screen">
|
||||||
|
<CustomImage
|
||||||
|
src={somthingWrong}
|
||||||
|
alt="something went wrong"
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
/>
|
||||||
|
<h3 className="text-2xl font-semibold text-primary text-center">
|
||||||
|
{t("somthingWentWrong")}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span>{t("tryLater")}</span>
|
||||||
|
<Button variant="outline" onClick={navigateHome}>
|
||||||
|
{t("home")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/faqs/page.jsx
Normal file
45
app/faqs/page.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import FaqsPage from "@/components/PagesComponent/Faq/FaqsPage";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ searchParams }) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const params = await searchParams;
|
||||||
|
const langCode = params?.lang || "en";
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}seo-settings?page=faqs`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
const faqs = data?.data?.[0];
|
||||||
|
return {
|
||||||
|
title: faqs?.translated_title || process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description:
|
||||||
|
faqs?.translated_description ||
|
||||||
|
process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: faqs?.image ? [faqs?.image] : [],
|
||||||
|
},
|
||||||
|
keywords:
|
||||||
|
faqs?.translated_keywords || process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <FaqsPage />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
9
app/favorites/page.jsx
Normal file
9
app/favorites/page.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import ProfileDashboard from "@/components/PagesComponent/ProfileDashboard/ProfileDashboard"
|
||||||
|
|
||||||
|
export default function FavoritesPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProfileDashboard />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
274
app/globals.css
Normal file
274
app/globals.css
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--foreground-rgb: 0, 0, 0;
|
||||||
|
--background-start-rgb: 214, 219, 220;
|
||||||
|
--background-end-rgb: 255, 255, 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--foreground-rgb: 255, 255, 255;
|
||||||
|
--background-start-rgb: 0, 0, 0;
|
||||||
|
--background-end-rgb: 0, 0, 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: 0 0% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 3.9%;
|
||||||
|
--primary: #00b2ca;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 0 0% 96.1%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: #f6f5fa;
|
||||||
|
--muted-foreground: 0 0% 45.1%;
|
||||||
|
--accent: 0 0% 96.1%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: #dc3545;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: #d3d3d3;
|
||||||
|
--input: 0 0% 89.8%;
|
||||||
|
--ring: 0 0% 3.9%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 0 0% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 0 0% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 0 0% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: #00b2ca;
|
||||||
|
--primary-foreground: 0 0% 9%;
|
||||||
|
--secondary: 0 0% 14.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 0 0% 14.9%;
|
||||||
|
--muted-foreground: 0 0% 63.9%;
|
||||||
|
--accent: 0 0% 14.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 14.9%;
|
||||||
|
--input: 0 0% 14.9%;
|
||||||
|
--ring: 0 0% 83.1%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
--font-color: #ffffff;
|
||||||
|
--light-font-color: #595b6c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-[background] text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
|
||||||
|
|
||||||
|
.landingSecHeader {
|
||||||
|
@apply text-center text-4xl lg:text-5xl font-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outlinedSecHead {
|
||||||
|
@apply p-3 px-4 border border-primary text-primary rounded-md font-semibold text-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storeIcons {
|
||||||
|
@apply max-w-max w-full sm:w-[170px] md:w-[215px] lg:w-[235px] h-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerSocialLinks {
|
||||||
|
@apply flex items-center justify-center w-[40px] h-[40px] rounded-md p-2 transition-all duration-500 bg-white/15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerContactIcons {
|
||||||
|
@apply flex items-center justify-center min-w-[48px] w-[48px] h-[48px] rounded-md p-[10px] transition-all duration-500 bg-white/15;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.footerSocialLinks:hover,
|
||||||
|
.footerContactIcons:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
box-shadow: 0px 8px 28px 0px var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLabel {
|
||||||
|
@apply text-white opacity-65 text-sm transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLabel:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-between {
|
||||||
|
@apply flex items-center justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labelInputCont {
|
||||||
|
@apply flex flex-col gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requiredInputLabel {
|
||||||
|
@apply after:content-['*'] after:text-destructive;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-container-otp {
|
||||||
|
@apply flex items-center justify-center h-7 py-1 px-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-otp {
|
||||||
|
@apply border-4 border-t-[var(--primary-color)] border-[#f3f3f3] rounded-full w-5 h-5 animate-spin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileActiveTab {
|
||||||
|
@apply py-2 px-4 bg-primary rounded-full text-white w-max
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
@apply text-xl sm:text-2xl font-medium capitalize
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
@apply w-full h-full relative flex items-center justify-center m-auto
|
||||||
|
}
|
||||||
|
|
||||||
|
.text_ellipsis {
|
||||||
|
@apply overflow-hidden text-ellipsis whitespace-nowrap
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-none::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for IE, Edge, and Firefox */
|
||||||
|
.scrollbar-none {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Firefox */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .place_search div {
|
||||||
|
width: 100%;
|
||||||
|
} */
|
||||||
|
|
||||||
|
.formWrapper .react-tel-input .special-label {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tel-input .form-control {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 40px !important;
|
||||||
|
border: 1px solid lightgray !important;
|
||||||
|
outline: none !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tel-input .flag-dropdown {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Update focus styles to match shadcn Input */
|
||||||
|
.react-tel-input .form-control:focus,
|
||||||
|
.react-tel-input .form-control:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--primary) !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 5px !important;
|
||||||
|
width: 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track */
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
border-radius: 10px !important;
|
||||||
|
background-color: lightgray;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
background-color: lightgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle */
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--primary) !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
background: var(--primary) !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* RTL overrides for react-phone-input-2*/
|
||||||
|
[dir='rtl'] .react-tel-input .form-control {
|
||||||
|
padding-left: inherit;
|
||||||
|
padding-right: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir='rtl'] .react-tel-input .selected-flag {
|
||||||
|
padding: 0 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir='rtl'] .react-tel-input .selected-flag .arrow {
|
||||||
|
left: auto;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir=rtl] input[type=tel i] {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perspective-1000 {
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-style-preserve-3d {
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backface-hidden {
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotate-y-180 {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
7
app/job-applications/page.jsx
Normal file
7
app/job-applications/page.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import ProfileDashboard from "@/components/PagesComponent/ProfileDashboard/ProfileDashboard";
|
||||||
|
|
||||||
|
const JobApplicationsPage = () => {
|
||||||
|
return <ProfileDashboard />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JobApplicationsPage;
|
||||||
57
app/landing/page.jsx
Normal file
57
app/landing/page.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import AnythingYouWant from "@/components/PagesComponent/LandingPage/AnythingYouWant";
|
||||||
|
import OurBlogs from "@/components/PagesComponent/LandingPage/OurBlogs";
|
||||||
|
import QuickAnswers from "@/components/PagesComponent/LandingPage/QuickAnswers";
|
||||||
|
import WorkProcess from "@/components/PagesComponent/LandingPage/WorkProcess";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ searchParams }) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const params = await searchParams;
|
||||||
|
const langCode = params?.lang || "en";
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}seo-settings?page=landing`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
const landing = data?.data?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: landing?.translated_title || process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description:
|
||||||
|
landing?.translated_description ||
|
||||||
|
process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: landing?.image ? [landing?.image] : [],
|
||||||
|
},
|
||||||
|
keywords:
|
||||||
|
landing?.translated_keywords || process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const LandingPage = () => {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<AnythingYouWant />
|
||||||
|
<WorkProcess />
|
||||||
|
<OurBlogs />
|
||||||
|
<QuickAnswers />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LandingPage;
|
||||||
46
app/layout.js
Normal file
46
app/layout.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Manrope } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Providers } from "@/redux/store/providers";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
// import Script from "next/script";
|
||||||
|
|
||||||
|
const manrope = Manrope({
|
||||||
|
weight: ["200", "300", "400", "500", "600", "700", "800"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const generateMetadata = () => {
|
||||||
|
return {
|
||||||
|
title: process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description: process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
keywords: process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
openGraph: {
|
||||||
|
title: process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description: process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
keywords: process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
web-version={process.env.NEXT_PUBLIC_WEB_VERSION}
|
||||||
|
className="scroll-smooth"
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
{/* <Script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-xxxxxxxxxxxx"
|
||||||
|
crossOrigin="anonymous" strategy="afterInteractive" /> */}
|
||||||
|
</head>
|
||||||
|
<body className={`${manrope.className} !pointer-events-auto`}>
|
||||||
|
<Providers>
|
||||||
|
{children}
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
</Providers>
|
||||||
|
{/* <div id="recaptcha-container"></div> */}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/loading.jsx
Normal file
10
app/loading.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Loader from "@/components/Common/Loader"
|
||||||
|
|
||||||
|
|
||||||
|
const Loading = () => {
|
||||||
|
return (
|
||||||
|
<Loader />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Loading
|
||||||
1
app/middleware.js
Normal file
1
app/middleware.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
app/my-ads/page.jsx
Normal file
5
app/my-ads/page.jsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import ProfileDashboard from "@/components/PagesComponent/ProfileDashboard/ProfileDashboard";
|
||||||
|
const MyAdsPage = () => {
|
||||||
|
return <ProfileDashboard />;
|
||||||
|
};
|
||||||
|
export default MyAdsPage;
|
||||||
8
app/my-listing/[slug]/page.jsx
Normal file
8
app/my-listing/[slug]/page.jsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import ProductDetail from "@/components/PagesComponent/ProductDetail/ProductDetails";
|
||||||
|
|
||||||
|
const MyListingPage = async ({ params }) => {
|
||||||
|
const { slug } = await params;
|
||||||
|
return <ProductDetail slug={slug} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MyListingPage;
|
||||||
22
app/not-found.jsx
Normal file
22
app/not-found.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
import CustomImage from "@/components/Common/CustomImage";
|
||||||
|
import notFoundImg from "@/public/assets/no_data_found_illustrator.svg";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { FaArrowLeft } from "react-icons/fa";
|
||||||
|
|
||||||
|
const NotFound = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen gap-3">
|
||||||
|
<CustomImage src={notFoundImg} width={200} height={200} alt="not found" />
|
||||||
|
<h3 className="text-2xl font-semibold text-primary text-center">
|
||||||
|
{t("pageNotFound")}
|
||||||
|
</h3>
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<FaArrowLeft /> {t("back")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFound;
|
||||||
7
app/notifications/page.jsx
Normal file
7
app/notifications/page.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import ProfileDashboard from "@/components/PagesComponent/ProfileDashboard/ProfileDashboard";
|
||||||
|
|
||||||
|
const NotificationsPage = () => {
|
||||||
|
return <ProfileDashboard />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationsPage;
|
||||||
202
app/page.js
Normal file
202
app/page.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import StructuredData from "@/components/Layout/StructuredData";
|
||||||
|
import Home from "@/components/PagesComponent/Home/Home";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ searchParams }) => {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const langCode = (await searchParams)?.lang;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}seo-settings?page=home`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
const home = data?.data?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: home?.translated_title || process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description:
|
||||||
|
home?.translated_description ||
|
||||||
|
process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: home?.image ? [home?.image] : [],
|
||||||
|
},
|
||||||
|
keywords:
|
||||||
|
home?.translated_keywords || process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCategories = async (langCode) => {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}get-categories?page=1`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.data?.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Categories Data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchProductItems = async (langCode) => {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}get-item?page=1`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.data?.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Product Items Data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFeaturedSections = async (langCode) => {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}get-featured-section`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Featured sections Data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function HomePage({ searchParams }) {
|
||||||
|
const langCode = (await searchParams)?.lang;
|
||||||
|
const [categoriesData, productItemsData, featuredSectionsData] =
|
||||||
|
await Promise.all([
|
||||||
|
fetchCategories(langCode),
|
||||||
|
fetchProductItems(langCode),
|
||||||
|
fetchFeaturedSections(langCode),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let jsonLd = null;
|
||||||
|
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO !== "false") {
|
||||||
|
const existingSlugs = new Set(
|
||||||
|
productItemsData.map((product) => product.slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
let featuredItems = [];
|
||||||
|
featuredSectionsData.forEach((section) => {
|
||||||
|
section.section_data.slice(0, 4).forEach((item) => {
|
||||||
|
if (!existingSlugs.has(item.slug)) {
|
||||||
|
featuredItems.push(item);
|
||||||
|
existingSlugs.add(item.slug); // Mark this item as included
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ItemList",
|
||||||
|
itemListElement: [
|
||||||
|
...categoriesData.map((category, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: index + 1,
|
||||||
|
item: {
|
||||||
|
"@type": "Thing", // No "Category" type in Schema.org
|
||||||
|
name: category?.translated_name,
|
||||||
|
url: `${process.env.NEXT_PUBLIC_WEB_URL}/ads?category=${category?.slug}`,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...productItemsData.map((product, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: categoriesData?.length + index + 1, // Ensure unique positions
|
||||||
|
item: {
|
||||||
|
"@type": "Product",
|
||||||
|
name: product?.translated_item?.name,
|
||||||
|
productID: product?.id,
|
||||||
|
description: product?.translated_item?.description,
|
||||||
|
image: product?.image,
|
||||||
|
url: `${process.env.NEXT_PUBLIC_WEB_URL}/ad-details/${product?.slug}`,
|
||||||
|
category: product?.category?.translated_name,
|
||||||
|
...(product?.price && {
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
price: product?.price,
|
||||||
|
priceCurrency: "USD",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
countryOfOrigin: product?.translated_item?.country,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...featuredItems.map((item, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: categoriesData.length + productItemsData.length + index + 1, // Ensure unique positions
|
||||||
|
item: {
|
||||||
|
"@type": "Product", // Assuming items from featured sections are products
|
||||||
|
name: item?.translated_item?.name,
|
||||||
|
productID: item?.id,
|
||||||
|
description: item?.translated_item?.description,
|
||||||
|
image: item?.image,
|
||||||
|
url: `${process.env.NEXT_PUBLIC_WEB_URL}/ad-details/${item?.slug}`,
|
||||||
|
category: item?.category?.translated_name,
|
||||||
|
...(item?.price && {
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
price: item?.price,
|
||||||
|
priceCurrency: "USD",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
countryOfOrigin: item?.translated_item?.country,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{jsonLd && <StructuredData data={jsonLd} />}
|
||||||
|
<Layout>
|
||||||
|
<Home productItemsData={productItemsData} />
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
app/privacy-policy/page.jsx
Normal file
47
app/privacy-policy/page.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import PrivacyPolicy from "@/components/PagesComponent/StaticPages/PrivacyPolicy";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ searchParams }) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const params = await searchParams;
|
||||||
|
const langCode = params?.lang || "en";
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}seo-settings?page=privacy-policy`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
const privacyPolicy = data?.data?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
title:
|
||||||
|
privacyPolicy?.translated_title || process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description:
|
||||||
|
privacyPolicy?.translated_description ||
|
||||||
|
process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: privacyPolicy?.image ? [privacyPolicy?.image] : [],
|
||||||
|
},
|
||||||
|
keywords:
|
||||||
|
privacyPolicy?.translated_keywords ||
|
||||||
|
process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const PrivacyPolicyPage = () => {
|
||||||
|
return <PrivacyPolicy />;
|
||||||
|
};
|
||||||
|
export default PrivacyPolicyPage;
|
||||||
7
app/profile/page.jsx
Normal file
7
app/profile/page.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import ProfileDashboard from "@/components/PagesComponent/ProfileDashboard/ProfileDashboard";
|
||||||
|
|
||||||
|
const ProfilePage = () => {
|
||||||
|
return <ProfileDashboard />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfilePage;
|
||||||
47
app/refund-policy/page.jsx
Normal file
47
app/refund-policy/page.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import RefundPolicy from "@/components/PagesComponent/StaticPages/RefundPolicy";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ searchParams }) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const params = await searchParams;
|
||||||
|
const langCode = params?.lang || "en";
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}seo-settings?page=refund-policy`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
const privacyPolicy = data?.data?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
title:
|
||||||
|
privacyPolicy?.translated_title || process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description:
|
||||||
|
privacyPolicy?.translated_description ||
|
||||||
|
process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: privacyPolicy?.image ? [privacyPolicy?.image] : [],
|
||||||
|
},
|
||||||
|
keywords:
|
||||||
|
privacyPolicy?.translated_keywords ||
|
||||||
|
process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const RefundPolicyPage = () => {
|
||||||
|
return <RefundPolicy />;
|
||||||
|
};
|
||||||
|
export default RefundPolicyPage;
|
||||||
7
app/reviews/page.jsx
Normal file
7
app/reviews/page.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import ProfileDashboard from "@/components/PagesComponent/ProfileDashboard/ProfileDashboard";
|
||||||
|
|
||||||
|
const ReviewsPage = () => {
|
||||||
|
return <ProfileDashboard />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReviewsPage;
|
||||||
98
app/seller/[id]/page.jsx
Normal file
98
app/seller/[id]/page.jsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import StructuredData from "@/components/Layout/StructuredData";
|
||||||
|
import Seller from "@/components/PagesComponent/Seller/Seller";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const { id } = await params;
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}get-seller?id=${id}`,
|
||||||
|
{
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
const seller = data?.data?.seller;
|
||||||
|
const title = seller?.name;
|
||||||
|
const image = seller?.profile;
|
||||||
|
return {
|
||||||
|
title: title ? title : process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description: process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: image ? [image] : [],
|
||||||
|
},
|
||||||
|
keywords: process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getSellerItems = async (id, langCode) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}get-item?page=1&user_id=${id}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.data?.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Product Items Data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SellerPage = async ({ params, searchParams }) => {
|
||||||
|
const { id } = await params;
|
||||||
|
const originalSearchParams = await searchParams;
|
||||||
|
const langCode = originalSearchParams?.lang || "en";
|
||||||
|
const sellerItems = await getSellerItems(id, langCode);
|
||||||
|
const jsonLd = sellerItems
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ItemList",
|
||||||
|
itemListElement: sellerItems?.map((product, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: index + 1, // Position starts at 1
|
||||||
|
item: {
|
||||||
|
"@type": "Product",
|
||||||
|
productID: product?.id,
|
||||||
|
name: product?.translated_item?.name,
|
||||||
|
description: product?.translated_item?.description,
|
||||||
|
image: product?.image,
|
||||||
|
url: `${process.env.NEXT_PUBLIC_WEB_URL}/ad-details/${product?.slug}`,
|
||||||
|
category: {
|
||||||
|
"@type": "Thing",
|
||||||
|
name: product?.category?.translated_name,
|
||||||
|
},
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
price: product?.price,
|
||||||
|
priceCurrency: "USD",
|
||||||
|
},
|
||||||
|
countryOfOrigin: product?.country,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StructuredData jsonLd={jsonLd} />
|
||||||
|
<Seller id={id} searchParams={originalSearchParams} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SellerPage;
|
||||||
223
app/sitemap.js
Normal file
223
app/sitemap.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { SITEMAP_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export default async function sitemap() {
|
||||||
|
// Check if SEO is enabled via environment variable
|
||||||
|
// If SEO is disabled, return empty array to prevent sitemap generation
|
||||||
|
const seoEnabled = process.env.NEXT_PUBLIC_SEO === "true";
|
||||||
|
if (!seoEnabled) {
|
||||||
|
// Return empty sitemap when SEO is disabled
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_WEB_URL;
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
let defaultLanguageCode = "en";
|
||||||
|
let languages = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiUrl}${process.env.NEXT_PUBLIC_END_POINT}get-system-settings`,
|
||||||
|
{ next: { revalidate: SITEMAP_REVALIDATE_SECONDS } } // Revalidate weekly
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
defaultLanguageCode = data?.data?.default_language || "en";
|
||||||
|
languages = data?.data?.languages || [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching languages for sitemap:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicRoutes = [
|
||||||
|
"about-us",
|
||||||
|
"ads",
|
||||||
|
"blogs",
|
||||||
|
"contact-us",
|
||||||
|
"faqs",
|
||||||
|
"landing",
|
||||||
|
"privacy-policy",
|
||||||
|
"refund-policy",
|
||||||
|
"subscription",
|
||||||
|
"terms-and-condition",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ✅ Escape XML entities
|
||||||
|
const escapeXml = (unsafe) =>
|
||||||
|
unsafe.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
||||||
|
const buildHreflangLinks = (url) => {
|
||||||
|
const links = {};
|
||||||
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
|
languages.forEach((lang) => {
|
||||||
|
links[lang.code] = escapeXml(`${url}${separator}lang=${lang.code}`);
|
||||||
|
});
|
||||||
|
// Add x-default
|
||||||
|
links["x-default"] = escapeXml(
|
||||||
|
`${url}${separator}lang=${defaultLanguageCode}`
|
||||||
|
);
|
||||||
|
return { languages: links };
|
||||||
|
};
|
||||||
|
// ✅ Add default lang param to main <loc> URLs
|
||||||
|
const withDefaultLang = (url) => {
|
||||||
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
|
return escapeXml(`${url}${separator}lang=${defaultLanguageCode}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const staticSitemapEntries = publicRoutes.map((route) => {
|
||||||
|
const url = `${baseUrl}/${route}`;
|
||||||
|
return {
|
||||||
|
url: withDefaultLang(url),
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.9,
|
||||||
|
alternates: buildHreflangLinks(url),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the base URL entry
|
||||||
|
const baseEntry = {
|
||||||
|
url: withDefaultLang(baseUrl),
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 1,
|
||||||
|
alternates: buildHreflangLinks(baseUrl),
|
||||||
|
};
|
||||||
|
|
||||||
|
let adEntries = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiUrl}${process.env.NEXT_PUBLIC_END_POINT}get-item-slug`,
|
||||||
|
{ next: { revalidate: SITEMAP_REVALIDATE_SECONDS } } // Revalidate weekly
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
const products = json?.data || [];
|
||||||
|
adEntries = products.map((product) => {
|
||||||
|
const url = `${baseUrl}/ad-details/${product?.slug}`;
|
||||||
|
return {
|
||||||
|
url: withDefaultLang(url),
|
||||||
|
lastModified: new Date(product?.updated_at),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.8,
|
||||||
|
alternates: buildHreflangLinks(url),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching products for sitemap:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let categoryEntries = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiUrl}${process.env.NEXT_PUBLIC_END_POINT}get-categories-slug`,
|
||||||
|
{ next: { revalidate: SITEMAP_REVALIDATE_SECONDS } } // Revalidate weekly
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
const categories = json?.data || [];
|
||||||
|
categoryEntries = categories.map((category) => {
|
||||||
|
const url = `${baseUrl}/ads?category=${category?.slug}`;
|
||||||
|
return {
|
||||||
|
url: withDefaultLang(url),
|
||||||
|
lastModified: new Date(category?.updated_at),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.7,
|
||||||
|
alternates: buildHreflangLinks(url),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching categories for sitemap:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let blogEntries = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiUrl}${process.env.NEXT_PUBLIC_END_POINT}get-blogs-slug`,
|
||||||
|
{ next: { revalidate: SITEMAP_REVALIDATE_SECONDS } } // Revalidate weekly
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
const blogs = json?.data || [];
|
||||||
|
blogEntries = blogs.map((blog) => {
|
||||||
|
const url = `${baseUrl}/blogs/${blog?.slug}`;
|
||||||
|
return {
|
||||||
|
url: withDefaultLang(url),
|
||||||
|
lastModified: new Date(blog?.updated_at),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.7,
|
||||||
|
alternates: buildHreflangLinks(url),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching blogs for sitemap:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let featuredSectionEntries = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiUrl}${process.env.NEXT_PUBLIC_END_POINT}get-featured-section-slug`,
|
||||||
|
{ next: { revalidate: SITEMAP_REVALIDATE_SECONDS } } // Revalidate weekly
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
const featuredSections = json?.data || [];
|
||||||
|
featuredSectionEntries = featuredSections.map((featuredSection) => {
|
||||||
|
const url = `${baseUrl}/ads?featured_section=${featuredSection?.slug}`;
|
||||||
|
return {
|
||||||
|
url: withDefaultLang(url),
|
||||||
|
lastModified: new Date(featuredSection?.updated_at),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.7,
|
||||||
|
alternates: buildHreflangLinks(url),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching featured sections for sitemap:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sellerProfileEntries = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiUrl}${process.env.NEXT_PUBLIC_END_POINT}get-seller-slug`,
|
||||||
|
{ next: { revalidate: SITEMAP_REVALIDATE_SECONDS } } // Revalidate weekly
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
const sellers = json?.data || [];
|
||||||
|
sellerProfileEntries = sellers.map((seller) => {
|
||||||
|
const url = `${baseUrl}/seller/${seller?.id}`;
|
||||||
|
return {
|
||||||
|
url: withDefaultLang(url),
|
||||||
|
lastModified: new Date(seller?.updated_at),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.7,
|
||||||
|
alternates: buildHreflangLinks(url),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching featured sections for sitemap:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
baseEntry,
|
||||||
|
...staticSitemapEntries,
|
||||||
|
...adEntries,
|
||||||
|
...categoryEntries,
|
||||||
|
...blogEntries,
|
||||||
|
...featuredSectionEntries,
|
||||||
|
...sellerProfileEntries,
|
||||||
|
];
|
||||||
|
}
|
||||||
45
app/subscription/page.jsx
Normal file
45
app/subscription/page.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import Subscription from "@/components/PagesComponent/Subscription/Subscription";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ searchParams }) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const params = await searchParams;
|
||||||
|
const langCode = params?.lang || "en";
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}seo-settings?page=subscription`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
const subscription = data?.data?.[0];
|
||||||
|
return {
|
||||||
|
title: subscription?.translated_title || process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description:
|
||||||
|
subscription?.translated_description ||
|
||||||
|
process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: subscription?.image ? [subscription?.image] : [],
|
||||||
|
},
|
||||||
|
keywords:
|
||||||
|
subscription?.translated_keywords || process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SubscriptionPage = () => {
|
||||||
|
return <Subscription />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubscriptionPage;
|
||||||
50
app/terms-and-condition/page.jsx
Normal file
50
app/terms-and-condition/page.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import TermsAndCondition from "@/components/PagesComponent/StaticPages/TermsAndCondition";
|
||||||
|
import { SEO_REVALIDATE_SECONDS } from "@/lib/constants";
|
||||||
|
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ searchParams }) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_SEO === "false") return;
|
||||||
|
const params = await searchParams;
|
||||||
|
const langCode = params?.lang || "en";
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}${process.env.NEXT_PUBLIC_END_POINT}seo-settings?page=terms-and-conditions`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Language": langCode || "en",
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: SEO_REVALIDATE_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
const termsAndConditions = data?.data?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
title:
|
||||||
|
termsAndConditions?.translated_title ||
|
||||||
|
process.env.NEXT_PUBLIC_META_TITLE,
|
||||||
|
description:
|
||||||
|
termsAndConditions?.translated_description ||
|
||||||
|
process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||||
|
openGraph: {
|
||||||
|
images: termsAndConditions?.image ? [termsAndConditions?.image] : [],
|
||||||
|
},
|
||||||
|
keywords:
|
||||||
|
termsAndConditions?.translated_keywords ||
|
||||||
|
process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching MetaData:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TermsAndConditionPage = () => {
|
||||||
|
return <TermsAndCondition />;
|
||||||
|
};
|
||||||
|
export default TermsAndConditionPage;
|
||||||
7
app/transactions/page.jsx
Normal file
7
app/transactions/page.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import ProfileDashboard from "@/components/PagesComponent/ProfileDashboard/ProfileDashboard";
|
||||||
|
|
||||||
|
const TransactionsPage = () => {
|
||||||
|
return <ProfileDashboard />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TransactionsPage;
|
||||||
7
app/user-subscription/page.jsx
Normal file
7
app/user-subscription/page.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import ProfileDashboard from "@/components/PagesComponent/ProfileDashboard/ProfileDashboard";
|
||||||
|
|
||||||
|
const UserSubscriptionPage = () => {
|
||||||
|
return <ProfileDashboard />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserSubscriptionPage;
|
||||||
7
app/user-verification/page.jsx
Normal file
7
app/user-verification/page.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import UserVerification from "@/components/PagesComponent/UserVerification/UserVerification";
|
||||||
|
|
||||||
|
const UserVerificationPage = () => {
|
||||||
|
return <UserVerification />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserVerificationPage;
|
||||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": false,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
249
components/Auth/DeleteAccountVerifyOtpModal.jsx
Normal file
249
components/Auth/DeleteAccountVerifyOtpModal.jsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import "react-phone-input-2/lib/style.css";
|
||||||
|
import { handleFirebaseAuthError, t } from "@/utils";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { logoutSuccess, userSignUpData } from "@/redux/reducer/authSlice";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import useAutoFocus from "../Common/useAutoFocus";
|
||||||
|
import {
|
||||||
|
deleteUser,
|
||||||
|
getAuth,
|
||||||
|
RecaptchaVerifier,
|
||||||
|
signInWithPhoneNumber,
|
||||||
|
} from "firebase/auth";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { deleteUserApi } from "@/utils/api";
|
||||||
|
|
||||||
|
const DeleteAccountVerifyOtpModal = ({
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
pathname,
|
||||||
|
navigate,
|
||||||
|
}) => {
|
||||||
|
const userData = useSelector(userSignUpData);
|
||||||
|
const auth = getAuth();
|
||||||
|
|
||||||
|
const countryCode = userData?.country_code;
|
||||||
|
const formattedNumber = userData?.mobile;
|
||||||
|
|
||||||
|
const otpInputRef = useAutoFocus();
|
||||||
|
const [showLoader, setShowLoader] = useState(false);
|
||||||
|
const [otp, setOtp] = useState("");
|
||||||
|
const [resendOtpLoader, setResendOtpLoader] = useState(false);
|
||||||
|
const [resendTimer, setResendTimer] = useState(0);
|
||||||
|
const [confirmationResult, setConfirmationResult] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
sendOTP();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let intervalId;
|
||||||
|
if (resendTimer > 0) {
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
setResendTimer((prevTimer) => prevTimer - 1);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [resendTimer]);
|
||||||
|
|
||||||
|
const generateRecaptcha = async () => {
|
||||||
|
// Reuse existing verifier if it's still valid
|
||||||
|
if (window.recaptchaVerifier && !window.recaptchaVerifier.destroyed) {
|
||||||
|
return window.recaptchaVerifier;
|
||||||
|
}
|
||||||
|
const recaptchaContainer = document.getElementById("recaptcha-container");
|
||||||
|
if (!recaptchaContainer) {
|
||||||
|
console.error("Container element 'recaptcha-container' not found.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Clear container and reset reference
|
||||||
|
recaptchaContainer.innerHTML = "";
|
||||||
|
window.recaptchaVerifier = undefined;
|
||||||
|
try {
|
||||||
|
window.recaptchaVerifier = new RecaptchaVerifier(
|
||||||
|
auth,
|
||||||
|
recaptchaContainer,
|
||||||
|
{ size: "invisible" }
|
||||||
|
);
|
||||||
|
return window.recaptchaVerifier;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing RecaptchaVerifier:", error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
recaptchaClear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const recaptchaClear = async () => {
|
||||||
|
if (window.recaptchaVerifier && !window.recaptchaVerifier.destroyed) {
|
||||||
|
try {
|
||||||
|
await window.recaptchaVerifier.clear();
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors - verifier might already be cleared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.recaptchaVerifier = undefined;
|
||||||
|
const recaptchaContainer = document.getElementById("recaptcha-container");
|
||||||
|
if (recaptchaContainer) {
|
||||||
|
recaptchaContainer.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendOTP = async () => {
|
||||||
|
try {
|
||||||
|
const PhoneNumber = `${countryCode}${formattedNumber}`;
|
||||||
|
setShowLoader(true);
|
||||||
|
const appVerifier = await generateRecaptcha();
|
||||||
|
const confirmation = await signInWithPhoneNumber(
|
||||||
|
auth,
|
||||||
|
PhoneNumber,
|
||||||
|
appVerifier
|
||||||
|
);
|
||||||
|
setConfirmationResult(confirmation);
|
||||||
|
toast.success(t("otpSentSuccess"));
|
||||||
|
setResendTimer(60);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
handleFirebaseAuthError(error.code);
|
||||||
|
} finally {
|
||||||
|
setShowLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyOTPWithFirebase = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
setShowLoader(true);
|
||||||
|
const result = await confirmationResult.confirm(otp);
|
||||||
|
const user = result.user;
|
||||||
|
await deleteUser(user);
|
||||||
|
await deleteUserApi.deleteUser();
|
||||||
|
logoutSuccess();
|
||||||
|
toast.success(t("userDeleteSuccess"));
|
||||||
|
setIsOpen(false);
|
||||||
|
if (pathname !== "/") {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const errorCode = error?.code;
|
||||||
|
handleFirebaseAuthError(errorCode);
|
||||||
|
} finally {
|
||||||
|
setShowLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resendOtp = async () => {
|
||||||
|
try {
|
||||||
|
setResendOtpLoader(true);
|
||||||
|
const PhoneNumber = `${countryCode}${formattedNumber}`;
|
||||||
|
const appVerifier = await generateRecaptcha();
|
||||||
|
const confirmation = await signInWithPhoneNumber(
|
||||||
|
auth,
|
||||||
|
PhoneNumber,
|
||||||
|
appVerifier
|
||||||
|
);
|
||||||
|
setConfirmationResult(confirmation);
|
||||||
|
toast.success(t("otpSentSuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
const errorCode = error.code;
|
||||||
|
handleFirebaseAuthError(errorCode);
|
||||||
|
} finally {
|
||||||
|
setResendOtpLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
className="px-[40px] sm:py-[50px] sm:px-[90px]"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-3xl sm:text-4xl font-light">
|
||||||
|
{t("verifyOtp")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-base text-black font-light">
|
||||||
|
{t("sentTo")} {`${countryCode} ${formattedNumber}`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
onSubmit={verifyOTPWithFirebase}
|
||||||
|
>
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="requiredInputLabel">{t("otp")}</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("enterOtp")}
|
||||||
|
id="otp"
|
||||||
|
name="otp"
|
||||||
|
value={otp}
|
||||||
|
maxLength={6}
|
||||||
|
onChange={(e) => setOtp(e.target.value)}
|
||||||
|
ref={otpInputRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={showLoader}
|
||||||
|
className="text-xl text-white font-light px-4 py-2"
|
||||||
|
size="big"
|
||||||
|
>
|
||||||
|
{showLoader ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("verify")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="text-lg text-black font-light bg-transparent"
|
||||||
|
size="big"
|
||||||
|
onClick={resendOtp}
|
||||||
|
disabled={resendOtpLoader || showLoader || resendTimer > 0}
|
||||||
|
>
|
||||||
|
{resendOtpLoader ? (
|
||||||
|
<Loader2 className="size-6 animate-spin" />
|
||||||
|
) : resendTimer > 0 ? (
|
||||||
|
`${t("resendOtp")} ${resendTimer}s`
|
||||||
|
) : (
|
||||||
|
t("resendOtp")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<div id="recaptcha-container" style={{ display: "none" }}></div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteAccountVerifyOtpModal;
|
||||||
382
components/Auth/LoginModal.jsx
Normal file
382
components/Auth/LoginModal.jsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
"use client";
|
||||||
|
import { formatPhoneNumber, handleFirebaseAuthError, t } from "@/utils";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import {
|
||||||
|
Fcmtoken,
|
||||||
|
getIsDemoMode,
|
||||||
|
getOtpServiceProvider,
|
||||||
|
settingsData,
|
||||||
|
} from "@/redux/reducer/settingSlice";
|
||||||
|
import "react-phone-input-2/lib/style.css";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { FcGoogle } from "react-icons/fc";
|
||||||
|
import { MdOutlineEmail, MdOutlineLocalPhone } from "react-icons/md";
|
||||||
|
import {
|
||||||
|
getAuth,
|
||||||
|
GoogleAuthProvider,
|
||||||
|
RecaptchaVerifier,
|
||||||
|
signInWithPhoneNumber,
|
||||||
|
signInWithPopup,
|
||||||
|
} from "firebase/auth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getOtpApi, userSignUpApi } from "@/utils/api";
|
||||||
|
import { loadUpdateData } from "@/redux/reducer/authSlice";
|
||||||
|
import LoginWithEmailForm from "./LoginWithEmailForm";
|
||||||
|
import LoginWithMobileForm from "./LoginWithMobileForm";
|
||||||
|
import OtpScreen from "./OtpScreen";
|
||||||
|
import TermsAndPrivacyLinks from "./TermsAndPrivacyLinks";
|
||||||
|
import { setIsLoginOpen } from "@/redux/reducer/globalStateSlice";
|
||||||
|
import ResetPasswordScreen from "./ResetPasswordScreen";
|
||||||
|
|
||||||
|
const LoginModal = ({ IsLoginOpen, setIsRegisterModalOpen }) => {
|
||||||
|
const settings = useSelector(settingsData);
|
||||||
|
const auth = getAuth();
|
||||||
|
const fetchFCM = useSelector(Fcmtoken);
|
||||||
|
const isDemoMode = useSelector(getIsDemoMode);
|
||||||
|
const [IsOTPScreen, setIsOTPScreen] = useState(null);
|
||||||
|
const [resendTimer, setResendTimer] = useState(0);
|
||||||
|
const [loginStates, setLoginStates] = useState({
|
||||||
|
number: isDemoMode ? "919876598765" : "",
|
||||||
|
countryCode: isDemoMode ? "+91" : '',
|
||||||
|
showLoader: false,
|
||||||
|
regionCode: "",
|
||||||
|
password: isDemoMode ? "123456" : "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [confirmationResult, setConfirmationResult] = useState(null);
|
||||||
|
const [FirebaseId, setFirebaseId] = useState("");
|
||||||
|
const { number, countryCode } = loginStates;
|
||||||
|
const formattedNumber = formatPhoneNumber(number, countryCode);
|
||||||
|
|
||||||
|
const otp_service_provider = useSelector(getOtpServiceProvider);
|
||||||
|
|
||||||
|
// Active authentication methods
|
||||||
|
const mobile_authentication = Number(settings?.mobile_authentication);
|
||||||
|
const google_authentication = Number(settings?.google_authentication);
|
||||||
|
const email_authentication = Number(settings?.email_authentication);
|
||||||
|
|
||||||
|
const [IsLoginWithEmail, setIsLoginWithEmail] = useState(
|
||||||
|
mobile_authentication === 0 && email_authentication === 1 ? true : false
|
||||||
|
);
|
||||||
|
|
||||||
|
const IsShowOrSignIn =
|
||||||
|
!(
|
||||||
|
mobile_authentication === 0 &&
|
||||||
|
email_authentication === 0 &&
|
||||||
|
google_authentication === 1
|
||||||
|
) && google_authentication === 1;
|
||||||
|
|
||||||
|
const OnHide = async () => {
|
||||||
|
setIsOTPScreen(null);
|
||||||
|
setIsLoginOpen(false);
|
||||||
|
setConfirmationResult(null);
|
||||||
|
setResendTimer(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateRecaptcha = async () => {
|
||||||
|
// Reuse existing verifier if it's still valid
|
||||||
|
if (window.recaptchaVerifier && !window.recaptchaVerifier.destroyed) {
|
||||||
|
return window.recaptchaVerifier;
|
||||||
|
}
|
||||||
|
const recaptchaContainer = document.getElementById("recaptcha-container");
|
||||||
|
if (!recaptchaContainer) {
|
||||||
|
console.error("Container element 'recaptcha-container' not found.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Clear container and reset reference
|
||||||
|
recaptchaContainer.innerHTML = "";
|
||||||
|
window.recaptchaVerifier = undefined;
|
||||||
|
try {
|
||||||
|
window.recaptchaVerifier = new RecaptchaVerifier(
|
||||||
|
auth,
|
||||||
|
recaptchaContainer,
|
||||||
|
{ size: "invisible" }
|
||||||
|
);
|
||||||
|
return window.recaptchaVerifier;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing RecaptchaVerifier:", error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
recaptchaClear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const recaptchaClear = async () => {
|
||||||
|
if (window.recaptchaVerifier && !window.recaptchaVerifier.destroyed) {
|
||||||
|
try {
|
||||||
|
await window.recaptchaVerifier.clear();
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors - verifier might already be cleared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.recaptchaVerifier = undefined;
|
||||||
|
const recaptchaContainer = document.getElementById("recaptcha-container");
|
||||||
|
if (recaptchaContainer) {
|
||||||
|
recaptchaContainer.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleSignup = async () => {
|
||||||
|
const provider = new GoogleAuthProvider();
|
||||||
|
try {
|
||||||
|
const res = await signInWithPopup(auth, provider);
|
||||||
|
const user = res.user;
|
||||||
|
try {
|
||||||
|
const response = await userSignUpApi.userSignup({
|
||||||
|
name: user.displayName ? user.displayName : "",
|
||||||
|
email: user?.email,
|
||||||
|
firebase_id: user?.uid, // Accessing UID directly from the user object
|
||||||
|
fcm_id: fetchFCM ? fetchFCM : "",
|
||||||
|
type: "google",
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
if (data.error === true) {
|
||||||
|
toast.error(data.message);
|
||||||
|
} else {
|
||||||
|
loadUpdateData(data);
|
||||||
|
toast.success(data.message);
|
||||||
|
}
|
||||||
|
OnHide();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
toast.error("Failed to complete signup");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorCode = error.code;
|
||||||
|
handleFirebaseAuthError(errorCode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateAnAccount = () => {
|
||||||
|
OnHide();
|
||||||
|
setIsRegisterModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle forgot password - send OTP and show OTP screen
|
||||||
|
const handleForgotPassword = async () => {
|
||||||
|
const PhoneNumber = `${loginStates.countryCode}${formattedNumber}`;
|
||||||
|
if (otp_service_provider === "twilio") {
|
||||||
|
try {
|
||||||
|
const response = await getOtpApi.getOtp({ number: formattedNumber, country_code: countryCode });
|
||||||
|
if (response?.data?.error === false) {
|
||||||
|
toast.success(t("otpSentSuccess"));
|
||||||
|
setResendTimer(60);
|
||||||
|
setIsOTPScreen("otp");
|
||||||
|
} else {
|
||||||
|
toast.error(t("failedToSendOtp"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const appVerifier = await generateRecaptcha();
|
||||||
|
const confirmation = await signInWithPhoneNumber(
|
||||||
|
auth,
|
||||||
|
PhoneNumber,
|
||||||
|
appVerifier
|
||||||
|
);
|
||||||
|
setConfirmationResult(confirmation);
|
||||||
|
toast.success(t("otpSentSuccess"));
|
||||||
|
setResendTimer(60);
|
||||||
|
setIsOTPScreen("otp");
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
console.log(error)
|
||||||
|
|
||||||
|
handleFirebaseAuthError(error.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle OTP verification success - move to reset password screen
|
||||||
|
const handleForgotPasswordOtpVerified = (firebase_id) => {
|
||||||
|
setFirebaseId(firebase_id);
|
||||||
|
setIsOTPScreen("reset");
|
||||||
|
toast.success(t("otpVerified"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle successful password reset - go back to login
|
||||||
|
const handleResetPasswordSuccess = () => {
|
||||||
|
setIsOTPScreen(null);
|
||||||
|
setConfirmationResult(null);
|
||||||
|
setResendTimer(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={IsLoginOpen} onOpenChange={setIsLoginOpen}>
|
||||||
|
<DialogContent
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
className="px-[40px] sm:py-[50px] sm:px-[90px]"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-3xl sm:text-4xl font-light">
|
||||||
|
{IsOTPScreen === "otp" ? (
|
||||||
|
t("verifyOtp")
|
||||||
|
) : IsOTPScreen === "reset" ? (
|
||||||
|
t("resetYourPassword")
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t("loginTo")}{" "}
|
||||||
|
<span className="text-primary">{settings?.company_name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-base text-black font-light">
|
||||||
|
{IsOTPScreen === "otp" ? (
|
||||||
|
<>
|
||||||
|
{t("sentTo")} {`${countryCode}${formattedNumber}`}{" "}
|
||||||
|
<span
|
||||||
|
onClick={() => setIsOTPScreen(false)}
|
||||||
|
className="text-primary underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("change")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : IsOTPScreen === "reset" ? (
|
||||||
|
t("enterNewPassword")
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t("newto")} {settings?.company_name}?{" "}
|
||||||
|
<span
|
||||||
|
className="text-primary cursor-pointer underline"
|
||||||
|
onClick={handleCreateAnAccount}
|
||||||
|
>
|
||||||
|
{t("createAccount")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{IsOTPScreen === "otp" ? (
|
||||||
|
<OtpScreen
|
||||||
|
OnHide={OnHide}
|
||||||
|
generateRecaptcha={generateRecaptcha}
|
||||||
|
countryCode={countryCode}
|
||||||
|
formattedNumber={formattedNumber}
|
||||||
|
confirmationResult={confirmationResult}
|
||||||
|
setConfirmationResult={setConfirmationResult}
|
||||||
|
resendTimer={resendTimer}
|
||||||
|
setResendTimer={setResendTimer}
|
||||||
|
regionCode={loginStates.regionCode}
|
||||||
|
isDemoMode={isDemoMode}
|
||||||
|
onOtpVerified={handleForgotPasswordOtpVerified}
|
||||||
|
key="forgot-password-otp"
|
||||||
|
/>
|
||||||
|
) : IsOTPScreen === "reset" ? (
|
||||||
|
<ResetPasswordScreen
|
||||||
|
FirebaseId={FirebaseId}
|
||||||
|
formattedNumber={formattedNumber}
|
||||||
|
countryCode={loginStates.countryCode}
|
||||||
|
onSuccess={handleResetPasswordSuccess}
|
||||||
|
onCancel={() => setIsOTPScreen(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-[30px] mt-3.5">
|
||||||
|
{!(
|
||||||
|
mobile_authentication === 0 &&
|
||||||
|
email_authentication === 0 &&
|
||||||
|
google_authentication === 1
|
||||||
|
) &&
|
||||||
|
mobile_authentication === 1 &&
|
||||||
|
email_authentication === 1 &&
|
||||||
|
(IsLoginWithEmail ? (
|
||||||
|
<LoginWithEmailForm OnHide={OnHide} key={IsLoginWithEmail} />
|
||||||
|
) : (
|
||||||
|
<LoginWithMobileForm
|
||||||
|
formattedNumber={formattedNumber}
|
||||||
|
loginStates={loginStates}
|
||||||
|
setLoginStates={setLoginStates}
|
||||||
|
onForgotPassword={handleForgotPassword}
|
||||||
|
OnHide={OnHide}
|
||||||
|
key={IsLoginWithEmail}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{email_authentication === 1 && mobile_authentication === 0 && (
|
||||||
|
<LoginWithEmailForm OnHide={OnHide} key={IsLoginWithEmail} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mobile_authentication === 1 && email_authentication === 0 && (
|
||||||
|
<LoginWithMobileForm
|
||||||
|
OnHide={OnHide}
|
||||||
|
formattedNumber={formattedNumber}
|
||||||
|
loginStates={loginStates}
|
||||||
|
setLoginStates={setLoginStates}
|
||||||
|
onForgotPassword={handleForgotPassword}
|
||||||
|
key={IsLoginWithEmail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{IsShowOrSignIn && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<hr className="w-full" />
|
||||||
|
<p className="text-nowrap text-sm">{t("orSignInWith")}</p>
|
||||||
|
<hr className="w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{google_authentication === 1 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="big"
|
||||||
|
className="flex items-center justify-center py-4 text-base"
|
||||||
|
onClick={handleGoogleSignup}
|
||||||
|
>
|
||||||
|
<FcGoogle className="!size-6" />
|
||||||
|
<span>{t("continueWithGoogle")}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{IsLoginWithEmail && mobile_authentication === 1 ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="big"
|
||||||
|
className="flex items-center justify-center py-4 text-base h-auto"
|
||||||
|
onClick={() => setIsLoginWithEmail(false)}
|
||||||
|
>
|
||||||
|
<MdOutlineLocalPhone className="!size-6" />
|
||||||
|
{t("continueWithMobile")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
!IsLoginWithEmail &&
|
||||||
|
email_authentication === 1 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="big"
|
||||||
|
className="flex items-center justify-center py-4 text-base h-auto"
|
||||||
|
onClick={() => setIsLoginWithEmail(true)}
|
||||||
|
>
|
||||||
|
<MdOutlineEmail className="!size-6" />
|
||||||
|
{t("continueWithEmail")}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TermsAndPrivacyLinks t={t} settings={settings} OnHide={OnHide} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div id="recaptcha-container" style={{ display: "none" }}></div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginModal;
|
||||||
189
components/Auth/LoginWithEmailForm.jsx
Normal file
189
components/Auth/LoginWithEmailForm.jsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { FaRegEye, FaRegEyeSlash } from "react-icons/fa";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import useAutoFocus from "../Common/useAutoFocus";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { handleFirebaseAuthError, t } from "@/utils";
|
||||||
|
import {
|
||||||
|
getAuth,
|
||||||
|
sendPasswordResetEmail,
|
||||||
|
signInWithEmailAndPassword,
|
||||||
|
} from "firebase/auth";
|
||||||
|
import { userSignUpApi } from "@/utils/api";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { Fcmtoken } from "@/redux/reducer/settingSlice";
|
||||||
|
import { loadUpdateData } from "@/redux/reducer/authSlice";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const LoginWithEmailForm = ({ OnHide }) => {
|
||||||
|
const emailRef = useAutoFocus();
|
||||||
|
const auth = getAuth();
|
||||||
|
const fetchFCM = useSelector(Fcmtoken);
|
||||||
|
const [loginStates, setLoginStates] = useState({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
IsPasswordVisible: false,
|
||||||
|
showLoader: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { email, password, IsPasswordVisible, showLoader } = loginStates;
|
||||||
|
|
||||||
|
const signin = async (email, password) => {
|
||||||
|
try {
|
||||||
|
const userCredential = await signInWithEmailAndPassword(
|
||||||
|
auth,
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
);
|
||||||
|
if (!userCredential?.user) {
|
||||||
|
toast.error(t("userNotFound"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return userCredential;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error signing in:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Signin = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
toast.error(t("emailRequired"));
|
||||||
|
return;
|
||||||
|
} else if (!/\S+@\S+\.\S+/.test(email)) {
|
||||||
|
toast.error(t("emailInvalid"));
|
||||||
|
return;
|
||||||
|
} else if (!password) {
|
||||||
|
toast.error(t("passwordRequired"));
|
||||||
|
return;
|
||||||
|
} else if (password.length < 6) {
|
||||||
|
toast.error(t("passwordTooShort"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoginStates((prev) => ({ ...prev, showLoader: true }));
|
||||||
|
const userCredential = await signin(email, password);
|
||||||
|
const user = userCredential.user;
|
||||||
|
if (user.emailVerified) {
|
||||||
|
try {
|
||||||
|
const response = await userSignUpApi.userSignup({
|
||||||
|
name: user?.displayName || "",
|
||||||
|
email: user?.email,
|
||||||
|
firebase_id: user?.uid,
|
||||||
|
fcm_id: fetchFCM ? fetchFCM : "",
|
||||||
|
type: "email",
|
||||||
|
is_login: 1,
|
||||||
|
});
|
||||||
|
const data = response.data;
|
||||||
|
if (data.error === false) {
|
||||||
|
loadUpdateData(data);
|
||||||
|
toast.success(data.message);
|
||||||
|
OnHide();
|
||||||
|
} else {
|
||||||
|
toast.error(data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
}
|
||||||
|
// Add your logic here for verified users
|
||||||
|
} else {
|
||||||
|
toast.error(t("verifyEmailFirst"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorCode = error.code;
|
||||||
|
console.log("Error code:", errorCode);
|
||||||
|
handleFirebaseAuthError(errorCode);
|
||||||
|
} finally {
|
||||||
|
setLoginStates((prev) => ({ ...prev, showLoader: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForgotModal = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await sendPasswordResetEmail(auth, email)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(t("resetPassword"));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log("error", error);
|
||||||
|
handleFirebaseAuthError(error?.code);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form className="flex flex-col gap-6" onSubmit={Signin}>
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="requiredInputLabel">{t("email")}</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder={t("enterEmail")}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLoginStates((prev) => ({ ...prev, email: e.target.value }))
|
||||||
|
}
|
||||||
|
ref={emailRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="requiredInputLabel">{t("password")}</Label>
|
||||||
|
<div className="flex items-center relative">
|
||||||
|
<Input
|
||||||
|
type={IsPasswordVisible ? "text" : "password"}
|
||||||
|
placeholder={t("enterPassword")}
|
||||||
|
className="ltr:pr-9 rtl:pl-9"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLoginStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute ltr:right-3 rtl:left-3 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setLoginStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
IsPasswordVisible: !prev.IsPasswordVisible,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{IsPasswordVisible ? (
|
||||||
|
<FaRegEye size={20} />
|
||||||
|
) : (
|
||||||
|
<FaRegEyeSlash size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="text-right font-semibold text-primary"
|
||||||
|
onClick={handleForgotModal}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t("forgtPassword")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="text-xl text-white font-light px-4 py-2"
|
||||||
|
size="big"
|
||||||
|
disabled={showLoader}
|
||||||
|
>
|
||||||
|
{showLoader ? (
|
||||||
|
<Loader2 className="size-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("signIn")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginWithEmailForm;
|
||||||
194
components/Auth/LoginWithMobileForm.jsx
Normal file
194
components/Auth/LoginWithMobileForm.jsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import PhoneInput from "react-phone-input-2";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import useAutoFocus from "../Common/useAutoFocus";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { isValidPhoneNumber } from "libphonenumber-js/max";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FaRegEye, FaRegEyeSlash } from "react-icons/fa";
|
||||||
|
import { getUserExistsApi, userSignUpApi } from "@/utils/api";
|
||||||
|
import { Fcmtoken } from "@/redux/reducer/settingSlice";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { loadUpdateData } from "@/redux/reducer/authSlice";
|
||||||
|
|
||||||
|
const LoginWithMobileForm = ({
|
||||||
|
loginStates,
|
||||||
|
setLoginStates,
|
||||||
|
formattedNumber,
|
||||||
|
onForgotPassword,
|
||||||
|
OnHide,
|
||||||
|
}) => {
|
||||||
|
const numberInputRef = useAutoFocus();
|
||||||
|
const { number, countryCode, showLoader } = loginStates;
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
const fcm_id = useSelector(Fcmtoken);
|
||||||
|
const [forgotPasswordLoading, setForgotPasswordLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleInputChange = (value, data) => {
|
||||||
|
setLoginStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
number: value,
|
||||||
|
countryCode: "+" + (data?.dialCode || ""),
|
||||||
|
regionCode: data?.countryCode.toLowerCase() || "",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCountryChange = (code) => {
|
||||||
|
setLoginStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
countryCode: code,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoginWithMobile = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (!isValidPhoneNumber(`${countryCode}${formattedNumber}`)) {
|
||||||
|
toast.error(t("invalidPhoneNumber"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoginStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
showLoader: true,
|
||||||
|
}));
|
||||||
|
const params = {
|
||||||
|
mobile: formattedNumber,
|
||||||
|
password: loginStates.password,
|
||||||
|
country_code: countryCode,
|
||||||
|
type: "phone",
|
||||||
|
fcm_id: fcm_id ? fcm_id : "",
|
||||||
|
is_login: 1,
|
||||||
|
};
|
||||||
|
const response = await userSignUpApi.userSignup(params);
|
||||||
|
if (response?.data?.error === false) {
|
||||||
|
toast.success(response?.data?.message);
|
||||||
|
loadUpdateData(response?.data);
|
||||||
|
OnHide();
|
||||||
|
} else {
|
||||||
|
toast.error(response?.data?.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setLoginStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
showLoader: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const checkIfUserExistsOrNot = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getUserExistsApi.getUserExists({
|
||||||
|
mobile: formattedNumber,
|
||||||
|
country_code: countryCode,
|
||||||
|
forgot_password: 1
|
||||||
|
})
|
||||||
|
if (res?.data?.error === false) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
toast.error(res?.data?.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Handle forgot password with loading state
|
||||||
|
const handleForgotPasswordClick = async () => {
|
||||||
|
if (!isValidPhoneNumber(`${countryCode}${formattedNumber}`)) {
|
||||||
|
toast.error(t("invalidPhoneNumber"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForgotPasswordLoading(true);
|
||||||
|
const isUserExists = await checkIfUserExistsOrNot()
|
||||||
|
if (!isUserExists) {
|
||||||
|
setForgotPasswordLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onForgotPassword();
|
||||||
|
setForgotPasswordLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-6" onSubmit={handleLoginWithMobile}>
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="font-semibold after:content-['*'] after:text-red-500">
|
||||||
|
{t("mobileNumber")}
|
||||||
|
</Label>
|
||||||
|
<PhoneInput
|
||||||
|
country={process.env.NEXT_PUBLIC_DEFAULT_COUNTRY}
|
||||||
|
value={number}
|
||||||
|
onChange={(phone, data) => handleInputChange(phone, data)}
|
||||||
|
onCountryChange={handleCountryChange}
|
||||||
|
inputProps={{
|
||||||
|
name: "phone",
|
||||||
|
required: true,
|
||||||
|
ref: numberInputRef,
|
||||||
|
}}
|
||||||
|
enableLongNumbers
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="requiredInputLabel">{t("password")}</Label>
|
||||||
|
<div className="flex items-center relative">
|
||||||
|
<Input
|
||||||
|
type={isPasswordVisible ? "text" : "password"}
|
||||||
|
placeholder={t("enterPassword")}
|
||||||
|
className="ltr:pr-9 rtl:pl-9"
|
||||||
|
value={loginStates.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLoginStates((prev) => ({ ...prev, password: e.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute ltr:right-3 rtl:left-3 cursor-pointer"
|
||||||
|
onClick={() => setIsPasswordVisible((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? (
|
||||||
|
<FaRegEye size={20} />
|
||||||
|
) : (
|
||||||
|
<FaRegEyeSlash size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="text-right font-semibold text-primary w-fit self-end"
|
||||||
|
onClick={handleForgotPasswordClick}
|
||||||
|
type="button"
|
||||||
|
disabled={forgotPasswordLoading}
|
||||||
|
>
|
||||||
|
{forgotPasswordLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-2 justify-end">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span>{t("loading")}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("forgtPassword")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={showLoader}
|
||||||
|
className="text-xl text-white font-light px-4 py-2"
|
||||||
|
size="big"
|
||||||
|
>
|
||||||
|
{showLoader ? <Loader2 className="size-6 animate-spin" /> : t("login")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginWithMobileForm;
|
||||||
36
components/Auth/MailSentSuccessModal.jsx
Normal file
36
components/Auth/MailSentSuccessModal.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import mainSentImg from "../../public/assets/Mail Verification.svg";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import CustomImage from "../Common/CustomImage";
|
||||||
|
|
||||||
|
const MailSentSuccessModal = ({ IsMailSentSuccess, setIsMailSentSuccess }) => {
|
||||||
|
return (
|
||||||
|
<Dialog open={IsMailSentSuccess} onOpenChange={setIsMailSentSuccess}>
|
||||||
|
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="sr-only"></DialogTitle>
|
||||||
|
<DialogDescription className="sr-only"></DialogDescription>
|
||||||
|
<div className="flex flex-col gap-3 items-center justify-center">
|
||||||
|
<CustomImage
|
||||||
|
src={mainSentImg}
|
||||||
|
alt="Verification Mail sent"
|
||||||
|
width={300}
|
||||||
|
height={195}
|
||||||
|
className="aspect-[300/195] object-contain"
|
||||||
|
/>
|
||||||
|
<h1 className="text-2xl font-medium">{t("youveGotMail")}</h1>
|
||||||
|
<p className="opacity-65">{t("verifyAccount")}</p>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MailSentSuccessModal;
|
||||||
244
components/Auth/OtpScreen.jsx
Normal file
244
components/Auth/OtpScreen.jsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { getAuth, signInWithPhoneNumber } from "firebase/auth";
|
||||||
|
import useAutoFocus from "../Common/useAutoFocus";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { handleFirebaseAuthError, t } from "@/utils";
|
||||||
|
import { getOtpApi, userSignUpApi, verifyOtpApi } from "@/utils/api";
|
||||||
|
import { loadUpdateData } from "@/redux/reducer/authSlice";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { Fcmtoken, getOtpServiceProvider } from "@/redux/reducer/settingSlice";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "../Common/useNavigate";
|
||||||
|
|
||||||
|
const OtpScreen = ({
|
||||||
|
generateRecaptcha,
|
||||||
|
countryCode,
|
||||||
|
formattedNumber,
|
||||||
|
confirmationResult,
|
||||||
|
setConfirmationResult,
|
||||||
|
OnHide,
|
||||||
|
resendTimer,
|
||||||
|
setResendTimer,
|
||||||
|
regionCode,
|
||||||
|
isDemoMode,
|
||||||
|
isRegister = false,
|
||||||
|
onOtpVerified,
|
||||||
|
password
|
||||||
|
}) => {
|
||||||
|
const { navigate } = useNavigate();
|
||||||
|
const otpInputRef = useAutoFocus();
|
||||||
|
const fetchFCM = useSelector(Fcmtoken);
|
||||||
|
const auth = getAuth();
|
||||||
|
const [resendOtpLoader, setResendOtpLoader] = useState(false);
|
||||||
|
const [showLoader, setShowLoader] = useState(false);
|
||||||
|
const [otp, setOtp] = useState(isDemoMode && !isRegister ? "123456" : "");
|
||||||
|
const otp_service_provider = useSelector(getOtpServiceProvider);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let intervalId;
|
||||||
|
if (resendTimer > 0) {
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
setResendTimer((prevTimer) => prevTimer - 1);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [resendTimer]);
|
||||||
|
|
||||||
|
const verifyOTPWithTwillio = async () => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
number: formattedNumber,
|
||||||
|
country_code: countryCode,
|
||||||
|
otp: otp,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRegister && password) {
|
||||||
|
payload.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await verifyOtpApi.verifyOtp(payload);
|
||||||
|
if (response?.data?.error === false) {
|
||||||
|
// If callback provided, use it (for forgot password)
|
||||||
|
if (onOtpVerified) {
|
||||||
|
onOtpVerified();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Otherwise, do normal login
|
||||||
|
loadUpdateData(response?.data);
|
||||||
|
toast.success(response?.data?.message);
|
||||||
|
if (
|
||||||
|
response?.data?.data?.email === "" ||
|
||||||
|
response?.data?.data?.name === ""
|
||||||
|
) {
|
||||||
|
navigate("/profile");
|
||||||
|
}
|
||||||
|
OnHide();
|
||||||
|
} else {
|
||||||
|
toast.error(response?.data?.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setShowLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyOTPWithFirebase = async () => {
|
||||||
|
try {
|
||||||
|
const result = await confirmationResult.confirm(otp);
|
||||||
|
// Access user information from the result
|
||||||
|
const user = result.user;
|
||||||
|
|
||||||
|
const firebase_id = user?.uid;
|
||||||
|
|
||||||
|
// If callback provided, use it (for forgot password)
|
||||||
|
if (onOtpVerified) {
|
||||||
|
onOtpVerified(firebase_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
mobile: formattedNumber,
|
||||||
|
firebase_id: user.uid, // Accessing UID directly from the user object
|
||||||
|
fcm_id: fetchFCM ? fetchFCM : "",
|
||||||
|
country_code: countryCode,
|
||||||
|
type: "phone",
|
||||||
|
region_code: regionCode?.toUpperCase() || "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRegister && password) {
|
||||||
|
payload.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, do normal login
|
||||||
|
const response = await userSignUpApi.userSignup(payload);
|
||||||
|
const data = response.data;
|
||||||
|
loadUpdateData(data);
|
||||||
|
toast.success(data.message);
|
||||||
|
OnHide();
|
||||||
|
if (data?.data?.email === "" || data?.data?.name === "") {
|
||||||
|
navigate("/profile");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const errorCode = error?.code;
|
||||||
|
handleFirebaseAuthError(errorCode);
|
||||||
|
} finally {
|
||||||
|
setShowLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyOTP = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (otp === "") {
|
||||||
|
toast.error(t("otpmissing"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowLoader(true);
|
||||||
|
if (otp_service_provider === "twilio") {
|
||||||
|
await verifyOTPWithTwillio();
|
||||||
|
} else {
|
||||||
|
await verifyOTPWithFirebase();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resendOtpWithTwillio = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getOtpApi.getOtp({ number: formattedNumber, country_code: countryCode });
|
||||||
|
if (response?.data?.error === false) {
|
||||||
|
toast.success(t("otpSentSuccess"));
|
||||||
|
setResendTimer(60); // Start the 60-second timer
|
||||||
|
} else {
|
||||||
|
toast.error(t("failedToSendOtp"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setResendOtpLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resendOtpWithFirebase = async (PhoneNumber) => {
|
||||||
|
try {
|
||||||
|
const appVerifier = await generateRecaptcha();
|
||||||
|
const confirmation = await signInWithPhoneNumber(
|
||||||
|
auth,
|
||||||
|
PhoneNumber,
|
||||||
|
appVerifier
|
||||||
|
);
|
||||||
|
setConfirmationResult(confirmation);
|
||||||
|
toast.success(t("otpSentSuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
const errorCode = error.code;
|
||||||
|
handleFirebaseAuthError(errorCode);
|
||||||
|
} finally {
|
||||||
|
setResendOtpLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resendOtp = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setResendOtpLoader(true);
|
||||||
|
const PhoneNumber = `${countryCode}${formattedNumber}`;
|
||||||
|
if (otp_service_provider === "twilio") {
|
||||||
|
await resendOtpWithTwillio();
|
||||||
|
} else {
|
||||||
|
await resendOtpWithFirebase(PhoneNumber);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-6" onSubmit={verifyOTP}>
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="requiredInputLabel">{t("otp")}</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("enterOtp")}
|
||||||
|
id="otp"
|
||||||
|
name="otp"
|
||||||
|
value={otp}
|
||||||
|
maxLength={6}
|
||||||
|
onChange={(e) => setOtp(e.target.value)}
|
||||||
|
ref={otpInputRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={showLoader}
|
||||||
|
className="text-xl text-white font-light px-4 py-2"
|
||||||
|
size="big"
|
||||||
|
>
|
||||||
|
{showLoader ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("verify")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="text-lg text-black font-light bg-transparent"
|
||||||
|
size="big"
|
||||||
|
onClick={resendOtp}
|
||||||
|
disabled={resendOtpLoader || showLoader || resendTimer > 0}
|
||||||
|
>
|
||||||
|
{resendOtpLoader ? (
|
||||||
|
<Loader2 className="size-6 animate-spin" />
|
||||||
|
) : resendTimer > 0 ? (
|
||||||
|
`${t("resendOtp")} ${resendTimer}s`
|
||||||
|
) : (
|
||||||
|
t("resendOtp")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OtpScreen;
|
||||||
180
components/Auth/RegisterModal.jsx
Normal file
180
components/Auth/RegisterModal.jsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { settingsData } from "@/redux/reducer/settingSlice";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import TermsAndPrivacyLinks from "./TermsAndPrivacyLinks";
|
||||||
|
import { setIsLoginOpen } from "@/redux/reducer/globalStateSlice";
|
||||||
|
import { MdOutlineEmail, MdOutlineLocalPhone } from "react-icons/md";
|
||||||
|
import RegisterWithEmailForm from "./RegisterWithEmailForm";
|
||||||
|
import RegisterWithMobileForm from "./RegisterWithMobileForm";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
const RegisterModal = ({
|
||||||
|
setIsMailSentSuccess,
|
||||||
|
IsRegisterModalOpen,
|
||||||
|
setIsRegisterModalOpen,
|
||||||
|
}) => {
|
||||||
|
// Get Global data
|
||||||
|
const settings = useSelector(settingsData);
|
||||||
|
const [descriptionState, setDescriptionState] = useState({
|
||||||
|
type: "register", // "register" | "otp"
|
||||||
|
phoneNumber: "",
|
||||||
|
});
|
||||||
|
const [isOTPScreen, setIsOTPScreen] = useState(false);
|
||||||
|
|
||||||
|
// Active authentication methods
|
||||||
|
const mobile_authentication = Number(settings?.mobile_authentication);
|
||||||
|
const email_authentication = Number(settings?.email_authentication);
|
||||||
|
|
||||||
|
// Toggle between email and mobile registration
|
||||||
|
const [IsRegisterWithEmail, setIsRegisterWithEmail] = useState(
|
||||||
|
!mobile_authentication == 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const OnHide = () => {
|
||||||
|
setIsRegisterModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoginClick = () => {
|
||||||
|
OnHide();
|
||||||
|
setIsLoginOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeClick = () => {
|
||||||
|
setIsOTPScreen(false);
|
||||||
|
setDescriptionState({ type: "register", phoneNumber: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show divider when alternative auth methods (email/mobile toggle) are available
|
||||||
|
const showOrSignInWith =
|
||||||
|
!isOTPScreen &&
|
||||||
|
((IsRegisterWithEmail && mobile_authentication == 1) ||
|
||||||
|
(!IsRegisterWithEmail && email_authentication == 1));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={IsRegisterModalOpen} onOpenChange={setIsRegisterModalOpen}>
|
||||||
|
<DialogContent
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
className="px-[40px] sm:py-[50px] sm:px-[90px]"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-3xl sm:text-4xl font-light">
|
||||||
|
{descriptionState.type === "otp" ? (
|
||||||
|
t("verifyOtp")
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t("welcomeTo")}{" "}
|
||||||
|
<span className="text-primary">{settings?.company_name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-base text-black font-light">
|
||||||
|
{descriptionState.type === "otp" ? (
|
||||||
|
<>
|
||||||
|
{t("sentTo")} {descriptionState.phoneNumber}{" "}
|
||||||
|
<span
|
||||||
|
className="text-primary cursor-pointer underline"
|
||||||
|
onClick={handleChangeClick}
|
||||||
|
>
|
||||||
|
{t("change")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t("haveAccount")}{" "}
|
||||||
|
<span
|
||||||
|
className="text-primary cursor-pointer underline"
|
||||||
|
onClick={handleLoginClick}
|
||||||
|
>
|
||||||
|
{t("logIn")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-[30px] mt-3.5">
|
||||||
|
{/* Show RegisterWithEmailForm when email auth is enabled */}
|
||||||
|
{email_authentication === 1 &&
|
||||||
|
(mobile_authentication == 0 || IsRegisterWithEmail) && (
|
||||||
|
<RegisterWithEmailForm
|
||||||
|
OnHide={OnHide}
|
||||||
|
setIsMailSentSuccess={setIsMailSentSuccess}
|
||||||
|
key={IsRegisterWithEmail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show RegisterWithMobileForm when mobile auth is enabled */}
|
||||||
|
{mobile_authentication == 1 &&
|
||||||
|
(email_authentication == 0 || !IsRegisterWithEmail) && (
|
||||||
|
<RegisterWithMobileForm
|
||||||
|
OnHide={OnHide}
|
||||||
|
setDescriptionState={setDescriptionState}
|
||||||
|
key={IsRegisterWithEmail}
|
||||||
|
isOTPScreen={isOTPScreen}
|
||||||
|
setIsOTPScreen={setIsOTPScreen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show divider when alternative auth methods are available */}
|
||||||
|
{showOrSignInWith && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<hr className="w-full" />
|
||||||
|
<p className="text-nowrap text-sm">{t("orSignUpWith")}</p>
|
||||||
|
<hr className="w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toggle buttons for switching between email and mobile */}
|
||||||
|
|
||||||
|
{showOrSignInWith && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Show "Continue with Mobile" button when email is selected and mobile is enabled */}
|
||||||
|
{IsRegisterWithEmail && mobile_authentication == 1 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="big"
|
||||||
|
className="flex items-center justify-center py-4 text-base h-auto"
|
||||||
|
onClick={() => setIsRegisterWithEmail(false)}
|
||||||
|
>
|
||||||
|
<MdOutlineLocalPhone className="!size-6" />
|
||||||
|
{t("continueWithMobile")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show "Continue with Email" button when mobile is selected and email is enabled */}
|
||||||
|
{!IsRegisterWithEmail && email_authentication === 1 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="big"
|
||||||
|
className="flex items-center justify-center py-4 text-base h-auto"
|
||||||
|
onClick={() => setIsRegisterWithEmail(true)}
|
||||||
|
>
|
||||||
|
<MdOutlineEmail className="!size-6" />
|
||||||
|
{t("continueWithEmail")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Terms and Privacy Links */}
|
||||||
|
{!isOTPScreen && (
|
||||||
|
<TermsAndPrivacyLinks t={t} settings={settings} OnHide={OnHide} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div id="recaptcha-container" style={{ display: "none" }}></div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegisterModal;
|
||||||
183
components/Auth/RegisterWithEmailForm.jsx
Normal file
183
components/Auth/RegisterWithEmailForm.jsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { FaRegEye, FaRegEyeSlash } from "react-icons/fa";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { handleFirebaseAuthError, t } from "@/utils";
|
||||||
|
import {
|
||||||
|
createUserWithEmailAndPassword,
|
||||||
|
getAuth,
|
||||||
|
sendEmailVerification,
|
||||||
|
} from "firebase/auth";
|
||||||
|
import { userSignUpApi } from "@/utils/api";
|
||||||
|
import useAutoFocus from "../Common/useAutoFocus";
|
||||||
|
|
||||||
|
const RegisterWithEmailForm = ({ OnHide, setIsMailSentSuccess }) => {
|
||||||
|
const auth = getAuth();
|
||||||
|
const emailRef = useAutoFocus();
|
||||||
|
|
||||||
|
// Form state management
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
IsPasswordVisible: false,
|
||||||
|
showLoader: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { email, username, password, IsPasswordVisible, showLoader } = formData;
|
||||||
|
|
||||||
|
// Handle input changes
|
||||||
|
const handleInputChange = (field, value) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle password visibility
|
||||||
|
const togglePasswordVisibility = () => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
IsPasswordVisible: !prev.IsPasswordVisible,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const handleSignup = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validate email
|
||||||
|
if (!email) {
|
||||||
|
toast.error(t("emailRequired"));
|
||||||
|
return;
|
||||||
|
} else if (!/\S+@\S+\.\S+/.test(email)) {
|
||||||
|
toast.error(t("emailInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate username
|
||||||
|
if (username?.trim() === "") {
|
||||||
|
toast.error(t("usernameRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password
|
||||||
|
if (!password) {
|
||||||
|
toast.error(t("passwordRequired"));
|
||||||
|
return;
|
||||||
|
} else if (password.length < 6) {
|
||||||
|
toast.error(t("passwordTooShort"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setFormData((prev) => ({ ...prev, showLoader: true }));
|
||||||
|
|
||||||
|
// Create user with email and password
|
||||||
|
const userCredential = await createUserWithEmailAndPassword(
|
||||||
|
auth,
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
);
|
||||||
|
const user = userCredential.user;
|
||||||
|
|
||||||
|
// Send email verification
|
||||||
|
await sendEmailVerification(user);
|
||||||
|
|
||||||
|
// Register user in backend
|
||||||
|
try {
|
||||||
|
const response = await userSignUpApi.userSignup({
|
||||||
|
name: username ? username : "",
|
||||||
|
email: email ? email : "",
|
||||||
|
firebase_id: user?.uid,
|
||||||
|
type: "email",
|
||||||
|
registration: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal and show success message
|
||||||
|
OnHide();
|
||||||
|
setIsMailSentSuccess(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("error", error);
|
||||||
|
toast.error(t("registrationFailed"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorCode = error.code;
|
||||||
|
console.log(error);
|
||||||
|
handleFirebaseAuthError(errorCode);
|
||||||
|
} finally {
|
||||||
|
setFormData((prev) => ({ ...prev, showLoader: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-6" onSubmit={handleSignup}>
|
||||||
|
{/* Email Input */}
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="requiredInputLabel">{t("email")}</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder={t("enterEmail")}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||||
|
ref={emailRef}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Username Input */}
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="requiredInputLabel">{t("username")}</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("typeUsername")}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => handleInputChange("username", e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="requiredInputLabel">{t("password")}</Label>
|
||||||
|
<div className="flex items-center relative">
|
||||||
|
<Input
|
||||||
|
type={IsPasswordVisible ? "text" : "password"}
|
||||||
|
placeholder={t("enterPassword")}
|
||||||
|
className="ltr:pr-9 rtl:pl-9"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => handleInputChange("password", e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute ltr:right-3 rtl:left-3 cursor-pointer"
|
||||||
|
onClick={togglePasswordVisibility}
|
||||||
|
>
|
||||||
|
{IsPasswordVisible ? (
|
||||||
|
<FaRegEye size={20} />
|
||||||
|
) : (
|
||||||
|
<FaRegEyeSlash size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={showLoader}
|
||||||
|
className="text-xl text-white font-light px-4 py-2"
|
||||||
|
size="big"
|
||||||
|
>
|
||||||
|
{showLoader ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("verifyEmail")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegisterWithEmailForm;
|
||||||
310
components/Auth/RegisterWithMobileForm.jsx
Normal file
310
components/Auth/RegisterWithMobileForm.jsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import PhoneInput from "react-phone-input-2";
|
||||||
|
import "react-phone-input-2/lib/style.css";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { isValidPhoneNumber } from "libphonenumber-js/max";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { handleFirebaseAuthError, t } from "@/utils";
|
||||||
|
import {
|
||||||
|
getAuth,
|
||||||
|
RecaptchaVerifier,
|
||||||
|
signInWithPhoneNumber,
|
||||||
|
} from "firebase/auth";
|
||||||
|
import { getOtpApi, getUserExistsApi } from "@/utils/api";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import {
|
||||||
|
getOtpServiceProvider,
|
||||||
|
settingsData,
|
||||||
|
} from "@/redux/reducer/settingSlice";
|
||||||
|
import useAutoFocus from "../Common/useAutoFocus";
|
||||||
|
import OtpScreen from "./OtpScreen";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { FaRegEye, FaRegEyeSlash } from "react-icons/fa";
|
||||||
|
|
||||||
|
const RegisterWithMobileForm = ({
|
||||||
|
OnHide,
|
||||||
|
setDescriptionState,
|
||||||
|
isOTPScreen,
|
||||||
|
setIsOTPScreen,
|
||||||
|
}) => {
|
||||||
|
const auth = getAuth();
|
||||||
|
const settings = useSelector(settingsData);
|
||||||
|
const isDemoMode = settings?.demo_mode;
|
||||||
|
const otp_service_provider = useSelector(getOtpServiceProvider);
|
||||||
|
const phoneInputRef = useAutoFocus();
|
||||||
|
|
||||||
|
// Mobile registration states
|
||||||
|
const [number, setNumber] = useState(isDemoMode ? "919876598765" : "");
|
||||||
|
const [countryCode, setCountryCode] = useState("");
|
||||||
|
const [regionCode, setRegionCode] = useState("");
|
||||||
|
const [confirmationResult, setConfirmationResult] = useState(null);
|
||||||
|
const [showLoader, setShowLoader] = useState(false);
|
||||||
|
const [resendTimer, setResendTimer] = useState(0);
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
// Remove any non-digit characters from the country code
|
||||||
|
const countryCodeDigitsOnly = countryCode.replace(/\D/g, "");
|
||||||
|
|
||||||
|
// Check if the entered number starts with the selected country code
|
||||||
|
const startsWithCountryCode = number.startsWith(countryCodeDigitsOnly);
|
||||||
|
|
||||||
|
// If the number starts with the country code, remove it
|
||||||
|
const formattedNumber = startsWithCountryCode
|
||||||
|
? number.substring(countryCodeDigitsOnly.length)
|
||||||
|
: number;
|
||||||
|
|
||||||
|
// Generate reCAPTCHA verifier
|
||||||
|
const generateRecaptcha = async () => {
|
||||||
|
// Reuse existing verifier if it's still valid
|
||||||
|
if (window.recaptchaVerifier && !window.recaptchaVerifier.destroyed) {
|
||||||
|
return window.recaptchaVerifier;
|
||||||
|
}
|
||||||
|
const recaptchaContainer = document.getElementById("recaptcha-container");
|
||||||
|
if (!recaptchaContainer) {
|
||||||
|
console.error("Container element 'recaptcha-container' not found.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Clear container and reset reference
|
||||||
|
recaptchaContainer.innerHTML = "";
|
||||||
|
window.recaptchaVerifier = undefined;
|
||||||
|
try {
|
||||||
|
window.recaptchaVerifier = new RecaptchaVerifier(
|
||||||
|
auth,
|
||||||
|
recaptchaContainer,
|
||||||
|
{ size: "invisible" }
|
||||||
|
);
|
||||||
|
return window.recaptchaVerifier;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing RecaptchaVerifier:", error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
recaptchaClear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const recaptchaClear = async () => {
|
||||||
|
if (window.recaptchaVerifier && !window.recaptchaVerifier.destroyed) {
|
||||||
|
try {
|
||||||
|
await window.recaptchaVerifier.clear();
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors - verifier might already be cleared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.recaptchaVerifier = undefined;
|
||||||
|
const recaptchaContainer = document.getElementById("recaptcha-container");
|
||||||
|
if (recaptchaContainer) {
|
||||||
|
recaptchaContainer.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send OTP with Twilio
|
||||||
|
const sendOtpWithTwillio = async (PhoneNumber) => {
|
||||||
|
try {
|
||||||
|
const response = await getOtpApi.getOtp({
|
||||||
|
number: formattedNumber,
|
||||||
|
country_code: countryCode,
|
||||||
|
});
|
||||||
|
if (response?.data?.error === false) {
|
||||||
|
toast.success(t("otpSentSuccess"));
|
||||||
|
setIsOTPScreen(true);
|
||||||
|
setResendTimer(60);
|
||||||
|
setDescriptionState({
|
||||||
|
type: "otp",
|
||||||
|
phoneNumber: PhoneNumber,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(t("failedToSendOtp"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("error", error);
|
||||||
|
} finally {
|
||||||
|
setShowLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send OTP with Firebase
|
||||||
|
const sendOtpWithFirebase = async (PhoneNumber) => {
|
||||||
|
try {
|
||||||
|
const appVerifier = await generateRecaptcha();
|
||||||
|
const confirmation = await signInWithPhoneNumber(
|
||||||
|
auth,
|
||||||
|
PhoneNumber,
|
||||||
|
appVerifier
|
||||||
|
);
|
||||||
|
setConfirmationResult(confirmation);
|
||||||
|
toast.success(t("otpSentSuccess"));
|
||||||
|
setIsOTPScreen(true);
|
||||||
|
setResendTimer(60);
|
||||||
|
setDescriptionState({
|
||||||
|
type: "otp",
|
||||||
|
phoneNumber: PhoneNumber,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const errorCode = error.code;
|
||||||
|
handleFirebaseAuthError(errorCode);
|
||||||
|
} finally {
|
||||||
|
setShowLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle phone input change
|
||||||
|
const handleInputChange = (value, data) => {
|
||||||
|
setNumber(value);
|
||||||
|
setCountryCode("+" + (data?.dialCode || ""));
|
||||||
|
setRegionCode(data?.countryCode.toLowerCase() || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle country change
|
||||||
|
const handleCountryChange = (code) => {
|
||||||
|
setCountryCode(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkIfUserExistsOrNot = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getUserExistsApi.getUserExists({
|
||||||
|
mobile: formattedNumber,
|
||||||
|
country_code: countryCode,
|
||||||
|
});
|
||||||
|
if (res?.data?.error === false) {
|
||||||
|
toast.error(res?.data?.message);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const handleMobileSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validate phone number
|
||||||
|
const PhoneNumber = `${countryCode}${formattedNumber}`;
|
||||||
|
|
||||||
|
if (!isValidPhoneNumber(PhoneNumber)) {
|
||||||
|
toast.error(t("invalidPhoneNumber"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password
|
||||||
|
if (!password) {
|
||||||
|
toast.error(t("passwordRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
toast.error(t("passwordTooShort"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Send OTP
|
||||||
|
setShowLoader(true);
|
||||||
|
const isUserExists = await checkIfUserExistsOrNot();
|
||||||
|
if (isUserExists) {
|
||||||
|
setShowLoader(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (otp_service_provider === "twilio") {
|
||||||
|
await sendOtpWithTwillio(PhoneNumber);
|
||||||
|
} else {
|
||||||
|
await sendOtpWithFirebase(PhoneNumber);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle password visibility
|
||||||
|
const togglePasswordVisibility = () => {
|
||||||
|
setIsPasswordVisible((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show OTP screen if OTP was sent
|
||||||
|
if (isOTPScreen) {
|
||||||
|
return (
|
||||||
|
<OtpScreen
|
||||||
|
OnHide={OnHide}
|
||||||
|
generateRecaptcha={generateRecaptcha}
|
||||||
|
countryCode={countryCode}
|
||||||
|
formattedNumber={formattedNumber}
|
||||||
|
confirmationResult={confirmationResult}
|
||||||
|
setConfirmationResult={setConfirmationResult}
|
||||||
|
setResendTimer={setResendTimer}
|
||||||
|
resendTimer={resendTimer}
|
||||||
|
regionCode={regionCode}
|
||||||
|
password={password}
|
||||||
|
isDemoMode={isDemoMode}
|
||||||
|
isRegister={true}
|
||||||
|
key="register-otp"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show mobile registration form
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-6" onSubmit={handleMobileSubmit}>
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="requiredInputLabel">{t("phoneNumber")}</Label>
|
||||||
|
<PhoneInput
|
||||||
|
country={process.env.NEXT_PUBLIC_DEFAULT_COUNTRY}
|
||||||
|
value={number}
|
||||||
|
onChange={(phone, data) => handleInputChange(phone, data)}
|
||||||
|
onCountryChange={handleCountryChange}
|
||||||
|
inputProps={{
|
||||||
|
name: "phone",
|
||||||
|
required: true,
|
||||||
|
ref: phoneInputRef,
|
||||||
|
}}
|
||||||
|
enableLongNumbers
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="requiredInputLabel">{t("password")}</Label>
|
||||||
|
<div className="flex items-center relative">
|
||||||
|
<Input
|
||||||
|
type={isPasswordVisible ? "text" : "password"}
|
||||||
|
placeholder={t("enterPassword")}
|
||||||
|
className="ltr:pr-9 rtl:pl-9"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute ltr:right-3 rtl:left-3 cursor-pointer"
|
||||||
|
onClick={togglePasswordVisibility}
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? (
|
||||||
|
<FaRegEye size={20} />
|
||||||
|
) : (
|
||||||
|
<FaRegEyeSlash size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={showLoader}
|
||||||
|
className="text-xl text-white font-light px-4 py-2"
|
||||||
|
size="big"
|
||||||
|
>
|
||||||
|
{showLoader ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("continue")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegisterWithMobileForm;
|
||||||
127
components/Auth/ResetPasswordScreen.jsx
Normal file
127
components/Auth/ResetPasswordScreen.jsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { FaRegEye, FaRegEyeSlash } from "react-icons/fa";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { resetPasswordApi, userSignUpApi } from "@/utils/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const ResetPasswordScreen = ({
|
||||||
|
formattedNumber,
|
||||||
|
countryCode,
|
||||||
|
onSuccess,
|
||||||
|
onCancel,
|
||||||
|
FirebaseId,
|
||||||
|
}) => {
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
const [resetPasswordLoader, setResetPasswordLoader] = useState(false);
|
||||||
|
|
||||||
|
const handleResetPassword = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!newPassword) {
|
||||||
|
toast.error(t("passwordRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
toast.error(t("passwordTooShort"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResetPasswordLoader(true);
|
||||||
|
try {
|
||||||
|
// Step 1: Get token by calling userSignUpApi
|
||||||
|
const loginResponse = await userSignUpApi.userSignup({
|
||||||
|
mobile: formattedNumber,
|
||||||
|
country_code: countryCode,
|
||||||
|
type: "phone",
|
||||||
|
firebase_id: FirebaseId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract token from response
|
||||||
|
const token = loginResponse?.data?.token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
toast.error(t("errorOccurred"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await resetPasswordApi.resetPassword({
|
||||||
|
number: formattedNumber,
|
||||||
|
country_code: countryCode,
|
||||||
|
new_password: newPassword,
|
||||||
|
token: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.data?.error === false) {
|
||||||
|
toast.success(response?.data?.message);
|
||||||
|
onSuccess(); // Go back to login screen
|
||||||
|
} else {
|
||||||
|
toast.error(response?.data?.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error(t("errorOccurred"));
|
||||||
|
} finally {
|
||||||
|
setResetPasswordLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-6 mt-3.5" onSubmit={handleResetPassword}>
|
||||||
|
<div className="labelInputCont">
|
||||||
|
<Label className="requiredInputLabel">{t("newPassword")}</Label>
|
||||||
|
<div className="flex items-center relative">
|
||||||
|
<Input
|
||||||
|
type={isPasswordVisible ? "text" : "password"}
|
||||||
|
placeholder={t("enterNewPassword")}
|
||||||
|
className="ltr:pr-9 rtl:pl-9"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute ltr:right-3 rtl:left-3 cursor-pointer"
|
||||||
|
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? (
|
||||||
|
<FaRegEye size={20} />
|
||||||
|
) : (
|
||||||
|
<FaRegEyeSlash size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={resetPasswordLoader}
|
||||||
|
className="text-xl text-white font-light px-4 py-2"
|
||||||
|
size="big"
|
||||||
|
>
|
||||||
|
{resetPasswordLoader ? (
|
||||||
|
<Loader2 className="size-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("submitResetPassword")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{onCancel && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="text-lg text-black font-light px-4 py-2"
|
||||||
|
size="big"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetPasswordScreen;
|
||||||
26
components/Auth/TermsAndPrivacyLinks.jsx
Normal file
26
components/Auth/TermsAndPrivacyLinks.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import CustomLink from "@/components/Common/CustomLink";
|
||||||
|
|
||||||
|
const TermsAndPrivacyLinks = ({ t, settings, OnHide }) => {
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
{t("agreeSignIn")} {settings?.company_name} <br />
|
||||||
|
<CustomLink
|
||||||
|
href="/terms-and-condition"
|
||||||
|
className="text-primary underline"
|
||||||
|
onClick={OnHide}
|
||||||
|
>
|
||||||
|
{t("termsService")}
|
||||||
|
</CustomLink>{" "}
|
||||||
|
{t("and")}{" "}
|
||||||
|
<CustomLink
|
||||||
|
href="/privacy-policy"
|
||||||
|
className="text-primary underline"
|
||||||
|
onClick={OnHide}
|
||||||
|
>
|
||||||
|
{t("privacyPolicy")}
|
||||||
|
</CustomLink>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TermsAndPrivacyLinks;
|
||||||
39
components/Auth/UnauthorizedModal.jsx
Normal file
39
components/Auth/UnauthorizedModal.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { getIsUnauthorized, setIsUnauthorized } from "@/redux/reducer/globalStateSlice";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
|
const UnauthorizedModal = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const open = useSelector(getIsUnauthorized);
|
||||||
|
|
||||||
|
const handleOk = () => {
|
||||||
|
dispatch(setIsUnauthorized(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open}>
|
||||||
|
<AlertDialogContent onInteractOutside={(e) => e.preventDefault()}>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Unauthorized</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
You do not have permission to access this resource. Please log in or
|
||||||
|
contact the administrator.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction onClick={handleOk}>OK</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnauthorizedModal;
|
||||||
114
components/BreadCrumb/BreadCrumb.jsx
Normal file
114
components/BreadCrumb/BreadCrumb.jsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import CustomLink from "@/components/Common/CustomLink";
|
||||||
|
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||||
|
|
||||||
|
const BreadCrumb = ({ title2 }) => {
|
||||||
|
const langCode = useSelector(getCurrentLangCode);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const BreadcrumbPath = useSelector(
|
||||||
|
(state) => state.BreadcrumbPath.BreadcrumbPath
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
title: t("home"),
|
||||||
|
key: "home",
|
||||||
|
href: "/",
|
||||||
|
isLink: true,
|
||||||
|
},
|
||||||
|
...(title2
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: title2,
|
||||||
|
key: "custom",
|
||||||
|
isLink: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: BreadcrumbPath && BreadcrumbPath.length > 0
|
||||||
|
? BreadcrumbPath.map((crumb, index) => {
|
||||||
|
const isLast = index === BreadcrumbPath.length - 1;
|
||||||
|
return {
|
||||||
|
title: crumb.name,
|
||||||
|
key: index + 1,
|
||||||
|
href: crumb?.slug,
|
||||||
|
isLink: !isLast && !crumb.isCurrent,
|
||||||
|
onClick: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (crumb.isAllCategories) {
|
||||||
|
// For "All Categories", preserve other URL parameters but remove category
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.delete("category");
|
||||||
|
newSearchParams.set("lang", langCode);
|
||||||
|
const newUrl = `/ads?${newSearchParams.toString()}`;
|
||||||
|
window.history.pushState(null, "", newUrl);
|
||||||
|
} else {
|
||||||
|
// ✅ ensure lang param is present
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.set("lang", langCode);
|
||||||
|
|
||||||
|
// if crumb.slug already has query params, merge them
|
||||||
|
let newUrl = crumb.slug.includes("?")
|
||||||
|
? `${crumb.slug}&lang=${langCode}`
|
||||||
|
: `${crumb.slug}?lang=${langCode}`;
|
||||||
|
|
||||||
|
window.history.pushState(null, "", newUrl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-muted">
|
||||||
|
<div className="container py-5">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
{items?.map((item, index) => {
|
||||||
|
return (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
{item.isLink && item.onClick ? (
|
||||||
|
<BreadcrumbLink
|
||||||
|
href="#"
|
||||||
|
className="text-black"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
item.onClick(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</BreadcrumbLink>
|
||||||
|
) : item.isLink ? (
|
||||||
|
<CustomLink href={item?.href} passHref>
|
||||||
|
<BreadcrumbLink asChild className="text-black">
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</CustomLink>
|
||||||
|
) : (
|
||||||
|
<p className="text-black">{item.title}</p>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{index !== items?.length - 1 && <BreadcrumbSeparator />}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BreadCrumb;
|
||||||
42
components/Common/CustomImage.jsx
Normal file
42
components/Common/CustomImage.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { getPlaceholderImage } from "@/redux/reducer/settingSlice";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
const CustomImage = ({ src, alt, loading = "lazy", ...props }) => {
|
||||||
|
const placeholderImage = useSelector(getPlaceholderImage);
|
||||||
|
const fallback = "/assets/Transperant_Placeholder.png";
|
||||||
|
// Initial source can be string OR object (StaticImageData)
|
||||||
|
const initialSrc =
|
||||||
|
(src && (typeof src === "string" ? src.trim() : src)) ||
|
||||||
|
(placeholderImage && placeholderImage.trim?.()) ||
|
||||||
|
fallback;
|
||||||
|
|
||||||
|
const [imgSrc, setImgSrc] = useState(initialSrc);
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
if (
|
||||||
|
imgSrc !== placeholderImage &&
|
||||||
|
typeof placeholderImage === "string" &&
|
||||||
|
placeholderImage.trim()
|
||||||
|
) {
|
||||||
|
setImgSrc(placeholderImage);
|
||||||
|
} else if (imgSrc !== fallback) {
|
||||||
|
setImgSrc(fallback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={imgSrc}
|
||||||
|
alt={alt}
|
||||||
|
onError={handleError}
|
||||||
|
loading={loading} // Dynamic loading: defaults to "lazy" if not provided
|
||||||
|
{...props} // width, height, className etc can still be passed
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomImage;
|
||||||
29
components/Common/CustomLink.jsx
Normal file
29
components/Common/CustomLink.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||||
|
import { getDefaultLanguageCode } from "@/redux/reducer/settingSlice";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
const CustomLink = ({ href, children, ...props }) => {
|
||||||
|
const defaultLangCode = useSelector(getDefaultLanguageCode);
|
||||||
|
const currentLangCode = useSelector(getCurrentLangCode);
|
||||||
|
|
||||||
|
const langCode = currentLangCode || defaultLangCode;
|
||||||
|
|
||||||
|
// Split hash (#) safely from href
|
||||||
|
const [baseHref, hash = ""] = href.split("#");
|
||||||
|
|
||||||
|
// Append lang param safely
|
||||||
|
const separator = baseHref.includes("?") ? "&" : "?";
|
||||||
|
const newHref = `${baseHref}${separator}lang=${langCode}${
|
||||||
|
hash ? `#${hash}` : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={newHref} {...props}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomLink;
|
||||||
11
components/Common/Header.jsx
Normal file
11
components/Common/Header.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import LandingHeader from "../PagesComponent/LandingPage/LandingHeader";
|
||||||
|
import HomeHeader from "../PagesComponent/Home/HomeHeader";
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
return pathname === "/landing" ? <LandingHeader /> : <HomeHeader />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
218
components/Common/LanguageDropdown.jsx
Normal file
218
components/Common/LanguageDropdown.jsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
CurrentLanguageData,
|
||||||
|
setCurrentLanguage,
|
||||||
|
} from "@/redux/reducer/languageSlice";
|
||||||
|
import { getCityData, saveCity } from "@/redux/reducer/locationSlice";
|
||||||
|
import { getIsPaidApi, settingsData } from "@/redux/reducer/settingSlice";
|
||||||
|
import { isEmptyObject, updateStickyNoteTranslations } from "@/utils";
|
||||||
|
import { getLanguageApi, getLocationApi } from "@/utils/api";
|
||||||
|
import {
|
||||||
|
setHasFetchedCategories,
|
||||||
|
setHasFetchedSystemSettings,
|
||||||
|
} from "@/utils/getFetcherStatus";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import CustomImage from "./CustomImage";
|
||||||
|
|
||||||
|
const LanguageDropdown = () => {
|
||||||
|
const IsPaidApi = useSelector(getIsPaidApi);
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const location = useSelector(getCityData);
|
||||||
|
const settings = useSelector(settingsData);
|
||||||
|
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||||
|
const currentLangCode = CurrentLanguage?.code;
|
||||||
|
const languages = settings && settings?.languages;
|
||||||
|
const isRTL = CurrentLanguage.rtl;
|
||||||
|
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const langCode = searchParams?.get("lang");
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
const setDefaultLanguage = async () => {
|
||||||
|
try {
|
||||||
|
params.set("lang", settings?.default_language.toLowerCase());
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
const language_code = settings?.default_language;
|
||||||
|
const res = await getLanguageApi.getLanguage({
|
||||||
|
language_code,
|
||||||
|
type: "web",
|
||||||
|
});
|
||||||
|
if (res?.data?.error === false) {
|
||||||
|
dispatch(setCurrentLanguage(res?.data?.data));
|
||||||
|
document.documentElement.lang =
|
||||||
|
res?.data?.data?.code?.toLowerCase() ||
|
||||||
|
settings?.default_language.toLowerCase();
|
||||||
|
} else {
|
||||||
|
toast.error(res?.data?.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if Redux language is empty or invalid
|
||||||
|
if (isEmptyObject(CurrentLanguage)) {
|
||||||
|
setDefaultLanguage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If URL has lang parameter and languages are loaded, check if valid and update if needed
|
||||||
|
if (langCode && languages.length > 0) {
|
||||||
|
const urlLang = languages.find(
|
||||||
|
(lang) => lang.code?.toUpperCase() === langCode.toUpperCase()
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
urlLang &&
|
||||||
|
currentLangCode?.toUpperCase() !== urlLang.code.toUpperCase()
|
||||||
|
) {
|
||||||
|
getLanguageData(urlLang.code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current language code is no longer valid (language was removed from settings)
|
||||||
|
if (languages && !languages.some((lang) => lang.code === currentLangCode)) {
|
||||||
|
setDefaultLanguage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!langCode) {
|
||||||
|
params.set("lang", currentLangCode);
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
}
|
||||||
|
}, [langCode]);
|
||||||
|
|
||||||
|
const getLanguageData = async (
|
||||||
|
language_code = settings?.default_language
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const res = await getLanguageApi.getLanguage({
|
||||||
|
language_code,
|
||||||
|
type: "web",
|
||||||
|
});
|
||||||
|
if (res?.data?.error === false) {
|
||||||
|
dispatch(setCurrentLanguage(res?.data?.data));
|
||||||
|
getLocationAfterLanguageChange(language_code);
|
||||||
|
document.documentElement.lang =
|
||||||
|
res?.data?.data?.code?.toLowerCase() || "en";
|
||||||
|
|
||||||
|
setHasFetchedSystemSettings(false);
|
||||||
|
setHasFetchedCategories(false);
|
||||||
|
updateStickyNoteTranslations();
|
||||||
|
} else {
|
||||||
|
toast.error(res?.data?.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocationAfterLanguageChange = async (language_code) => {
|
||||||
|
if (IsPaidApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no country/state/city/area stored, skip API call
|
||||||
|
if (
|
||||||
|
!location?.country &&
|
||||||
|
!location?.state &&
|
||||||
|
!location?.city &&
|
||||||
|
!location?.area
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getLocationApi.getLocation({
|
||||||
|
lat: location?.lat,
|
||||||
|
lng: location?.long,
|
||||||
|
lang: language_code,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.data.error === false) {
|
||||||
|
const result = response?.data?.data;
|
||||||
|
const updatedLocation = {};
|
||||||
|
|
||||||
|
if (location?.country) updatedLocation.country = result?.country;
|
||||||
|
if (location?.state) updatedLocation.state = result?.state;
|
||||||
|
if (location?.city) updatedLocation.city = result?.city;
|
||||||
|
if (location?.area) {
|
||||||
|
updatedLocation.area = result?.area;
|
||||||
|
updatedLocation.areaId = result?.area_id;
|
||||||
|
}
|
||||||
|
updatedLocation.lat = location?.lat;
|
||||||
|
updatedLocation.long = location?.long;
|
||||||
|
|
||||||
|
// ✅ Dynamically build formattedAddress only with existing parts
|
||||||
|
const parts = [];
|
||||||
|
if (location?.area) parts.push(result?.area_translation);
|
||||||
|
if (location?.city) parts.push(result?.city_translation);
|
||||||
|
if (location?.state) parts.push(result?.state_translation);
|
||||||
|
if (location?.country) parts.push(result?.country_translation);
|
||||||
|
updatedLocation.address_translated = parts.filter(Boolean).join(", ");
|
||||||
|
saveCity(updatedLocation);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLanguageSelect = (id) => {
|
||||||
|
const lang = languages?.find((item) => item.id === Number(id));
|
||||||
|
if (CurrentLanguage.id === lang.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
params.set("lang", lang.code.toLowerCase()); // Store language code
|
||||||
|
// Push new URL with lang param
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
getLanguageData(lang?.code);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="border rounded-full py-2 px-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<CustomImage
|
||||||
|
key={CurrentLanguage?.id}
|
||||||
|
src={CurrentLanguage?.image}
|
||||||
|
alt={CurrentLanguage?.name || "language"}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
<span>{CurrentLanguage?.code}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="min-w-0 max-h-[250px] overflow-y-auto"
|
||||||
|
align={isRTL ? "start" : "end"}
|
||||||
|
>
|
||||||
|
{languages &&
|
||||||
|
languages.map((lang) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={lang?.id}
|
||||||
|
onClick={() => handleLanguageSelect(lang.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<CustomImage
|
||||||
|
src={lang?.image}
|
||||||
|
alt={lang.name || "english"}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
<span>{lang.code}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LanguageDropdown;
|
||||||
12
components/Common/Loader.jsx
Normal file
12
components/Common/Loader.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const Loader = () => {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex items-center justify-center">
|
||||||
|
<div className="relative w-12 h-12">
|
||||||
|
<div className="absolute w-12 h-12 bg-primary rounded-full animate-ping"></div>
|
||||||
|
<div className="absolute w-12 h-12 bg-primary rounded-full animate-ping delay-1000"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Loader;
|
||||||
89
components/Common/MapComponent.jsx
Normal file
89
components/Common/MapComponent.jsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import {
|
||||||
|
getDefaultLatitude,
|
||||||
|
getDefaultLongitude,
|
||||||
|
} from "@/redux/reducer/settingSlice";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { MapContainer, Marker, TileLayer, useMapEvents } from "react-leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import L from "leaflet";
|
||||||
|
|
||||||
|
// Fix Leaflet default marker icon issue
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
|
||||||
|
iconUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
|
||||||
|
shadowUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
const MapClickHandler = ({ onMapClick }) => {
|
||||||
|
useMapEvents({
|
||||||
|
click: (e) => {
|
||||||
|
onMapClick(e.latlng);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MapComponent = ({ getLocationWithMap, location }) => {
|
||||||
|
const latitude = useSelector(getDefaultLatitude);
|
||||||
|
const longitude = useSelector(getDefaultLongitude);
|
||||||
|
|
||||||
|
const mapRef = useRef();
|
||||||
|
const position = {
|
||||||
|
lat: Number(location?.lat) || latitude,
|
||||||
|
lng: Number(location?.long) || longitude,
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapRef.current && position.lat && position.lng) {
|
||||||
|
mapRef.current.flyTo(
|
||||||
|
[position.lat, position.lng],
|
||||||
|
mapRef.current.getZoom()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [position?.lat, position?.lng]);
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
width: "100%",
|
||||||
|
height: "400px",
|
||||||
|
zIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMapClick = (latlng) => {
|
||||||
|
if (getLocationWithMap) {
|
||||||
|
getLocationWithMap({
|
||||||
|
lat: latlng.lat,
|
||||||
|
lng: latlng.lng,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapContainer
|
||||||
|
style={containerStyle}
|
||||||
|
center={[position?.lat, position?.lng]}
|
||||||
|
zoom={6}
|
||||||
|
ref={mapRef}
|
||||||
|
whenCreated={(mapInstance) => {
|
||||||
|
mapRef.current = mapInstance;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<MapClickHandler onMapClick={handleMapClick} />
|
||||||
|
{position?.lat && position?.lng && (
|
||||||
|
<Marker position={[position?.lat, position?.lng]}></Marker>
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MapComponent;
|
||||||
83
components/Common/OpenInAppDrawer.jsx
Normal file
83
components/Common/OpenInAppDrawer.jsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer"
|
||||||
|
import { settingsData } from "@/redux/reducer/settingSlice";
|
||||||
|
import { t } from "@/utils"
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const OpenInAppDrawer = ({ isOpenInApp, setIsOpenInApp }) => {
|
||||||
|
|
||||||
|
const path = usePathname()
|
||||||
|
const settings = useSelector(settingsData);
|
||||||
|
const companyName = settings?.company_name;
|
||||||
|
const scheme = settings?.deep_link_scheme;
|
||||||
|
const playStoreLink = settings?.play_store_link;
|
||||||
|
const appStoreLink = settings?.app_store_link;
|
||||||
|
|
||||||
|
|
||||||
|
function handleOpenInApp() {
|
||||||
|
|
||||||
|
var appScheme = `${scheme}://${window.location.hostname}${path}`;
|
||||||
|
var userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||||
|
var isAndroid = /android/i.test(userAgent);
|
||||||
|
var isIOS = /iPad|iPhone|iPod/.test(userAgent) && !window.MSStream;
|
||||||
|
|
||||||
|
let applicationLink;
|
||||||
|
if (isAndroid) {
|
||||||
|
applicationLink = playStoreLink;
|
||||||
|
} else if (isIOS) {
|
||||||
|
applicationLink = appStoreLink;
|
||||||
|
} else {
|
||||||
|
// Fallback for desktop or other platforms
|
||||||
|
applicationLink = playStoreLink || appStoreLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to open the app
|
||||||
|
window.location.href = appScheme;
|
||||||
|
// Set a timeout to check if app opened
|
||||||
|
setTimeout(function () {
|
||||||
|
if (document.hidden || document.webkitHidden) {
|
||||||
|
// App opened successfully
|
||||||
|
} else {
|
||||||
|
// App is not installed, ask user if they want to go to app store
|
||||||
|
if (confirm(`${companyName} ${t('appIsNotInstalled')} ${isIOS ? t('appStore') : t('playStore')}?`)) {
|
||||||
|
|
||||||
|
if (!applicationLink) {
|
||||||
|
toast.error(`${companyName} ${isIOS ? t('appStore') : t('playStore')} ${t('linkNotAvailable')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = applicationLink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={isOpenInApp} onOpenChange={setIsOpenInApp}>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>{`${t('viewIn')} ${companyName} ${t('app')}`}</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
{t('getTheBestExperienceByOpeningThisInOurMobileApp')}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button onClick={handleOpenInApp}>
|
||||||
|
{t('openInApp')}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OpenInAppDrawer
|
||||||
14
components/Common/PageLoader.jsx
Normal file
14
components/Common/PageLoader.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
const PageLoader = () => {
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-20vh)] flex items-center justify-center">
|
||||||
|
<div className="relative w-12 h-12">
|
||||||
|
<div className="absolute w-12 h-12 bg-primary rounded-full animate-ping"></div>
|
||||||
|
<div className="absolute w-12 h-12 bg-primary rounded-full animate-ping delay-1000"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageLoader
|
||||||
113
components/Common/Pagination.jsx
Normal file
113
components/Common/Pagination.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
Pagination as PaginationContainer,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
|
||||||
|
const Pagination = ({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
onPageChange(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePaginationItems = () => {
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
if (totalPages <= 6) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
items.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentPage <= 3) {
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
items.push(i);
|
||||||
|
}
|
||||||
|
items.push(null); // Ellipsis
|
||||||
|
items.push(totalPages);
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
items.push(1);
|
||||||
|
items.push(null); // Ellipsis
|
||||||
|
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||||||
|
items.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items.push(1);
|
||||||
|
items.push(null); // Ellipsis
|
||||||
|
items.push(currentPage - 1);
|
||||||
|
items.push(currentPage);
|
||||||
|
items.push(currentPage + 1);
|
||||||
|
items.push(null); // Ellipsis
|
||||||
|
items.push(totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render if there's only 1 page or no pages
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PaginationContainer className={className}>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage > 1) handlePageChange(currentPage - 1);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
currentPage === 1 ? "pointer-events-none opacity-50" : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{generatePaginationItems().map((page, index) =>
|
||||||
|
page === null ? (
|
||||||
|
<PaginationItem key={`ellipsis-${index}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem key={page}>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePageChange(page);
|
||||||
|
}}
|
||||||
|
isActive={page === currentPage}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage < totalPages) handlePageChange(currentPage + 1);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
currentPage === totalPages ? "pointer-events-none opacity-50" : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</PaginationContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Pagination;
|
||||||
120
components/Common/ProductCard.jsx
Normal file
120
components/Common/ProductCard.jsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { formatDate, t } from "@/utils";
|
||||||
|
import { BiBadgeCheck } from "react-icons/bi";
|
||||||
|
import { FaHeart, FaRegHeart } from "react-icons/fa";
|
||||||
|
import { manageFavouriteApi } from "@/utils/api";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||||
|
import CustomLink from "@/components/Common/CustomLink";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { setIsLoginOpen } from "@/redux/reducer/globalStateSlice";
|
||||||
|
import CustomImage from "./CustomImage";
|
||||||
|
|
||||||
|
const ProductCard = ({ item, handleLike }) => {
|
||||||
|
const userData = useSelector(userSignUpData);
|
||||||
|
const isJobCategory = Number(item?.category?.is_job_category) === 1;
|
||||||
|
const translated_item = item.translated_item;
|
||||||
|
|
||||||
|
const isHidePrice = isJobCategory
|
||||||
|
? !item?.formatted_salary_range
|
||||||
|
: !item?.formatted_price;
|
||||||
|
|
||||||
|
const price = isJobCategory
|
||||||
|
? item?.formatted_salary_range
|
||||||
|
: item?.formatted_price;
|
||||||
|
|
||||||
|
const productLink =
|
||||||
|
userData?.id === item?.user_id
|
||||||
|
? `/my-listing/${item?.slug}`
|
||||||
|
: `/ad-details/${item.slug}`;
|
||||||
|
|
||||||
|
const handleLikeItem = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
if (!userData) {
|
||||||
|
setIsLoginOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await manageFavouriteApi.manageFavouriteApi({
|
||||||
|
item_id: item?.id,
|
||||||
|
});
|
||||||
|
if (response?.data?.error === false) {
|
||||||
|
toast.success(response?.data?.message);
|
||||||
|
handleLike(item?.id);
|
||||||
|
} else {
|
||||||
|
toast.error(t("failedToLike"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error(t("failedToLike"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomLink
|
||||||
|
href={productLink}
|
||||||
|
className="border p-2 rounded-2xl flex flex-col gap-2 h-full"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<CustomImage
|
||||||
|
src={item?.image}
|
||||||
|
width={288}
|
||||||
|
height={249}
|
||||||
|
className="w-full aspect-square rounded object-cover"
|
||||||
|
alt="Product"
|
||||||
|
/>
|
||||||
|
{item?.is_feature && (
|
||||||
|
<div className="flex items-center gap-1 ltr:rounded-tl rtl:rounded-tr py-0.5 px-1 bg-primary absolute top-0 ltr:left-0 rtl:right-0">
|
||||||
|
<BiBadgeCheck size={16} color="white" />
|
||||||
|
<p className="text-white text-xs sm:text-sm">{t("featured")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={handleLikeItem}
|
||||||
|
className="absolute h-10 w-10 ltr:right-2 rtl:left-2 top-2 bg-white p-2 rounded-full flex items-center justify-center text-primary"
|
||||||
|
>
|
||||||
|
{item?.is_liked ? (
|
||||||
|
<button>
|
||||||
|
<FaHeart size={24} className="like_icon" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button>
|
||||||
|
<FaRegHeart size={24} className="like_icon" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-between gap-2">
|
||||||
|
{isHidePrice ? (
|
||||||
|
<p className="text-sm sm:text-base font-medium line-clamp-1">
|
||||||
|
{translated_item?.name || item?.name}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
className="text-sm sm:text-lg font-bold break-all text-balance line-clamp-2"
|
||||||
|
title={price}
|
||||||
|
>
|
||||||
|
{price}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs sm:text-sm opacity-65 whitespace-nowrap">
|
||||||
|
{formatDate(item?.created_at)}‎
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isHidePrice && (
|
||||||
|
<p className="text-sm sm:text-base font-medium line-clamp-1">
|
||||||
|
{translated_item?.name || item?.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs sm:text-sm opacity-65 line-clamp-1">
|
||||||
|
{item?.translated_address}
|
||||||
|
</p>
|
||||||
|
</CustomLink>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCard;
|
||||||
17
components/Common/ProductCardSkeleton.jsx
Normal file
17
components/Common/ProductCardSkeleton.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
|
||||||
|
const ProductCardSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<div className="border p-2 rounded-2xl flex flex-col gap-2">
|
||||||
|
<Skeleton className="w-full aspect-square" />
|
||||||
|
<div className="space-between">
|
||||||
|
<Skeleton className="w-1/4 h-4" />
|
||||||
|
<Skeleton className="w-1/4 h-4" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="w-3/4 h-4" />
|
||||||
|
<Skeleton className="w-2/3 h-4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCardSkeleton;
|
||||||
116
components/Common/ProductHorizontalCard.jsx
Normal file
116
components/Common/ProductHorizontalCard.jsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { formatDate, t } from "@/utils";
|
||||||
|
import { BiBadgeCheck } from "react-icons/bi";
|
||||||
|
import { FaHeart, FaRegHeart } from "react-icons/fa";
|
||||||
|
import { manageFavouriteApi } from "@/utils/api";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import CustomLink from "@/components/Common/CustomLink";
|
||||||
|
import { setIsLoginOpen } from "@/redux/reducer/globalStateSlice";
|
||||||
|
import CustomImage from "./CustomImage";
|
||||||
|
|
||||||
|
const ProductHorizontalCard = ({ item, handleLike }) => {
|
||||||
|
const userData = useSelector(userSignUpData);
|
||||||
|
const translated_item = item.translated_item;
|
||||||
|
|
||||||
|
const productLink =
|
||||||
|
userData?.id === item?.user_id
|
||||||
|
? `/my-listing/${item?.slug}`
|
||||||
|
: `/ad-details/${item.slug}`;
|
||||||
|
const isJobCategory = Number(item?.category?.is_job_category) === 1;
|
||||||
|
|
||||||
|
const isHidePrice = isJobCategory
|
||||||
|
? !item?.formatted_salary_range
|
||||||
|
: !item?.formatted_price;
|
||||||
|
|
||||||
|
const price = isJobCategory
|
||||||
|
? item?.formatted_salary_range
|
||||||
|
: item?.formatted_price;
|
||||||
|
|
||||||
|
const handleLikeItem = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
if (!userData) {
|
||||||
|
setIsLoginOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await manageFavouriteApi.manageFavouriteApi({
|
||||||
|
item_id: item?.id,
|
||||||
|
});
|
||||||
|
if (response?.data?.error === false) {
|
||||||
|
toast.success(response?.data?.message);
|
||||||
|
handleLike(item?.id);
|
||||||
|
} else {
|
||||||
|
toast.error(t("failedToLike"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error(t("failedToLike"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomLink
|
||||||
|
href={productLink}
|
||||||
|
className="border p-2 rounded-md flex items-center gap-2 sm:gap-4 w-full relative"
|
||||||
|
>
|
||||||
|
<CustomImage
|
||||||
|
src={item?.image}
|
||||||
|
width={219}
|
||||||
|
height={190}
|
||||||
|
alt="Product"
|
||||||
|
className="w-[100px] sm:w-[219px] h-auto aspect-square sm:aspect-[219/190] rounded object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={handleLikeItem}
|
||||||
|
className="absolute h-8 w-8 ltr:right-2 rtl:left-2 top-2 bg-white p-1.5 rounded-full flex items-center justify-center text-primary z-10"
|
||||||
|
>
|
||||||
|
{item?.is_liked ? (
|
||||||
|
<button>
|
||||||
|
<FaHeart size={20} className="like_icon" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button>
|
||||||
|
<FaRegHeart size={20} className="like_icon" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 sm:gap-2 flex-1 relative min-w-0">
|
||||||
|
{item?.is_feature && (
|
||||||
|
<div className="flex items-center gap-1 rounded-md py-0.5 px-1 bg-primary w-fit mb-1">
|
||||||
|
<BiBadgeCheck size={16} color="white" />
|
||||||
|
<p className="text-white text-xs sm:text-sm">{t("featured")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isHidePrice && (
|
||||||
|
<p className="text-sm sm:text-lg font-bold truncate" title={price}>
|
||||||
|
{price}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-xs sm:text-base font-medium line-clamp-1"
|
||||||
|
title={translated_item?.name || item?.name}
|
||||||
|
>
|
||||||
|
{translated_item?.name || item?.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-xs sm:text-sm opacity-65 line-clamp-1">
|
||||||
|
{item?.translated_address}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-auto">
|
||||||
|
<p className="text-xs sm:text-sm opacity-65 whitespace-nowrap">
|
||||||
|
{formatDate(item?.created_at)}‎
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CustomLink>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductHorizontalCard;
|
||||||
34
components/Common/ProductHorizontalCardSkeleton.jsx
Normal file
34
components/Common/ProductHorizontalCardSkeleton.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
|
||||||
|
const ProductHorizontalCardSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<div className="border p-2 rounded-md flex gap-2 sm:gap-4 w-full relative">
|
||||||
|
{/* Product image skeleton */}
|
||||||
|
<Skeleton className="w-[100px] sm:w-[219px] h-auto aspect-square sm:aspect-[219/190] rounded" />
|
||||||
|
|
||||||
|
{/* Like button skeleton */}
|
||||||
|
<Skeleton className="absolute h-8 w-8 ltr:right-2 rtl:left-2 top-2 rounded-full" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 sm:gap-2 flex-1">
|
||||||
|
{/* Featured badge skeleton */}
|
||||||
|
<Skeleton className="h-6 w-24 rounded-md mb-1" />
|
||||||
|
|
||||||
|
{/* Price skeleton */}
|
||||||
|
<Skeleton className="h-5 sm:h-6 w-24 rounded" />
|
||||||
|
|
||||||
|
{/* Name skeleton */}
|
||||||
|
<Skeleton className="h-4 sm:h-5 w-3/4 rounded" />
|
||||||
|
|
||||||
|
{/* Location skeleton */}
|
||||||
|
<Skeleton className="h-3 sm:h-4 w-1/2 rounded" />
|
||||||
|
|
||||||
|
{/* Date skeleton */}
|
||||||
|
<div className="flex justify-end mt-auto">
|
||||||
|
<Skeleton className="h-3 sm:h-4 w-24 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductHorizontalCardSkeleton;
|
||||||
46
components/Common/ReusableAlertDialog.jsx
Normal file
46
components/Common/ReusableAlertDialog.jsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
const ReusableAlertDialog = ({
|
||||||
|
open,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
cancelText = t("cancel"),
|
||||||
|
confirmText = t("confirm"),
|
||||||
|
confirmDisabled = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open}>
|
||||||
|
<AlertDialogContent onInteractOutside={(e) => e.preventDefault()}>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
{description && (
|
||||||
|
<AlertDialogDescription asChild={typeof description !== "string"}>
|
||||||
|
{typeof description === "string" ? description : description}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
)}
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={onCancel}>{cancelText}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction disabled={confirmDisabled} onClick={onConfirm}>
|
||||||
|
{confirmDisabled ? <Loader2 className="w-4 h-4 animate-spin" /> : confirmText}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReusableAlertDialog;
|
||||||
107
components/Common/ShareDropdown.jsx
Normal file
107
components/Common/ShareDropdown.jsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
FacebookIcon,
|
||||||
|
FacebookShareButton,
|
||||||
|
TwitterShareButton,
|
||||||
|
WhatsappIcon,
|
||||||
|
WhatsappShareButton,
|
||||||
|
XIcon,
|
||||||
|
} from "react-share";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { IoShareSocialOutline } from "react-icons/io5";
|
||||||
|
import { CiLink } from "react-icons/ci";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { t } from "@/utils/index";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
const ShareDropdown = ({ url, title, headline, companyName, className }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const langCode = searchParams.get("lang");
|
||||||
|
const isRTL = useSelector(getIsRtl);
|
||||||
|
|
||||||
|
const handleCopyUrl = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url + "?share=true&lang=" + langCode);
|
||||||
|
toast.success(t("copyToClipboard"));
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error copying to clipboard:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className={className}>
|
||||||
|
<IoShareSocialOutline size={20} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align={isRTL ? "start" : "end"}>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<FacebookShareButton
|
||||||
|
className="w-full"
|
||||||
|
url={url}
|
||||||
|
hashtag={title}
|
||||||
|
onClick={handleShare}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FacebookIcon className="!size-6" round />
|
||||||
|
<span>{t("facebook")}</span>
|
||||||
|
</div>
|
||||||
|
</FacebookShareButton>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<TwitterShareButton
|
||||||
|
className="w-full"
|
||||||
|
url={url}
|
||||||
|
title={headline}
|
||||||
|
onClick={handleShare}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<XIcon className="!size-6" round />
|
||||||
|
<span>X</span>
|
||||||
|
</div>
|
||||||
|
</TwitterShareButton>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<WhatsappShareButton
|
||||||
|
className="w-100"
|
||||||
|
url={url}
|
||||||
|
title={headline}
|
||||||
|
hashtag={companyName}
|
||||||
|
onClick={handleShare}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WhatsappIcon className="!size-6" round />
|
||||||
|
<span>{t("whatsapp")}</span>
|
||||||
|
</div>
|
||||||
|
</WhatsappShareButton>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 w-full"
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
>
|
||||||
|
<CiLink className="!size-6" />
|
||||||
|
<span>{t("copyLink")}</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShareDropdown;
|
||||||
17
components/Common/useAutoFocus.jsx
Normal file
17
components/Common/useAutoFocus.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
const useAutoFocus = () => {
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return inputRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAutoFocus;
|
||||||
27
components/Common/useNavigate.jsx
Normal file
27
components/Common/useNavigate.jsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||||
|
import { getDefaultLanguageCode } from "@/redux/reducer/settingSlice";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
export const useNavigate = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const currentLangCode = useSelector(getCurrentLangCode);
|
||||||
|
const defaultLangCode = useSelector(getDefaultLanguageCode);
|
||||||
|
|
||||||
|
const langCode = currentLangCode || defaultLangCode;
|
||||||
|
|
||||||
|
const navigate = (path, options = {}) => {
|
||||||
|
if (path.includes("?")) {
|
||||||
|
// Path already has query parameters, add lang parameter
|
||||||
|
const langParam = langCode ? `&lang=${langCode}` : "";
|
||||||
|
router.push(`${path}${langParam}`, options);
|
||||||
|
} else {
|
||||||
|
// Path has no query parameters, add lang parameter with ?
|
||||||
|
const langParam = langCode ? `?lang=${langCode}` : "";
|
||||||
|
router.push(`${path}${langParam}`, options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { navigate };
|
||||||
|
};
|
||||||
21
components/EmptyStates/NoData.jsx
Normal file
21
components/EmptyStates/NoData.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import NoDataFound from "../../public/assets/no_data_found_illustrator.svg";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import CustomImage from "../Common/CustomImage";
|
||||||
|
|
||||||
|
const NoData = ({ name }) => {
|
||||||
|
return (
|
||||||
|
<div className="text-center flex flex-col items-center justify-center gap-2 h-[50vh]">
|
||||||
|
<div>
|
||||||
|
<CustomImage src={NoDataFound} alt="no_img" width={200} height={200} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-2xl font-medium text-primary">
|
||||||
|
{t("no")} {name} {t("found")}
|
||||||
|
</h3>
|
||||||
|
<p>{t("sorryTryAnotherWay")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NoData;
|
||||||
51
components/Filter/AreaNode.jsx
Normal file
51
components/Filter/AreaNode.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { setSelectedLocation } from "@/redux/reducer/globalStateSlice";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
const AreaNode = ({ area, city, state, country }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const selectedAreaId = searchParams.get("areaId") || "";
|
||||||
|
|
||||||
|
const isSelected = Number(selectedAreaId) === Number(area.id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSelected) {
|
||||||
|
dispatch(setSelectedLocation(area));
|
||||||
|
}
|
||||||
|
}, [isSelected, area]);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.set("areaId", area?.id?.toString());
|
||||||
|
newSearchParams.set("area", area?.name);
|
||||||
|
newSearchParams.set("lat", area?.latitude?.toString());
|
||||||
|
newSearchParams.set("lng", area?.longitude?.toString());
|
||||||
|
newSearchParams.set("country", country?.name);
|
||||||
|
newSearchParams.set("state", state?.name);
|
||||||
|
newSearchParams.set("city", city?.name);
|
||||||
|
|
||||||
|
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<div className="rounded">
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm",
|
||||||
|
isSelected && "border bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{area.translated_name || area?.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AreaNode;
|
||||||
59
components/Filter/BudgetFilter.jsx
Normal file
59
components/Filter/BudgetFilter.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
const BudgetFilter = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [budget, setBudget] = useState({
|
||||||
|
minPrice: searchParams.get("min_price") || "",
|
||||||
|
maxPrice: searchParams.get("max_price") || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { minPrice, maxPrice } = budget;
|
||||||
|
|
||||||
|
const handleMinMaxPrice = () => {
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.set("min_price", minPrice);
|
||||||
|
newSearchParams.set("max_price", maxPrice);
|
||||||
|
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<form className="flex gap-4">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={t("from")}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBudget((prev) => ({ ...prev, minPrice: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
value={minPrice}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={t("to")}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBudget((prev) => ({ ...prev, maxPrice: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
value={maxPrice}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="hover:bg-primary hover:text-white"
|
||||||
|
variant="outline"
|
||||||
|
disabled={minPrice == null || maxPrice == null || minPrice >= maxPrice}
|
||||||
|
onClick={handleMinMaxPrice}
|
||||||
|
>
|
||||||
|
{t("apply")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BudgetFilter;
|
||||||
145
components/Filter/CategoryNode.jsx
Normal file
145
components/Filter/CategoryNode.jsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { BreadcrumbPathData } from "@/redux/reducer/breadCrumbSlice";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { categoryApi } from "@/utils/api";
|
||||||
|
import { Loader2, Minus, Plus } from "lucide-react";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { useNavigate } from "../Common/useNavigate";
|
||||||
|
|
||||||
|
const CategoryNode = ({ category, extraDetails, setExtraDetails }) => {
|
||||||
|
const { navigate } = useNavigate();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [subcategories, setSubcategories] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const breadcrumbPath = useSelector(BreadcrumbPathData);
|
||||||
|
|
||||||
|
const selectedSlug = searchParams.get("category") || "";
|
||||||
|
const isSelected = category.slug === selectedSlug;
|
||||||
|
|
||||||
|
const shouldExpand = useMemo(() => {
|
||||||
|
if (!Array.isArray(breadcrumbPath) || breadcrumbPath.length <= 2)
|
||||||
|
return false;
|
||||||
|
// Skip the first (All Categories) and last (leaf node)
|
||||||
|
const keysToCheck = breadcrumbPath.slice(1, -1).map((crumb) => crumb.key);
|
||||||
|
return keysToCheck.includes(category.slug);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 📦 Auto-expand if it's in the path
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldExpand && !expanded) {
|
||||||
|
// If not already expanded and part of the path, expand and load children
|
||||||
|
setExpanded(true);
|
||||||
|
fetchSubcategories();
|
||||||
|
}
|
||||||
|
}, [shouldExpand]);
|
||||||
|
const fetchSubcategories = async (page = 1, append = false) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await categoryApi.getCategory({
|
||||||
|
category_id: category.id,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
const data = response.data.data.data;
|
||||||
|
const hasMore =
|
||||||
|
response.data.data.last_page > response.data.data.current_page;
|
||||||
|
setSubcategories((prev) => (append ? [...prev, ...data] : data));
|
||||||
|
setHasMore(hasMore);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleExpand = async () => {
|
||||||
|
if (!expanded && subcategories.length === 0) {
|
||||||
|
await fetchSubcategories();
|
||||||
|
}
|
||||||
|
setExpanded((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.set("category", category.slug);
|
||||||
|
Object.keys(extraDetails || {}).forEach((key) => {
|
||||||
|
newSearchParams.delete(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
setExtraDetails({})
|
||||||
|
|
||||||
|
if (pathname.startsWith("/ads")) {
|
||||||
|
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||||
|
} else {
|
||||||
|
navigate(`/ads?${newSearchParams.toString()}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
setPage(nextPage);
|
||||||
|
await fetchSubcategories(nextPage, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<div className="flex items-center rounded text-sm">
|
||||||
|
{category.subcategories_count > 0 &&
|
||||||
|
(isLoading ? (
|
||||||
|
<div className="p-1">
|
||||||
|
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||||
|
onClick={handleToggleExpand}
|
||||||
|
>
|
||||||
|
{expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm flex items-center justify-between gap-2",
|
||||||
|
isSelected && "border bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="break-all">{category.translated_name}</span>
|
||||||
|
<span>({category.all_items_count})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||||
|
{subcategories.map((sub) => (
|
||||||
|
<CategoryNode
|
||||||
|
key={sub.id + "filter-tree"}
|
||||||
|
category={sub}
|
||||||
|
selectedSlug={selectedSlug}
|
||||||
|
searchParams={searchParams}
|
||||||
|
extraDetails={extraDetails}
|
||||||
|
setExtraDetails={setExtraDetails}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
className="text-primary text-center text-sm py-1 px-2"
|
||||||
|
>
|
||||||
|
{t("loadMore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryNode;
|
||||||
158
components/Filter/CityNode.jsx
Normal file
158
components/Filter/CityNode.jsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Loader2, Minus, Plus } from "lucide-react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import AreaNode from "./AreaNode";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getAreasApi } from "@/utils/api";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
import { setSelectedLocation } from "@/redux/reducer/globalStateSlice";
|
||||||
|
|
||||||
|
const CityNode = ({ city, country, state }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [areas, setAreas] = useState({
|
||||||
|
data: [],
|
||||||
|
currentPage: 1,
|
||||||
|
hasMore: false,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadMore: false,
|
||||||
|
expanded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lat = searchParams.get("lat") || "";
|
||||||
|
const lng = searchParams.get("lng") || "";
|
||||||
|
const selectedCity = searchParams.get("city") || "";
|
||||||
|
const selectedArea = searchParams.get("area") || "";
|
||||||
|
|
||||||
|
const isSelected = useMemo(() => {
|
||||||
|
return city?.latitude === lat && city?.longitude === lng && !selectedArea;
|
||||||
|
}, [lat, lng]);
|
||||||
|
|
||||||
|
const shouldExpand = selectedCity === city?.name && selectedArea;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSelected) {
|
||||||
|
dispatch(setSelectedLocation(city));
|
||||||
|
}
|
||||||
|
}, [isSelected, city]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldExpand && !areas.expanded) {
|
||||||
|
fetchAreas();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAreas = async (page = 1) => {
|
||||||
|
try {
|
||||||
|
page > 1
|
||||||
|
? setAreas((prev) => ({ ...prev, isLoadMore: true }))
|
||||||
|
: setAreas((prev) => ({ ...prev, isLoading: true }));
|
||||||
|
|
||||||
|
const response = await getAreasApi.getAreas({
|
||||||
|
city_id: city.id,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
const newData = response?.data?.data?.data ?? [];
|
||||||
|
const currentPage = response?.data?.data?.current_page;
|
||||||
|
const lastPage = response?.data?.data?.last_page;
|
||||||
|
|
||||||
|
setAreas((prev) => ({
|
||||||
|
...prev,
|
||||||
|
data: page > 1 ? [...prev.data, ...newData] : newData,
|
||||||
|
currentPage,
|
||||||
|
hasMore: lastPage > currentPage,
|
||||||
|
expanded: true,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setAreas((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadMore: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleExpand = async () => {
|
||||||
|
if (!areas.expanded && areas.data.length === 0) {
|
||||||
|
await fetchAreas();
|
||||||
|
} else {
|
||||||
|
setAreas((prev) => ({ ...prev, expanded: !prev.expanded }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.set("country", country?.name);
|
||||||
|
newSearchParams.set("state", state?.name);
|
||||||
|
newSearchParams.set("city", city?.name);
|
||||||
|
newSearchParams.set("lat", city.latitude);
|
||||||
|
newSearchParams.set("lng", city.longitude);
|
||||||
|
// Always remove unrelated location filters to avoid redundancy
|
||||||
|
newSearchParams.delete("area");
|
||||||
|
newSearchParams.delete("areaId");
|
||||||
|
newSearchParams.delete("km_range");
|
||||||
|
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
await fetchAreas(areas.currentPage + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<div className="flex items-center rounded">
|
||||||
|
{areas?.isLoading ? (
|
||||||
|
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
city.areas_count > 0 && (
|
||||||
|
<button
|
||||||
|
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||||
|
onClick={handleToggleExpand}
|
||||||
|
>
|
||||||
|
{areas.expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm break-all",
|
||||||
|
isSelected && "border bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{city.translated_name || city?.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{areas.expanded && (
|
||||||
|
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||||
|
{areas.data.map((area) => (
|
||||||
|
<AreaNode
|
||||||
|
key={area.id}
|
||||||
|
area={area}
|
||||||
|
city={city}
|
||||||
|
state={state}
|
||||||
|
country={country}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{areas.hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
className="text-primary text-center text-sm py-1 px-2"
|
||||||
|
disabled={areas.isLoadMore}
|
||||||
|
>
|
||||||
|
{areas.isLoadMore ? t("loading") : t("loadMore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CityNode;
|
||||||
160
components/Filter/CountryNode.jsx
Normal file
160
components/Filter/CountryNode.jsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getStatesApi } from "@/utils/api";
|
||||||
|
import { Loader2, Minus, Plus } from "lucide-react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import StateNode from "./StateNode";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { setSelectedLocation } from "@/redux/reducer/globalStateSlice";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
const CountryNode = ({ country }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [states, setStates] = useState({
|
||||||
|
data: [],
|
||||||
|
currentPage: 1,
|
||||||
|
hasMore: false,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadMore: false,
|
||||||
|
expanded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lat = searchParams.get("lat") || "";
|
||||||
|
const lng = searchParams.get("lng") || "";
|
||||||
|
|
||||||
|
const selectedState = searchParams.get("state") || "";
|
||||||
|
const selectedCity = searchParams.get("city") || "";
|
||||||
|
const selectedArea = searchParams.get("area") || "";
|
||||||
|
const selectedCountry = searchParams.get("country") || "";
|
||||||
|
|
||||||
|
const isSelected = useMemo(() => {
|
||||||
|
return (
|
||||||
|
country?.latitude === lat &&
|
||||||
|
country?.longitude === lng &&
|
||||||
|
!selectedState &&
|
||||||
|
!selectedCity &&
|
||||||
|
!selectedArea
|
||||||
|
);
|
||||||
|
}, [lat, lng]);
|
||||||
|
|
||||||
|
const shouldExpand = selectedCountry === country?.name && selectedState;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldExpand && !states.expanded) {
|
||||||
|
fetchStates();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSelected) {
|
||||||
|
dispatch(setSelectedLocation(country));
|
||||||
|
}
|
||||||
|
}, [isSelected, country]);
|
||||||
|
|
||||||
|
const fetchStates = async (page = 1) => {
|
||||||
|
try {
|
||||||
|
page > 1
|
||||||
|
? setStates((prev) => ({ ...prev, isLoadMore: true }))
|
||||||
|
: setStates((prev) => ({ ...prev, isLoading: true }));
|
||||||
|
|
||||||
|
const response = await getStatesApi.getStates({
|
||||||
|
country_id: country.id,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
const newData = response?.data?.data?.data ?? [];
|
||||||
|
const currentPage = response?.data?.data?.current_page;
|
||||||
|
const lastPage = response?.data?.data?.last_page;
|
||||||
|
|
||||||
|
setStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
data: page > 1 ? [...prev.data, ...newData] : newData,
|
||||||
|
currentPage,
|
||||||
|
hasMore: lastPage > currentPage,
|
||||||
|
expanded: true,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadMore: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleExpand = async () => {
|
||||||
|
if (!states.expanded && states.data.length === 0) {
|
||||||
|
await fetchStates();
|
||||||
|
} else {
|
||||||
|
setStates((prev) => ({ ...prev, expanded: !prev.expanded }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.set("country", country?.name);
|
||||||
|
newSearchParams.set("lat", country.latitude);
|
||||||
|
newSearchParams.set("lng", country.longitude);
|
||||||
|
newSearchParams.delete("state");
|
||||||
|
newSearchParams.delete("city");
|
||||||
|
newSearchParams.delete("area");
|
||||||
|
newSearchParams.delete("areaId");
|
||||||
|
newSearchParams.delete("km_range");
|
||||||
|
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
await fetchStates(states.currentPage + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<div className="flex items-center rounded">
|
||||||
|
{states?.isLoading ? (
|
||||||
|
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
country.states_count > 0 && (
|
||||||
|
<button
|
||||||
|
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||||
|
onClick={handleToggleExpand}
|
||||||
|
>
|
||||||
|
{states.expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm",
|
||||||
|
isSelected && "border bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{country?.translated_name || country?.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{states.expanded && (
|
||||||
|
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||||
|
{states.data.map((state) => (
|
||||||
|
<StateNode key={state.id} state={state} country={country} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{states.hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
className="text-primary text-center text-sm py-1 px-2"
|
||||||
|
disabled={states.isLoadMore}
|
||||||
|
>
|
||||||
|
{states.isLoadMore ? t("loading") : t("loadMore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CountryNode;
|
||||||
63
components/Filter/DatePostedFilter.jsx
Normal file
63
components/Filter/DatePostedFilter.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { Checkbox } from "../ui/checkbox";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
|
||||||
|
const DatePostedFilter = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const value = searchParams.get("date_posted") || "";
|
||||||
|
|
||||||
|
const datesPostedOptions = [
|
||||||
|
{
|
||||||
|
label: "allTime",
|
||||||
|
value: "all-time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "today",
|
||||||
|
value: "today",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "within1Week",
|
||||||
|
value: "within-1-week",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "within2Weeks",
|
||||||
|
value: "within-2-week",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "within1Month",
|
||||||
|
value: "within-1-month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "within3Months",
|
||||||
|
value: "within-3-month",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleCheckboxChange = (optionValue) => {
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
if (value === optionValue) {
|
||||||
|
// Uncheck: remove the filter
|
||||||
|
newSearchParams.delete("date_posted");
|
||||||
|
} else {
|
||||||
|
// Check: set the filter
|
||||||
|
newSearchParams.set("date_posted", optionValue);
|
||||||
|
}
|
||||||
|
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{datesPostedOptions.map((option) => (
|
||||||
|
<div className="flex items-center gap-2" key={option.value}>
|
||||||
|
<Checkbox
|
||||||
|
checked={value === option.value}
|
||||||
|
onCheckedChange={() => handleCheckboxChange(option.value)}
|
||||||
|
/>
|
||||||
|
<label>{t(option.label)}</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DatePostedFilter;
|
||||||
155
components/Filter/ExtraDetailsFilter.jsx
Normal file
155
components/Filter/ExtraDetailsFilter.jsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
import { Checkbox } from "../ui/checkbox";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
const ExtraDetailsFilter = ({
|
||||||
|
customFields,
|
||||||
|
extraDetails,
|
||||||
|
setExtraDetails,
|
||||||
|
newSearchParams,
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const isApplyDisabled = () => {
|
||||||
|
return !Object.values(extraDetails).some(
|
||||||
|
(val) => (Array.isArray(val) && val.length > 0) || (!!val && val !== "")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (id, value, checked) => {
|
||||||
|
setExtraDetails((prev) => {
|
||||||
|
const existing = prev[id] || [];
|
||||||
|
const updated = checked
|
||||||
|
? [...existing, value]
|
||||||
|
: existing.filter((v) => v !== value);
|
||||||
|
return { ...prev, [id]: updated.length ? updated : "" };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (fieldId, value) => {
|
||||||
|
setExtraDetails((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[fieldId]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
Object.entries(extraDetails).forEach(([key, val]) => {
|
||||||
|
if (Array.isArray(val) && val.length) {
|
||||||
|
newSearchParams.set(key, val.join(","));
|
||||||
|
} else if (val) {
|
||||||
|
newSearchParams.set(key, val);
|
||||||
|
} else {
|
||||||
|
newSearchParams.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 flex-col">
|
||||||
|
{customFields.map((field) => (
|
||||||
|
<Fragment key={field.id}>
|
||||||
|
{/* Checkbox */}
|
||||||
|
{field.type === "checkbox" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="font-semibold" htmlFor={field.id}>
|
||||||
|
{field.translated_name || field.name}
|
||||||
|
</Label>
|
||||||
|
{field.values.map((option, index) => (
|
||||||
|
<div key={option} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${field.id}-${option}`}
|
||||||
|
checked={(extraDetails[field.id] || []).includes(option)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleCheckboxChange(field.id, option, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`${field.id}-${option}`}
|
||||||
|
className="text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
{field?.translated_value[index] || option}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Radio */}
|
||||||
|
{field.type === "radio" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="font-semibold" htmlFor={field.id}>
|
||||||
|
{field.translated_name || field.name}
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{field.values.map((option, index) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={`py-2 px-4 w-fit rounded-md border transition-colors ${
|
||||||
|
extraDetails[field.id] === option
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleInputChange(field.id, option)}
|
||||||
|
aria-pressed={extraDetails[field.id] === option}
|
||||||
|
>
|
||||||
|
{field?.translated_value[index] || option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{field.type === "dropdown" && (
|
||||||
|
<div className="w-full flex flex-col gap-2">
|
||||||
|
<Label className="font-semibold capitalize" htmlFor={field.id}>
|
||||||
|
{field.translated_name || field.name}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={extraDetails[field.id] || ""}
|
||||||
|
onValueChange={(val) => handleInputChange(field.id, val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={`${t("select")} ${
|
||||||
|
field.translated_name || field.name
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{field.values.map((option, index) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{field?.translated_value[index] || option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hover:bg-primary hover:text-white"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={isApplyDisabled()}
|
||||||
|
>
|
||||||
|
{t("apply")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExtraDetailsFilter;
|
||||||
118
components/Filter/Filter.jsx
Normal file
118
components/Filter/Filter.jsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import FilterTree from "./FilterTree";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import LocationTree from "./LocationTree";
|
||||||
|
import BudgetFilter from "./BudgetFilter";
|
||||||
|
import DatePostedFilter from "./DatePostedFilter";
|
||||||
|
import RangeFilter from "./RangeFilter";
|
||||||
|
import ExtraDetailsFilter from "./ExtraDetailsFilter";
|
||||||
|
|
||||||
|
const Filter = ({
|
||||||
|
customFields,
|
||||||
|
extraDetails,
|
||||||
|
setExtraDetails,
|
||||||
|
newSearchParams,
|
||||||
|
country,
|
||||||
|
state,
|
||||||
|
city,
|
||||||
|
area,
|
||||||
|
}) => {
|
||||||
|
const langId = useSelector(getCurrentLangCode);
|
||||||
|
const isShowCustomfieldFilter =
|
||||||
|
customFields &&
|
||||||
|
customFields.length > 0 &&
|
||||||
|
customFields.some(
|
||||||
|
(field) =>
|
||||||
|
field.type === "checkbox" ||
|
||||||
|
field.type === "radio" ||
|
||||||
|
field.type === "dropdown"
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLocationSelected = country || state || city || area;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full border rounded-lg overflow-hidden">
|
||||||
|
<div className="px-4 py-2 font-semibold border-b text-xl">
|
||||||
|
{t("filters")}
|
||||||
|
</div>
|
||||||
|
<div className=" flex flex-col ">
|
||||||
|
<Accordion
|
||||||
|
type="multiple"
|
||||||
|
defaultValue={
|
||||||
|
isLocationSelected ? ["location", "category"] : ["category"]
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<AccordionItem value="category" className="border-b">
|
||||||
|
<AccordionTrigger className="p-4">
|
||||||
|
<span className="font-semibold text-base">{t("category")}</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 pb-4">
|
||||||
|
<FilterTree key={langId} extraDetails={extraDetails} setExtraDetails={setExtraDetails} />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="location" className="border-b">
|
||||||
|
<AccordionTrigger className="p-4">
|
||||||
|
<span className="font-semibold text-base">{t("location")}</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 pb-4">
|
||||||
|
<LocationTree />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="budget" className="border-b">
|
||||||
|
<AccordionTrigger className="p-4">
|
||||||
|
<span className="font-semibold text-base">{t("budget")}</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 pb-4">
|
||||||
|
<BudgetFilter />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="date-posted" className="border-b">
|
||||||
|
<AccordionTrigger className="p-4">
|
||||||
|
<span className="font-semibold text-base">{t("datePosted")}</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 pb-4">
|
||||||
|
<DatePostedFilter />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="nearby-range" className="border-b">
|
||||||
|
<AccordionTrigger className="p-4">
|
||||||
|
<span className="font-semibold text-base">
|
||||||
|
{t("nearByKmRange")}
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 pb-4">
|
||||||
|
<RangeFilter />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
{isShowCustomfieldFilter && (
|
||||||
|
<AccordionItem value="extra-details">
|
||||||
|
<AccordionTrigger className="p-4">
|
||||||
|
<span className="font-semibold text-base">
|
||||||
|
{t("extradetails")}
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 pb-4">
|
||||||
|
<ExtraDetailsFilter
|
||||||
|
customFields={customFields}
|
||||||
|
extraDetails={extraDetails}
|
||||||
|
setExtraDetails={setExtraDetails}
|
||||||
|
newSearchParams={newSearchParams}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Filter;
|
||||||
104
components/Filter/FilterTree.jsx
Normal file
104
components/Filter/FilterTree.jsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Loader2, Minus, Plus } from "lucide-react";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import CategoryNode from "./CategoryNode";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "../Common/useNavigate";
|
||||||
|
import useGetCategories from "../Layout/useGetCategories";
|
||||||
|
|
||||||
|
const FilterTree = ({ extraDetails, setExtraDetails }) => {
|
||||||
|
const { navigate } = useNavigate();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const {
|
||||||
|
getCategories,
|
||||||
|
cateData,
|
||||||
|
isCatLoading,
|
||||||
|
isCatLoadMore,
|
||||||
|
catCurrentPage,
|
||||||
|
catLastPage,
|
||||||
|
} = useGetCategories();
|
||||||
|
const hasMore = catCurrentPage < catLastPage;
|
||||||
|
|
||||||
|
const selectedSlug = searchParams.get("category") || "";
|
||||||
|
const isSelected = !selectedSlug; // "All" category is selected when no category is selected
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
|
const handleToggleExpand = () => {
|
||||||
|
setExpanded((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.delete("category");
|
||||||
|
Object.keys(extraDetails || {})?.forEach((key) => {
|
||||||
|
params.delete(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
setExtraDetails({})
|
||||||
|
|
||||||
|
if (pathname.startsWith("/ads")) {
|
||||||
|
window.history.pushState(null, "", `/ads?${params.toString()}`);
|
||||||
|
} else {
|
||||||
|
navigate(`/ads?${params.toString()}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<div className="flex items-center rounded text-sm">
|
||||||
|
{isCatLoading ? (
|
||||||
|
<div className="p-1">
|
||||||
|
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||||
|
onClick={handleToggleExpand}
|
||||||
|
>
|
||||||
|
{expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm",
|
||||||
|
isSelected && "border bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("allCategories")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && cateData.length > 0 && (
|
||||||
|
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||||
|
{cateData.map((category) => (
|
||||||
|
<CategoryNode
|
||||||
|
key={category.id + "filter-tree"}
|
||||||
|
category={category}
|
||||||
|
extraDetails={extraDetails}
|
||||||
|
setExtraDetails={setExtraDetails}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={() => getCategories(catCurrentPage + 1)}
|
||||||
|
className="text-primary text-center text-sm py-1 px-2"
|
||||||
|
disabled={isCatLoadMore}
|
||||||
|
>
|
||||||
|
{isCatLoadMore ? t("loading") : t("loadMore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterTree;
|
||||||
130
components/Filter/LocationTree.jsx
Normal file
130
components/Filter/LocationTree.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getCoutriesApi } from "@/utils/api";
|
||||||
|
import { Loader2, Minus, Plus } from "lucide-react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import CountryNode from "./CountryNode";
|
||||||
|
|
||||||
|
const LocationTree = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const langCode = searchParams.get("lang");
|
||||||
|
|
||||||
|
const selectedCountry = searchParams.get("country") || "";
|
||||||
|
const selectedState = searchParams.get("state") || "";
|
||||||
|
const selectedCity = searchParams.get("city") || "";
|
||||||
|
const selectedArea = searchParams.get("area") || "";
|
||||||
|
const isAllSelected =
|
||||||
|
!selectedCountry && !selectedState && !selectedCity && !selectedArea;
|
||||||
|
|
||||||
|
const [countries, setCountries] = useState({
|
||||||
|
data: [],
|
||||||
|
currentPage: 1,
|
||||||
|
hasMore: false,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadMore: false,
|
||||||
|
expanded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCountries();
|
||||||
|
}, [langCode]);
|
||||||
|
|
||||||
|
const fetchCountries = async (page = 1) => {
|
||||||
|
try {
|
||||||
|
page > 1
|
||||||
|
? setCountries((prev) => ({ ...prev, isLoadMore: true }))
|
||||||
|
: setCountries((prev) => ({ ...prev, isLoading: true }));
|
||||||
|
|
||||||
|
const response = await getCoutriesApi.getCoutries({ page });
|
||||||
|
const newData = response?.data?.data?.data ?? [];
|
||||||
|
const currentPage = response?.data?.data?.current_page;
|
||||||
|
const lastPage = response?.data?.data?.last_page;
|
||||||
|
|
||||||
|
setCountries((prev) => ({
|
||||||
|
...prev,
|
||||||
|
data: page > 1 ? [...prev.data, ...newData] : newData,
|
||||||
|
currentPage,
|
||||||
|
hasMore: lastPage > currentPage,
|
||||||
|
expanded: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setCountries((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadMore: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleExpand = () => {
|
||||||
|
setCountries((prev) => ({ ...prev, expanded: !prev.expanded }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAllLocationsClick = () => {
|
||||||
|
// Clear all location parameters
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.delete("country");
|
||||||
|
newSearchParams.delete("state");
|
||||||
|
newSearchParams.delete("city");
|
||||||
|
newSearchParams.delete("area");
|
||||||
|
newSearchParams.delete("lat");
|
||||||
|
newSearchParams.delete("lng");
|
||||||
|
newSearchParams.delete("km_range");
|
||||||
|
|
||||||
|
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<div className="flex items-center rounded">
|
||||||
|
{countries?.isLoading ? (
|
||||||
|
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||||
|
onClick={handleToggleExpand}
|
||||||
|
>
|
||||||
|
{countries.expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAllLocationsClick}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm",
|
||||||
|
isAllSelected && "border bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("allCountries")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{countries.expanded && countries.data.length > 0 && (
|
||||||
|
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||||
|
{countries.data.map((country) => (
|
||||||
|
<CountryNode key={country.id + langCode} country={country} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{countries.hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={() => fetchCountries(countries.currentPage + 1)}
|
||||||
|
className="text-primary text-center text-sm py-1 px-2"
|
||||||
|
disabled={countries.isLoadMore}
|
||||||
|
>
|
||||||
|
{countries.isLoadMore ? t("loading") : t("loadMore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocationTree;
|
||||||
78
components/Filter/RangeFilter.jsx
Normal file
78
components/Filter/RangeFilter.jsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Slider } from "../ui/slider";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { getMaxRange, getMinRange } from "@/redux/reducer/settingSlice";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||||
|
|
||||||
|
const RangeFilter = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const isRTL = useSelector(getIsRtl);
|
||||||
|
|
||||||
|
const kmRange = searchParams.get("km_range");
|
||||||
|
const areaId = searchParams.get("areaId");
|
||||||
|
const lat = searchParams.get("lat");
|
||||||
|
const lng = searchParams.get("lng");
|
||||||
|
|
||||||
|
const min = useSelector(getMinRange);
|
||||||
|
const max = useSelector(getMaxRange);
|
||||||
|
|
||||||
|
const [value, setValue] = useState([kmRange || min]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleRangeApply = () => {
|
||||||
|
if (!areaId) {
|
||||||
|
setError(t("pleaseSelectArea"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInvalidCoord = (value) =>
|
||||||
|
value === null ||
|
||||||
|
value === undefined ||
|
||||||
|
value === "" ||
|
||||||
|
isNaN(Number(value));
|
||||||
|
|
||||||
|
if (isInvalidCoord(lat) || isInvalidCoord(lng)) {
|
||||||
|
setError(t("InvalidLatOrLng"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.set("km_range", value);
|
||||||
|
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2 justify-between">
|
||||||
|
<span>{t("rangeLabel")}</span>
|
||||||
|
<span>{value} KM</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Slider
|
||||||
|
value={value}
|
||||||
|
onValueChange={setValue}
|
||||||
|
max={max}
|
||||||
|
min={min}
|
||||||
|
step={1}
|
||||||
|
dir={isRTL ? "rtl" : "ltr"}
|
||||||
|
/>
|
||||||
|
{error && <span className="text-sm text-destructive">{error}</span>}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="hover:bg-primary hover:text-white w-full"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRangeApply}
|
||||||
|
disabled={value[0] <= 0}
|
||||||
|
>
|
||||||
|
{t("apply")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RangeFilter;
|
||||||
162
components/Filter/StateNode.jsx
Normal file
162
components/Filter/StateNode.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { Loader2, Minus, Plus } from "lucide-react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import CityNode from "./CityNode";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getCitiesApi } from "@/utils/api";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
import { setSelectedLocation } from "@/redux/reducer/globalStateSlice";
|
||||||
|
|
||||||
|
const StateNode = ({ state, country }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [cities, setCities] = useState({
|
||||||
|
data: [],
|
||||||
|
currentPage: 1,
|
||||||
|
hasMore: false,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadMore: false,
|
||||||
|
expanded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCity = searchParams.get("city") || "";
|
||||||
|
const selectedArea = searchParams.get("area") || "";
|
||||||
|
const selectedState = searchParams.get("state") || "";
|
||||||
|
const lat = searchParams.get("lat") || "";
|
||||||
|
const lng = searchParams.get("lng") || "";
|
||||||
|
|
||||||
|
const isSelected = useMemo(() => {
|
||||||
|
return (
|
||||||
|
state?.latitude === lat &&
|
||||||
|
state?.longitude === lng &&
|
||||||
|
!selectedCity &&
|
||||||
|
!selectedArea
|
||||||
|
);
|
||||||
|
}, [lat, lng]);
|
||||||
|
|
||||||
|
const shouldExpand = selectedState === state?.name && selectedCity;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldExpand && !cities.expanded) {
|
||||||
|
fetchCities();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSelected) {
|
||||||
|
dispatch(setSelectedLocation(state));
|
||||||
|
}
|
||||||
|
}, [isSelected, state]);
|
||||||
|
|
||||||
|
const fetchCities = async (page = 1) => {
|
||||||
|
try {
|
||||||
|
page > 1
|
||||||
|
? setCities((prev) => ({ ...prev, isLoadMore: true }))
|
||||||
|
: setCities((prev) => ({ ...prev, isLoading: true }));
|
||||||
|
|
||||||
|
const response = await getCitiesApi.getCities({
|
||||||
|
state_id: state.id,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
const newData = response?.data?.data?.data ?? [];
|
||||||
|
const currentPage = response?.data?.data?.current_page;
|
||||||
|
const lastPage = response?.data?.data?.last_page;
|
||||||
|
|
||||||
|
setCities((prev) => ({
|
||||||
|
...prev,
|
||||||
|
data: page > 1 ? [...prev.data, ...newData] : newData,
|
||||||
|
currentPage,
|
||||||
|
hasMore: lastPage > currentPage,
|
||||||
|
expanded: true,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setCities((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadMore: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleExpand = async () => {
|
||||||
|
if (!cities.expanded && cities.data.length === 0) {
|
||||||
|
await fetchCities();
|
||||||
|
} else {
|
||||||
|
setCities((prev) => ({ ...prev, expanded: !prev.expanded }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.set("country", country?.name);
|
||||||
|
newSearchParams.set("state", state?.name);
|
||||||
|
newSearchParams.set("lat", state.latitude);
|
||||||
|
newSearchParams.set("lng", state.longitude);
|
||||||
|
newSearchParams.delete("city");
|
||||||
|
newSearchParams.delete("area");
|
||||||
|
newSearchParams.delete("areaId");
|
||||||
|
newSearchParams.delete("km_range");
|
||||||
|
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
await fetchCities(cities.currentPage + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<div className="flex items-center rounded">
|
||||||
|
{cities?.isLoading ? (
|
||||||
|
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
state.cities_count > 0 && (
|
||||||
|
<button
|
||||||
|
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||||
|
onClick={handleToggleExpand}
|
||||||
|
>
|
||||||
|
{cities.expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm",
|
||||||
|
isSelected && "border bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{state?.translated_name || state?.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cities.expanded && (
|
||||||
|
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||||
|
{cities.data.map((city) => (
|
||||||
|
<CityNode
|
||||||
|
key={city.id}
|
||||||
|
city={city}
|
||||||
|
country={country}
|
||||||
|
state={state}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{cities.hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
className="text-primary text-center text-sm py-1 px-2"
|
||||||
|
disabled={cities.isLoadMore}
|
||||||
|
>
|
||||||
|
{cities.isLoadMore ? t("loading") : t("loadMore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StateNode;
|
||||||
251
components/Footer/Footer.jsx
Normal file
251
components/Footer/Footer.jsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
import CustomLink from "@/components/Common/CustomLink";
|
||||||
|
import { FaFacebook, FaLinkedin, FaPinterest } from "react-icons/fa";
|
||||||
|
import { FaInstagram, FaSquareXTwitter } from "react-icons/fa6";
|
||||||
|
import { SlLocationPin } from "react-icons/sl";
|
||||||
|
import { RiMailSendFill } from "react-icons/ri";
|
||||||
|
import { BiPhoneCall } from "react-icons/bi";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { quickLinks } from "@/utils/constants";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { settingsData } from "@/redux/reducer/settingSlice";
|
||||||
|
import googleDownload from "../../public/assets/Google Download.png";
|
||||||
|
import appleDownload from "../../public/assets/iOS Download.png";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||||
|
import CustomImage from "../Common/CustomImage";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||||
|
const settings = useSelector(settingsData);
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const showGetInTouchSection =
|
||||||
|
settings?.company_address ||
|
||||||
|
settings?.company_email ||
|
||||||
|
settings?.company_tel1 ||
|
||||||
|
settings?.company_tel2;
|
||||||
|
|
||||||
|
const showDownloadLinks =
|
||||||
|
settings?.play_store_link && settings?.app_store_link;
|
||||||
|
|
||||||
|
const marginTop = showDownloadLinks ? "mt-[150px]" : "mt-48";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className={`bg-[#1a1a1a] text-white ${marginTop}`}>
|
||||||
|
<div className="container py-12 relative">
|
||||||
|
{showDownloadLinks && (
|
||||||
|
<div className="relative bg-[#FF7F50] top-[-140px] lg:top-[-125px] xl:top-[-150px] p-6 xl:p-12 rounded-md flex flex-col lg:flex-row items-center justify-between">
|
||||||
|
<h2 className="text-3xl md:text-4xl xl:text-5xl text-center lg:text-left text-balance font-light mb-4 md:mb-0 w-full">
|
||||||
|
{t("experienceTheMagic")} {settings?.company_name} {t("app")}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-row lg:flex-row items-center">
|
||||||
|
{settings?.app_store_link && (
|
||||||
|
<Link href={settings?.play_store_link}>
|
||||||
|
<CustomImage
|
||||||
|
src={googleDownload}
|
||||||
|
alt="google"
|
||||||
|
className="storeIcons"
|
||||||
|
width={235}
|
||||||
|
height={85}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{settings?.app_store_link && (
|
||||||
|
<Link href={settings?.app_store_link}>
|
||||||
|
<CustomImage
|
||||||
|
src={appleDownload}
|
||||||
|
alt="apple"
|
||||||
|
className="storeIcons"
|
||||||
|
width={235}
|
||||||
|
height={85}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`grid grid-1 sm:grid-cols-12 gap-12 ${
|
||||||
|
showDownloadLinks && "mt-[-70px] lg:mt-[-64px] xl:mt-[-75px]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Company Info */}
|
||||||
|
<div className="space-y-6 sm:col-span-12 lg:col-span-4">
|
||||||
|
<CustomLink href="/">
|
||||||
|
<CustomImage
|
||||||
|
src={settings?.footer_logo}
|
||||||
|
alt="eClassify"
|
||||||
|
width={195}
|
||||||
|
height={52}
|
||||||
|
className="w-full h-[52px] object-contain ltr:object-left rtl:object-right max-w-[195px]"
|
||||||
|
/>
|
||||||
|
</CustomLink>
|
||||||
|
<p className="text-gray-300 text-sm max-w-md">
|
||||||
|
{settings?.footer_description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center flex-wrap gap-6">
|
||||||
|
{settings?.facebook_link && (
|
||||||
|
<CustomLink
|
||||||
|
href={settings?.facebook_link}
|
||||||
|
target="_blank"
|
||||||
|
className="footerSocialLinks"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<FaFacebook size={22} />
|
||||||
|
</CustomLink>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings?.instagram_link && (
|
||||||
|
<Link
|
||||||
|
href={settings?.instagram_link}
|
||||||
|
target="_blank"
|
||||||
|
className="footerSocialLinks"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<FaInstagram size={22} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings?.x_link && (
|
||||||
|
<Link
|
||||||
|
href={settings?.x_link}
|
||||||
|
target="_blank"
|
||||||
|
className="footerSocialLinks"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<FaSquareXTwitter size={22} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings?.linkedin_link && (
|
||||||
|
<Link
|
||||||
|
href={settings?.linkedin_link}
|
||||||
|
target="_blank"
|
||||||
|
className="footerSocialLinks"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<FaLinkedin size={22} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings?.pinterest_link && (
|
||||||
|
<Link
|
||||||
|
href={settings?.pinterest_link}
|
||||||
|
target="_blank"
|
||||||
|
className="footerSocialLinks"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<FaPinterest size={22} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-12 lg:hidden border-t-2 border-dashed border-gray-500 w-full"></div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="ltr:lg:border-l-2 rtl:lg:border-r-2 lg:border-dashed lg:border-gray-500 ltr:lg:pl-6 rtl:lg:pr-6 sm:col-span-6 lg:col-span-4">
|
||||||
|
<h3 className="text-xl font-semibold mb-6">{t("quickLinks")}</h3>
|
||||||
|
<nav className="space-y-4">
|
||||||
|
{quickLinks.map((link) => (
|
||||||
|
<CustomLink
|
||||||
|
key={link.id}
|
||||||
|
href={link.href}
|
||||||
|
className="group block hover:text-[var(--primary-color)] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="relative flex items-center">
|
||||||
|
<span className="absolute left-0 top-1/2 transform -translate-y-1/2 h-[10px] w-[10px] bg-[var(--primary-color)] rounded-full opacity-0 group-hover:opacity-100 transition-all duration-500"></span>
|
||||||
|
<span className="opacity-65 group-hover:text-[var(--primary-color)] group-hover:opacity-100 group-hover:ml-4 transition-all duration-500">
|
||||||
|
{t(link.labelKey)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</CustomLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Information */}
|
||||||
|
|
||||||
|
{showGetInTouchSection && (
|
||||||
|
<div className="ltr:lg:border-l-2 rtl:lg:border-r-2 lg:border-dashed lg:border-gray-500 ltr:lg:pl-6 rtl:lg:pr-6 sm:col-span-6 lg:col-span-4">
|
||||||
|
<h3 className="text-xl font-semibold mb-6">{t("getInTouch")}</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{settings?.company_address && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="footerContactIcons">
|
||||||
|
<SlLocationPin size={22} />
|
||||||
|
</div>
|
||||||
|
<p className="footerLabel">{settings?.company_address}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings?.company_email && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="footerContactIcons">
|
||||||
|
<RiMailSendFill size={22} />
|
||||||
|
</div>
|
||||||
|
<CustomLink
|
||||||
|
href={`mailto:${settings?.company_email}`}
|
||||||
|
className="footerLabel"
|
||||||
|
>
|
||||||
|
{settings?.company_email}
|
||||||
|
</CustomLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(settings?.company_tel1 || settings?.company_tel2) && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="footerContactIcons">
|
||||||
|
<BiPhoneCall size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{settings?.company_tel1 && (
|
||||||
|
<CustomLink
|
||||||
|
href={`tel:${settings?.company_tel1}`}
|
||||||
|
className="footerLabel"
|
||||||
|
>
|
||||||
|
{settings?.company_tel1}
|
||||||
|
</CustomLink>
|
||||||
|
)}
|
||||||
|
{settings?.company_tel2 && (
|
||||||
|
<CustomLink
|
||||||
|
href={`tel:${settings?.company_tel2}`}
|
||||||
|
className="footerLabel"
|
||||||
|
>
|
||||||
|
{settings?.company_tel2}
|
||||||
|
</CustomLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copyright */}
|
||||||
|
<div className="container">
|
||||||
|
<div className="py-4 flex flex-wrap gap-3 justify-between items-center border-t-2 border-dashed border-gray-500">
|
||||||
|
<p className="footerLabel">
|
||||||
|
{t("copyright")} © {settings?.company_name} {currentYear}.{" "}
|
||||||
|
{t("allRightsReserved")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4 whitespace-nowrap">
|
||||||
|
<CustomLink href="/privacy-policy" className="footerLabel">
|
||||||
|
{t("privacyPolicy")}
|
||||||
|
</CustomLink>
|
||||||
|
<CustomLink href="/terms-and-condition" className="footerLabel">
|
||||||
|
{t("termsConditions")}
|
||||||
|
</CustomLink>
|
||||||
|
<CustomLink href="/refund-policy" className="footerLabel">
|
||||||
|
{t("refundPolicy")}
|
||||||
|
</CustomLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
components/Layout/Layout.jsx
Normal file
48
components/Layout/Layout.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
import Header from "../Common/Header";
|
||||||
|
import Footer from "../Footer/Footer";
|
||||||
|
import PushNotificationLayout from "./PushNotificationLayout";
|
||||||
|
import Loading from "@/app/loading";
|
||||||
|
import UnderMaintenance from "../../public/assets/something_went_wrong.svg";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { useClientLayoutLogic } from "./useClientLayoutLogic";
|
||||||
|
import CustomImage from "../Common/CustomImage";
|
||||||
|
import ScrollToTopButton from "./ScrollToTopButton";
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
const { isLoading, isMaintenanceMode, isRedirectToLanding } =
|
||||||
|
useClientLayoutLogic();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRedirectToLanding) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMaintenanceMode) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen gap-2">
|
||||||
|
<CustomImage
|
||||||
|
src={UnderMaintenance}
|
||||||
|
alt="Maintenance Mode"
|
||||||
|
height={255}
|
||||||
|
width={255}
|
||||||
|
/>
|
||||||
|
<p className="text-center max-w-[40%]">{t("underMaintenance")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PushNotificationLayout>
|
||||||
|
<div className="flex flex-col min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
<ScrollToTopButton />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</PushNotificationLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
components/Layout/PushNotificationLayout.jsx
Normal file
101
components/Layout/PushNotificationLayout.jsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import "firebase/messaging";
|
||||||
|
import FirebaseData from "../../utils/Firebase";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { setNotification } from "@/redux/reducer/globalStateSlice";
|
||||||
|
import { useNavigate } from "../Common/useNavigate";
|
||||||
|
import { getIsLoggedIn } from "@/redux/reducer/authSlice";
|
||||||
|
|
||||||
|
const PushNotificationLayout = ({ children }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [fcmToken, setFcmToken] = useState("");
|
||||||
|
const { fetchToken, onMessageListener } = FirebaseData();
|
||||||
|
const { navigate } = useNavigate();
|
||||||
|
const isLoggedIn = useSelector(getIsLoggedIn);
|
||||||
|
const unsubscribeRef = useRef(null);
|
||||||
|
|
||||||
|
const handleFetchToken = async () => {
|
||||||
|
await fetchToken(setFcmToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch token when user logs in
|
||||||
|
useEffect(() => {
|
||||||
|
handleFetchToken();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set up message listener when logged in, clean up when logged out
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
// Clean up listener when user logs out
|
||||||
|
if (unsubscribeRef.current) {
|
||||||
|
unsubscribeRef.current();
|
||||||
|
unsubscribeRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up listener when user logs in
|
||||||
|
const setupListener = async () => {
|
||||||
|
try {
|
||||||
|
unsubscribeRef.current = await onMessageListener((payload) => {
|
||||||
|
if (payload && payload.data) {
|
||||||
|
dispatch(setNotification(payload.data));
|
||||||
|
if (Notification.permission === "granted") {
|
||||||
|
const notif = new Notification(payload.notification.title, {
|
||||||
|
body: payload.notification.body,
|
||||||
|
});
|
||||||
|
const tab =
|
||||||
|
payload.data?.user_type === "Seller" ? "buying" : "selling";
|
||||||
|
|
||||||
|
notif.onclick = () => {
|
||||||
|
if (
|
||||||
|
payload.data.type === "chat" ||
|
||||||
|
payload.data.type === "offer"
|
||||||
|
) {
|
||||||
|
navigate(
|
||||||
|
`/chat?activeTab=${tab}&chatid=${payload.data?.item_offer_id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error handling foreground notification:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setupListener();
|
||||||
|
|
||||||
|
// Cleanup on unmount or logout
|
||||||
|
return () => {
|
||||||
|
if (unsubscribeRef.current) {
|
||||||
|
unsubscribeRef.current();
|
||||||
|
unsubscribeRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isLoggedIn, dispatch, navigate, onMessageListener]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fcmToken) {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register("/firebase-messaging-sw.js")
|
||||||
|
.then((registration) => {
|
||||||
|
console.log(
|
||||||
|
"Service Worker registration successful with scope: ",
|
||||||
|
registration.scope
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log("Service Worker registration failed: ", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fcmToken]);
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PushNotificationLayout;
|
||||||
41
components/Layout/ScrollToTopButton.jsx
Normal file
41
components/Layout/ScrollToTopButton.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { IoIosArrowUp } from "react-icons/io";
|
||||||
|
|
||||||
|
const ScrollToTopButton = () => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const toggleVisibility = () => {
|
||||||
|
if (window.pageYOffset > 300) {
|
||||||
|
setIsVisible(true);
|
||||||
|
} else {
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", toggleVisibility);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", toggleVisibility);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={scrollToTop}
|
||||||
|
className={cn(
|
||||||
|
"fixed bottom-7 right-7 bg-primary text-white rounded z-[1000] p-2 flex items-center justify-center size-12",
|
||||||
|
isVisible ? "flex" : "hidden"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IoIosArrowUp size={22} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScrollToTopButton;
|
||||||
14
components/Layout/StructuredData.jsx
Normal file
14
components/Layout/StructuredData.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const StructuredData = ({ data }) => {
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StructuredData;
|
||||||
100
components/Layout/useClientLayoutLogic.jsx
Normal file
100
components/Layout/useClientLayoutLogic.jsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { settingsApi } from "@/utils/api";
|
||||||
|
import {
|
||||||
|
settingsSucess,
|
||||||
|
getIsMaintenanceMode,
|
||||||
|
} from "@/redux/reducer/settingSlice";
|
||||||
|
import {
|
||||||
|
getKilometerRange,
|
||||||
|
setKilometerRange,
|
||||||
|
setIsBrowserSupported,
|
||||||
|
} from "@/redux/reducer/locationSlice";
|
||||||
|
import { getIsVisitedLandingPage } from "@/redux/reducer/globalStateSlice";
|
||||||
|
import { getCurrentLangCode, getIsRtl } from "@/redux/reducer/languageSlice";
|
||||||
|
import {
|
||||||
|
getHasFetchedSystemSettings,
|
||||||
|
setHasFetchedSystemSettings,
|
||||||
|
} from "@/utils/getFetcherStatus";
|
||||||
|
import { useNavigate } from "../Common/useNavigate";
|
||||||
|
|
||||||
|
export function useClientLayoutLogic() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { navigate } = useNavigate();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const currentLangCode = useSelector(getCurrentLangCode);
|
||||||
|
const isMaintenanceMode = useSelector(getIsMaintenanceMode);
|
||||||
|
const isRtl = useSelector(getIsRtl);
|
||||||
|
const appliedRange = useSelector(getKilometerRange);
|
||||||
|
const isVisitedLandingPage = useSelector(getIsVisitedLandingPage);
|
||||||
|
const [isRedirectToLanding, setIsRedirectToLanding] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getSystemSettings = async () => {
|
||||||
|
if (getHasFetchedSystemSettings()) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Get settings from API
|
||||||
|
const response = await settingsApi.getSettings();
|
||||||
|
const data = response?.data;
|
||||||
|
dispatch(settingsSucess({ data }));
|
||||||
|
|
||||||
|
// Set kilometer range from settings API
|
||||||
|
const min = Number(data?.data?.min_length);
|
||||||
|
const max = Number(data?.data?.max_length);
|
||||||
|
if (appliedRange < min) dispatch(setKilometerRange(min));
|
||||||
|
else if (appliedRange > max) dispatch(setKilometerRange(max));
|
||||||
|
|
||||||
|
// Set primary color from settings API
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--primary",
|
||||||
|
data?.data?.web_theme_color
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set favicon from settings API
|
||||||
|
if (data?.data?.favicon_icon) {
|
||||||
|
const favicon =
|
||||||
|
document.querySelector('link[rel="icon"]') ||
|
||||||
|
document.createElement("link");
|
||||||
|
favicon.rel = "icon";
|
||||||
|
favicon.href = data.data.favicon_icon;
|
||||||
|
if (!document.querySelector('link[rel="icon"]')) {
|
||||||
|
document.head.appendChild(favicon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasFetchedSystemSettings(true);
|
||||||
|
// Check if landing page is enabled and redirect to landing page if not visited
|
||||||
|
const showLandingPage = Number(data?.data?.show_landing_page) === 1;
|
||||||
|
if (showLandingPage && !isVisitedLandingPage) {
|
||||||
|
setIsRedirectToLanding(true);
|
||||||
|
navigate("/landing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching settings:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getSystemSettings();
|
||||||
|
|
||||||
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||||
|
if (isSafari) dispatch(setIsBrowserSupported(false));
|
||||||
|
}, [currentLangCode]);
|
||||||
|
|
||||||
|
// Set direction of the document
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dir = isRtl ? "rtl" : "ltr";
|
||||||
|
}, [isRtl]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
isMaintenanceMode,
|
||||||
|
isRedirectToLanding,
|
||||||
|
};
|
||||||
|
}
|
||||||
72
components/Layout/useGetCategories.jsx
Normal file
72
components/Layout/useGetCategories.jsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import {
|
||||||
|
CategoryData,
|
||||||
|
getCatCurrentPage,
|
||||||
|
getCatLastPage,
|
||||||
|
getIsCatLoading,
|
||||||
|
getIsCatLoadMore,
|
||||||
|
setCatCurrentPage,
|
||||||
|
setCateData,
|
||||||
|
setCatLastPage,
|
||||||
|
setIsCatLoading,
|
||||||
|
setIsCatLoadMore,
|
||||||
|
} from "@/redux/reducer/categorySlice";
|
||||||
|
import { categoryApi } from "@/utils/api"; // assume you have this
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import {
|
||||||
|
getHasFetchedCategories,
|
||||||
|
setHasFetchedCategories,
|
||||||
|
} from "@/utils/getFetcherStatus";
|
||||||
|
|
||||||
|
const useGetCategories = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const cateData = useSelector(CategoryData);
|
||||||
|
const isCatLoading = useSelector(getIsCatLoading);
|
||||||
|
const isCatLoadMore = useSelector(getIsCatLoadMore);
|
||||||
|
const catLastPage = useSelector(getCatLastPage);
|
||||||
|
const catCurrentPage = useSelector(getCatCurrentPage);
|
||||||
|
|
||||||
|
const getCategories = useCallback(
|
||||||
|
async (page = 1) => {
|
||||||
|
if (page === 1 && getHasFetchedCategories()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (page === 1) {
|
||||||
|
dispatch(setIsCatLoading(true));
|
||||||
|
} else {
|
||||||
|
dispatch(setIsCatLoadMore(true));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await categoryApi.getCategory({ page });
|
||||||
|
if (res?.data?.error === false) {
|
||||||
|
const data = res?.data?.data?.data;
|
||||||
|
if (page === 1) {
|
||||||
|
dispatch(setCateData(data));
|
||||||
|
} else {
|
||||||
|
dispatch(setCateData([...cateData, ...data]));
|
||||||
|
}
|
||||||
|
dispatch(setCatCurrentPage(res?.data?.data?.current_page));
|
||||||
|
dispatch(setCatLastPage(res?.data?.data?.last_page));
|
||||||
|
setHasFetchedCategories(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
dispatch(setIsCatLoading(false));
|
||||||
|
dispatch(setIsCatLoadMore(false));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cateData, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCategories,
|
||||||
|
isCatLoading,
|
||||||
|
cateData,
|
||||||
|
isCatLoadMore,
|
||||||
|
catLastPage,
|
||||||
|
catCurrentPage,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useGetCategories;
|
||||||
87
components/Layout/useGetLocation.jsx
Normal file
87
components/Layout/useGetLocation.jsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||||
|
import { getIsPaidApi } from "@/redux/reducer/settingSlice";
|
||||||
|
import { getLocationApi } from "@/utils/api";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
const useGetLocation = () => {
|
||||||
|
const IsPaidApi = useSelector(getIsPaidApi);
|
||||||
|
const currentLangCode = useSelector(getCurrentLangCode);
|
||||||
|
|
||||||
|
const fetchLocationData = async (pos) => {
|
||||||
|
const { lat, lng } = pos;
|
||||||
|
|
||||||
|
const response = await getLocationApi.getLocation({
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
lang: IsPaidApi ? "en" : currentLangCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.data?.error !== false) {
|
||||||
|
throw new Error("Location fetch failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= GOOGLE PLACES (PAID API) ================= */
|
||||||
|
if (IsPaidApi) {
|
||||||
|
let city = "";
|
||||||
|
let state = "";
|
||||||
|
let country = "";
|
||||||
|
|
||||||
|
const results = response?.data?.data?.results || [];
|
||||||
|
|
||||||
|
results.forEach((result) => {
|
||||||
|
const getComponent = (type) =>
|
||||||
|
result.address_components.find((c) => c.types.includes(type))
|
||||||
|
?.long_name || "";
|
||||||
|
|
||||||
|
if (!city) city = getComponent("locality");
|
||||||
|
if (!state) state = getComponent("administrative_area_level_1");
|
||||||
|
if (!country) country = getComponent("country");
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat,
|
||||||
|
long: lng,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
country,
|
||||||
|
formattedAddress: [city, state, country].filter(Boolean).join(", "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= INTERNAL LOCATION API ================= */
|
||||||
|
const r = response?.data?.data;
|
||||||
|
|
||||||
|
const formattedAddress = [r?.area, r?.city, r?.state, r?.country]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const address_translated = [
|
||||||
|
r?.area_translation,
|
||||||
|
r?.city_translation,
|
||||||
|
r?.state_translation,
|
||||||
|
r?.country_translation,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat: r?.latitude,
|
||||||
|
long: r?.longitude,
|
||||||
|
city: r?.city || "",
|
||||||
|
state: r?.state || "",
|
||||||
|
country: r?.country || "",
|
||||||
|
area: r?.area || "",
|
||||||
|
areaId: r?.area_id || "",
|
||||||
|
|
||||||
|
// English (API / backend)
|
||||||
|
formattedAddress,
|
||||||
|
|
||||||
|
// Translated (UI)
|
||||||
|
address_translated,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return { fetchLocationData };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useGetLocation;
|
||||||
117
components/Location/GetLocationWithMap.jsx
Normal file
117
components/Location/GetLocationWithMap.jsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import {
|
||||||
|
getDefaultLatitude,
|
||||||
|
getDefaultLongitude,
|
||||||
|
} from "@/redux/reducer/settingSlice";
|
||||||
|
import { getCityData } from "@/redux/reducer/locationSlice";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Circle,
|
||||||
|
MapContainer,
|
||||||
|
Marker,
|
||||||
|
TileLayer,
|
||||||
|
useMapEvents,
|
||||||
|
} from "react-leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import L from "leaflet";
|
||||||
|
|
||||||
|
// Fix Leaflet default marker icon issue
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
|
||||||
|
iconUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
|
||||||
|
shadowUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
const MapClickHandler = ({ onMapClick }) => {
|
||||||
|
useMapEvents({
|
||||||
|
click: (e) => {
|
||||||
|
onMapClick(e.latlng);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GetLocationWithMap = ({ position, getLocationWithMap, KmRange }) => {
|
||||||
|
const latitude = useSelector(getDefaultLatitude);
|
||||||
|
const longitude = useSelector(getDefaultLongitude);
|
||||||
|
const globalPos = useSelector(getCityData);
|
||||||
|
const mapRef = useRef();
|
||||||
|
|
||||||
|
const placeHolderPos = {
|
||||||
|
lat: globalPos?.lat,
|
||||||
|
lng: globalPos?.long,
|
||||||
|
};
|
||||||
|
|
||||||
|
const markerLatLong =
|
||||||
|
position?.lat && position?.lng ? position : placeHolderPos;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapRef.current && markerLatLong.lat && markerLatLong.lng) {
|
||||||
|
mapRef.current.flyTo(
|
||||||
|
[markerLatLong.lat, markerLatLong.lng],
|
||||||
|
mapRef.current.getZoom()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [markerLatLong?.lat, markerLatLong?.lng]);
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
width: "100%",
|
||||||
|
height: "300px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMapClick = (latlng) => {
|
||||||
|
if (getLocationWithMap) {
|
||||||
|
getLocationWithMap({
|
||||||
|
lat: latlng.lat,
|
||||||
|
lng: latlng.lng,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
style={containerStyle}
|
||||||
|
center={[markerLatLong?.lat || latitude, markerLatLong?.lng || longitude]}
|
||||||
|
zoom={6}
|
||||||
|
ref={mapRef}
|
||||||
|
whenCreated={(mapInstance) => {
|
||||||
|
mapRef.current = mapInstance;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<MapClickHandler onMapClick={handleMapClick} />
|
||||||
|
<Marker
|
||||||
|
position={[
|
||||||
|
markerLatLong?.lat || latitude,
|
||||||
|
markerLatLong?.lng || longitude,
|
||||||
|
]}
|
||||||
|
></Marker>
|
||||||
|
<Circle
|
||||||
|
center={[
|
||||||
|
markerLatLong?.lat || latitude,
|
||||||
|
markerLatLong?.lng || longitude,
|
||||||
|
]}
|
||||||
|
radius={KmRange * 1000} // radius in meters
|
||||||
|
pathOptions={{
|
||||||
|
color: getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--primary-color")
|
||||||
|
.trim(),
|
||||||
|
fillColor: getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--primary-color")
|
||||||
|
.trim(),
|
||||||
|
fillOpacity: 0.2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GetLocationWithMap;
|
||||||
251
components/Location/LandingAdEditSearchAutocomplete.jsx
Normal file
251
components/Location/LandingAdEditSearchAutocomplete.jsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { saveCity } from "@/redux/reducer/locationSlice";
|
||||||
|
import { getIsPaidApi } from "@/redux/reducer/settingSlice";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { getLocationApi } from "@/utils/api";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { useNavigate } from "../Common/useNavigate";
|
||||||
|
import { MapPin } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
|
||||||
|
const LandingAdEditSearchAutocomplete = ({
|
||||||
|
saveOnSuggestionClick,
|
||||||
|
OnHide,
|
||||||
|
setSelectedLocation,
|
||||||
|
}) => {
|
||||||
|
const isSuggestionClick = useRef(false);
|
||||||
|
const IsPaidApi = useSelector(getIsPaidApi);
|
||||||
|
const { navigate } = useNavigate();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch] = useDebounce(search, 500);
|
||||||
|
const [autoState, setAutoState] = useState({
|
||||||
|
suggestions: [],
|
||||||
|
loading: false,
|
||||||
|
show: false,
|
||||||
|
});
|
||||||
|
const sessionTokenRef = useRef(null);
|
||||||
|
|
||||||
|
// Generate a new session token (UUID v4)
|
||||||
|
const generateSessionToken = () => {
|
||||||
|
// Use crypto.randomUUID() if available (modern browsers)
|
||||||
|
// Fallback to a simple UUID generator
|
||||||
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Fallback UUID generator
|
||||||
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
|
||||||
|
/[xy]/g,
|
||||||
|
function (c) {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch suggestions
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSuggestions = async () => {
|
||||||
|
if (isSuggestionClick.current) {
|
||||||
|
isSuggestionClick.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (debouncedSearch && debouncedSearch.length > 1) {
|
||||||
|
setAutoState((prev) => ({ ...prev, loading: true, show: true }));
|
||||||
|
try {
|
||||||
|
// Generate new session token for new search session
|
||||||
|
// Only generate if we don't have one or if search changed significantly
|
||||||
|
if (!sessionTokenRef.current) {
|
||||||
|
sessionTokenRef.current = generateSessionToken();
|
||||||
|
}
|
||||||
|
const response = await getLocationApi.getLocation({
|
||||||
|
search: debouncedSearch,
|
||||||
|
lang: "en",
|
||||||
|
// Only include sessiontoken for Google Places API (IsPaidApi)
|
||||||
|
...(IsPaidApi && { session_id: sessionTokenRef.current }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (IsPaidApi) {
|
||||||
|
const results = response?.data?.data?.predictions || [];
|
||||||
|
setAutoState({ suggestions: results, loading: false, show: true });
|
||||||
|
} else {
|
||||||
|
const results = response?.data?.data || [];
|
||||||
|
const formattedResults = results.map((result) => ({
|
||||||
|
description: [
|
||||||
|
result?.area_translation,
|
||||||
|
result?.city_translation,
|
||||||
|
result?.state_translation,
|
||||||
|
result?.country_translation,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
original: result,
|
||||||
|
}));
|
||||||
|
setAutoState({
|
||||||
|
suggestions: formattedResults,
|
||||||
|
loading: false,
|
||||||
|
show: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("error", error);
|
||||||
|
setAutoState({ suggestions: [], loading: false, show: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset session token when search is cleared
|
||||||
|
sessionTokenRef.current = null;
|
||||||
|
setAutoState({ suggestions: [], loading: false, show: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSuggestions();
|
||||||
|
}, [debouncedSearch, IsPaidApi]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleSuggestionClick = async (suggestion) => {
|
||||||
|
isSuggestionClick.current = true;
|
||||||
|
|
||||||
|
if (IsPaidApi) {
|
||||||
|
// Use the same session token from autocomplete request
|
||||||
|
// This groups autocomplete + place details into one billing session
|
||||||
|
const response = await getLocationApi.getLocation({
|
||||||
|
place_id: suggestion.place_id,
|
||||||
|
lang: "en",
|
||||||
|
// Use the same session token from autocomplete (only if it exists)
|
||||||
|
...(sessionTokenRef.current && {
|
||||||
|
session_id: sessionTokenRef.current,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response?.data?.data?.results?.[0];
|
||||||
|
const addressComponents = result.address_components || [];
|
||||||
|
|
||||||
|
const getAddressComponent = (type) => {
|
||||||
|
const component = addressComponents.find((comp) =>
|
||||||
|
comp.types.includes(type)
|
||||||
|
);
|
||||||
|
return component?.long_name || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const city = getAddressComponent("locality");
|
||||||
|
const state = getAddressComponent("administrative_area_level_1");
|
||||||
|
const country = getAddressComponent("country");
|
||||||
|
const data = {
|
||||||
|
lat: result?.geometry?.location?.lat,
|
||||||
|
long: result?.geometry?.location?.lng,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
country,
|
||||||
|
formattedAddress: suggestion?.description,
|
||||||
|
};
|
||||||
|
setSearch(suggestion?.description || "");
|
||||||
|
setAutoState({ suggestions: [], loading: false, show: false });
|
||||||
|
|
||||||
|
// Reset session token after place details request (session complete)
|
||||||
|
sessionTokenRef.current = null;
|
||||||
|
|
||||||
|
if (saveOnSuggestionClick) {
|
||||||
|
saveCity(data);
|
||||||
|
OnHide?.();
|
||||||
|
// avoid redirect if already on home page otherwise router.push triggering server side api calls
|
||||||
|
if (pathname !== "/") {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedLocation(data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const original = suggestion.original;
|
||||||
|
const data = {
|
||||||
|
lat: original?.latitude,
|
||||||
|
long: original?.longitude,
|
||||||
|
city: original?.city || "",
|
||||||
|
state: original?.state || "",
|
||||||
|
country: original?.country || "",
|
||||||
|
formattedAddress: suggestion.description || "",
|
||||||
|
area: original?.area || "",
|
||||||
|
areaId: original?.area_id || "",
|
||||||
|
};
|
||||||
|
setSearch(suggestion?.description || "");
|
||||||
|
setAutoState({ suggestions: [], loading: false, show: false });
|
||||||
|
if (saveOnSuggestionClick) {
|
||||||
|
saveCity(data);
|
||||||
|
OnHide?.();
|
||||||
|
// avoid redirect if already on home page otherwise router.push triggering server side api calls
|
||||||
|
if (pathname !== "/") {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedLocation(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Command
|
||||||
|
shouldFilter={false} // VERY IMPORTANT
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("selectLocation")}
|
||||||
|
value={search}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSearch(value);
|
||||||
|
|
||||||
|
if (!sessionTokenRef.current) {
|
||||||
|
sessionTokenRef.current = generateSessionToken();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (autoState.suggestions.length > 0) {
|
||||||
|
setAutoState((p) => ({ ...p, show: true }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-0"
|
||||||
|
wrapperClassName="border-b-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{autoState.show &&
|
||||||
|
(autoState.suggestions.length > 0 || autoState.loading) && (
|
||||||
|
<CommandList className="absolute top-full left-0 right-0 z-[1500] max-h-[220px] mt-4 overflow-y-auto rounded-lg border bg-white shadow-lg w-full">
|
||||||
|
{autoState.loading && (
|
||||||
|
<CommandEmpty>{t("loading")}</CommandEmpty>
|
||||||
|
)}
|
||||||
|
<CommandGroup>
|
||||||
|
{autoState.suggestions.map((s, idx) => (
|
||||||
|
<CommandItem
|
||||||
|
key={idx}
|
||||||
|
value={s.description}
|
||||||
|
onSelect={() => {
|
||||||
|
handleSuggestionClick(s);
|
||||||
|
setAutoState((p) => ({ ...p, show: false }));
|
||||||
|
}}
|
||||||
|
className="ltr:text-left rtl:text-right"
|
||||||
|
>
|
||||||
|
<MapPin size={16} className="flex-shrink-0" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{s.description || "Unknown"}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
)}
|
||||||
|
</Command>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LandingAdEditSearchAutocomplete;
|
||||||
43
components/Location/LocationModal.jsx
Normal file
43
components/Location/LocationModal.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { getCityData } from "@/redux/reducer/locationSlice";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import LocationSelector from "./LocationSelector";
|
||||||
|
import MapLocation from "./MapLocation";
|
||||||
|
import { getIsPaidApi } from "@/redux/reducer/settingSlice";
|
||||||
|
|
||||||
|
const LocationModal = ({ IsLocationModalOpen, setIsLocationModalOpen }) => {
|
||||||
|
const IsPaidApi = useSelector(getIsPaidApi);
|
||||||
|
const [IsMapLocation, setIsMapLocation] = useState(IsPaidApi);
|
||||||
|
const cityData = useSelector(getCityData);
|
||||||
|
const [selectedCity, setSelectedCity] = useState(cityData || "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={IsLocationModalOpen} onOpenChange={setIsLocationModalOpen}>
|
||||||
|
<DialogContent
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
className="!gap-6"
|
||||||
|
>
|
||||||
|
{IsMapLocation ? (
|
||||||
|
<MapLocation
|
||||||
|
OnHide={() => setIsLocationModalOpen(false)}
|
||||||
|
selectedCity={selectedCity}
|
||||||
|
setSelectedCity={setSelectedCity}
|
||||||
|
setIsMapLocation={setIsMapLocation}
|
||||||
|
IsPaidApi={IsPaidApi}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LocationSelector
|
||||||
|
OnHide={() => setIsLocationModalOpen(false)}
|
||||||
|
setSelectedCity={setSelectedCity}
|
||||||
|
IsMapLocation={IsMapLocation}
|
||||||
|
setIsMapLocation={setIsMapLocation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocationModal;
|
||||||
578
components/Location/LocationSelector.jsx
Normal file
578
components/Location/LocationSelector.jsx
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { IoSearch } from "react-icons/io5";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { MdArrowBack, MdOutlineKeyboardArrowRight } from "react-icons/md";
|
||||||
|
import { BiCurrentLocation } from "react-icons/bi";
|
||||||
|
import { DialogHeader, DialogTitle } from "../ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
import NoData from "../EmptyStates/NoData";
|
||||||
|
import { Loader2, SearchIcon } from "lucide-react";
|
||||||
|
import { useInView } from "react-intersection-observer";
|
||||||
|
import {
|
||||||
|
getAreasApi,
|
||||||
|
getCitiesApi,
|
||||||
|
getCoutriesApi,
|
||||||
|
getStatesApi,
|
||||||
|
} from "@/utils/api";
|
||||||
|
import {
|
||||||
|
getIsBrowserSupported,
|
||||||
|
resetCityData,
|
||||||
|
saveCity,
|
||||||
|
setKilometerRange,
|
||||||
|
} from "@/redux/reducer/locationSlice";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { getMinRange } from "@/redux/reducer/settingSlice";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
import SearchAutocomplete from "./SearchAutocomplete";
|
||||||
|
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||||
|
import { useNavigate } from "../Common/useNavigate";
|
||||||
|
import useGetLocation from "../Layout/useGetLocation";
|
||||||
|
|
||||||
|
const LocationSelector = ({ OnHide, setSelectedCity, setIsMapLocation }) => {
|
||||||
|
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { navigate } = useNavigate();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const minLength = useSelector(getMinRange);
|
||||||
|
const { ref, inView } = useInView();
|
||||||
|
const IsBrowserSupported = useSelector(getIsBrowserSupported);
|
||||||
|
|
||||||
|
const viewHistory = useRef([]);
|
||||||
|
const skipNextSearchEffect = useRef(false);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch] = useDebounce(search, 500);
|
||||||
|
const [locationStatus, setLocationStatus] = useState(null);
|
||||||
|
const [currentView, setCurrentView] = useState("countries");
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState({
|
||||||
|
country: null,
|
||||||
|
state: null,
|
||||||
|
city: null,
|
||||||
|
area: null,
|
||||||
|
});
|
||||||
|
const [locationData, setLocationData] = useState({
|
||||||
|
items: [],
|
||||||
|
currentPage: 1,
|
||||||
|
hasMore: false,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadMore: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fetchLocationData } = useGetLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipNextSearchEffect.current) {
|
||||||
|
skipNextSearchEffect.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchData(debouncedSearch);
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inView && locationData?.hasMore && !locationData?.isLoading) {
|
||||||
|
fetchData(debouncedSearch, locationData?.currentPage + 1);
|
||||||
|
}
|
||||||
|
}, [inView]);
|
||||||
|
|
||||||
|
const handleSubmitLocation = () => {
|
||||||
|
minLength > 0
|
||||||
|
? dispatch(setKilometerRange(minLength))
|
||||||
|
: dispatch(setKilometerRange(0));
|
||||||
|
// avoid redirect if already on home page otherwise router.push triggering server side api calls
|
||||||
|
if (pathname !== "/") {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async (
|
||||||
|
search = "",
|
||||||
|
page = 1,
|
||||||
|
view = currentView,
|
||||||
|
location = selectedLocation
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
setLocationData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: page === 1,
|
||||||
|
isLoadMore: page > 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
const params = { page };
|
||||||
|
if (search) {
|
||||||
|
params.search = search;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (view) {
|
||||||
|
case "countries":
|
||||||
|
response = await getCoutriesApi.getCoutries(params);
|
||||||
|
break;
|
||||||
|
case "states":
|
||||||
|
response = await getStatesApi.getStates({
|
||||||
|
...params,
|
||||||
|
country_id: location.country.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "cities":
|
||||||
|
response = await getCitiesApi.getCities({
|
||||||
|
...params,
|
||||||
|
state_id: location.state.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "areas":
|
||||||
|
response = await getAreasApi.getAreas({
|
||||||
|
...params,
|
||||||
|
city_id: location.city.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.error === false) {
|
||||||
|
const items = response.data.data.data;
|
||||||
|
|
||||||
|
// MOD: if no results and not on countries, auto-save & close
|
||||||
|
if (items.length === 0 && view !== "countries" && !search) {
|
||||||
|
switch (view) {
|
||||||
|
case "states":
|
||||||
|
saveCity({
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
country: location.country.name,
|
||||||
|
lat: location.country.latitude,
|
||||||
|
long: location.country.longitude,
|
||||||
|
formattedAddress: location.country.translated_name,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "cities":
|
||||||
|
saveCity({
|
||||||
|
city: "",
|
||||||
|
state: location.state.name,
|
||||||
|
country: location.country.name,
|
||||||
|
lat: location.state.latitude,
|
||||||
|
long: location.state.longitude,
|
||||||
|
formattedAddress: [
|
||||||
|
location?.state.translated_name,
|
||||||
|
location?.country.translated_name,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "areas":
|
||||||
|
saveCity({
|
||||||
|
city: location.city.name,
|
||||||
|
state: location.state.name,
|
||||||
|
country: location.country.name,
|
||||||
|
lat: location.city.latitude,
|
||||||
|
long: location.city.longitude,
|
||||||
|
formattedAddress: [
|
||||||
|
location?.city.translated_name,
|
||||||
|
location?.state.translated_name,
|
||||||
|
location?.country.translated_name,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmitLocation();
|
||||||
|
OnHide();
|
||||||
|
return; // stop further processing
|
||||||
|
}
|
||||||
|
setLocationData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
items:
|
||||||
|
page > 1
|
||||||
|
? [...prev.items, ...response.data.data.data]
|
||||||
|
: response.data.data.data,
|
||||||
|
hasMore:
|
||||||
|
response.data.data.current_page < response.data.data.last_page,
|
||||||
|
currentPage: response.data.data.current_page,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setLocationData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadMore: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemSelect = async (item) => {
|
||||||
|
// MOD: push current state onto history
|
||||||
|
viewHistory.current.push({
|
||||||
|
view: currentView,
|
||||||
|
location: selectedLocation,
|
||||||
|
dataState: locationData,
|
||||||
|
search: search,
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextView = "";
|
||||||
|
let newLocation = {};
|
||||||
|
|
||||||
|
switch (currentView) {
|
||||||
|
case "countries":
|
||||||
|
newLocation = {
|
||||||
|
...selectedLocation,
|
||||||
|
country: item,
|
||||||
|
state: null,
|
||||||
|
city: null,
|
||||||
|
area: null,
|
||||||
|
};
|
||||||
|
nextView = "states";
|
||||||
|
break;
|
||||||
|
case "states":
|
||||||
|
newLocation = {
|
||||||
|
...selectedLocation,
|
||||||
|
state: item,
|
||||||
|
city: null,
|
||||||
|
area: null,
|
||||||
|
};
|
||||||
|
nextView = "cities";
|
||||||
|
break;
|
||||||
|
case "cities":
|
||||||
|
newLocation = {
|
||||||
|
...selectedLocation,
|
||||||
|
city: item,
|
||||||
|
area: null,
|
||||||
|
};
|
||||||
|
nextView = "areas";
|
||||||
|
break;
|
||||||
|
case "areas":
|
||||||
|
saveCity({
|
||||||
|
country: selectedLocation?.country?.name,
|
||||||
|
state: selectedLocation?.state?.name,
|
||||||
|
city: selectedLocation?.city?.name,
|
||||||
|
area: item?.name,
|
||||||
|
areaId: item?.id,
|
||||||
|
lat: item?.latitude,
|
||||||
|
long: item?.longitude,
|
||||||
|
formattedAddress: [
|
||||||
|
item?.translated_name,
|
||||||
|
selectedLocation?.city?.translated_name,
|
||||||
|
selectedLocation?.state?.translated_name,
|
||||||
|
selectedLocation?.country?.translated_name,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
});
|
||||||
|
handleSubmitLocation();
|
||||||
|
OnHide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedLocation(newLocation);
|
||||||
|
setCurrentView(nextView);
|
||||||
|
await fetchData("", 1, nextView, newLocation);
|
||||||
|
if (search) {
|
||||||
|
skipNextSearchEffect.current = true;
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaceholderText = () => {
|
||||||
|
switch (currentView) {
|
||||||
|
case "countries":
|
||||||
|
return `${t("search")} ${t("country")}`;
|
||||||
|
case "states":
|
||||||
|
return `${t("search")} ${t("state")}`;
|
||||||
|
case "cities":
|
||||||
|
return `${t("search")} ${t("city")}`;
|
||||||
|
case "areas":
|
||||||
|
return `${t("search")} ${t("area")}`;
|
||||||
|
default:
|
||||||
|
return `${t("search")} ${t("location")}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFormattedLocation = () => {
|
||||||
|
if (!selectedLocation) return t("location");
|
||||||
|
const parts = [];
|
||||||
|
if (selectedLocation.area?.translated_name)
|
||||||
|
parts.push(selectedLocation.area.translated_name);
|
||||||
|
if (selectedLocation.city?.translated_name)
|
||||||
|
parts.push(selectedLocation.city.translated_name);
|
||||||
|
if (selectedLocation.state?.translated_name)
|
||||||
|
parts.push(selectedLocation.state.translated_name);
|
||||||
|
if (selectedLocation.country?.translated_name)
|
||||||
|
parts.push(selectedLocation.country.translated_name);
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(", ") : t("location");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = async () => {
|
||||||
|
const prev = viewHistory.current.pop();
|
||||||
|
if (!prev) return;
|
||||||
|
|
||||||
|
setCurrentView(prev.view);
|
||||||
|
setSelectedLocation(prev.location);
|
||||||
|
|
||||||
|
if (search !== prev.search) {
|
||||||
|
skipNextSearchEffect.current = true;
|
||||||
|
setSearch(prev.search);
|
||||||
|
}
|
||||||
|
if (prev.dataState) {
|
||||||
|
setLocationData(prev.dataState);
|
||||||
|
} else {
|
||||||
|
await fetchData(prev.search ?? "", 1, prev.view, prev.location);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
switch (currentView) {
|
||||||
|
case "countries":
|
||||||
|
return t("country");
|
||||||
|
case "states":
|
||||||
|
return t("state");
|
||||||
|
case "cities":
|
||||||
|
return t("city");
|
||||||
|
case "areas":
|
||||||
|
return t("area");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAllSelect = () => {
|
||||||
|
switch (currentView) {
|
||||||
|
case "countries":
|
||||||
|
resetCityData();
|
||||||
|
handleSubmitLocation();
|
||||||
|
OnHide();
|
||||||
|
break;
|
||||||
|
case "states":
|
||||||
|
saveCity({
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
country: selectedLocation?.country?.name,
|
||||||
|
lat: selectedLocation?.country?.latitude,
|
||||||
|
long: selectedLocation?.country?.longitude,
|
||||||
|
formattedAddress: selectedLocation?.country?.translated_name,
|
||||||
|
});
|
||||||
|
handleSubmitLocation();
|
||||||
|
OnHide();
|
||||||
|
break;
|
||||||
|
case "cities":
|
||||||
|
saveCity({
|
||||||
|
city: "",
|
||||||
|
state: selectedLocation?.state?.name,
|
||||||
|
country: selectedLocation?.country?.name,
|
||||||
|
lat: selectedLocation?.state?.latitude,
|
||||||
|
long: selectedLocation?.state?.longitude,
|
||||||
|
formattedAddress: [
|
||||||
|
selectedLocation?.state?.translated_name,
|
||||||
|
selectedLocation?.country?.translated_name,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
});
|
||||||
|
handleSubmitLocation();
|
||||||
|
OnHide();
|
||||||
|
break;
|
||||||
|
case "areas":
|
||||||
|
saveCity({
|
||||||
|
city: selectedLocation?.city?.name,
|
||||||
|
state: selectedLocation?.state?.name,
|
||||||
|
country: selectedLocation?.country?.name,
|
||||||
|
lat: selectedLocation?.city?.latitude,
|
||||||
|
long: selectedLocation?.city?.longitude,
|
||||||
|
formattedAddress: [
|
||||||
|
selectedLocation?.city?.translated_name,
|
||||||
|
selectedLocation?.state?.translated_name,
|
||||||
|
selectedLocation?.country?.translated_name,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
});
|
||||||
|
handleSubmitLocation();
|
||||||
|
OnHide();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllButtonTitle = () => {
|
||||||
|
switch (currentView) {
|
||||||
|
case "countries":
|
||||||
|
return t("allCountries");
|
||||||
|
case "states":
|
||||||
|
return `${t("allIn")} ${selectedLocation.country?.translated_name}`;
|
||||||
|
case "cities":
|
||||||
|
return `${t("allIn")} ${selectedLocation.state?.translated_name}`;
|
||||||
|
case "areas":
|
||||||
|
return `${t("allIn")} ${selectedLocation.city?.translated_name}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentLocationUsingFreeApi = async (latitude, longitude) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchLocationData({ lat: latitude, lng: longitude });
|
||||||
|
setSelectedCity(data);
|
||||||
|
setIsMapLocation(true);
|
||||||
|
setLocationStatus(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
setLocationStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentLocation = () => {
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
setLocationStatus("fetching");
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
async (position) => {
|
||||||
|
const { latitude, longitude } = position.coords;
|
||||||
|
await getCurrentLocationUsingFreeApi(latitude, longitude);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error("Geolocation error:", error);
|
||||||
|
if (error.code === error.PERMISSION_DENIED) {
|
||||||
|
setLocationStatus("denied");
|
||||||
|
} else {
|
||||||
|
setLocationStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(t("geoLocationNotSupported"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-xl ltr:text-left rtl:text-right">
|
||||||
|
{currentView !== "countries" && (
|
||||||
|
<button onClick={handleBack}>
|
||||||
|
<MdArrowBack size={20} className="rtl:scale-x-[-1]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{getFormattedLocation()}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
||||||
|
{currentView === "countries" ? (
|
||||||
|
<div className="flex items-center gap-2 border rounded-sm relative">
|
||||||
|
<SearchAutocomplete saveOnSuggestionClick={true} OnHide={OnHide} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 border rounded-sm relative p-3 ltr:pl-9 rtl:pr-9">
|
||||||
|
<SearchIcon className="size-4 shrink-0 absolute left-3 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full outline-none text-sm"
|
||||||
|
placeholder={getPlaceholderText()}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div >
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Location Wrapper */}
|
||||||
|
|
||||||
|
{
|
||||||
|
IsBrowserSupported && (
|
||||||
|
<div className="flex items-center gap-2 border rounded p-2 px-4">
|
||||||
|
<BiCurrentLocation className="text-primary size-5 shrink-0" />
|
||||||
|
<button
|
||||||
|
className="flex flex-col items-start ltr:text-left rtl:text-right"
|
||||||
|
disabled={false}
|
||||||
|
onClick={getCurrentLocation}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-primary">
|
||||||
|
{t("useCurrentLocation")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-normal m-0 text-gray-500">
|
||||||
|
{locationStatus === "fetching"
|
||||||
|
? t("gettingLocation")
|
||||||
|
: locationStatus === "denied"
|
||||||
|
? t("locationPermissionDenied")
|
||||||
|
: locationStatus === "error"
|
||||||
|
? t("error")
|
||||||
|
: t("automaticallyDetectLocation")}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<div className="border border-sm rounded-sm">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 p-3 text-sm font-medium justify-between w-full border-b ltr:text-left rtl:text-right"
|
||||||
|
onClick={handleAllSelect}
|
||||||
|
>
|
||||||
|
<span>{getAllButtonTitle()}</span>
|
||||||
|
<div className="bg-muted rounded-sm">
|
||||||
|
<MdOutlineKeyboardArrowRight
|
||||||
|
size={20}
|
||||||
|
className="rtl:scale-x-[-1]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="overflow-y-auto h-[300px]">
|
||||||
|
{locationData.isLoading ? (
|
||||||
|
<PlacesSkeleton />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{locationData.items.length > 0 ? (
|
||||||
|
locationData.items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex items-center ltr:text-left rtl:text-right gap-1 p-3 text-sm font-medium justify-between w-full",
|
||||||
|
index !== locationData.length - 1 && "border-b"
|
||||||
|
)}
|
||||||
|
onClick={() => handleItemSelect(item)}
|
||||||
|
key={item?.id}
|
||||||
|
ref={
|
||||||
|
index === locationData.items.length - 1 &&
|
||||||
|
locationData.hasMore
|
||||||
|
? ref
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{item?.translated_name || item?.name}</span>
|
||||||
|
<div className="bg-muted rounded-sm">
|
||||||
|
<MdOutlineKeyboardArrowRight
|
||||||
|
size={20}
|
||||||
|
className="rtl:scale-x-[-1]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<NoData name={getTitle()} />
|
||||||
|
)}
|
||||||
|
{locationData.isLoadMore && (
|
||||||
|
<div className="p-4 flex justify-center">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocationSelector;
|
||||||
|
|
||||||
|
const PlacesSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-3 p-3">
|
||||||
|
<Skeleton className="h-4 w-4 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-[85%]" />
|
||||||
|
<Skeleton className="h-3 w-[60%]" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-5 w-5 rounded-sm" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
components/Location/Map.jsx
Normal file
61
components/Location/Map.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import L from "leaflet";
|
||||||
|
import { MapContainer, Marker, TileLayer } from "react-leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// Fix Leaflet default marker icon issue
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
|
||||||
|
iconUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
|
||||||
|
shadowUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
const Map = ({ latitude, longitude }) => {
|
||||||
|
const containerStyle = {
|
||||||
|
width: "100%",
|
||||||
|
height: "200px",
|
||||||
|
zIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate latitude and longitude
|
||||||
|
const lat = parseFloat(latitude);
|
||||||
|
const lng = parseFloat(longitude);
|
||||||
|
|
||||||
|
// Check if coordinates are valid numbers and within valid ranges
|
||||||
|
const isValidLat = !isNaN(lat) && lat >= -90 && lat <= 90;
|
||||||
|
const isValidLng = !isNaN(lng) && lng >= -180 && lng <= 180;
|
||||||
|
|
||||||
|
// Use default coordinates if invalid (you can change these to your preferred default location)
|
||||||
|
const center = [isValidLat ? lat : 0, isValidLng ? lng : 0];
|
||||||
|
|
||||||
|
// Don't render marker if coordinates are invalid
|
||||||
|
const shouldShowMarker = isValidLat && isValidLng;
|
||||||
|
|
||||||
|
useEffect(() => {}, [shouldShowMarker]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
shouldShowMarker && (
|
||||||
|
<MapContainer
|
||||||
|
style={containerStyle}
|
||||||
|
center={center}
|
||||||
|
zoom={10}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
zoomControl={false}
|
||||||
|
attributionControl={false}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<Marker position={center} />
|
||||||
|
</MapContainer>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Map;
|
||||||
193
components/Location/MapLocation.jsx
Normal file
193
components/Location/MapLocation.jsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { DialogTitle } from "@radix-ui/react-dialog";
|
||||||
|
import { DialogFooter, DialogHeader } from "../ui/dialog";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { MdArrowBack } from "react-icons/md";
|
||||||
|
import SearchAutocomplete from "./SearchAutocomplete";
|
||||||
|
import { BiCurrentLocation } from "react-icons/bi";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { getMaxRange, getMinRange } from "@/redux/reducer/settingSlice";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import {
|
||||||
|
getIsBrowserSupported,
|
||||||
|
getKilometerRange,
|
||||||
|
resetCityData,
|
||||||
|
saveCity,
|
||||||
|
setKilometerRange,
|
||||||
|
} from "@/redux/reducer/locationSlice";
|
||||||
|
import { Slider } from "../ui/slider";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { CurrentLanguageData, getIsRtl } from "@/redux/reducer/languageSlice";
|
||||||
|
import { useNavigate } from "../Common/useNavigate";
|
||||||
|
import useGetLocation from "../Layout/useGetLocation";
|
||||||
|
|
||||||
|
const GetLocationWithMap = dynamic(() => import("./GetLocationWithMap"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="w-full h-[300px] bg-gray-100 rounded-lg" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MapLocation = ({
|
||||||
|
OnHide,
|
||||||
|
selectedCity,
|
||||||
|
setSelectedCity,
|
||||||
|
setIsMapLocation,
|
||||||
|
IsPaidApi,
|
||||||
|
}) => {
|
||||||
|
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { navigate } = useNavigate();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const radius = useSelector(getKilometerRange);
|
||||||
|
const [KmRange, setKmRange] = useState(radius || 0);
|
||||||
|
const [IsFetchingLocation, setIsFetchingLocation] = useState(false);
|
||||||
|
const min_range = useSelector(getMinRange);
|
||||||
|
const max_range = useSelector(getMaxRange);
|
||||||
|
const IsBrowserSupported = useSelector(getIsBrowserSupported);
|
||||||
|
const isRTL = useSelector(getIsRtl);
|
||||||
|
const { fetchLocationData } = useGetLocation();
|
||||||
|
|
||||||
|
const getCurrentLocation = async () => {
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
setIsFetchingLocation(true);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
async (position) => {
|
||||||
|
try {
|
||||||
|
const { latitude, longitude } = position.coords;
|
||||||
|
const data = await fetchLocationData({ lat: latitude, lng: longitude });
|
||||||
|
setSelectedCity(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching location data:", error);
|
||||||
|
toast.error(t("errorOccurred"));
|
||||||
|
} finally {
|
||||||
|
setIsFetchingLocation(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log(error);
|
||||||
|
toast.error(t("locationNotGranted"));
|
||||||
|
setIsFetchingLocation(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(t("geoLocationNotSupported"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocationWithMap = async (pos) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchLocationData(pos);
|
||||||
|
setSelectedCity(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching location data:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const isInvalidLocation = !selectedCity?.lat || !selectedCity?.long;
|
||||||
|
if (isInvalidLocation) {
|
||||||
|
toast.error(t("pleaseSelectLocation"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch(setKilometerRange(KmRange));
|
||||||
|
saveCity(selectedCity);
|
||||||
|
toast.success(t("locationSaved"));
|
||||||
|
OnHide();
|
||||||
|
// avoid redirect if already on home page otherwise router.push triggering server side api calls
|
||||||
|
if (pathname !== "/") {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
resetCityData();
|
||||||
|
min_range > 0
|
||||||
|
? dispatch(setKilometerRange(min_range))
|
||||||
|
: dispatch(setKilometerRange(0));
|
||||||
|
OnHide();
|
||||||
|
// avoid redirect if already on home page otherwise router.push triggering server side api calls
|
||||||
|
if (pathname !== "/") {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 font-semibold text-xl">
|
||||||
|
{!IsPaidApi && (
|
||||||
|
<button onClick={() => setIsMapLocation(false)}>
|
||||||
|
<MdArrowBack size={20} className="rtl:scale-x-[-1]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCity?.address_translated ||
|
||||||
|
selectedCity?.formattedAddress ? (
|
||||||
|
<p>
|
||||||
|
{selectedCity?.address_translated ||
|
||||||
|
selectedCity?.formattedAddress}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
t("addYourAddress")
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center border rounded-md">
|
||||||
|
<div className="flex-[3] sm:flex-[2]">
|
||||||
|
<SearchAutocomplete
|
||||||
|
saveOnSuggestionClick={false}
|
||||||
|
OnHide={OnHide}
|
||||||
|
setSelectedLocation={setSelectedCity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{IsBrowserSupported && (
|
||||||
|
<>
|
||||||
|
<div className="border-r h-full" />
|
||||||
|
<button
|
||||||
|
className="flex-1 flex items-center justify-center"
|
||||||
|
onClick={getCurrentLocation}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 py-2 px-4">
|
||||||
|
<BiCurrentLocation className="size-5 shrink-0" />
|
||||||
|
<span className="text-sm text-balance hidden sm:inline">
|
||||||
|
{IsFetchingLocation
|
||||||
|
? t("gettingLocation")
|
||||||
|
: t("currentLocation")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<GetLocationWithMap
|
||||||
|
KmRange={KmRange}
|
||||||
|
position={{ lat: selectedCity?.lat, lng: selectedCity?.long }}
|
||||||
|
getLocationWithMap={getLocationWithMap}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2 justify-between">
|
||||||
|
<span>{t("rangeLabel")}</span>
|
||||||
|
<span>{KmRange} KM</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[KmRange]}
|
||||||
|
onValueChange={(value) => setKmRange(value[0])}
|
||||||
|
max={max_range}
|
||||||
|
min={min_range}
|
||||||
|
step={1}
|
||||||
|
dir={isRTL ? "rtl" : "ltr"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleReset}>
|
||||||
|
{t("reset")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{t("save")}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MapLocation;
|
||||||
248
components/Location/SearchAutocomplete.jsx
Normal file
248
components/Location/SearchAutocomplete.jsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { saveCity } from "@/redux/reducer/locationSlice";
|
||||||
|
import { getIsPaidApi } from "@/redux/reducer/settingSlice";
|
||||||
|
import { t } from "@/utils";
|
||||||
|
import { getLocationApi } from "@/utils/api";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { useNavigate } from "../Common/useNavigate";
|
||||||
|
import { MapPin } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
|
||||||
|
const SearchAutocomplete = ({
|
||||||
|
saveOnSuggestionClick,
|
||||||
|
OnHide,
|
||||||
|
setSelectedLocation,
|
||||||
|
}) => {
|
||||||
|
const isSuggestionClick = useRef(false);
|
||||||
|
const IsPaidApi = useSelector(getIsPaidApi);
|
||||||
|
const { navigate } = useNavigate();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch] = useDebounce(search, 500);
|
||||||
|
const [autoState, setAutoState] = useState({
|
||||||
|
suggestions: [],
|
||||||
|
loading: false,
|
||||||
|
show: false,
|
||||||
|
});
|
||||||
|
const sessionTokenRef = useRef(null);
|
||||||
|
|
||||||
|
// Generate a new session token (UUID v4)
|
||||||
|
const generateSessionToken = () => {
|
||||||
|
// Use crypto.randomUUID() if available (modern browsers)
|
||||||
|
// Fallback to a simple UUID generator
|
||||||
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Fallback UUID generator
|
||||||
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
|
||||||
|
/[xy]/g,
|
||||||
|
function (c) {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch suggestions
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSuggestions = async () => {
|
||||||
|
if (isSuggestionClick.current) {
|
||||||
|
isSuggestionClick.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (debouncedSearch && debouncedSearch.length > 1) {
|
||||||
|
setAutoState((prev) => ({ ...prev, loading: true, show: true }));
|
||||||
|
try {
|
||||||
|
// Generate new session token for new search session
|
||||||
|
// Only generate if we don't have one or if search changed significantly
|
||||||
|
if (!sessionTokenRef.current) {
|
||||||
|
sessionTokenRef.current = generateSessionToken();
|
||||||
|
}
|
||||||
|
const response = await getLocationApi.getLocation({
|
||||||
|
search: debouncedSearch,
|
||||||
|
lang: "en",
|
||||||
|
// Only include sessiontoken for Google Places API (IsPaidApi)
|
||||||
|
...(IsPaidApi && { session_id: sessionTokenRef.current }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (IsPaidApi) {
|
||||||
|
const results = response?.data?.data?.predictions || [];
|
||||||
|
setAutoState({ suggestions: results, loading: false, show: true });
|
||||||
|
} else {
|
||||||
|
const results = response?.data?.data || [];
|
||||||
|
const formattedResults = results.map((result) => ({
|
||||||
|
description: [
|
||||||
|
result?.area_translation,
|
||||||
|
result?.city_translation,
|
||||||
|
result?.state_translation,
|
||||||
|
result?.country_translation,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
original: result,
|
||||||
|
}));
|
||||||
|
setAutoState({
|
||||||
|
suggestions: formattedResults,
|
||||||
|
loading: false,
|
||||||
|
show: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("error", error);
|
||||||
|
setAutoState({ suggestions: [], loading: false, show: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset session token when search is cleared
|
||||||
|
sessionTokenRef.current = null;
|
||||||
|
setAutoState({ suggestions: [], loading: false, show: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSuggestions();
|
||||||
|
}, [debouncedSearch, IsPaidApi]);
|
||||||
|
|
||||||
|
const handleSuggestionClick = async (suggestion) => {
|
||||||
|
isSuggestionClick.current = true;
|
||||||
|
|
||||||
|
if (IsPaidApi) {
|
||||||
|
// Use the same session token from autocomplete request
|
||||||
|
// This groups autocomplete + place details into one billing session
|
||||||
|
const response = await getLocationApi.getLocation({
|
||||||
|
place_id: suggestion.place_id,
|
||||||
|
lang: "en",
|
||||||
|
// Use the same session token from autocomplete (only if it exists)
|
||||||
|
...(sessionTokenRef.current && {
|
||||||
|
session_id: sessionTokenRef.current,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response?.data?.data?.results?.[0];
|
||||||
|
const addressComponents = result.address_components || [];
|
||||||
|
|
||||||
|
const getAddressComponent = (type) => {
|
||||||
|
const component = addressComponents.find((comp) =>
|
||||||
|
comp.types.includes(type)
|
||||||
|
);
|
||||||
|
return component?.long_name || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const city = getAddressComponent("locality");
|
||||||
|
const state = getAddressComponent("administrative_area_level_1");
|
||||||
|
const country = getAddressComponent("country");
|
||||||
|
const data = {
|
||||||
|
lat: result?.geometry?.location?.lat,
|
||||||
|
long: result?.geometry?.location?.lng,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
country,
|
||||||
|
formattedAddress: suggestion?.description,
|
||||||
|
};
|
||||||
|
setSearch(suggestion?.description || "");
|
||||||
|
setAutoState({ suggestions: [], loading: false, show: false });
|
||||||
|
|
||||||
|
// Reset session token after place details request (session complete)
|
||||||
|
sessionTokenRef.current = null;
|
||||||
|
|
||||||
|
if (saveOnSuggestionClick) {
|
||||||
|
saveCity(data);
|
||||||
|
OnHide?.();
|
||||||
|
// avoid redirect if already on home page otherwise router.push triggering server side api calls
|
||||||
|
if (pathname !== "/") {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedLocation(data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const original = suggestion.original;
|
||||||
|
const data = {
|
||||||
|
lat: original?.latitude,
|
||||||
|
long: original?.longitude,
|
||||||
|
city: original?.city || "",
|
||||||
|
state: original?.state || "",
|
||||||
|
country: original?.country || "",
|
||||||
|
formattedAddress: suggestion.description || "",
|
||||||
|
area: original?.area || "",
|
||||||
|
areaId: original?.area_id || "",
|
||||||
|
};
|
||||||
|
setSearch(suggestion?.description || "");
|
||||||
|
setAutoState({ suggestions: [], loading: false, show: false });
|
||||||
|
if (saveOnSuggestionClick) {
|
||||||
|
saveCity(data);
|
||||||
|
OnHide?.();
|
||||||
|
// avoid redirect if already on home page otherwise router.push triggering server side api calls
|
||||||
|
if (pathname !== "/") {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedLocation(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Command
|
||||||
|
shouldFilter={false} // VERY IMPORTANT
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("selectLocation")}
|
||||||
|
value={search}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSearch(value);
|
||||||
|
|
||||||
|
if (!sessionTokenRef.current) {
|
||||||
|
sessionTokenRef.current = generateSessionToken();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (autoState.suggestions.length > 0) {
|
||||||
|
setAutoState((p) => ({ ...p, show: true }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
wrapperClassName="border-b-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{autoState.show &&
|
||||||
|
(autoState.suggestions.length > 0 || autoState.loading) && (
|
||||||
|
<CommandList className="absolute top-full left-0 right-0 z-[1500] max-h-[220px] overflow-y-auto rounded-lg border bg-white shadow-lg">
|
||||||
|
{autoState.loading && (
|
||||||
|
<CommandEmpty>{t("loading")}</CommandEmpty>
|
||||||
|
)}
|
||||||
|
<CommandGroup>
|
||||||
|
{autoState.suggestions.map((s, idx) => (
|
||||||
|
<CommandItem
|
||||||
|
key={idx}
|
||||||
|
value={s.description}
|
||||||
|
onSelect={() => {
|
||||||
|
handleSuggestionClick(s);
|
||||||
|
setAutoState((p) => ({ ...p, show: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapPin size={16} className="flex-shrink-0" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{s.description || "Unknown"}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
)}
|
||||||
|
</Command>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchAutocomplete;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user