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