classify web

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

View File

@@ -0,0 +1,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;

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

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

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

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

View File

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

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

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

View 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">&nbsp;{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">&nbsp;{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;

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

@@ -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