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