classify web

This commit is contained in:
Husanjonazamov
2026-02-24 12:52:49 +05:00
commit 64af77101f
310 changed files with 45449 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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='&copy; <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;

View 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

View 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

View 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;

View 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)}&lrm;
</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;

View 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;

View 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)}&lrm;
</p>
</div>
</div>
</CustomLink>
);
};
export default ProductHorizontalCard;

View 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;

View 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;

View 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;

View 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;

View 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 };
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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>
);
}

View 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;

View 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;

View 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;

View 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,
};
}

View 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;

View 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;

View 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='&copy; <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;

View 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;

View 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;

View 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>
);
};

View 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='&copy; <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;

View 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;

View 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;

View File

@@ -0,0 +1,817 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import Filter from "../../Filter/Filter";
import {
allItemApi,
FeaturedSectionApi,
getCustomFieldsApi,
getParentCategoriesApi,
getSeoSettingsApi,
} from "@/utils/api";
import ProductCard from "@/components/Common/ProductCard";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TbTransferVertical } from "react-icons/tb";
import ProductHorizontalCard from "@/components/Common/ProductHorizontalCard";
import ProductCardSkeleton from "@/components/Common/ProductCardSkeleton";
import ProductHorizontalCardSkeleton from "@/components/Common/ProductHorizontalCardSkeleton";
import NoData from "@/components/EmptyStates/NoData";
import { IoGrid } from "react-icons/io5";
import { CiGrid2H } from "react-icons/ci";
import { Badge } from "@/components/ui/badge";
import { IoIosCloseCircle } from "react-icons/io";
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
import Layout from "@/components/Layout/Layout";
import { Button } from "@/components/ui/button";
import { useDispatch, useSelector } from "react-redux";
import {
BreadcrumbPathData,
setBreadcrumbPath,
} from "@/redux/reducer/breadCrumbSlice";
import { seoData, t } from "@/utils";
import { getSelectedLocation } from "@/redux/reducer/globalStateSlice";
import { generateKeywords } from "@/utils/generateKeywords";
const Ads = () => {
const dispatch = useDispatch();
const searchParams = useSearchParams();
const newSearchParams = new URLSearchParams(searchParams);
const BreadcrumbPath = useSelector(BreadcrumbPathData);
const [view, setView] = useState("grid");
const [advertisements, setAdvertisements] = useState({
data: [],
currentPage: 1,
hasMore: false,
isLoading: false,
isLoadMore: false,
});
const [featuredTitle, setFeaturedTitle] = useState("");
const selectedLocation = useSelector(getSelectedLocation);
const query = searchParams.get("query") || "";
const slug = searchParams.get("category") || "";
const country = searchParams.get("country") || "";
const state = searchParams.get("state") || "";
const city = searchParams.get("city") || "";
const area = searchParams.get("area") || "";
const areaId = Number(searchParams.get("areaId")) || "";
const lat = Number(searchParams.get("lat"));
const lng = Number(searchParams.get("lng"));
const min_price = searchParams.get("min_price")
? Number(searchParams.get("min_price"))
: "";
const max_price = searchParams.get("max_price")
? Number(searchParams.get("max_price"))
: "";
const date_posted = searchParams.get("date_posted") || "";
const km_range = searchParams.get("km_range") || "";
const sortBy = searchParams.get("sort_by") || "new-to-old";
const langCode = searchParams.get("lang");
const featured_section = searchParams.get("featured_section") || "";
const isMinPrice =
min_price !== "" &&
min_price !== null &&
min_price !== undefined &&
min_price >= 0;
const knownParams = [
"country",
"state",
"city",
"area",
"areaId",
"lat",
"lng",
"min_price",
"max_price",
"date_posted",
"km_range",
"sort_by",
"category",
"query",
"lang",
"featured_section",
];
const title = useMemo(() => {
if (BreadcrumbPath.length === 2) {
return BreadcrumbPath[1]?.name;
}
if (BreadcrumbPath.length > 2) {
const last = BreadcrumbPath[BreadcrumbPath.length - 1]?.name;
const secondLast = BreadcrumbPath[BreadcrumbPath.length - 2]?.name;
return `${last} ${t("in")} ${secondLast}`;
}
return t("ads");
}, [BreadcrumbPath, t]);
const category =
BreadcrumbPath.length > 1 &&
BreadcrumbPath[BreadcrumbPath.length - 1]?.name;
const [customFields, setCustomFields] = useState([]);
const initialExtraDetails = useMemo(() => {
const temprorayExtraDet = {};
Array.from(searchParams.entries() || []).forEach(([key, value]) => {
if (!knownParams?.includes(key)) {
temprorayExtraDet[key] = value?.includes(",")
? value?.split(",")
: value;
}
});
return temprorayExtraDet;
}, [
JSON.stringify(
Array.from(searchParams.entries()).filter(
([key]) => !knownParams.includes(key)
)
),
]);
const [extraDetails, setExtraDetails] = useState(initialExtraDetails);
// Count active filters
const getActiveFilterCount = () => {
let count = 0;
// Location filter
if (country || state || city || areaId) count++;
// KM Range filter
if (km_range) count++;
if (category) count++;
if (featured_section) count++;
// Query filter
if (query) count++;
// Date Posted filter
if (date_posted) count++;
// Price Range filter
if (isMinPrice && max_price) count++;
// Extra Details filters
if (initialExtraDetails && Object.keys(initialExtraDetails).length > 0) {
count += Object.keys(initialExtraDetails).length;
}
return count;
};
const activeFilterCount = getActiveFilterCount();
useEffect(() => {
const fetchFeaturedSectionData = async () => {
try {
const response = await FeaturedSectionApi.getFeaturedSections({
slug: featured_section,
});
if (response?.data?.error === false) {
setFeaturedTitle(
response?.data?.data?.[0]?.translated_name ||
response?.data?.data?.[0]?.title
);
} else {
console.error(response?.data?.message);
}
} catch (error) {
console.error("Error:", error);
}
};
if (featured_section) {
fetchFeaturedSectionData();
}
}, [langCode, featured_section]);
useEffect(() => {
if (slug) {
constructBreadcrumbPath();
} else {
dispatch(
setBreadcrumbPath([
{
name: t("allCategories"),
key: "all-categories",
slug: "/ads",
isAllCategories: true,
},
])
);
setCustomFields([]);
setExtraDetails({});
const fetchSeoSettings = async () => {
const res = await getSeoSettingsApi.getSeoSettings({
page: "ad-listing",
});
const data = res.data.data[0];
const title =
data?.translated_title ||
data?.title ||
process.env.NEXT_PUBLIC_META_TITLE;
const description =
data?.translated_description ||
process.env.NEXT_PUBLIC_META_DESCRIPTION;
const keywords =
data?.translated_keywords || process.env.NEXT_PUBLIC_META_kEYWORDS;
const image = data?.image || "";
const canonicalUrl = process.env.NEXT_PUBLIC_WEB_URL;
seoData({ title, description, keywords, image, canonicalUrl });
};
fetchSeoSettings();
}
}, [slug, langCode]);
const getCustomFieldsData = async (categoryIds) => {
try {
const res = await getCustomFieldsApi.getCustomFields({
category_ids: categoryIds,
filter: true,
});
const data = res?.data?.data;
setCustomFields(data);
const isShowCustomfieldFilter =
data.length > 0 &&
data.some(
(field) =>
field.type === "checkbox" ||
field.type === "radio" ||
field.type === "dropdown"
);
if (isShowCustomfieldFilter) {
const initialExtraDetails = {};
data.forEach((field) => {
const value = searchParams.get(field.id);
if (value) {
initialExtraDetails[field.id] =
field.type === "checkbox" ? value.split(",") : value;
}
});
setExtraDetails(initialExtraDetails);
} else {
setExtraDetails({});
}
} catch (error) {
console.log(error);
}
};
const constructBreadcrumbPath = async () => {
try {
const res = await getParentCategoriesApi.getPaymentCategories({
slug,
tree: 0,
});
const data = res?.data?.data || [];
const selectedCategory = data?.at(-1);
if (selectedCategory) {
seoData({
title:
selectedCategory?.translated_name || process.env.NEXT_PUBLIC_META_TITLE,
description:
selectedCategory?.translated_description ||
process.env.NEXT_PUBLIC_META_DESCRIPTION,
keywords:
generateKeywords(selectedCategory?.translated_description) ||
process.env.NEXT_PUBLIC_META_kEYWORDS,
image: selectedCategory?.image,
canonicalUrl: `${process.env.NEXT_PUBLIC_WEB_URL}?category=${selectedCategory?.slug}&lang=${langCode}`,
});
}
const breadcrumbArray = [
{
name: t("allCategories"),
key: "all-categories",
slug: "/ads",
isAllCategories: true,
},
...data.map((item) => ({
name: item.translated_name,
key: item.slug,
slug: `/ads?category=${item.slug}`,
})),
];
dispatch(setBreadcrumbPath(breadcrumbArray));
const categoryIds = data.map((category) => category.id).join(",");
await getCustomFieldsData(categoryIds);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
getSingleCatItem(1);
}, [
lat,
lng,
areaId,
city,
state,
country,
min_price,
max_price,
date_posted,
km_range,
sortBy,
initialExtraDetails,
slug,
query,
langCode,
featured_section,
]);
const getSingleCatItem = async (page) => {
try {
const parameters = { page, limit: 12 };
if (sortBy) parameters.sort_by = sortBy;
if (isMinPrice) parameters.min_price = min_price;
if (max_price) parameters.max_price = max_price;
if (date_posted) parameters.posted_since = date_posted;
if (slug) parameters.category_slug = slug;
if (extraDetails) parameters.custom_fields = extraDetails;
if (featured_section) parameters.featured_section_slug = featured_section;
if (Number(km_range) > 0) {
parameters.latitude = lat;
parameters.longitude = lng;
parameters.radius = km_range;
} else {
if (areaId) {
parameters.area_id = areaId;
} else if (city) {
parameters.city = city;
} else if (state) {
parameters.state = state;
} else if (country) {
parameters.country = country;
}
}
if (query) {
parameters.search = query;
}
page === 1
? setAdvertisements((prev) => ({ ...prev, isLoading: true }))
: setAdvertisements((prev) => ({ ...prev, isLoadMore: true }));
const res = await allItemApi.getItems(parameters);
const data = res?.data;
if (data.error === false) {
page > 1
? setAdvertisements((prev) => ({
...prev,
data: [...prev.data, ...data?.data?.data],
currentPage: data?.data?.current_page,
hasMore: data?.data?.last_page > data?.data?.current_page,
}))
: setAdvertisements((prev) => ({
...prev,
data: data?.data?.data,
currentPage: data?.data?.current_page,
hasMore: data?.data?.last_page > data?.data?.current_page,
}));
}
} catch (error) {
console.log(error);
} finally {
setAdvertisements((prev) => ({
...prev,
isLoading: false,
isLoadMore: false,
}));
}
};
const handleProdLoadMore = async () => {
setAdvertisements((prev) => ({ ...prev, isLoadMore: true }));
await getSingleCatItem(advertisements.currentPage + 1);
};
const handleSortBy = (value) => {
newSearchParams.set("sort_by", value);
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
};
const handleLike = (id) => {
const updatedItems = advertisements.data.map((item) => {
if (item.id === id) {
return { ...item, is_liked: !item.is_liked };
}
return item;
});
setAdvertisements((prev) => ({ ...prev, data: updatedItems }));
};
const handleClearLocation = () => {
newSearchParams.delete("country");
newSearchParams.delete("state");
newSearchParams.delete("city");
newSearchParams.delete("area");
newSearchParams.delete("areaId");
newSearchParams.delete("lat");
newSearchParams.delete("lng");
newSearchParams.delete("km_range");
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
};
const handleClearRange = () => {
newSearchParams.delete("km_range");
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
};
const handleClearDatePosted = () => {
newSearchParams.delete("date_posted");
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
};
const handleClearBudget = () => {
newSearchParams.delete("min_price");
newSearchParams.delete("max_price");
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
};
const handleClearFeaturedSection = () => {
newSearchParams.delete("featured_section");
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
};
const handleClearCategory = () => {
newSearchParams.delete("category");
Object.keys(extraDetails || {})?.forEach((key) => {
newSearchParams.delete(key);
});
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
};
const handleClearExtraDetail = (keyToRemove) => {
const updatedExtraDetails = { ...extraDetails };
delete updatedExtraDetails[keyToRemove];
setExtraDetails(updatedExtraDetails);
newSearchParams.delete(keyToRemove);
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
};
const handleClearAll = () => {
newSearchParams.delete("country");
newSearchParams.delete("state");
newSearchParams.delete("city");
newSearchParams.delete("area");
newSearchParams.delete("areaId");
newSearchParams.delete("lat");
newSearchParams.delete("lng");
newSearchParams.delete("km_range");
newSearchParams.delete("date_posted");
newSearchParams.delete("min_price");
newSearchParams.delete("max_price");
newSearchParams.delete("category");
newSearchParams.delete("query");
newSearchParams.delete("featured_section");
Object.keys(initialExtraDetails || {})?.forEach((key) => {
newSearchParams.delete(key);
});
setExtraDetails({});
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
};
const handleClearQuery = () => {
newSearchParams.delete("query");
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
};
const postedSince =
date_posted === "all-time"
? t("allTime")
: date_posted === "today"
? t("today")
: date_posted === "within-1-week"
? t("within1Week")
: date_posted === "within-2-week"
? t("within2Weeks")
: date_posted === "within-1-month"
? t("within1Month")
: date_posted === "within-3-month"
? t("within3Months")
: "";
return (
<Layout>
<BreadCrumb />
<div className="container mt-8">
<div className="flex flex-col">
<h1 className="text-2xl font-semibold mb-6">{title}</h1>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
<div className="xl:col-span-3 lg:col-span-4 col-span-1">
<Filter
customFields={customFields}
extraDetails={extraDetails}
setExtraDetails={setExtraDetails}
newSearchParams={newSearchParams}
country={country}
state={state}
city={city}
area={area}
/>
</div>
<div className="xl:col-span-9 lg:col-span-8 col-span-1 flex flex-col gap-5">
<div className="flex justify-between items-center">
<div>
<div className="flex flex-col md:flex-row items-start md:items-center gap-2">
<div className="flex gap-2 items-center whitespace-nowrap">
<TbTransferVertical />
{t("sortBy")}
</div>
<Select value={sortBy} onValueChange={handleSortBy}>
<SelectTrigger className="max-w-[180px] font-semibold">
<SelectValue
placeholder={t("sortBy")}
className="font-semibold"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup className="font-semibold">
<SelectItem value="new-to-old">
{t("newestToOldest")}
</SelectItem>
<SelectItem value="old-to-new">
{t("oldestToNewest")}
</SelectItem>
<SelectItem value="price-high-to-low">
{t("priceHighToLow")}
</SelectItem>
<SelectItem value="price-low-to-high">
{t("priceLowToHigh")}
</SelectItem>
<SelectItem value="popular_items">
{t("popular")}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setView("list")}
className={`text-muted-foreground p-3 rounded-full ${view === "list" ? "bg-primary text-white" : ""
}`}
>
<CiGrid2H size={22} />
</button>
<button
onClick={() => setView("grid")}
className={` text-muted-foreground p-3 rounded-full ${view === "grid" ? "bg-primary text-white" : ""
}`}
>
<IoGrid size={22} />
</button>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex gap-2 flex-wrap">
{category && (
<Badge
variant="outline"
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
>
<span>
{t("category")}: {category}
</span>
<IoIosCloseCircle
size={22}
className="cursor-pointer "
onClick={handleClearCategory}
/>
</Badge>
)}
{query && (
<Badge
variant="outline"
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
>
<span>
{t("search")}: {query}
</span>
<IoIosCloseCircle
size={22}
className="cursor-pointer "
onClick={handleClearQuery}
/>
</Badge>
)}
{(country || state || city || area) && (
<Badge
variant="outline"
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
>
<span>
{t("location")}:{" "}
{selectedLocation?.translated_name ||
selectedLocation?.name}
</span>
<IoIosCloseCircle
size={22}
className="cursor-pointer "
onClick={handleClearLocation}
/>
</Badge>
)}
{Number(km_range) > 0 && (
<Badge
variant="outline"
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
>
<span>
{t("nearByRange")}: {km_range} KM
</span>
<IoIosCloseCircle
size={22}
className="cursor-pointer "
onClick={handleClearRange}
/>
</Badge>
)}
{date_posted && (
<Badge
variant="outline"
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
>
<span>
{t("datePosted")}: {postedSince}
</span>
<IoIosCloseCircle
size={22}
className="cursor-pointer "
onClick={handleClearDatePosted}
/>
</Badge>
)}
{isMinPrice && max_price && (
<Badge
variant="outline"
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
>
<span>
{t("budget")}: {min_price}-{max_price}
</span>
<IoIosCloseCircle
size={22}
className="cursor-pointer "
onClick={handleClearBudget}
/>
</Badge>
)}
{featured_section && (
<Badge
variant="outline"
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
>
<span>
{t("featuredSection")}: {featuredTitle}
</span>
<IoIosCloseCircle
size={22}
className="cursor-pointer "
onClick={handleClearFeaturedSection}
/>
</Badge>
)}
{initialExtraDetails &&
Object.entries(initialExtraDetails || {}).map(
([key, value]) => {
const field = customFields.find(
(f) => f.id.toString() === key.toString()
);
const fieldName = field?.translated_name || field?.name;
// Function to get translated value
const getTranslatedValue = (val) => {
if (!field?.values || !field?.translated_value)
return val;
const idx = field.values.indexOf(val);
return idx !== -1 ? field.translated_value[idx] : val;
};
const displayValue = Array.isArray(value)
? value.map((v) => getTranslatedValue(v)).join(", ")
: getTranslatedValue(value);
return (
<Badge
key={key}
variant="outline"
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
>
<span>
{fieldName}: {displayValue}
</span>
<IoIosCloseCircle
size={22}
className="cursor-pointer"
onClick={() => handleClearExtraDetail(key)}
/>
</Badge>
);
}
)}
</div>
{activeFilterCount > 1 && (
<button
className="text-primary whitespace-nowrap"
onClick={handleClearAll}
>
{t("clearAll")}
</button>
)}
</div>
<div className="grid grid-cols-12 gap-4">
{advertisements?.isLoading ? (
Array.from({ length: 12 }).map((_, index) =>
view === "list" ? (
<div className="col-span-12" key={index}>
<ProductHorizontalCardSkeleton />
</div>
) : (
<div
key={index}
className="col-span-12 sm:col-span-6 xl:col-span-4"
>
<ProductCardSkeleton />
</div>
)
)
) : advertisements.data && advertisements.data.length > 0 ? (
advertisements.data?.map((item, index) =>
view === "list" ? (
<div className="col-span-12" key={index}>
<ProductHorizontalCard
item={item}
handleLike={handleLike}
/>
</div>
) : (
<div
className="col-span-12 sm:col-span-6 xl:col-span-4"
key={index}
>
<ProductCard item={item} handleLike={handleLike} />
</div>
)
)
) : (
<div className="col-span-12">
<NoData name={t("ads")} />
</div>
)}
</div>
{advertisements.data &&
advertisements.data.length > 0 &&
advertisements.hasMore && (
<div className="text-center mt-6">
<Button
variant="outline"
className="text-sm sm:text-base text-primary w-[256px]"
disabled={
advertisements.isLoading || advertisements.isLoadMore
}
onClick={handleProdLoadMore}
>
{advertisements.isLoadMore ? t("loading") : t("loadMore")}
</Button>
</div>
)}
</div>
</div>
</div>
</div>
</Layout>
);
};
export default Ads;

View File

