classify web
This commit is contained in:
50
components/PagesComponent/ProductDetail/AdEditedByAdmin.jsx
Normal file
50
components/PagesComponent/ProductDetail/AdEditedByAdmin.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { t } from "@/utils";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LiaUserEditSolid } from "react-icons/lia";
|
||||
|
||||
const AdEditedByAdmin = ({ admin_edit_reason }) => {
|
||||
const textRef = useRef(null);
|
||||
const [isTextOverflowing, setIsTextOverflowing] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkTextOverflow = () => {
|
||||
if (textRef.current && !isExpanded) {
|
||||
const element = textRef.current;
|
||||
const isOverflowing = element.scrollHeight > element.clientHeight;
|
||||
setIsTextOverflowing(isOverflowing);
|
||||
}
|
||||
};
|
||||
|
||||
checkTextOverflow();
|
||||
window.addEventListener("resize", checkTextOverflow);
|
||||
|
||||
return () => window.removeEventListener("resize", checkTextOverflow);
|
||||
}, [isExpanded]);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 border border-[#ffb3b3] bg-[#fff6f6] rounded-lg px-4 py-5 text-destructive">
|
||||
<LiaUserEditSolid className="size-12 text-destructive flex-shrink-0" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-[#d32f2f]">
|
||||
{t("adEditedBy")} <b>{t("admin")}</b>
|
||||
</span>
|
||||
<div className="text-sm text-destructive">
|
||||
<p ref={textRef} className={!isExpanded ? "line-clamp-2" : ""}>
|
||||
{admin_edit_reason}
|
||||
</p>
|
||||
</div>
|
||||
{isTextOverflowing && (
|
||||
<button
|
||||
className="text-sm font-medium text-destructive"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? t("seeLess") : t("seeMore")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdEditedByAdmin;
|
||||
51
components/PagesComponent/ProductDetail/AdsReportCard.jsx
Normal file
51
components/PagesComponent/ProductDetail/AdsReportCard.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { PiWarningOctagon } from "react-icons/pi";
|
||||
import ReportModal from "@/components/User/ReportModal";
|
||||
import { getIsLoggedIn } from "@/redux/reducer/authSlice";
|
||||
import { useSelector } from "react-redux";
|
||||
import { setIsLoginOpen } from "@/redux/reducer/globalStateSlice";
|
||||
import { t } from "@/utils";
|
||||
const AdsReportCard = ({ productDetails, setProductDetails }) => {
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const isLoggedIn = useSelector(getIsLoggedIn);
|
||||
|
||||
const handleReportAd = () => {
|
||||
if (!isLoggedIn) {
|
||||
setIsLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
setIsReportModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border rounded-lg ">
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<div className="bg-[#DC354514] rounded-full p-2">
|
||||
<PiWarningOctagon size={22} className="text-[#DC3545]" />
|
||||
</div>
|
||||
<p className="text-base">{t("reportItmLabel")}</p>
|
||||
</div>
|
||||
<div className="border-b w-full"></div>
|
||||
<div className="flex p-4 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-base font-medium">{t("adId")}</p>
|
||||
<p className="text-base font-medium"> #{productDetails?.id}</p>
|
||||
</div>
|
||||
<button
|
||||
className=" bg-muted text-primary font-semibold py-1 px-2 rounded-md text-sm"
|
||||
onClick={handleReportAd}
|
||||
>
|
||||
{t("reportAd")}
|
||||
</button>
|
||||
</div>
|
||||
<ReportModal
|
||||
productDetails={productDetails}
|
||||
setProductDetails={setProductDetails}
|
||||
isReportModalOpen={isReportModalOpen}
|
||||
setIsReportModalOpen={setIsReportModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdsReportCard;
|
||||
220
components/PagesComponent/ProductDetail/AdsStatusChangeCards.jsx
Normal file
220
components/PagesComponent/ProductDetail/AdsStatusChangeCards.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { chanegItemStatusApi } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
import SoldOutModal from "./SoldOutModal";
|
||||
import ReusableAlertDialog from "@/components/Common/ReusableAlertDialog";
|
||||
import { t } from "@/utils";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
|
||||
const AdsStatusChangeCards = ({
|
||||
productDetails,
|
||||
setProductDetails,
|
||||
status,
|
||||
setStatus,
|
||||
}) => {
|
||||
const { navigate } = useNavigate();
|
||||
const [IsChangingStatus, setIsChangingStatus] = useState(false);
|
||||
const [showSoldOut, setShowSoldOut] = useState(false);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [selectedRadioValue, setSelectedRadioValue] = useState(null);
|
||||
|
||||
const isJobAd = productDetails?.category?.is_job_category === 1;
|
||||
|
||||
const isSoftRejected =
|
||||
productDetails?.status === "soft rejected" ||
|
||||
productDetails?.status === "resubmitted";
|
||||
|
||||
const IsDisableSelect = !(
|
||||
productDetails?.status === "approved" ||
|
||||
productDetails?.status === "inactive"
|
||||
);
|
||||
|
||||
const isShowRejectedReason =
|
||||
productDetails?.rejected_reason &&
|
||||
(productDetails?.status === "soft rejected" ||
|
||||
productDetails?.status === "permanent rejected");
|
||||
const resubmitAdForReview = async () => {
|
||||
try {
|
||||
const res = await chanegItemStatusApi.changeItemStatus({
|
||||
item_id: productDetails?.id,
|
||||
status: "resubmitted",
|
||||
});
|
||||
|
||||
if (res?.data?.error === false) {
|
||||
toast(t("adResubmitted"));
|
||||
setProductDetails((prev) => ({ ...prev, status: "resubmitted" }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = (newStatus) => {
|
||||
setStatus(newStatus);
|
||||
};
|
||||
|
||||
const updateItemStatus = async () => {
|
||||
if (productDetails?.status === status) {
|
||||
toast.error(t("changeStatusToSave"));
|
||||
return;
|
||||
}
|
||||
if (status === "sold out") {
|
||||
setShowSoldOut(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsChangingStatus(true);
|
||||
const res = await chanegItemStatusApi.changeItemStatus({
|
||||
item_id: productDetails?.id,
|
||||
status: status === "approved" ? "active" : status,
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
setProductDetails((prev) => ({ ...prev, status }));
|
||||
toast.success(t("statusUpdated"));
|
||||
navigate("/my-ads");
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
} finally {
|
||||
setIsChangingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const makeItemSoldOut = async () => {
|
||||
try {
|
||||
setIsChangingStatus(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
const res = await chanegItemStatusApi.changeItemStatus({
|
||||
item_id: productDetails?.id,
|
||||
status: "sold out",
|
||||
sold_to: selectedRadioValue,
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
toast.success(t("statusUpdated"));
|
||||
setProductDetails((prev) => ({ ...prev, status: "sold out" }));
|
||||
setShowConfirmModal(false);
|
||||
} else {
|
||||
toast.error(t("failedToUpdateStatus"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsChangingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSoftRejected ? (
|
||||
<div className="border rounded-md gap-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4 text-xl font-medium border-b">
|
||||
{t("adWasRejectedResubmitNow")}
|
||||
</div>
|
||||
{productDetails?.rejected_reason && (
|
||||
<p className="bg-red-100 text-[#dc3545] px-2 py-1 rounded text-sm mt-[7px] font-medium">
|
||||
<span className="font-medium">{t("rejectedReason")}:</span>{" "}
|
||||
{productDetails?.rejected_reason}
|
||||
</p>
|
||||
)}
|
||||
<div className="w-full p-4 ">
|
||||
<button
|
||||
className="bg-primary text-white font-medium w-full p-2 rounded-md"
|
||||
disabled={productDetails?.status === "resubmitted"}
|
||||
onClick={resubmitAdForReview}
|
||||
>
|
||||
{productDetails?.status === "resubmitted"
|
||||
? t("resubmitted")
|
||||
: t("resubmit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col border rounded-md ">
|
||||
<div className="p-4 border-b font-semibold">{t("changeStatus")}</div>
|
||||
<div className="p-4 flex flex-col gap-4 ">
|
||||
<Select
|
||||
className="outline-none "
|
||||
value={status}
|
||||
onValueChange={handleStatusChange}
|
||||
disabled={IsChangingStatus || IsDisableSelect}
|
||||
>
|
||||
<SelectTrigger className="outline-none">
|
||||
<SelectValue placeholder={t("status")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="approved">{t("active")}</SelectItem>
|
||||
<SelectItem value="inactive">{t("deactivate")}</SelectItem>
|
||||
<SelectItem value="review" disabled>
|
||||
{" "}
|
||||
{t("review")}
|
||||
</SelectItem>
|
||||
<SelectItem value="permanent rejected" disabled>
|
||||
{" "}
|
||||
{t("permanentRejected")}
|
||||
</SelectItem>
|
||||
<SelectItem value="expired" disabled>
|
||||
{t("expired")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="sold out"
|
||||
disabled={productDetails?.status === "inactive"}
|
||||
>
|
||||
{isJobAd ? t("jobClosed") : t("soldOut")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{isShowRejectedReason && (
|
||||
<p className="bg-red-100 text-[#dc3545] px-2 py-1 rounded text-sm mt-[7px] font-medium">
|
||||
<span className="font-medium">{t("rejectedReason")}:</span>{" "}
|
||||
{productDetails?.rejected_reason}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className="bg-primary text-white font-medium w-full p-2 rounded-md disabled:opacity-80"
|
||||
onClick={updateItemStatus}
|
||||
disabled={IsChangingStatus || IsDisableSelect}
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SoldOutModal
|
||||
productDetails={productDetails}
|
||||
showSoldOut={showSoldOut}
|
||||
setShowSoldOut={setShowSoldOut}
|
||||
selectedRadioValue={selectedRadioValue}
|
||||
setSelectedRadioValue={setSelectedRadioValue}
|
||||
setShowConfirmModal={setShowConfirmModal}
|
||||
/>
|
||||
|
||||
<ReusableAlertDialog
|
||||
open={showConfirmModal}
|
||||
onCancel={() => setShowConfirmModal(false)}
|
||||
onConfirm={makeItemSoldOut}
|
||||
title={isJobAd ? t("confirmHire") : t("confirmSoldOut")}
|
||||
description={
|
||||
isJobAd ? t("markAsClosedDescription") : t("cantUndoChanges")
|
||||
}
|
||||
cancelText={t("cancel")}
|
||||
confirmText={t("confirm")}
|
||||
confirmDisabled={IsChangingStatus}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdsStatusChangeCards;
|
||||
267
components/PagesComponent/ProductDetail/ApplyJobModal.jsx
Normal file
267
components/PagesComponent/ProductDetail/ApplyJobModal.jsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { t } from "@/utils";
|
||||
import { HiOutlineUpload } from "react-icons/hi";
|
||||
import { MdOutlineAttachFile } from "react-icons/md";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { jobApplyApi } from "@/utils/api";
|
||||
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const ApplyJobModal = ({
|
||||
showApplyModal,
|
||||
setShowApplyModal,
|
||||
item_id,
|
||||
setProductDetails,
|
||||
}) => {
|
||||
const userData = useSelector(userSignUpData);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: userData?.name || "",
|
||||
phoneNumber: userData?.mobile || "",
|
||||
email: userData?.email || "",
|
||||
resume: null,
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [resumePreview, setResumePreview] = useState(null);
|
||||
|
||||
// this is the useEffect to revoke the object url of the resume preview
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resumePreview?.url) {
|
||||
URL.revokeObjectURL(resumePreview.url);
|
||||
}
|
||||
};
|
||||
}, [resumePreview?.url]);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
const file = acceptedFiles[0];
|
||||
if (file) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
resume: file,
|
||||
}));
|
||||
|
||||
// Create preview for PDF files
|
||||
if (file.type === "application/pdf") {
|
||||
const fileUrl = URL.createObjectURL(file);
|
||||
setResumePreview({
|
||||
url: fileUrl,
|
||||
isPdf: true,
|
||||
name: file.name,
|
||||
});
|
||||
} else {
|
||||
setResumePreview({
|
||||
url: null,
|
||||
isPdf: false,
|
||||
name: file.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"application/pdf": [".pdf"],
|
||||
"application/msword": [".doc"],
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
[".docx"],
|
||||
},
|
||||
maxFiles: 1,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const removeResume = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
resume: null,
|
||||
}));
|
||||
if (resumePreview?.url) {
|
||||
URL.revokeObjectURL(resumePreview.url);
|
||||
}
|
||||
setResumePreview(null);
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
// Create form data object to send
|
||||
const data = {
|
||||
full_name: formData.fullName,
|
||||
mobile: formData.phoneNumber,
|
||||
email: formData.email,
|
||||
item_id: item_id,
|
||||
};
|
||||
|
||||
// Only include resume if it's available
|
||||
if (formData.resume) {
|
||||
data.resume = formData.resume;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
const res = await jobApplyApi.jobApply(data);
|
||||
if (res?.data?.error === false) {
|
||||
toast.success(res?.data?.message);
|
||||
setProductDetails((prev) => ({
|
||||
...prev,
|
||||
is_already_job_applied: true,
|
||||
}));
|
||||
setShowApplyModal(false);
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showApplyModal} onOpenChange={setShowApplyModal}>
|
||||
<DialogContent
|
||||
className="max-w-md sm:max-w-lg"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
{t("applyNow")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Full Name */}
|
||||
<div className="labelInputCont">
|
||||
<Label htmlFor="fullName" className="requiredInputLabel">
|
||||
{t("fullName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
type="text"
|
||||
placeholder={t("enterFullName")}
|
||||
value={formData.fullName}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone Number */}
|
||||
<div className="labelInputCont">
|
||||
<Label htmlFor="phoneNumber" className="requiredInputLabel">
|
||||
{t("phoneNumber")}
|
||||
</Label>
|
||||
<Input
|
||||
id="phoneNumber"
|
||||
name="phoneNumber"
|
||||
type="tel"
|
||||
placeholder={t("enterPhoneNumber")}
|
||||
value={formData.phoneNumber}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
pattern="[0-9]*"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="labelInputCont">
|
||||
<Label htmlFor="email" className="requiredInputLabel">
|
||||
{t("email")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={t("enterEmail")}
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resume Upload */}
|
||||
<div className="labelInputCont">
|
||||
<Label>
|
||||
{t("resume")} ({t("optional")})
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
{!resumePreview ? (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-all duration-200 cursor-pointer hover:border-primary hover:bg-muted ${
|
||||
isDragActive ? "border-primary bg-muted" : ""
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<HiOutlineUpload className="mx-auto h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDragActive
|
||||
? t("dropResumeHere")
|
||||
: t("dragAndDropResume")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{t("clickToSelect")}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">PDF, DOC, DOCX</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg p-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MdOutlineAttachFile className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground break-all text-balance">
|
||||
{resumePreview.name}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeResume}
|
||||
className="text-red-500 hover:text-red-700 text-sm"
|
||||
>
|
||||
{t("remove")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowApplyModal(false)}
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} className="flex-1">
|
||||
{isSubmitting ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplyJobModal;
|
||||
142
components/PagesComponent/ProductDetail/JobApplicationCard.jsx
Normal file
142
components/PagesComponent/ProductDetail/JobApplicationCard.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Check, X, Download } from "lucide-react";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { formatDateMonthYear, t } from "@/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import { updateJobStatusApi } from "@/utils/api";
|
||||
|
||||
const JobApplicationCard = ({
|
||||
application,
|
||||
setReceivedApplications,
|
||||
isJobFilled,
|
||||
}) => {
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const handleStatusChange = async (newStatus) => {
|
||||
try {
|
||||
setProcessing(true);
|
||||
const res = await updateJobStatusApi.updateJobStatus({
|
||||
job_id: application.id,
|
||||
status: newStatus,
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
toast.success(res?.data?.message);
|
||||
setReceivedApplications((prev) => ({
|
||||
...prev,
|
||||
data: prev.data.map((app) =>
|
||||
app.id === application.id ? { ...app, status: newStatus } : app
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
switch (status) {
|
||||
case "accepted":
|
||||
return <Badge className="bg-green-600">{t("accepted")}</Badge>;
|
||||
case "rejected":
|
||||
return <Badge className="bg-red-500">{t("rejected")}</Badge>;
|
||||
case "pending":
|
||||
return <Badge className="bg-yellow-500">{t("pending")}</Badge>;
|
||||
default:
|
||||
return <Badge className="bg-yellow-500">{t("pending")}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between ltr:flex-row rtl:flex-row-reverse">
|
||||
<h3 className="font-semibold">{application.full_name}</h3>
|
||||
{getStatusBadge(application.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex flex-col ltr:text-left rtl:text-right">
|
||||
<span className="text-muted-foreground font-medium text-xs uppercase tracking-wide">
|
||||
{t("email")}
|
||||
</span>
|
||||
<span>{application.email}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col ltr:text-left rtl:text-right">
|
||||
<span className="text-muted-foreground font-medium text-xs uppercase tracking-wide">
|
||||
{t("phone")}
|
||||
</span>
|
||||
<span>{application.mobile}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:col-span-2 ltr:text-left rtl:text-right">
|
||||
<span className="text-muted-foreground font-medium text-xs uppercase tracking-wide">
|
||||
{t("appliedDate")}
|
||||
</span>
|
||||
<span>
|
||||
{formatDateMonthYear(application.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(application.status === "pending" || application.resume) && (
|
||||
<>
|
||||
<Separator className="my-3" />
|
||||
<div className="flex flex-wrap gap-2 ltr:flex-row rtl:flex-row-reverse">
|
||||
{application.status === "pending" && !isJobFilled && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-primary text-white"
|
||||
onClick={() => handleStatusChange("accepted")}
|
||||
disabled={processing}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
{t("accept")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleStatusChange("rejected")}
|
||||
disabled={processing}
|
||||
>
|
||||
<X className="size-4" />
|
||||
{t("reject")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{application.resume && (
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<CustomLink
|
||||
href={application.resume}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{t("viewResume")}
|
||||
</CustomLink>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobApplicationCard;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const JobApplicationCardSkeleton = () => {
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1 sm:col-span-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-px w-full" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobApplicationCardSkeleton;
|
||||
143
components/PagesComponent/ProductDetail/JobApplicationModal.jsx
Normal file
143
components/PagesComponent/ProductDetail/JobApplicationModal.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "@/utils";
|
||||
import { getAdJobApplicationsApi } from "@/utils/api";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import JobApplicationCard from "./JobApplicationCard";
|
||||
import JobApplicationCardSkeleton from "./JobApplicationCardSkeleton";
|
||||
|
||||
const JobApplicationModal = ({
|
||||
IsShowJobApplications,
|
||||
setIsShowJobApplications,
|
||||
listingId,
|
||||
isJobFilled,
|
||||
}) => {
|
||||
const [receivedApplications, setReceivedApplications] = useState({
|
||||
data: [],
|
||||
isLoading: true,
|
||||
isLoadingMore: false,
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (IsShowJobApplications) {
|
||||
fetchApplications(receivedApplications?.currentPage);
|
||||
}
|
||||
}, [IsShowJobApplications]);
|
||||
|
||||
const fetchApplications = async (page) => {
|
||||
try {
|
||||
if (page === 1) {
|
||||
setReceivedApplications((prev) => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
}));
|
||||
} else {
|
||||
setReceivedApplications((prev) => ({
|
||||
...prev,
|
||||
isLoadingMore: true,
|
||||
}));
|
||||
}
|
||||
const res = await getAdJobApplicationsApi.getAdJobApplications({
|
||||
page,
|
||||
item_id: listingId,
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
if (page === 1) {
|
||||
setReceivedApplications((prev) => ({
|
||||
...prev,
|
||||
data: res?.data?.data?.data || [],
|
||||
currentPage: res?.data?.data?.current_page,
|
||||
hasMore: res?.data?.data?.last_page > res?.data?.data?.current_page,
|
||||
}));
|
||||
} else {
|
||||
setReceivedApplications((prev) => ({
|
||||
...prev,
|
||||
data: [...prev.data, ...(res?.data?.data?.data || [])],
|
||||
currentPage: res?.data?.data?.current_page,
|
||||
hasMore: res?.data?.data?.last_page > res?.data?.data?.current_page,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setReceivedApplications((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
const loadMore = () => {
|
||||
fetchApplications(receivedApplications.currentPage + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={IsShowJobApplications}
|
||||
onOpenChange={setIsShowJobApplications}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-2xl max-h-[80vh]"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
{t("jobApplications")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="!text-base">
|
||||
{t("jobApplicationsDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[60vh] pr-4">
|
||||
<div className="space-y-4">
|
||||
{receivedApplications.isLoading ? (
|
||||
// Show skeleton loading state
|
||||
Array.from({ length: 3 }).map((_, index) => (
|
||||
<JobApplicationCardSkeleton key={index} />
|
||||
))
|
||||
) : receivedApplications?.data?.length > 0 ? (
|
||||
receivedApplications?.data?.map((application) => (
|
||||
<JobApplicationCard
|
||||
key={application?.id}
|
||||
application={application}
|
||||
setReceivedApplications={setReceivedApplications}
|
||||
isJobFilled={isJobFilled}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoData name={t("jobApplications")} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{receivedApplications.hasMore && (
|
||||
<div className="text-center my-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadMore}
|
||||
disabled={receivedApplications.isLoadingMore}
|
||||
className="text-sm sm:text-base text-primary w-[256px]"
|
||||
>
|
||||
{receivedApplications.isLoadingMore
|
||||
? t("loading")
|
||||
: t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobApplicationModal;
|
||||
112
components/PagesComponent/ProductDetail/MakeFeaturedAd.jsx
Normal file
112
components/PagesComponent/ProductDetail/MakeFeaturedAd.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { t } from "@/utils";
|
||||
import adIcon from "@/public/assets/ad_icon.svg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ReusableAlertDialog from "@/components/Common/ReusableAlertDialog";
|
||||
import { createFeaturedItemApi, getLimitsApi } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
|
||||
const MakeFeaturedAd = ({ item_id, setProductDetails }) => {
|
||||
const [isGettingLimits, setIsGettingLimits] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [modalConfig, setModalConfig] = useState({});
|
||||
const [isConfirmLoading, setIsConfirmLoading] = useState(false);
|
||||
const { navigate } = useNavigate();
|
||||
|
||||
const handleCreateFeaturedAd = async () => {
|
||||
try {
|
||||
setIsGettingLimits(true);
|
||||
const res = await getLimitsApi.getLimits({
|
||||
package_type: "advertisement",
|
||||
});
|
||||
|
||||
if (res?.data?.error === false) {
|
||||
// ✅ Limit granted → show confirmation modal
|
||||
setModalConfig({
|
||||
title: t("createFeaturedAd"),
|
||||
description: t("youWantToCreateFeaturedAd"),
|
||||
cancelText: t("cancel"),
|
||||
confirmText: t("yes"),
|
||||
onConfirm: createFeaturedAd,
|
||||
});
|
||||
} else {
|
||||
// ❌ No package → show subscribe modal
|
||||
setModalConfig({
|
||||
title: t("noPackage"),
|
||||
description: t("pleaseSubscribes"),
|
||||
cancelText: t("cancel"),
|
||||
confirmText: t("subscribe"),
|
||||
onConfirm: () => navigate("/user-subscription"),
|
||||
});
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsGettingLimits(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createFeaturedAd = async () => {
|
||||
try {
|
||||
setIsConfirmLoading(true);
|
||||
const res = await createFeaturedItemApi.createFeaturedItem({
|
||||
item_id,
|
||||
positions: "home_screen",
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
toast.success(t("featuredAdCreated"));
|
||||
setProductDetails((prev) => ({
|
||||
...prev,
|
||||
is_feature: true,
|
||||
}));
|
||||
setIsModalOpen(false);
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsConfirmLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border rounded-md p-4 flex flex-col md:flex-row items-center gap-3 justify-between">
|
||||
<div className="flex flex-col md:flex-row items-center gap-4">
|
||||
<div className="bg-muted py-4 px-5 rounded-md">
|
||||
<div className="w-[62px] h-[75px] relative">
|
||||
<CustomImage
|
||||
src={adIcon}
|
||||
alt="featured-ad"
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xl font-medium text-center ltr:md:text-left rtl:md:text-right">
|
||||
{t("featureAdPrompt")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCreateFeaturedAd} disabled={isGettingLimits}>
|
||||
{t("createFeaturedAd")}
|
||||
</Button>
|
||||
</div>
|
||||
<ReusableAlertDialog
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
onConfirm={modalConfig.onConfirm}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
cancelText={modalConfig.cancelText}
|
||||
confirmText={modalConfig.confirmText}
|
||||
confirmDisabled={isConfirmLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MakeFeaturedAd;
|
||||
198
components/PagesComponent/ProductDetail/MakeOfferModal.jsx
Normal file
198
components/PagesComponent/ProductDetail/MakeOfferModal.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { itemOfferApi, tipsApi } from "@/utils/api";
|
||||
import { t } from "@/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { FaCheck } from "react-icons/fa";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
|
||||
const MakeOfferModal = ({ isOpen, onClose, productDetails }) => {
|
||||
const { navigate } = useNavigate();
|
||||
const [offerAmount, setOfferAmount] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [tips, setTips] = useState([]);
|
||||
const [isLoadingTips, setIsLoadingTips] = useState(true);
|
||||
const [canProceed, setCanProceed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchTips();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchTips = async () => {
|
||||
try {
|
||||
setIsLoadingTips(true);
|
||||
const response = await tipsApi.tips();
|
||||
if (response?.data?.error === false) {
|
||||
const tipsData = response.data.data || [];
|
||||
setTips(tipsData);
|
||||
// If no tips found, automatically set canProceed to true
|
||||
if (!tipsData.length) {
|
||||
setCanProceed(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching tips:", error);
|
||||
// If error occurs, show make offer interface
|
||||
setCanProceed(true);
|
||||
} finally {
|
||||
setIsLoadingTips(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateOffer = () => {
|
||||
if (!offerAmount.trim()) {
|
||||
setError(t("offerAmountRequired"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const amount = Number(offerAmount);
|
||||
if (amount >= productDetails?.price) {
|
||||
setError(t("offerMustBeLessThanSellerPrice"));
|
||||
return false;
|
||||
}
|
||||
|
||||
setError("");
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateOffer()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const response = await itemOfferApi.offer({
|
||||
item_id: productDetails?.id,
|
||||
amount: Number(offerAmount),
|
||||
});
|
||||
|
||||
if (response?.data?.error === false) {
|
||||
toast.success(t("offerSentSuccessfully"));
|
||||
onClose();
|
||||
navigate("/chat?activeTab=buying&chatid=" + response?.data?.data?.id);
|
||||
} else {
|
||||
toast.error(t("unableToSendOffer"));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("unableToSendOffer"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setOfferAmount(e.target.value);
|
||||
setError(""); // Clear error when user starts typing
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
setCanProceed(true);
|
||||
};
|
||||
|
||||
const renderMakeOfferForm = () => (
|
||||
<div className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-4xl font-normal">
|
||||
{t("makeAn")}
|
||||
<span className="text-primary"> {t("offer")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("openToOffers")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-2 bg-muted py-6 px-3 rounded-md justify-center items-center">
|
||||
<span>{t("sellerPrice")}</span>
|
||||
<span className="text-2xl font-medium">
|
||||
{productDetails?.formatted_price}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offerAmount" className="requiredInputLabel">
|
||||
{t("yourOffer")}
|
||||
</Label>
|
||||
<Input
|
||||
id="offerAmount"
|
||||
type="number"
|
||||
value={offerAmount}
|
||||
onChange={handleChange}
|
||||
placeholder={t("typeOfferPrice")}
|
||||
min={0}
|
||||
className={error ? "border-red-500 focus-visible:ring-red-500" : ""}
|
||||
/>
|
||||
{error && <span className="text-red-500 text-sm">{error}</span>}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="py-2 px-4 rounded-md"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t("sending") : t("sendOffer")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTipsSection = () => (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-4xl text-center font-normal">
|
||||
{t("safety")}
|
||||
<span className="text-primary"> {t("tips")}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isLoadingTips ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center space-x-4">
|
||||
<Skeleton className="h-4 w-[5%]" />
|
||||
<Skeleton className="h-4 w-[95%]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tips.map((tip, index) => (
|
||||
<div key={tip?.id} className="flex items-center gap-2">
|
||||
<div className="p-2 text-white bg-primary rounded-full">
|
||||
<FaCheck size={18} />
|
||||
</div>
|
||||
<p className="">{tip?.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleContinue}>{t("continue")}</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
className="sm:py-[50px] sm:px-[90px]"
|
||||
>
|
||||
{!canProceed ? renderTipsSection() : renderMakeOfferForm()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default MakeOfferModal;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { formatDateMonthYear, t } from "@/utils/index";
|
||||
import { FaBriefcase, FaRegCalendarCheck, FaRegHeart } from "react-icons/fa";
|
||||
import { IoEyeOutline } from "react-icons/io5";
|
||||
import { toast } from "sonner";
|
||||
import { useSelector } from "react-redux";
|
||||
import { deleteItemApi } from "@/utils/api";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { getCompanyName } from "@/redux/reducer/settingSlice";
|
||||
import ShareDropdown from "@/components/Common/ShareDropdown";
|
||||
import { useState } from "react";
|
||||
import JobApplicationModal from "./JobApplicationModal";
|
||||
import ReusableAlertDialog from "@/components/Common/ReusableAlertDialog";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
|
||||
const MyAdsListingDetailCard = ({ productDetails }) => {
|
||||
const { navigate } = useNavigate();
|
||||
const CompanyName = useSelector(getCompanyName);
|
||||
|
||||
const [IsDeleteAccount, setIsDeleteAccount] = useState(false);
|
||||
const [IsDeletingAccount, setIsDeletingAccount] = useState(false);
|
||||
|
||||
const [IsShowJobApplications, setIsShowJobApplications] = useState(false);
|
||||
const productName =
|
||||
productDetails?.translated_item?.name || productDetails?.name;
|
||||
// share variables
|
||||
const currentUrl = `${process.env.NEXT_PUBLIC_WEB_URL}/ad-details/${productDetails?.slug}`;
|
||||
const FbTitle = productName + " | " + CompanyName;
|
||||
const headline = `🚀 Discover the perfect deal! Explore "${productName}" from ${CompanyName} and grab it before it's gone. Shop now at`;
|
||||
const isEditable =
|
||||
productDetails?.status &&
|
||||
!["permanent rejected", "inactive", "sold out", "expired"].includes(
|
||||
productDetails.status
|
||||
);
|
||||
|
||||
// job application variables
|
||||
const isJobCategory = Number(productDetails?.category?.is_job_category) === 1;
|
||||
const isShowReceivedJobApplications =
|
||||
isJobCategory &&
|
||||
(productDetails?.status === "approved" ||
|
||||
productDetails?.status === "featured" ||
|
||||
productDetails?.status === "sold out");
|
||||
|
||||
const price = isJobCategory
|
||||
? productDetails?.formatted_salary_range
|
||||
: productDetails?.formatted_price;
|
||||
|
||||
const deleteAd = async () => {
|
||||
try {
|
||||
setIsDeletingAccount(true);
|
||||
const res = await deleteItemApi.deleteItem({
|
||||
item_id: productDetails?.id,
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
toast.success(t("adDeleted"));
|
||||
navigate("/my-ads");
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsDeletingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col border rounded-lg">
|
||||
<div className="flex w-full flex-col gap-4 p-4 border-b">
|
||||
<div className="flex justify-between max-w-full">
|
||||
<h1
|
||||
className="text-2xl font-medium word-break-all line-clamp-2"
|
||||
title={productName}
|
||||
>
|
||||
{productName}
|
||||
</h1>
|
||||
{productDetails?.status === "approved" && (
|
||||
<ShareDropdown
|
||||
url={currentUrl}
|
||||
title={FbTitle}
|
||||
headline={headline}
|
||||
companyName={CompanyName}
|
||||
className="rounded-full size-10 flex items-center justify-center p-2 border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-end w-full">
|
||||
<h2
|
||||
className="text-primary text-3xl font-bold break-all text-balance line-clamp-2"
|
||||
title={price}
|
||||
>
|
||||
{price}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{t("adId")} #{productDetails?.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center text-muted-foreground gap-1 p-4 border-b flex-wrap">
|
||||
<div className="text-sm flex items-center gap-1 ">
|
||||
<FaRegCalendarCheck size={14} />
|
||||
{t("postedOn")}: {formatDateMonthYear(productDetails?.created_at)}
|
||||
</div>
|
||||
<div className="ltr:border-l rtl:border-r gap-1 flex items-center text-sm px-2">
|
||||
<IoEyeOutline size={14} />
|
||||
{t("views")}: {productDetails?.clicks}
|
||||
</div>
|
||||
<div className="ltr:border-l rtl:border-r gap-1 flex items-center text-sm px-2">
|
||||
<FaRegHeart size={14} />
|
||||
{t("favorites")}: {productDetails?.total_likes}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex items-center gap-4 flex-wrap">
|
||||
<button
|
||||
className="py-2 px-4 flex-1 rounded-md bg-black text-white font-medium"
|
||||
onClick={() => setIsDeleteAccount(true)}
|
||||
>
|
||||
{t("delete")}
|
||||
</button>
|
||||
|
||||
{isEditable && (
|
||||
<CustomLink
|
||||
href={`/edit-listing/${productDetails?.id}`}
|
||||
className="bg-primary py-2 px-4 flex-1 rounded-md text-white font-medium text-center"
|
||||
>
|
||||
{t("edit")}
|
||||
</CustomLink>
|
||||
)}
|
||||
|
||||
{isShowReceivedJobApplications && (
|
||||
<button
|
||||
onClick={() => setIsShowJobApplications(true)}
|
||||
className="bg-black py-2 px-4 flex-1 rounded-md text-white font-medium whitespace-nowrap flex items-center gap-2 justify-center"
|
||||
>
|
||||
<FaBriefcase />
|
||||
{t("jobApplications")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<JobApplicationModal
|
||||
IsShowJobApplications={IsShowJobApplications}
|
||||
setIsShowJobApplications={setIsShowJobApplications}
|
||||
listingId={productDetails?.id}
|
||||
isJobFilled={productDetails?.status === "sold out"}
|
||||
/>
|
||||
<ReusableAlertDialog
|
||||
open={IsDeleteAccount}
|
||||
onCancel={() => setIsDeleteAccount(false)}
|
||||
onConfirm={deleteAd}
|
||||
title={t("areYouSure")}
|
||||
description={t("youWantToDeleteThisAd")}
|
||||
cancelText={t("cancel")}
|
||||
confirmText={t("yes")}
|
||||
confirmDisabled={IsDeletingAccount}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyAdsListingDetailCard;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import parse from "html-react-parser";
|
||||
import { t } from "@/utils";
|
||||
const ProductDescription = ({ productDetails }) => {
|
||||
const [showFullDescription, setShowFullDescription] = useState(false);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const descriptionRef = useRef(null);
|
||||
|
||||
const translated_item = productDetails?.translated_item;
|
||||
|
||||
const fullDescription =
|
||||
translated_item?.description?.replace(/\n/g, "<br />") ||
|
||||
productDetails?.description?.replace(/\n/g, "<br />");
|
||||
|
||||
useEffect(() => {
|
||||
const descriptionBody = descriptionRef.current;
|
||||
if (descriptionBody) {
|
||||
setIsOverflowing(
|
||||
descriptionBody.scrollHeight > descriptionBody.clientHeight
|
||||
);
|
||||
}
|
||||
}, [fullDescription]);
|
||||
|
||||
const toggleDescription = () => {
|
||||
setShowFullDescription((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-2xl font-medium">{t("description")}</span>
|
||||
<div
|
||||
className={`${
|
||||
showFullDescription ? "h-[100%]" : "max-h-[72px]"
|
||||
} max-w-full prose lg:prose-lg overflow-hidden`}
|
||||
ref={descriptionRef}
|
||||
>
|
||||
{parse(fullDescription || "")}
|
||||
</div>
|
||||
{isOverflowing && (
|
||||
<div className=" flex justify-center items-center">
|
||||
<button
|
||||
onClick={toggleDescription}
|
||||
className="text-primary font-bold text-base"
|
||||
>
|
||||
{showFullDescription ? t("seeLess") : t("seeMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDescription;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { formatDateMonthYear, t } from "@/utils/index";
|
||||
import { FaHeart, FaRegCalendarCheck, FaRegHeart } from "react-icons/fa";
|
||||
import { manageFavouriteApi } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getIsLoggedIn } from "@/redux/reducer/authSlice";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getCompanyName } from "@/redux/reducer/settingSlice";
|
||||
import ShareDropdown from "@/components/Common/ShareDropdown";
|
||||
import { setIsLoginOpen } from "@/redux/reducer/globalStateSlice";
|
||||
|
||||
const ProductDetailCard = ({ productDetails, setProductDetails }) => {
|
||||
const path = usePathname();
|
||||
const currentUrl = `${process.env.NEXT_PUBLIC_WEB_URL}${path}`;
|
||||
const translated_item = productDetails?.translated_item;
|
||||
const isLoggedIn = useSelector(getIsLoggedIn);
|
||||
const CompanyName = useSelector(getCompanyName);
|
||||
const FbTitle =
|
||||
(translated_item?.name || productDetails?.name) + " | " + CompanyName;
|
||||
const headline = `🚀 Discover the perfect deal! Explore "${
|
||||
translated_item?.name || productDetails?.name
|
||||
}" from ${CompanyName} and grab it before it's gone. Shop now at`;
|
||||
|
||||
const isJobCategory = Number(productDetails?.category?.is_job_category) === 1;
|
||||
const price = isJobCategory
|
||||
? productDetails?.formatted_salary_range
|
||||
: productDetails?.formatted_price;
|
||||
|
||||
const handleLikeItem = async () => {
|
||||
if (!isLoggedIn) {
|
||||
setIsLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await manageFavouriteApi.manageFavouriteApi({
|
||||
item_id: productDetails?.id,
|
||||
});
|
||||
if (response?.data?.error === false) {
|
||||
setProductDetails((prev) => ({
|
||||
...prev,
|
||||
is_liked: !productDetails?.is_liked,
|
||||
}));
|
||||
}
|
||||
toast.success(response?.data?.message);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border p-4 rounded-lg">
|
||||
<div className="flex justify-between max-w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1
|
||||
className="text-2xl font-medium word-break-all line-clamp-2"
|
||||
title={translated_item?.name || productDetails?.name}
|
||||
>
|
||||
{translated_item?.name || productDetails?.name}
|
||||
</h1>
|
||||
<h2
|
||||
className="text-primary text-3xl font-bold break-all text-balance line-clamp-2"
|
||||
title={price}
|
||||
>
|
||||
{price}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
className="rounded-full size-10 flex items-center justify-center p-2 border"
|
||||
onClick={handleLikeItem}
|
||||
>
|
||||
{productDetails?.is_liked === true ? (
|
||||
<FaHeart size={20} className="text-primary" />
|
||||
) : (
|
||||
<FaRegHeart size={20} />
|
||||
)}
|
||||
</button>
|
||||
<ShareDropdown
|
||||
url={currentUrl}
|
||||
title={FbTitle}
|
||||
headline={headline}
|
||||
companyName={CompanyName}
|
||||
className="rounded-full p-2 border bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 items-center">
|
||||
<FaRegCalendarCheck />
|
||||
{t("postedOn")}:{formatDateMonthYear(productDetails?.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailCard;
|
||||
264
components/PagesComponent/ProductDetail/ProductDetails.jsx
Normal file
264
components/PagesComponent/ProductDetail/ProductDetails.jsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { allItemApi, getMyItemsApi, setItemTotalClickApi } from "@/utils/api";
|
||||
import ProductFeature from "./ProductFeature";
|
||||
import ProductDescription from "./ProductDescription";
|
||||
import ProductDetailCard from "./ProductDetailCard";
|
||||
import SellerDetailCard from "./SellerDetailCard";
|
||||
import ProductLocation from "./ProductLocation";
|
||||
import AdsReportCard from "./AdsReportCard";
|
||||
import SimilarProducts from "./SimilarProducts";
|
||||
import MyAdsListingDetailCard from "./MyAdsListingDetailCard";
|
||||
import AdsStatusChangeCards from "./AdsStatusChangeCards";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import ProductGallery from "./ProductGallery";
|
||||
import {
|
||||
getFilteredCustomFields,
|
||||
getYouTubeVideoId,
|
||||
t,
|
||||
truncate,
|
||||
} from "@/utils";
|
||||
import PageLoader from "@/components/Common/PageLoader";
|
||||
import OpenInAppDrawer from "@/components/Common/OpenInAppDrawer";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
|
||||
import { setBreadcrumbPath } from "@/redux/reducer/breadCrumbSlice";
|
||||
import MakeFeaturedAd from "./MakeFeaturedAd";
|
||||
import RenewAd from "./RenewAd";
|
||||
import AdEditedByAdmin from "./AdEditedByAdmin";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
|
||||
const ProductDetails = ({ slug }) => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const dispatch = useDispatch();
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const isShare = searchParams.get("share") == "true" ? true : false;
|
||||
const isMyListing = pathName?.startsWith("/my-listing") ? true : false;
|
||||
const [productDetails, setProductDetails] = useState(null);
|
||||
const [galleryImages, setGalleryImages] = useState([]);
|
||||
const [status, setStatus] = useState("");
|
||||
const [videoData, setVideoData] = useState({
|
||||
url: "",
|
||||
thumbnail: "",
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpenInApp, setIsOpenInApp] = useState(false);
|
||||
|
||||
const IsShowFeaturedAd =
|
||||
isMyListing &&
|
||||
!productDetails?.is_feature &&
|
||||
productDetails?.status === "approved";
|
||||
|
||||
const isMyAdExpired = isMyListing && productDetails?.status === "expired";
|
||||
const isEditedByAdmin =
|
||||
isMyListing && productDetails?.is_edited_by_admin === 1;
|
||||
|
||||
useEffect(() => {
|
||||
fetchProductDetails();
|
||||
}, [CurrentLanguage?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth <= 768 && !isMyListing && isShare) {
|
||||
setIsOpenInApp(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchMyListingDetails = async (slug) => {
|
||||
const response = await getMyItemsApi.getMyItems({ slug });
|
||||
const product = response?.data?.data?.data?.[0];
|
||||
if (!product) throw new Error("My listing product not found");
|
||||
setProductDetails(product);
|
||||
const videoLink = product?.video_link;
|
||||
if (videoLink) {
|
||||
const videoId = getYouTubeVideoId(videoLink);
|
||||
const thumbnail = videoId
|
||||
? `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`
|
||||
: "";
|
||||
setVideoData((prev) => ({ ...prev, url: videoLink, thumbnail }));
|
||||
}
|
||||
|
||||
const galleryImages =
|
||||
product?.gallery_images?.map((image) => image?.image) || [];
|
||||
setGalleryImages([product?.image, ...galleryImages]);
|
||||
setStatus(product?.status);
|
||||
dispatch(
|
||||
setBreadcrumbPath([
|
||||
{
|
||||
name: t("myAds"),
|
||||
slug: "/my-ads",
|
||||
},
|
||||
{
|
||||
name: truncate(product?.translated_item?.name || product?.name, 80),
|
||||
},
|
||||
])
|
||||
);
|
||||
};
|
||||
const incrementViews = async (item_id) => {
|
||||
try {
|
||||
if (!item_id) {
|
||||
console.error("Invalid item_id for incrementViews");
|
||||
return;
|
||||
}
|
||||
const res = await setItemTotalClickApi.setItemTotalClick({ item_id });
|
||||
} catch (error) {
|
||||
console.error("Error incrementing views:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPublicListingDetails = async (slug) => {
|
||||
const response = await allItemApi.getItems({ slug });
|
||||
const product = response?.data?.data?.data?.[0];
|
||||
|
||||
if (!product) throw new Error("Public listing product not found");
|
||||
setProductDetails(product);
|
||||
const videoLink = product?.video_link;
|
||||
if (videoLink) {
|
||||
setVideoData((prev) => ({ ...prev, url: videoLink }));
|
||||
const videoId = getYouTubeVideoId(videoLink);
|
||||
const thumbnail = videoId
|
||||
? `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`
|
||||
: "";
|
||||
setVideoData((prev) => ({ ...prev, thumbnail }));
|
||||
}
|
||||
|
||||
const galleryImages =
|
||||
product?.gallery_images?.map((image) => image?.image) || [];
|
||||
setGalleryImages([product?.image, ...galleryImages]);
|
||||
await incrementViews(product?.id);
|
||||
};
|
||||
|
||||
const fetchProductDetails = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (isMyListing) {
|
||||
await fetchMyListingDetails(slug);
|
||||
} else {
|
||||
await fetchPublicListingDetails(slug);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch product details:", error);
|
||||
// You can also show a toast or error message here
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFields = getFilteredCustomFields(
|
||||
productDetails?.all_translated_custom_fields,
|
||||
CurrentLanguage?.id
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
{isLoading ? (
|
||||
<PageLoader />
|
||||
) : productDetails ? (
|
||||
<>
|
||||
{isMyListing ? (
|
||||
<BreadCrumb />
|
||||
) : (
|
||||
<BreadCrumb
|
||||
title2={truncate(
|
||||
productDetails?.translated_item?.name || productDetails?.name,
|
||||
80
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="container mt-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-7 mt-6">
|
||||
<div className="col-span-1 lg:col-span-8">
|
||||
<div className="flex flex-col gap-7">
|
||||
<ProductGallery
|
||||
galleryImages={galleryImages}
|
||||
videoData={videoData}
|
||||
/>
|
||||
|
||||
{IsShowFeaturedAd && (
|
||||
<MakeFeaturedAd
|
||||
item_id={productDetails?.id}
|
||||
setProductDetails={setProductDetails}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filteredFields.length > 0 && (
|
||||
<ProductFeature filteredFields={filteredFields} />
|
||||
)}
|
||||
<ProductDescription productDetails={productDetails} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col col-span-1 lg:col-span-4 gap-7">
|
||||
{isMyListing ? (
|
||||
<MyAdsListingDetailCard productDetails={productDetails} />
|
||||
) : (
|
||||
<ProductDetailCard
|
||||
productDetails={productDetails}
|
||||
setProductDetails={setProductDetails}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isMyListing && (
|
||||
<SellerDetailCard
|
||||
productDetails={productDetails}
|
||||
setProductDetails={setProductDetails}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMyListing && (
|
||||
<AdsStatusChangeCards
|
||||
productDetails={productDetails}
|
||||
setProductDetails={setProductDetails}
|
||||
status={status}
|
||||
setStatus={setStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEditedByAdmin && (
|
||||
<AdEditedByAdmin
|
||||
admin_edit_reason={productDetails?.admin_edit_reason}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMyAdExpired && (
|
||||
<RenewAd
|
||||
item_id={productDetails?.id}
|
||||
setProductDetails={setProductDetails}
|
||||
currentLanguageId={CurrentLanguage?.id}
|
||||
setStatus={setStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProductLocation productDetails={productDetails} />
|
||||
|
||||
{!isMyListing && !productDetails?.is_already_reported && (
|
||||
<AdsReportCard
|
||||
productDetails={productDetails}
|
||||
setProductDetails={setProductDetails}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isMyListing && (
|
||||
<SimilarProducts
|
||||
productDetails={productDetails}
|
||||
key={`similar-products-${CurrentLanguage?.id}`}
|
||||
/>
|
||||
)}
|
||||
<OpenInAppDrawer
|
||||
isOpenInApp={isOpenInApp}
|
||||
setIsOpenInApp={setIsOpenInApp}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="container mt-8">
|
||||
<NoData name={t("oneAdvertisement")} />
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetails;
|
||||
80
components/PagesComponent/ProductDetail/ProductFeature.jsx
Normal file
80
components/PagesComponent/ProductDetail/ProductFeature.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FaRegLightbulb } from "react-icons/fa";
|
||||
import { isPdf, t } from "@/utils/index";
|
||||
import { MdOutlineAttachFile } from "react-icons/md";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const ProductFeature = ({ filteredFields }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 bg-muted rounded-lg">
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div>
|
||||
<Badge className="bg-primary rounded-sm gap-1 text-base text-white py-2 px-4">
|
||||
<FaRegLightbulb />
|
||||
{t("highlights")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 items-start mt-6">
|
||||
{filteredFields?.map((feature, index) => {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-2 md:gap-3 w-full" key={index}>
|
||||
<div className="flex items-center gap-2 w-full md:w-1/3">
|
||||
<CustomImage
|
||||
src={feature?.image}
|
||||
alt={feature?.translated_name || feature?.name}
|
||||
height={24}
|
||||
width={24}
|
||||
className="aspect-square size-6"
|
||||
/>
|
||||
<p className="text-base font-medium text-wrap">
|
||||
{feature?.translated_name || feature?.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-4 w-full md:w-2/3">
|
||||
<span className="hidden md:inline">:</span>
|
||||
{feature.type === "fileinput" ? (
|
||||
isPdf(feature?.value?.[0]) ? (
|
||||
<div className="flex gap-1 items-center">
|
||||
<MdOutlineAttachFile size={20} />
|
||||
<CustomLink
|
||||
href={feature?.value?.[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("viewPdf")}
|
||||
</CustomLink>
|
||||
</div>
|
||||
) : (
|
||||
<CustomLink
|
||||
href={feature?.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<CustomImage
|
||||
src={feature?.value}
|
||||
alt="Preview"
|
||||
width={36}
|
||||
height={36}
|
||||
/>
|
||||
</CustomLink>
|
||||
)
|
||||
) : (
|
||||
<p className="text-base text-muted-foreground w-full">
|
||||
{Array.isArray(feature?.translated_selected_values)
|
||||
? feature?.translated_selected_values.join(", ")
|
||||
: feature?.translated_selected_values}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductFeature;
|
||||
205
components/PagesComponent/ProductDetail/ProductGallery.jsx
Normal file
205
components/PagesComponent/ProductDetail/ProductGallery.jsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||||
import "react-photo-view/dist/react-photo-view.css";
|
||||
import {
|
||||
RiArrowLeftLine,
|
||||
RiArrowRightLine,
|
||||
RiPlayCircleFill,
|
||||
} from "react-icons/ri";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
import ReactPlayer from "react-player";
|
||||
import { getPlaceholderImage } from "@/redux/reducer/settingSlice";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const ProductGallery = ({ galleryImages, videoData }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0); // -1 means video
|
||||
const carouselApi = useRef(null);
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
const placeHolderImage = useSelector(getPlaceholderImage);
|
||||
|
||||
const hasVideo = videoData?.url;
|
||||
|
||||
useEffect(() => {
|
||||
if (!carouselApi.current) return;
|
||||
// If no video, we use this normally
|
||||
const handleSelect = () => {
|
||||
const index = carouselApi.current.selectedScrollSnap();
|
||||
setSelectedIndex(index);
|
||||
};
|
||||
carouselApi.current.on("select", handleSelect);
|
||||
setSelectedIndex(carouselApi.current.selectedScrollSnap());
|
||||
|
||||
return () => {
|
||||
carouselApi.current?.off("select", handleSelect);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePrevImage = () => {
|
||||
if (!carouselApi.current) return;
|
||||
if (selectedIndex === -1) {
|
||||
// From video, go to last image
|
||||
const lastImageIndex = galleryImages.length - 1;
|
||||
carouselApi.current.scrollTo(lastImageIndex);
|
||||
setSelectedIndex(lastImageIndex);
|
||||
} else if (selectedIndex === 0) {
|
||||
if (hasVideo) {
|
||||
setSelectedIndex(-1);
|
||||
} else {
|
||||
const lastIndex = galleryImages.length - 1;
|
||||
carouselApi.current.scrollTo(lastIndex);
|
||||
setSelectedIndex(lastIndex);
|
||||
}
|
||||
} else {
|
||||
const newIndex = selectedIndex - 1;
|
||||
carouselApi.current.scrollTo(newIndex);
|
||||
setSelectedIndex(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextImage = () => {
|
||||
if (!carouselApi.current) return;
|
||||
if (selectedIndex === -1) {
|
||||
// From video, go to first image
|
||||
carouselApi.current.scrollTo(0);
|
||||
setSelectedIndex(0);
|
||||
} else if (selectedIndex === galleryImages.length - 1) {
|
||||
// From last image, go to video
|
||||
if (hasVideo) {
|
||||
// Go to video
|
||||
setSelectedIndex(-1);
|
||||
} else {
|
||||
// Loop to first image
|
||||
carouselApi.current.scrollTo(0);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} else {
|
||||
const newIndex = (selectedIndex + 1) % galleryImages.length;
|
||||
carouselApi.current.scrollTo(newIndex);
|
||||
setSelectedIndex(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageClick = (index) => {
|
||||
setSelectedIndex(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<PhotoProvider>
|
||||
<div className="bg-muted rounded-lg">
|
||||
{selectedIndex === -1 ? (
|
||||
<ReactPlayer
|
||||
url={videoData.url}
|
||||
controls
|
||||
playing={false}
|
||||
className="aspect-[870/500] rounded-lg"
|
||||
width="100%"
|
||||
height="100%"
|
||||
config={{
|
||||
file: {
|
||||
attributes: { controlsList: "nodownload" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PhotoView
|
||||
src={galleryImages[selectedIndex] || placeHolderImage}
|
||||
index={selectedIndex}
|
||||
key={selectedIndex}
|
||||
>
|
||||
<CustomImage
|
||||
src={galleryImages[selectedIndex]}
|
||||
alt="Product Detail"
|
||||
width={870}
|
||||
height={500}
|
||||
className="h-full w-full object-center object-contain rounded-lg aspect-[870/500] cursor-pointer"
|
||||
/>
|
||||
</PhotoView>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Carousel
|
||||
key={isRTL ? "rtl" : "ltr"}
|
||||
opts={{
|
||||
align: "start",
|
||||
containScroll: "trim",
|
||||
direction: isRTL ? "rtl" : "ltr",
|
||||
}}
|
||||
className="w-full"
|
||||
setApi={(api) => {
|
||||
carouselApi.current = api;
|
||||
}}
|
||||
>
|
||||
<CarouselContent className="md:-ml-[20px]">
|
||||
{galleryImages?.map((image, index) => (
|
||||
<CarouselItem key={index} className="basis-auto md:pl-[20px]">
|
||||
<PhotoView src={image} index={index} className="hidden" />
|
||||
<CustomImage
|
||||
src={image}
|
||||
alt="Product Detail"
|
||||
height={120}
|
||||
width={120}
|
||||
className={`w-[100px] sm:w-[120px] aspect-square object-cover rounded-lg cursor-pointer ${
|
||||
selectedIndex === index ? "border-2 border-primary" : ""
|
||||
}`}
|
||||
onClick={() => handleImageClick(index)}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
{hasVideo && (
|
||||
<CarouselItem className="basis-auto md:pl-[20px]">
|
||||
<div
|
||||
className={`relative w-[100px] sm:w-[120px] aspect-square rounded-lg cursor-pointer ${
|
||||
selectedIndex === -1 ? "border-2 border-primary" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedIndex(-1)}
|
||||
>
|
||||
<CustomImage
|
||||
src={videoData?.thumbnail}
|
||||
alt="Video Thumbnail"
|
||||
height={120}
|
||||
width={120}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-30 rounded-lg flex items-center justify-center">
|
||||
<RiPlayCircleFill size={40} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
)}
|
||||
</CarouselContent>
|
||||
<div className="absolute top-1/2 ltr:left-2 rtl:right-2 -translate-y-1/2">
|
||||
<button
|
||||
onClick={handlePrevImage}
|
||||
className="bg-primary p-1 sm:p-2 rounded-full"
|
||||
>
|
||||
<RiArrowLeftLine
|
||||
size={24}
|
||||
color="white"
|
||||
className={isRTL ? "rotate-180" : ""}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute top-1/2 ltr:right-2 rtl:left-2 -translate-y-1/2">
|
||||
<button
|
||||
onClick={handleNextImage}
|
||||
className="bg-primary p-1 sm:p-2 rounded-full"
|
||||
>
|
||||
<RiArrowRightLine
|
||||
size={24}
|
||||
color="white"
|
||||
className={isRTL ? "rotate-180" : ""}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Carousel>
|
||||
</div>
|
||||
</PhotoProvider>
|
||||
);
|
||||
};
|
||||
export default ProductGallery;
|
||||
50
components/PagesComponent/ProductDetail/ProductLocation.jsx
Normal file
50
components/PagesComponent/ProductDetail/ProductLocation.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { IoLocationOutline } from "react-icons/io5";
|
||||
import dynamic from "next/dynamic";
|
||||
import { t } from "@/utils";
|
||||
|
||||
const Map = dynamic(() => import("@/components/Location/Map"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const ProductLocation = ({ productDetails }) => {
|
||||
const handleShowMapClick = () => {
|
||||
const locationQuery = `${
|
||||
productDetails?.translated_item?.address || productDetails?.address
|
||||
}`;
|
||||
const googleMapsUrl = `https://www.google.com/maps?q=${locationQuery}&ll=${productDetails?.latitude},${productDetails?.longitude}&z=12&t=m`;
|
||||
window.open(googleMapsUrl, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border rounded-lg ">
|
||||
<div className="p-4">
|
||||
<p className="font-bold">{t("postedIn")}</p>
|
||||
</div>
|
||||
<div className="border-b w-full"></div>
|
||||
<div className="flex flex-col p-4 gap-4">
|
||||
<div className="flex items-start gap-2 ">
|
||||
<IoLocationOutline size={22} className="mt-1" />
|
||||
<p className="w-full overflow-hidden text-ellipsis">
|
||||
{productDetails?.translated_address}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<Map
|
||||
latitude={productDetails?.latitude}
|
||||
longitude={productDetails?.longitude}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="border px-4 py-2 rounded-md w-full flex items-center gap-2 text-base justify-center"
|
||||
onClick={handleShowMapClick}
|
||||
>
|
||||
{t("showOnMap")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductLocation;
|
||||
116
components/PagesComponent/ProductDetail/RenewAd.jsx
Normal file
116
components/PagesComponent/ProductDetail/RenewAd.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getIsFreAdListing } from "@/redux/reducer/settingSlice";
|
||||
import { t } from "@/utils";
|
||||
import { getPackageApi, renewItemApi } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const RenewAd = ({
|
||||
currentLanguageId,
|
||||
setProductDetails,
|
||||
item_id,
|
||||
setStatus,
|
||||
}) => {
|
||||
const { navigate } = useNavigate();
|
||||
const [RenewId, setRenewId] = useState("");
|
||||
const [ItemPackages, setItemPackages] = useState([]);
|
||||
const [isRenewingAd, setIsRenewingAd] = useState(false);
|
||||
|
||||
const isFreeAdListing = useSelector(getIsFreAdListing);
|
||||
|
||||
useEffect(() => {
|
||||
getItemsPackageData();
|
||||
}, [currentLanguageId]);
|
||||
|
||||
const getItemsPackageData = async () => {
|
||||
try {
|
||||
const res = await getPackageApi.getPackage({ type: "item_listing" });
|
||||
const { data } = res.data;
|
||||
setItemPackages(data);
|
||||
setRenewId(data[0]?.id);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenewItem = async () => {
|
||||
try {
|
||||
const subPackage = ItemPackages.find(
|
||||
(p) => Number(p.id) === Number(RenewId)
|
||||
);
|
||||
if (!isFreeAdListing && !subPackage?.is_active) {
|
||||
toast.error(t("purchasePackageFirst"));
|
||||
navigate("/user-subscription");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsRenewingAd(true);
|
||||
const res = await renewItemApi.renewItem({
|
||||
item_ids: item_id,
|
||||
...(isFreeAdListing ? {} : { package_id: RenewId }),
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
setProductDetails((prev) => ({
|
||||
...prev,
|
||||
status: res?.data?.data?.status,
|
||||
expiry_date: res?.data?.data?.expiry_date,
|
||||
}));
|
||||
setStatus(res?.data?.data?.status);
|
||||
toast.success(res?.data?.message);
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsRenewingAd(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border rounded-md ">
|
||||
<div className="p-4 border-b font-semibold">{t("renewAd")}</div>
|
||||
<div className="p-4 flex flex-col gap-4 ">
|
||||
<Select
|
||||
className="outline-none "
|
||||
value={RenewId}
|
||||
onValueChange={(value) => setRenewId(value)}
|
||||
>
|
||||
<SelectTrigger className="outline-none">
|
||||
<SelectValue placeholder={t("renewAd")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-[--radix-select-trigger-width]">
|
||||
{ItemPackages.map((item) => (
|
||||
<SelectItem value={item?.id} key={item?.id}>
|
||||
{item?.translated_name} - {item.duration} {t("days")}{" "}
|
||||
{item?.is_active && t("activePlan")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<button
|
||||
className="bg-primary text-white font-medium w-full p-2 rounded-md disabled:opacity-80"
|
||||
onClick={handleRenewItem}
|
||||
disabled={isRenewingAd}
|
||||
>
|
||||
{t("renew")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenewAd;
|
||||
221
components/PagesComponent/ProductDetail/SellerDetailCard.jsx
Normal file
221
components/PagesComponent/ProductDetail/SellerDetailCard.jsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MdVerifiedUser } from "react-icons/md";
|
||||
import { IoMdStar } from "react-icons/io";
|
||||
import { FaArrowRight, FaPaperPlane } from "react-icons/fa";
|
||||
import { IoChatboxEllipsesOutline } from "react-icons/io5";
|
||||
import { useSelector } from "react-redux";
|
||||
import { extractYear, t } from "@/utils";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { BiPhoneCall } from "react-icons/bi";
|
||||
import { itemOfferApi } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||
import { Gift } from "lucide-react";
|
||||
import MakeOfferModal from "./MakeOfferModal";
|
||||
import { setIsLoginOpen } from "@/redux/reducer/globalStateSlice";
|
||||
import ApplyJobModal from "./ApplyJobModal";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import Link from "next/link";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
|
||||
const SellerDetailCard = ({ productDetails, setProductDetails }) => {
|
||||
const { navigate } = useNavigate();
|
||||
const userData = productDetails && productDetails?.user;
|
||||
const memberSinceYear = productDetails?.created_at
|
||||
? extractYear(productDetails.created_at)
|
||||
: "";
|
||||
const [IsStartingChat, setIsStartingChat] = useState(false);
|
||||
const loggedInUser = useSelector(userSignUpData);
|
||||
const loggedInUserId = loggedInUser?.id;
|
||||
const [IsOfferModalOpen, setIsOfferModalOpen] = useState(false);
|
||||
const [showApplyModal, setShowApplyModal] = useState(false);
|
||||
|
||||
const isAllowedToMakeOffer =
|
||||
productDetails?.price > 0 &&
|
||||
!productDetails?.is_already_offered &&
|
||||
Number(productDetails?.category?.is_job_category) === 0 &&
|
||||
Number(productDetails?.category?.price_optional) === 0;
|
||||
const isJobCategory = Number(productDetails?.category?.is_job_category) === 1;
|
||||
const isApplied = productDetails?.is_already_job_applied;
|
||||
const item_id = productDetails?.id;
|
||||
|
||||
const offerData = {
|
||||
itemPrice: productDetails?.price,
|
||||
itemId: productDetails?.id,
|
||||
};
|
||||
|
||||
const handleChat = async () => {
|
||||
if (!loggedInUserId) {
|
||||
setIsLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsStartingChat(true);
|
||||
const response = await itemOfferApi.offer({
|
||||
item_id: offerData.itemId,
|
||||
});
|
||||
const { data } = response.data;
|
||||
navigate("/chat?activeTab=buying&chatid=" + data?.id);
|
||||
} catch (error) {
|
||||
toast.error(t("unableToStartChat"));
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsStartingChat(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMakeOffer = () => {
|
||||
if (!loggedInUserId) {
|
||||
setIsLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
setIsOfferModalOpen(true);
|
||||
};
|
||||
|
||||
const handleApplyJob = () => {
|
||||
if (!loggedInUserId) {
|
||||
setIsLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
setShowApplyModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center rounded-lg border flex-col">
|
||||
{(userData?.is_verified === 1 || memberSinceYear) && (
|
||||
<div className="p-4 flex justify-between items-center w-full">
|
||||
{productDetails?.user?.is_verified == 1 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="p-1 bg-[#FA6E53] flex items-center gap-1 rounded-md text-white text-sm"
|
||||
>
|
||||
<MdVerifiedUser size={20} />
|
||||
{t("verified")}
|
||||
</Badge>
|
||||
)}
|
||||
{memberSinceYear && (
|
||||
<p className="ltr:ml-auto rtl:mr-auto text-sm text-muted-foreground">
|
||||
{t("memberSince")}: {memberSinceYear}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="border-b w-full"></div>
|
||||
<div className="flex gap-2 justify-between w-full items-center p-4">
|
||||
<div className="flex gap-2.5 items-center max-w-[90%]">
|
||||
<CustomImage
|
||||
onClick={() => navigate(`/seller/${productDetails?.user?.id}`)}
|
||||
src={productDetails?.user?.profile}
|
||||
alt="Seller Image"
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-[80px] aspect-square rounded-lg cursor-pointer"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<CustomLink
|
||||
href={`/seller/${productDetails?.user?.id}`}
|
||||
className="font-bold text-lg text_ellipsis"
|
||||
>
|
||||
{productDetails?.user?.name}
|
||||
</CustomLink>
|
||||
{productDetails?.user?.reviews_count > 0 &&
|
||||
productDetails?.user?.average_rating && (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<IoMdStar size={20} className="text-black" />
|
||||
<p className="flex">
|
||||
{Number(productDetails?.user?.average_rating).toFixed(2)}
|
||||
</p>{" "}
|
||||
|{" "}
|
||||
<p className="flex text-sm ">
|
||||
{productDetails?.user?.reviews_count}
|
||||
</p>{" "}
|
||||
{t("ratings")}
|
||||
</div>
|
||||
)}
|
||||
{productDetails?.user?.show_personal_details === 1 &&
|
||||
productDetails?.user?.email && (
|
||||
<Link
|
||||
href={`mailto:${productDetails?.user?.email}`}
|
||||
className="text-sm text_ellipsis"
|
||||
>
|
||||
{productDetails?.user?.email}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CustomLink href={`/seller/${productDetails?.user?.id}`}>
|
||||
<FaArrowRight size={20} className="text-black rtl:scale-x-[-1]" />
|
||||
</CustomLink>
|
||||
</div>
|
||||
<div className="border-b w-full"></div>
|
||||
<div className="flex flex-wrap items-center gap-4 p-4 w-full">
|
||||
<button
|
||||
onClick={handleChat}
|
||||
disabled={IsStartingChat}
|
||||
className="bg-[#000] text-white p-4 rounded-md flex items-center gap-2 text-base font-medium justify-center whitespace-nowrap [flex:1_1_47%]"
|
||||
>
|
||||
<IoChatboxEllipsesOutline size={22} />
|
||||
{IsStartingChat ? (
|
||||
<span>{t("startingChat")}</span>
|
||||
) : (
|
||||
<span>{t("startChat")}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{productDetails?.user?.mobile && (
|
||||
<Link
|
||||
href={`tel:${
|
||||
productDetails?.user?.country_code
|
||||
? `+${productDetails.user.country_code}`
|
||||
: ""
|
||||
}${productDetails?.user?.mobile}`}
|
||||
className="bg-[#000] text-white p-4 rounded-md flex items-center gap-2 text-base font-medium justify-center whitespace-nowrap [flex:1_1_47%]"
|
||||
>
|
||||
<BiPhoneCall size={21} />
|
||||
<span>{t("call")}</span>
|
||||
</Link>
|
||||
)}
|
||||
{isAllowedToMakeOffer && (
|
||||
<button
|
||||
onClick={handleMakeOffer}
|
||||
className="bg-primary text-white p-4 rounded-md flex items-center gap-2 text-base font-medium justify-center whitespace-nowrap [flex:1_1_47%]"
|
||||
>
|
||||
<Gift size={21} />
|
||||
{t("makeOffer")}
|
||||
</button>
|
||||
)}
|
||||
{isJobCategory && (
|
||||
<button
|
||||
className={`text-white p-4 rounded-md flex items-center gap-2 text-base font-medium justify-center whitespace-nowrap [flex:1_1_47%] ${
|
||||
isApplied ? "bg-primary" : "bg-black"
|
||||
}`}
|
||||
disabled={isApplied}
|
||||
onClick={handleApplyJob}
|
||||
>
|
||||
<FaPaperPlane size={20} />
|
||||
{isApplied ? t("applied") : t("applyNow")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MakeOfferModal
|
||||
isOpen={IsOfferModalOpen}
|
||||
onClose={() => setIsOfferModalOpen(false)}
|
||||
productDetails={productDetails}
|
||||
key={`offer-modal-${IsOfferModalOpen}`}
|
||||
/>
|
||||
<ApplyJobModal
|
||||
key={`apply-job-modal-${showApplyModal}`}
|
||||
showApplyModal={showApplyModal}
|
||||
setShowApplyModal={setShowApplyModal}
|
||||
item_id={item_id}
|
||||
setProductDetails={setProductDetails}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SellerDetailCard;
|
||||
97
components/PagesComponent/ProductDetail/SimilarProducts.jsx
Normal file
97
components/PagesComponent/ProductDetail/SimilarProducts.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "@/components/ui/carousel";
|
||||
import { allItemApi } from "@/utils/api";
|
||||
import ProductCard from "@/components/Common/ProductCard";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
import { t } from "@/utils";
|
||||
import { getCityData, getKilometerRange } from "@/redux/reducer/locationSlice";
|
||||
|
||||
const SimilarProducts = ({ productDetails }) => {
|
||||
const [similarData, setSimilarData] = useState([]);
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
const location = useSelector(getCityData);
|
||||
const KmRange = useSelector(getKilometerRange);
|
||||
|
||||
const fetchSimilarData = async (cateID) => {
|
||||
try {
|
||||
const response = await allItemApi.getItems({
|
||||
category_id: cateID,
|
||||
...(location?.lat &&
|
||||
location?.long && {
|
||||
latitude: location?.lat,
|
||||
longitude: location?.long,
|
||||
radius: KmRange,
|
||||
}),
|
||||
});
|
||||
const responseData = response?.data;
|
||||
if (responseData) {
|
||||
const { data } = responseData;
|
||||
const filteredData = data?.data?.filter(
|
||||
(item) => item.id !== productDetails?.id
|
||||
);
|
||||
setSimilarData(filteredData || []);
|
||||
} else {
|
||||
console.error("Invalid response:", response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (productDetails?.category_id) {
|
||||
fetchSimilarData(productDetails?.category_id);
|
||||
}
|
||||
}, [productDetails?.category_id]);
|
||||
|
||||
if (similarData && similarData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleLikeAllData = (id) => {
|
||||
const updatedItems = similarData.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, is_liked: !item.is_liked };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
setSimilarData(updatedItems);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 mt-8">
|
||||
<h2 className="text-2xl font-medium">{t("relatedAds")}</h2>
|
||||
<Carousel
|
||||
key={isRTL ? "rtl" : "ltr"}
|
||||
opts={{
|
||||
direction: isRTL ? "rtl" : "ltr",
|
||||
}}
|
||||
>
|
||||
<CarouselContent>
|
||||
{similarData?.map((item) => (
|
||||
<CarouselItem
|
||||
key={item.id}
|
||||
className="md:basis-1/3 lg:basis-[25%] basis-2/3 sm:basis-1/2"
|
||||
>
|
||||
<ProductCard item={item} handleLike={handleLikeAllData} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
{similarData?.length > 3 && (
|
||||
<>
|
||||
<CarouselPrevious className="hidden md:flex absolute top-1/2 ltr:left-2 rtl:right-2 rtl:scale-x-[-1] -translate-y-1/2 bg-primary text-white rounded-full" />
|
||||
<CarouselNext className="hidden md:flex absolute top-1/2 ltr:right-2 rtl:left-2 rtl:scale-x-[-1] -translate-y-1/2 bg-primary text-white rounded-full" />
|
||||
</>
|
||||
)}
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimilarProducts;
|
||||
199
components/PagesComponent/ProductDetail/SoldOutModal.jsx
Normal file
199
components/PagesComponent/ProductDetail/SoldOutModal.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { t } from "@/utils";
|
||||
import { getItemBuyerListApi } from "@/utils/api";
|
||||
import NoDataFound from "../../../public/assets/no_data_found_illustrator.svg";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const SoldOutModal = ({
|
||||
productDetails,
|
||||
showSoldOut,
|
||||
setShowSoldOut,
|
||||
selectedRadioValue,
|
||||
setSelectedRadioValue,
|
||||
setShowConfirmModal,
|
||||
}) => {
|
||||
const [buyers, setBuyers] = useState([]);
|
||||
const [isNoneOfAboveChecked, setIsNoneOfAboveChecked] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const isJobAd = productDetails?.category?.is_job_category === 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (showSoldOut) {
|
||||
getBuyers();
|
||||
}
|
||||
}, [showSoldOut]);
|
||||
|
||||
const handleNoneOfAboveChecked = (checked) => {
|
||||
if (selectedRadioValue !== null) {
|
||||
setSelectedRadioValue(null);
|
||||
}
|
||||
setIsNoneOfAboveChecked(checked);
|
||||
};
|
||||
|
||||
const handleRadioButtonCheck = (value) => {
|
||||
if (isNoneOfAboveChecked) {
|
||||
setIsNoneOfAboveChecked(false);
|
||||
}
|
||||
setSelectedRadioValue(value);
|
||||
};
|
||||
|
||||
const handleHideModal = () => {
|
||||
setIsNoneOfAboveChecked(false);
|
||||
setSelectedRadioValue(null);
|
||||
setShowSoldOut(false);
|
||||
};
|
||||
|
||||
const getBuyers = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await getItemBuyerListApi.getItemBuyerList({
|
||||
item_id: productDetails?.id,
|
||||
});
|
||||
setBuyers(res?.data?.data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSoldOut = () => {
|
||||
setShowSoldOut(false);
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showSoldOut} onOpenChange={handleHideModal}>
|
||||
<DialogContent
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
{isJobAd ? t("whoWasHired") : t("whoMadePurchase")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-6">
|
||||
<div className="rounded-md p-2 bg-muted flex items-center gap-4">
|
||||
<div className="">
|
||||
<CustomImage
|
||||
src={productDetails?.image}
|
||||
alt={productDetails?.name}
|
||||
height={80}
|
||||
width={80}
|
||||
className="h-20 w-20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-medium">{productDetails?.name}</h1>
|
||||
<p className="text-xl font-medium text-primary">
|
||||
{productDetails?.formatted_price}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-red-500">
|
||||
{isJobAd ? t("selectHiredApplicant") : t("selectBuyerFromList")}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
// Buyers list skeleton
|
||||
<>
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div key={item} className="flex justify-between">
|
||||
<div className="flex gap-4 items-center">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{buyers?.length > 0 ? (
|
||||
buyers?.map((buyer) => {
|
||||
return (
|
||||
<div key={buyer?.id} className="flex justify-between">
|
||||
<div className="flex gap-4 items-center">
|
||||
<CustomImage
|
||||
src={buyer?.profile}
|
||||
width={48}
|
||||
height={48}
|
||||
alt="Ad Buyer"
|
||||
className="h-12 w-12 rounded-full"
|
||||
/>
|
||||
<span className="text-sm">{buyer?.name}</span>
|
||||
</div>
|
||||
<RadioGroup
|
||||
onValueChange={(value) => handleRadioButtonCheck(value)}
|
||||
value={selectedRadioValue}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={buyer?.id}
|
||||
id={`buyer-${buyer?.id}`}
|
||||
/>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex justify-center flex-col items-center gap-4">
|
||||
<div>
|
||||
<CustomImage
|
||||
src={NoDataFound}
|
||||
alt="no_img"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-xl font-medium">
|
||||
{isJobAd ? t("noApplicantsFound") : t("noBuyersFound")}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-6 pb-1 border-t flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 ">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={isNoneOfAboveChecked}
|
||||
onCheckedChange={(checked) => handleNoneOfAboveChecked(checked)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t("noneOfAbove")}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="border rounded-md px-4 py-2 text-lg disabled:bg-gray-500 disabled:text-white hover:bg-primary disabled:border-none hover:text-white"
|
||||
disabled={
|
||||
(!selectedRadioValue && !isNoneOfAboveChecked) || isLoading
|
||||
}
|
||||
onClick={handleSoldOut}
|
||||
>
|
||||
{isJobAd ? t("jobClosed") : t("soldOut")}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default SoldOutModal;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
const SoldOutModalSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<div className='mt-4 flex flex-col gap-6'>
|
||||
<div className='rounded-md p-2 bg-muted flex items-center gap-4'>
|
||||
<Skeleton className='h-20 w-20 rounded-md' />
|
||||
<div className='w-full'>
|
||||
<Skeleton className='h-5 w-3/5 mb-2' />
|
||||
<Skeleton className='h-7 w-2/5' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-5 w-4/5' />
|
||||
|
||||
{/* Buyers list skeletons */}
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div key={item} className='flex justify-between'>
|
||||
<div className='flex gap-4 items-center'>
|
||||
<Skeleton className='h-12 w-12 rounded-full' />
|
||||
<Skeleton className='h-4 w-24' />
|
||||
</div>
|
||||
<Skeleton className='h-4 w-4 rounded-full' />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className='pt-6 pb-1 border-t flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-4 w-4' />
|
||||
<Skeleton className='h-4 w-1/3' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-1/4 rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SoldOutModalSkeleton
|
||||
Reference in New Issue
Block a user