@@ -0,0 +1,54 @@
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getIsRtl } from "@/redux/reducer/languageSlice";
import { t } from "@/utils";
import { useSelector } from "react-redux";
const AdLanguageSelector = ({
langId,
setLangId,
languages,
setTranslations,
}) => {
const isRTL = useSelector(getIsRtl);
const handleLangChange = (newId) => {
setLangId(newId);
setTranslations((t) => ({
...t,
[newId]: t[newId] || {},
}));
};
return (
<div className="flex items-center gap-2">
<p className="whitespace-nowrap text-sm font-medium hidden lg:block">
{t("selectLanguage")}
</p>
<Select value={langId} onValueChange={handleLangChange}>
<SelectTrigger className="gap-2">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent align={isRTL ? "start" : "end"}>
<SelectGroup>
{languages &&
languages.length > 0 &&
languages.map((lang) => (
<SelectItem key={lang.id} value={lang.id}>
{lang.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
);
};
export default AdLanguageSelector;

View File

@@ -0,0 +1,55 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import trueGif from "../../../public/assets/true.gif";
import CustomLink from "@/components/Common/CustomLink";
import { t } from "@/utils";
import CustomImage from "@/components/Common/CustomImage";
const AdSuccessModal = ({
openSuccessModal,
setOpenSuccessModal,
createdAdSlug,
}) => {
const closeSuccessModal = () => {
setOpenSuccessModal(false);
};
return (
<Dialog open={openSuccessModal} onOpenChange={closeSuccessModal}>
<DialogContent
className="[&>button]:hidden !max-w-[520px] py-[50px] px-[30px] sm:px-[80px]"
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<DialogHeader className="flex flex-col gap-4 items-center">
<CustomImage
src={trueGif}
alt="success"
height={176}
width={176}
className="h-44 w-44"
/>
<DialogTitle className="text-3xl font-semibold text-center !p-0 mt-0">
{t("adPostedSuccess")}
</DialogTitle>
<CustomLink
href={`/my-listing/${createdAdSlug}`}
className="py-3 px-6 bg-primary text-white rounded-md"
>
{t("viewAd")}
</CustomLink>
<CustomLink href="/" className="">
{t("backToHome")}
</CustomLink>
</DialogHeader>
</DialogContent>
</Dialog>
);
};
export default AdSuccessModal;

View File

@@ -0,0 +1,753 @@
"use client";
import { useEffect, useState } from "react";
import ComponentOne from "./ComponentOne";
import {
addItemApi,
categoryApi,
getCurrenciesApi,
getCustomFieldsApi,
getParentCategoriesApi,
} from "@/utils/api";
import ComponentTwo from "./ComponentTwo";
import {
filterNonDefaultTranslations,
getDefaultCountryCode,
isValidURL,
prepareCustomFieldFiles,
prepareCustomFieldTranslations,
t,
validateExtraDetails,
} from "@/utils";
import { toast } from "sonner";
import ComponentThree from "./ComponentThree";
import ComponentFour from "./ComponentFour";
import ComponentFive from "./ComponentFive";
import { useSelector } from "react-redux";
import AdSuccessModal from "./AdSuccessModal";
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
import Layout from "@/components/Layout/Layout";
import Checkauth from "@/HOC/Checkauth";
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
import AdLanguageSelector from "./AdLanguageSelector";
import {
getDefaultLanguageCode,
getLanguages,
} from "@/redux/reducer/settingSlice";
import { userSignUpData } from "@/redux/reducer/authSlice";
import { isValidPhoneNumber } from "libphonenumber-js/max";
import { getCurrentCountry } from "@/redux/reducer/locationSlice";
const AdsListing = () => {
const CurrentLanguage = useSelector(CurrentLanguageData);
const currentCountry = useSelector(getCurrentCountry);
const [step, setStep] = useState(1);
const [categories, setCategories] = useState();
const [categoriesLoading, setCategoriesLoading] = useState(false);
const [isLoadMoreCat, setIsLoadMoreCat] = useState(false);
const [categoryPath, setCategoryPath] = useState([]);
const [currentPage, setCurrentPage] = useState();
const [lastPage, setLastPage] = useState();
const [disabledTab, setDisabledTab] = useState({
categoryTab: false,
detailTab: true,
extraDetailTabl: true,
images: true,
location: true,
});
const [customFields, setCustomFields] = useState([]);
const [filePreviews, setFilePreviews] = useState({});
const [uploadedImages, setUploadedImages] = useState([]);
const [otherImages, setOtherImages] = useState([]);
const [location, setLocation] = useState({});
const [currencies, setCurrencies] = useState([]);
const [isAdPlaced, setIsAdPlaced] = useState(false);
const [openSuccessModal, setOpenSuccessModal] = useState(false);
const [createdAdSlug, setCreatedAdSlug] = useState("");
const userData = useSelector(userSignUpData);
const languages = useSelector(getLanguages);
const defaultLanguageCode = useSelector(getDefaultLanguageCode);
const defaultLangId = languages?.find(
(lang) => lang.code === defaultLanguageCode
)?.id;
const [extraDetails, setExtraDetails] = useState({
[defaultLangId]: {},
});
const [langId, setLangId] = useState(defaultLangId);
const countryCode =
userData?.country_code?.replace("+", "") || getDefaultCountryCode();
const mobile = userData?.mobile || "";
const regionCode =
userData?.region_code?.toLowerCase() ||
process.env.NEXT_PUBLIC_DEFAULT_COUNTRY?.toLowerCase() ||
"in";
const [translations, setTranslations] = useState({
[langId]: {
contact: mobile,
country_code: countryCode,
region_code: regionCode,
},
});
const hasTextbox = customFields.some((field) => field.type === "textbox");
const defaultDetails = translations[defaultLangId] || {};
const currentDetails = translations[langId] || {};
const currentExtraDetails = extraDetails[langId] || {};
const is_job_category =
Number(categoryPath[categoryPath.length - 1]?.is_job_category) === 1;
const isPriceOptional =
Number(categoryPath[categoryPath.length - 1]?.price_optional) === 1;
const allCategoryIdsString = categoryPath
.map((category) => category.id)
.join(",");
let lastItemId = categoryPath[categoryPath.length - 1]?.id;
useEffect(() => {
if (step === 1) {
handleFetchCategories();
}
}, [CurrentLanguage.id]);
useEffect(() => {
if (step !== 1 && allCategoryIdsString) {
getCustomFieldsData();
}
}, [allCategoryIdsString, CurrentLanguage.id]);
useEffect(() => {
// Update category path translations when language changes
if (categoryPath.length > 0) {
const lastCategoryId = categoryPath[categoryPath.length - 1]?.id;
if (lastCategoryId) {
getParentCategoriesApi
.getPaymentCategories({
child_category_id: lastCategoryId,
})
.then((res) => {
const updatedPath = res?.data?.data;
if (updatedPath?.length > 0) {
setCategoryPath(updatedPath);
}
})
.catch((err) => {
console.log("Error updating category path:", err);
});
}
}
}, [CurrentLanguage.id]);
useEffect(() => {
getCurrencies();
}, [countryCode]);
const getCurrencies = async () => {
try {
let params = {};
if (currentCountry) {
params.country = currentCountry;
}
const res = await getCurrenciesApi.getCurrencies(params);
const currenciesData = res?.data?.data || [];
setCurrencies(currenciesData);
// 🚫 IMPORTANT: If no currencies, REMOVE currency_id
if (currenciesData.length === 0) {
setTranslations((prev) => {
const updated = { ...prev };
if (updated[langId]?.currency_id) {
delete updated[langId].currency_id;
}
return updated;
});
return;
}
// ✅ Normal case: set default currency
const defaultCurrency =
currenciesData.find((c) => c.selected == 1) || currenciesData[0];
if (defaultCurrency && !translations[langId]?.currency_id) {
setTranslations((prev) => ({
...prev,
[langId]: {
...prev[langId],
currency_id: defaultCurrency.id,
},
}));
}
} catch (error) {
console.log("error", error);
}
};
const handleFetchCategories = async (
category,
isWaitForApiResToUpdatePath = false
) => {
setCategoriesLoading(true);
try {
const categoryId = category ? category?.id : null;
const res = await categoryApi.getCategory({
category_id: categoryId,
listing: 1,
});
if (res?.data?.error === false) {
const data = res?.data?.data?.data;
setCategories(data);
setCurrentPage(res?.data?.data?.current_page);
setLastPage(res?.data?.data?.last_page);
if (isWaitForApiResToUpdatePath) {
setCategoryPath((prevPath) => [...prevPath, category]);
if (category.subcategories_count == 0) {
setStep(2);
setDisabledTab({
categoryTab: true,
detailTab: false,
extraDetailTabl: false,
images: false,
location: false,
});
}
} else {
const index = categoryPath.findIndex(
(item) => item.id === category?.id
);
setCategoryPath((prevPath) => prevPath.slice(0, index + 1));
}
} else {
toast.error(res?.data?.message);
}
} catch (error) {
console.log("error", error);
} finally {
setCategoriesLoading(false);
}
};
const getCustomFieldsData = async () => {
try {
const res = await getCustomFieldsApi.getCustomFields({
category_ids: allCategoryIdsString,
});
const data = res?.data?.data;
setCustomFields(data);
const initializedDetails = {};
languages.forEach((lang) => {
const langFields = {};
data.forEach((item) => {
if (lang.id !== defaultLangId && item.type !== "textbox") return;
let initialValue = "";
switch (item.type) {
case "checkbox":
case "radio":
initialValue = [];
break;
case "fileinput":
initialValue = null;
break;
case "dropdown":
case "textbox":
case "number":
case "text":
initialValue = "";
break;
default:
break;
}
langFields[item.id] = initialValue;
});
initializedDetails[lang.id] = langFields;
});
setExtraDetails(initializedDetails);
} catch (error) {
console.log(error);
}
};
const handleCategoryTabClick = async (category) => {
await handleFetchCategories(category, true);
};
const handleSelectedTabClick = (id) => {
setCustomFields([]);
setLangId(defaultLangId);
setTranslations({
[defaultLangId]: {
contact: mobile,
country_code: countryCode,
region_code: regionCode,
},
});
setExtraDetails({
[defaultLangId]: {},
});
if (step !== 1) {
setStep(1);
setDisabledTab({
categoryTab: false,
detailTab: true,
extraDetailTabl: true,
images: true,
location: true,
});
}
// ✅ SINGLE CATEGORY EDGE CASE
const index = categoryPath.findIndex((item) => item.id === id);
if (index === 0) {
setCategoryPath([]);
// Fetch root / all categories
handleFetchCategories(null);
return;
}
// ✅ NORMAL BACK-NAVIGATION FLOW
const secondLast = categoryPath[index - 1];
if (secondLast) {
handleFetchCategories(secondLast);
}
};
const handleDetailsSubmit = () => {
if (customFields?.length === 0) {
setStep(4);
} else {
setStep(3);
}
};
const SLUG_RE = /^[a-z0-9-]+$/i;
const isEmpty = (x) => !x || !x.toString().trim();
const isNegative = (n) => Number(n) < 0;
const handleFullSubmission = () => {
const {
name,
description,
price,
slug,
contact,
video_link,
min_salary,
max_salary,
country_code,
} = defaultDetails;
// Step 1: Must pick a category
const catId = categoryPath.at(-1)?.id;
if (!catId) {
toast.error(t("selectCategory"));
return setStep(1);
}
// Step 2: Get data for default (selected) language
if (isEmpty(name) || isEmpty(description)) {
toast.error(t("completeDetails")); // Title, desc, phone required
return setStep(2);
}
// ✅ Validate phone number ONLY if user entered one as it is optional
if (Boolean(contact) && !isValidPhoneNumber(`+${country_code}${contact}`)) {
toast.error(t("invalidPhoneNumber"));
return setStep(2);
}
// Step 3: Validate job or price fields
if (is_job_category) {
const min = min_salary ? Number(min_salary) : null;
const max = max_salary ? Number(max_salary) : null;
if (min !== null && min < 0) {
toast.error(t("enterValidSalaryMin"));
return setStep(2);
}
if (max !== null && max < 0) {
toast.error(t("enterValidSalaryMax"));
return setStep(2);
}
if (min !== null && max !== null) {
if (min === max) {
toast.error(t("salaryMinCannotBeEqualMax"));
return setStep(2);
}
if (min > max) {
toast.error(t("salaryMinCannotBeGreaterThanMax"));
return setStep(2);
}
}
} else {
if (!isPriceOptional && isEmpty(price)) {
toast.error(t("completeDetails")); // Price is required
return setStep(2);
}
if (!isEmpty(price) && isNegative(price)) {
toast.error(t("enterValidPrice"));
return setStep(2);
}
}
// Step 4: Video URL check
if (!isEmpty(video_link) && !isValidURL(video_link)) {
toast.error(t("enterValidUrl"));
return setStep(2);
}
// Step 5: Slug validation
if (!isEmpty(slug) && !SLUG_RE.test(slug.trim())) {
toast.error(t("addValidSlug"));
return setStep(2);
}
if (
customFields.length !== 0 &&
!validateExtraDetails({
languages,
defaultLangId,
extraDetails,
customFields,
filePreviews,
})
) {
return setStep(3);
}
if (uploadedImages.length === 0) {
toast.error(t("uploadMainPicture"));
setStep(4);
return;
}
if (
!location?.country ||
!location?.state ||
!location?.city ||
!location?.formattedAddress
) {
toast.error(t("pleaseSelectCity"));
return;
}
postAd();
};
const postAd = async () => {
const catId = categoryPath.at(-1)?.id;
const customFieldTranslations =
prepareCustomFieldTranslations(extraDetails);
const customFieldFiles = prepareCustomFieldFiles(
extraDetails,
defaultLangId
);
const nonDefaultTranslations = filterNonDefaultTranslations(
translations,
defaultLangId
);
const allData = {
name: defaultDetails.name,
slug: defaultDetails.slug.trim(),
description: defaultDetails?.description,
category_id: catId,
all_category_ids: allCategoryIdsString,
price: defaultDetails.price,
contact: defaultDetails.contact,
video_link: defaultDetails?.video_link,
// custom_fields: transformedCustomFields,
image: uploadedImages[0],
gallery_images: otherImages,
address: location?.formattedAddress,
latitude: location?.lat,
longitude: location?.long,
custom_field_files: customFieldFiles,
country: location?.country,
state: location?.state,
city: location?.city,
...(location?.area_id ? { area_id: Number(location?.area_id) } : {}),
...(Object.keys(nonDefaultTranslations).length > 0 && {
translations: nonDefaultTranslations,
}),
...(Object.keys(customFieldTranslations).length > 0 && {
custom_field_translations: customFieldTranslations,
}),
...(defaultDetails?.currency_id && {
currency_id: defaultDetails?.currency_id,
}),
region_code: defaultDetails?.region_code?.toUpperCase() || "",
};
if (is_job_category) {
// Only add salary fields if they're provided
if (defaultDetails.min_salary) {
allData.min_salary = defaultDetails.min_salary;
}
if (defaultDetails.max_salary) {
allData.max_salary = defaultDetails.max_salary;
}
} else {
allData.price = defaultDetails.price;
}
try {
setIsAdPlaced(true);
const res = await addItemApi.addItem(allData);
if (res?.data?.error === false) {
setOpenSuccessModal(true);
setCreatedAdSlug(res?.data?.data[0]?.slug);
} else {
toast.error(res?.data?.message);
}
} catch (error) {
console.error(error);
} finally {
setIsAdPlaced(false);
}
};
const handleGoBack = () => {
setStep((prev) => {
if (customFields.length === 0 && step === 4) {
return prev - 2;
} else {
return prev - 1;
}
});
};
const fetchMoreCategory = async () => {
setIsLoadMoreCat(true);
try {
const response = await categoryApi.getCategory({
page: `${currentPage + 1}`,
category_id: lastItemId,
listing: 1,
});
const { data } = response.data;
setCategories((prev) => [...prev, ...data.data]);
setCurrentPage(data?.current_page); // Update the current page
setLastPage(data?.last_page); // Update the current page
} catch (error) {
console.error("Error:", error);
} finally {
setIsLoadMoreCat(false);
}
};
const handleTabClick = (tab) => {
if (tab === 1 && !disabledTab.categoryTab) {
setStep(1);
} else if (tab === 2 && !disabledTab.detailTab) {
setStep(2);
} else if (tab === 3 && !disabledTab.extraDetailTabl) {
setStep(3);
} else if (tab === 4 && !disabledTab.images) {
setStep(4);
} else if (tab === 5 && !disabledTab.location) {
setStep(5);
}
};
const handleDeatilsBack = () => {
setCustomFields([]);
setLangId(defaultLangId);
setTranslations({
[defaultLangId]: {
contact: mobile,
country_code: countryCode,
region_code: regionCode,
},
});
setExtraDetails({
[defaultLangId]: {},
});
if (step !== 1) {
setStep(1);
setDisabledTab({
selectCategory: false,
details: true,
extraDet: true,
img: true,
loc: true,
});
}
const secondLast = categoryPath.at(-2);
if (secondLast) {
handleFetchCategories(secondLast);
}
};
return (
<Layout>
<BreadCrumb title2={t("adListing")} />
<div className="container">
<div className="flex flex-col gap-8 mt-8">
<h1 className="text-2xl font-medium">{t("adListing")}</h1>
<div className="flex flex-col gap-6 border rounded-md p-4">
<div className="flex items-center gap-3 justify-between bg-muted px-4 py-2 rounded-md flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<div
className={`transition-all duration-300 p-2 cursor-pointer ${step === 1 ? "bg-primary text-white" : ""
} rounded-md ${disabledTab?.categoryTab == true ? "opacity-60" : " "
}`}
onClick={() => handleTabClick(1)}
>
{t("selectedCategory")}
</div>
<div
className={`transition-all duration-300 p-2 cursor-pointer ${step === 2 ? "bg-primary text-white" : ""
} rounded-md ${disabledTab?.detailTab == true ? "opacity-60" : " "
}`}
onClick={() => handleTabClick(2)}
>
{t("details")}
</div>
{customFields?.length > 0 && (
<div
className={`transition-all duration-300 p-2 cursor-pointer ${step === 3 ? "bg-primary text-white" : ""
} rounded-md ${disabledTab?.extraDetailTabl == true ? "opacity-60" : " "
}`}
onClick={() => handleTabClick(3)}
>
{t("extraDetails")}
</div>
)}
<div
className={`transition-all duration-300 p-2 cursor-pointer ${step === 4 ? "bg-primary text-white" : ""
} rounded-md ${disabledTab?.images == true ? "opacity-60" : " "
}`}
onClick={() => handleTabClick(4)}
>
{t("images")}
</div>
<div
className={`transition-all duration-300 p-2 cursor-pointer ${step === 5 ? "bg-primary text-white" : ""
} rounded-md ${disabledTab?.location == true ? "opacity-60" : " "
}`}
onClick={() => handleTabClick(5)}
>
{t("location")}
</div>
</div>
{(step == 2 || (step === 3 && hasTextbox)) && (
<AdLanguageSelector
langId={langId}
setLangId={setLangId}
languages={languages}
setTranslations={setTranslations}
/>
)}
</div>
{(step == 1 || step == 2) && categoryPath?.length > 0 && (
<div className="flex flex-col gap-2">
<p className="font-medium text-xl">{t("selectedCategory")}</p>
<div className="flex">
{categoryPath?.map((item, index) => {
const shouldShowComma =
categoryPath.length > 1 &&
index !== categoryPath.length - 1;
return (
<button
key={item.id}
className="text-primary ltr:text-left rtl:text-right"
onClick={() => handleSelectedTabClick(item?.id)}
disabled={categoriesLoading}
>
{item.translated_name || item.name}
{shouldShowComma && ", "}
</button>
);
})}
</div>
</div>
)}
<div>
{step == 1 && (
<ComponentOne
categories={categories}
setCategoryPath={setCategoryPath}
fetchMoreCategory={fetchMoreCategory}
lastPage={lastPage}
currentPage={currentPage}
isLoadMoreCat={isLoadMoreCat}
handleCategoryTabClick={handleCategoryTabClick}
categoriesLoading={categoriesLoading}
/>
)}
{step == 2 && (
<ComponentTwo
currencies={currencies}
setTranslations={setTranslations}
current={currentDetails}
langId={langId}
defaultLangId={defaultLangId}
handleDetailsSubmit={handleDetailsSubmit}
handleDeatilsBack={handleDeatilsBack}
is_job_category={is_job_category}
isPriceOptional={isPriceOptional}
/>
)}
{step == 3 && (
<ComponentThree
customFields={customFields}
setExtraDetails={setExtraDetails}
filePreviews={filePreviews}
setFilePreviews={setFilePreviews}
setStep={setStep}
handleGoBack={handleGoBack}
currentExtraDetails={currentExtraDetails}
langId={langId}
defaultLangId={defaultLangId}
/>
)}
{step == 4 && (
<ComponentFour
uploadedImages={uploadedImages}
setUploadedImages={setUploadedImages}
otherImages={otherImages}
setOtherImages={setOtherImages}
setStep={setStep}
handleGoBack={handleGoBack}
/>
)}
{step == 5 && (
<ComponentFive
location={location}
setLocation={setLocation}
handleFullSubmission={handleFullSubmission}
isAdPlaced={isAdPlaced}
handleGoBack={handleGoBack}
/>
)}
</div>
</div>
</div>
<AdSuccessModal
openSuccessModal={openSuccessModal}
setOpenSuccessModal={setOpenSuccessModal}
createdAdSlug={createdAdSlug}
/>
</div>
</Layout>
);
};
export default Checkauth(AdsListing);

View File

@@ -0,0 +1,173 @@
import { useState } from "react";
import { FaLocationCrosshairs } from "react-icons/fa6";
import dynamic from "next/dynamic";
import { BiMapPin } from "react-icons/bi";
import { IoLocationOutline } from "react-icons/io5";
import { toast } from "sonner";
import { useSelector } from "react-redux";
import ManualAddress from "./ManualAddress";
import { getIsBrowserSupported } from "@/redux/reducer/locationSlice";
import { getIsPaidApi } from "@/redux/reducer/settingSlice";
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
import { t } from "@/utils";
import LandingAdEditSearchAutocomplete from "@/components/Location/LandingAdEditSearchAutocomplete";
import { Loader2 } from "lucide-react";
import useGetLocation from "@/components/Layout/useGetLocation";
const MapComponent = dynamic(() => import("@/components/Common/MapComponent"), {
ssr: false,
loading: () => <div className="w-full h-[400px] bg-gray-100 rounded-lg" />,
});
const ComponentFive = ({
location,
setLocation,
handleFullSubmission,
isAdPlaced,
handleGoBack,
}) => {
const CurrentLanguage = useSelector(CurrentLanguageData);
const [showManualAddress, setShowManualAddress] = useState(false);
const isBrowserSupported = useSelector(getIsBrowserSupported);
const [IsGettingCurrentLocation, setIsGettingCurrentLocation] =
useState(false);
const IsPaidApi = useSelector(getIsPaidApi);
const { fetchLocationData } = useGetLocation();
const getLocationWithMap = async (pos) => {
try {
const data = await fetchLocationData(pos);
setLocation(data);
} catch (error) {
console.error("Error fetching location data:", error);
toast.error(t("errorOccurred"));
}
};
const getCurrentLocation = async () => {
if (navigator.geolocation) {
setIsGettingCurrentLocation(true);
navigator.geolocation.getCurrentPosition(
async (position) => {
try {
const { latitude, longitude } = position.coords;
const data = await fetchLocationData({ lat: latitude, lng: longitude });
setLocation(data);
} catch (error) {
console.error("Error fetching location data:", error);
toast.error(t("errorOccurred"));
} finally {
setIsGettingCurrentLocation(false);
}
},
(error) => {
toast.error(t("locationNotGranted"));
setIsGettingCurrentLocation(false);
}
);
} else {
toast.error(t("geoLocationNotSupported"));
}
};
return (
<>
<div className="flex flex-col gap-8">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3">
<h5 className="text-xl font-medium">{t("addLocation")}</h5>
<div className="flex items-center gap-2 border rounded-md w-full md:w-96 min-h-[42px]">
<LandingAdEditSearchAutocomplete
saveOnSuggestionClick={false}
setSelectedLocation={setLocation}
/>
{isBrowserSupported && (
<button
onClick={getCurrentLocation}
disabled={IsGettingCurrentLocation}
className="bg-primary p-2 text-white gap-2 flex items-center rounded-md h-10"
>
<span>
{IsGettingCurrentLocation ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<FaLocationCrosshairs size={16} />
)}
</span>
<span className="whitespace-nowrap hidden md:inline">
{IsGettingCurrentLocation ? t("loading") : t("locateMe")}
</span>
</button>
)}
</div>
</div>
<div className="flex gap-8 flex-col">
<MapComponent
location={location}
getLocationWithMap={getLocationWithMap}
/>
<div className="flex items-center gap-3 bg-muted rounded-lg p-4 ">
<div className="p-5 rounded-md bg-white">
<BiMapPin className="text-primary" size={32} />
</div>
<span className="flex flex-col gap-1">
<h6 className="font-medium">{t("address")}</h6>
{location?.address_translated || location?.formattedAddress ? (
<p>
{location?.address_translated || location?.formattedAddress}
</p>
) : (
t("addYourAddress")
)}
</span>
</div>
</div>
{!IsPaidApi && (
<>
<div className="relative flex items-center justify-center">
<div className="absolute top-1/2 left-0 right-0 h-px bg-[#d3d3d3]"></div>
<div className="relative bg-muted text-black text-base font-medium rounded-full w-12 h-12 flex items-center justify-center uppercase">
{t("or")}
</div>
</div>
<div className="flex flex-col gap-3 items-center justify-center">
<p className="text-xl font-semibold">
{t("whatLocAdYouSelling")}
</p>
<button
className="p-2 flex items-center gap-2 border rounded-md font-medium"
onClick={() => setShowManualAddress(true)}
>
<IoLocationOutline size={20} />
{t("addLocation")}
</button>
</div>
</>
)}
<div className="flex justify-end gap-3">
<button
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
onClick={handleGoBack}
>
{t("back")}
</button>
<button
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light disabled:bg-muted-foreground"
disabled={isAdPlaced}
onClick={handleFullSubmission}
>
{isAdPlaced ? t("posting") : t("postNow")}
</button>
</div>
</div>
<ManualAddress
key={showManualAddress}
showManualAddress={showManualAddress}
setShowManualAddress={setShowManualAddress}
setLocation={setLocation}
/>
</>
);
};
export default ComponentFive;

View File

@@ -0,0 +1,233 @@
import { IoInformationCircleOutline } from "react-icons/io5";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useDropzone } from "react-dropzone";
import { HiOutlineUpload } from "react-icons/hi";
import { MdClose } from "react-icons/md";
import { toast } from "sonner";
import { t } from "@/utils";
import CustomImage from "@/components/Common/CustomImage";
const ComponentFour = ({
uploadedImages,
setUploadedImages,
otherImages,
setOtherImages,
setStep,
handleGoBack,
}) => {
const onDrop = (acceptedFiles) => {
if (acceptedFiles.length == 0) {
toast.error(t("wrongFile"));
} else {
setUploadedImages(acceptedFiles);
}
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"image/jpeg": [".jpeg", ".jpg"],
"image/png": [".png"],
},
multiple: false,
});
const files = () =>
uploadedImages?.map((file, index) => (
<div key={index} className="relative">
<CustomImage
width={591}
height={350}
className="rounded-2xl object-cover aspect-[591/350]"
src={URL.createObjectURL(file)}
alt={file.name}
/>
<div className="absolute top-2 left-2 flex gap-2 items-center">
<button
className="bg-white p-1 rounded-full"
onClick={() => removeImage(index)}
>
<MdClose size={14} color="black" />
</button>
<div className="text-white text-xs flex flex-col">
<span>{file.name}</span>
<span>{Math.round(file.size / 1024)} KB</span>
</div>
</div>
</div>
));
const removeImage = (index) => {
setUploadedImages((prevImages) => prevImages.filter((_, i) => i !== index));
};
const onOtherDrop = (acceptedFiles) => {
const currentFilesCount = otherImages.length; // Number of files already uploaded
const remainingSlots = 5 - currentFilesCount; // How many more files can be uploaded
if (remainingSlots === 0) {
// Show error if the limit has been reached
toast.error(t("imageLimitExceeded"));
return;
}
if (acceptedFiles.length > remainingSlots) {
// Show error if the number of new files exceeds the remaining slots
toast.error(
t("youCanUpload") + " " + remainingSlots + " " + t("moreImages")
);
return;
}
// Add the new files to the state
setOtherImages((prevImages) => [...prevImages, ...acceptedFiles]);
};
const {
getRootProps: getRootOtheProps,
getInputProps: getInputOtherProps,
isDragActive: isDragOtherActive,
} = useDropzone({
onDrop: onOtherDrop,
accept: {
"image/jpeg": [".jpeg", ".jpg"],
"image/png": [".png"],
},
multiple: true,
});
const removeOtherImage = (index) => {
setOtherImages((prevImages) => prevImages.filter((_, i) => i !== index));
};
const filesOther = () =>
otherImages &&
otherImages?.map((file, index) => (
<div key={`${file.name}-${file.size}`} className="relative">
<CustomImage
width={591}
height={350}
className="rounded-2xl object-cover aspect-[591/350]"
src={URL.createObjectURL(file)}
alt={file.name}
/>
<div className="absolute top-2 left-2 flex gap-2 items-center">
<button
className="bg-white p-1 rounded-full"
onClick={() => removeOtherImage(index)}
>
<MdClose size={22} color="black" />
</button>
<div className="text-white text-xs flex flex-col">
<span>{file.name}</span>
<span>{Math.round(file.size / 1024)} KB</span>
</div>
</div>
</div>
));
return (
<div className="flex flex-col gap-8">
<div className="grid grid-col-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<span className="requiredInputLabel text-sm font-semibold">
{t("mainPicture")}
</span>
<div className="border-2 border-dashed rounded-lg p-2">
<div
{...getRootProps()}
className="flex flex-col min-h-[175px] items-center justify-center cursor-pointer"
style={{ display: uploadedImages.length > 0 ? "none" : "" }}
>
<input {...getInputProps()} />
{isDragActive ? (
<span className="text-primary font-medium">
{t("dropFiles")}
</span>
) : (
<div className="flex flex-col gap-2 items-center text-center">
<span className="text-muted-foreground">
{t("dragFiles")}
</span>
<span className="text-muted-foreground">{t("or")}</span>
<div className="flex items-center text-primary">
<HiOutlineUpload size={24} />
<span className="font-medium">{t("upload")}</span>
</div>
</div>
)}
</div>
<div>{files()}</div>
</div>
</div>
<div className="flex flex-col gap-3">
<span className="flex items-center gap-1 font-semibold text-sm">
{t("otherPicture")}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<IoInformationCircleOutline size={22} />
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
className="font-normal"
>
<p>{t("maxOtherImages")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
<div className="border-2 border-dashed rounded-lg p-2">
<div
{...getRootOtheProps()}
className="flex flex-col items-center justify-center min-h-[175px] cursor-pointer"
style={{ display: otherImages.length >= 5 ? "none" : "" }}
>
<input {...getInputOtherProps()} />
{isDragOtherActive ? (
<span className="text-primary font-medium">
{t("dropFiles")}
</span>
) : (
<div className="flex flex-col gap-2 items-center text-center">
<span className="text-muted-foreground">
{t("dragFiles")}
</span>
<span className="text-muted-foreground">{t("or")}</span>
<div className="flex items-center text-primary">
<HiOutlineUpload size={24} />
<span className="font-medium">{t("upload")}</span>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-3">{filesOther()}</div>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<button
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
onClick={handleGoBack}
>
{t("back")}
</button>
<button
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light"
onClick={() => setStep(5)}
>
{t("next")}
</button>
</div>
</div>
);
};
export default ComponentFour;

View File

@@ -0,0 +1,81 @@
"use client";
import { MdChevronRight } from "react-icons/md";
import { Button } from "@/components/ui/button";
import { t } from "@/utils";
import CustomImage from "@/components/Common/CustomImage";
const ComponentOne = ({
categories,
categoriesLoading,
fetchMoreCategory,
lastPage,
currentPage,
isLoadMoreCat,
handleCategoryTabClick,
}) => {
return (
<>
<div className=" grid grid-cols-1 md:grid-cols-3 gap-6">
{categoriesLoading ? (
<div className="col-span-12 py-28">
<Loader />
</div>
) : (
categories?.map((category) => {
return (
<div className="" key={category?.id}>
<div
className="flex justify-between items-center cursor-pointer"
key={category?.id}
onClick={() => handleCategoryTabClick(category)}
>
<div className="flex items-center gap-2 ">
<CustomImage
src={category?.image}
alt={category?.translated_name || category?.name}
height={48}
width={48}
className="h-12 w-12 rounded-full"
/>
<span className="break-all">
{category?.translated_name || category?.name}
</span>
</div>
{category?.subcategories?.length > 0 && (
<MdChevronRight size={24} className="rtl:scale-x-[-1]" />
)}
</div>
</div>
);
})
)}
</div>
{lastPage > currentPage && (
<div className="text-center mt-6">
<Button
variant="outline"
className="text-sm sm:text-base text-primary w-[256px]"
disabled={isLoadMoreCat || categoriesLoading}
onClick={fetchMoreCategory}
>
{isLoadMoreCat ? t("loading") : t("loadMore")}
</Button>
</div>
)}
</>
);
};
export default ComponentOne;
const Loader = () => {
return (
<div className="flex 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>
);
};

View File

@@ -0,0 +1,338 @@
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { toast } from "sonner";
import { HiOutlineUpload } from "react-icons/hi";
import { MdOutlineAttachFile } from "react-icons/md";
import CustomLink from "@/components/Common/CustomLink";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { handleKeyDown, inpNum, t } from "@/utils";
import CustomImage from "@/components/Common/CustomImage";
const ComponentThree = ({
customFields,
setExtraDetails,
filePreviews,
setFilePreviews,
setStep,
handleGoBack,
currentExtraDetails,
langId,
defaultLangId,
}) => {
const write = (fieldId, value) =>
setExtraDetails((prev) => ({
...prev,
[langId]: {
...prev[langId],
[fieldId]: value,
},
}));
const handleFileChange = (id, file) => {
if (file) {
const allowedExtensions = /\.(jpg|jpeg|svg|png|pdf)$/i;
if (!allowedExtensions.test(file.name)) {
toast.error(t("notAllowedFile"));
return;
}
const fileUrl = URL.createObjectURL(file);
setFilePreviews((prev) => {
const oldUrl = prev?.[langId]?.[id]?.url;
if (oldUrl) {
URL.revokeObjectURL(oldUrl);
}
return {
...prev,
[langId]: {
...(prev[langId] || {}),
[id]: {
url: fileUrl,
isPdf: /\.pdf$/i.test(file.name),
},
},
};
});
write(id, file);
}
};
const handleChange = (id, value) => write(id, value ?? "");
const handleCheckboxChange = (id, value, checked) => {
const list = currentExtraDetails[id] || [];
const next = checked ? [...list, value] : list.filter((v) => v !== value);
write(id, next);
};
const renderCustomFields = (field) => {
let {
id,
name,
translated_name,
type,
translated_value,
values,
min_length,
max_length,
is_required,
} = field;
const inputProps = {
id,
name: id,
required: !!is_required,
onChange: (e) => handleChange(id, e.target.value),
value: currentExtraDetails[id] || "",
...(type === "number"
? { min: min_length, max: max_length }
: { minLength: min_length, maxLength: max_length }),
};
switch (type) {
case "number": {
return (
<div className="flex flex-col">
<Input
type={type}
inputMode="numeric"
placeholder={`${t("enter")} ${translated_name || name}`}
{...inputProps}
onKeyDown={(e) => handleKeyDown(e, max_length)}
onKeyPress={(e) => inpNum(e)}
/>
{max_length && (
<span className="self-end text-sm text-muted-foreground">
{`${currentExtraDetails[id]?.length ?? 0}/${max_length}`}
</span>
)}
</div>
);
}
case "textbox": {
return (
<div className=" flex flex-col">
<Textarea
placeholder={`${t("enter")} ${translated_name || name}`}
{...inputProps}
/>
{max_length && (
<span className="self-end text-sm text-muted-foreground">
{`${currentExtraDetails[id]?.length ?? 0}/${max_length}`}
</span>
)}
</div>
);
}
case "dropdown":
return (
<div className="w-full">
<Select
id={id}
name={id}
onValueChange={(value) => handleChange(id, value)}
value={currentExtraDetails[id] || ""}
>
<SelectTrigger className="outline-none focus:outline-none">
<SelectValue
className="font-medium"
placeholder={`${t("select")} ${translated_name || name}`}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel value="">
{t("select")} {translated_name || name}
</SelectLabel>
{values?.map((option, index) => (
<SelectItem
id={option}
className="font-medium"
key={option}
value={option}
>
{translated_value[index] || option}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
);
case "checkbox":
return (
<div className="flex w-full flex-wrap gap-2">
{values?.map((value, index) => {
const uniqueId = `${id}-${value}-${index}`;
return (
<div key={uniqueId} className="flex gap-1 items-center">
<Checkbox
id={uniqueId}
value={value}
onCheckedChange={(checked) =>
handleCheckboxChange(id, value, checked)
}
checked={currentExtraDetails[id]?.includes(value)}
/>
<label
htmlFor={uniqueId}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{translated_value[index] || value}
</label>
</div>
);
})}
</div>
);
case "radio":
return (
<RadioGroup
value={currentExtraDetails[id] || ""}
onValueChange={(value) => handleChange(id, value)}
className="flex flex-wrap gap-2"
>
{(translated_value || values)?.map((option, index) => {
const uniqueId = `${id}-${option}-${index}`;
return (
<div
key={uniqueId}
className="flex items-center gap-2 flex-wrap"
>
<RadioGroupItem
value={option}
id={uniqueId}
className="sr-only peer"
/>
<label
htmlFor={uniqueId}
className={`${currentExtraDetails[id] === option
? "bg-primary text-white"
: ""
} border rounded-md px-4 py-2 cursor-pointer transition-colors`}
>
{translated_value[index] || option}
</label>
</div>
);
})}
</RadioGroup>
);
case "fileinput":
const fileUrl = filePreviews?.[langId]?.[id]?.url;
const isPdf = filePreviews?.[langId]?.[id]?.isPdf;
return (
<>
<label htmlFor={id} className="flex gap-2 items-center">
<div className="cursor-pointer border px-2.5 py-1 rounded">
<HiOutlineUpload size={24} fontWeight="400" />
</div>
{fileUrl && (
<div className="flex items-center gap-1 text-sm flex-nowrap break-words">
{isPdf ? (
<>
<MdOutlineAttachFile />
<CustomLink
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
>
{t("viewPdf")}
</CustomLink>
</>
) : (
<CustomImage
key={fileUrl}
src={fileUrl}
alt="Preview"
className="h-9 w-9"
height={36}
width={36}
/>
)}
</div>
)}
</label>
<input
type="file"
id={id}
name={id}
className="hidden"
onChange={(e) => handleFileChange(id, e.target.files[0])}
accept=".jpg,.jpeg,.png,.svg,.pdf"
/>
<span className="text-sm text-muted-foreground">
{t("allowedFileType")}
</span>
</>
);
default:
break;
}
};
return (
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{customFields?.map((field) => {
if (langId !== defaultLangId && field.type !== "textbox") return null;
return (
<div className="flex flex-col w-full gap-2" key={field?.id}>
<div className="flex gap-2 items-center">
<CustomImage
src={field?.image}
alt={field?.name}
height={28}
width={28}
className="h-7 w-7 rounded-sm"
/>
<Label
className={`${field?.required === 1 && defaultLangId === langId
? "requiredInputLabel"
: ""
}`}
>
{field?.translated_name || field?.name}
</Label>
</div>
{renderCustomFields(field)}
</div>
);
})}
</div>
<div className="flex justify-end gap-3">
<button
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
onClick={handleGoBack}
>
{t("back")}
</button>
<button
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light"
onClick={() => setStep(4)}
>
{t("next")}
</button>
</div>
</div>
);
};
export default ComponentThree;

View File

@@ -0,0 +1,293 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { getIsRtl } from "@/redux/reducer/languageSlice";
import {
getCurrencyIsoCode,
getCurrencyPosition,
getCurrencySymbol,
} from "@/redux/reducer/settingSlice";
import { generateSlug, t } from "@/utils";
import PhoneInput from "react-phone-input-2";
import { useSelector } from "react-redux";
const ComponentTwo = ({
setTranslations,
current,
langId,
handleDetailsSubmit,
handleDeatilsBack,
is_job_category,
isPriceOptional,
defaultLangId,
currencies,
}) => {
const currencyPosition = useSelector(getCurrencyPosition);
const currencySymbol = useSelector(getCurrencySymbol);
const currencyIsoCode = useSelector(getCurrencyIsoCode);
const isRTL = useSelector(getIsRtl);
const selectedCurrency = currencies?.find(
(curr) => curr?.id === current?.currency_id
);
// Use selected currency's symbol and position, or fallback to Redux settings
const displaySymbol = selectedCurrency?.symbol || currencySymbol;
const displayPosition = selectedCurrency?.position || currencyPosition;
const placeholderLabel =
displayPosition === "right" ? `00 ${displaySymbol}` : `${displaySymbol} 00`;
const handleField = (field) => (e) => {
const value = e.target.value;
setTranslations((prev) => {
const updatedLangData = {
...prev[langId],
[field]: value,
};
// ✅ Only auto-generate slug if default language and field is title
if (field === "name" && langId === defaultLangId) {
updatedLangData.slug = generateSlug(value);
}
return {
...prev,
[langId]: updatedLangData,
};
});
};
const handlePhoneChange = (value, data) => {
const dial = data?.dialCode || ""; // Dial code like "91", "1"
const iso2 = data?.countryCode || ""; // Region code like "in", "us", "ae"
setTranslations((prev) => {
const pureMobile = value.startsWith(dial)
? value.slice(dial.length)
: value;
return {
...prev,
[langId]: {
...prev[langId],
contact: pureMobile,
country_code: dial,
region_code: iso2,
},
};
});
};
const handleCurrencyChange = (currencyId) => {
setTranslations((prev) => ({
...prev,
[langId]: {
...prev[langId],
currency_id: Number(currencyId),
},
}));
};
return (
<div className="flex flex-col w-full gap-6">
<div className="flex flex-col gap-2">
<Label
htmlFor="title"
className={langId === defaultLangId ? "requiredInputLabel" : ""}
>
{t("title")}
</Label>
<Input
type="text"
name="title"
id="title"
placeholder={t("enterTitle")}
value={current.name || ""}
onChange={handleField("name")} //here send param as the one we need to send to the api
/>
</div>
<div className="flex flex-col gap-2">
<Label
htmlFor="description"
className={langId === defaultLangId ? "requiredInputLabel" : ""}
>
{t("description")}
</Label>
<Textarea
name="description"
id="description"
cols="30"
rows="3"
placeholder={t("enterDescription")}
className="border rounded-md px-4 py-2 outline-none"
value={current.description || ""}
onChange={handleField("description")}
></Textarea>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="currency">{t("currency")}</Label>
{
currencies?.length > 0 ?
<Select
value={current.currency_id?.toString()}
onValueChange={handleCurrencyChange}
dir={isRTL ? "rtl" : "ltr"}
>
<SelectTrigger>
<SelectValue placeholder="Currency" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{currencies?.map((currency) => (
<SelectItem
key={currency.id}
value={currency.id.toString()}
>
{currency.iso_code} ({currency.symbol})
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
:
<Select
value={currencyIsoCode} // same default value you already have
disabled
dir={isRTL ? "rtl" : "ltr"}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{/* Required for RTL */}
<SelectContent>
<SelectGroup>
<SelectItem value={currencyIsoCode}>
{currencyIsoCode} ({currencySymbol})
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
}
</div>
{/* Render the rest only for default language */}
{langId === defaultLangId && (
<>
{is_job_category ? (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="salaryMin">{t("salaryMin")}</Label>
<Input
type="number"
name="salaryMin"
id="salaryMin"
min={0}
placeholder={placeholderLabel}
value={current.min_salary || ""}
onChange={handleField("min_salary")}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="salaryMax">{t("salaryMax")}</Label>
<Input
type="number"
min={0}
name="salaryMax"
id="salaryMax"
placeholder={placeholderLabel}
value={current.max_salary || ""}
onChange={handleField("max_salary")}
/>
</div>
</>
) : (
<div className="flex flex-col gap-2">
<Label
htmlFor="price"
className={
!isPriceOptional && langId === defaultLangId
? "requiredInputLabel"
: ""
}
>
{t("price")}
</Label>
<Input
type="number"
name="price"
id="price"
min={0}
placeholder={placeholderLabel}
value={current.price || ""}
onChange={handleField("price")}
/>
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="phonenumber">{t("phoneNumber")}</Label>
<PhoneInput
country={process.env.NEXT_PUBLIC_DEFAULT_COUNTRY}
value={`${current.country_code}${current.contact}`}
onChange={(phone, data) => handlePhoneChange(phone, data)}
inputProps={{
name: "phonenumber",
id: "phonenumber",
}}
enableLongNumbers
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="videoLink">{t("videoLink")}</Label>
<Input
type="text"
name="videoLink"
id="videoLink"
placeholder={t("enterAdditionalLinks")}
value={current.video_link || ""}
onChange={handleField("video_link")}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="slug">
{t("slug")}{" "}
<span className="text-muted-foreground text-xs">
({t("allowedSlug")})
</span>
</Label>
<Input
type="text"
name="slug"
id="slug"
placeholder={t("enterSlug")}
value={current.slug || ""}
onChange={handleField("slug")}
/>
</div>
</>
)}
<div className="flex justify-end gap-3">
<button
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
onClick={handleDeatilsBack}
>
{t("back")}
</button>
<button
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light"
onClick={handleDetailsSubmit}
>
{t("next")}
</button>
</div>
</div>
);
};
export default ComponentTwo;

View File

@@ -0,0 +1,853 @@
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import { useInView } from "react-intersection-observer";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
getAreasApi,
getCitiesApi,
getCoutriesApi,
getStatesApi,
} from "@/utils/api";
import { Textarea } from "@/components/ui/textarea";
import { t } from "@/utils";
const ManualAddress = ({
showManualAddress,
setShowManualAddress,
setLocation,
}) => {
const [CountryStore, setCountryStore] = useState({
Countries: [],
SelectedCountry: {},
CountrySearch: "",
currentPage: 1,
hasMore: false,
countryOpen: false,
isLoading: false,
});
const [StateStore, setStateStore] = useState({
States: [],
SelectedState: {},
StateSearch: "",
currentPage: 1,
hasMore: false,
stateOpen: false,
isLoading: false,
});
const [CityStore, setCityStore] = useState({
Cities: [],
SelectedCity: {},
CitySearch: "",
currentPage: 1,
hasMore: false,
isLoading: false,
cityOpen: false,
});
const [AreaStore, setAreaStore] = useState({
Areas: [],
SelectedArea: {},
AreaSearch: "",
currentPage: 1,
hasMore: false,
areaOpen: false,
isLoading: false,
});
const [Address, setAddress] = useState("");
const [fieldErrors, setFieldErrors] = useState({});
const isCountrySelected =
Object.keys(CountryStore?.SelectedCountry).length > 0;
// Check if areas exist for the selected city
const hasAreas = AreaStore?.Areas.length > 0;
// Infinite scroll refs
const { ref: stateRef, inView: stateInView } = useInView();
const { ref: countryRef, inView: countryInView } = useInView();
const { ref: cityRef, inView: cityInView } = useInView();
const { ref: areaRef, inView: areaInView } = useInView();
const getCountriesData = async (search, page) => {
try {
setCountryStore((prev) => ({
...prev,
isLoading: true,
Countries: search ? [] : prev.Countries, // Clear list if searching
}));
// Fetch countries
const params = {};
if (search) {
params.search = search; // Send only 'search' if provided
} else {
params.page = page; // Send only 'page' if no search
}
const res = await getCoutriesApi.getCoutries(params);
let allCountries;
if (page > 1) {
allCountries = [...CountryStore?.Countries, ...res?.data?.data?.data];
} else {
allCountries = res?.data?.data?.data;
}
setCountryStore((prev) => ({
...prev,
currentPage: res?.data?.data?.current_page,
Countries: allCountries,
hasMore:
res?.data?.data?.current_page < res?.data?.data?.last_page
? true
: false,
isLoading: false,
}));
} catch (error) {
console.error("Error fetching countries data:", error);
setCountryStore((prev) => ({ ...prev, isLoading: false }));
}
};
const getStatesData = async (search, page) => {
try {
setStateStore((prev) => ({
...prev,
isLoading: true,
States: search ? [] : prev.States,
}));
const params = {
country_id: CountryStore?.SelectedCountry?.id,
};
if (search) {
params.search = search; // Send only 'search' if provided
} else {
params.page = page; // Send only 'page' if no search
}
const res = await getStatesApi.getStates(params);
let allStates;
if (page > 1) {
allStates = [...StateStore?.States, ...res?.data?.data?.data];
} else {
allStates = res?.data?.data?.data;
}
setStateStore((prev) => ({
...prev,
currentPage: res?.data?.data?.current_page,
States: allStates,
hasMore:
res?.data?.data?.current_page < res?.data?.data?.last_page
? true
: false,
isLoading: false,
}));
} catch (error) {
console.error("Error fetching states data:", error);
setStateStore((prev) => ({ ...prev, isLoading: false }));
return [];
}
};
const getCitiesData = async (search, page) => {
try {
setCityStore((prev) => ({
...prev,
isLoading: true,
Cities: search ? [] : prev.Cities,
}));
const params = {
state_id: StateStore?.SelectedState?.id,
};
if (search) {
params.search = search; // Send only 'search' if provided
} else {
params.page = page; // Send only 'page' if no search
}
const res = await getCitiesApi.getCities(params);
let allCities;
if (page > 1) {
allCities = [...CityStore?.Cities, ...res?.data?.data?.data];
} else {
allCities = res?.data?.data?.data;
}
setCityStore((prev) => ({
...prev,
currentPage: res?.data?.data?.current_page,
Cities: allCities,
hasMore:
res?.data?.data?.current_page < res?.data?.data?.last_page
? true
: false,
isLoading: false,
}));
} catch (error) {
console.error("Error fetching cities data:", error);
setCityStore((prev) => ({ ...prev, isLoading: false }));
return [];
}
};
const getAreaData = async (search, page) => {
try {
setAreaStore((prev) => ({
...prev,
isLoading: true,
Areas: search ? [] : prev.Areas,
}));
const params = {
city_id: CityStore?.SelectedCity?.id,
};
if (search) {
params.search = search;
} else {
params.page = page;
}
const res = await getAreasApi.getAreas(params);
let allArea;
if (page > 1) {
allArea = [...AreaStore?.Areas, ...res?.data?.data?.data];
} else {
allArea = res?.data?.data?.data;
}
setAreaStore((prev) => ({
...prev,
currentPage: res?.data?.data?.current_page,
Areas: allArea,
hasMore:
res?.data?.data?.current_page < res?.data?.data?.last_page
? true
: false,
isLoading: false,
}));
} catch (error) {
console.error("Error fetching areas data:", error);
setAreaStore((prev) => ({ ...prev, isLoading: false }));
return [];
}
};
useEffect(() => {
const timeout = setTimeout(() => {
if (showManualAddress) {
getCountriesData(CountryStore?.CountrySearch, 1);
}
}, 500);
return () => {
clearTimeout(timeout);
};
}, [CountryStore?.CountrySearch, showManualAddress]);
useEffect(() => {
if (CountryStore?.SelectedCountry?.id) {
const timeout = setTimeout(() => {
getStatesData(StateStore?.StateSearch, 1);
}, 500);
return () => {
clearTimeout(timeout);
};
}
}, [CountryStore?.SelectedCountry?.id, StateStore?.StateSearch]);
useEffect(() => {
if (StateStore?.SelectedState?.id) {
const timeout = setTimeout(() => {
getCitiesData(CityStore?.CitySearch, 1);
}, 500);
return () => {
clearTimeout(timeout);
};
}
}, [StateStore?.SelectedState?.id, CityStore?.CitySearch]);
useEffect(() => {
if (CityStore?.SelectedCity?.id) {
const timeout = setTimeout(() => {
getAreaData(AreaStore?.AreaSearch, 1);
}, 500);
return () => {
clearTimeout(timeout);
};
}
}, [CityStore?.SelectedCity?.id, AreaStore?.AreaSearch]);
// Trigger infinite scroll when refs come into view
useEffect(() => {
if (CountryStore?.hasMore && !CountryStore?.isLoading && countryInView) {
getCountriesData("", CountryStore?.currentPage + 1);
}
}, [
countryInView,
CountryStore?.hasMore,
CountryStore?.isLoading,
CountryStore?.currentPage,
]);
useEffect(() => {
if (StateStore?.hasMore && !StateStore?.isLoading && stateInView) {
getStatesData("", StateStore?.currentPage + 1);
}
}, [
stateInView,
StateStore?.hasMore,
StateStore?.isLoading,
StateStore?.currentPage,
]);
useEffect(() => {
if (CityStore?.hasMore && !CityStore?.isLoading && cityInView) {
getCitiesData("", CityStore?.currentPage + 1);
}
}, [
cityInView,
CityStore?.hasMore,
CityStore?.isLoading,
CityStore?.currentPage,
]);
useEffect(() => {
if (AreaStore?.hasMore && !AreaStore?.isLoading && areaInView) {
getAreaData("", AreaStore?.currentPage + 1);
}
}, [
areaInView,
AreaStore?.hasMore,
AreaStore?.isLoading,
AreaStore?.currentPage,
]);
const validateFields = () => {
const errors = {};
if (!CountryStore?.SelectedCountry?.name) errors.country = true;
if (!StateStore?.SelectedState?.name) errors.state = true;
if (!CityStore?.SelectedCity?.name) errors.city = true;
if (!AreaStore?.SelectedArea?.name && !Address.trim())
errors.address = true;
return errors;
};
const handleCountryChange = (value) => {
const Country = CountryStore?.Countries.find(
(country) => country.name === value
);
setCountryStore((prev) => ({
...prev,
SelectedCountry: Country,
countryOpen: false,
}));
setStateStore({
States: [],
SelectedState: {},
StateSearch: "",
currentPage: 1,
hasMore: false,
stateOpen: false,
});
setCityStore({
Cities: [],
SelectedCity: {},
CitySearch: "",
currentPage: 1,
hasMore: false,
cityOpen: false,
});
setAreaStore({
Areas: [],
SelectedArea: {},
AreaSearch: "",
currentPage: 1,
hasMore: false,
areaOpen: false,
});
setAddress("");
};
const handleStateChange = (value) => {
const State = StateStore?.States.find((state) => state.name === value);
setStateStore((prev) => ({
...prev,
SelectedState: State,
stateOpen: false,
}));
setCityStore({
Cities: [],
SelectedCity: {},
CitySearch: "",
currentPage: 1,
hasMore: false,
cityOpen: false,
});
setAreaStore({
Areas: [],
SelectedArea: {},
AreaSearch: "",
currentPage: 1,
hasMore: false,
areaOpen: false,
});
setAddress("");
};
const handleCityChange = (value) => {
const City = CityStore?.Cities.find((city) => city.name === value);
setCityStore((prev) => ({
...prev,
SelectedCity: City,
cityOpen: false,
}));
setAreaStore({
Areas: [],
SelectedArea: {},
AreaSearch: "",
currentPage: 1,
hasMore: false,
areaOpen: false,
});
setAddress("");
};
const handleAreaChange = (value) => {
const chosenArea = AreaStore?.Areas.find((item) => item.name === value);
setAreaStore((prev) => ({
...prev,
SelectedArea: chosenArea,
areaOpen: false,
}));
};
const handleSave = () => {
const errors = validateFields();
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
// Build address parts array and filter out empty values
const addressParts = [];
const addressPartsTranslated = [];
if (hasAreas && AreaStore?.SelectedArea?.name) {
addressParts.push(AreaStore.SelectedArea.name);
addressPartsTranslated.push(
AreaStore.SelectedArea.translated_name || AreaStore.SelectedArea.name
);
} else if (Address.trim()) {
addressParts.push(Address.trim());
addressPartsTranslated.push(Address.trim());
}
if (CityStore?.SelectedCity?.name) {
addressParts.push(CityStore.SelectedCity.name);
addressPartsTranslated.push(
CityStore.SelectedCity.translated_name || CityStore.SelectedCity.name
);
}
if (StateStore?.SelectedState?.name) {
addressParts.push(StateStore.SelectedState.name);
addressPartsTranslated.push(
StateStore.SelectedState.translated_name ||
StateStore.SelectedState.name
);
}
if (CountryStore?.SelectedCountry?.name) {
addressParts.push(CountryStore.SelectedCountry.name);
addressPartsTranslated.push(
CountryStore.SelectedCountry.translated_name ||
CountryStore.SelectedCountry.name
);
}
const formattedAddress = addressParts.join(", ");
const formattedAddressTranslated = addressPartsTranslated.join(", ");
const locationData = {
country: CountryStore?.SelectedCountry?.name || "",
state: StateStore?.SelectedState?.name || "",
city: CityStore?.SelectedCity?.name || "",
formattedAddress: formattedAddress,
address_translated: formattedAddressTranslated,
lat: CityStore?.SelectedCity?.latitude || null,
long: CityStore?.SelectedCity?.longitude || null,
area_id: AreaStore?.SelectedArea?.id || null,
};
setLocation(locationData);
setShowManualAddress(false);
};
return (
<Dialog open={showManualAddress} onOpenChange={setShowManualAddress}>
<DialogContent className="">
<DialogHeader>
<DialogTitle>{t("manuAddAddress")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-1">
<Popover
modal
open={CountryStore?.countryOpen}
onOpenChange={(isOpen) =>
setCountryStore((prev) => ({ ...prev, countryOpen: isOpen }))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={CountryStore?.countryOpen}
className={`w-full justify-between outline-none ${fieldErrors.country ? "border-red-500" : ""
}`}
>
{CountryStore?.SelectedCountry?.translated_name ||
CountryStore?.SelectedCountry?.name ||
t("country")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{fieldErrors.country && (
<span className="text-red-500 text-sm">
{t("countryRequired")}
</span>
)}
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder={t("searchCountries")}
value={CountryStore.CountrySearch || ""}
onValueChange={(val) => {
setCountryStore((prev) => ({
...prev,
CountrySearch: val,
}));
}}
/>
<CommandEmpty>
{CountryStore.isLoading ? (
<LoacationLoader />
) : (
<div className="text-center">
<p className="text-sm text-muted-foreground">
{t("noCountriesFound")}
</p>
</div>
)}
</CommandEmpty>
<CommandGroup className="max-h-[240px] overflow-y-auto">
{CountryStore?.Countries?.map((country, index) => {
const isLast =
index === CountryStore?.Countries?.length - 1;
return (
<CommandItem
key={country.id}
value={country.name}
onSelect={handleCountryChange}
ref={isLast ? countryRef : null}
>
<Check
className={cn(
"mr-2 h-4 w-4",
CountryStore?.SelectedCountry?.name ===
country?.name
? "opacity-100"
: "opacity-0"
)}
/>
{country.translated_name || country.name}
</CommandItem>
);
})}
{CountryStore.isLoading &&
CountryStore.Countries.length > 0 && <LoacationLoader />}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex flex-col gap-1">
<Popover
modal
open={StateStore?.stateOpen}
onOpenChange={(isOpen) =>
setStateStore((prev) => ({ ...prev, stateOpen: isOpen }))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={StateStore?.stateOpen}
className={`w-full justify-between outline-none ${fieldErrors.state ? "border-red-500" : ""
}`}
disabled={!isCountrySelected}
>
{StateStore?.SelectedState?.translated_name ||
StateStore?.SelectedState?.name ||
t("state")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{fieldErrors.state && (
<span className="text-red-500 text-sm">
{t("stateRequired")}
</span>
)}
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder={t("searchStates")}
value={StateStore.StateSearch || ""}
onValueChange={(val) => {
setStateStore((prev) => ({ ...prev, StateSearch: val }));
}}
/>
<CommandEmpty>
{StateStore.isLoading ? (
<LoacationLoader />
) : (
<div className="text-center">
<p className="text-sm text-muted-foreground">
{t("noStatesFound")}
</p>
</div>
)}
</CommandEmpty>
<CommandGroup className="max-h-[240px] overflow-y-auto">
{StateStore?.States?.map((state, index) => {
const isLast = index === StateStore?.States?.length - 1;
return (
<CommandItem
key={state.id}
value={state.name}
onSelect={handleStateChange}
ref={isLast ? stateRef : null}
>
<Check
className={cn(
"mr-2 h-4 w-4",
StateStore?.SelectedState?.name === state?.name
? "opacity-100"
: "opacity-0"
)}
/>
{state.translated_name || state.name}
</CommandItem>
);
})}
{StateStore.isLoading && StateStore.States.length > 0 && (
<LoacationLoader />
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex flex-col gap-1">
<Popover
modal
open={CityStore?.cityOpen}
onOpenChange={(isOpen) =>
setCityStore((prev) => ({ ...prev, cityOpen: isOpen }))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={CityStore?.cityOpen}
className={`w-full justify-between outline-none ${fieldErrors.city ? "border-red-500" : ""
}`}
disabled={!StateStore?.SelectedState?.id}
>
{CityStore?.SelectedCity?.translated_name ||
CityStore?.SelectedCity?.name ||
t("city")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{fieldErrors.city && (
<span className="text-red-500 text-sm">
{t("cityRequired")}
</span>
)}
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder={t("searchCities")}
value={CityStore.CitySearch || ""}
onValueChange={(val) => {
setCityStore((prev) => ({ ...prev, CitySearch: val }));
}}
/>
<CommandEmpty>
{CityStore.isLoading ? (
<LoacationLoader />
) : (
<div className="text-center">
<p className="text-sm text-muted-foreground">
{t("noCitiesFound")}
</p>
</div>
)}
</CommandEmpty>
<CommandGroup className="max-h-[240px] overflow-y-auto">
{CityStore?.Cities?.map((city, index) => {
const isLast = index === CityStore?.Cities?.length - 1;
return (
<CommandItem
key={city.id}
value={city.name}
onSelect={handleCityChange}
ref={isLast ? cityRef : null}
>
<Check
className={cn(
"mr-2 h-4 w-4",
CityStore?.SelectedCity?.name === city?.name
? "opacity-100"
: "opacity-0"
)}
/>
{city.translated_name || city.name}
</CommandItem>
);
})}
{CityStore.isLoading && CityStore.Cities.length > 0 && (
<LoacationLoader />
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
{hasAreas || AreaStore?.AreaSearch ? (
<div className="flex flex-col gap-1">
<Popover
modal
open={AreaStore?.areaOpen}
onOpenChange={(isOpen) =>
setAreaStore((prev) => ({ ...prev, areaOpen: isOpen }))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={AreaStore?.areaOpen}
className={`w-full justify-between outline-none ${fieldErrors.address ? "border-red-500" : ""
}`}
disabled={!CityStore?.SelectedCity?.id}
>
{AreaStore?.SelectedArea?.translated_name ||
AreaStore?.SelectedArea?.name ||
t("area")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{fieldErrors.address && (
<span className="text-red-500 text-sm">
{t("areaRequired")}
</span>
)}
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder={t("searchAreas")}
value={AreaStore.AreaSearch || ""}
onValueChange={(val) => {
setAreaStore((prev) => ({ ...prev, AreaSearch: val }));
}}
/>
<CommandEmpty>
{AreaStore.isLoading ? (
<LoacationLoader />
) : (
<div className="text-center">
<p className="text-sm text-muted-foreground">
{t("noAreasFound")}
</p>
</div>
)}
</CommandEmpty>
<CommandGroup className="max-h-[240px] overflow-y-auto">
{AreaStore?.Areas?.map((area, index) => {
const isLast = index === AreaStore?.Areas?.length - 1;
return (
<CommandItem
key={area.id}
value={area.name}
onSelect={handleAreaChange}
ref={isLast ? areaRef : null}
>
<Check
className={cn(
"mr-2 h-4 w-4",
AreaStore?.SelectedArea?.name === area?.name
? "opacity-100"
: "opacity-0"
)}
/>
{area.translated_name || area.name}
</CommandItem>
);
})}
{AreaStore.isLoading && AreaStore.Areas.length > 0 && (
<LoacationLoader />
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
) : (
<div className="flex flex-col gap-1">
<Textarea
rows={5}
className={`border p-2 outline-none rounded-md w-full ${fieldErrors.address ? "border-red-500" : ""
}`}
placeholder={t("enterAddre")}
value={Address}
onChange={(e) => setAddress(e.target.value)}
disabled={!CityStore?.SelectedCity?.id}
/>
{fieldErrors.address && (
<span className="text-red-500 text-sm">
{t("addressRequired")}
</span>
)}
</div>
)}
</div>
<DialogFooter className="flex justify-end gap-2">
<button onClick={() => setShowManualAddress(false)}>
{t("cancel")}
</button>
<button
className="bg-primary p-2 px-4 rounded-md text-white font-medium"
onClick={handleSave}
>
{t("save")}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ManualAddress;
const LoacationLoader = () => {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-4 animate-spin" />
<span className="ml-2 text-sm text-muted-foreground">
{t("loading")}..
</span>
</div>
);
};

View File

@@ -0,0 +1,203 @@
"use client";
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
import { setBreadcrumbPath } from "@/redux/reducer/breadCrumbSlice";
import { formatDateMonthYear, t, truncate } from "@/utils";
import { getBlogsApi } from "@/utils/api";
import { useEffect, useState } from "react";
import { FaEye, FaRegCalendarCheck } from "react-icons/fa";
import { useDispatch, useSelector } from "react-redux";
import parse from "html-react-parser";
import {
FacebookShareButton,
TwitterShareButton,
WhatsappShareButton,
} from "react-share";
import { BiLink, BiLogoFacebook, BiLogoWhatsapp } from "react-icons/bi";
import { RiTwitterXLine } from "react-icons/ri";
import { usePathname } from "next/navigation";
import { toast } from "sonner";
import BlogCard from "../LandingPage/BlogCard";
import { Badge } from "@/components/ui/badge";
import Layout from "@/components/Layout/Layout";
import { getCompanyName } from "@/redux/reducer/settingSlice";
import PopularPosts from "../Blogs/PopularPosts";
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
import NoData from "@/components/EmptyStates/NoData";
import PageLoader from "@/components/Common/PageLoader";
import CustomImage from "@/components/Common/CustomImage";
const BlogDetailPage = ({ slug }) => {
const CurrentLanguage = useSelector(CurrentLanguageData);
const path = usePathname();
const dispatch = useDispatch();
const admin = useSelector((state) => state?.Settings?.data?.data?.admin);
const CompanyName = useSelector(getCompanyName);
const [blogData, setBlogData] = useState([]);
const [relatedBlogs, setRelatedBlogs] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const currentUrl = `${process.env.NEXT_PUBLIC_WEB_URL}${path}`;
const langCode = CurrentLanguage?.code?.toUpperCase();
useEffect(() => {
getBlogsData();
}, [CurrentLanguage.id]);
const getBlogsData = async () => {
try {
setIsLoading(true);
const res = await getBlogsApi.getBlogs({ slug: slug, views: 1 });
setBlogData(res?.data?.data?.data[0]);
const title = res?.data?.data?.data[0]?.title;
dispatch(
setBreadcrumbPath([
{
name: t("ourBlogs"),
slug: "/blogs",
},
{
name: truncate(title, 30),
},
])
);
setRelatedBlogs(res?.data?.other_blogs);
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
}
};
const handleCopyUrl = async () => {
try {
await navigator.clipboard.writeText(currentUrl);
toast(t("copyToClipboard"));
} catch (error) {
console.error("Error copying to clipboard:", error);
}
};
return (
<Layout>
<BreadCrumb />
<div className="container">
<div className="flex flex-col mt-8 gap-12">
<div className="grid md:grid-cols-12 grid-col-1 gap-6">
{isLoading ? (
<div className="col-span-1 md:col-span-8">
<PageLoader />
</div>
) : blogData ? (
<div className="col-span-1 md:col-span-8 flex flex-col gap-8">
<h1 className="text-3xl font-medium">
{blogData?.translated_title || blogData?.title}
</h1>
<div className="flex items-center flex-wrap gap-2 opacity-60 text-sm">
<div className="flex gap-2 items-center">
<CustomImage
src={admin?.profile}
alt={admin?.name || "Admin Image"}
height={28}
width={28}
className="size-7 aspect-square rounded-md"
/>
<p>{admin?.name}</p>
</div>
<div className="border-r h-[16px]"></div>
<div className="flex items-center gap-1">
<FaEye size={16} />
{t("views")}: {blogData?.views}
</div>
<div className="border-r h-[16px] "></div>
<div className="flex gap-2 items-center">
<FaRegCalendarCheck size={16} color="" />
{t("postedOn")}: {formatDateMonthYear(blogData?.created_at)}
</div>
</div>
<CustomImage
src={blogData?.image}
alt={blogData?.title || "Blog Image"}
height={838}
width={500}
className="w-full h-auto aspect-[838/500] rounded-lg"
/>
<div className="max-w-full prose lg:prose-lg">
{parse(
blogData?.translated_description ||
blogData?.description ||
""
)}
</div>
<div className="border-t pt-4 flex items-center justify-between ">
<div className="flex flex-col gap-2 opacity-60">
<span className="pb-2 font-sm ">{t("shareThis")}</span>
<div className="flex gap-3">
<button className="border-none" onClick={handleCopyUrl}>
<BiLink size={24} />
</button>
<FacebookShareButton
url={currentUrl}
title={currentUrl + CompanyName}
hashtag={CompanyName}
>
<BiLogoFacebook size={24} />
</FacebookShareButton>
<TwitterShareButton url={currentUrl}>
<RiTwitterXLine size={24} />
</TwitterShareButton>
<WhatsappShareButton
url={currentUrl}
title={
blogData?.translated_title ||
blogData?.title + "" + " - " + "" + CompanyName
}
hashtag={CompanyName}
>
<BiLogoWhatsapp size={24} />
</WhatsappShareButton>
</div>
</div>
<div>
{blogData?.translated_tags && (
<div className="flex gap-2 items-center flex-wrap justify-end">
{blogData?.translated_tags?.map((e) => (
<Badge
key={e}
variant="outline"
className="font-normal"
>
{e}
</Badge>
))}
</div>
)}
</div>
</div>
</div>
) : (
<div className="col-span-1 md:col-span-8">
<NoData name={t("blog")} />
</div>
)}
<div className="col-span-1 md:col-span-4">
<PopularPosts langCode={langCode} />
</div>
</div>
<div className="flex gap-8 flex-col">
<h1 className="text-2xl font-medium">{t("relatedArticle")}</h1>
<div className="grid md:grid-cols-12 grid-cols-1 gap-4">
{relatedBlogs &&
relatedBlogs?.map((blog, index) => (
<div className="md:col-span-4 col-span-12" key={index}>
<BlogCard blog={blog} />
</div>
))}
</div>
</div>
</div>
</div>
</Layout>
);
};
export default BlogDetailPage;

View File

@@ -0,0 +1,103 @@
"use client";
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
import { t } from "@/utils";
import { getBlogsApi } from "@/utils/api";
import { useEffect, useState } from "react";
import BlogCardSkeleton from "@/components/Skeletons/BlogCardSkeleton";
import BlogCard from "../LandingPage/BlogCard";
import Layout from "@/components/Layout/Layout";
import { Button } from "@/components/ui/button";
import NoData from "@/components/EmptyStates/NoData";
import Tags from "./Tags";
import { useSearchParams } from "next/navigation";
import { useSelector } from "react-redux";
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
const Blogs = () => {
const searchParams = useSearchParams();
const tag = searchParams?.get("tag");
const langCode = useSelector(getCurrentLangCode);
const [blogs, setBlogs] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [isLoadMore, setIsLoadMore] = useState(false);
useEffect(() => {
getBlogsData(currentPage);
}, [tag, langCode]);
const getBlogsData = async (page) => {
try {
page > 1 ? setIsLoadMore(true) : setIsLoading(true);
const res = await getBlogsApi.getBlogs({
sort_by: "new-to-old",
page,
...(tag && { tag }),
});
if (res?.data?.error === false) {
page === 1
? setBlogs(res?.data?.data?.data)
: setBlogs([...blogs, ...res?.data?.data?.data]);
setCurrentPage(res?.data?.data?.current_page);
setHasMore(res?.data?.data?.current_page < res?.data?.data?.last_page);
} else {
console.error(res?.data?.message);
}
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
setIsLoadMore(false);
}
};
const handleLoadMore = () => {
getBlogsData(currentPage + 1);
};
return (
<Layout>
<BreadCrumb title2={t("ourBlogs")} />
<div className="container">
<div className="flex flex-col mt-8 gap-6">
<h1 className="text-2xl font-medium">{t("ourBlogs")}</h1>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 lg:gap-4 ">
<div className="lg:col-span-8 col-span-12 order-2 lg:order-1">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{isLoading ? (
Array.from({ length: 6 })?.map((_, index) => (
<BlogCardSkeleton key={index} />
))
) : blogs && blogs?.length > 0 ? (
blogs?.map((blog) => <BlogCard key={blog?.id} blog={blog} />)
) : (
<div className="col-span-full">
<NoData name={t("blog")} />
</div>
)}
</div>
{hasMore && (
<div className="text-center mt-6 mb-2">
<Button
variant="outline"
className="text-sm sm:text-base text-primary w-[256px]"
disabled={isLoading || isLoadMore}
onClick={handleLoadMore}
>
{isLoadMore ? t("loading") : t("loadMore")}
</Button>
</div>
)}
</div>
<div className="col-span-12 lg:col-span-4 order-1 lg:order-2">
<Tags tag={tag} langCode={langCode} />
</div>
</div>
</div>
</div>
</Layout>
);
};
export default Blogs;

View File

@@ -0,0 +1,80 @@
import NoData from "@/components/EmptyStates/NoData";
import { Skeleton } from "@/components/ui/skeleton";
import { t } from "@/utils";
import { getBlogsApi } from "@/utils/api";
import CustomLink from "@/components/Common/CustomLink";
import { useEffect, useState } from "react";
import CustomImage from "@/components/Common/CustomImage";
const PopularPosts = ({ langCode }) => {
const [isPopularPostLoading, setIsPopularPostLoading] = useState(false);
const [popularBlogs, setPopulerBlogs] = useState([]);
useEffect(() => {
getPopulerBlogsData();
}, [langCode]);
const getPopulerBlogsData = async () => {
setIsPopularPostLoading(true);
try {
const res = await getBlogsApi.getBlogs({ sort_by: "popular" });
setPopulerBlogs(res?.data?.data?.data);
} catch (error) {
console.log(error);
} finally {
setIsPopularPostLoading(false);
}
};
return (
<div className="flex flex-col border rounded-xl">
<div className="p-4 border-b">
<p className="font-medium">{t("popularPosts")}</p>
</div>
<div className="flex flex-col gap-2">
{isPopularPostLoading ? (
Array.from({ length: 8 })?.map((_, index) => (
<PopularPostsSkeleton key={index} />
))
) : popularBlogs && popularBlogs?.length > 0 ? (
popularBlogs?.map((popularBlog) => (
<CustomLink
key={popularBlog?.id}
href={`/blogs/${popularBlog?.slug}`}
className="flex gap-3 px-4 py-2 items-center"
>
<CustomImage
src={popularBlog?.image}
alt={popularBlog?.title}
height={48}
width={64}
className="aspect-[64/48] rounded object-cover"
/>
<p className="line-clamp-3 font-medium">
{popularBlog?.translated_title || popularBlog?.title}
</p>
</CustomLink>
))
) : (
<div className="col-span-full">
<NoData name={t("popularPosts")} />
</div>
)}
</div>
</div>
);
};
export default PopularPosts;
const PopularPostsSkeleton = () => {
return (
<div className="flex gap-3 px-4 py-2 items-center">
<Skeleton className="h-12 w-16 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
);
};

View File

@@ -0,0 +1,82 @@
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { t } from "@/utils";
import { getBlogTagsApi } from "@/utils/api";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { useNavigate } from "@/components/Common/useNavigate";
const Tags = ({ tag, langCode }) => {
const pathname = usePathname();
const { navigate } = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [blogTags, setBlogTags] = useState([]);
const isAllTagActive = pathname === "/blogs" && !tag;
useEffect(() => {
getBlogTagsData();
}, [langCode]);
const getBlogTagsData = async () => {
try {
setIsLoading(true);
const res = await getBlogTagsApi.getBlogs();
setBlogTags(res?.data?.data);
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
}
};
const handleAllTags = () => {
navigate("/blogs", { scroll: false });
};
const handleTagClick = (tagItem) => {
window.history.pushState(null, "", `/blogs?tag=${tagItem}`);
};
return (
<div className="flex flex-col border rounded-lg ">
<div className="p-4">
<p className="font-bold">{t("tags")}</p>
</div>
<div className="border-b w-full"></div>
<div className="p-4 flex flex-wrap gap-2">
{isLoading ? (
Array.from({ length: 10 }).map((_, index) => (
<Skeleton key={index} className="w-20 h-8" />
))
) : (
<>
<button
className={cn(
"border px-4 text-sm py-2 rounded-md",
isAllTagActive && "border-primary text-primary"
)}
onClick={handleAllTags}
>
{t("all")}
</button>
{blogTags?.map((tagItem) => (
<button
key={tagItem.value}
className={cn(
"border px-4 text-sm py-2 rounded-md break-all",
tag === String(tagItem.value) && "border-primary text-primary"
)}
onClick={() => handleTagClick(tagItem.value)}
>
{tagItem.label}
</button>
))}
</>
)}
</div>
</div>
);
};
export default Tags;

View File

@@ -0,0 +1,228 @@
import { FaArrowRight, FaCheck } from "react-icons/fa";
import { formatPriceAbbreviated, t } from "@/utils";
import CustomImage from "@/components/Common/CustomImage";
import { useState } from "react";
const AddListingPlanCard = ({ pckg, handlePurchasePackage }) => {
const [isFlipped, setIsFlipped] = useState(false);
const descriptionItems =
Array.isArray(pckg?.translated_key_points) &&
pckg.translated_key_points.length > 0
? pckg.translated_key_points
: (pckg?.translated_description || pckg?.description || "")
.split("\r\n")
.filter(Boolean);
const isPackageActive = pckg?.is_active == 1;
const userPurchasedPackage = pckg?.user_purchased_packages?.[0]
const remainingDays = userPurchasedPackage?.remaining_days;
const remainingItems = userPurchasedPackage?.remaining_item_limit;
const totalDays = pckg?.duration;
const totalItems = pckg?.item_limit;
const listingDurationDays = isPackageActive ? userPurchasedPackage?.listing_duration_days : pckg?.listing_duration_days
return (
<div className="perspective-1000">
<div
className={`relative transition-transform duration-500 transform-style-preserve-3d ${isFlipped ? "rotate-y-180" : ""
}`}
>
<div
className={`backface-hidden rounded-lg relative p-4 sm:p-8 shadow-sm border ${isPackageActive == 1 ? "bg-primary !text-white" : "bg-white"
}`}
>
{/* Sale Badge */}
{pckg?.discount_in_percentage > 0 && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 z-20">
<span className="bg-primary text-white px-6 py-2 rounded-full text-sm font-medium whitespace-nowrap">
{t("save")} {pckg?.discount_in_percentage}% {t("off")}
</span>
</div>
)}
{/* Card Header */}
<div className="flex items-center gap-4">
<CustomImage
height={80}
width={80}
src={pckg.icon}
alt="Bronze medal"
className="aspect-square rounded-lg"
/>
<div className="flex flex-col gap-2 overflow-hidden">
<h2 className="text-xl font-medium mb-1 line-clamp-2 overflow-hidden">
{pckg?.translated_name || pckg?.name}
</h2>
<div className="flex items-center gap-1">
{pckg?.final_price !== 0 ? (
<p className="text-xl font-bold">
{formatPriceAbbreviated(pckg?.final_price)}
</p>
) : (
t("Free")
)}
{pckg?.price > pckg?.final_price && (
<p className="text-xl font-bold line-through text-gray-500">
{formatPriceAbbreviated(pckg?.price)}
</p>
)}
</div>
</div>
</div>
{/* Divider */}
<div className="border-t border-gray-200 my-6"></div>
<div className="h-[250px] overflow-y-auto mb-3">
<h6 className="text-base font-medium">Features List</h6>
{/* Feature List */}
<div className="flex flex-col gap-2 p-3 text-sm">
<div className="flex items-center gap-3">
<span
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
}`}
>
<FaCheck />
</span>
<span className="text-normal capitalize">
{t("packageValidity")}:{" "}
{isPackageActive
? `${remainingDays} / ${totalDays} ${t("days")}`
: `${totalDays} ${t("days")}`}
</span>
</div>
<div className="flex items-center gap-3">
<span
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
}`}
>
<FaCheck />
</span>
<span className="capitalize">
{t("listingDuration")}: {listingDurationDays} {t("days")}
</span>
</div>
<div className="flex items-center gap-3">
<span
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
}`}
>
<FaCheck />
</span>
<span className="text-normal">
{totalItems === "unlimited"
? t("unlimited")
: isPackageActive
? `${remainingItems} / ${totalItems}`
: totalItems}{" "}
{t("adsListing")}
</span>
</div>
{pckg.categories.length === 0 && (
<div className="flex items-center gap-3">
<span
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
}`}
>
<FaCheck />
</span>
<span className="text-normal ">{t("allCategoriesIncluded")}</span>
</div>
)}
{descriptionItems.map((item, index) => (
<div key={index} className="flex items-center gap-3">
<span
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
}`}
>
<FaCheck />
</span>
<span className="text-normal ">{item}</span>
</div>
))}
</div>
{pckg.categories.length > 0 && (
<>
<h6 className="text-base font-medium">{t("categoryIncludes")}</h6>
<div className="flex flex-col gap-2 p-3 text-sm">
{pckg.categories.slice(0, 2).map((category) => (
<div key={category.id} className="flex items-center gap-3">
<span
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
}`}
>
<FaCheck />
</span>
<span className="text-normal ">
{category.translated_name || category.name}
</span>
</div>
))}
</div>
{pckg.categories.length > 2 && (
<button
onClick={() => setIsFlipped(true)}
className={`text-sm underline px-3 ${isPackageActive == 1 ? "text-white" : "text-primary"
}`}
>
{t("seeMore")}
</button>
)}
</>
)}
</div>
<div className="flex items-center justify-center h-12 max-h-12 p-4 md:p-0">
<button
onClick={() => handlePurchasePackage(pckg)}
className={` w-full ${isPackageActive == 1 ? "hidden" : "flex"
} py-1 px-3 md:py-2 md:px-4 lg:py-3 lg:px-6 rounded-lg items-center text-primary justify-center hover:bg-primary border hover:text-white transition-all duration-300`}
>
<span className="font-light text-lg">{t("choosePlan")}</span>
<span className="ml-2">
<FaArrowRight size={20} className="rtl:scale-x-[-1]" />
</span>
</button>
</div>
</div>
<div
className={`absolute inset-0 rotate-y-180 backface-hidden rounded-lg p-4 sm:p-8 shadow-sm border
${isPackageActive == 1 ? "bg-primary text-white" : "bg-white"}
`}
>
<h6 className="text-lg font-medium mb-4">{t("allCategories")}</h6>
<div className="flex flex-col gap-2 overflow-y-auto max-h-[300px]">
{pckg.categories.map((category) => (
<div key={category.id} className="flex items-center gap-3">
<FaCheck className="text-primary" />
<span>{category.translated_name || category.name}</span>
</div>
))}
</div>
<button
onClick={() => setIsFlipped(false)}
className={`mt-4 text-sm underline ${isPackageActive == 1 ? "text-white" : "text-primary"
}`}
>
{t("back")}
</button>
</div>
</div>
</div >
);
};
export default AddListingPlanCard;

View File

@@ -0,0 +1,139 @@
import { t } from "@/utils";
import { RiUserForbidLine } from "react-icons/ri";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getBlockedUsers, unBlockUserApi } from "@/utils/api";
import { useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { useSelector } from "react-redux";
import { getIsRtl } from "@/redux/reducer/languageSlice";
import CustomImage from "@/components/Common/CustomImage";
const BlockedUsersMenu = ({ setSelectedChatDetails }) => {
const [blockedUsersList, setBlockedUsersList] = useState([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [unblockingId, setUnblockingId] = useState("");
const isRTL = useSelector(getIsRtl);
const fetchBlockedUsers = async () => {
setLoading(true);
try {
const response = await getBlockedUsers.blockedUsers();
const { data } = response;
setBlockedUsersList(data?.data);
} catch (error) {
console.error("Error fetching blocked users:", error);
} finally {
setLoading(false);
}
};
const handleOpenChange = (isOpen) => {
setOpen(isOpen);
if (isOpen) {
fetchBlockedUsers();
}
};
const handleUnblock = async (userId, e) => {
e.stopPropagation();
setUnblockingId(userId);
try {
const response = await unBlockUserApi.unBlockUser({
blocked_user_id: userId,
});
if (response?.data?.error === false) {
// Refresh the blocked users list after successful unblock
setBlockedUsersList((prevList) =>
prevList.filter((user) => user.id !== userId)
);
setSelectedChatDetails((prev) => ({
...prev,
user_blocked: false,
}));
toast.success(response?.data?.message);
}
} catch (error) {
console.error("Error unblocking user:", error);
} finally {
setUnblockingId("");
}
};
const BlockedUserSkeleton = () => (
<div className="flex items-center justify-between p-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<Skeleton className="h-10 w-10 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-8 w-16 rounded-md" />
</div>
);
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button className="focus:outline-none">
<RiUserForbidLine size={22} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={isRTL ? "start" : "end"} className="w-72">
<DropdownMenuLabel>{t("blockedUsers")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-64 overflow-y-auto">
{loading ? (
Array.from({ length: 2 }, (_, index) => (
<BlockedUserSkeleton key={index} />
))
) : blockedUsersList && blockedUsersList.length > 0 ? (
<DropdownMenuGroup>
{blockedUsersList.map((user) => (
<DropdownMenuItem
key={user.id}
className="flex items-center justify-between p-2"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="h-10 w-10 flex-shrink-0 rounded-full overflow-hidden bg-gray-200 relative">
<CustomImage
src={user?.profile}
alt={user.name}
fill
className="object-cover"
/>
</div>
<span className="truncate">{user.name}</span>
</div>
<button
onClick={(e) => handleUnblock(user?.id, e)}
disabled={unblockingId === user?.id}
className={`px-3 py-1 text-sm ${
unblockingId === user?.id
? "bg-gray-400 cursor-not-allowed"
: "bg-primary hover:bg-primary/80"
} text-white rounded-md flex-shrink-0 ml-2`}
>
{t("unblock")}
</button>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
) : (
<div className="p-4 text-center text-muted-foreground">
{t("noBlockedUsers")}
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default BlockedUsersMenu;

View File

@@ -0,0 +1,173 @@
"use client";
import SelectedChatHeader from "./SelectedChatHeader";
import ChatList from "./ChatList";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import NoChatFound from "./NoChatFound";
import ChatMessages from "./ChatMessages";
import { useSelector } from "react-redux";
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
import { useMediaQuery } from "usehooks-ts";
import { chatListApi } from "@/utils/api";
import { useNavigate } from "@/components/Common/useNavigate";
const Chat = () => {
const searchParams = useSearchParams();
const activeTab = searchParams.get("activeTab") || "selling";
const chatId = Number(searchParams.get("chatid")) || "";
const [selectedChatDetails, setSelectedChatDetails] = useState();
const langCode = useSelector(getCurrentLangCode);
const { navigate } = useNavigate();
const [IsLoading, setIsLoading] = useState(true);
const [buyer, setBuyer] = useState({
BuyerChatList: [],
CurrentBuyerPage: 1,
HasMoreBuyer: false,
});
const [seller, setSeller] = useState({
SellerChatList: [],
CurrentSellerPage: 1,
HasMoreSeller: false,
});
const isLargeScreen = useMediaQuery("(min-width: 1200px)");
const fetchSellerChatList = async (page = 1) => {
if (page === 1) {
setIsLoading(true);
}
try {
const res = await chatListApi.chatList({ type: "seller", page });
if (res?.data?.error === false) {
const data = res?.data?.data?.data;
const currentPage = res?.data?.data?.current_page;
const lastPage = res?.data?.data?.last_page;
setSeller((prev) => ({
...prev,
SellerChatList: page === 1 ? data : [...prev.SellerChatList, ...data],
CurrentSellerPage: currentPage,
HasMoreSeller: currentPage < lastPage,
}));
} else {
console.error(res?.data?.message);
}
} catch (error) {
console.error("Error fetching seller chat list:", error);
} finally {
setIsLoading(false);
}
};
const fetchBuyerChatList = async (page = 1) => {
if (page === 1) {
setIsLoading(true);
}
try {
const res = await chatListApi.chatList({ type: "buyer", page });
if (res?.data?.error === false) {
const data = res?.data?.data?.data;
const currentPage = res?.data?.data?.current_page;
const lastPage = res?.data?.data?.last_page;
setBuyer((prev) => ({
...prev,
BuyerChatList: page === 1 ? data : [...prev.BuyerChatList, ...data],
CurrentBuyerPage: currentPage,
HasMoreBuyer: currentPage < lastPage,
}));
} else {
console.log(res?.data?.message);
}
} catch (error) {
console.log("Error fetching buyer chat list:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
activeTab === "selling" ? fetchSellerChatList() : fetchBuyerChatList();
}, [activeTab, langCode]);
useEffect(() => {
if (chatId && activeTab === "selling" && seller.SellerChatList.length > 0) {
setSelectedChatDetails(
seller.SellerChatList.find((chat) => chat.id === chatId)
);
} else if (
chatId &&
activeTab === "buying" &&
buyer.BuyerChatList.length > 0
) {
setSelectedChatDetails(
buyer.BuyerChatList.find((chat) => chat.id === chatId)
);
} else if (!chatId) {
setSelectedChatDetails("");
}
}, [chatId, activeTab, seller.SellerChatList, buyer.BuyerChatList, langCode]);
const handleBack = () => {
const params = new URLSearchParams(searchParams.toString());
params.delete("chatid");
navigate(`?${params.toString()}`, { scroll: false });
};
return (
<div className="grid grid-cols-1 xl:grid-cols-12">
<div className="col-span-4">
{(isLargeScreen || !chatId || IsLoading) && (
<ChatList
chatId={chatId}
activeTab={activeTab}
buyer={buyer}
setBuyer={setBuyer}
langCode={langCode}
isLargeScreen={isLargeScreen}
seller={seller}
setSeller={setSeller}
IsLoading={IsLoading}
fetchSellerChatList={fetchSellerChatList}
fetchBuyerChatList={fetchBuyerChatList}
setSelectedChatDetails={setSelectedChatDetails}
/>
)}
</div>
{(isLargeScreen || chatId) && (
<div className="col-span-8">
{selectedChatDetails?.id ? (
<div className="ltr:xl:border-l rtl:lg:border-r h-[65vh] lg:h-[800px] flex flex-col">
<SelectedChatHeader
selectedChat={selectedChatDetails}
isSelling={activeTab === "selling"}
setSelectedChat={setSelectedChatDetails}
handleBack={handleBack}
isLargeScreen={isLargeScreen}
/>
<ChatMessages
selectedChatDetails={selectedChatDetails}
setSelectedChatDetails={setSelectedChatDetails}
isSelling={activeTab === "selling"}
setBuyer={setBuyer}
chatId={chatId}
/>
</div>
) : (
<div className="ltr:xl:border-l rtl:xl:border-r h-[60vh] lg:h-[800px] flex items-center justify-center">
<NoChatFound
isLargeScreen={isLargeScreen}
handleBack={handleBack}
/>
</div>
)}
</div>
)}
</div>
);
};
export default Chat;

View File

@@ -0,0 +1,119 @@
import { t } from "@/utils";
import ChatListCard from "./ChatListCard";
import ChatListCardSkeleton from "./ChatListCardSkeleton";
import BlockedUsersMenu from "./BlockedUsersMenu";
import NoChatListFound from "./NoChatListFound";
import InfiniteScroll from "react-infinite-scroll-component";
import CustomLink from "@/components/Common/CustomLink";
const ChatList = ({
chatId,
activeTab,
buyer,
setBuyer,
isLargeScreen,
seller,
setSeller,
IsLoading,
fetchSellerChatList,
fetchBuyerChatList,
setSelectedChatDetails
}) => {
const handleChatTabClick = (chat, isSelling) => {
if (isSelling) {
setSeller((prev) => ({
...prev,
SellerChatList: prev.SellerChatList.map((item) =>
item.id === chat.id ? { ...item, unread_chat_count: 0 } : item
),
}));
} else {
setBuyer((prev) => ({
...prev,
BuyerChatList: prev.BuyerChatList.map((item) =>
item.id === chat.id ? { ...item, unread_chat_count: 0 } : item
),
}));
}
};
return (
<div className="h-[60vh] max-h-[800px] flex flex-col lg:h-full">
{isLargeScreen && (
<div className="p-4 flex items-center gap-1 justify-between border-b">
<h4 className="font-medium text-xl">{t("chat")}</h4>
{/* Blocked Users Menu Component */}
<BlockedUsersMenu setSelectedChatDetails={setSelectedChatDetails} />
</div>
)}
<div className="flex items-center">
<CustomLink
href={`/chat?activeTab=selling`}
className={`py-4 flex-1 text-center border-b ${activeTab === "selling" ? "border-primary" : ""
}`}
scroll={false}
>
{t("selling")}
</CustomLink>
<CustomLink
href={`/chat?activeTab=buying`}
className={`py-4 flex-1 text-center border-b ${activeTab === "buying" ? "border-primary" : ""
}`}
scroll={false}
>
{t("buying")}
</CustomLink>
</div>
<div className="flex-1 overflow-y-auto" id="chatList">
<InfiniteScroll
dataLength={
activeTab === "buying"
? buyer.BuyerChatList?.length
: seller.SellerChatList?.length
}
next={() => {
activeTab === "buying"
? fetchBuyerChatList(buyer.CurrentBuyerPage + 1)
: fetchSellerChatList(seller.CurrentSellerPage + 1);
}}
hasMore={
activeTab === "buying" ? buyer.HasMoreBuyer : seller.HasMoreSeller
}
loader={Array.from({ length: 3 }, (_, index) => (
<ChatListCardSkeleton key={index} />
))}
scrollableTarget="chatList"
>
{IsLoading
? Array.from({ length: 8 }, (_, index) => (
<ChatListCardSkeleton key={index} />
))
: (() => {
const chatList =
activeTab === "selling"
? seller.SellerChatList
: buyer.BuyerChatList;
return chatList.length > 0 ? (
chatList.map((chat, index) => (
<ChatListCard
key={Number(chat.id) || index}
chat={chat}
isActive={chat?.id === chatId}
isSelling={activeTab === "selling"}
handleChatTabClick={handleChatTabClick}
/>
))
) : (
<div className="h-full flex items-center justify-center p-4">
<NoChatListFound />
</div>
);
})()}
</InfiniteScroll>
</div>
</div>
);
};
export default ChatList;

View File

@@ -0,0 +1,62 @@
import { formatTime } from "@/utils";
import CustomLink from "@/components/Common/CustomLink";
import CustomImage from "@/components/Common/CustomImage";
const ChatListCard = ({ chat, isSelling, isActive, handleChatTabClick }) => {
const user = isSelling ? chat?.buyer : chat?.seller;
const isUnread = chat?.unread_chat_count > 0;
return (
<CustomLink
scroll={false}
href={`/chat?activeTab=${isSelling ? "selling" : "buying"}&chatid=${
chat?.id
}`}
onClick={() => handleChatTabClick(chat, isSelling)}
className={`py-3 px-4 border-b flex items-center gap-4 cursor-pointer ${
isActive ? "bg-primary text-white" : ""
}`}
>
<div className="relative flex-shrink-0">
<CustomImage
src={user?.profile}
alt="User avatar"
width={56}
height={56}
className="w-[56px] h-auto aspect-square object-cover rounded-full"
/>
<CustomImage
src={chat?.item?.image}
alt="Item image"
width={24}
height={24}
className="w-[24px] h-auto aspect-square object-cover rounded-full absolute top-[32px] bottom-[-6px] right-[-6px]"
/>
</div>
<div className="flex flex-col gap-2 w-full min-w-0">
<div className="w-full flex items-center gap-1 justify-between min-w-0">
<h5 className="font-medium truncate" title={user?.name}>
{user?.name}
</h5>
<span className="text-xs">{formatTime(chat?.last_message_time)}</span>
</div>
<div className="flex items-center gap-1 justify-between">
<p
className="truncate text-sm"
title={chat?.item?.translated_name || chat?.item?.name}
>
{chat?.item?.translated_name || chat?.item?.name}
</p>
{isUnread && !isActive && (
<span className="flex items-center justify-center bg-primary text-white rounded-full px-2 py-1 text-xs">
{chat?.unread_chat_count}
</span>
)}
</div>
</div>
</CustomLink>
);
};
export default ChatListCard;

View File

@@ -0,0 +1,25 @@
import { Skeleton } from "@/components/ui/skeleton";
const ChatListCardSkeleton = () => {
return (
<div className="p-4 border-b">
<div className="flex items-start gap-3">
{/* Avatar skeleton */}
<Skeleton className="w-12 h-12 rounded-full" />
<div className="flex-1">
{/* Name skeleton */}
<Skeleton className="h-4 w-[40%] mb-2 rounded-md" />
{/* Message skeleton */}
<Skeleton className="h-3 w-[70%] rounded-md" />
</div>
{/* Time skeleton */}
<Skeleton className="h-3 w-[15%] rounded-md" />
</div>
</div>
)
}
export default ChatListCardSkeleton

View File

@@ -0,0 +1,343 @@
import { userSignUpData } from "@/redux/reducer/authSlice";
import {
formatChatMessageTime,
formatMessageDate,
formatPriceAbbreviated,
t,
} from "@/utils";
import { getMessagesApi } from "@/utils/api";
import { Fragment, useEffect, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { Skeleton } from "@/components/ui/skeleton";
import { Loader2, ChevronUp } from "lucide-react";
import dynamic from "next/dynamic";
const SendMessage = dynamic(() => import("./SendMessage"), { ssr: false });
import GiveReview from "./GiveReview";
import { getNotification } from "@/redux/reducer/globalStateSlice";
import CustomImage from "@/components/Common/CustomImage";
import { cn } from "@/lib/utils";
// Skeleton component for chat messages
const ChatMessagesSkeleton = () => {
return (
<div className="flex flex-col gap-4 w-full">
{/* Skeleton for date separator */}
{/* Received message skeletons */}
<div className="flex flex-col gap-1 w-[65%] max-w-[80%]">
<Skeleton className="h-16 w-full rounded-md" />
<Skeleton className="h-3 w-[30%] rounded-md" />
</div>
{/* Sent message skeletons */}
<div className="flex flex-col gap-1 w-[70%] max-w-[80%] self-end">
<Skeleton className="h-10 w-full rounded-md" />
<Skeleton className="h-3 w-[30%] self-end rounded-md" />
</div>
{/* Image message skeleton */}
<div className="flex flex-col gap-1 w-[50%] max-w-[80%]">
<Skeleton className="h-32 w-full rounded-md" />
<Skeleton className="h-3 w-[30%] rounded-md" />
</div>
{/* Audio message skeleton */}
<div className="flex flex-col gap-1 w-[60%] max-w-[80%] self-end">
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-3 w-[30%] self-end rounded-md" />
</div>
{/* Another message skeleton */}
<div className="flex flex-col gap-1 w-[45%] max-w-[80%]">
<Skeleton className="h-14 w-full rounded-md" />
<Skeleton className="h-3 w-[30%] rounded-md" />
</div>
<div className="flex flex-col gap-1 w-[60%] max-w-[80%] self-end">
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-3 w-[30%] self-end rounded-md" />
</div>
{/* Another message skeleton */}
<div className="flex flex-col gap-1 w-[45%] max-w-[80%]">
<Skeleton className="h-14 w-full rounded-md" />
<Skeleton className="h-3 w-[30%] rounded-md" />
</div>
</div>
);
};
const renderMessageContent = (message, isCurrentUser) => {
const baseTextClass = isCurrentUser
? "text-white bg-primary p-2 rounded-md w-fit"
: "text-black bg-border p-2 rounded-md w-fit";
const audioStyles = isCurrentUser ? "border-primary" : "border-border";
switch (message.message_type) {
case "audio":
return (
<audio
src={message.audio}
controls
className={`w-full sm:w-[70%] ${
isCurrentUser ? "self-end" : "self-start"
} rounded-md border-2 ${audioStyles}`}
controlsList="nodownload"
type="audio/mpeg"
preload="metadata"
/>
);
case "file":
return (
<div className={`${baseTextClass}`}>
<CustomImage
src={message.file}
alt="Chat Image"
className="rounded-md w-auto h-auto max-h-[250px] max-w-[250px] object-contain"
width={200}
height={200}
/>
</div>
);
case "file_and_text":
return (
<div className={`${baseTextClass} flex flex-col gap-2`}>
<CustomImage
src={message.file}
alt="Chat Image"
className="rounded-md w-auto h-auto max-h-[250px] max-w-[250px] object-contain"
width={200}
height={200}
/>
<div className="border-white/20">{message.message}</div>
</div>
);
default:
return (
<p
className={`${baseTextClass} whitespace-pre-wrap ${
isCurrentUser ? "self-end" : "self-start"
}`}
>
{message?.message}
</p>
);
}
};
const ChatMessages = ({
selectedChatDetails,
isSelling,
setSelectedChatDetails,
setBuyer,
chatId,
}) => {
const notification = useSelector(getNotification);
const [chatMessages, setChatMessages] = useState([]);
const [currentMessagesPage, setCurrentMessagesPage] = useState(1);
const [hasMoreChatMessages, setHasMoreChatMessages] = useState(false);
const [isLoadPrevMesg, setIsLoadPrevMesg] = useState(false);
const [IsLoading, setIsLoading] = useState(false);
const [showReviewDialog, setShowReviewDialog] = useState(false);
const lastMessageDate = useRef(null);
const isAskForReview =
!isSelling &&
selectedChatDetails?.item?.status === "sold out" &&
!selectedChatDetails?.item?.review &&
Number(selectedChatDetails?.item?.sold_to) ===
Number(selectedChatDetails?.buyer_id);
const user = useSelector(userSignUpData);
const userId = user?.id;
useEffect(() => {
if (selectedChatDetails?.id) {
fetchChatMessgaes(1);
}
}, [selectedChatDetails?.id]);
useEffect(() => {
if (
notification?.type === "chat" &&
Number(notification?.item_offer_id) === Number(chatId) &&
(notification?.user_type === "Seller" ? !isSelling : isSelling)
) {
const newMessage = {
message_type: notification?.message_type_temp,
message: notification?.message,
sender_id: Number(notification?.sender_id),
created_at: notification?.created_at,
audio: notification?.audio,
file: notification?.file,
id: Number(notification?.id),
item_offer_id: Number(notification?.item_offer_id),
updated_at: notification?.updated_at,
};
setChatMessages((prev) => [...prev, newMessage]);
}
}, [notification]);
const fetchChatMessgaes = async (page) => {
try {
page > 1 ? setIsLoadPrevMesg(true) : setIsLoading(true);
const response = await getMessagesApi.chatMessages({
item_offer_id: selectedChatDetails?.id,
page,
});
if (response?.data?.error === false) {
const currentPage = Number(response?.data?.data?.current_page);
const lastPage = Number(response?.data?.data?.last_page);
const hasMoreChatMessages = currentPage < lastPage;
const chatMessages = (response?.data?.data?.data).reverse();
setCurrentMessagesPage(currentPage);
setHasMoreChatMessages(hasMoreChatMessages);
page > 1
? setChatMessages((prev) => [...chatMessages, ...prev])
: setChatMessages(chatMessages);
}
} catch (error) {
console.log(error);
} finally {
setIsLoadPrevMesg(false);
setIsLoading(false);
}
};
return (
<>
<div className="flex-1 overflow-y-auto bg-muted p-4 flex flex-col gap-2.5 relative">
{IsLoading ? (
<ChatMessagesSkeleton />
) : (
<>
{/* Show review dialog if open */}
{showReviewDialog && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/20 p-4">
<div className="w-full max-w-md">
<GiveReview
itemId={selectedChatDetails?.item_id}
sellerId={selectedChatDetails?.seller_id}
onClose={() => setShowReviewDialog(false)}
onSuccess={handleReviewSuccess}
/>
</div>
</div>
)}
{/* button to load previous messages */}
{hasMoreChatMessages && !IsLoading && (
<div className="absolute top-3 left-0 right-0 z-10 flex justify-center pb-2">
<button
onClick={() => fetchChatMessgaes(currentMessagesPage + 1)}
disabled={isLoadPrevMesg}
className="text-primary text-sm font-medium px-3 py-1.5 bg-white/90 rounded-full shadow-md hover:bg-white flex items-center gap-1.5"
>
{isLoadPrevMesg ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("loading")}
</>
) : (
<>
<ChevronUp className="w-4 h-4" />
{t("loadPreviousMessages")}
</>
)}
</button>
</div>
)}
{/* offer price */}
{!hasMoreChatMessages &&
selectedChatDetails?.amount > 0 &&
(() => {
const isSeller = isSelling;
const containerClasses = `flex flex-col gap-1 rounded-md p-2 w-fit ${
isSeller ? "bg-border" : "bg-primary text-white self-end"
}`;
const label = isSeller ? t("offer") : t("yourOffer");
return (
<div className={containerClasses}>
<p className="text-sm">{label}</p>
<span className="text-xl font-medium">
{selectedChatDetails.formatted_amount}
</span>
</div>
);
})()}
{/* chat messages */}
{chatMessages &&
chatMessages.length > 0 &&
chatMessages.map((message) => {
const messageDate = formatMessageDate(message.created_at);
const showDateSeparator =
messageDate !== lastMessageDate.current;
if (showDateSeparator) {
lastMessageDate.current = messageDate;
}
return (
<Fragment key={message?.id}>
{showDateSeparator && (
<p className="text-xs bg-[#f1f1f1] py-1 px-2 rounded-lg text-muted-foreground my-5 mx-auto">
{messageDate}
</p>
)}
{message.sender_id === userId ? (
<div
className={cn(
"flex flex-col gap-1 max-w-[80%] self-end",
message.message_type === "audio" && "w-full"
)}
key={message?.id}
>
{renderMessageContent(message, true)}
<p className="text-xs text-muted-foreground ltr:text-right rtl:text-left">
{formatChatMessageTime(message?.created_at)}
</p>
</div>
) : (
<div
className={cn(
"flex flex-col gap-1 max-w-[80%]",
message.message_type === "audio" && "w-full"
)}
key={message?.id}
>
{renderMessageContent(message, false)}
<p className="text-xs text-muted-foreground ltr:text-left rtl:text-right">
{formatChatMessageTime(message?.created_at)}
</p>
</div>
)}
</Fragment>
);
})}
</>
)}
</div>
{isAskForReview && (
<GiveReview
key={`review-${selectedChatDetails?.id}`}
itemId={selectedChatDetails?.item_id}
setSelectedChatDetails={setSelectedChatDetails}
setBuyer={setBuyer}
/>
)}
<SendMessage
key={`send-${selectedChatDetails?.id}`}
selectedChatDetails={selectedChatDetails}
setChatMessages={setChatMessages}
/>
</>
);
};
export default ChatMessages;

View File

@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { FaStar } from "react-icons/fa";
import { t } from "@/utils";
import { addItemReviewApi } from "@/utils/api";
import { toast } from "sonner";
const GiveReview = ({ itemId, setSelectedChatDetails, setBuyer }) => {
const [rating, setRating] = useState(0);
const [hoveredRating, setHoveredRating] = useState(0);
const [review, setReview] = useState("");
const [errors, setErrors] = useState({
rating: "",
review: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleRatingClick = (selectedRating) => {
setRating(selectedRating);
setErrors((prev) => ({ ...prev, rating: "" }));
};
const handleMouseEnter = (starValue) => {
setHoveredRating(starValue);
};
const handleMouseLeave = () => {
setHoveredRating(0);
};
const handleReviewChange = (e) => {
setReview(e.target.value);
setErrors((prev) => ({ ...prev, review: "" }));
};
const validateForm = () => {
const newErrors = {
rating: "",
review: "",
};
let isValid = true;
if (rating === 0) {
newErrors.rating = t("pleaseSelectRating");
isValid = false;
}
if (!review.trim()) {
newErrors.review = t("pleaseWriteReview");
isValid = false;
}
setErrors(newErrors);
return isValid;
};
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
try {
setIsSubmitting(true);
const res = await addItemReviewApi.addItemReview({
item_id: itemId,
review,
ratings: rating,
});
if (res?.data?.error === false) {
toast.success(res?.data?.message);
setSelectedChatDetails((prev) => ({
...prev,
item: {
...prev.item,
review: res?.data?.data,
},
}));
setBuyer((prev) => ({
...prev,
BuyerChatList: prev.BuyerChatList.map((chatItem) =>
chatItem?.item?.id === Number(res?.data?.data?.item_id)
? {
...chatItem,
item: {
...chatItem.item,
review: res?.data?.data?.review, // use review from API
},
}
: chatItem
),
}));
setRating(0);
setReview("");
setErrors({
rating: "",
review: "",
});
} else {
toast.error(res?.data?.message);
}
} catch (error) {
console.log(error);
toast.error(t("somethingWentWrong"));
} finally {
setIsSubmitting(false);
}
};
return (
<div className="bg-muted p-4">
<div className="rounded-lg p-4 bg-white">
<div className="mb-5">
<h3 className="text-base font-medium mb-2">{t("rateSeller")}</h3>
<p className="text-sm text-gray-500 mb-3">{t("rateYourExp")}</p>
<div className="flex gap-2 mb-2">
{[1, 2, 3, 4, 5].map((starValue) => (
<button
key={starValue}
type="button"
className="p-1 focus:outline-none"
onClick={() => handleRatingClick(starValue)}
onMouseEnter={() => handleMouseEnter(starValue)}
onMouseLeave={handleMouseLeave}
aria-label={`Rate ${starValue} stars out of 5`}
tabIndex={0}
>
<FaStar
className={`text-3xl ${
(hoveredRating || rating) >= starValue
? "text-yellow-400"
: "text-gray-200"
}`}
/>
</button>
))}
</div>
{errors.rating && (
<p className="text-red-500 text-sm mt-1">{errors.rating}</p>
)}
</div>
<div className="mb-4">
<Textarea
placeholder={t("writeAReview")}
value={review}
onChange={handleReviewChange}
className={`min-h-[100px] resize-none border-gray-200 rounded ${
errors.review ? "border-red-500" : ""
}`}
/>
{errors.review && (
<p className="text-red-500 text-sm mt-1">{errors.review}</p>
)}
</div>
<div className="flex justify-end">
<Button
onClick={handleSubmit}
className="bg-primary text-white px-6"
disabled={isSubmitting}
>
{t("submit")}
</Button>
</div>
</div>
</div>
);
};
export default GiveReview;

View File

@@ -0,0 +1,21 @@
import { Button } from "@/components/ui/button";
import { t } from "@/utils";
import { MdArrowBack } from "react-icons/md";
const NoChatFound = ({ handleBack, isLargeScreen }) => {
return (
<div className="flex flex-col gap-3 text-center items-center justify-center">
<h5 className="text-primary text-2xl font-medium">{t("noChatFound")}</h5>
<p>{t("startConversation")}</p>
{!isLargeScreen && (
<Button className="w-fit" onClick={handleBack}>
<MdArrowBack size={20} className="rtl:scale-x-[-1]" />
{t("back")}
</Button>
)}
</div>
);
};
export default NoChatFound;

View File

@@ -0,0 +1,23 @@
import { t } from "@/utils";
import noChatListFound from "../../../public/assets/no_data_found_illustrator.svg";
import CustomImage from "@/components/Common/CustomImage";
const NoChatListFound = () => {
return (
<div className="flex flex-col items-center justify-center gap-2">
<CustomImage
src={noChatListFound}
alt="no chat list found"
width={200}
height={200}
className="w-[200px] h-auto aspect-square"
/>
<h3 className="font-medium text-2xl text-primary text-center">
{t("noConversationsFound")}
</h3>
<span className="text-sm text-center">{t("noChatsAvailable")}</span>
</div>
);
};
export default NoChatListFound;

View File

@@ -0,0 +1,137 @@
import { HiOutlineDotsVertical } from "react-icons/hi";
import { t } from "@/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import CustomLink from "@/components/Common/CustomLink";
import { blockUserApi, unBlockUserApi } from "@/utils/api";
import { toast } from "sonner";
import { useSelector } from "react-redux";
import { getIsRtl } from "@/redux/reducer/languageSlice";
import CustomImage from "@/components/Common/CustomImage";
import { MdArrowBack } from "react-icons/md";
const SelectedChatHeader = ({
selectedChat,
isSelling,
setSelectedChat,
handleBack,
isLargeScreen,
}) => {
const isBlocked = selectedChat?.user_blocked;
const userData = isSelling ? selectedChat?.buyer : selectedChat?.seller;
const itemData = selectedChat?.item;
const isRTL = useSelector(getIsRtl);
const handleBlockUser = async (id) => {
try {
const response = await blockUserApi.blockUser({
blocked_user_id: userData?.id,
});
if (response?.data?.error === false) {
setSelectedChat((prevData) => ({
...prevData,
user_blocked: true,
}));
toast.success(response?.data?.message);
} else {
toast.error(response?.data?.message);
}
} catch (error) {
console.log(error);
}
};
const handleUnBlockUser = async (id) => {
try {
const response = await unBlockUserApi.unBlockUser({
blocked_user_id: userData?.id,
});
if (response?.data.error === false) {
setSelectedChat((prevData) => ({
...prevData,
user_blocked: false,
}));
toast.success(response?.data?.message);
} else {
toast.error(response?.data?.message);
}
} catch (error) {
console.log(error);
}
};
return (
<div className="flex items-center justify-between gap-1 px-4 py-3 border-b">
<div className="flex items-center gap-4 min-w-0">
{!isLargeScreen && (
<button onClick={handleBack}>
<MdArrowBack size={20} className="rtl:scale-x-[-1]" />
</button>
)}
<div className="relative flex-shrink-0">
<CustomLink href={`/seller/${userData?.id}`}>
<CustomImage
src={userData?.profile}
alt="avatar"
width={56}
height={56}
className="w-[56px] h-auto aspect-square object-cover rounded-full"
/>
</CustomLink>
<CustomImage
src={userData?.profile}
alt="avatar"
width={24}
height={24}
className="w-[24px] h-auto aspect-square object-cover rounded-full absolute top-[32px] bottom-[-6px] right-[-6px]"
/>
</div>
<div className="flex flex-col gap-2 w-full min-w-0">
<CustomLink
href={`/seller/${userData?.id}`}
className="font-medium truncate"
title={userData?.name}
>
{userData?.name}
</CustomLink>
<p
className="truncate text-sm"
title={itemData?.translated_name || itemData?.name}
>
{itemData?.translated_name || itemData?.name}
</p>
</div>
</div>
{/* Dropdown Menu for Actions */}
<div className="flex flex-col gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="self-end">
<HiOutlineDotsVertical size={22} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={isRTL ? "start" : "end"}>
<DropdownMenuItem
className="cursor-pointer"
onClick={isBlocked ? handleUnBlockUser : handleBlockUser}
>
<span>{isBlocked ? t("unblock") : t("block")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="text-xs whitespace-nowrap">
{itemData?.formatted_price}
</div>
</div>
</div>
);
};
export default SelectedChatHeader;

View File

@@ -0,0 +1,256 @@
"use client";
import { sendMessageApi } from "@/utils/api";
import { useEffect, useState, useRef } from "react";
import { IoMdAttach, IoMdSend } from "react-icons/io";
import { FaMicrophone, FaRegStopCircle } from "react-icons/fa";
import { Loader2, X } from "lucide-react";
import { useReactMediaRecorder } from "react-media-recorder";
import { toast } from "sonner";
import { t } from "@/utils";
import CustomImage from "@/components/Common/CustomImage";
const SendMessage = ({ selectedChatDetails, setChatMessages }) => {
const isAllowToChat =
selectedChatDetails?.item?.status === "approved" ||
selectedChatDetails?.item?.status === "featured";
if (!isAllowToChat) {
return (
<div className="p-4 border-t text-center text-muted-foreground">
{t("thisAd")} {selectedChatDetails?.item?.status}
</div>
);
}
const id = selectedChatDetails?.id;
const [message, setMessage] = useState("");
const [selectedFile, setSelectedFile] = useState(null);
const [previewUrl, setPreviewUrl] = useState("");
const [isSending, setIsSending] = useState(false);
const fileInputRef = useRef(null);
// Voice recording setup
const { status, startRecording, stopRecording, mediaBlobUrl, error } =
useReactMediaRecorder({
audio: true,
blobPropertyBag: { type: "audio/mpeg" },
});
const isRecording = status === "recording";
const [recordingDuration, setRecordingDuration] = useState(0);
// Format recording duration as mm:ss
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
};
// Timer for recording
useEffect(() => {
let timer;
if (isRecording) {
timer = setInterval(() => {
setRecordingDuration((prev) => prev + 1);
}, 1000);
} else {
setRecordingDuration(0);
}
return () => clearInterval(timer);
}, [isRecording]);
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
stopRecording();
};
}, []);
// Handle recorded audio
useEffect(() => {
if (mediaBlobUrl && status === "stopped") {
handleRecordedAudio();
}
}, [mediaBlobUrl, status]);
const handleRecordedAudio = async () => {
try {
const response = await fetch(mediaBlobUrl);
const blob = await response.blob();
const audioFile = new File([blob], "recording.mp3", {
type: "audio/mpeg",
});
sendMessage(audioFile);
} catch (err) {
console.error("Error processing audio:", err);
toast.error("Failed to process recording");
}
};
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (!file) return;
// Check if file is an image
const allowedTypes = ["image/jpeg", "image/png", "image/jpg"];
if (!allowedTypes.includes(file.type)) {
toast.error("Only image files (JPEG, PNG, JPG) are allowed");
return;
}
// Create preview URL for image
const fileUrl = URL.createObjectURL(file);
setPreviewUrl(fileUrl);
setSelectedFile(file);
};
const removeSelectedFile = () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setSelectedFile(null);
setPreviewUrl("");
// Reset file input value to allow selecting the same file again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const sendMessage = async (audioFile = null) => {
if ((!message.trim() && !selectedFile && !audioFile) || isSending) return;
const params = {
item_offer_id: id,
message: message ? message : "",
file: selectedFile ? selectedFile : "",
audio: audioFile ? audioFile : "",
};
try {
setIsSending(true);
const response = await sendMessageApi.sendMessage(params);
if (!response?.data?.error) {
setChatMessages((prev) => [...prev, response.data.data]);
setMessage("");
removeSelectedFile();
} else {
toast.error(response?.data?.message || "Failed to send message");
}
} catch (error) {
console.error(error);
toast.error("Error sending message");
} finally {
setIsSending(false);
}
};
const handleVoiceButtonClick = () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
if (error) {
console.log(error);
switch (error) {
case "permission_denied":
toast.error(t("microphoneAccessDenied"));
break;
case "no_specified_media_found":
toast.error(t("noMicrophoneFound"));
break;
default:
toast.error(t("somethingWentWrong"));
}
}
}
};
return (
<div className="flex flex-col">
{/* File Preview */}
{previewUrl && (
<div className="px-4 pt-2 pb-1">
<div className="relative w-32 h-32 border rounded-md overflow-hidden group">
<CustomImage
src={previewUrl}
alt="File preview"
fill
className="object-contain"
/>
<button
onClick={removeSelectedFile}
className="absolute top-1 right-1 bg-black/70 text-white p-1 rounded-full opacity-70 hover:opacity-100"
>
<X size={14} />
</button>
</div>
</div>
)}
{/* Input Area */}
<div className="p-4 border-t flex items-center gap-2">
{!isRecording && (
<>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/jpeg,image/png,image/jpg"
onChange={handleFileSelect}
/>
<button
onClick={() => fileInputRef.current.click()}
aria-label="Attach file"
>
<IoMdAttach size={20} className="text-muted-foreground" />
</button>
</>
)}
{isRecording ? (
<div className="flex-1 py-2 px-3 bg-red-50 text-red-500 rounded-md flex items-center justify-center font-medium">
{t("recording")} {formatDuration(recordingDuration)}
</div>
) : (
<textarea
type="text"
placeholder="Message..."
className="flex-1 outline-none border px-3 py-1 rounded-md"
value={message}
rows={2}
onChange={(e) => setMessage(e.target.value)}
/>
)}
<button
className="p-2 bg-primary text-white rounded-md"
disabled={isSending}
onClick={
message.trim() || selectedFile
? () => sendMessage()
: handleVoiceButtonClick
}
>
{isSending ? (
<Loader2 size={20} className="animate-spin" />
) : message.trim() || selectedFile ? (
<IoMdSend size={20} className="rtl:scale-x-[-1]" />
) : isRecording ? (
<FaRegStopCircle size={20} />
) : (
<FaMicrophone size={20} />
)}
</button>
</div>
</div>
);
};
export default SendMessage;

View File

@@ -0,0 +1,380 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { settingsData } from "@/redux/reducer/settingSlice";
import { t } from "@/utils";
import { useState } from "react";
import {
FaInstagram,
FaFacebook,
FaLinkedin,
FaPinterest,
} from "react-icons/fa";
import { GrLocation } from "react-icons/gr";
import { RiMailSendLine } from "react-icons/ri";
import { useSelector } from "react-redux";
import { TbPhoneCall } from "react-icons/tb";
import { FaSquareXTwitter } from "react-icons/fa6";
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
import { contactUsApi } from "@/utils/api";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import Layout from "@/components/Layout/Layout";
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
import parse from "html-react-parser";
import Link from "next/link";
const ContactUs = () => {
const CurrentLanguage = useSelector(CurrentLanguageData);
const settings = useSelector(settingsData);
const [IsLoading, setIsLoading] = useState(false);
const contactUs = settings?.contact_us;
const [formData, setFormData] = useState({
name: "",
email: "",
subject: "",
message: "",
});
const [errors, setErrors] = useState({
name: "",
email: "",
subject: "",
message: "",
});
const validateEmail = (email) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value,
});
// Clear error when user starts typing
setErrors({
...errors,
[name]: "",
});
};
const validateForm = () => {
let isValid = true;
const newErrors = {};
// Name validation
if (!formData.name.trim()) {
newErrors.name = t("nameRequired");
isValid = false;
}
// Email validation
if (!formData.email.trim()) {
newErrors.email = t("emailRequired");
isValid = false;
} else if (!validateEmail(formData.email)) {
newErrors.email = t("invalidEmail");
isValid = false;
}
// Subject validation
if (!formData.subject.trim()) {
newErrors.subject = t("subjectRequired");
isValid = false;
}
// Message validation
if (!formData.message.trim()) {
newErrors.message = t("messageRequired");
isValid = false;
}
setErrors(newErrors);
return isValid;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (validateForm()) {
try {
setIsLoading(true);
const res = await contactUsApi.contactUs(formData);
if (res?.data?.error === false) {
toast.success(t("thankForContacting"));
setFormData({
name: "",
email: "",
subject: "",
message: "",
});
} else {
toast.error(t("errorOccurred"));
}
} catch (error) {
toast.error(t("errorOccurred"));
console.log(error);
} finally {
setIsLoading(false);
}
}
};
return (
<Layout>
<BreadCrumb title2={t("contactUs")} />
<div className="container">
<h1 className="sectionTitle mt-8">{t("contactUs")}</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 mt-6 border rounded-lg">
{/* Contact Form */}
<div className="lg:col-span-2 p-4 sm:p-6 rounded-lg">
<h2 className="text-lg sm:text-xl font-medium mb-2">
{t("sendMessage")}
</h2>
<p className="text-sm sm:text-base text-muted-foreground mb-6">
{t("contactIntro")}
</p>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="labelInputCont">
<Label htmlFor="name" className="requiredInputLabel">
{t("name")}
</Label>
<Input
id="name"
name="name"
type="text"
placeholder={t("enterName")}
value={formData.name}
onChange={handleChange}
className={
errors.name
? "border-red-500 focus-visible:ring-red-500"
: ""
}
/>
{errors.name && (
<span className="text-red-500 text-sm">
{errors.name}
</span>
)}
</div>
<div className="labelInputCont">
<Label htmlFor="email" className="requiredInputLabel">
{t("email")}
</Label>
<Input
id="email"
name="email"
type="text"
placeholder={t("enterEmail")}
value={formData.email}
onChange={handleChange}
className={
errors.email
? "border-red-500 focus-visible:ring-red-500"
: ""
}
/>
{errors.email && (
<span className="text-red-500 text-sm">
{errors.email}
</span>
)}
</div>
</div>
<div className="labelInputCont">
<Label htmlFor="subject" className="requiredInputLabel">
{t("subject")}
</Label>
<Input
id="subject"
name="subject"
type="text"
placeholder={t("enterSubject")}
value={formData.subject}
onChange={handleChange}
className={
errors.subject
? "border-red-500 focus-visible:ring-red-500"
: ""
}
/>
{errors.subject && (
<span className="text-red-500 text-sm">
{errors.subject}
</span>
)}
</div>
<div className="labelInputCont">
<Label htmlFor="message" className="requiredInputLabel">
{t("message")}
</Label>
<Textarea
id="message"
name="message"
placeholder={t("enterMessage")}
value={formData.message}
onChange={handleChange}
className={
errors.message
? "border-red-500 focus-visible:ring-red-500"
: ""
}
/>
{errors.message && (
<span className="text-red-500 text-sm">
{errors.message}
</span>
)}
</div>
<div className="flex justify-end">
<Button type="submit" disabled={IsLoading}>
{IsLoading ? (
<>
<Loader2 className="animate-spin" />
{t("submitting")}
</>
) : (
t("submit")
)}
</Button>
</div>
</div>
</form>
</div>
{/* Contact Information */}
<div className="bg-[#1a1a1a] text-white p-4 sm:p-6 rounded-lg">
<h2 className="text-lg sm:text-xl font-medium mb-6">
{t("contactInfo")}
</h2>
<div className="space-y-6">
<div className="max-w-full prose lg:prose-lg prose-invert">
{parse(contactUs || "")}
</div>
{settings?.company_address && (
<div className="flex items-center gap-4">
<div className="footerSocialLinks">
<GrLocation size={24} />
</div>
<p className="text-sm text-white/65 hover:text-primary">
{settings?.company_address}
</p>
</div>
)}
{settings?.company_email && (
<div className="flex items-center gap-4">
<div className="footerSocialLinks">
<RiMailSendLine size={24} />
</div>
<Link
href={`mailto:${settings?.company_email}`}
className="text-sm text-white/65 hover:text-primary"
>
{settings?.company_email}
</Link>
</div>
)}
{settings?.company_tel1 && settings?.company_tel2 && (
<div className="flex items-center gap-4">
<div className="footerSocialLinks">
<TbPhoneCall size={24} />
</div>
<div className="flex flex-col gap-1">
<Link
href={`tel:${settings?.company_tel1}`}
className="text-sm text-white/65 hover:text-primary"
>
{settings?.company_tel1}
</Link>
<Link
href={`tel:${settings?.company_tel2}`}
className="text-sm text-white/65 hover:text-primary"
>
{settings?.company_tel2}
</Link>
</div>
</div>
)}
<div>
<h3 className="text-lg sm:text-xl font-medium mb-6">
{t("socialMedia")}
</h3>
<div className="flex flex-wrap gap-4">
{settings?.facebook_link && (
<Link
href={settings?.facebook_link}
className="footerSocialLinks"
>
<FaFacebook size={24} />
</Link>
)}
{settings?.instagram_link && (
<Link
href={settings?.instagram_link}
className="footerSocialLinks"
>
<FaInstagram size={22} />
</Link>
)}
{settings?.x_link && (
<Link
href={settings?.x_link}
className="footerSocialLinks"
>
<FaSquareXTwitter size={22} />
</Link>
)}
{settings?.linkedin_link && (
<Link
href={settings?.linkedin_link}
className="footerSocialLinks"
>
<FaLinkedin size={24} />
</Link>
)}
{settings?.pinterest_link && (
<Link
href={settings?.pinterest_link}
className="footerSocialLinks"
>
<FaPinterest size={24} />
</Link>
)}
</div>
</div>
{settings?.google_map_iframe_link && (
<iframe
src={settings?.google_map_iframe_link}
width="100%"
height="200"
className="aspect-[432/189] w-full rounded mt-6"
/>
)}
</div>
</div>
</div>
</div>
</Layout>
);
};
export default ContactUs;

View File

@@ -0,0 +1,53 @@
import {
Dialog,
DialogContent,
} from "@/components/ui/dialog";
import trueGif from "../../../public/assets/true.gif";
import CustomLink from "@/components/Common/CustomLink";
import { t } from "@/utils";
import CustomImage from "@/components/Common/CustomImage";
const AdEditSuccessModal = ({
openSuccessModal,
setOpenSuccessModal,
createdAdSlug,
}) => {
const closeSuccessModal = () => {
setOpenSuccessModal(false);
};
return (
<Dialog open={openSuccessModal} onOpenChange={closeSuccessModal}>
<DialogContent
className="[&>button]:hidden lgmax-w-[100px]"
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<div className="flex flex-col items-center justify-center gap-4">
<div className="flex flex-col justify-center items-center">
<CustomImage
src={trueGif}
alt="success"
height={176}
width={176}
className="h-44 w-44"
/>
<h2 className="text-3xl font-semibold">{t("adEditedSuccess")}</h2>
</div>
<CustomLink
href={`/my-listing/${createdAdSlug}`}
className="py-3 px-6 bg-primary text-white rounded-md"
>
{t("viewAd")}
</CustomLink>
<CustomLink href="/" className="">
{t("backToHome")}
</CustomLink>
</div>
</DialogContent>
</Dialog>
);
};
export default AdEditSuccessModal;

View File

@@ -0,0 +1,177 @@
import { useState } from "react";
import { BiMapPin } from "react-icons/bi";
import { FaLocationCrosshairs } from "react-icons/fa6";
import { IoLocationOutline } from "react-icons/io5";
import ManualAddress from "../AdsListing/ManualAddress";
import { t } from "@/utils";
import { getIsPaidApi } from "@/redux/reducer/settingSlice";
import { useSelector } from "react-redux";
import dynamic from "next/dynamic";
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
import LandingAdEditSearchAutocomplete from "@/components/Location/LandingAdEditSearchAutocomplete";
import { getIsBrowserSupported } from "@/redux/reducer/locationSlice";
import { Loader2 } from "lucide-react";
import useGetLocation from "@/components/Layout/useGetLocation";
const MapComponent = dynamic(() => import("@/components/Common/MapComponent"), {
ssr: false,
loading: () => <div className="w-full h-[400px] bg-gray-100 rounded-lg" />,
});
const EditComponentFour = ({
location,
setLocation,
handleFullSubmission,
isAdPlaced,
handleGoBack,
}) => {
const isBrowserSupported = useSelector(getIsBrowserSupported);
const CurrentLanguage = useSelector(CurrentLanguageData);
const [showManualAddress, setShowManualAddress] = useState();
const [IsGettingCurrentLocation, setIsGettingCurrentLocation] =
useState(false);
const IsPaidApi = useSelector(getIsPaidApi);
const { fetchLocationData } = useGetLocation();
const getCurrentLocation = async () => {
if (navigator.geolocation) {
setIsGettingCurrentLocation(true);
navigator.geolocation.getCurrentPosition(
async (position) => {
try {
const { latitude, longitude } = position.coords;
const data = await fetchLocationData({
lat: latitude,
lng: longitude,
});
setLocation(data);
} catch (error) {
console.error("Error fetching location data:", error);
toast.error(t("errorOccurred"));
} finally {
setIsGettingCurrentLocation(false);
}
},
(error) => {
toast.error(t("locationNotGranted"));
setIsGettingCurrentLocation(false);
}
);
} else {
toast.error(t("geoLocationNotSupported"));
}
};
const getLocationWithMap = async (pos) => {
try {
const data = await fetchLocationData(pos);
setLocation(data);
} catch (error) {
console.error("Error fetching location data:", error);
toast.error(t("errorOccurred"));
}
};
return (
<>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-8">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3">
<h5 className="text-xl font-medium">{t("addLocation")}</h5>
<div className="flex items-center gap-2 border rounded-md w-full md:w-96 min-h-[42px]">
<LandingAdEditSearchAutocomplete
saveOnSuggestionClick={false}
setSelectedLocation={setLocation}
/>
{isBrowserSupported && (
<button
onClick={getCurrentLocation}
disabled={IsGettingCurrentLocation}
className="bg-primary p-2 text-white gap-2 flex items-center rounded-md h-10"
>
<span>
{IsGettingCurrentLocation ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<FaLocationCrosshairs size={16} />
)}
</span>
<span className="whitespace-nowrap hidden md:inline">
{IsGettingCurrentLocation ? t("loading") : t("locateMe")}
</span>
</button>
)}
</div>
</div>
<div className="flex gap-8 flex-col">
<MapComponent
location={location}
getLocationWithMap={getLocationWithMap}
/>
<div className="flex items-center gap-3 bg-muted rounded-lg p-4 ">
<div className="p-5 rounded-md bg-white">
<BiMapPin className="text-primary" size={32} />
</div>
<span className="flex flex-col gap-1">
<h6 className="font-medium">{t("address")}</h6>
{location?.address_translated || location?.formattedAddress ? (
<p>
{location?.address_translated || location?.formattedAddress}
</p>
) : (
t("addYourAddress")
)}
</span>
</div>
</div>
{!IsPaidApi && (
<>
<div className="relative flex items-center justify-center ">
<div className="absolute top-1/2 left-0 right-0 h-px bg-[#d3d3d3]"></div>
<div className="relative bg-muted text-black text-base font-medium rounded-full w-12 h-12 flex items-center justify-center uppercase">
{t("or")}
</div>
</div>
<div className="flex flex-col gap-3 items-center justify-center ">
<p className="text-xl font-semibold">
{t("whatLocAdYouSelling")}
</p>
<button
className="p-2 flex items-center gap-2 border rounded-md font-medium"
onClick={() => setShowManualAddress(true)}
>
<IoLocationOutline size={20} />
{t("addLocation")}
</button>
</div>
</>
)}
</div>
<div className="flex justify-end gap-3">
<button
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
onClick={handleGoBack}
>
{t("back")}
</button>
<button
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light0 disabled:bg-gray-500"
disabled={isAdPlaced}
onClick={handleFullSubmission}
>
{isAdPlaced ? t("posting") : t("postNow")}
</button>
</div>
</div>
<ManualAddress
key={showManualAddress}
showManualAddress={showManualAddress}
setShowManualAddress={setShowManualAddress}
setLocation={setLocation}
/>
</>
);
};
export default EditComponentFour;

View File

@@ -0,0 +1,289 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { getIsRtl } from "@/redux/reducer/languageSlice";
import {
getCurrencyIsoCode,
getCurrencyPosition,
getCurrencySymbol,
} from "@/redux/reducer/settingSlice";
import { generateSlug, t } from "@/utils";
import PhoneInput from "react-phone-input-2";
import { useSelector } from "react-redux";
const EditComponentOne = ({
setTranslations,
current,
langId,
defaultLangId,
handleDetailsSubmit,
is_job_category,
isPriceOptional,
currencies,
}) => {
const currencyPosition = useSelector(getCurrencyPosition);
const currencySymbol = useSelector(getCurrencySymbol);
const currencyIsoCode = useSelector(getCurrencyIsoCode);
const isRTL = useSelector(getIsRtl);
const selectedCurrency = currencies?.find(
(curr) => curr?.id === current?.currency_id
);
// Use selected currency's symbol and position, or fallback to Redux settings
const displaySymbol = selectedCurrency?.symbol || currencySymbol;
const displayPosition = selectedCurrency?.position || currencyPosition;
const placeholderLabel =
displayPosition === "right" ? `00 ${displaySymbol}` : `${displaySymbol} 00`;
const handleField = (field) => (e) => {
const value = e.target.value;
setTranslations((prev) => {
const updatedLangData = {
...prev[langId],
[field]: value,
};
// ✅ Only auto-generate slug if default language and field is title
if (field === "name" && langId === defaultLangId) {
updatedLangData.slug = generateSlug(value);
}
return {
...prev,
[langId]: updatedLangData,
};
});
};
const handlePhoneChange = (value, data) => {
const dial = data?.dialCode || ""; // Dial code like "91", "1"
const iso2 = data?.countryCode || ""; // Region code like "in", "us", "ae"
setTranslations((prev) => {
const pureMobile = value.startsWith(dial)
? value.slice(dial.length)
: value;
return {
...prev,
[langId]: {
...prev[langId],
contact: pureMobile,
country_code: dial,
region_code: iso2,
},
};
});
};
const handleCurrencyChange = (currencyId) => {
setTranslations((prev) => ({
...prev,
[langId]: {
...prev[langId],
currency_id: Number(currencyId),
},
}));
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Label
htmlFor="title"
className={langId === defaultLangId ? "requiredInputLabel" : ""}
>
{t("title")}
</Label>
<Input
type="text"
name="title"
id="title"
placeholder={t("enterTitle")}
value={current.name || ""}
onChange={handleField("name")}
/>
</div>
<div className="flex flex-col gap-2">
<Label
htmlFor="description"
className={langId === defaultLangId ? "requiredInputLabel" : ""}
>
{t("description")}
</Label>
<Textarea
name="description"
id="description"
placeholder={t("enterDescription")}
value={current.description || ""}
onChange={handleField("description")}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="currency">{t("currency")}</Label>
{
currencies?.length > 0 ?
<Select
value={current?.currency_id?.toString()}
onValueChange={handleCurrencyChange}
dir={isRTL ? "rtl" : "ltr"}
>
<SelectTrigger>
<SelectValue placeholder={t("currency")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{/* <SelectLabel>Currencies</SelectLabel> */}
{currencies?.map((currency) => (
<SelectItem
key={currency.id}
value={currency.id.toString()}
dir={isRTL ? "rtl" : "ltr"}
>
{currency.iso_code} ({currency.symbol})
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
:
<Select
value={currencyIsoCode} // same default value you already have
disabled
dir={isRTL ? "rtl" : "ltr"}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{/* Required for RTL */}
<SelectContent>
<SelectGroup>
<SelectItem value={currencyIsoCode}>
{currencyIsoCode} ({currencySymbol})
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
}
</div>
{langId === defaultLangId && (
<>
{is_job_category ? (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="min_salary">{t("salaryMin")}</Label>
<Input
type="number"
name="min_salary"
id="min_salary"
placeholder={placeholderLabel}
value={current.min_salary || ""}
onChange={handleField("min_salary")}
min={0}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="max_salary">{t("salaryMax")}</Label>
<Input
type="number"
name="max_salary"
id="max_salary"
placeholder={placeholderLabel}
value={current.max_salary || ""}
onChange={handleField("max_salary")}
min={0}
/>
</div>
</>
) : (
<div className="flex flex-col gap-2">
<Label
htmlFor="price"
className={
!isPriceOptional && langId === defaultLangId
? "requiredInputLabel"
: ""
}
>
{t("price")}
</Label>
<Input
type="number"
name="price"
id="price"
placeholder={placeholderLabel}
value={current.price || ""}
onChange={handleField("price")}
min={0}
/>
</div>
)}
<div className="flex flex-col gap-2">
<Label
htmlFor="phonenumber"
>
{t("phoneNumber")}
</Label>
<PhoneInput
country={process.env.NEXT_PUBLIC_DEFAULT_COUNTRY}
value={`${current.country_code}${current.contact}`}
onChange={(phone, data) => handlePhoneChange(phone, data)}
inputProps={{
name: "phonenumber",
id: "phonenumber",
}}
enableLongNumbers
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="videoLink">{t("videoLink")}</Label>
<Input
type="text"
name="videoLink"
id="videoLink"
placeholder={t("enterAdditionalLinks")}
value={current.video_link || ""}
onChange={handleField("video_link")}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="slug">
{t("slug")}{" "}
<span className="text-sm text-muted-foreground">
({t("allowedSlug")})
</span>
</Label>
<Input
type="text"
name="slug"
id="slug"
placeholder={t("enterSlug")}
onChange={handleField("slug")}
value={current.slug || ""}
/>
</div>
</>
)}
<div className="flex justify-end">
<button
className="bg-primary text-white px-4 py-2 rounded-md text-xl"
onClick={handleDetailsSubmit}
>
{t("next")}
</button>
</div>
</div>
);
};
export default EditComponentOne;

View File

@@ -0,0 +1,298 @@
import { useCallback, useMemo } from "react";
import { IoInformationCircleOutline } from "react-icons/io5";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useDropzone } from "react-dropzone";
import { HiOutlineUpload } from "react-icons/hi";
import { MdClose } from "react-icons/md";
import { t } from "@/utils";
import CustomImage from "@/components/Common/CustomImage";
const EditComponentThree = ({
uploadedImages,
setUploadedImages,
OtherImages,
setOtherImages,
handleImageSubmit,
handleGoBack,
setDeleteImagesId,
}) => {
const onDrop = useCallback((acceptedFiles) => {
if (acceptedFiles.length == 0) {
toast.error(t("wrongFile"));
} else {
setUploadedImages(acceptedFiles);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"image/jpeg": [".jpeg", ".jpg"],
"image/png": [".png"],
},
multiple: false,
});
const files = useMemo(() => {
if (typeof uploadedImages === "string") {
return (
<div className="relative">
<CustomImage
width={591}
height={350}
className="rounded-2xl object-cover aspect-[591/350]"
src={uploadedImages}
alt="Uploaded Image"
/>
<div className="absolute top-2 left-2 flex gap-2 items-center">
<button
className="bg-white p-1 rounded-full"
onClick={() => removeImage(0)}
>
<MdClose
size={14}
color="black"
className="flex items-center justify-center rounded-full"
/>
</button>
<div className="text-white flex flex-col">
<span>{t("uploadedImage")}</span>
</div>
</div>
</div>
);
} else {
return (
uploadedImages?.map((file, index) => (
<div key={index} className="relative">
<CustomImage
width={591}
height={350}
className="rounded-2xl object-cover aspect-[591/350]"
src={URL.createObjectURL(file)}
alt={index}
/>
<div className="absolute top-2 left-2 flex gap-2 items-center">
<button
className="bg-white p-1 rounded-full"
onClick={() => removeImage(index)}
>
<MdClose
size={14}
color="black"
className="flex items-center justify-center rounded-full"
/>
</button>
<div className="text-white text-xs flex flex-col">
<span>{file.name}</span>
<span>{Math.round(file.size / 1024)} KB</span>
</div>
</div>
</div>
)) || []
);
}
}, [uploadedImages]);
const removeImage = (index) => {
if (typeof uploadedImages === "string") {
setUploadedImages([]);
} else {
setUploadedImages((prevImages) =>
prevImages?.filter((_, i) => i !== index)
);
}
};
const onOtherDrop = useCallback(
(acceptedFiles) => {
const currentFilesCount = OtherImages.length; // Number of files already uploaded
const remainingSlots = 5 - currentFilesCount; // How many more files can be uploaded
if (remainingSlots === 0) {
// Show error if the limit has been reached
toast.error(t("imageLimitExceeded"));
return;
}
if (acceptedFiles.length > remainingSlots) {
// Show error if the number of new files exceeds the remaining slots
toast.error(
t("youCanUpload") + " " + remainingSlots + " " + t("moreImages")
);
return;
}
// Add the new files to the state
setOtherImages((prevImages) => [...prevImages, ...acceptedFiles]);
},
[OtherImages]
);
const {
getRootProps: getRootOtheProps,
getInputProps: getInputOtherProps,
isDragActive: isDragOtherActive,
} = useDropzone({
onDrop: onOtherDrop,
accept: {
"image/jpeg": [".jpeg", ".jpg"],
"image/png": [".png"],
},
multiple: true,
});
const removeOtherImage = (index, file) => {
setOtherImages((prevImages) => prevImages.filter((_, i) => i !== index));
setDeleteImagesId((prevIds) => {
const newId = file?.id;
if (prevIds) {
return `${prevIds},${newId}`;
} else {
return `${newId}`;
}
});
};
const filesOther = useMemo(
() =>
OtherImages &&
OtherImages?.map((file, index) => (
<div key={file.id || `${file?.name}-${file?.size}`} className="relative">
<CustomImage
width={591}
height={350}
className="rounded-2xl object-cover aspect-[591/350]"
src={file.image ? file.image : URL.createObjectURL(file)}
alt={index}
/>
<div className="absolute top-2 left-2 flex gap-2 items-center">
<button
className="bg-white p-1 rounded-full"
onClick={() => removeOtherImage(index, file)}
>
<MdClose
size={14}
color="black"
className="flex items-center justify-center rounded-full"
/>
</button>
{
(file?.name || file?.size) &&
<div className="text-white text-xs flex flex-col">
<span>{file.name}</span>
<span>{Math.round(file.size / 1024)} KB</span>
</div>
}
</div>
</div>
)),
[OtherImages]
);
return (
<div className="flex flex-col gap-8">
<div className="grid grid-col-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<span className="requiredInputLabel text-sm">{t("mainPicture")}</span>
<div className="border-2 border-dashed rounded-lg p-2">
<div
{...getRootProps()}
className="flex flex-col min-h-[175px] items-center justify-center cursor-pointer"
style={{ display: uploadedImages.length > 0 ? "none" : "" }}
>
<input {...getInputProps()} />
{isDragActive ? (
<span className="text-muted-foreground font-medium">
{t("dropFiles")}
</span>
) : (
<div className="flex flex-col items-center gap-2 text-center">
<span className="text-muted-foreground">
{t("dragFiles")}
</span>
<span className="text-muted-foreground">{t("or")}</span>
<div className="flex items-center gap-2 text-primary">
<HiOutlineUpload size={24} />
<span className="font-medium">{t("upload")}</span>
</div>
</div>
)}
</div>
<div>{files}</div>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1 font-semibold text-sm">
{t("otherPicture")}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<IoInformationCircleOutline size={22} />
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
className="font-normal"
>
<p>{t("maxOtherImages")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
<div className="border-2 border-dashed rounded-lg p-2">
<div
{...getRootOtheProps()}
className="flex flex-col items-center justify-center min-h-[175px] cursor-pointer"
style={{ display: OtherImages.length >= 5 ? "none" : "" }}
>
<input {...getInputOtherProps()} />
{isDragOtherActive ? (
<span className="text-primary font-medium">
{t("dropFiles")}
</span>
) : (
<div className="flex flex-col gap-2 items-center text-center">
<span className="text-muted-foreground">
{t("dragFiles")}
</span>
<span className="text-muted-foreground">{t("or")}</span>
<div className="flex items-center gap-2 text-primary">
<HiOutlineUpload size={24} />
<span className="font-medium">{t("upload")}</span>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-3">{filesOther}</div>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<button
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
onClick={handleGoBack}
>
{t("back")}
</button>
<button
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light"
onClick={handleImageSubmit}
>
{t("next")}
</button>
</div>
</div>
);
};
export default EditComponentThree;

View File

@@ -0,0 +1,322 @@
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { toast } from "sonner";
import { HiOutlineUpload } from "react-icons/hi";
import { MdOutlineAttachFile } from "react-icons/md";
import CustomLink from "@/components/Common/CustomLink";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { handleKeyDown, inpNum, t } from "@/utils";
import CustomImage from "@/components/Common/CustomImage";
const EditComponentTwo = ({
customFields,
setExtraDetails,
handleGoBack,
filePreviews,
setFilePreviews,
submitExtraDetails,
currentExtraDetails,
langId,
defaultLangId,
}) => {
const write = (fieldId, value) =>
setExtraDetails((prev) => ({
...prev,
[langId]: {
...prev[langId],
[fieldId]: value,
},
}));
const handleFileChange = (id, file) => {
if (file) {
const allowedExtensions = /\.(jpg|jpeg|svg|png|pdf)$/i;
if (!allowedExtensions.test(file.name)) {
toast.error(t("notAllowedFile"));
return;
}
const fileUrl = URL.createObjectURL(file);
setFilePreviews((prevPreviews) => ({
...prevPreviews,
[id]: {
url: fileUrl,
isPdf: /\.pdf$/i.test(file.name),
},
}));
write(id, file);
}
};
const handleCheckboxChange = (id, value, checked) => {
const list = currentExtraDetails[id] || [];
const next = checked
? list.includes(value)
? list
: [...list, value]
: list.filter((v) => v !== value);
write(id, next);
};
const handleChange = (id, value) => write(id, value ?? "");
const renderCustomFields = (field) => {
let {
id,
translated_name,
name,
type,
translated_value,
values,
min_length,
max_length,
} = field;
const inputProps = {
id,
name: id,
onChange: (e) => handleChange(id, e.target.value),
value: currentExtraDetails[id] || "",
...(type === "number"
? { min: min_length, max: max_length }
: { minLength: min_length, maxLength: max_length }),
};
switch (type) {
case "number":
return (
<div className="flex flex-col">
<Input
type={type}
inputMode="numeric"
placeholder={`${t("enter")} ${translated_name || name}`}
{...inputProps}
onKeyDown={(e) => handleKeyDown(e, max_length)}
onKeyPress={(e) => inpNum(e)}
/>
{max_length && (
<span className="self-end text-sm text-muted-foreground">
{`${currentExtraDetails[id]?.length ?? 0}/${max_length}`}
</span>
)}
</div>
);
case "textbox":
return (
<div className=" flex flex-col">
<Textarea
placeholder={`${t("enter")} ${translated_name || name}`}
{...inputProps}
/>
{max_length && (
<span className="self-end text-sm text-muted-foreground">
{`${currentExtraDetails[id]?.length ?? 0}/${max_length}`}
</span>
)}
</div>
);
case "dropdown":
return (
<div className="w-full">
<Select
id={id}
name={id}
value={currentExtraDetails[id] || ""}
onValueChange={(value) => handleChange(id, value)}
>
<SelectTrigger className="outline-none focus:outline-none">
<SelectValue
className="font-medium"
placeholder={`${t("select")} ${translated_name || name}`}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel value="">
{t("select")} {translated_name || name}
</SelectLabel>
{values?.map((option, index) => (
<SelectItem
id={option}
className="font-medium"
key={option}
value={option}
>
{translated_value[index] || option}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
);
case "checkbox":
return (
<div className="flex w-full flex-wrap gap-2">
{values?.map((value, index) => {
const uniqueId = `${id}-${value}-${index}`;
return (
<div key={uniqueId} className="flex gap-1 items-center">
<Checkbox
id={uniqueId}
value={value}
onCheckedChange={(checked) =>
handleCheckboxChange(id, value, checked)
}
checked={currentExtraDetails[id]?.includes(value)}
/>
<label
htmlFor={uniqueId}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{translated_value[index] || value}
</label>
</div>
);
})}
</div>
);
case "radio":
return (
<RadioGroup
value={currentExtraDetails[id] || ""}
onValueChange={(value) => handleChange(id, value)}
className="flex gap-2 flex-wrap"
>
{values?.map((option, index) => {
const uniqueId = `${id}-${option}-${index}`;
return (
<div
key={uniqueId}
className="flex items-center gap-2 flex-wrap"
>
<RadioGroupItem
value={option}
id={uniqueId}
className="sr-only peer "
/>
<label
htmlFor={uniqueId}
className={`${currentExtraDetails[id] === option
? "bg-primary text-white"
: ""
} border rounded-md px-4 py-2 cursor-pointer transition-colors`}
>
{translated_value[index] || option}
</label>
</div>
);
})}
</RadioGroup>
);
case "fileinput":
const fileUrl = filePreviews[id]?.url;
return (
<>
<label htmlFor={id} className="flex gap-2 items-center">
<div className="cursor-pointer border px-2.5 py-1 rounded">
<HiOutlineUpload size={24} fontWeight="400" />
</div>
{filePreviews[id] && (
<div className="flex items-center gap-1 text-sm flex-nowrap break-words">
{filePreviews[id]?.isPdf ? (
<>
<MdOutlineAttachFile />
<CustomLink
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
>
{t("viewPdf")}
</CustomLink>
</>
) : (
<CustomImage
key={fileUrl}
src={fileUrl}
alt="Preview"
className="h-9 w-9"
height={36}
width={36}
/>
)}
</div>
)}
</label>
<input
type="file"
id={id}
name={name}
className="hidden"
onChange={(e) => handleFileChange(id, e.target.files[0])}
accept=".jpg,.jpeg,.png,.svg,.pdf"
/>
<span className="text-sm text-muted-foreground">
{t("allowedFileType")}
</span>
</>
);
default:
break;
}
};
return (
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{customFields?.map((field) => {
if (langId !== defaultLangId && field.type !== "textbox") return null;
return (
<div className="flex flex-col w-full gap-2" key={field?.id}>
<div className="flex gap-2 items-center">
<CustomImage
src={field?.image}
alt={field?.name}
height={28}
width={28}
className="h-7 w-7 rounded-sm"
/>
<Label
className={`${field?.required === 1 && defaultLangId === langId
? "requiredInputLabel"
: ""
}`}
>
{field?.translated_name || field?.name}
</Label>
</div>
{renderCustomFields(field)}
</div>
);
})}
</div>
<div className="flex justify-end gap-3">
<button
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
onClick={handleGoBack}
>
{t("back")}
</button>
<button
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light"
onClick={submitExtraDetails}
>
{t("next")}
</button>
</div>
</div>
);
};
export default EditComponentTwo;

View File

@@ -0,0 +1,538 @@
"use client";
import { useEffect, useState } from "react";
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
import {
editItemApi,
getCurrenciesApi,
getCustomFieldsApi,
getMyItemsApi,
getParentCategoriesApi,
} from "@/utils/api";
import {
filterNonDefaultTranslations,
getMainDetailsTranslations,
isValidURL,
prefillExtraDetails,
prepareCustomFieldFiles,
prepareCustomFieldTranslations,
t,
validateExtraDetails,
} from "@/utils";
import EditComponentOne from "./EditComponentOne";
import EditComponentTwo from "./EditComponentTwo";
import EditComponentThree from "./EditComponentThree";
import EditComponentFour from "./EditComponentFour";
import { toast } from "sonner";
import Layout from "@/components/Layout/Layout";
import Checkauth from "@/HOC/Checkauth";
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
import { useSelector } from "react-redux";
import AdSuccessModal from "../AdsListing/AdSuccessModal";
import {
getDefaultLanguageCode,
getLanguages,
} from "@/redux/reducer/settingSlice";
import AdLanguageSelector from "../AdsListing/AdLanguageSelector";
import PageLoader from "@/components/Common/PageLoader";
import { isValidPhoneNumber } from "libphonenumber-js/max";
const EditListing = ({ id }) => {
const CurrentLanguage = useSelector(CurrentLanguageData);
const [step, setStep] = useState(1);
const [CreatedAdSlug, setCreatedAdSlug] = useState("");
const [openSuccessModal, setOpenSuccessModal] = useState(false);
const [selectedCategoryPath, setSelectedCategoryPath] = useState([]);
const [customFields, setCustomFields] = useState([]);
const [uploadedImages, setUploadedImages] = useState([]);
const [OtherImages, setOtherImages] = useState([]);
const [Location, setLocation] = useState({});
const [currencies, setCurrencies] = useState([]);
const [filePreviews, setFilePreviews] = useState({});
const [deleteImagesId, setDeleteImagesId] = useState("");
const [isAdPlaced, setIsAdPlaced] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const languages = useSelector(getLanguages);
const defaultLanguageCode = useSelector(getDefaultLanguageCode);
const defaultLangId = languages?.find(
(lang) => lang.code === defaultLanguageCode
)?.id;
const [extraDetails, setExtraDetails] = useState({
[defaultLangId]: {},
});
const [langId, setLangId] = useState(defaultLangId);
const [translations, setTranslations] = useState({
[defaultLangId]: {},
});
const hasTextbox = customFields.some((field) => field.type === "textbox");
const defaultDetails = translations[defaultLangId] || {};
const currentDetails = translations[langId] || {};
const currentExtraDetails = extraDetails[langId] || {};
const is_job_category =
Number(
selectedCategoryPath[selectedCategoryPath.length - 1]?.is_job_category
) === 1;
const isPriceOptional =
Number(
selectedCategoryPath[selectedCategoryPath.length - 1]?.price_optional
) === 1;
useEffect(() => {
getSingleListingData();
}, [CurrentLanguage.id]);
const fetchCategoryPath = async (childCategoryId) => {
try {
const categoryResponse =
await getParentCategoriesApi.getPaymentCategories({
child_category_id: childCategoryId,
});
setSelectedCategoryPath(categoryResponse?.data?.data);
} catch (error) {
console.log("Error fetching category path:", error);
}
};
const getCustomFields = async (categoryIds, extraFieldValue) => {
try {
const customFieldsRes = await getCustomFieldsApi.getCustomFields({
category_ids: categoryIds,
});
const data = customFieldsRes?.data?.data;
setCustomFields(data);
const tempExtraDetails = prefillExtraDetails({
data,
languages,
defaultLangId,
extraFieldValue,
setFilePreviews,
});
setExtraDetails(tempExtraDetails);
setLangId(defaultLangId);
} catch (error) {
console.log("Error fetching custom fields:", error);
}
};
const getCurrencies = async () => {
try {
const res = await getCurrenciesApi.getCurrencies();
const currenciesData = res?.data?.data || [];
setCurrencies(currenciesData);
return currenciesData; // Return the currencies data
} catch (error) {
console.log("error", error);
return [];
}
};
const getSingleListingData = async () => {
try {
setIsLoading(true);
const res = await getMyItemsApi.getMyItems({ id: Number(id) });
const listingData = res?.data?.data?.data?.[0];
if (!listingData) {
throw new Error("Listing not found");
}
// Get currencies data directly
const [_, __, currenciesData] = await Promise.all([
getCustomFields(
listingData.all_category_ids,
listingData?.all_translated_custom_fields
),
fetchCategoryPath(listingData?.category_id),
getCurrencies(),
]);
setUploadedImages(listingData?.image);
setOtherImages(listingData?.gallery_images);
const mainDetailsTranslation = getMainDetailsTranslations(
listingData,
languages,
defaultLangId,
currenciesData
);
setTranslations(mainDetailsTranslation);
setLocation({
country: listingData?.country,
state: listingData?.state,
city: listingData?.city,
formattedAddress: listingData?.translated_address,
lat: listingData?.latitude,
long: listingData?.longitude,
area_id: listingData?.area_id ? listingData?.area_id : null,
});
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
}
};
const handleDetailsSubmit = () => {
if (customFields?.length === 0) {
setStep(3);
return;
}
setStep(2);
};
const handleImageSubmit = () => {
if (uploadedImages.length === 0) {
toast.error(t("uploadMainPicture"));
return;
}
setStep(4);
};
const handleGoBack = () => {
if (step == 3 && customFields?.length == 0) {
setStep((prev) => prev - 2);
} else {
setStep((prev) => prev - 1);
}
};
const handleTabClick = (tab) => {
if (tab === 1) {
setStep(1);
} else if (tab === 2) {
setStep(2);
} else if (tab === 3) {
setStep(3);
} else if (tab === 4) {
setStep(4);
}
};
const submitExtraDetails = () => {
setStep(3);
};
const SLUG_RE = /^[a-z0-9-]+$/i;
const isEmpty = (x) => !x || !x.toString().trim();
const isNegative = (n) => Number(n) < 0;
const handleFullSubmission = () => {
const {
name,
description,
price,
slug,
contact,
video_link,
min_salary,
max_salary,
country_code,
} = defaultDetails;
if (!name.trim() || !description.trim()) {
toast.error(t("completeDetails"));
setStep(1);
return;
}
// ✅ Validate phone number ONLY if user entered one as it is optional
if (Boolean(contact) && !isValidPhoneNumber(`+${country_code}${contact}`)) {
toast.error(t("invalidPhoneNumber"));
return setStep(1);
}
if (is_job_category) {
const min = min_salary ? Number(min_salary) : null;
const max = max_salary ? Number(max_salary) : null;
// Salary fields are optional, but validate if provided
if (min !== null && min < 0) {
toast.error(t("enterValidSalaryMin"));
setStep(1);
return;
}
if (max !== null && max < 0) {
toast.error(t("enterValidSalaryMax"));
setStep(1);
return;
}
if (min !== null && max !== null) {
if (min === max) {
toast.error(t("salaryMinCannotBeEqualMax"));
return setStep(1);
}
if (min > max) {
toast.error(t("salaryMinCannotBeGreaterThanMax"));
return setStep(1);
}
}
} else {
if (!isPriceOptional && isEmpty(price)) {
toast.error(t("completeDetails"));
return setStep(1);
}
if (!isEmpty(price) && isNegative(price)) {
toast.error(t("enterValidPrice"));
return setStep(1);
}
}
if (!isEmpty(slug) && !SLUG_RE.test(slug.trim())) {
toast.error(t("addValidSlug"));
return setStep(1);
}
if (!isEmpty(video_link) && !isValidURL(video_link)) {
toast.error(t("enterValidUrl"));
setStep(1);
return;
}
if (
customFields.length !== 0 &&
!validateExtraDetails({
languages,
defaultLangId,
extraDetails,
customFields,
filePreviews,
})
) {
setStep(2);
return;
}
if (uploadedImages.length === 0) {
toast.error(t("uploadMainPicture"));
setStep(3);
return;
}
if (
!Location?.country ||
!Location?.state ||
!Location?.city ||
!Location?.formattedAddress
) {
toast.error(t("pleaseSelectCity"));
return;
}
editAd();
};
const editAd = async () => {
const nonDefaultTranslations = filterNonDefaultTranslations(
translations,
defaultLangId
);
const customFieldTranslations =
prepareCustomFieldTranslations(extraDetails);
const customFieldFiles = prepareCustomFieldFiles(
extraDetails,
defaultLangId
);
const allData = {
id: id,
name: defaultDetails.name,
slug: defaultDetails.slug.trim(),
description: defaultDetails?.description,
price: defaultDetails.price,
contact: defaultDetails.contact,
region_code: defaultDetails?.region_code?.toUpperCase() || "",
video_link: defaultDetails?.video_link,
// custom_fields: transformedCustomFields,
image: typeof uploadedImages == "string" ? null : uploadedImages[0],
gallery_images: OtherImages,
address: Location?.formattedAddress,
latitude: Location?.lat,
longitude: Location?.long,
custom_field_files: customFieldFiles,
country: Location?.country,
state: Location?.state,
city: Location?.city,
...(Location?.area_id ? { area_id: Number(Location?.area_id) } : {}),
delete_item_image_id: deleteImagesId,
...(Object.keys(nonDefaultTranslations).length > 0 && {
translations: nonDefaultTranslations,
}),
...(defaultDetails?.currency_id && {
currency_id: defaultDetails?.currency_id,
}),
...(Object.keys(customFieldTranslations).length > 0 && {
custom_field_translations: customFieldTranslations,
}),
// expiry_date: '2025-10-13'
};
if (is_job_category) {
// Only add salary fields if they're provided
allData.min_salary = defaultDetails.min_salary;
allData.max_salary = defaultDetails.max_salary;
} else {
allData.price = defaultDetails.price;
}
try {
setIsAdPlaced(true);
const res = await editItemApi.editItem(allData);
if (res?.data?.error === false) {
setOpenSuccessModal(true);
setCreatedAdSlug(res?.data?.data[0]?.slug);
} else {
toast.error(res?.data?.message);
}
} catch (error) {
console.log(error);
} finally {
setIsAdPlaced(false);
}
};
return (
<Layout>
{isLoading ? (
<PageLoader />
) : (
<>
<BreadCrumb title2={t("editListing")} />
<div className="container">
<div className="flex flex-col gap-6 mt-8">
<h1 className="text-2xl font-medium">{t("editListing")}</h1>
<div className="flex flex-col gap-6 border rounded-md p-4">
<div className="flex items-center gap-3 justify-between bg-muted px-4 py-2 rounded-md flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<div
className={`transition-all duration-300 p-2 cursor-pointer ${step === 1 ? "bg-primary text-white" : ""
} rounded-md `}
onClick={() => handleTabClick(1)}
>
{t("details")}
</div>
{customFields?.length > 0 && (
<div
className={`transition-all duration-300 p-2 cursor-pointer ${step === 2 ? "bg-primary text-white" : ""
} rounded-md`}
onClick={() => handleTabClick(2)}
>
{t("extraDetails")}
</div>
)}
<div
className={`transition-all duration-300 p-2 cursor-pointer ${step === 3 ? "bg-primary text-white" : ""
} rounded-md `}
onClick={() => handleTabClick(3)}
>
{t("images")}
</div>
<div
className={`transition-all duration-300 p-2 cursor-pointer ${step === 4 ? "bg-primary text-white" : ""
} rounded-md `}
onClick={() => handleTabClick(4)}
>
{t("location")}
</div>
</div>
{(step === 1 || (step === 2 && hasTextbox)) && (
<AdLanguageSelector
langId={langId}
setLangId={setLangId}
languages={languages}
setTranslations={setTranslations}
/>
)}
</div>
{step === 1 &&
selectedCategoryPath &&
selectedCategoryPath?.length > 0 && (
<div className="flex flex-col gap-2">
<h1 className="font-medium text-xl">
{t("selectedCategory")}
</h1>
<div className="flex">
{selectedCategoryPath?.map((item, index) => {
const shouldShowComma =
selectedCategoryPath.length > 1 &&
index !== selectedCategoryPath.length - 1;
return (
<span className="text-primary" key={item.id}>
{item.name}
{shouldShowComma && ", "}
</span>
);
})}
</div>
</div>
)}
<div>
{step == 1 && (
<EditComponentOne
setTranslations={setTranslations}
current={currentDetails}
langId={langId}
defaultLangId={defaultLangId}
handleDetailsSubmit={handleDetailsSubmit}
is_job_category={is_job_category}
isPriceOptional={isPriceOptional}
currencies={currencies}
/>
)}
{step == 2 && customFields.length > 0 && (
<EditComponentTwo
customFields={customFields}
extraDetails={extraDetails}
setExtraDetails={setExtraDetails}
handleGoBack={handleGoBack}
filePreviews={filePreviews}
setFilePreviews={setFilePreviews}
submitExtraDetails={submitExtraDetails}
currentExtraDetails={currentExtraDetails}
langId={langId}
defaultLangId={defaultLangId}
/>
)}
{step == 3 && (
<EditComponentThree
setUploadedImages={setUploadedImages}
uploadedImages={uploadedImages}
OtherImages={OtherImages}
setOtherImages={setOtherImages}
handleImageSubmit={handleImageSubmit}
handleGoBack={handleGoBack}
setDeleteImagesId={setDeleteImagesId}
/>
)}
{step == 4 && (
<EditComponentFour
handleGoBack={handleGoBack}
location={Location}
setLocation={setLocation}
handleFullSubmission={handleFullSubmission}
isAdPlaced={isAdPlaced}
/>
)}
</div>
</div>
</div>
</div>
<AdSuccessModal
openSuccessModal={openSuccessModal}
setOpenSuccessModal={setOpenSuccessModal}
createdAdSlug={CreatedAdSlug}
/>
</>
)}
</Layout>
);
};
export default Checkauth(EditListing);

View File

@@ -0,0 +1,31 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
const FaqCard = ({ faq }) => {
return (
<Accordion
type="single"
collapsible
className="border rounded-md overflow-hidden"
>
<AccordionItem value={faq?.id} className="border-none group">
<AccordionTrigger
className="text-start font-bold text-base px-4 hover:no-underline bg-transparent
group-data-[state=open]:bg-muted group-data-[state=open]:text-primary
group-data-[state=open]:border-b"
>
{faq?.translated_question || faq?.question}
</AccordionTrigger>
<AccordionContent className="bg-muted p-4">
<p className="text-base">{faq?.translated_answer || faq?.answer}</p>
</AccordionContent>
</AccordionItem>
</Accordion>
);
};
export default FaqCard;

View File

@@ -0,0 +1,57 @@
"use client";
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
import { t } from "@/utils";
import { useEffect, useState } from "react";
import { getFaqApi } from "@/utils/api";
import FaqCard from "./FaqCard";
import Layout from "@/components/Layout/Layout";
import { useSelector } from "react-redux";
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
import PageLoader from "@/components/Common/PageLoader";
import NoData from "@/components/EmptyStates/NoData";
const FaqsPage = () => {
const [faqs, setFaqs] = useState([]);
const [loading, setLoading] = useState(false);
const CurrentLanguage = useSelector(CurrentLanguageData);
useEffect(() => {
fetchFaqs();
}, [CurrentLanguage.id]);
const fetchFaqs = async () => {
try {
setLoading(true);
const res = await getFaqApi.getFaq();
setFaqs(res?.data?.data);
} catch (error) {
console.log("error", error);
} finally {
setLoading(false);
}
};
return (
<Layout>
<BreadCrumb title2={t("faqs")} />
{loading ? (
<PageLoader />
) : faqs && faqs?.length > 0 ? (
<div className="container">
<div className="flex flex-col gap-6 mt-8">
<h1 className="text-2xl font-semibold">{t("faqs")}</h1>
<div className="flex flex-col gap-4 md:gap-8">
{faqs?.map((faq) => {
return <FaqCard faq={faq} key={faq?.id} />;
})}
</div>
</div>
</div>
) : (
<NoData name={t("faqs")} />
)}
</Layout>
);
};
export default FaqsPage;

View File

@@ -0,0 +1,95 @@
"use client";
import ProductCard from "@/components/Common/ProductCard";
import NoData from "@/components/EmptyStates/NoData";
import ProductCardSkeleton from "@/components/Common/ProductCardSkeleton";
import { Button } from "@/components/ui/button";
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
import { t } from "@/utils";
import { getFavouriteApi } from "@/utils/api";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
const Favorites = () => {
const CurrentLanguage = useSelector(CurrentLanguageData);
const [favoritesData, setFavoriteData] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [IsLoadMore, setIsLoadMore] = useState(false);
const fetchFavoriteItems = async (page) => {
try {
if (page === 1) {
setIsLoading(true);
}
const response = await getFavouriteApi.getFavouriteApi({ page });
const data = response?.data?.data?.data;
if (page === 1) {
setFavoriteData(data);
} else {
setFavoriteData((prevData) => [...prevData, ...data]);
}
setCurrentPage(response?.data?.data.current_page);
if (response?.data?.data.current_page < response?.data?.data.last_page) {
setHasMore(true);
} else {
setHasMore(false);
}
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
setIsLoadMore(false);
}
};
useEffect(() => {
fetchFavoriteItems(currentPage);
}, [currentPage, CurrentLanguage.id]);
const handleLoadMore = () => {
setIsLoadMore(true);
setCurrentPage((prevPage) => prevPage + 1);
};
const handleLike = (id) => {
fetchFavoriteItems(1);
};
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 lg:grid-cols-2 gap-3 sm:gap-6">
{isLoading ? (
[...Array(12)].map((_, index) => <ProductCardSkeleton key={index} />)
) : favoritesData && favoritesData.length > 0 ? (
favoritesData?.map(
(fav) =>
fav?.is_liked && (
<ProductCard key={fav?.id} item={fav} handleLike={handleLike} />
)
)
) : (
<div className="col-span-full">
<NoData name={t("favorites")} />
</div>
)}
</div>
{favoritesData && favoritesData.length > 0 && hasMore && (
<div className="text-center mt-6">
<Button
variant="outline"
className="text-sm sm:text-base text-primary w-[256px]"
disabled={isLoading || IsLoadMore}
onClick={handleLoadMore}
>
{IsLoadMore ? t("loading") : t("loadMore")}
</Button>
</div>
)}
</>
);
};
export default Favorites;

View File

@@ -0,0 +1,165 @@
import ProductCard from "@/components/Common/ProductCard";
import NoData from "@/components/EmptyStates/NoData";
import AllItemsSkeleton from "@/components/PagesComponent/Home/AllItemsSkeleton";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { resetBreadcrumb } from "@/redux/reducer/breadCrumbSlice";
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
import { t } from "@/utils";
import { allItemApi } from "@/utils/api";
import { Info, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
const AllItems = ({ cityData, KmRange }) => {
const dispatch = useDispatch();
const CurrentLanguage = useSelector(CurrentLanguageData);
const [AllItem, setAllItem] = useState([]);
const [hasMore, setHasMore] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [isLoadMore, setIsLoadMore] = useState(false);
// State to track if we should show location alert
const [locationAlertMessage, setLocationAlertMessage] = useState("");
const getAllItemData = async (page) => {
if (page === 1) {
setIsLoading(true);
}
try {
const params = {
page,
current_page: "home",
};
if (Number(KmRange) > 0 && (cityData?.areaId || cityData?.city)) {
// Add location-based parameters for non-demo mode
params.radius = KmRange;
params.latitude = cityData.lat;
params.longitude = cityData.long;
} else {
// Add location hierarchy parameters for non-demo mode
if (cityData?.areaId) {
params.area_id = cityData.areaId;
} else if (cityData?.city) {
params.city = cityData.city;
} else if (cityData?.state) {
params.state = cityData.state;
} else if (cityData?.country) {
params.country = cityData.country;
}
}
const response = await allItemApi.getItems(params);
if (response.data?.error === true) {
throw new Error(response.data?.message);
}
const apiMessage = response.data.message;
// Check if message indicates no items in selected location
const isNoItemsInLocation = apiMessage
?.toLowerCase()
.includes("no ads found");
// Show alert only if there are items but from different location
if (isNoItemsInLocation && response?.data?.data?.data?.length > 0) {
setLocationAlertMessage(apiMessage);
} else {
setLocationAlertMessage("");
}
if (response?.data?.data?.data?.length > 0) {
const data = response?.data?.data?.data;
if (page === 1) {
setAllItem(data);
} else {
setAllItem((prevData) => [...prevData, ...data]);
}
const currentPage = response?.data?.data?.current_page;
const lastPage = response?.data?.data?.last_page;
setHasMore(currentPage < lastPage);
setCurrentPage(currentPage);
} else {
setAllItem([]);
}
} catch (error) {
console.error("Error:", error);
} finally {
setIsLoading(false);
setIsLoadMore(false);
}
};
useEffect(() => {
getAllItemData(1);
}, [cityData.lat, cityData.long, KmRange, CurrentLanguage?.id]);
const handleLoadMore = () => {
setIsLoadMore(true);
getAllItemData(currentPage + 1);
};
useEffect(() => {
// reset breadcrumb path when in home page
dispatch(resetBreadcrumb());
}, []);
const handleLikeAllData = (id) => {
const updatedItems = AllItem.map((item) => {
if (item.id === id) {
return { ...item, is_liked: !item.is_liked };
}
return item;
});
setAllItem(updatedItems);
};
return (
<section className="container mt-12">
<h5 className="text-xl sm:text-2xl font-medium">
{t("allAdvertisements")}
</h5>
{/* Location Alert - shows when items are from different location */}
{locationAlertMessage && AllItem.length > 0 && (
<Alert variant="warning" className="mt-3">
<Info className="size-4" />
<AlertTitle>{locationAlertMessage}</AlertTitle>
<AlertDescription className="sr-only"></AlertDescription>
</Alert>
)}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-6 mt-6">
{isLoading ? (
<AllItemsSkeleton />
) : AllItem && AllItem.length > 0 ? (
AllItem?.map((item) => (
<ProductCard
key={item?.id}
item={item}
handleLike={handleLikeAllData}
/>
))
) : (
<div className="col-span-full">
<NoData name={t("advertisement")} />
</div>
)}
</div>
{AllItem && AllItem.length > 0 && hasMore && (
<div className="text-center mt-6">
<Button
variant="outline"
className="text-sm sm:text-base text-primary w-[256px]"
disabled={isLoading || isLoadMore}
onClick={handleLoadMore}
>
{isLoadMore ? t("loading") : t("loadMore")}
</Button>
</div>
)}
</section>
);
};
export default AllItems;

View File

@@ -0,0 +1,14 @@
import { useId } from "react";
import ProductCardSkeleton from "../../Common/ProductCardSkeleton";
const AllItemsSkeleton = () => {
return (
<>
{Array.from({ length: 8 }).map(() => (
<ProductCardSkeleton key={useId()} />
))}
</>
);
};
export default AllItemsSkeleton;

View File

@@ -0,0 +1,62 @@
"use client";
import { t } from "@/utils";
import CustomLink from "@/components/Common/CustomLink";
import ProductCard from "@/components/Common/ProductCard";
import { Fragment } from "react";
const FeaturedSections = ({ featuredData, setFeaturedData, allEmpty }) => {
const handleLike = (id) => {
const updatedData = featuredData.map((section) => {
const updatedSectionData = section.section_data.map((item) => {
if (item.id === id) {
return { ...item, is_liked: !item.is_liked };
}
return item;
});
return { ...section, section_data: updatedSectionData };
});
setFeaturedData(updatedData);
};
return (
featuredData &&
featuredData.length > 0 &&
!allEmpty && (
<section className="container">
{featuredData.map(
(ele) =>
ele?.section_data.length > 0 && (
<Fragment key={ele?.id}>
<div className="space-between gap-2 mt-12">
<h5 className="text-xl sm:text-2xl font-medium">
{ele?.translated_name || ele?.title}
</h5>
{ele?.section_data.length > 4 && (
<CustomLink
href={`/ads?featured_section=${ele?.slug}`}
className="text-sm sm:text-base font-medium whitespace-nowrap"
prefetch={false}
>
{t("viewAll")}
</CustomLink>
)}
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-6 mt-6">
{ele?.section_data.slice(0, 4).map((data) => (
<ProductCard
key={data?.id}
item={data}
handleLike={handleLike}
/>
))}
</div>
</Fragment>
)
)}
</section>
)
);
};
export default FeaturedSections;

View File

@@ -0,0 +1,20 @@
import { Skeleton } from "../../ui/skeleton";
import ProductCardSkeleton from "../../Common/ProductCardSkeleton";
const FeaturedSectionsSkeleton = () => {
return (
<div className="container">
<div className="space-between gap-2 mt-12">
<Skeleton className="w-1/6 h-4" />
<Skeleton className="w-1/12 h-4" />
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-6 mt-6">
{Array.from({ length: 4 }).map((_, index) => (
<ProductCardSkeleton key={index} />
))}
</div>
</div>
);
};
export default FeaturedSectionsSkeleton;

View File

@@ -0,0 +1,300 @@
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { t } from "@/utils";
import CustomLink from "@/components/Common/CustomLink";
import { useEffect, useRef, useState } from "react";
import { IoIosMore } from "react-icons/io";
import CustomImage from "@/components/Common/CustomImage";
import { useNavigate } from "@/components/Common/useNavigate";
import { usePathname, useSearchParams } from "next/navigation";
const HeaderCategories = ({ cateData }) => {
const containerRef = useRef(null);
const measureRef = useRef(null);
const pathname = usePathname();
const searchParams = useSearchParams();
const { navigate } = useNavigate();
const [fitCategoriesCount, setFitCategoriesCount] = useState(3);
useEffect(() => {
const calculateFit = () => {
if (!containerRef.current || !measureRef.current) return;
const containerWidth = containerRef.current.offsetWidth;
const otherWidth = 80; //approx width of other option
const availableWidth = containerWidth - otherWidth;
const items = Array.from(measureRef.current.children);
let totalWidth = 0;
let visible = 0;
for (const item of items) {
const width = item.getBoundingClientRect().width + 48; // padding/gap buffer
if (totalWidth + width > availableWidth) break;
totalWidth += width;
visible++;
}
setFitCategoriesCount(visible);
};
const resizeObserver = new ResizeObserver(calculateFit);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, [cateData]);
// Helper function to build URL with category while preserving existing search params
const buildCategoryUrl = (categorySlug) => {
if (pathname.startsWith("/ads")) {
// Preserve existing search params and update category
const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.delete("lang");
newSearchParams.set("category", categorySlug);
return `/ads?${newSearchParams.toString()}`;
} else {
// Not on ads page, just add category
return `/ads?category=${categorySlug}`;
}
};
const handleCategoryClick = (slug) => {
if (pathname.startsWith("/ads")) {
const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.set("category", slug);
const newUrl = `/ads?${newSearchParams.toString()}`;
window.history.pushState(null, "", newUrl);
} else {
navigate(`/ads?category=${slug}`);
}
};
const handleOtherCategoryClick = () => {
if (pathname.startsWith("/ads")) {
const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.delete("category");
const newUrl = `/ads?${newSearchParams.toString()}`;
window.history.pushState(null, "", newUrl);
} else {
navigate(`/ads`);
}
};
return (
<div className="hidden lg:block py-1.5 border-b">
<div className="container" ref={containerRef}>
{/* Hidden measurement row */}
<div
ref={measureRef}
className="absolute opacity-0 pointer-events-none flex"
style={{ position: "fixed", top: -9999, left: -9999 }}
>
{cateData.map((category) => (
<div key={category.id} className="px-2">
<span className="whitespace-nowrap font-medium text-sm">
{category.translated_name}
</span>
</div>
))}
</div>
<NavigationMenu>
<NavigationMenuList className="rtl:flex-row-reverse">
{cateData?.slice(0, fitCategoriesCount)?.map((category) => (
<NavigationMenuItem key={category.id}>
{category.subcategories_count > 0 ? (
<>
<NavigationMenuTrigger
onClick={() => handleCategoryClick(category.slug)}
>
{category.translated_name}
</NavigationMenuTrigger>
<NavigationMenuContent className="rtl:[direction:rtl]">
<NavigationMenuLink asChild>
<div
style={{
width: containerRef?.current?.offsetWidth - 32,
}}
className="flex overflow-x-auto"
>
<div className="w-[20%] p-4 bg-muted">
<div className="flex gap-1">
<CustomImage
src={category?.image}
alt={category?.translated_name}
width={22}
height={22}
className="w-22 h-auto aspect-square"
/>
<p className="font-bold">
{category?.translated_name}
</p>
</div>
</div>
<div className="w-[80%] p-4">
<div className="flex flex-col flex-wrap w-min gap-8 h-[30vh] max-h-[30vh]">
{/* <CustomLink
href={`/ads?category=${category.slug}`}
className="font-semibold whitespace-nowrap text-sm hover:text-primary"
>
{t("seeAllIn")} {category.translated_name}
</CustomLink> */}
{category.subcategories.map((subcategory) => (
<div key={subcategory.id}>
<CustomLink
href={buildCategoryUrl(subcategory.slug)}
className="font-semibold whitespace-nowrap text-sm hover:text-primary"
>
{subcategory.translated_name}
</CustomLink>
{subcategory.subcategories_count > 0 && (
<ul className="flex flex-col gap-2 mt-2">
{subcategory?.subcategories
?.slice(0, 5)
.map((nestedSubcategory) => (
<li
key={nestedSubcategory?.id}
className="text-xs"
>
<CustomLink
href={buildCategoryUrl(
nestedSubcategory?.slug
)}
className="hover:text-primary whitespace-nowrap"
>
{
nestedSubcategory?.translated_name
}
</CustomLink>
</li>
))}
{subcategory.subcategories.length > 5 && (
<li className="text-xs">
<CustomLink
href={buildCategoryUrl(
subcategory.slug
)}
className="hover:text-primary"
>
{t("viewAll")}
</CustomLink>
</li>
)}
</ul>
)}
</div>
))}
</div>
</div>
</div>
</NavigationMenuLink>
</NavigationMenuContent>
</>
) : (
<NavigationMenuLink
className={navigationMenuTriggerStyle()}
href={buildCategoryUrl(category?.slug)}
asChild
>
<CustomLink href={buildCategoryUrl(category?.slug)}>
{category.translated_name}
</CustomLink>
</NavigationMenuLink>
)}
</NavigationMenuItem>
))}
{cateData && cateData.length > fitCategoriesCount && (
<NavigationMenuItem>
<NavigationMenuTrigger onClick={handleOtherCategoryClick}>
{t("other")}
</NavigationMenuTrigger>
<NavigationMenuContent className="rtl:[direction:rtl]">
<NavigationMenuLink asChild>
<div
style={{ width: containerRef?.current?.offsetWidth - 32 }}
className="flex overflow-x-auto w-[80vw]"
>
<div className="w-[20%] p-4 bg-muted">
<div className="flex gap-1">
<IoIosMore size={22} />
<p className="font-bold">{t("other")}</p>
</div>
</div>
<div className="w-[80%] p-4">
<div className="flex flex-col flex-wrap w-min gap-8 h-[30vh] max-h-[30vh]">
{cateData
.slice(fitCategoriesCount)
.map((subcategory) => (
<div key={subcategory.id}>
<CustomLink
href={buildCategoryUrl(subcategory.slug)}
className="font-semibold whitespace-nowrap text-sm hover:text-primary"
>
{subcategory.translated_name}
</CustomLink>
{subcategory.subcategories_count > 0 && (
<ul className="flex flex-col gap-2 mt-2">
{subcategory?.subcategories
?.slice(0, 5)
.map((nestedSubcategory) => (
<li
key={nestedSubcategory?.id}
className="text-xs"
>
<CustomLink
href={buildCategoryUrl(
nestedSubcategory?.slug
)}
className="hover:text-primary"
>
{nestedSubcategory?.translated_name}
</CustomLink>
</li>
))}
{subcategory.subcategories.length > 5 && (
<li className="text-xs">
<CustomLink
href={buildCategoryUrl(
subcategory.slug
)}
className="hover:text-primary"
>
{t("viewAll")}
</CustomLink>
</li>
)}
</ul>
)}
</div>
))}
</div>
</div>
</div>
</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
)}
</NavigationMenuList>
</NavigationMenu>
</div>
</div>
);
};
export default HeaderCategories;

View File

@@ -0,0 +1,110 @@
"use client";
import { useEffect, useState } from "react";
import AllItems from "./AllItems";
import FeaturedSections from "./FeaturedSections";
import { FeaturedSectionApi, sliderApi } from "@/utils/api";
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
import { useSelector } from "react-redux";
import { getCityData, getKilometerRange } from "@/redux/reducer/locationSlice";
import OfferSliderSkeleton from "@/components/PagesComponent/Home/OfferSliderSkeleton";
import FeaturedSectionsSkeleton from "./FeaturedSectionsSkeleton";
import PopularCategories from "./PopularCategories";
import dynamic from "next/dynamic";
const OfferSlider = dynamic(() => import("./OfferSlider"), {
ssr: false,
loading: OfferSliderSkeleton,
});
const Home = () => {
const KmRange = useSelector(getKilometerRange);
const cityData = useSelector(getCityData);
const currentLanguageCode = useSelector(getCurrentLangCode);
const [IsFeaturedLoading, setIsFeaturedLoading] = useState(false);
const [featuredData, setFeaturedData] = useState([]);
const [Slider, setSlider] = useState([]);
const [IsSliderLoading, setIsSliderLoading] = useState(true);
const allEmpty = featuredData?.every((ele) => ele?.section_data.length === 0);
useEffect(() => {
const fetchSliderData = async () => {
let params = {};
if (cityData?.city) {
params.city = cityData.city;
params.state = cityData.state;
params.country = cityData.country;
} else if (cityData?.state) {
params.state = cityData.state;
} else if (cityData?.country) {
params.country = cityData.country;
}
try {
const response = await sliderApi.getSlider(params);
const data = response.data;
setSlider(data.data);
} catch (error) {
console.error("Error:", error);
} finally {
setIsSliderLoading(false);
}
};
fetchSliderData();
}, [cityData?.city, cityData?.state, cityData?.country]);
useEffect(() => {
const fetchFeaturedSectionData = async () => {
setIsFeaturedLoading(true);
try {
const params = {};
if (Number(KmRange) > 0 && (cityData?.areaId || cityData?.city)) {
params.radius = KmRange;
params.latitude = cityData.lat;
params.longitude = cityData.long;
} else {
if (cityData?.areaId) {
params.area_id = cityData.areaId;
} else if (cityData?.city) {
params.city = cityData.city;
} else if (cityData?.state) {
params.state = cityData.state;
} else if (cityData?.country) {
params.country = cityData.country;
}
}
const response = await FeaturedSectionApi.getFeaturedSections(params);
const { data } = response.data;
setFeaturedData(data);
} catch (error) {
console.error("Error:", error);
} finally {
setIsFeaturedLoading(false);
}
};
fetchFeaturedSectionData();
}, [cityData.lat, cityData.long, KmRange, currentLanguageCode]);
return (
<>
{IsSliderLoading ? (
<OfferSliderSkeleton />
) : (
Slider &&
Slider.length > 0 && (
<OfferSlider Slider={Slider} IsLoading={IsSliderLoading} />
)
)}
<PopularCategories />
{IsFeaturedLoading ? (
<FeaturedSectionsSkeleton />
) : (
<FeaturedSections
featuredData={featuredData}
setFeaturedData={setFeaturedData}
allEmpty={allEmpty}
/>
)}
<AllItems cityData={cityData} KmRange={KmRange} />
</>
);
};
export default Home;

Some files were not shown because too many files have changed in this diff Show More