classify web
This commit is contained in:
817
components/PagesComponent/Ads/Ads.jsx
Normal file
817
components/PagesComponent/Ads/Ads.jsx
Normal file
@@ -0,0 +1,817 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Filter from "../../Filter/Filter";
|
||||
import {
|
||||
allItemApi,
|
||||
FeaturedSectionApi,
|
||||
getCustomFieldsApi,
|
||||
getParentCategoriesApi,
|
||||
getSeoSettingsApi,
|
||||
} from "@/utils/api";
|
||||
import ProductCard from "@/components/Common/ProductCard";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TbTransferVertical } from "react-icons/tb";
|
||||
import ProductHorizontalCard from "@/components/Common/ProductHorizontalCard";
|
||||
import ProductCardSkeleton from "@/components/Common/ProductCardSkeleton";
|
||||
import ProductHorizontalCardSkeleton from "@/components/Common/ProductHorizontalCardSkeleton";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import { IoGrid } from "react-icons/io5";
|
||||
import { CiGrid2H } from "react-icons/ci";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { IoIosCloseCircle } from "react-icons/io";
|
||||
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import {
|
||||
BreadcrumbPathData,
|
||||
setBreadcrumbPath,
|
||||
} from "@/redux/reducer/breadCrumbSlice";
|
||||
import { seoData, t } from "@/utils";
|
||||
import { getSelectedLocation } from "@/redux/reducer/globalStateSlice";
|
||||
import { generateKeywords } from "@/utils/generateKeywords";
|
||||
|
||||
const Ads = () => {
|
||||
const dispatch = useDispatch();
|
||||
const searchParams = useSearchParams();
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
const BreadcrumbPath = useSelector(BreadcrumbPathData);
|
||||
|
||||
const [view, setView] = useState("grid");
|
||||
const [advertisements, setAdvertisements] = useState({
|
||||
data: [],
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isLoadMore: false,
|
||||
});
|
||||
const [featuredTitle, setFeaturedTitle] = useState("");
|
||||
|
||||
const selectedLocation = useSelector(getSelectedLocation);
|
||||
|
||||
const query = searchParams.get("query") || "";
|
||||
const slug = searchParams.get("category") || "";
|
||||
const country = searchParams.get("country") || "";
|
||||
const state = searchParams.get("state") || "";
|
||||
const city = searchParams.get("city") || "";
|
||||
const area = searchParams.get("area") || "";
|
||||
const areaId = Number(searchParams.get("areaId")) || "";
|
||||
const lat = Number(searchParams.get("lat"));
|
||||
const lng = Number(searchParams.get("lng"));
|
||||
const min_price = searchParams.get("min_price")
|
||||
? Number(searchParams.get("min_price"))
|
||||
: "";
|
||||
const max_price = searchParams.get("max_price")
|
||||
? Number(searchParams.get("max_price"))
|
||||
: "";
|
||||
const date_posted = searchParams.get("date_posted") || "";
|
||||
const km_range = searchParams.get("km_range") || "";
|
||||
const sortBy = searchParams.get("sort_by") || "new-to-old";
|
||||
const langCode = searchParams.get("lang");
|
||||
const featured_section = searchParams.get("featured_section") || "";
|
||||
|
||||
const isMinPrice =
|
||||
min_price !== "" &&
|
||||
min_price !== null &&
|
||||
min_price !== undefined &&
|
||||
min_price >= 0;
|
||||
|
||||
const knownParams = [
|
||||
"country",
|
||||
"state",
|
||||
"city",
|
||||
"area",
|
||||
"areaId",
|
||||
"lat",
|
||||
"lng",
|
||||
"min_price",
|
||||
"max_price",
|
||||
"date_posted",
|
||||
"km_range",
|
||||
"sort_by",
|
||||
"category",
|
||||
"query",
|
||||
"lang",
|
||||
"featured_section",
|
||||
];
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (BreadcrumbPath.length === 2) {
|
||||
return BreadcrumbPath[1]?.name;
|
||||
}
|
||||
|
||||
if (BreadcrumbPath.length > 2) {
|
||||
const last = BreadcrumbPath[BreadcrumbPath.length - 1]?.name;
|
||||
const secondLast = BreadcrumbPath[BreadcrumbPath.length - 2]?.name;
|
||||
return `${last} ${t("in")} ${secondLast}`;
|
||||
}
|
||||
|
||||
return t("ads");
|
||||
}, [BreadcrumbPath, t]);
|
||||
|
||||
const category =
|
||||
BreadcrumbPath.length > 1 &&
|
||||
BreadcrumbPath[BreadcrumbPath.length - 1]?.name;
|
||||
|
||||
const [customFields, setCustomFields] = useState([]);
|
||||
|
||||
const initialExtraDetails = useMemo(() => {
|
||||
const temprorayExtraDet = {};
|
||||
Array.from(searchParams.entries() || []).forEach(([key, value]) => {
|
||||
if (!knownParams?.includes(key)) {
|
||||
temprorayExtraDet[key] = value?.includes(",")
|
||||
? value?.split(",")
|
||||
: value;
|
||||
}
|
||||
});
|
||||
return temprorayExtraDet;
|
||||
}, [
|
||||
JSON.stringify(
|
||||
Array.from(searchParams.entries()).filter(
|
||||
([key]) => !knownParams.includes(key)
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
const [extraDetails, setExtraDetails] = useState(initialExtraDetails);
|
||||
|
||||
// Count active filters
|
||||
const getActiveFilterCount = () => {
|
||||
let count = 0;
|
||||
|
||||
// Location filter
|
||||
if (country || state || city || areaId) count++;
|
||||
|
||||
// KM Range filter
|
||||
if (km_range) count++;
|
||||
|
||||
if (category) count++;
|
||||
|
||||
if (featured_section) count++;
|
||||
|
||||
// Query filter
|
||||
if (query) count++;
|
||||
|
||||
// Date Posted filter
|
||||
if (date_posted) count++;
|
||||
|
||||
// Price Range filter
|
||||
if (isMinPrice && max_price) count++;
|
||||
|
||||
// Extra Details filters
|
||||
if (initialExtraDetails && Object.keys(initialExtraDetails).length > 0) {
|
||||
count += Object.keys(initialExtraDetails).length;
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
const activeFilterCount = getActiveFilterCount();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFeaturedSectionData = async () => {
|
||||
try {
|
||||
const response = await FeaturedSectionApi.getFeaturedSections({
|
||||
slug: featured_section,
|
||||
});
|
||||
|
||||
if (response?.data?.error === false) {
|
||||
setFeaturedTitle(
|
||||
response?.data?.data?.[0]?.translated_name ||
|
||||
response?.data?.data?.[0]?.title
|
||||
);
|
||||
} else {
|
||||
console.error(response?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
};
|
||||
if (featured_section) {
|
||||
fetchFeaturedSectionData();
|
||||
}
|
||||
}, [langCode, featured_section]);
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
constructBreadcrumbPath();
|
||||
} else {
|
||||
dispatch(
|
||||
setBreadcrumbPath([
|
||||
{
|
||||
name: t("allCategories"),
|
||||
key: "all-categories",
|
||||
slug: "/ads",
|
||||
isAllCategories: true,
|
||||
},
|
||||
])
|
||||
);
|
||||
setCustomFields([]);
|
||||
setExtraDetails({});
|
||||
const fetchSeoSettings = async () => {
|
||||
const res = await getSeoSettingsApi.getSeoSettings({
|
||||
page: "ad-listing",
|
||||
});
|
||||
const data = res.data.data[0];
|
||||
const title =
|
||||
data?.translated_title ||
|
||||
data?.title ||
|
||||
process.env.NEXT_PUBLIC_META_TITLE;
|
||||
const description =
|
||||
data?.translated_description ||
|
||||
process.env.NEXT_PUBLIC_META_DESCRIPTION;
|
||||
const keywords =
|
||||
data?.translated_keywords || process.env.NEXT_PUBLIC_META_kEYWORDS;
|
||||
const image = data?.image || "";
|
||||
const canonicalUrl = process.env.NEXT_PUBLIC_WEB_URL;
|
||||
seoData({ title, description, keywords, image, canonicalUrl });
|
||||
};
|
||||
fetchSeoSettings();
|
||||
}
|
||||
}, [slug, langCode]);
|
||||
|
||||
const getCustomFieldsData = async (categoryIds) => {
|
||||
try {
|
||||
const res = await getCustomFieldsApi.getCustomFields({
|
||||
category_ids: categoryIds,
|
||||
filter: true,
|
||||
});
|
||||
const data = res?.data?.data;
|
||||
setCustomFields(data);
|
||||
const isShowCustomfieldFilter =
|
||||
data.length > 0 &&
|
||||
data.some(
|
||||
(field) =>
|
||||
field.type === "checkbox" ||
|
||||
field.type === "radio" ||
|
||||
field.type === "dropdown"
|
||||
);
|
||||
|
||||
if (isShowCustomfieldFilter) {
|
||||
const initialExtraDetails = {};
|
||||
data.forEach((field) => {
|
||||
const value = searchParams.get(field.id);
|
||||
if (value) {
|
||||
initialExtraDetails[field.id] =
|
||||
field.type === "checkbox" ? value.split(",") : value;
|
||||
}
|
||||
});
|
||||
setExtraDetails(initialExtraDetails);
|
||||
} else {
|
||||
setExtraDetails({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const constructBreadcrumbPath = async () => {
|
||||
try {
|
||||
const res = await getParentCategoriesApi.getPaymentCategories({
|
||||
slug,
|
||||
tree: 0,
|
||||
});
|
||||
const data = res?.data?.data || [];
|
||||
const selectedCategory = data?.at(-1);
|
||||
if (selectedCategory) {
|
||||
seoData({
|
||||
title:
|
||||
selectedCategory?.translated_name || process.env.NEXT_PUBLIC_META_TITLE,
|
||||
description:
|
||||
selectedCategory?.translated_description ||
|
||||
process.env.NEXT_PUBLIC_META_DESCRIPTION,
|
||||
keywords:
|
||||
generateKeywords(selectedCategory?.translated_description) ||
|
||||
process.env.NEXT_PUBLIC_META_kEYWORDS,
|
||||
image: selectedCategory?.image,
|
||||
canonicalUrl: `${process.env.NEXT_PUBLIC_WEB_URL}?category=${selectedCategory?.slug}&lang=${langCode}`,
|
||||
});
|
||||
}
|
||||
const breadcrumbArray = [
|
||||
{
|
||||
name: t("allCategories"),
|
||||
key: "all-categories",
|
||||
slug: "/ads",
|
||||
isAllCategories: true,
|
||||
},
|
||||
...data.map((item) => ({
|
||||
name: item.translated_name,
|
||||
key: item.slug,
|
||||
slug: `/ads?category=${item.slug}`,
|
||||
})),
|
||||
];
|
||||
dispatch(setBreadcrumbPath(breadcrumbArray));
|
||||
const categoryIds = data.map((category) => category.id).join(",");
|
||||
await getCustomFieldsData(categoryIds);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getSingleCatItem(1);
|
||||
}, [
|
||||
lat,
|
||||
lng,
|
||||
areaId,
|
||||
city,
|
||||
state,
|
||||
country,
|
||||
min_price,
|
||||
max_price,
|
||||
date_posted,
|
||||
km_range,
|
||||
sortBy,
|
||||
initialExtraDetails,
|
||||
slug,
|
||||
query,
|
||||
langCode,
|
||||
featured_section,
|
||||
]);
|
||||
|
||||
const getSingleCatItem = async (page) => {
|
||||
try {
|
||||
const parameters = { page, limit: 12 };
|
||||
if (sortBy) parameters.sort_by = sortBy;
|
||||
if (isMinPrice) parameters.min_price = min_price;
|
||||
if (max_price) parameters.max_price = max_price;
|
||||
if (date_posted) parameters.posted_since = date_posted;
|
||||
if (slug) parameters.category_slug = slug;
|
||||
if (extraDetails) parameters.custom_fields = extraDetails;
|
||||
if (featured_section) parameters.featured_section_slug = featured_section;
|
||||
|
||||
if (Number(km_range) > 0) {
|
||||
parameters.latitude = lat;
|
||||
parameters.longitude = lng;
|
||||
parameters.radius = km_range;
|
||||
} else {
|
||||
if (areaId) {
|
||||
parameters.area_id = areaId;
|
||||
} else if (city) {
|
||||
parameters.city = city;
|
||||
} else if (state) {
|
||||
parameters.state = state;
|
||||
} else if (country) {
|
||||
parameters.country = country;
|
||||
}
|
||||
}
|
||||
if (query) {
|
||||
parameters.search = query;
|
||||
}
|
||||
page === 1
|
||||
? setAdvertisements((prev) => ({ ...prev, isLoading: true }))
|
||||
: setAdvertisements((prev) => ({ ...prev, isLoadMore: true }));
|
||||
|
||||
const res = await allItemApi.getItems(parameters);
|
||||
const data = res?.data;
|
||||
|
||||
if (data.error === false) {
|
||||
page > 1
|
||||
? setAdvertisements((prev) => ({
|
||||
...prev,
|
||||
data: [...prev.data, ...data?.data?.data],
|
||||
currentPage: data?.data?.current_page,
|
||||
hasMore: data?.data?.last_page > data?.data?.current_page,
|
||||
}))
|
||||
: setAdvertisements((prev) => ({
|
||||
...prev,
|
||||
data: data?.data?.data,
|
||||
currentPage: data?.data?.current_page,
|
||||
hasMore: data?.data?.last_page > data?.data?.current_page,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setAdvertisements((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isLoadMore: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleProdLoadMore = async () => {
|
||||
setAdvertisements((prev) => ({ ...prev, isLoadMore: true }));
|
||||
await getSingleCatItem(advertisements.currentPage + 1);
|
||||
};
|
||||
|
||||
const handleSortBy = (value) => {
|
||||
newSearchParams.set("sort_by", value);
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const handleLike = (id) => {
|
||||
const updatedItems = advertisements.data.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, is_liked: !item.is_liked };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
setAdvertisements((prev) => ({ ...prev, data: updatedItems }));
|
||||
};
|
||||
|
||||
const handleClearLocation = () => {
|
||||
newSearchParams.delete("country");
|
||||
newSearchParams.delete("state");
|
||||
newSearchParams.delete("city");
|
||||
newSearchParams.delete("area");
|
||||
newSearchParams.delete("areaId");
|
||||
newSearchParams.delete("lat");
|
||||
newSearchParams.delete("lng");
|
||||
newSearchParams.delete("km_range");
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const handleClearRange = () => {
|
||||
newSearchParams.delete("km_range");
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const handleClearDatePosted = () => {
|
||||
newSearchParams.delete("date_posted");
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const handleClearBudget = () => {
|
||||
newSearchParams.delete("min_price");
|
||||
newSearchParams.delete("max_price");
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const handleClearFeaturedSection = () => {
|
||||
newSearchParams.delete("featured_section");
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const handleClearCategory = () => {
|
||||
newSearchParams.delete("category");
|
||||
Object.keys(extraDetails || {})?.forEach((key) => {
|
||||
newSearchParams.delete(key);
|
||||
});
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const handleClearExtraDetail = (keyToRemove) => {
|
||||
const updatedExtraDetails = { ...extraDetails };
|
||||
delete updatedExtraDetails[keyToRemove];
|
||||
setExtraDetails(updatedExtraDetails);
|
||||
|
||||
newSearchParams.delete(keyToRemove);
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
newSearchParams.delete("country");
|
||||
newSearchParams.delete("state");
|
||||
newSearchParams.delete("city");
|
||||
newSearchParams.delete("area");
|
||||
newSearchParams.delete("areaId");
|
||||
newSearchParams.delete("lat");
|
||||
newSearchParams.delete("lng");
|
||||
newSearchParams.delete("km_range");
|
||||
newSearchParams.delete("date_posted");
|
||||
newSearchParams.delete("min_price");
|
||||
newSearchParams.delete("max_price");
|
||||
newSearchParams.delete("category");
|
||||
newSearchParams.delete("query");
|
||||
newSearchParams.delete("featured_section");
|
||||
Object.keys(initialExtraDetails || {})?.forEach((key) => {
|
||||
newSearchParams.delete(key);
|
||||
});
|
||||
setExtraDetails({});
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const handleClearQuery = () => {
|
||||
newSearchParams.delete("query");
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const postedSince =
|
||||
date_posted === "all-time"
|
||||
? t("allTime")
|
||||
: date_posted === "today"
|
||||
? t("today")
|
||||
: date_posted === "within-1-week"
|
||||
? t("within1Week")
|
||||
: date_posted === "within-2-week"
|
||||
? t("within2Weeks")
|
||||
: date_posted === "within-1-month"
|
||||
? t("within1Month")
|
||||
: date_posted === "within-3-month"
|
||||
? t("within3Months")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<BreadCrumb />
|
||||
<div className="container mt-8">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-2xl font-semibold mb-6">{title}</h1>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<div className="xl:col-span-3 lg:col-span-4 col-span-1">
|
||||
<Filter
|
||||
customFields={customFields}
|
||||
extraDetails={extraDetails}
|
||||
setExtraDetails={setExtraDetails}
|
||||
newSearchParams={newSearchParams}
|
||||
country={country}
|
||||
state={state}
|
||||
city={city}
|
||||
area={area}
|
||||
/>
|
||||
</div>
|
||||
<div className="xl:col-span-9 lg:col-span-8 col-span-1 flex flex-col gap-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center gap-2">
|
||||
<div className="flex gap-2 items-center whitespace-nowrap">
|
||||
<TbTransferVertical />
|
||||
{t("sortBy")}
|
||||
</div>
|
||||
<Select value={sortBy} onValueChange={handleSortBy}>
|
||||
<SelectTrigger className="max-w-[180px] font-semibold">
|
||||
<SelectValue
|
||||
placeholder={t("sortBy")}
|
||||
className="font-semibold"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup className="font-semibold">
|
||||
<SelectItem value="new-to-old">
|
||||
{t("newestToOldest")}
|
||||
</SelectItem>
|
||||
<SelectItem value="old-to-new">
|
||||
{t("oldestToNewest")}
|
||||
</SelectItem>
|
||||
<SelectItem value="price-high-to-low">
|
||||
{t("priceHighToLow")}
|
||||
</SelectItem>
|
||||
<SelectItem value="price-low-to-high">
|
||||
{t("priceLowToHigh")}
|
||||
</SelectItem>
|
||||
<SelectItem value="popular_items">
|
||||
{t("popular")}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className={`text-muted-foreground p-3 rounded-full ${view === "list" ? "bg-primary text-white" : ""
|
||||
}`}
|
||||
>
|
||||
<CiGrid2H size={22} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("grid")}
|
||||
className={` text-muted-foreground p-3 rounded-full ${view === "grid" ? "bg-primary text-white" : ""
|
||||
}`}
|
||||
>
|
||||
<IoGrid size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{category && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
|
||||
>
|
||||
<span>
|
||||
{t("category")}: {category}
|
||||
</span>
|
||||
<IoIosCloseCircle
|
||||
size={22}
|
||||
className="cursor-pointer "
|
||||
onClick={handleClearCategory}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{query && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
|
||||
>
|
||||
<span>
|
||||
{t("search")}: {query}
|
||||
</span>
|
||||
<IoIosCloseCircle
|
||||
size={22}
|
||||
className="cursor-pointer "
|
||||
onClick={handleClearQuery}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{(country || state || city || area) && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
|
||||
>
|
||||
<span>
|
||||
{t("location")}:{" "}
|
||||
{selectedLocation?.translated_name ||
|
||||
selectedLocation?.name}
|
||||
</span>
|
||||
<IoIosCloseCircle
|
||||
size={22}
|
||||
className="cursor-pointer "
|
||||
onClick={handleClearLocation}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
{Number(km_range) > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
|
||||
>
|
||||
<span>
|
||||
{t("nearByRange")}: {km_range} KM
|
||||
</span>
|
||||
<IoIosCloseCircle
|
||||
size={22}
|
||||
className="cursor-pointer "
|
||||
onClick={handleClearRange}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{date_posted && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
|
||||
>
|
||||
<span>
|
||||
{t("datePosted")}: {postedSince}
|
||||
</span>
|
||||
<IoIosCloseCircle
|
||||
size={22}
|
||||
className="cursor-pointer "
|
||||
onClick={handleClearDatePosted}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{isMinPrice && max_price && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
|
||||
>
|
||||
<span>
|
||||
{t("budget")}: {min_price}-{max_price}
|
||||
</span>
|
||||
<IoIosCloseCircle
|
||||
size={22}
|
||||
className="cursor-pointer "
|
||||
onClick={handleClearBudget}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{featured_section && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
|
||||
>
|
||||
<span>
|
||||
{t("featuredSection")}: {featuredTitle}
|
||||
</span>
|
||||
<IoIosCloseCircle
|
||||
size={22}
|
||||
className="cursor-pointer "
|
||||
onClick={handleClearFeaturedSection}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
{initialExtraDetails &&
|
||||
Object.entries(initialExtraDetails || {}).map(
|
||||
([key, value]) => {
|
||||
const field = customFields.find(
|
||||
(f) => f.id.toString() === key.toString()
|
||||
);
|
||||
|
||||
const fieldName = field?.translated_name || field?.name;
|
||||
|
||||
// Function to get translated value
|
||||
const getTranslatedValue = (val) => {
|
||||
if (!field?.values || !field?.translated_value)
|
||||
return val;
|
||||
const idx = field.values.indexOf(val);
|
||||
return idx !== -1 ? field.translated_value[idx] : val;
|
||||
};
|
||||
|
||||
const displayValue = Array.isArray(value)
|
||||
? value.map((v) => getTranslatedValue(v)).join(", ")
|
||||
: getTranslatedValue(value);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={key}
|
||||
variant="outline"
|
||||
className="px-4 text-base font-normal py-2 rounded-full flex items-center gap-2 bg-muted"
|
||||
>
|
||||
<span>
|
||||
{fieldName}: {displayValue}
|
||||
</span>
|
||||
<IoIosCloseCircle
|
||||
size={22}
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleClearExtraDetail(key)}
|
||||
/>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
{activeFilterCount > 1 && (
|
||||
<button
|
||||
className="text-primary whitespace-nowrap"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
{t("clearAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{advertisements?.isLoading ? (
|
||||
Array.from({ length: 12 }).map((_, index) =>
|
||||
view === "list" ? (
|
||||
<div className="col-span-12" key={index}>
|
||||
<ProductHorizontalCardSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={index}
|
||||
className="col-span-12 sm:col-span-6 xl:col-span-4"
|
||||
>
|
||||
<ProductCardSkeleton />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : advertisements.data && advertisements.data.length > 0 ? (
|
||||
advertisements.data?.map((item, index) =>
|
||||
view === "list" ? (
|
||||
<div className="col-span-12" key={index}>
|
||||
<ProductHorizontalCard
|
||||
item={item}
|
||||
handleLike={handleLike}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="col-span-12 sm:col-span-6 xl:col-span-4"
|
||||
key={index}
|
||||
>
|
||||
<ProductCard item={item} handleLike={handleLike} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<div className="col-span-12">
|
||||
<NoData name={t("ads")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{advertisements.data &&
|
||||
advertisements.data.length > 0 &&
|
||||
advertisements.hasMore && (
|
||||
<div className="text-center mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-sm sm:text-base text-primary w-[256px]"
|
||||
disabled={
|
||||
advertisements.isLoading || advertisements.isLoadMore
|
||||
}
|
||||
onClick={handleProdLoadMore}
|
||||
>
|
||||
{advertisements.isLoadMore ? t("loading") : t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Ads;
|
||||
54
components/PagesComponent/AdsListing/AdLanguageSelector.jsx
Normal file
54
components/PagesComponent/AdsListing/AdLanguageSelector.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
import { t } from "@/utils";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const AdLanguageSelector = ({
|
||||
langId,
|
||||
setLangId,
|
||||
languages,
|
||||
setTranslations,
|
||||
}) => {
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
|
||||
const handleLangChange = (newId) => {
|
||||
setLangId(newId);
|
||||
setTranslations((t) => ({
|
||||
...t,
|
||||
[newId]: t[newId] || {},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="whitespace-nowrap text-sm font-medium hidden lg:block">
|
||||
{t("selectLanguage")}
|
||||
</p>
|
||||
<Select value={langId} onValueChange={handleLangChange}>
|
||||
<SelectTrigger className="gap-2">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align={isRTL ? "start" : "end"}>
|
||||
<SelectGroup>
|
||||
{languages &&
|
||||
languages.length > 0 &&
|
||||
languages.map((lang) => (
|
||||
<SelectItem key={lang.id} value={lang.id}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdLanguageSelector;
|
||||
55
components/PagesComponent/AdsListing/AdSuccessModal.jsx
Normal file
55
components/PagesComponent/AdsListing/AdSuccessModal.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import trueGif from "../../../public/assets/true.gif";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const AdSuccessModal = ({
|
||||
openSuccessModal,
|
||||
setOpenSuccessModal,
|
||||
createdAdSlug,
|
||||
}) => {
|
||||
const closeSuccessModal = () => {
|
||||
setOpenSuccessModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={openSuccessModal} onOpenChange={closeSuccessModal}>
|
||||
<DialogContent
|
||||
className="[&>button]:hidden !max-w-[520px] py-[50px] px-[30px] sm:px-[80px]"
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="flex flex-col gap-4 items-center">
|
||||
<CustomImage
|
||||
src={trueGif}
|
||||
alt="success"
|
||||
height={176}
|
||||
width={176}
|
||||
className="h-44 w-44"
|
||||
/>
|
||||
<DialogTitle className="text-3xl font-semibold text-center !p-0 mt-0">
|
||||
{t("adPostedSuccess")}
|
||||
</DialogTitle>
|
||||
<CustomLink
|
||||
href={`/my-listing/${createdAdSlug}`}
|
||||
className="py-3 px-6 bg-primary text-white rounded-md"
|
||||
>
|
||||
{t("viewAd")}
|
||||
</CustomLink>
|
||||
<CustomLink href="/" className="">
|
||||
{t("backToHome")}
|
||||
</CustomLink>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdSuccessModal;
|
||||
753
components/PagesComponent/AdsListing/AdsListing.jsx
Normal file
753
components/PagesComponent/AdsListing/AdsListing.jsx
Normal file
@@ -0,0 +1,753 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import ComponentOne from "./ComponentOne";
|
||||
import {
|
||||
addItemApi,
|
||||
categoryApi,
|
||||
getCurrenciesApi,
|
||||
getCustomFieldsApi,
|
||||
getParentCategoriesApi,
|
||||
} from "@/utils/api";
|
||||
import ComponentTwo from "./ComponentTwo";
|
||||
import {
|
||||
filterNonDefaultTranslations,
|
||||
getDefaultCountryCode,
|
||||
isValidURL,
|
||||
prepareCustomFieldFiles,
|
||||
prepareCustomFieldTranslations,
|
||||
t,
|
||||
validateExtraDetails,
|
||||
} from "@/utils";
|
||||
import { toast } from "sonner";
|
||||
import ComponentThree from "./ComponentThree";
|
||||
import ComponentFour from "./ComponentFour";
|
||||
import ComponentFive from "./ComponentFive";
|
||||
import { useSelector } from "react-redux";
|
||||
import AdSuccessModal from "./AdSuccessModal";
|
||||
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import Checkauth from "@/HOC/Checkauth";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import AdLanguageSelector from "./AdLanguageSelector";
|
||||
import {
|
||||
getDefaultLanguageCode,
|
||||
getLanguages,
|
||||
} from "@/redux/reducer/settingSlice";
|
||||
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js/max";
|
||||
import { getCurrentCountry } from "@/redux/reducer/locationSlice";
|
||||
|
||||
const AdsListing = () => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const currentCountry = useSelector(getCurrentCountry);
|
||||
const [step, setStep] = useState(1);
|
||||
const [categories, setCategories] = useState();
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(false);
|
||||
const [isLoadMoreCat, setIsLoadMoreCat] = useState(false);
|
||||
const [categoryPath, setCategoryPath] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState();
|
||||
const [lastPage, setLastPage] = useState();
|
||||
const [disabledTab, setDisabledTab] = useState({
|
||||
categoryTab: false,
|
||||
detailTab: true,
|
||||
extraDetailTabl: true,
|
||||
images: true,
|
||||
location: true,
|
||||
});
|
||||
const [customFields, setCustomFields] = useState([]);
|
||||
const [filePreviews, setFilePreviews] = useState({});
|
||||
const [uploadedImages, setUploadedImages] = useState([]);
|
||||
const [otherImages, setOtherImages] = useState([]);
|
||||
const [location, setLocation] = useState({});
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [isAdPlaced, setIsAdPlaced] = useState(false);
|
||||
const [openSuccessModal, setOpenSuccessModal] = useState(false);
|
||||
const [createdAdSlug, setCreatedAdSlug] = useState("");
|
||||
const userData = useSelector(userSignUpData);
|
||||
|
||||
const languages = useSelector(getLanguages);
|
||||
const defaultLanguageCode = useSelector(getDefaultLanguageCode);
|
||||
const defaultLangId = languages?.find(
|
||||
(lang) => lang.code === defaultLanguageCode
|
||||
)?.id;
|
||||
|
||||
const [extraDetails, setExtraDetails] = useState({
|
||||
[defaultLangId]: {},
|
||||
});
|
||||
const [langId, setLangId] = useState(defaultLangId);
|
||||
|
||||
const countryCode =
|
||||
userData?.country_code?.replace("+", "") || getDefaultCountryCode();
|
||||
const mobile = userData?.mobile || "";
|
||||
const regionCode =
|
||||
userData?.region_code?.toLowerCase() ||
|
||||
process.env.NEXT_PUBLIC_DEFAULT_COUNTRY?.toLowerCase() ||
|
||||
"in";
|
||||
|
||||
const [translations, setTranslations] = useState({
|
||||
[langId]: {
|
||||
contact: mobile,
|
||||
country_code: countryCode,
|
||||
region_code: regionCode,
|
||||
},
|
||||
});
|
||||
const hasTextbox = customFields.some((field) => field.type === "textbox");
|
||||
|
||||
const defaultDetails = translations[defaultLangId] || {};
|
||||
const currentDetails = translations[langId] || {};
|
||||
const currentExtraDetails = extraDetails[langId] || {};
|
||||
|
||||
const is_job_category =
|
||||
Number(categoryPath[categoryPath.length - 1]?.is_job_category) === 1;
|
||||
const isPriceOptional =
|
||||
Number(categoryPath[categoryPath.length - 1]?.price_optional) === 1;
|
||||
|
||||
const allCategoryIdsString = categoryPath
|
||||
.map((category) => category.id)
|
||||
.join(",");
|
||||
let lastItemId = categoryPath[categoryPath.length - 1]?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 1) {
|
||||
handleFetchCategories();
|
||||
}
|
||||
}, [CurrentLanguage.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 1 && allCategoryIdsString) {
|
||||
getCustomFieldsData();
|
||||
}
|
||||
}, [allCategoryIdsString, CurrentLanguage.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update category path translations when language changes
|
||||
if (categoryPath.length > 0) {
|
||||
const lastCategoryId = categoryPath[categoryPath.length - 1]?.id;
|
||||
if (lastCategoryId) {
|
||||
getParentCategoriesApi
|
||||
.getPaymentCategories({
|
||||
child_category_id: lastCategoryId,
|
||||
})
|
||||
.then((res) => {
|
||||
const updatedPath = res?.data?.data;
|
||||
if (updatedPath?.length > 0) {
|
||||
setCategoryPath(updatedPath);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Error updating category path:", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [CurrentLanguage.id]);
|
||||
|
||||
useEffect(() => {
|
||||
getCurrencies();
|
||||
}, [countryCode]);
|
||||
|
||||
const getCurrencies = async () => {
|
||||
try {
|
||||
let params = {};
|
||||
|
||||
if (currentCountry) {
|
||||
params.country = currentCountry;
|
||||
}
|
||||
const res = await getCurrenciesApi.getCurrencies(params);
|
||||
const currenciesData = res?.data?.data || [];
|
||||
setCurrencies(currenciesData);
|
||||
// 🚫 IMPORTANT: If no currencies, REMOVE currency_id
|
||||
if (currenciesData.length === 0) {
|
||||
setTranslations((prev) => {
|
||||
const updated = { ...prev };
|
||||
|
||||
if (updated[langId]?.currency_id) {
|
||||
delete updated[langId].currency_id;
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ✅ Normal case: set default currency
|
||||
const defaultCurrency =
|
||||
currenciesData.find((c) => c.selected == 1) || currenciesData[0];
|
||||
|
||||
if (defaultCurrency && !translations[langId]?.currency_id) {
|
||||
setTranslations((prev) => ({
|
||||
...prev,
|
||||
[langId]: {
|
||||
...prev[langId],
|
||||
currency_id: defaultCurrency.id,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchCategories = async (
|
||||
category,
|
||||
isWaitForApiResToUpdatePath = false
|
||||
) => {
|
||||
setCategoriesLoading(true);
|
||||
try {
|
||||
const categoryId = category ? category?.id : null;
|
||||
|
||||
const res = await categoryApi.getCategory({
|
||||
category_id: categoryId,
|
||||
listing: 1,
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
const data = res?.data?.data?.data;
|
||||
setCategories(data);
|
||||
setCurrentPage(res?.data?.data?.current_page);
|
||||
setLastPage(res?.data?.data?.last_page);
|
||||
if (isWaitForApiResToUpdatePath) {
|
||||
setCategoryPath((prevPath) => [...prevPath, category]);
|
||||
if (category.subcategories_count == 0) {
|
||||
setStep(2);
|
||||
setDisabledTab({
|
||||
categoryTab: true,
|
||||
detailTab: false,
|
||||
extraDetailTabl: false,
|
||||
images: false,
|
||||
location: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const index = categoryPath.findIndex(
|
||||
(item) => item.id === category?.id
|
||||
);
|
||||
setCategoryPath((prevPath) => prevPath.slice(0, index + 1));
|
||||
}
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
} finally {
|
||||
setCategoriesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getCustomFieldsData = async () => {
|
||||
try {
|
||||
const res = await getCustomFieldsApi.getCustomFields({
|
||||
category_ids: allCategoryIdsString,
|
||||
});
|
||||
const data = res?.data?.data;
|
||||
setCustomFields(data);
|
||||
|
||||
const initializedDetails = {};
|
||||
|
||||
languages.forEach((lang) => {
|
||||
const langFields = {};
|
||||
|
||||
data.forEach((item) => {
|
||||
if (lang.id !== defaultLangId && item.type !== "textbox") return;
|
||||
|
||||
let initialValue = "";
|
||||
switch (item.type) {
|
||||
case "checkbox":
|
||||
case "radio":
|
||||
initialValue = [];
|
||||
break;
|
||||
case "fileinput":
|
||||
initialValue = null;
|
||||
break;
|
||||
case "dropdown":
|
||||
case "textbox":
|
||||
case "number":
|
||||
case "text":
|
||||
initialValue = "";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
langFields[item.id] = initialValue;
|
||||
});
|
||||
initializedDetails[lang.id] = langFields;
|
||||
});
|
||||
setExtraDetails(initializedDetails);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryTabClick = async (category) => {
|
||||
await handleFetchCategories(category, true);
|
||||
};
|
||||
|
||||
const handleSelectedTabClick = (id) => {
|
||||
setCustomFields([]);
|
||||
setLangId(defaultLangId);
|
||||
setTranslations({
|
||||
[defaultLangId]: {
|
||||
contact: mobile,
|
||||
country_code: countryCode,
|
||||
region_code: regionCode,
|
||||
},
|
||||
});
|
||||
setExtraDetails({
|
||||
[defaultLangId]: {},
|
||||
});
|
||||
if (step !== 1) {
|
||||
setStep(1);
|
||||
setDisabledTab({
|
||||
categoryTab: false,
|
||||
detailTab: true,
|
||||
extraDetailTabl: true,
|
||||
images: true,
|
||||
location: true,
|
||||
});
|
||||
}
|
||||
// ✅ SINGLE CATEGORY EDGE CASE
|
||||
|
||||
const index = categoryPath.findIndex((item) => item.id === id);
|
||||
|
||||
if (index === 0) {
|
||||
setCategoryPath([]);
|
||||
// Fetch root / all categories
|
||||
handleFetchCategories(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ NORMAL BACK-NAVIGATION FLOW
|
||||
const secondLast = categoryPath[index - 1];
|
||||
|
||||
if (secondLast) {
|
||||
handleFetchCategories(secondLast);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetailsSubmit = () => {
|
||||
if (customFields?.length === 0) {
|
||||
setStep(4);
|
||||
} else {
|
||||
setStep(3);
|
||||
}
|
||||
};
|
||||
const SLUG_RE = /^[a-z0-9-]+$/i;
|
||||
const isEmpty = (x) => !x || !x.toString().trim();
|
||||
const isNegative = (n) => Number(n) < 0;
|
||||
|
||||
const handleFullSubmission = () => {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
slug,
|
||||
contact,
|
||||
video_link,
|
||||
min_salary,
|
||||
max_salary,
|
||||
country_code,
|
||||
} = defaultDetails;
|
||||
|
||||
// Step 1: Must pick a category
|
||||
const catId = categoryPath.at(-1)?.id;
|
||||
|
||||
if (!catId) {
|
||||
toast.error(t("selectCategory"));
|
||||
return setStep(1);
|
||||
}
|
||||
// Step 2: Get data for default (selected) language
|
||||
if (isEmpty(name) || isEmpty(description)) {
|
||||
toast.error(t("completeDetails")); // Title, desc, phone required
|
||||
return setStep(2);
|
||||
}
|
||||
|
||||
// ✅ Validate phone number ONLY if user entered one as it is optional
|
||||
if (Boolean(contact) && !isValidPhoneNumber(`+${country_code}${contact}`)) {
|
||||
toast.error(t("invalidPhoneNumber"));
|
||||
return setStep(2);
|
||||
}
|
||||
|
||||
// Step 3: Validate job or price fields
|
||||
if (is_job_category) {
|
||||
const min = min_salary ? Number(min_salary) : null;
|
||||
const max = max_salary ? Number(max_salary) : null;
|
||||
|
||||
if (min !== null && min < 0) {
|
||||
toast.error(t("enterValidSalaryMin"));
|
||||
return setStep(2);
|
||||
}
|
||||
if (max !== null && max < 0) {
|
||||
toast.error(t("enterValidSalaryMax"));
|
||||
return setStep(2);
|
||||
}
|
||||
if (min !== null && max !== null) {
|
||||
if (min === max) {
|
||||
toast.error(t("salaryMinCannotBeEqualMax"));
|
||||
return setStep(2);
|
||||
}
|
||||
if (min > max) {
|
||||
toast.error(t("salaryMinCannotBeGreaterThanMax"));
|
||||
return setStep(2);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!isPriceOptional && isEmpty(price)) {
|
||||
toast.error(t("completeDetails")); // Price is required
|
||||
return setStep(2);
|
||||
}
|
||||
if (!isEmpty(price) && isNegative(price)) {
|
||||
toast.error(t("enterValidPrice"));
|
||||
return setStep(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Video URL check
|
||||
if (!isEmpty(video_link) && !isValidURL(video_link)) {
|
||||
toast.error(t("enterValidUrl"));
|
||||
return setStep(2);
|
||||
}
|
||||
|
||||
// Step 5: Slug validation
|
||||
if (!isEmpty(slug) && !SLUG_RE.test(slug.trim())) {
|
||||
toast.error(t("addValidSlug"));
|
||||
return setStep(2);
|
||||
}
|
||||
|
||||
if (
|
||||
customFields.length !== 0 &&
|
||||
!validateExtraDetails({
|
||||
languages,
|
||||
defaultLangId,
|
||||
extraDetails,
|
||||
customFields,
|
||||
filePreviews,
|
||||
})
|
||||
) {
|
||||
return setStep(3);
|
||||
}
|
||||
if (uploadedImages.length === 0) {
|
||||
toast.error(t("uploadMainPicture"));
|
||||
setStep(4);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!location?.country ||
|
||||
!location?.state ||
|
||||
!location?.city ||
|
||||
!location?.formattedAddress
|
||||
) {
|
||||
toast.error(t("pleaseSelectCity"));
|
||||
return;
|
||||
}
|
||||
postAd();
|
||||
};
|
||||
|
||||
const postAd = async () => {
|
||||
const catId = categoryPath.at(-1)?.id;
|
||||
const customFieldTranslations =
|
||||
prepareCustomFieldTranslations(extraDetails);
|
||||
|
||||
const customFieldFiles = prepareCustomFieldFiles(
|
||||
extraDetails,
|
||||
defaultLangId
|
||||
);
|
||||
const nonDefaultTranslations = filterNonDefaultTranslations(
|
||||
translations,
|
||||
defaultLangId
|
||||
);
|
||||
|
||||
const allData = {
|
||||
name: defaultDetails.name,
|
||||
slug: defaultDetails.slug.trim(),
|
||||
description: defaultDetails?.description,
|
||||
category_id: catId,
|
||||
all_category_ids: allCategoryIdsString,
|
||||
price: defaultDetails.price,
|
||||
contact: defaultDetails.contact,
|
||||
video_link: defaultDetails?.video_link,
|
||||
// custom_fields: transformedCustomFields,
|
||||
image: uploadedImages[0],
|
||||
gallery_images: otherImages,
|
||||
address: location?.formattedAddress,
|
||||
latitude: location?.lat,
|
||||
longitude: location?.long,
|
||||
custom_field_files: customFieldFiles,
|
||||
country: location?.country,
|
||||
state: location?.state,
|
||||
city: location?.city,
|
||||
...(location?.area_id ? { area_id: Number(location?.area_id) } : {}),
|
||||
...(Object.keys(nonDefaultTranslations).length > 0 && {
|
||||
translations: nonDefaultTranslations,
|
||||
}),
|
||||
...(Object.keys(customFieldTranslations).length > 0 && {
|
||||
custom_field_translations: customFieldTranslations,
|
||||
}),
|
||||
...(defaultDetails?.currency_id && {
|
||||
currency_id: defaultDetails?.currency_id,
|
||||
}),
|
||||
region_code: defaultDetails?.region_code?.toUpperCase() || "",
|
||||
};
|
||||
if (is_job_category) {
|
||||
// Only add salary fields if they're provided
|
||||
if (defaultDetails.min_salary) {
|
||||
allData.min_salary = defaultDetails.min_salary;
|
||||
}
|
||||
if (defaultDetails.max_salary) {
|
||||
allData.max_salary = defaultDetails.max_salary;
|
||||
}
|
||||
} else {
|
||||
allData.price = defaultDetails.price;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAdPlaced(true);
|
||||
const res = await addItemApi.addItem(allData);
|
||||
if (res?.data?.error === false) {
|
||||
setOpenSuccessModal(true);
|
||||
setCreatedAdSlug(res?.data?.data[0]?.slug);
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsAdPlaced(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
setStep((prev) => {
|
||||
if (customFields.length === 0 && step === 4) {
|
||||
return prev - 2;
|
||||
} else {
|
||||
return prev - 1;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMoreCategory = async () => {
|
||||
setIsLoadMoreCat(true);
|
||||
try {
|
||||
const response = await categoryApi.getCategory({
|
||||
page: `${currentPage + 1}`,
|
||||
category_id: lastItemId,
|
||||
listing: 1,
|
||||
});
|
||||
const { data } = response.data;
|
||||
setCategories((prev) => [...prev, ...data.data]);
|
||||
setCurrentPage(data?.current_page); // Update the current page
|
||||
setLastPage(data?.last_page); // Update the current page
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
setIsLoadMoreCat(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
if (tab === 1 && !disabledTab.categoryTab) {
|
||||
setStep(1);
|
||||
} else if (tab === 2 && !disabledTab.detailTab) {
|
||||
setStep(2);
|
||||
} else if (tab === 3 && !disabledTab.extraDetailTabl) {
|
||||
setStep(3);
|
||||
} else if (tab === 4 && !disabledTab.images) {
|
||||
setStep(4);
|
||||
} else if (tab === 5 && !disabledTab.location) {
|
||||
setStep(5);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeatilsBack = () => {
|
||||
setCustomFields([]);
|
||||
setLangId(defaultLangId);
|
||||
setTranslations({
|
||||
[defaultLangId]: {
|
||||
contact: mobile,
|
||||
country_code: countryCode,
|
||||
region_code: regionCode,
|
||||
},
|
||||
});
|
||||
setExtraDetails({
|
||||
[defaultLangId]: {},
|
||||
});
|
||||
|
||||
if (step !== 1) {
|
||||
setStep(1);
|
||||
setDisabledTab({
|
||||
selectCategory: false,
|
||||
details: true,
|
||||
extraDet: true,
|
||||
img: true,
|
||||
loc: true,
|
||||
});
|
||||
}
|
||||
const secondLast = categoryPath.at(-2);
|
||||
if (secondLast) {
|
||||
handleFetchCategories(secondLast);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<BreadCrumb title2={t("adListing")} />
|
||||
<div className="container">
|
||||
<div className="flex flex-col gap-8 mt-8">
|
||||
<h1 className="text-2xl font-medium">{t("adListing")}</h1>
|
||||
<div className="flex flex-col gap-6 border rounded-md p-4">
|
||||
<div className="flex items-center gap-3 justify-between bg-muted px-4 py-2 rounded-md flex-wrap">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div
|
||||
className={`transition-all duration-300 p-2 cursor-pointer ${step === 1 ? "bg-primary text-white" : ""
|
||||
} rounded-md ${disabledTab?.categoryTab == true ? "opacity-60" : " "
|
||||
}`}
|
||||
onClick={() => handleTabClick(1)}
|
||||
>
|
||||
{t("selectedCategory")}
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 p-2 cursor-pointer ${step === 2 ? "bg-primary text-white" : ""
|
||||
} rounded-md ${disabledTab?.detailTab == true ? "opacity-60" : " "
|
||||
}`}
|
||||
onClick={() => handleTabClick(2)}
|
||||
>
|
||||
{t("details")}
|
||||
</div>
|
||||
|
||||
{customFields?.length > 0 && (
|
||||
<div
|
||||
className={`transition-all duration-300 p-2 cursor-pointer ${step === 3 ? "bg-primary text-white" : ""
|
||||
} rounded-md ${disabledTab?.extraDetailTabl == true ? "opacity-60" : " "
|
||||
}`}
|
||||
onClick={() => handleTabClick(3)}
|
||||
>
|
||||
{t("extraDetails")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`transition-all duration-300 p-2 cursor-pointer ${step === 4 ? "bg-primary text-white" : ""
|
||||
} rounded-md ${disabledTab?.images == true ? "opacity-60" : " "
|
||||
}`}
|
||||
onClick={() => handleTabClick(4)}
|
||||
>
|
||||
{t("images")}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`transition-all duration-300 p-2 cursor-pointer ${step === 5 ? "bg-primary text-white" : ""
|
||||
} rounded-md ${disabledTab?.location == true ? "opacity-60" : " "
|
||||
}`}
|
||||
onClick={() => handleTabClick(5)}
|
||||
>
|
||||
{t("location")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(step == 2 || (step === 3 && hasTextbox)) && (
|
||||
<AdLanguageSelector
|
||||
langId={langId}
|
||||
setLangId={setLangId}
|
||||
languages={languages}
|
||||
setTranslations={setTranslations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(step == 1 || step == 2) && categoryPath?.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-medium text-xl">{t("selectedCategory")}</p>
|
||||
<div className="flex">
|
||||
{categoryPath?.map((item, index) => {
|
||||
const shouldShowComma =
|
||||
categoryPath.length > 1 &&
|
||||
index !== categoryPath.length - 1;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className="text-primary ltr:text-left rtl:text-right"
|
||||
onClick={() => handleSelectedTabClick(item?.id)}
|
||||
disabled={categoriesLoading}
|
||||
>
|
||||
{item.translated_name || item.name}
|
||||
{shouldShowComma && ", "}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{step == 1 && (
|
||||
<ComponentOne
|
||||
categories={categories}
|
||||
setCategoryPath={setCategoryPath}
|
||||
fetchMoreCategory={fetchMoreCategory}
|
||||
lastPage={lastPage}
|
||||
currentPage={currentPage}
|
||||
isLoadMoreCat={isLoadMoreCat}
|
||||
handleCategoryTabClick={handleCategoryTabClick}
|
||||
categoriesLoading={categoriesLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step == 2 && (
|
||||
<ComponentTwo
|
||||
currencies={currencies}
|
||||
setTranslations={setTranslations}
|
||||
current={currentDetails}
|
||||
langId={langId}
|
||||
defaultLangId={defaultLangId}
|
||||
handleDetailsSubmit={handleDetailsSubmit}
|
||||
handleDeatilsBack={handleDeatilsBack}
|
||||
is_job_category={is_job_category}
|
||||
isPriceOptional={isPriceOptional}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step == 3 && (
|
||||
<ComponentThree
|
||||
customFields={customFields}
|
||||
setExtraDetails={setExtraDetails}
|
||||
filePreviews={filePreviews}
|
||||
setFilePreviews={setFilePreviews}
|
||||
setStep={setStep}
|
||||
handleGoBack={handleGoBack}
|
||||
currentExtraDetails={currentExtraDetails}
|
||||
langId={langId}
|
||||
defaultLangId={defaultLangId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step == 4 && (
|
||||
<ComponentFour
|
||||
uploadedImages={uploadedImages}
|
||||
setUploadedImages={setUploadedImages}
|
||||
otherImages={otherImages}
|
||||
setOtherImages={setOtherImages}
|
||||
setStep={setStep}
|
||||
handleGoBack={handleGoBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step == 5 && (
|
||||
<ComponentFive
|
||||
location={location}
|
||||
setLocation={setLocation}
|
||||
handleFullSubmission={handleFullSubmission}
|
||||
isAdPlaced={isAdPlaced}
|
||||
handleGoBack={handleGoBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdSuccessModal
|
||||
openSuccessModal={openSuccessModal}
|
||||
setOpenSuccessModal={setOpenSuccessModal}
|
||||
createdAdSlug={createdAdSlug}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkauth(AdsListing);
|
||||
173
components/PagesComponent/AdsListing/ComponentFive.jsx
Normal file
173
components/PagesComponent/AdsListing/ComponentFive.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState } from "react";
|
||||
import { FaLocationCrosshairs } from "react-icons/fa6";
|
||||
import dynamic from "next/dynamic";
|
||||
import { BiMapPin } from "react-icons/bi";
|
||||
import { IoLocationOutline } from "react-icons/io5";
|
||||
import { toast } from "sonner";
|
||||
import { useSelector } from "react-redux";
|
||||
import ManualAddress from "./ManualAddress";
|
||||
import { getIsBrowserSupported } from "@/redux/reducer/locationSlice";
|
||||
import { getIsPaidApi } from "@/redux/reducer/settingSlice";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import { t } from "@/utils";
|
||||
import LandingAdEditSearchAutocomplete from "@/components/Location/LandingAdEditSearchAutocomplete";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import useGetLocation from "@/components/Layout/useGetLocation";
|
||||
|
||||
const MapComponent = dynamic(() => import("@/components/Common/MapComponent"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="w-full h-[400px] bg-gray-100 rounded-lg" />,
|
||||
});
|
||||
|
||||
const ComponentFive = ({
|
||||
location,
|
||||
setLocation,
|
||||
handleFullSubmission,
|
||||
isAdPlaced,
|
||||
handleGoBack,
|
||||
}) => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const [showManualAddress, setShowManualAddress] = useState(false);
|
||||
const isBrowserSupported = useSelector(getIsBrowserSupported);
|
||||
const [IsGettingCurrentLocation, setIsGettingCurrentLocation] =
|
||||
useState(false);
|
||||
const IsPaidApi = useSelector(getIsPaidApi);
|
||||
const { fetchLocationData } = useGetLocation();
|
||||
|
||||
const getLocationWithMap = async (pos) => {
|
||||
try {
|
||||
const data = await fetchLocationData(pos);
|
||||
setLocation(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching location data:", error);
|
||||
toast.error(t("errorOccurred"));
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentLocation = async () => {
|
||||
if (navigator.geolocation) {
|
||||
setIsGettingCurrentLocation(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
try {
|
||||
const { latitude, longitude } = position.coords;
|
||||
const data = await fetchLocationData({ lat: latitude, lng: longitude });
|
||||
setLocation(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching location data:", error);
|
||||
toast.error(t("errorOccurred"));
|
||||
} finally {
|
||||
setIsGettingCurrentLocation(false);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
toast.error(t("locationNotGranted"));
|
||||
setIsGettingCurrentLocation(false);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.error(t("geoLocationNotSupported"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3">
|
||||
<h5 className="text-xl font-medium">{t("addLocation")}</h5>
|
||||
<div className="flex items-center gap-2 border rounded-md w-full md:w-96 min-h-[42px]">
|
||||
<LandingAdEditSearchAutocomplete
|
||||
saveOnSuggestionClick={false}
|
||||
setSelectedLocation={setLocation}
|
||||
/>
|
||||
{isBrowserSupported && (
|
||||
<button
|
||||
onClick={getCurrentLocation}
|
||||
disabled={IsGettingCurrentLocation}
|
||||
className="bg-primary p-2 text-white gap-2 flex items-center rounded-md h-10"
|
||||
>
|
||||
<span>
|
||||
{IsGettingCurrentLocation ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<FaLocationCrosshairs size={16} />
|
||||
)}
|
||||
</span>
|
||||
<span className="whitespace-nowrap hidden md:inline">
|
||||
{IsGettingCurrentLocation ? t("loading") : t("locateMe")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8 flex-col">
|
||||
<MapComponent
|
||||
location={location}
|
||||
getLocationWithMap={getLocationWithMap}
|
||||
/>
|
||||
<div className="flex items-center gap-3 bg-muted rounded-lg p-4 ">
|
||||
<div className="p-5 rounded-md bg-white">
|
||||
<BiMapPin className="text-primary" size={32} />
|
||||
</div>
|
||||
<span className="flex flex-col gap-1">
|
||||
<h6 className="font-medium">{t("address")}</h6>
|
||||
{location?.address_translated || location?.formattedAddress ? (
|
||||
<p>
|
||||
{location?.address_translated || location?.formattedAddress}
|
||||
</p>
|
||||
) : (
|
||||
t("addYourAddress")
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!IsPaidApi && (
|
||||
<>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="absolute top-1/2 left-0 right-0 h-px bg-[#d3d3d3]"></div>
|
||||
<div className="relative bg-muted text-black text-base font-medium rounded-full w-12 h-12 flex items-center justify-center uppercase">
|
||||
{t("or")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 items-center justify-center">
|
||||
<p className="text-xl font-semibold">
|
||||
{t("whatLocAdYouSelling")}
|
||||
</p>
|
||||
<button
|
||||
className="p-2 flex items-center gap-2 border rounded-md font-medium"
|
||||
onClick={() => setShowManualAddress(true)}
|
||||
>
|
||||
<IoLocationOutline size={20} />
|
||||
{t("addLocation")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
<button
|
||||
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light disabled:bg-muted-foreground"
|
||||
disabled={isAdPlaced}
|
||||
onClick={handleFullSubmission}
|
||||
>
|
||||
{isAdPlaced ? t("posting") : t("postNow")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ManualAddress
|
||||
key={showManualAddress}
|
||||
showManualAddress={showManualAddress}
|
||||
setShowManualAddress={setShowManualAddress}
|
||||
setLocation={setLocation}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentFive;
|
||||
233
components/PagesComponent/AdsListing/ComponentFour.jsx
Normal file
233
components/PagesComponent/AdsListing/ComponentFour.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { IoInformationCircleOutline } from "react-icons/io5";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { HiOutlineUpload } from "react-icons/hi";
|
||||
import { MdClose } from "react-icons/md";
|
||||
import { toast } from "sonner";
|
||||
import { t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const ComponentFour = ({
|
||||
uploadedImages,
|
||||
setUploadedImages,
|
||||
otherImages,
|
||||
setOtherImages,
|
||||
setStep,
|
||||
handleGoBack,
|
||||
}) => {
|
||||
const onDrop = (acceptedFiles) => {
|
||||
if (acceptedFiles.length == 0) {
|
||||
toast.error(t("wrongFile"));
|
||||
} else {
|
||||
setUploadedImages(acceptedFiles);
|
||||
}
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"image/jpeg": [".jpeg", ".jpg"],
|
||||
"image/png": [".png"],
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const files = () =>
|
||||
uploadedImages?.map((file, index) => (
|
||||
<div key={index} className="relative">
|
||||
<CustomImage
|
||||
width={591}
|
||||
height={350}
|
||||
className="rounded-2xl object-cover aspect-[591/350]"
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
/>
|
||||
<div className="absolute top-2 left-2 flex gap-2 items-center">
|
||||
<button
|
||||
className="bg-white p-1 rounded-full"
|
||||
onClick={() => removeImage(index)}
|
||||
>
|
||||
<MdClose size={14} color="black" />
|
||||
</button>
|
||||
<div className="text-white text-xs flex flex-col">
|
||||
<span>{file.name}</span>
|
||||
<span>{Math.round(file.size / 1024)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
const removeImage = (index) => {
|
||||
setUploadedImages((prevImages) => prevImages.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const onOtherDrop = (acceptedFiles) => {
|
||||
const currentFilesCount = otherImages.length; // Number of files already uploaded
|
||||
const remainingSlots = 5 - currentFilesCount; // How many more files can be uploaded
|
||||
|
||||
if (remainingSlots === 0) {
|
||||
// Show error if the limit has been reached
|
||||
toast.error(t("imageLimitExceeded"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (acceptedFiles.length > remainingSlots) {
|
||||
// Show error if the number of new files exceeds the remaining slots
|
||||
toast.error(
|
||||
t("youCanUpload") + " " + remainingSlots + " " + t("moreImages")
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Add the new files to the state
|
||||
setOtherImages((prevImages) => [...prevImages, ...acceptedFiles]);
|
||||
};
|
||||
|
||||
const {
|
||||
getRootProps: getRootOtheProps,
|
||||
getInputProps: getInputOtherProps,
|
||||
isDragActive: isDragOtherActive,
|
||||
} = useDropzone({
|
||||
onDrop: onOtherDrop,
|
||||
accept: {
|
||||
"image/jpeg": [".jpeg", ".jpg"],
|
||||
"image/png": [".png"],
|
||||
},
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
const removeOtherImage = (index) => {
|
||||
setOtherImages((prevImages) => prevImages.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const filesOther = () =>
|
||||
otherImages &&
|
||||
otherImages?.map((file, index) => (
|
||||
<div key={`${file.name}-${file.size}`} className="relative">
|
||||
<CustomImage
|
||||
width={591}
|
||||
height={350}
|
||||
className="rounded-2xl object-cover aspect-[591/350]"
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
/>
|
||||
<div className="absolute top-2 left-2 flex gap-2 items-center">
|
||||
<button
|
||||
className="bg-white p-1 rounded-full"
|
||||
onClick={() => removeOtherImage(index)}
|
||||
>
|
||||
<MdClose size={22} color="black" />
|
||||
</button>
|
||||
<div className="text-white text-xs flex flex-col">
|
||||
<span>{file.name}</span>
|
||||
<span>{Math.round(file.size / 1024)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-col-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="requiredInputLabel text-sm font-semibold">
|
||||
{t("mainPicture")}
|
||||
</span>
|
||||
<div className="border-2 border-dashed rounded-lg p-2">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className="flex flex-col min-h-[175px] items-center justify-center cursor-pointer"
|
||||
style={{ display: uploadedImages.length > 0 ? "none" : "" }}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isDragActive ? (
|
||||
<span className="text-primary font-medium">
|
||||
{t("dropFiles")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 items-center text-center">
|
||||
<span className="text-muted-foreground">
|
||||
{t("dragFiles")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{t("or")}</span>
|
||||
<div className="flex items-center text-primary">
|
||||
<HiOutlineUpload size={24} />
|
||||
<span className="font-medium">{t("upload")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>{files()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="flex items-center gap-1 font-semibold text-sm">
|
||||
{t("otherPicture")}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<IoInformationCircleOutline size={22} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="font-normal"
|
||||
>
|
||||
<p>{t("maxOtherImages")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
<div className="border-2 border-dashed rounded-lg p-2">
|
||||
<div
|
||||
{...getRootOtheProps()}
|
||||
className="flex flex-col items-center justify-center min-h-[175px] cursor-pointer"
|
||||
style={{ display: otherImages.length >= 5 ? "none" : "" }}
|
||||
>
|
||||
<input {...getInputOtherProps()} />
|
||||
{isDragOtherActive ? (
|
||||
<span className="text-primary font-medium">
|
||||
{t("dropFiles")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 items-center text-center">
|
||||
<span className="text-muted-foreground">
|
||||
{t("dragFiles")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{t("or")}</span>
|
||||
<div className="flex items-center text-primary">
|
||||
<HiOutlineUpload size={24} />
|
||||
<span className="font-medium">{t("upload")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">{filesOther()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
<button
|
||||
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={() => setStep(5)}
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentFour;
|
||||
81
components/PagesComponent/AdsListing/ComponentOne.jsx
Normal file
81
components/PagesComponent/AdsListing/ComponentOne.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
import { MdChevronRight } from "react-icons/md";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const ComponentOne = ({
|
||||
categories,
|
||||
categoriesLoading,
|
||||
fetchMoreCategory,
|
||||
lastPage,
|
||||
currentPage,
|
||||
isLoadMoreCat,
|
||||
handleCategoryTabClick,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className=" grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{categoriesLoading ? (
|
||||
<div className="col-span-12 py-28">
|
||||
<Loader />
|
||||
</div>
|
||||
) : (
|
||||
categories?.map((category) => {
|
||||
return (
|
||||
<div className="" key={category?.id}>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
key={category?.id}
|
||||
onClick={() => handleCategoryTabClick(category)}
|
||||
>
|
||||
<div className="flex items-center gap-2 ">
|
||||
<CustomImage
|
||||
src={category?.image}
|
||||
alt={category?.translated_name || category?.name}
|
||||
height={48}
|
||||
width={48}
|
||||
className="h-12 w-12 rounded-full"
|
||||
/>
|
||||
<span className="break-all">
|
||||
{category?.translated_name || category?.name}
|
||||
</span>
|
||||
</div>
|
||||
{category?.subcategories?.length > 0 && (
|
||||
<MdChevronRight size={24} className="rtl:scale-x-[-1]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lastPage > currentPage && (
|
||||
<div className="text-center mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-sm sm:text-base text-primary w-[256px]"
|
||||
disabled={isLoadMoreCat || categoriesLoading}
|
||||
onClick={fetchMoreCategory}
|
||||
>
|
||||
{isLoadMoreCat ? t("loading") : t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentOne;
|
||||
|
||||
const Loader = () => {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="relative w-12 h-12">
|
||||
<div className="absolute w-12 h-12 bg-primary rounded-full animate-ping"></div>
|
||||
<div className="absolute w-12 h-12 bg-primary rounded-full animate-ping delay-1000"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
338
components/PagesComponent/AdsListing/ComponentThree.jsx
Normal file
338
components/PagesComponent/AdsListing/ComponentThree.jsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { toast } from "sonner";
|
||||
import { HiOutlineUpload } from "react-icons/hi";
|
||||
import { MdOutlineAttachFile } from "react-icons/md";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { handleKeyDown, inpNum, t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const ComponentThree = ({
|
||||
customFields,
|
||||
setExtraDetails,
|
||||
filePreviews,
|
||||
setFilePreviews,
|
||||
setStep,
|
||||
handleGoBack,
|
||||
currentExtraDetails,
|
||||
langId,
|
||||
defaultLangId,
|
||||
}) => {
|
||||
const write = (fieldId, value) =>
|
||||
setExtraDetails((prev) => ({
|
||||
...prev,
|
||||
[langId]: {
|
||||
...prev[langId],
|
||||
[fieldId]: value,
|
||||
},
|
||||
}));
|
||||
|
||||
const handleFileChange = (id, file) => {
|
||||
if (file) {
|
||||
const allowedExtensions = /\.(jpg|jpeg|svg|png|pdf)$/i;
|
||||
if (!allowedExtensions.test(file.name)) {
|
||||
toast.error(t("notAllowedFile"));
|
||||
return;
|
||||
}
|
||||
const fileUrl = URL.createObjectURL(file);
|
||||
setFilePreviews((prev) => {
|
||||
const oldUrl = prev?.[langId]?.[id]?.url;
|
||||
if (oldUrl) {
|
||||
URL.revokeObjectURL(oldUrl);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[langId]: {
|
||||
...(prev[langId] || {}),
|
||||
[id]: {
|
||||
url: fileUrl,
|
||||
isPdf: /\.pdf$/i.test(file.name),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
write(id, file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (id, value) => write(id, value ?? "");
|
||||
|
||||
const handleCheckboxChange = (id, value, checked) => {
|
||||
const list = currentExtraDetails[id] || [];
|
||||
const next = checked ? [...list, value] : list.filter((v) => v !== value);
|
||||
write(id, next);
|
||||
};
|
||||
|
||||
const renderCustomFields = (field) => {
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
translated_name,
|
||||
type,
|
||||
translated_value,
|
||||
values,
|
||||
min_length,
|
||||
max_length,
|
||||
is_required,
|
||||
} = field;
|
||||
|
||||
const inputProps = {
|
||||
id,
|
||||
name: id,
|
||||
required: !!is_required,
|
||||
onChange: (e) => handleChange(id, e.target.value),
|
||||
value: currentExtraDetails[id] || "",
|
||||
...(type === "number"
|
||||
? { min: min_length, max: max_length }
|
||||
: { minLength: min_length, maxLength: max_length }),
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case "number": {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Input
|
||||
type={type}
|
||||
inputMode="numeric"
|
||||
placeholder={`${t("enter")} ${translated_name || name}`}
|
||||
{...inputProps}
|
||||
onKeyDown={(e) => handleKeyDown(e, max_length)}
|
||||
onKeyPress={(e) => inpNum(e)}
|
||||
/>
|
||||
{max_length && (
|
||||
<span className="self-end text-sm text-muted-foreground">
|
||||
{`${currentExtraDetails[id]?.length ?? 0}/${max_length}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "textbox": {
|
||||
return (
|
||||
<div className=" flex flex-col">
|
||||
<Textarea
|
||||
placeholder={`${t("enter")} ${translated_name || name}`}
|
||||
{...inputProps}
|
||||
/>
|
||||
{max_length && (
|
||||
<span className="self-end text-sm text-muted-foreground">
|
||||
{`${currentExtraDetails[id]?.length ?? 0}/${max_length}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "dropdown":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
id={id}
|
||||
name={id}
|
||||
onValueChange={(value) => handleChange(id, value)}
|
||||
value={currentExtraDetails[id] || ""}
|
||||
>
|
||||
<SelectTrigger className="outline-none focus:outline-none">
|
||||
<SelectValue
|
||||
className="font-medium"
|
||||
placeholder={`${t("select")} ${translated_name || name}`}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel value="">
|
||||
{t("select")} {translated_name || name}
|
||||
</SelectLabel>
|
||||
{values?.map((option, index) => (
|
||||
<SelectItem
|
||||
id={option}
|
||||
className="font-medium"
|
||||
key={option}
|
||||
value={option}
|
||||
>
|
||||
{translated_value[index] || option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "checkbox":
|
||||
return (
|
||||
<div className="flex w-full flex-wrap gap-2">
|
||||
{values?.map((value, index) => {
|
||||
const uniqueId = `${id}-${value}-${index}`;
|
||||
return (
|
||||
<div key={uniqueId} className="flex gap-1 items-center">
|
||||
<Checkbox
|
||||
id={uniqueId}
|
||||
value={value}
|
||||
onCheckedChange={(checked) =>
|
||||
handleCheckboxChange(id, value, checked)
|
||||
}
|
||||
checked={currentExtraDetails[id]?.includes(value)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={uniqueId}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{translated_value[index] || value}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
case "radio":
|
||||
return (
|
||||
<RadioGroup
|
||||
value={currentExtraDetails[id] || ""}
|
||||
onValueChange={(value) => handleChange(id, value)}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{(translated_value || values)?.map((option, index) => {
|
||||
const uniqueId = `${id}-${option}-${index}`;
|
||||
return (
|
||||
<div
|
||||
key={uniqueId}
|
||||
className="flex items-center gap-2 flex-wrap"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option}
|
||||
id={uniqueId}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<label
|
||||
htmlFor={uniqueId}
|
||||
className={`${currentExtraDetails[id] === option
|
||||
? "bg-primary text-white"
|
||||
: ""
|
||||
} border rounded-md px-4 py-2 cursor-pointer transition-colors`}
|
||||
>
|
||||
{translated_value[index] || option}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
);
|
||||
|
||||
case "fileinput":
|
||||
const fileUrl = filePreviews?.[langId]?.[id]?.url;
|
||||
const isPdf = filePreviews?.[langId]?.[id]?.isPdf;
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={id} className="flex gap-2 items-center">
|
||||
<div className="cursor-pointer border px-2.5 py-1 rounded">
|
||||
<HiOutlineUpload size={24} fontWeight="400" />
|
||||
</div>
|
||||
{fileUrl && (
|
||||
<div className="flex items-center gap-1 text-sm flex-nowrap break-words">
|
||||
{isPdf ? (
|
||||
<>
|
||||
<MdOutlineAttachFile />
|
||||
<CustomLink
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("viewPdf")}
|
||||
</CustomLink>
|
||||
</>
|
||||
) : (
|
||||
<CustomImage
|
||||
key={fileUrl}
|
||||
src={fileUrl}
|
||||
alt="Preview"
|
||||
className="h-9 w-9"
|
||||
height={36}
|
||||
width={36}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id={id}
|
||||
name={id}
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileChange(id, e.target.files[0])}
|
||||
accept=".jpg,.jpeg,.png,.svg,.pdf"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("allowedFileType")}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{customFields?.map((field) => {
|
||||
if (langId !== defaultLangId && field.type !== "textbox") return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-2" key={field?.id}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<CustomImage
|
||||
src={field?.image}
|
||||
alt={field?.name}
|
||||
height={28}
|
||||
width={28}
|
||||
className="h-7 w-7 rounded-sm"
|
||||
/>
|
||||
<Label
|
||||
className={`${field?.required === 1 && defaultLangId === langId
|
||||
? "requiredInputLabel"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{field?.translated_name || field?.name}
|
||||
</Label>
|
||||
</div>
|
||||
{renderCustomFields(field)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
<button
|
||||
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={() => setStep(4)}
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentThree;
|
||||
293
components/PagesComponent/AdsListing/ComponentTwo.jsx
Normal file
293
components/PagesComponent/AdsListing/ComponentTwo.jsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
import {
|
||||
getCurrencyIsoCode,
|
||||
getCurrencyPosition,
|
||||
getCurrencySymbol,
|
||||
} from "@/redux/reducer/settingSlice";
|
||||
import { generateSlug, t } from "@/utils";
|
||||
import PhoneInput from "react-phone-input-2";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const ComponentTwo = ({
|
||||
setTranslations,
|
||||
current,
|
||||
langId,
|
||||
handleDetailsSubmit,
|
||||
handleDeatilsBack,
|
||||
is_job_category,
|
||||
isPriceOptional,
|
||||
defaultLangId,
|
||||
currencies,
|
||||
}) => {
|
||||
const currencyPosition = useSelector(getCurrencyPosition);
|
||||
const currencySymbol = useSelector(getCurrencySymbol);
|
||||
const currencyIsoCode = useSelector(getCurrencyIsoCode);
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
|
||||
const selectedCurrency = currencies?.find(
|
||||
(curr) => curr?.id === current?.currency_id
|
||||
);
|
||||
// Use selected currency's symbol and position, or fallback to Redux settings
|
||||
const displaySymbol = selectedCurrency?.symbol || currencySymbol;
|
||||
const displayPosition = selectedCurrency?.position || currencyPosition;
|
||||
|
||||
const placeholderLabel =
|
||||
displayPosition === "right" ? `00 ${displaySymbol}` : `${displaySymbol} 00`;
|
||||
|
||||
const handleField = (field) => (e) => {
|
||||
const value = e.target.value;
|
||||
setTranslations((prev) => {
|
||||
const updatedLangData = {
|
||||
...prev[langId],
|
||||
[field]: value,
|
||||
};
|
||||
// ✅ Only auto-generate slug if default language and field is title
|
||||
if (field === "name" && langId === defaultLangId) {
|
||||
updatedLangData.slug = generateSlug(value);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[langId]: updatedLangData,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handlePhoneChange = (value, data) => {
|
||||
const dial = data?.dialCode || ""; // Dial code like "91", "1"
|
||||
const iso2 = data?.countryCode || ""; // Region code like "in", "us", "ae"
|
||||
setTranslations((prev) => {
|
||||
const pureMobile = value.startsWith(dial)
|
||||
? value.slice(dial.length)
|
||||
: value;
|
||||
return {
|
||||
...prev,
|
||||
[langId]: {
|
||||
...prev[langId],
|
||||
contact: pureMobile,
|
||||
country_code: dial,
|
||||
region_code: iso2,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleCurrencyChange = (currencyId) => {
|
||||
setTranslations((prev) => ({
|
||||
...prev,
|
||||
[langId]: {
|
||||
...prev[langId],
|
||||
currency_id: Number(currencyId),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="title"
|
||||
className={langId === defaultLangId ? "requiredInputLabel" : ""}
|
||||
>
|
||||
{t("title")}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="title"
|
||||
id="title"
|
||||
placeholder={t("enterTitle")}
|
||||
value={current.name || ""}
|
||||
onChange={handleField("name")} //here send param as the one we need to send to the api
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="description"
|
||||
className={langId === defaultLangId ? "requiredInputLabel" : ""}
|
||||
>
|
||||
{t("description")}
|
||||
</Label>
|
||||
<Textarea
|
||||
name="description"
|
||||
id="description"
|
||||
cols="30"
|
||||
rows="3"
|
||||
placeholder={t("enterDescription")}
|
||||
className="border rounded-md px-4 py-2 outline-none"
|
||||
value={current.description || ""}
|
||||
onChange={handleField("description")}
|
||||
></Textarea>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="currency">{t("currency")}</Label>
|
||||
{
|
||||
currencies?.length > 0 ?
|
||||
<Select
|
||||
value={current.currency_id?.toString()}
|
||||
onValueChange={handleCurrencyChange}
|
||||
dir={isRTL ? "rtl" : "ltr"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{currencies?.map((currency) => (
|
||||
<SelectItem
|
||||
key={currency.id}
|
||||
value={currency.id.toString()}
|
||||
>
|
||||
{currency.iso_code} ({currency.symbol})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
:
|
||||
<Select
|
||||
value={currencyIsoCode} // ✅ same default value you already have
|
||||
disabled
|
||||
dir={isRTL ? "rtl" : "ltr"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
{/* Required for RTL */}
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={currencyIsoCode}>
|
||||
{currencyIsoCode} ({currencySymbol})
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
</div>
|
||||
{/* Render the rest only for default language */}
|
||||
{langId === defaultLangId && (
|
||||
<>
|
||||
{is_job_category ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="salaryMin">{t("salaryMin")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
name="salaryMin"
|
||||
id="salaryMin"
|
||||
min={0}
|
||||
placeholder={placeholderLabel}
|
||||
value={current.min_salary || ""}
|
||||
onChange={handleField("min_salary")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="salaryMax">{t("salaryMax")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
name="salaryMax"
|
||||
id="salaryMax"
|
||||
placeholder={placeholderLabel}
|
||||
value={current.max_salary || ""}
|
||||
onChange={handleField("max_salary")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="price"
|
||||
className={
|
||||
!isPriceOptional && langId === defaultLangId
|
||||
? "requiredInputLabel"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{t("price")}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
name="price"
|
||||
id="price"
|
||||
min={0}
|
||||
placeholder={placeholderLabel}
|
||||
value={current.price || ""}
|
||||
onChange={handleField("price")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="phonenumber">{t("phoneNumber")}</Label>
|
||||
<PhoneInput
|
||||
country={process.env.NEXT_PUBLIC_DEFAULT_COUNTRY}
|
||||
value={`${current.country_code}${current.contact}`}
|
||||
onChange={(phone, data) => handlePhoneChange(phone, data)}
|
||||
inputProps={{
|
||||
name: "phonenumber",
|
||||
id: "phonenumber",
|
||||
}}
|
||||
enableLongNumbers
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="videoLink">{t("videoLink")}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="videoLink"
|
||||
id="videoLink"
|
||||
placeholder={t("enterAdditionalLinks")}
|
||||
value={current.video_link || ""}
|
||||
onChange={handleField("video_link")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="slug">
|
||||
{t("slug")}{" "}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
({t("allowedSlug")})
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="slug"
|
||||
id="slug"
|
||||
placeholder={t("enterSlug")}
|
||||
value={current.slug || ""}
|
||||
onChange={handleField("slug")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={handleDeatilsBack}
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
<button
|
||||
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={handleDetailsSubmit}
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentTwo;
|
||||
853
components/PagesComponent/AdsListing/ManualAddress.jsx
Normal file
853
components/PagesComponent/AdsListing/ManualAddress.jsx
Normal file
@@ -0,0 +1,853 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getAreasApi,
|
||||
getCitiesApi,
|
||||
getCoutriesApi,
|
||||
getStatesApi,
|
||||
} from "@/utils/api";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { t } from "@/utils";
|
||||
|
||||
const ManualAddress = ({
|
||||
showManualAddress,
|
||||
setShowManualAddress,
|
||||
setLocation,
|
||||
}) => {
|
||||
const [CountryStore, setCountryStore] = useState({
|
||||
Countries: [],
|
||||
SelectedCountry: {},
|
||||
CountrySearch: "",
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
countryOpen: false,
|
||||
isLoading: false,
|
||||
});
|
||||
const [StateStore, setStateStore] = useState({
|
||||
States: [],
|
||||
SelectedState: {},
|
||||
StateSearch: "",
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
stateOpen: false,
|
||||
isLoading: false,
|
||||
});
|
||||
const [CityStore, setCityStore] = useState({
|
||||
Cities: [],
|
||||
SelectedCity: {},
|
||||
CitySearch: "",
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
cityOpen: false,
|
||||
});
|
||||
const [AreaStore, setAreaStore] = useState({
|
||||
Areas: [],
|
||||
SelectedArea: {},
|
||||
AreaSearch: "",
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
areaOpen: false,
|
||||
isLoading: false,
|
||||
});
|
||||
const [Address, setAddress] = useState("");
|
||||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
const isCountrySelected =
|
||||
Object.keys(CountryStore?.SelectedCountry).length > 0;
|
||||
// Check if areas exist for the selected city
|
||||
const hasAreas = AreaStore?.Areas.length > 0;
|
||||
// Infinite scroll refs
|
||||
const { ref: stateRef, inView: stateInView } = useInView();
|
||||
const { ref: countryRef, inView: countryInView } = useInView();
|
||||
const { ref: cityRef, inView: cityInView } = useInView();
|
||||
const { ref: areaRef, inView: areaInView } = useInView();
|
||||
|
||||
const getCountriesData = async (search, page) => {
|
||||
try {
|
||||
setCountryStore((prev) => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
Countries: search ? [] : prev.Countries, // Clear list if searching
|
||||
}));
|
||||
// Fetch countries
|
||||
const params = {};
|
||||
if (search) {
|
||||
params.search = search; // Send only 'search' if provided
|
||||
} else {
|
||||
params.page = page; // Send only 'page' if no search
|
||||
}
|
||||
|
||||
const res = await getCoutriesApi.getCoutries(params);
|
||||
let allCountries;
|
||||
if (page > 1) {
|
||||
allCountries = [...CountryStore?.Countries, ...res?.data?.data?.data];
|
||||
} else {
|
||||
allCountries = res?.data?.data?.data;
|
||||
}
|
||||
setCountryStore((prev) => ({
|
||||
...prev,
|
||||
currentPage: res?.data?.data?.current_page,
|
||||
Countries: allCountries,
|
||||
hasMore:
|
||||
res?.data?.data?.current_page < res?.data?.data?.last_page
|
||||
? true
|
||||
: false,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching countries data:", error);
|
||||
setCountryStore((prev) => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
};
|
||||
const getStatesData = async (search, page) => {
|
||||
try {
|
||||
setStateStore((prev) => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
States: search ? [] : prev.States,
|
||||
}));
|
||||
const params = {
|
||||
country_id: CountryStore?.SelectedCountry?.id,
|
||||
};
|
||||
if (search) {
|
||||
params.search = search; // Send only 'search' if provided
|
||||
} else {
|
||||
params.page = page; // Send only 'page' if no search
|
||||
}
|
||||
|
||||
const res = await getStatesApi.getStates(params);
|
||||
|
||||
let allStates;
|
||||
if (page > 1) {
|
||||
allStates = [...StateStore?.States, ...res?.data?.data?.data];
|
||||
} else {
|
||||
allStates = res?.data?.data?.data;
|
||||
}
|
||||
|
||||
setStateStore((prev) => ({
|
||||
...prev,
|
||||
currentPage: res?.data?.data?.current_page,
|
||||
States: allStates,
|
||||
hasMore:
|
||||
res?.data?.data?.current_page < res?.data?.data?.last_page
|
||||
? true
|
||||
: false,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching states data:", error);
|
||||
setStateStore((prev) => ({ ...prev, isLoading: false }));
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const getCitiesData = async (search, page) => {
|
||||
try {
|
||||
setCityStore((prev) => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
Cities: search ? [] : prev.Cities,
|
||||
}));
|
||||
const params = {
|
||||
state_id: StateStore?.SelectedState?.id,
|
||||
};
|
||||
if (search) {
|
||||
params.search = search; // Send only 'search' if provided
|
||||
} else {
|
||||
params.page = page; // Send only 'page' if no search
|
||||
}
|
||||
|
||||
const res = await getCitiesApi.getCities(params);
|
||||
let allCities;
|
||||
if (page > 1) {
|
||||
allCities = [...CityStore?.Cities, ...res?.data?.data?.data];
|
||||
} else {
|
||||
allCities = res?.data?.data?.data;
|
||||
}
|
||||
setCityStore((prev) => ({
|
||||
...prev,
|
||||
currentPage: res?.data?.data?.current_page,
|
||||
Cities: allCities,
|
||||
hasMore:
|
||||
res?.data?.data?.current_page < res?.data?.data?.last_page
|
||||
? true
|
||||
: false,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching cities data:", error);
|
||||
setCityStore((prev) => ({ ...prev, isLoading: false }));
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const getAreaData = async (search, page) => {
|
||||
try {
|
||||
setAreaStore((prev) => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
Areas: search ? [] : prev.Areas,
|
||||
}));
|
||||
const params = {
|
||||
city_id: CityStore?.SelectedCity?.id,
|
||||
};
|
||||
if (search) {
|
||||
params.search = search;
|
||||
} else {
|
||||
params.page = page;
|
||||
}
|
||||
const res = await getAreasApi.getAreas(params);
|
||||
let allArea;
|
||||
if (page > 1) {
|
||||
allArea = [...AreaStore?.Areas, ...res?.data?.data?.data];
|
||||
} else {
|
||||
allArea = res?.data?.data?.data;
|
||||
}
|
||||
setAreaStore((prev) => ({
|
||||
...prev,
|
||||
currentPage: res?.data?.data?.current_page,
|
||||
Areas: allArea,
|
||||
hasMore:
|
||||
res?.data?.data?.current_page < res?.data?.data?.last_page
|
||||
? true
|
||||
: false,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching areas data:", error);
|
||||
setAreaStore((prev) => ({ ...prev, isLoading: false }));
|
||||
return [];
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (showManualAddress) {
|
||||
getCountriesData(CountryStore?.CountrySearch, 1);
|
||||
}
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [CountryStore?.CountrySearch, showManualAddress]);
|
||||
useEffect(() => {
|
||||
if (CountryStore?.SelectedCountry?.id) {
|
||||
const timeout = setTimeout(() => {
|
||||
getStatesData(StateStore?.StateSearch, 1);
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
}, [CountryStore?.SelectedCountry?.id, StateStore?.StateSearch]);
|
||||
useEffect(() => {
|
||||
if (StateStore?.SelectedState?.id) {
|
||||
const timeout = setTimeout(() => {
|
||||
getCitiesData(CityStore?.CitySearch, 1);
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
}, [StateStore?.SelectedState?.id, CityStore?.CitySearch]);
|
||||
useEffect(() => {
|
||||
if (CityStore?.SelectedCity?.id) {
|
||||
const timeout = setTimeout(() => {
|
||||
getAreaData(AreaStore?.AreaSearch, 1);
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
}, [CityStore?.SelectedCity?.id, AreaStore?.AreaSearch]);
|
||||
|
||||
// Trigger infinite scroll when refs come into view
|
||||
useEffect(() => {
|
||||
if (CountryStore?.hasMore && !CountryStore?.isLoading && countryInView) {
|
||||
getCountriesData("", CountryStore?.currentPage + 1);
|
||||
}
|
||||
}, [
|
||||
countryInView,
|
||||
CountryStore?.hasMore,
|
||||
CountryStore?.isLoading,
|
||||
CountryStore?.currentPage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (StateStore?.hasMore && !StateStore?.isLoading && stateInView) {
|
||||
getStatesData("", StateStore?.currentPage + 1);
|
||||
}
|
||||
}, [
|
||||
stateInView,
|
||||
StateStore?.hasMore,
|
||||
StateStore?.isLoading,
|
||||
StateStore?.currentPage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (CityStore?.hasMore && !CityStore?.isLoading && cityInView) {
|
||||
getCitiesData("", CityStore?.currentPage + 1);
|
||||
}
|
||||
}, [
|
||||
cityInView,
|
||||
CityStore?.hasMore,
|
||||
CityStore?.isLoading,
|
||||
CityStore?.currentPage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (AreaStore?.hasMore && !AreaStore?.isLoading && areaInView) {
|
||||
getAreaData("", AreaStore?.currentPage + 1);
|
||||
}
|
||||
}, [
|
||||
areaInView,
|
||||
AreaStore?.hasMore,
|
||||
AreaStore?.isLoading,
|
||||
AreaStore?.currentPage,
|
||||
]);
|
||||
|
||||
const validateFields = () => {
|
||||
const errors = {};
|
||||
if (!CountryStore?.SelectedCountry?.name) errors.country = true;
|
||||
if (!StateStore?.SelectedState?.name) errors.state = true;
|
||||
if (!CityStore?.SelectedCity?.name) errors.city = true;
|
||||
if (!AreaStore?.SelectedArea?.name && !Address.trim())
|
||||
errors.address = true;
|
||||
return errors;
|
||||
};
|
||||
|
||||
const handleCountryChange = (value) => {
|
||||
const Country = CountryStore?.Countries.find(
|
||||
(country) => country.name === value
|
||||
);
|
||||
setCountryStore((prev) => ({
|
||||
...prev,
|
||||
SelectedCountry: Country,
|
||||
countryOpen: false,
|
||||
}));
|
||||
setStateStore({
|
||||
States: [],
|
||||
SelectedState: {},
|
||||
StateSearch: "",
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
stateOpen: false,
|
||||
});
|
||||
setCityStore({
|
||||
Cities: [],
|
||||
SelectedCity: {},
|
||||
CitySearch: "",
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
cityOpen: false,
|
||||
});
|
||||
setAreaStore({
|
||||
Areas: [],
|
||||
SelectedArea: {},
|
||||
AreaSearch: "",
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
areaOpen: false,
|
||||
});
|
||||
setAddress("");
|
||||
};
|
||||
const handleStateChange = (value) => {
|
||||
const State = StateStore?.States.find((state) => state.name === value);
|
||||
setStateStore((prev) => ({
|
||||
...prev,
|
||||
SelectedState: State,
|
||||
stateOpen: false,
|
||||
}));
|
||||
setCityStore({
|
||||
Cities: [],
|
||||
SelectedCity: {},
|
||||
CitySearch: "",
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
cityOpen: false,
|
||||
});
|
||||
setAreaStore({
|
||||
Areas: [],
|
||||
SelectedArea: {},
|
||||
AreaSearch: "",
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
areaOpen: false,
|
||||
});
|
||||
setAddress("");
|
||||
};
|
||||
const handleCityChange = (value) => {
|
||||
const City = CityStore?.Cities.find((city) => city.name === value);
|
||||
setCityStore((prev) => ({
|
||||
...prev,
|
||||
SelectedCity: City,
|
||||
cityOpen: false,
|
||||
}));
|
||||
setAreaStore({
|
||||
Areas: [],
|
||||
SelectedArea: {},
|
||||
AreaSearch: "",
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
areaOpen: false,
|
||||
});
|
||||
setAddress("");
|
||||
};
|
||||
const handleAreaChange = (value) => {
|
||||
const chosenArea = AreaStore?.Areas.find((item) => item.name === value);
|
||||
setAreaStore((prev) => ({
|
||||
...prev,
|
||||
SelectedArea: chosenArea,
|
||||
areaOpen: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const errors = validateFields();
|
||||
setFieldErrors(errors);
|
||||
if (Object.keys(errors).length > 0) return;
|
||||
|
||||
// Build address parts array and filter out empty values
|
||||
|
||||
const addressParts = [];
|
||||
const addressPartsTranslated = [];
|
||||
|
||||
if (hasAreas && AreaStore?.SelectedArea?.name) {
|
||||
addressParts.push(AreaStore.SelectedArea.name);
|
||||
addressPartsTranslated.push(
|
||||
AreaStore.SelectedArea.translated_name || AreaStore.SelectedArea.name
|
||||
);
|
||||
} else if (Address.trim()) {
|
||||
addressParts.push(Address.trim());
|
||||
addressPartsTranslated.push(Address.trim());
|
||||
}
|
||||
if (CityStore?.SelectedCity?.name) {
|
||||
addressParts.push(CityStore.SelectedCity.name);
|
||||
addressPartsTranslated.push(
|
||||
CityStore.SelectedCity.translated_name || CityStore.SelectedCity.name
|
||||
);
|
||||
}
|
||||
if (StateStore?.SelectedState?.name) {
|
||||
addressParts.push(StateStore.SelectedState.name);
|
||||
addressPartsTranslated.push(
|
||||
StateStore.SelectedState.translated_name ||
|
||||
StateStore.SelectedState.name
|
||||
);
|
||||
}
|
||||
if (CountryStore?.SelectedCountry?.name) {
|
||||
addressParts.push(CountryStore.SelectedCountry.name);
|
||||
addressPartsTranslated.push(
|
||||
CountryStore.SelectedCountry.translated_name ||
|
||||
CountryStore.SelectedCountry.name
|
||||
);
|
||||
}
|
||||
|
||||
const formattedAddress = addressParts.join(", ");
|
||||
const formattedAddressTranslated = addressPartsTranslated.join(", ");
|
||||
|
||||
const locationData = {
|
||||
country: CountryStore?.SelectedCountry?.name || "",
|
||||
state: StateStore?.SelectedState?.name || "",
|
||||
city: CityStore?.SelectedCity?.name || "",
|
||||
formattedAddress: formattedAddress,
|
||||
address_translated: formattedAddressTranslated,
|
||||
lat: CityStore?.SelectedCity?.latitude || null,
|
||||
long: CityStore?.SelectedCity?.longitude || null,
|
||||
area_id: AreaStore?.SelectedArea?.id || null,
|
||||
};
|
||||
setLocation(locationData);
|
||||
setShowManualAddress(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showManualAddress} onOpenChange={setShowManualAddress}>
|
||||
<DialogContent className="">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("manuAddAddress")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Popover
|
||||
modal
|
||||
open={CountryStore?.countryOpen}
|
||||
onOpenChange={(isOpen) =>
|
||||
setCountryStore((prev) => ({ ...prev, countryOpen: isOpen }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={CountryStore?.countryOpen}
|
||||
className={`w-full justify-between outline-none ${fieldErrors.country ? "border-red-500" : ""
|
||||
}`}
|
||||
>
|
||||
{CountryStore?.SelectedCountry?.translated_name ||
|
||||
CountryStore?.SelectedCountry?.name ||
|
||||
t("country")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{fieldErrors.country && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{t("countryRequired")}
|
||||
</span>
|
||||
)}
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("searchCountries")}
|
||||
value={CountryStore.CountrySearch || ""}
|
||||
onValueChange={(val) => {
|
||||
setCountryStore((prev) => ({
|
||||
...prev,
|
||||
CountrySearch: val,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
{CountryStore.isLoading ? (
|
||||
<LoacationLoader />
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noCountriesFound")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="max-h-[240px] overflow-y-auto">
|
||||
{CountryStore?.Countries?.map((country, index) => {
|
||||
const isLast =
|
||||
index === CountryStore?.Countries?.length - 1;
|
||||
return (
|
||||
<CommandItem
|
||||
key={country.id}
|
||||
value={country.name}
|
||||
onSelect={handleCountryChange}
|
||||
ref={isLast ? countryRef : null}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
CountryStore?.SelectedCountry?.name ===
|
||||
country?.name
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{country.translated_name || country.name}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{CountryStore.isLoading &&
|
||||
CountryStore.Countries.length > 0 && <LoacationLoader />}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Popover
|
||||
modal
|
||||
open={StateStore?.stateOpen}
|
||||
onOpenChange={(isOpen) =>
|
||||
setStateStore((prev) => ({ ...prev, stateOpen: isOpen }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={StateStore?.stateOpen}
|
||||
className={`w-full justify-between outline-none ${fieldErrors.state ? "border-red-500" : ""
|
||||
}`}
|
||||
disabled={!isCountrySelected}
|
||||
>
|
||||
{StateStore?.SelectedState?.translated_name ||
|
||||
StateStore?.SelectedState?.name ||
|
||||
t("state")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{fieldErrors.state && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{t("stateRequired")}
|
||||
</span>
|
||||
)}
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("searchStates")}
|
||||
value={StateStore.StateSearch || ""}
|
||||
onValueChange={(val) => {
|
||||
setStateStore((prev) => ({ ...prev, StateSearch: val }));
|
||||
}}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
{StateStore.isLoading ? (
|
||||
<LoacationLoader />
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noStatesFound")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="max-h-[240px] overflow-y-auto">
|
||||
{StateStore?.States?.map((state, index) => {
|
||||
const isLast = index === StateStore?.States?.length - 1;
|
||||
return (
|
||||
<CommandItem
|
||||
key={state.id}
|
||||
value={state.name}
|
||||
onSelect={handleStateChange}
|
||||
ref={isLast ? stateRef : null}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
StateStore?.SelectedState?.name === state?.name
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{state.translated_name || state.name}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{StateStore.isLoading && StateStore.States.length > 0 && (
|
||||
<LoacationLoader />
|
||||
)}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Popover
|
||||
modal
|
||||
open={CityStore?.cityOpen}
|
||||
onOpenChange={(isOpen) =>
|
||||
setCityStore((prev) => ({ ...prev, cityOpen: isOpen }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={CityStore?.cityOpen}
|
||||
className={`w-full justify-between outline-none ${fieldErrors.city ? "border-red-500" : ""
|
||||
}`}
|
||||
disabled={!StateStore?.SelectedState?.id}
|
||||
>
|
||||
{CityStore?.SelectedCity?.translated_name ||
|
||||
CityStore?.SelectedCity?.name ||
|
||||
t("city")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{fieldErrors.city && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{t("cityRequired")}
|
||||
</span>
|
||||
)}
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("searchCities")}
|
||||
value={CityStore.CitySearch || ""}
|
||||
onValueChange={(val) => {
|
||||
setCityStore((prev) => ({ ...prev, CitySearch: val }));
|
||||
}}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
{CityStore.isLoading ? (
|
||||
<LoacationLoader />
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noCitiesFound")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="max-h-[240px] overflow-y-auto">
|
||||
{CityStore?.Cities?.map((city, index) => {
|
||||
const isLast = index === CityStore?.Cities?.length - 1;
|
||||
return (
|
||||
<CommandItem
|
||||
key={city.id}
|
||||
value={city.name}
|
||||
onSelect={handleCityChange}
|
||||
ref={isLast ? cityRef : null}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
CityStore?.SelectedCity?.name === city?.name
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{city.translated_name || city.name}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{CityStore.isLoading && CityStore.Cities.length > 0 && (
|
||||
<LoacationLoader />
|
||||
)}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{hasAreas || AreaStore?.AreaSearch ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Popover
|
||||
modal
|
||||
open={AreaStore?.areaOpen}
|
||||
onOpenChange={(isOpen) =>
|
||||
setAreaStore((prev) => ({ ...prev, areaOpen: isOpen }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={AreaStore?.areaOpen}
|
||||
className={`w-full justify-between outline-none ${fieldErrors.address ? "border-red-500" : ""
|
||||
}`}
|
||||
disabled={!CityStore?.SelectedCity?.id}
|
||||
>
|
||||
{AreaStore?.SelectedArea?.translated_name ||
|
||||
AreaStore?.SelectedArea?.name ||
|
||||
t("area")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{fieldErrors.address && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{t("areaRequired")}
|
||||
</span>
|
||||
)}
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("searchAreas")}
|
||||
value={AreaStore.AreaSearch || ""}
|
||||
onValueChange={(val) => {
|
||||
setAreaStore((prev) => ({ ...prev, AreaSearch: val }));
|
||||
}}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
{AreaStore.isLoading ? (
|
||||
<LoacationLoader />
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noAreasFound")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="max-h-[240px] overflow-y-auto">
|
||||
{AreaStore?.Areas?.map((area, index) => {
|
||||
const isLast = index === AreaStore?.Areas?.length - 1;
|
||||
return (
|
||||
<CommandItem
|
||||
key={area.id}
|
||||
value={area.name}
|
||||
onSelect={handleAreaChange}
|
||||
ref={isLast ? areaRef : null}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
AreaStore?.SelectedArea?.name === area?.name
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{area.translated_name || area.name}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{AreaStore.isLoading && AreaStore.Areas.length > 0 && (
|
||||
<LoacationLoader />
|
||||
)}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Textarea
|
||||
rows={5}
|
||||
className={`border p-2 outline-none rounded-md w-full ${fieldErrors.address ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder={t("enterAddre")}
|
||||
value={Address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
disabled={!CityStore?.SelectedCity?.id}
|
||||
/>
|
||||
{fieldErrors.address && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{t("addressRequired")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="flex justify-end gap-2">
|
||||
<button onClick={() => setShowManualAddress(false)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="bg-primary p-2 px-4 rounded-md text-white font-medium"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualAddress;
|
||||
|
||||
const LoacationLoader = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{t("loading")}..
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
203
components/PagesComponent/BlogDetail/BlogDetailPage.jsx
Normal file
203
components/PagesComponent/BlogDetail/BlogDetailPage.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
|
||||
import { setBreadcrumbPath } from "@/redux/reducer/breadCrumbSlice";
|
||||
import { formatDateMonthYear, t, truncate } from "@/utils";
|
||||
import { getBlogsApi } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FaEye, FaRegCalendarCheck } from "react-icons/fa";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import parse from "html-react-parser";
|
||||
import {
|
||||
FacebookShareButton,
|
||||
TwitterShareButton,
|
||||
WhatsappShareButton,
|
||||
} from "react-share";
|
||||
import { BiLink, BiLogoFacebook, BiLogoWhatsapp } from "react-icons/bi";
|
||||
import { RiTwitterXLine } from "react-icons/ri";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import BlogCard from "../LandingPage/BlogCard";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { getCompanyName } from "@/redux/reducer/settingSlice";
|
||||
import PopularPosts from "../Blogs/PopularPosts";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import PageLoader from "@/components/Common/PageLoader";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const BlogDetailPage = ({ slug }) => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const path = usePathname();
|
||||
const dispatch = useDispatch();
|
||||
const admin = useSelector((state) => state?.Settings?.data?.data?.admin);
|
||||
const CompanyName = useSelector(getCompanyName);
|
||||
const [blogData, setBlogData] = useState([]);
|
||||
const [relatedBlogs, setRelatedBlogs] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const currentUrl = `${process.env.NEXT_PUBLIC_WEB_URL}${path}`;
|
||||
|
||||
const langCode = CurrentLanguage?.code?.toUpperCase();
|
||||
|
||||
useEffect(() => {
|
||||
getBlogsData();
|
||||
}, [CurrentLanguage.id]);
|
||||
|
||||
const getBlogsData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await getBlogsApi.getBlogs({ slug: slug, views: 1 });
|
||||
setBlogData(res?.data?.data?.data[0]);
|
||||
const title = res?.data?.data?.data[0]?.title;
|
||||
dispatch(
|
||||
setBreadcrumbPath([
|
||||
{
|
||||
name: t("ourBlogs"),
|
||||
slug: "/blogs",
|
||||
},
|
||||
{
|
||||
name: truncate(title, 30),
|
||||
},
|
||||
])
|
||||
);
|
||||
setRelatedBlogs(res?.data?.other_blogs);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(currentUrl);
|
||||
toast(t("copyToClipboard"));
|
||||
} catch (error) {
|
||||
console.error("Error copying to clipboard:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<BreadCrumb />
|
||||
<div className="container">
|
||||
<div className="flex flex-col mt-8 gap-12">
|
||||
<div className="grid md:grid-cols-12 grid-col-1 gap-6">
|
||||
{isLoading ? (
|
||||
<div className="col-span-1 md:col-span-8">
|
||||
<PageLoader />
|
||||
</div>
|
||||
) : blogData ? (
|
||||
<div className="col-span-1 md:col-span-8 flex flex-col gap-8">
|
||||
<h1 className="text-3xl font-medium">
|
||||
{blogData?.translated_title || blogData?.title}
|
||||
</h1>
|
||||
<div className="flex items-center flex-wrap gap-2 opacity-60 text-sm">
|
||||
<div className="flex gap-2 items-center">
|
||||
<CustomImage
|
||||
src={admin?.profile}
|
||||
alt={admin?.name || "Admin Image"}
|
||||
height={28}
|
||||
width={28}
|
||||
className="size-7 aspect-square rounded-md"
|
||||
/>
|
||||
<p>{admin?.name}</p>
|
||||
</div>
|
||||
<div className="border-r h-[16px]"></div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FaEye size={16} />
|
||||
{t("views")}: {blogData?.views}
|
||||
</div>
|
||||
<div className="border-r h-[16px] "></div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<FaRegCalendarCheck size={16} color="" />
|
||||
{t("postedOn")}: {formatDateMonthYear(blogData?.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<CustomImage
|
||||
src={blogData?.image}
|
||||
alt={blogData?.title || "Blog Image"}
|
||||
height={838}
|
||||
width={500}
|
||||
className="w-full h-auto aspect-[838/500] rounded-lg"
|
||||
/>
|
||||
<div className="max-w-full prose lg:prose-lg">
|
||||
{parse(
|
||||
blogData?.translated_description ||
|
||||
blogData?.description ||
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t pt-4 flex items-center justify-between ">
|
||||
<div className="flex flex-col gap-2 opacity-60">
|
||||
<span className="pb-2 font-sm ">{t("shareThis")}</span>
|
||||
<div className="flex gap-3">
|
||||
<button className="border-none" onClick={handleCopyUrl}>
|
||||
<BiLink size={24} />
|
||||
</button>
|
||||
<FacebookShareButton
|
||||
url={currentUrl}
|
||||
title={currentUrl + CompanyName}
|
||||
hashtag={CompanyName}
|
||||
>
|
||||
<BiLogoFacebook size={24} />
|
||||
</FacebookShareButton>
|
||||
<TwitterShareButton url={currentUrl}>
|
||||
<RiTwitterXLine size={24} />
|
||||
</TwitterShareButton>
|
||||
<WhatsappShareButton
|
||||
url={currentUrl}
|
||||
title={
|
||||
blogData?.translated_title ||
|
||||
blogData?.title + "" + " - " + "" + CompanyName
|
||||
}
|
||||
hashtag={CompanyName}
|
||||
>
|
||||
<BiLogoWhatsapp size={24} />
|
||||
</WhatsappShareButton>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{blogData?.translated_tags && (
|
||||
<div className="flex gap-2 items-center flex-wrap justify-end">
|
||||
{blogData?.translated_tags?.map((e) => (
|
||||
<Badge
|
||||
key={e}
|
||||
variant="outline"
|
||||
className="font-normal"
|
||||
>
|
||||
{e}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-span-1 md:col-span-8">
|
||||
<NoData name={t("blog")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="col-span-1 md:col-span-4">
|
||||
<PopularPosts langCode={langCode} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8 flex-col">
|
||||
<h1 className="text-2xl font-medium">{t("relatedArticle")}</h1>
|
||||
<div className="grid md:grid-cols-12 grid-cols-1 gap-4">
|
||||
{relatedBlogs &&
|
||||
relatedBlogs?.map((blog, index) => (
|
||||
<div className="md:col-span-4 col-span-12" key={index}>
|
||||
<BlogCard blog={blog} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
export default BlogDetailPage;
|
||||
103
components/PagesComponent/Blogs/Blogs.jsx
Normal file
103
components/PagesComponent/Blogs/Blogs.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
|
||||
import { t } from "@/utils";
|
||||
import { getBlogsApi } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import BlogCardSkeleton from "@/components/Skeletons/BlogCardSkeleton";
|
||||
import BlogCard from "../LandingPage/BlogCard";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import Tags from "./Tags";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||
|
||||
const Blogs = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const tag = searchParams?.get("tag");
|
||||
const langCode = useSelector(getCurrentLangCode);
|
||||
const [blogs, setBlogs] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoadMore, setIsLoadMore] = useState(false);
|
||||
useEffect(() => {
|
||||
getBlogsData(currentPage);
|
||||
}, [tag, langCode]);
|
||||
|
||||
const getBlogsData = async (page) => {
|
||||
try {
|
||||
page > 1 ? setIsLoadMore(true) : setIsLoading(true);
|
||||
const res = await getBlogsApi.getBlogs({
|
||||
sort_by: "new-to-old",
|
||||
page,
|
||||
...(tag && { tag }),
|
||||
});
|
||||
|
||||
if (res?.data?.error === false) {
|
||||
page === 1
|
||||
? setBlogs(res?.data?.data?.data)
|
||||
: setBlogs([...blogs, ...res?.data?.data?.data]);
|
||||
setCurrentPage(res?.data?.data?.current_page);
|
||||
setHasMore(res?.data?.data?.current_page < res?.data?.data?.last_page);
|
||||
} else {
|
||||
console.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
getBlogsData(currentPage + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<BreadCrumb title2={t("ourBlogs")} />
|
||||
<div className="container">
|
||||
<div className="flex flex-col mt-8 gap-6">
|
||||
<h1 className="text-2xl font-medium">{t("ourBlogs")}</h1>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 lg:gap-4 ">
|
||||
<div className="lg:col-span-8 col-span-12 order-2 lg:order-1">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 6 })?.map((_, index) => (
|
||||
<BlogCardSkeleton key={index} />
|
||||
))
|
||||
) : blogs && blogs?.length > 0 ? (
|
||||
blogs?.map((blog) => <BlogCard key={blog?.id} blog={blog} />)
|
||||
) : (
|
||||
<div className="col-span-full">
|
||||
<NoData name={t("blog")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="text-center mt-6 mb-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-sm sm:text-base text-primary w-[256px]"
|
||||
disabled={isLoading || isLoadMore}
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
{isLoadMore ? t("loading") : t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-12 lg:col-span-4 order-1 lg:order-2">
|
||||
<Tags tag={tag} langCode={langCode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Blogs;
|
||||
80
components/PagesComponent/Blogs/PopularPosts.jsx
Normal file
80
components/PagesComponent/Blogs/PopularPosts.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { t } from "@/utils";
|
||||
import { getBlogsApi } from "@/utils/api";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { useEffect, useState } from "react";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const PopularPosts = ({ langCode }) => {
|
||||
const [isPopularPostLoading, setIsPopularPostLoading] = useState(false);
|
||||
const [popularBlogs, setPopulerBlogs] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
getPopulerBlogsData();
|
||||
}, [langCode]);
|
||||
|
||||
const getPopulerBlogsData = async () => {
|
||||
setIsPopularPostLoading(true);
|
||||
try {
|
||||
const res = await getBlogsApi.getBlogs({ sort_by: "popular" });
|
||||
setPopulerBlogs(res?.data?.data?.data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsPopularPostLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border rounded-xl">
|
||||
<div className="p-4 border-b">
|
||||
<p className="font-medium">{t("popularPosts")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{isPopularPostLoading ? (
|
||||
Array.from({ length: 8 })?.map((_, index) => (
|
||||
<PopularPostsSkeleton key={index} />
|
||||
))
|
||||
) : popularBlogs && popularBlogs?.length > 0 ? (
|
||||
popularBlogs?.map((popularBlog) => (
|
||||
<CustomLink
|
||||
key={popularBlog?.id}
|
||||
href={`/blogs/${popularBlog?.slug}`}
|
||||
className="flex gap-3 px-4 py-2 items-center"
|
||||
>
|
||||
<CustomImage
|
||||
src={popularBlog?.image}
|
||||
alt={popularBlog?.title}
|
||||
height={48}
|
||||
width={64}
|
||||
className="aspect-[64/48] rounded object-cover"
|
||||
/>
|
||||
<p className="line-clamp-3 font-medium">
|
||||
{popularBlog?.translated_title || popularBlog?.title}
|
||||
</p>
|
||||
</CustomLink>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full">
|
||||
<NoData name={t("popularPosts")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopularPosts;
|
||||
|
||||
const PopularPostsSkeleton = () => {
|
||||
return (
|
||||
<div className="flex gap-3 px-4 py-2 items-center">
|
||||
<Skeleton className="h-12 w-16 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
82
components/PagesComponent/Blogs/Tags.jsx
Normal file
82
components/PagesComponent/Blogs/Tags.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { t } from "@/utils";
|
||||
import { getBlogTagsApi } from "@/utils/api";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
|
||||
const Tags = ({ tag, langCode }) => {
|
||||
const pathname = usePathname();
|
||||
const { navigate } = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [blogTags, setBlogTags] = useState([]);
|
||||
|
||||
const isAllTagActive = pathname === "/blogs" && !tag;
|
||||
|
||||
useEffect(() => {
|
||||
getBlogTagsData();
|
||||
}, [langCode]);
|
||||
|
||||
const getBlogTagsData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await getBlogTagsApi.getBlogs();
|
||||
setBlogTags(res?.data?.data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllTags = () => {
|
||||
navigate("/blogs", { scroll: false });
|
||||
};
|
||||
|
||||
const handleTagClick = (tagItem) => {
|
||||
window.history.pushState(null, "", `/blogs?tag=${tagItem}`);
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col border rounded-lg ">
|
||||
<div className="p-4">
|
||||
<p className="font-bold">{t("tags")}</p>
|
||||
</div>
|
||||
<div className="border-b w-full"></div>
|
||||
<div className="p-4 flex flex-wrap gap-2">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 10 }).map((_, index) => (
|
||||
<Skeleton key={index} className="w-20 h-8" />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={cn(
|
||||
"border px-4 text-sm py-2 rounded-md",
|
||||
isAllTagActive && "border-primary text-primary"
|
||||
)}
|
||||
onClick={handleAllTags}
|
||||
>
|
||||
{t("all")}
|
||||
</button>
|
||||
|
||||
{blogTags?.map((tagItem) => (
|
||||
<button
|
||||
key={tagItem.value}
|
||||
className={cn(
|
||||
"border px-4 text-sm py-2 rounded-md break-all",
|
||||
tag === String(tagItem.value) && "border-primary text-primary"
|
||||
)}
|
||||
onClick={() => handleTagClick(tagItem.value)}
|
||||
>
|
||||
{tagItem.label}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tags;
|
||||
228
components/PagesComponent/Cards/AddListingPlanCard.jsx
Normal file
228
components/PagesComponent/Cards/AddListingPlanCard.jsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { FaArrowRight, FaCheck } from "react-icons/fa";
|
||||
import { formatPriceAbbreviated, t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import { useState } from "react";
|
||||
|
||||
const AddListingPlanCard = ({ pckg, handlePurchasePackage }) => {
|
||||
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
|
||||
const descriptionItems =
|
||||
Array.isArray(pckg?.translated_key_points) &&
|
||||
pckg.translated_key_points.length > 0
|
||||
? pckg.translated_key_points
|
||||
: (pckg?.translated_description || pckg?.description || "")
|
||||
.split("\r\n")
|
||||
.filter(Boolean);
|
||||
|
||||
const isPackageActive = pckg?.is_active == 1;
|
||||
|
||||
const userPurchasedPackage = pckg?.user_purchased_packages?.[0]
|
||||
const remainingDays = userPurchasedPackage?.remaining_days;
|
||||
const remainingItems = userPurchasedPackage?.remaining_item_limit;
|
||||
const totalDays = pckg?.duration;
|
||||
const totalItems = pckg?.item_limit;
|
||||
const listingDurationDays = isPackageActive ? userPurchasedPackage?.listing_duration_days : pckg?.listing_duration_days
|
||||
|
||||
return (
|
||||
|
||||
<div className="perspective-1000">
|
||||
<div
|
||||
className={`relative transition-transform duration-500 transform-style-preserve-3d ${isFlipped ? "rotate-y-180" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`backface-hidden rounded-lg relative p-4 sm:p-8 shadow-sm border ${isPackageActive == 1 ? "bg-primary !text-white" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
{/* Sale Badge */}
|
||||
{pckg?.discount_in_percentage > 0 && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 z-20">
|
||||
<span className="bg-primary text-white px-6 py-2 rounded-full text-sm font-medium whitespace-nowrap">
|
||||
{t("save")} {pckg?.discount_in_percentage}% {t("off")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<CustomImage
|
||||
height={80}
|
||||
width={80}
|
||||
src={pckg.icon}
|
||||
alt="Bronze medal"
|
||||
className="aspect-square rounded-lg"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 overflow-hidden">
|
||||
<h2 className="text-xl font-medium mb-1 line-clamp-2 overflow-hidden">
|
||||
{pckg?.translated_name || pckg?.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
{pckg?.final_price !== 0 ? (
|
||||
<p className="text-xl font-bold">
|
||||
{formatPriceAbbreviated(pckg?.final_price)}
|
||||
</p>
|
||||
) : (
|
||||
t("Free")
|
||||
)}
|
||||
{pckg?.price > pckg?.final_price && (
|
||||
<p className="text-xl font-bold line-through text-gray-500">
|
||||
{formatPriceAbbreviated(pckg?.price)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-gray-200 my-6"></div>
|
||||
|
||||
<div className="h-[250px] overflow-y-auto mb-3">
|
||||
<h6 className="text-base font-medium">Features List</h6>
|
||||
|
||||
{/* Feature List */}
|
||||
<div className="flex flex-col gap-2 p-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
<FaCheck />
|
||||
</span>
|
||||
<span className="text-normal capitalize">
|
||||
{t("packageValidity")}:{" "}
|
||||
{isPackageActive
|
||||
? `${remainingDays} / ${totalDays} ${t("days")}`
|
||||
: `${totalDays} ${t("days")}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
|
||||
<FaCheck />
|
||||
</span>
|
||||
<span className="capitalize">
|
||||
{t("listingDuration")}: {listingDurationDays} {t("days")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
<FaCheck />
|
||||
</span>
|
||||
<span className="text-normal">
|
||||
{totalItems === "unlimited"
|
||||
? t("unlimited")
|
||||
: isPackageActive
|
||||
? `${remainingItems} / ${totalItems}`
|
||||
: totalItems}{" "}
|
||||
{t("adsListing")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pckg.categories.length === 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
<FaCheck />
|
||||
</span>
|
||||
<span className="text-normal ">{t("allCategoriesIncluded")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{descriptionItems.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<span
|
||||
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
<FaCheck />
|
||||
</span>
|
||||
<span className="text-normal ">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pckg.categories.length > 0 && (
|
||||
<>
|
||||
<h6 className="text-base font-medium">{t("categoryIncludes")}</h6>
|
||||
<div className="flex flex-col gap-2 p-3 text-sm">
|
||||
{pckg.categories.slice(0, 2).map((category) => (
|
||||
<div key={category.id} className="flex items-center gap-3">
|
||||
<span
|
||||
className={`${isPackageActive == 1 ? "text-white" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
<FaCheck />
|
||||
</span>
|
||||
<span className="text-normal ">
|
||||
{category.translated_name || category.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pckg.categories.length > 2 && (
|
||||
<button
|
||||
onClick={() => setIsFlipped(true)}
|
||||
className={`text-sm underline px-3 ${isPackageActive == 1 ? "text-white" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
{t("seeMore")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center h-12 max-h-12 p-4 md:p-0">
|
||||
<button
|
||||
onClick={() => handlePurchasePackage(pckg)}
|
||||
className={` w-full ${isPackageActive == 1 ? "hidden" : "flex"
|
||||
} py-1 px-3 md:py-2 md:px-4 lg:py-3 lg:px-6 rounded-lg items-center text-primary justify-center hover:bg-primary border hover:text-white transition-all duration-300`}
|
||||
>
|
||||
<span className="font-light text-lg">{t("choosePlan")}</span>
|
||||
<span className="ml-2">
|
||||
<FaArrowRight size={20} className="rtl:scale-x-[-1]" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div
|
||||
className={`absolute inset-0 rotate-y-180 backface-hidden rounded-lg p-4 sm:p-8 shadow-sm border
|
||||
${isPackageActive == 1 ? "bg-primary text-white" : "bg-white"}
|
||||
`}
|
||||
>
|
||||
<h6 className="text-lg font-medium mb-4">{t("allCategories")}</h6>
|
||||
|
||||
<div className="flex flex-col gap-2 overflow-y-auto max-h-[300px]">
|
||||
{pckg.categories.map((category) => (
|
||||
<div key={category.id} className="flex items-center gap-3">
|
||||
<FaCheck className="text-primary" />
|
||||
<span>{category.translated_name || category.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFlipped(false)}
|
||||
className={`mt-4 text-sm underline ${isPackageActive == 1 ? "text-white" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
export default AddListingPlanCard;
|
||||
139
components/PagesComponent/Chat/BlockedUsersMenu.jsx
Normal file
139
components/PagesComponent/Chat/BlockedUsersMenu.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { t } from "@/utils";
|
||||
import { RiUserForbidLine } from "react-icons/ri";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getBlockedUsers, unBlockUserApi } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const BlockedUsersMenu = ({ setSelectedChatDetails }) => {
|
||||
const [blockedUsersList, setBlockedUsersList] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [unblockingId, setUnblockingId] = useState("");
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
|
||||
const fetchBlockedUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getBlockedUsers.blockedUsers();
|
||||
const { data } = response;
|
||||
setBlockedUsersList(data?.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching blocked users:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) {
|
||||
fetchBlockedUsers();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnblock = async (userId, e) => {
|
||||
e.stopPropagation();
|
||||
setUnblockingId(userId);
|
||||
try {
|
||||
const response = await unBlockUserApi.unBlockUser({
|
||||
blocked_user_id: userId,
|
||||
});
|
||||
if (response?.data?.error === false) {
|
||||
// Refresh the blocked users list after successful unblock
|
||||
setBlockedUsersList((prevList) =>
|
||||
prevList.filter((user) => user.id !== userId)
|
||||
);
|
||||
setSelectedChatDetails((prev) => ({
|
||||
...prev,
|
||||
user_blocked: false,
|
||||
}));
|
||||
toast.success(response?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error unblocking user:", error);
|
||||
} finally {
|
||||
setUnblockingId("");
|
||||
}
|
||||
};
|
||||
|
||||
const BlockedUserSkeleton = () => (
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16 rounded-md" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="focus:outline-none">
|
||||
<RiUserForbidLine size={22} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={isRTL ? "start" : "end"} className="w-72">
|
||||
<DropdownMenuLabel>{t("blockedUsers")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{loading ? (
|
||||
Array.from({ length: 2 }, (_, index) => (
|
||||
<BlockedUserSkeleton key={index} />
|
||||
))
|
||||
) : blockedUsersList && blockedUsersList.length > 0 ? (
|
||||
<DropdownMenuGroup>
|
||||
{blockedUsersList.map((user) => (
|
||||
<DropdownMenuItem
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="h-10 w-10 flex-shrink-0 rounded-full overflow-hidden bg-gray-200 relative">
|
||||
<CustomImage
|
||||
src={user?.profile}
|
||||
alt={user.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate">{user.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleUnblock(user?.id, e)}
|
||||
disabled={unblockingId === user?.id}
|
||||
className={`px-3 py-1 text-sm ${
|
||||
unblockingId === user?.id
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-primary hover:bg-primary/80"
|
||||
} text-white rounded-md flex-shrink-0 ml-2`}
|
||||
>
|
||||
{t("unblock")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
{t("noBlockedUsers")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
export default BlockedUsersMenu;
|
||||
173
components/PagesComponent/Chat/Chat.jsx
Normal file
173
components/PagesComponent/Chat/Chat.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
import SelectedChatHeader from "./SelectedChatHeader";
|
||||
import ChatList from "./ChatList";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import NoChatFound from "./NoChatFound";
|
||||
import ChatMessages from "./ChatMessages";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { chatListApi } from "@/utils/api";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
|
||||
const Chat = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const activeTab = searchParams.get("activeTab") || "selling";
|
||||
const chatId = Number(searchParams.get("chatid")) || "";
|
||||
const [selectedChatDetails, setSelectedChatDetails] = useState();
|
||||
const langCode = useSelector(getCurrentLangCode);
|
||||
const { navigate } = useNavigate();
|
||||
|
||||
const [IsLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [buyer, setBuyer] = useState({
|
||||
BuyerChatList: [],
|
||||
CurrentBuyerPage: 1,
|
||||
HasMoreBuyer: false,
|
||||
});
|
||||
|
||||
const [seller, setSeller] = useState({
|
||||
SellerChatList: [],
|
||||
CurrentSellerPage: 1,
|
||||
HasMoreSeller: false,
|
||||
});
|
||||
|
||||
const isLargeScreen = useMediaQuery("(min-width: 1200px)");
|
||||
|
||||
const fetchSellerChatList = async (page = 1) => {
|
||||
if (page === 1) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const res = await chatListApi.chatList({ type: "seller", page });
|
||||
if (res?.data?.error === false) {
|
||||
const data = res?.data?.data?.data;
|
||||
const currentPage = res?.data?.data?.current_page;
|
||||
const lastPage = res?.data?.data?.last_page;
|
||||
|
||||
setSeller((prev) => ({
|
||||
...prev,
|
||||
SellerChatList: page === 1 ? data : [...prev.SellerChatList, ...data],
|
||||
CurrentSellerPage: currentPage,
|
||||
HasMoreSeller: currentPage < lastPage,
|
||||
}));
|
||||
} else {
|
||||
console.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching seller chat list:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBuyerChatList = async (page = 1) => {
|
||||
if (page === 1) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const res = await chatListApi.chatList({ type: "buyer", page });
|
||||
if (res?.data?.error === false) {
|
||||
const data = res?.data?.data?.data;
|
||||
const currentPage = res?.data?.data?.current_page;
|
||||
const lastPage = res?.data?.data?.last_page;
|
||||
|
||||
setBuyer((prev) => ({
|
||||
...prev,
|
||||
BuyerChatList: page === 1 ? data : [...prev.BuyerChatList, ...data],
|
||||
CurrentBuyerPage: currentPage,
|
||||
HasMoreBuyer: currentPage < lastPage,
|
||||
}));
|
||||
} else {
|
||||
console.log(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching buyer chat list:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
activeTab === "selling" ? fetchSellerChatList() : fetchBuyerChatList();
|
||||
}, [activeTab, langCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId && activeTab === "selling" && seller.SellerChatList.length > 0) {
|
||||
setSelectedChatDetails(
|
||||
seller.SellerChatList.find((chat) => chat.id === chatId)
|
||||
);
|
||||
} else if (
|
||||
chatId &&
|
||||
activeTab === "buying" &&
|
||||
buyer.BuyerChatList.length > 0
|
||||
) {
|
||||
setSelectedChatDetails(
|
||||
buyer.BuyerChatList.find((chat) => chat.id === chatId)
|
||||
);
|
||||
} else if (!chatId) {
|
||||
setSelectedChatDetails("");
|
||||
}
|
||||
}, [chatId, activeTab, seller.SellerChatList, buyer.BuyerChatList, langCode]);
|
||||
|
||||
const handleBack = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("chatid");
|
||||
navigate(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12">
|
||||
<div className="col-span-4">
|
||||
{(isLargeScreen || !chatId || IsLoading) && (
|
||||
<ChatList
|
||||
chatId={chatId}
|
||||
activeTab={activeTab}
|
||||
buyer={buyer}
|
||||
setBuyer={setBuyer}
|
||||
langCode={langCode}
|
||||
isLargeScreen={isLargeScreen}
|
||||
seller={seller}
|
||||
setSeller={setSeller}
|
||||
IsLoading={IsLoading}
|
||||
fetchSellerChatList={fetchSellerChatList}
|
||||
fetchBuyerChatList={fetchBuyerChatList}
|
||||
setSelectedChatDetails={setSelectedChatDetails}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(isLargeScreen || chatId) && (
|
||||
<div className="col-span-8">
|
||||
{selectedChatDetails?.id ? (
|
||||
<div className="ltr:xl:border-l rtl:lg:border-r h-[65vh] lg:h-[800px] flex flex-col">
|
||||
<SelectedChatHeader
|
||||
selectedChat={selectedChatDetails}
|
||||
isSelling={activeTab === "selling"}
|
||||
setSelectedChat={setSelectedChatDetails}
|
||||
handleBack={handleBack}
|
||||
isLargeScreen={isLargeScreen}
|
||||
/>
|
||||
<ChatMessages
|
||||
selectedChatDetails={selectedChatDetails}
|
||||
setSelectedChatDetails={setSelectedChatDetails}
|
||||
isSelling={activeTab === "selling"}
|
||||
setBuyer={setBuyer}
|
||||
chatId={chatId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ltr:xl:border-l rtl:xl:border-r h-[60vh] lg:h-[800px] flex items-center justify-center">
|
||||
<NoChatFound
|
||||
isLargeScreen={isLargeScreen}
|
||||
handleBack={handleBack}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
119
components/PagesComponent/Chat/ChatList.jsx
Normal file
119
components/PagesComponent/Chat/ChatList.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { t } from "@/utils";
|
||||
import ChatListCard from "./ChatListCard";
|
||||
import ChatListCardSkeleton from "./ChatListCardSkeleton";
|
||||
import BlockedUsersMenu from "./BlockedUsersMenu";
|
||||
import NoChatListFound from "./NoChatListFound";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
|
||||
const ChatList = ({
|
||||
chatId,
|
||||
activeTab,
|
||||
buyer,
|
||||
setBuyer,
|
||||
isLargeScreen,
|
||||
seller,
|
||||
setSeller,
|
||||
IsLoading,
|
||||
fetchSellerChatList,
|
||||
fetchBuyerChatList,
|
||||
setSelectedChatDetails
|
||||
}) => {
|
||||
const handleChatTabClick = (chat, isSelling) => {
|
||||
if (isSelling) {
|
||||
setSeller((prev) => ({
|
||||
...prev,
|
||||
SellerChatList: prev.SellerChatList.map((item) =>
|
||||
item.id === chat.id ? { ...item, unread_chat_count: 0 } : item
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
setBuyer((prev) => ({
|
||||
...prev,
|
||||
BuyerChatList: prev.BuyerChatList.map((item) =>
|
||||
item.id === chat.id ? { ...item, unread_chat_count: 0 } : item
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[60vh] max-h-[800px] flex flex-col lg:h-full">
|
||||
{isLargeScreen && (
|
||||
<div className="p-4 flex items-center gap-1 justify-between border-b">
|
||||
<h4 className="font-medium text-xl">{t("chat")}</h4>
|
||||
{/* Blocked Users Menu Component */}
|
||||
<BlockedUsersMenu setSelectedChatDetails={setSelectedChatDetails} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<CustomLink
|
||||
href={`/chat?activeTab=selling`}
|
||||
className={`py-4 flex-1 text-center border-b ${activeTab === "selling" ? "border-primary" : ""
|
||||
}`}
|
||||
scroll={false}
|
||||
>
|
||||
{t("selling")}
|
||||
</CustomLink>
|
||||
<CustomLink
|
||||
href={`/chat?activeTab=buying`}
|
||||
className={`py-4 flex-1 text-center border-b ${activeTab === "buying" ? "border-primary" : ""
|
||||
}`}
|
||||
scroll={false}
|
||||
>
|
||||
{t("buying")}
|
||||
</CustomLink>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto" id="chatList">
|
||||
<InfiniteScroll
|
||||
dataLength={
|
||||
activeTab === "buying"
|
||||
? buyer.BuyerChatList?.length
|
||||
: seller.SellerChatList?.length
|
||||
}
|
||||
next={() => {
|
||||
activeTab === "buying"
|
||||
? fetchBuyerChatList(buyer.CurrentBuyerPage + 1)
|
||||
: fetchSellerChatList(seller.CurrentSellerPage + 1);
|
||||
}}
|
||||
hasMore={
|
||||
activeTab === "buying" ? buyer.HasMoreBuyer : seller.HasMoreSeller
|
||||
}
|
||||
loader={Array.from({ length: 3 }, (_, index) => (
|
||||
<ChatListCardSkeleton key={index} />
|
||||
))}
|
||||
scrollableTarget="chatList"
|
||||
>
|
||||
{IsLoading
|
||||
? Array.from({ length: 8 }, (_, index) => (
|
||||
<ChatListCardSkeleton key={index} />
|
||||
))
|
||||
: (() => {
|
||||
const chatList =
|
||||
activeTab === "selling"
|
||||
? seller.SellerChatList
|
||||
: buyer.BuyerChatList;
|
||||
return chatList.length > 0 ? (
|
||||
chatList.map((chat, index) => (
|
||||
<ChatListCard
|
||||
key={Number(chat.id) || index}
|
||||
chat={chat}
|
||||
isActive={chat?.id === chatId}
|
||||
isSelling={activeTab === "selling"}
|
||||
handleChatTabClick={handleChatTabClick}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<NoChatListFound />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatList;
|
||||
62
components/PagesComponent/Chat/ChatListCard.jsx
Normal file
62
components/PagesComponent/Chat/ChatListCard.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { formatTime } from "@/utils";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const ChatListCard = ({ chat, isSelling, isActive, handleChatTabClick }) => {
|
||||
const user = isSelling ? chat?.buyer : chat?.seller;
|
||||
const isUnread = chat?.unread_chat_count > 0;
|
||||
|
||||
return (
|
||||
<CustomLink
|
||||
scroll={false}
|
||||
href={`/chat?activeTab=${isSelling ? "selling" : "buying"}&chatid=${
|
||||
chat?.id
|
||||
}`}
|
||||
onClick={() => handleChatTabClick(chat, isSelling)}
|
||||
className={`py-3 px-4 border-b flex items-center gap-4 cursor-pointer ${
|
||||
isActive ? "bg-primary text-white" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<CustomImage
|
||||
src={user?.profile}
|
||||
alt="User avatar"
|
||||
width={56}
|
||||
height={56}
|
||||
className="w-[56px] h-auto aspect-square object-cover rounded-full"
|
||||
/>
|
||||
|
||||
<CustomImage
|
||||
src={chat?.item?.image}
|
||||
alt="Item image"
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-[24px] h-auto aspect-square object-cover rounded-full absolute top-[32px] bottom-[-6px] right-[-6px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full min-w-0">
|
||||
<div className="w-full flex items-center gap-1 justify-between min-w-0">
|
||||
<h5 className="font-medium truncate" title={user?.name}>
|
||||
{user?.name}
|
||||
</h5>
|
||||
<span className="text-xs">{formatTime(chat?.last_message_time)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 justify-between">
|
||||
<p
|
||||
className="truncate text-sm"
|
||||
title={chat?.item?.translated_name || chat?.item?.name}
|
||||
>
|
||||
{chat?.item?.translated_name || chat?.item?.name}
|
||||
</p>
|
||||
{isUnread && !isActive && (
|
||||
<span className="flex items-center justify-center bg-primary text-white rounded-full px-2 py-1 text-xs">
|
||||
{chat?.unread_chat_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CustomLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatListCard;
|
||||
25
components/PagesComponent/Chat/ChatListCardSkeleton.jsx
Normal file
25
components/PagesComponent/Chat/ChatListCardSkeleton.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const ChatListCardSkeleton = () => {
|
||||
return (
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar skeleton */}
|
||||
<Skeleton className="w-12 h-12 rounded-full" />
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Name skeleton */}
|
||||
<Skeleton className="h-4 w-[40%] mb-2 rounded-md" />
|
||||
|
||||
{/* Message skeleton */}
|
||||
<Skeleton className="h-3 w-[70%] rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Time skeleton */}
|
||||
<Skeleton className="h-3 w-[15%] rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatListCardSkeleton
|
||||
343
components/PagesComponent/Chat/ChatMessages.jsx
Normal file
343
components/PagesComponent/Chat/ChatMessages.jsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||
import {
|
||||
formatChatMessageTime,
|
||||
formatMessageDate,
|
||||
formatPriceAbbreviated,
|
||||
t,
|
||||
} from "@/utils";
|
||||
import { getMessagesApi } from "@/utils/api";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Loader2, ChevronUp } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
const SendMessage = dynamic(() => import("./SendMessage"), { ssr: false });
|
||||
import GiveReview from "./GiveReview";
|
||||
import { getNotification } from "@/redux/reducer/globalStateSlice";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Skeleton component for chat messages
|
||||
const ChatMessagesSkeleton = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{/* Skeleton for date separator */}
|
||||
|
||||
{/* Received message skeletons */}
|
||||
<div className="flex flex-col gap-1 w-[65%] max-w-[80%]">
|
||||
<Skeleton className="h-16 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Sent message skeletons */}
|
||||
<div className="flex flex-col gap-1 w-[70%] max-w-[80%] self-end">
|
||||
<Skeleton className="h-10 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] self-end rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Image message skeleton */}
|
||||
<div className="flex flex-col gap-1 w-[50%] max-w-[80%]">
|
||||
<Skeleton className="h-32 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Audio message skeleton */}
|
||||
<div className="flex flex-col gap-1 w-[60%] max-w-[80%] self-end">
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] self-end rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Another message skeleton */}
|
||||
<div className="flex flex-col gap-1 w-[45%] max-w-[80%]">
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] rounded-md" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-[60%] max-w-[80%] self-end">
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] self-end rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Another message skeleton */}
|
||||
<div className="flex flex-col gap-1 w-[45%] max-w-[80%]">
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessageContent = (message, isCurrentUser) => {
|
||||
const baseTextClass = isCurrentUser
|
||||
? "text-white bg-primary p-2 rounded-md w-fit"
|
||||
: "text-black bg-border p-2 rounded-md w-fit";
|
||||
|
||||
const audioStyles = isCurrentUser ? "border-primary" : "border-border";
|
||||
|
||||
switch (message.message_type) {
|
||||
case "audio":
|
||||
return (
|
||||
<audio
|
||||
src={message.audio}
|
||||
controls
|
||||
className={`w-full sm:w-[70%] ${
|
||||
isCurrentUser ? "self-end" : "self-start"
|
||||
} rounded-md border-2 ${audioStyles}`}
|
||||
controlsList="nodownload"
|
||||
type="audio/mpeg"
|
||||
preload="metadata"
|
||||
/>
|
||||
);
|
||||
|
||||
case "file":
|
||||
return (
|
||||
<div className={`${baseTextClass}`}>
|
||||
<CustomImage
|
||||
src={message.file}
|
||||
alt="Chat Image"
|
||||
className="rounded-md w-auto h-auto max-h-[250px] max-w-[250px] object-contain"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "file_and_text":
|
||||
return (
|
||||
<div className={`${baseTextClass} flex flex-col gap-2`}>
|
||||
<CustomImage
|
||||
src={message.file}
|
||||
alt="Chat Image"
|
||||
className="rounded-md w-auto h-auto max-h-[250px] max-w-[250px] object-contain"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
<div className="border-white/20">{message.message}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<p
|
||||
className={`${baseTextClass} whitespace-pre-wrap ${
|
||||
isCurrentUser ? "self-end" : "self-start"
|
||||
}`}
|
||||
>
|
||||
{message?.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ChatMessages = ({
|
||||
selectedChatDetails,
|
||||
isSelling,
|
||||
setSelectedChatDetails,
|
||||
setBuyer,
|
||||
chatId,
|
||||
}) => {
|
||||
const notification = useSelector(getNotification);
|
||||
const [chatMessages, setChatMessages] = useState([]);
|
||||
const [currentMessagesPage, setCurrentMessagesPage] = useState(1);
|
||||
const [hasMoreChatMessages, setHasMoreChatMessages] = useState(false);
|
||||
const [isLoadPrevMesg, setIsLoadPrevMesg] = useState(false);
|
||||
const [IsLoading, setIsLoading] = useState(false);
|
||||
const [showReviewDialog, setShowReviewDialog] = useState(false);
|
||||
const lastMessageDate = useRef(null);
|
||||
const isAskForReview =
|
||||
!isSelling &&
|
||||
selectedChatDetails?.item?.status === "sold out" &&
|
||||
!selectedChatDetails?.item?.review &&
|
||||
Number(selectedChatDetails?.item?.sold_to) ===
|
||||
Number(selectedChatDetails?.buyer_id);
|
||||
|
||||
const user = useSelector(userSignUpData);
|
||||
const userId = user?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChatDetails?.id) {
|
||||
fetchChatMessgaes(1);
|
||||
}
|
||||
}, [selectedChatDetails?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
notification?.type === "chat" &&
|
||||
Number(notification?.item_offer_id) === Number(chatId) &&
|
||||
(notification?.user_type === "Seller" ? !isSelling : isSelling)
|
||||
) {
|
||||
const newMessage = {
|
||||
message_type: notification?.message_type_temp,
|
||||
message: notification?.message,
|
||||
sender_id: Number(notification?.sender_id),
|
||||
created_at: notification?.created_at,
|
||||
audio: notification?.audio,
|
||||
file: notification?.file,
|
||||
id: Number(notification?.id),
|
||||
item_offer_id: Number(notification?.item_offer_id),
|
||||
updated_at: notification?.updated_at,
|
||||
};
|
||||
|
||||
setChatMessages((prev) => [...prev, newMessage]);
|
||||
}
|
||||
}, [notification]);
|
||||
|
||||
const fetchChatMessgaes = async (page) => {
|
||||
try {
|
||||
page > 1 ? setIsLoadPrevMesg(true) : setIsLoading(true);
|
||||
const response = await getMessagesApi.chatMessages({
|
||||
item_offer_id: selectedChatDetails?.id,
|
||||
page,
|
||||
});
|
||||
if (response?.data?.error === false) {
|
||||
const currentPage = Number(response?.data?.data?.current_page);
|
||||
const lastPage = Number(response?.data?.data?.last_page);
|
||||
const hasMoreChatMessages = currentPage < lastPage;
|
||||
const chatMessages = (response?.data?.data?.data).reverse();
|
||||
setCurrentMessagesPage(currentPage);
|
||||
setHasMoreChatMessages(hasMoreChatMessages);
|
||||
page > 1
|
||||
? setChatMessages((prev) => [...chatMessages, ...prev])
|
||||
: setChatMessages(chatMessages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoadPrevMesg(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto bg-muted p-4 flex flex-col gap-2.5 relative">
|
||||
{IsLoading ? (
|
||||
<ChatMessagesSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{/* Show review dialog if open */}
|
||||
{showReviewDialog && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/20 p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<GiveReview
|
||||
itemId={selectedChatDetails?.item_id}
|
||||
sellerId={selectedChatDetails?.seller_id}
|
||||
onClose={() => setShowReviewDialog(false)}
|
||||
onSuccess={handleReviewSuccess}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* button to load previous messages */}
|
||||
{hasMoreChatMessages && !IsLoading && (
|
||||
<div className="absolute top-3 left-0 right-0 z-10 flex justify-center pb-2">
|
||||
<button
|
||||
onClick={() => fetchChatMessgaes(currentMessagesPage + 1)}
|
||||
disabled={isLoadPrevMesg}
|
||||
className="text-primary text-sm font-medium px-3 py-1.5 bg-white/90 rounded-full shadow-md hover:bg-white flex items-center gap-1.5"
|
||||
>
|
||||
{isLoadPrevMesg ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t("loading")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
{t("loadPreviousMessages")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* offer price */}
|
||||
{!hasMoreChatMessages &&
|
||||
selectedChatDetails?.amount > 0 &&
|
||||
(() => {
|
||||
const isSeller = isSelling;
|
||||
const containerClasses = `flex flex-col gap-1 rounded-md p-2 w-fit ${
|
||||
isSeller ? "bg-border" : "bg-primary text-white self-end"
|
||||
}`;
|
||||
const label = isSeller ? t("offer") : t("yourOffer");
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<p className="text-sm">{label}</p>
|
||||
<span className="text-xl font-medium">
|
||||
{selectedChatDetails.formatted_amount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* chat messages */}
|
||||
{chatMessages &&
|
||||
chatMessages.length > 0 &&
|
||||
chatMessages.map((message) => {
|
||||
const messageDate = formatMessageDate(message.created_at);
|
||||
const showDateSeparator =
|
||||
messageDate !== lastMessageDate.current;
|
||||
if (showDateSeparator) {
|
||||
lastMessageDate.current = messageDate;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={message?.id}>
|
||||
{showDateSeparator && (
|
||||
<p className="text-xs bg-[#f1f1f1] py-1 px-2 rounded-lg text-muted-foreground my-5 mx-auto">
|
||||
{messageDate}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{message.sender_id === userId ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 max-w-[80%] self-end",
|
||||
message.message_type === "audio" && "w-full"
|
||||
)}
|
||||
key={message?.id}
|
||||
>
|
||||
{renderMessageContent(message, true)}
|
||||
<p className="text-xs text-muted-foreground ltr:text-right rtl:text-left">
|
||||
{formatChatMessageTime(message?.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 max-w-[80%]",
|
||||
message.message_type === "audio" && "w-full"
|
||||
)}
|
||||
key={message?.id}
|
||||
>
|
||||
{renderMessageContent(message, false)}
|
||||
<p className="text-xs text-muted-foreground ltr:text-left rtl:text-right">
|
||||
{formatChatMessageTime(message?.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isAskForReview && (
|
||||
<GiveReview
|
||||
key={`review-${selectedChatDetails?.id}`}
|
||||
itemId={selectedChatDetails?.item_id}
|
||||
setSelectedChatDetails={setSelectedChatDetails}
|
||||
setBuyer={setBuyer}
|
||||
/>
|
||||
)}
|
||||
<SendMessage
|
||||
key={`send-${selectedChatDetails?.id}`}
|
||||
selectedChatDetails={selectedChatDetails}
|
||||
setChatMessages={setChatMessages}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessages;
|
||||
173
components/PagesComponent/Chat/GiveReview.jsx
Normal file
173
components/PagesComponent/Chat/GiveReview.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FaStar } from "react-icons/fa";
|
||||
import { t } from "@/utils";
|
||||
import { addItemReviewApi } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const GiveReview = ({ itemId, setSelectedChatDetails, setBuyer }) => {
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
const [review, setReview] = useState("");
|
||||
const [errors, setErrors] = useState({
|
||||
rating: "",
|
||||
review: "",
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleRatingClick = (selectedRating) => {
|
||||
setRating(selectedRating);
|
||||
setErrors((prev) => ({ ...prev, rating: "" }));
|
||||
};
|
||||
|
||||
const handleMouseEnter = (starValue) => {
|
||||
setHoveredRating(starValue);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredRating(0);
|
||||
};
|
||||
|
||||
const handleReviewChange = (e) => {
|
||||
setReview(e.target.value);
|
||||
setErrors((prev) => ({ ...prev, review: "" }));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {
|
||||
rating: "",
|
||||
review: "",
|
||||
};
|
||||
let isValid = true;
|
||||
|
||||
if (rating === 0) {
|
||||
newErrors.rating = t("pleaseSelectRating");
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!review.trim()) {
|
||||
newErrors.review = t("pleaseWriteReview");
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const res = await addItemReviewApi.addItemReview({
|
||||
item_id: itemId,
|
||||
review,
|
||||
ratings: rating,
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
toast.success(res?.data?.message);
|
||||
setSelectedChatDetails((prev) => ({
|
||||
...prev,
|
||||
item: {
|
||||
...prev.item,
|
||||
review: res?.data?.data,
|
||||
},
|
||||
}));
|
||||
setBuyer((prev) => ({
|
||||
...prev,
|
||||
BuyerChatList: prev.BuyerChatList.map((chatItem) =>
|
||||
chatItem?.item?.id === Number(res?.data?.data?.item_id)
|
||||
? {
|
||||
...chatItem,
|
||||
item: {
|
||||
...chatItem.item,
|
||||
review: res?.data?.data?.review, // use review from API
|
||||
},
|
||||
}
|
||||
: chatItem
|
||||
),
|
||||
}));
|
||||
setRating(0);
|
||||
setReview("");
|
||||
setErrors({
|
||||
rating: "",
|
||||
review: "",
|
||||
});
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error(t("somethingWentWrong"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-muted p-4">
|
||||
<div className="rounded-lg p-4 bg-white">
|
||||
<div className="mb-5">
|
||||
<h3 className="text-base font-medium mb-2">{t("rateSeller")}</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">{t("rateYourExp")}</p>
|
||||
|
||||
<div className="flex gap-2 mb-2">
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<button
|
||||
key={starValue}
|
||||
type="button"
|
||||
className="p-1 focus:outline-none"
|
||||
onClick={() => handleRatingClick(starValue)}
|
||||
onMouseEnter={() => handleMouseEnter(starValue)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
aria-label={`Rate ${starValue} stars out of 5`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<FaStar
|
||||
className={`text-3xl ${
|
||||
(hoveredRating || rating) >= starValue
|
||||
? "text-yellow-400"
|
||||
: "text-gray-200"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{errors.rating && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.rating}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Textarea
|
||||
placeholder={t("writeAReview")}
|
||||
value={review}
|
||||
onChange={handleReviewChange}
|
||||
className={`min-h-[100px] resize-none border-gray-200 rounded ${
|
||||
errors.review ? "border-red-500" : ""
|
||||
}`}
|
||||
/>
|
||||
{errors.review && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.review}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="bg-primary text-white px-6"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GiveReview;
|
||||
21
components/PagesComponent/Chat/NoChatFound.jsx
Normal file
21
components/PagesComponent/Chat/NoChatFound.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { t } from "@/utils";
|
||||
import { MdArrowBack } from "react-icons/md";
|
||||
|
||||
const NoChatFound = ({ handleBack, isLargeScreen }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-center items-center justify-center">
|
||||
<h5 className="text-primary text-2xl font-medium">{t("noChatFound")}</h5>
|
||||
<p>{t("startConversation")}</p>
|
||||
|
||||
{!isLargeScreen && (
|
||||
<Button className="w-fit" onClick={handleBack}>
|
||||
<MdArrowBack size={20} className="rtl:scale-x-[-1]" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoChatFound;
|
||||
23
components/PagesComponent/Chat/NoChatListFound.jsx
Normal file
23
components/PagesComponent/Chat/NoChatListFound.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { t } from "@/utils";
|
||||
import noChatListFound from "../../../public/assets/no_data_found_illustrator.svg";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const NoChatListFound = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<CustomImage
|
||||
src={noChatListFound}
|
||||
alt="no chat list found"
|
||||
width={200}
|
||||
height={200}
|
||||
className="w-[200px] h-auto aspect-square"
|
||||
/>
|
||||
<h3 className="font-medium text-2xl text-primary text-center">
|
||||
{t("noConversationsFound")}
|
||||
</h3>
|
||||
<span className="text-sm text-center">{t("noChatsAvailable")}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoChatListFound;
|
||||
137
components/PagesComponent/Chat/SelectedChatHeader.jsx
Normal file
137
components/PagesComponent/Chat/SelectedChatHeader.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { HiOutlineDotsVertical } from "react-icons/hi";
|
||||
import { t } from "@/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { blockUserApi, unBlockUserApi } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import { MdArrowBack } from "react-icons/md";
|
||||
|
||||
const SelectedChatHeader = ({
|
||||
selectedChat,
|
||||
isSelling,
|
||||
setSelectedChat,
|
||||
handleBack,
|
||||
isLargeScreen,
|
||||
}) => {
|
||||
const isBlocked = selectedChat?.user_blocked;
|
||||
const userData = isSelling ? selectedChat?.buyer : selectedChat?.seller;
|
||||
const itemData = selectedChat?.item;
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
|
||||
const handleBlockUser = async (id) => {
|
||||
try {
|
||||
const response = await blockUserApi.blockUser({
|
||||
blocked_user_id: userData?.id,
|
||||
});
|
||||
|
||||
if (response?.data?.error === false) {
|
||||
setSelectedChat((prevData) => ({
|
||||
...prevData,
|
||||
user_blocked: true,
|
||||
}));
|
||||
toast.success(response?.data?.message);
|
||||
} else {
|
||||
toast.error(response?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnBlockUser = async (id) => {
|
||||
try {
|
||||
const response = await unBlockUserApi.unBlockUser({
|
||||
blocked_user_id: userData?.id,
|
||||
});
|
||||
if (response?.data.error === false) {
|
||||
setSelectedChat((prevData) => ({
|
||||
...prevData,
|
||||
user_blocked: false,
|
||||
}));
|
||||
toast.success(response?.data?.message);
|
||||
} else {
|
||||
toast.error(response?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1 px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{!isLargeScreen && (
|
||||
<button onClick={handleBack}>
|
||||
<MdArrowBack size={20} className="rtl:scale-x-[-1]" />
|
||||
</button>
|
||||
)}
|
||||
<div className="relative flex-shrink-0">
|
||||
<CustomLink href={`/seller/${userData?.id}`}>
|
||||
<CustomImage
|
||||
src={userData?.profile}
|
||||
alt="avatar"
|
||||
width={56}
|
||||
height={56}
|
||||
className="w-[56px] h-auto aspect-square object-cover rounded-full"
|
||||
/>
|
||||
</CustomLink>
|
||||
<CustomImage
|
||||
src={userData?.profile}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-[24px] h-auto aspect-square object-cover rounded-full absolute top-[32px] bottom-[-6px] right-[-6px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full min-w-0">
|
||||
<CustomLink
|
||||
href={`/seller/${userData?.id}`}
|
||||
className="font-medium truncate"
|
||||
title={userData?.name}
|
||||
>
|
||||
{userData?.name}
|
||||
</CustomLink>
|
||||
<p
|
||||
className="truncate text-sm"
|
||||
title={itemData?.translated_name || itemData?.name}
|
||||
>
|
||||
{itemData?.translated_name || itemData?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown Menu for Actions */}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="self-end">
|
||||
<HiOutlineDotsVertical size={22} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={isRTL ? "start" : "end"}>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={isBlocked ? handleUnBlockUser : handleBlockUser}
|
||||
>
|
||||
<span>{isBlocked ? t("unblock") : t("block")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="text-xs whitespace-nowrap">
|
||||
{itemData?.formatted_price}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectedChatHeader;
|
||||
256
components/PagesComponent/Chat/SendMessage.jsx
Normal file
256
components/PagesComponent/Chat/SendMessage.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
import { sendMessageApi } from "@/utils/api";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { IoMdAttach, IoMdSend } from "react-icons/io";
|
||||
import { FaMicrophone, FaRegStopCircle } from "react-icons/fa";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { useReactMediaRecorder } from "react-media-recorder";
|
||||
import { toast } from "sonner";
|
||||
import { t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const SendMessage = ({ selectedChatDetails, setChatMessages }) => {
|
||||
const isAllowToChat =
|
||||
selectedChatDetails?.item?.status === "approved" ||
|
||||
selectedChatDetails?.item?.status === "featured";
|
||||
|
||||
if (!isAllowToChat) {
|
||||
return (
|
||||
<div className="p-4 border-t text-center text-muted-foreground">
|
||||
{t("thisAd")} {selectedChatDetails?.item?.status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const id = selectedChatDetails?.id;
|
||||
const [message, setMessage] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Voice recording setup
|
||||
const { status, startRecording, stopRecording, mediaBlobUrl, error } =
|
||||
useReactMediaRecorder({
|
||||
audio: true,
|
||||
blobPropertyBag: { type: "audio/mpeg" },
|
||||
});
|
||||
|
||||
const isRecording = status === "recording";
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
|
||||
// Format recording duration as mm:ss
|
||||
const formatDuration = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// Timer for recording
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (isRecording) {
|
||||
timer = setInterval(() => {
|
||||
setRecordingDuration((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
setRecordingDuration(0);
|
||||
}
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
stopRecording();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle recorded audio
|
||||
useEffect(() => {
|
||||
if (mediaBlobUrl && status === "stopped") {
|
||||
handleRecordedAudio();
|
||||
}
|
||||
}, [mediaBlobUrl, status]);
|
||||
|
||||
const handleRecordedAudio = async () => {
|
||||
try {
|
||||
const response = await fetch(mediaBlobUrl);
|
||||
const blob = await response.blob();
|
||||
const audioFile = new File([blob], "recording.mp3", {
|
||||
type: "audio/mpeg",
|
||||
});
|
||||
sendMessage(audioFile);
|
||||
} catch (err) {
|
||||
console.error("Error processing audio:", err);
|
||||
toast.error("Failed to process recording");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Check if file is an image
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/jpg"];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error("Only image files (JPEG, PNG, JPG) are allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL for image
|
||||
const fileUrl = URL.createObjectURL(file);
|
||||
setPreviewUrl(fileUrl);
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const removeSelectedFile = () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl("");
|
||||
// Reset file input value to allow selecting the same file again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async (audioFile = null) => {
|
||||
if ((!message.trim() && !selectedFile && !audioFile) || isSending) return;
|
||||
|
||||
const params = {
|
||||
item_offer_id: id,
|
||||
message: message ? message : "",
|
||||
file: selectedFile ? selectedFile : "",
|
||||
audio: audioFile ? audioFile : "",
|
||||
};
|
||||
|
||||
try {
|
||||
setIsSending(true);
|
||||
const response = await sendMessageApi.sendMessage(params);
|
||||
|
||||
if (!response?.data?.error) {
|
||||
setChatMessages((prev) => [...prev, response.data.data]);
|
||||
setMessage("");
|
||||
removeSelectedFile();
|
||||
} else {
|
||||
toast.error(response?.data?.message || "Failed to send message");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Error sending message");
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoiceButtonClick = () => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
startRecording();
|
||||
if (error) {
|
||||
console.log(error);
|
||||
switch (error) {
|
||||
case "permission_denied":
|
||||
toast.error(t("microphoneAccessDenied"));
|
||||
break;
|
||||
case "no_specified_media_found":
|
||||
toast.error(t("noMicrophoneFound"));
|
||||
break;
|
||||
default:
|
||||
toast.error(t("somethingWentWrong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* File Preview */}
|
||||
{previewUrl && (
|
||||
<div className="px-4 pt-2 pb-1">
|
||||
<div className="relative w-32 h-32 border rounded-md overflow-hidden group">
|
||||
<CustomImage
|
||||
src={previewUrl}
|
||||
alt="File preview"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
<button
|
||||
onClick={removeSelectedFile}
|
||||
className="absolute top-1 right-1 bg-black/70 text-white p-1 rounded-full opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 border-t flex items-center gap-2">
|
||||
{!isRecording && (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/jpeg,image/png,image/jpg"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
aria-label="Attach file"
|
||||
>
|
||||
<IoMdAttach size={20} className="text-muted-foreground" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isRecording ? (
|
||||
<div className="flex-1 py-2 px-3 bg-red-50 text-red-500 rounded-md flex items-center justify-center font-medium">
|
||||
{t("recording")} {formatDuration(recordingDuration)}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
type="text"
|
||||
placeholder="Message..."
|
||||
className="flex-1 outline-none border px-3 py-1 rounded-md"
|
||||
value={message}
|
||||
rows={2}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="p-2 bg-primary text-white rounded-md"
|
||||
disabled={isSending}
|
||||
onClick={
|
||||
message.trim() || selectedFile
|
||||
? () => sendMessage()
|
||||
: handleVoiceButtonClick
|
||||
}
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : message.trim() || selectedFile ? (
|
||||
<IoMdSend size={20} className="rtl:scale-x-[-1]" />
|
||||
) : isRecording ? (
|
||||
<FaRegStopCircle size={20} />
|
||||
) : (
|
||||
<FaMicrophone size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendMessage;
|
||||
380
components/PagesComponent/Contact/ContactUs.jsx
Normal file
380
components/PagesComponent/Contact/ContactUs.jsx
Normal file
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { settingsData } from "@/redux/reducer/settingSlice";
|
||||
import { t } from "@/utils";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
FaInstagram,
|
||||
FaFacebook,
|
||||
FaLinkedin,
|
||||
FaPinterest,
|
||||
} from "react-icons/fa";
|
||||
import { GrLocation } from "react-icons/gr";
|
||||
import { RiMailSendLine } from "react-icons/ri";
|
||||
import { useSelector } from "react-redux";
|
||||
import { TbPhoneCall } from "react-icons/tb";
|
||||
import { FaSquareXTwitter } from "react-icons/fa6";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import { contactUsApi } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
|
||||
import parse from "html-react-parser";
|
||||
import Link from "next/link";
|
||||
|
||||
const ContactUs = () => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const settings = useSelector(settingsData);
|
||||
const [IsLoading, setIsLoading] = useState(false);
|
||||
const contactUs = settings?.contact_us;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const validateEmail = (email) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
});
|
||||
// Clear error when user starts typing
|
||||
setErrors({
|
||||
...errors,
|
||||
[name]: "",
|
||||
});
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
let isValid = true;
|
||||
const newErrors = {};
|
||||
|
||||
// Name validation
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = t("nameRequired");
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = t("emailRequired");
|
||||
isValid = false;
|
||||
} else if (!validateEmail(formData.email)) {
|
||||
newErrors.email = t("invalidEmail");
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Subject validation
|
||||
if (!formData.subject.trim()) {
|
||||
newErrors.subject = t("subjectRequired");
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Message validation
|
||||
if (!formData.message.trim()) {
|
||||
newErrors.message = t("messageRequired");
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (validateForm()) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await contactUsApi.contactUs(formData);
|
||||
if (res?.data?.error === false) {
|
||||
toast.success(t("thankForContacting"));
|
||||
setFormData({
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
});
|
||||
} else {
|
||||
toast.error(t("errorOccurred"));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("errorOccurred"));
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<BreadCrumb title2={t("contactUs")} />
|
||||
<div className="container">
|
||||
<h1 className="sectionTitle mt-8">{t("contactUs")}</h1>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 mt-6 border rounded-lg">
|
||||
{/* Contact Form */}
|
||||
<div className="lg:col-span-2 p-4 sm:p-6 rounded-lg">
|
||||
<h2 className="text-lg sm:text-xl font-medium mb-2">
|
||||
{t("sendMessage")}
|
||||
</h2>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-6">
|
||||
{t("contactIntro")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="labelInputCont">
|
||||
<Label htmlFor="name" className="requiredInputLabel">
|
||||
{t("name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder={t("enterName")}
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className={
|
||||
errors.name
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{errors.name && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="labelInputCont">
|
||||
<Label htmlFor="email" className="requiredInputLabel">
|
||||
{t("email")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder={t("enterEmail")}
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className={
|
||||
errors.email
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{errors.email && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="labelInputCont">
|
||||
<Label htmlFor="subject" className="requiredInputLabel">
|
||||
{t("subject")}
|
||||
</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
name="subject"
|
||||
type="text"
|
||||
placeholder={t("enterSubject")}
|
||||
value={formData.subject}
|
||||
onChange={handleChange}
|
||||
className={
|
||||
errors.subject
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{errors.subject && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.subject}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="labelInputCont">
|
||||
<Label htmlFor="message" className="requiredInputLabel">
|
||||
{t("message")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
placeholder={t("enterMessage")}
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
className={
|
||||
errors.message
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{errors.message && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={IsLoading}>
|
||||
{IsLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
{t("submitting")}
|
||||
</>
|
||||
) : (
|
||||
t("submit")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="bg-[#1a1a1a] text-white p-4 sm:p-6 rounded-lg">
|
||||
<h2 className="text-lg sm:text-xl font-medium mb-6">
|
||||
{t("contactInfo")}
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="max-w-full prose lg:prose-lg prose-invert">
|
||||
{parse(contactUs || "")}
|
||||
</div>
|
||||
|
||||
{settings?.company_address && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="footerSocialLinks">
|
||||
<GrLocation size={24} />
|
||||
</div>
|
||||
<p className="text-sm text-white/65 hover:text-primary">
|
||||
{settings?.company_address}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings?.company_email && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="footerSocialLinks">
|
||||
<RiMailSendLine size={24} />
|
||||
</div>
|
||||
<Link
|
||||
href={`mailto:${settings?.company_email}`}
|
||||
className="text-sm text-white/65 hover:text-primary"
|
||||
>
|
||||
{settings?.company_email}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings?.company_tel1 && settings?.company_tel2 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="footerSocialLinks">
|
||||
<TbPhoneCall size={24} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href={`tel:${settings?.company_tel1}`}
|
||||
className="text-sm text-white/65 hover:text-primary"
|
||||
>
|
||||
{settings?.company_tel1}
|
||||
</Link>
|
||||
<Link
|
||||
href={`tel:${settings?.company_tel2}`}
|
||||
className="text-sm text-white/65 hover:text-primary"
|
||||
>
|
||||
{settings?.company_tel2}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg sm:text-xl font-medium mb-6">
|
||||
{t("socialMedia")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{settings?.facebook_link && (
|
||||
<Link
|
||||
href={settings?.facebook_link}
|
||||
className="footerSocialLinks"
|
||||
>
|
||||
<FaFacebook size={24} />
|
||||
</Link>
|
||||
)}
|
||||
{settings?.instagram_link && (
|
||||
<Link
|
||||
href={settings?.instagram_link}
|
||||
className="footerSocialLinks"
|
||||
>
|
||||
<FaInstagram size={22} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{settings?.x_link && (
|
||||
<Link
|
||||
href={settings?.x_link}
|
||||
className="footerSocialLinks"
|
||||
>
|
||||
<FaSquareXTwitter size={22} />
|
||||
</Link>
|
||||
)}
|
||||
{settings?.linkedin_link && (
|
||||
<Link
|
||||
href={settings?.linkedin_link}
|
||||
className="footerSocialLinks"
|
||||
>
|
||||
<FaLinkedin size={24} />
|
||||
</Link>
|
||||
)}
|
||||
{settings?.pinterest_link && (
|
||||
<Link
|
||||
href={settings?.pinterest_link}
|
||||
className="footerSocialLinks"
|
||||
>
|
||||
<FaPinterest size={24} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings?.google_map_iframe_link && (
|
||||
<iframe
|
||||
src={settings?.google_map_iframe_link}
|
||||
width="100%"
|
||||
height="200"
|
||||
className="aspect-[432/189] w-full rounded mt-6"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactUs;
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import trueGif from "../../../public/assets/true.gif";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const AdEditSuccessModal = ({
|
||||
openSuccessModal,
|
||||
setOpenSuccessModal,
|
||||
createdAdSlug,
|
||||
}) => {
|
||||
const closeSuccessModal = () => {
|
||||
setOpenSuccessModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={openSuccessModal} onOpenChange={closeSuccessModal}>
|
||||
<DialogContent
|
||||
className="[&>button]:hidden lgmax-w-[100px]"
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<CustomImage
|
||||
src={trueGif}
|
||||
alt="success"
|
||||
height={176}
|
||||
width={176}
|
||||
className="h-44 w-44"
|
||||
/>
|
||||
<h2 className="text-3xl font-semibold">{t("adEditedSuccess")}</h2>
|
||||
</div>
|
||||
<CustomLink
|
||||
href={`/my-listing/${createdAdSlug}`}
|
||||
className="py-3 px-6 bg-primary text-white rounded-md"
|
||||
>
|
||||
{t("viewAd")}
|
||||
</CustomLink>
|
||||
<CustomLink href="/" className="">
|
||||
{t("backToHome")}
|
||||
</CustomLink>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdEditSuccessModal;
|
||||
177
components/PagesComponent/EditListing/EditComponentFour.jsx
Normal file
177
components/PagesComponent/EditListing/EditComponentFour.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState } from "react";
|
||||
import { BiMapPin } from "react-icons/bi";
|
||||
import { FaLocationCrosshairs } from "react-icons/fa6";
|
||||
import { IoLocationOutline } from "react-icons/io5";
|
||||
import ManualAddress from "../AdsListing/ManualAddress";
|
||||
import { t } from "@/utils";
|
||||
import { getIsPaidApi } from "@/redux/reducer/settingSlice";
|
||||
import { useSelector } from "react-redux";
|
||||
import dynamic from "next/dynamic";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import LandingAdEditSearchAutocomplete from "@/components/Location/LandingAdEditSearchAutocomplete";
|
||||
import { getIsBrowserSupported } from "@/redux/reducer/locationSlice";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import useGetLocation from "@/components/Layout/useGetLocation";
|
||||
|
||||
const MapComponent = dynamic(() => import("@/components/Common/MapComponent"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="w-full h-[400px] bg-gray-100 rounded-lg" />,
|
||||
});
|
||||
|
||||
const EditComponentFour = ({
|
||||
location,
|
||||
setLocation,
|
||||
handleFullSubmission,
|
||||
isAdPlaced,
|
||||
handleGoBack,
|
||||
}) => {
|
||||
const isBrowserSupported = useSelector(getIsBrowserSupported);
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const [showManualAddress, setShowManualAddress] = useState();
|
||||
const [IsGettingCurrentLocation, setIsGettingCurrentLocation] =
|
||||
useState(false);
|
||||
const IsPaidApi = useSelector(getIsPaidApi);
|
||||
const { fetchLocationData } = useGetLocation();
|
||||
|
||||
const getCurrentLocation = async () => {
|
||||
if (navigator.geolocation) {
|
||||
setIsGettingCurrentLocation(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
try {
|
||||
const { latitude, longitude } = position.coords;
|
||||
const data = await fetchLocationData({
|
||||
lat: latitude,
|
||||
lng: longitude,
|
||||
});
|
||||
setLocation(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching location data:", error);
|
||||
toast.error(t("errorOccurred"));
|
||||
} finally {
|
||||
setIsGettingCurrentLocation(false);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
toast.error(t("locationNotGranted"));
|
||||
setIsGettingCurrentLocation(false);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.error(t("geoLocationNotSupported"));
|
||||
}
|
||||
};
|
||||
|
||||
const getLocationWithMap = async (pos) => {
|
||||
try {
|
||||
const data = await fetchLocationData(pos);
|
||||
setLocation(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching location data:", error);
|
||||
toast.error(t("errorOccurred"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3">
|
||||
<h5 className="text-xl font-medium">{t("addLocation")}</h5>
|
||||
<div className="flex items-center gap-2 border rounded-md w-full md:w-96 min-h-[42px]">
|
||||
<LandingAdEditSearchAutocomplete
|
||||
saveOnSuggestionClick={false}
|
||||
setSelectedLocation={setLocation}
|
||||
/>
|
||||
{isBrowserSupported && (
|
||||
<button
|
||||
onClick={getCurrentLocation}
|
||||
disabled={IsGettingCurrentLocation}
|
||||
className="bg-primary p-2 text-white gap-2 flex items-center rounded-md h-10"
|
||||
>
|
||||
<span>
|
||||
{IsGettingCurrentLocation ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<FaLocationCrosshairs size={16} />
|
||||
)}
|
||||
</span>
|
||||
<span className="whitespace-nowrap hidden md:inline">
|
||||
{IsGettingCurrentLocation ? t("loading") : t("locateMe")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8 flex-col">
|
||||
<MapComponent
|
||||
location={location}
|
||||
getLocationWithMap={getLocationWithMap}
|
||||
/>
|
||||
<div className="flex items-center gap-3 bg-muted rounded-lg p-4 ">
|
||||
<div className="p-5 rounded-md bg-white">
|
||||
<BiMapPin className="text-primary" size={32} />
|
||||
</div>
|
||||
<span className="flex flex-col gap-1">
|
||||
<h6 className="font-medium">{t("address")}</h6>
|
||||
{location?.address_translated || location?.formattedAddress ? (
|
||||
<p>
|
||||
{location?.address_translated || location?.formattedAddress}
|
||||
</p>
|
||||
) : (
|
||||
t("addYourAddress")
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!IsPaidApi && (
|
||||
<>
|
||||
<div className="relative flex items-center justify-center ">
|
||||
<div className="absolute top-1/2 left-0 right-0 h-px bg-[#d3d3d3]"></div>
|
||||
<div className="relative bg-muted text-black text-base font-medium rounded-full w-12 h-12 flex items-center justify-center uppercase">
|
||||
{t("or")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 items-center justify-center ">
|
||||
<p className="text-xl font-semibold">
|
||||
{t("whatLocAdYouSelling")}
|
||||
</p>
|
||||
<button
|
||||
className="p-2 flex items-center gap-2 border rounded-md font-medium"
|
||||
onClick={() => setShowManualAddress(true)}
|
||||
>
|
||||
<IoLocationOutline size={20} />
|
||||
{t("addLocation")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
<button
|
||||
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light0 disabled:bg-gray-500"
|
||||
disabled={isAdPlaced}
|
||||
onClick={handleFullSubmission}
|
||||
>
|
||||
{isAdPlaced ? t("posting") : t("postNow")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ManualAddress
|
||||
key={showManualAddress}
|
||||
showManualAddress={showManualAddress}
|
||||
setShowManualAddress={setShowManualAddress}
|
||||
setLocation={setLocation}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditComponentFour;
|
||||
289
components/PagesComponent/EditListing/EditComponentOne.jsx
Normal file
289
components/PagesComponent/EditListing/EditComponentOne.jsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
import {
|
||||
getCurrencyIsoCode,
|
||||
getCurrencyPosition,
|
||||
getCurrencySymbol,
|
||||
} from "@/redux/reducer/settingSlice";
|
||||
import { generateSlug, t } from "@/utils";
|
||||
import PhoneInput from "react-phone-input-2";
|
||||
import { useSelector } from "react-redux";
|
||||
const EditComponentOne = ({
|
||||
setTranslations,
|
||||
current,
|
||||
langId,
|
||||
defaultLangId,
|
||||
handleDetailsSubmit,
|
||||
is_job_category,
|
||||
isPriceOptional,
|
||||
currencies,
|
||||
}) => {
|
||||
const currencyPosition = useSelector(getCurrencyPosition);
|
||||
const currencySymbol = useSelector(getCurrencySymbol);
|
||||
const currencyIsoCode = useSelector(getCurrencyIsoCode);
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
const selectedCurrency = currencies?.find(
|
||||
(curr) => curr?.id === current?.currency_id
|
||||
);
|
||||
|
||||
// Use selected currency's symbol and position, or fallback to Redux settings
|
||||
const displaySymbol = selectedCurrency?.symbol || currencySymbol;
|
||||
const displayPosition = selectedCurrency?.position || currencyPosition;
|
||||
|
||||
const placeholderLabel =
|
||||
displayPosition === "right" ? `00 ${displaySymbol}` : `${displaySymbol} 00`;
|
||||
|
||||
const handleField = (field) => (e) => {
|
||||
const value = e.target.value;
|
||||
setTranslations((prev) => {
|
||||
const updatedLangData = {
|
||||
...prev[langId],
|
||||
[field]: value,
|
||||
};
|
||||
|
||||
// ✅ Only auto-generate slug if default language and field is title
|
||||
if (field === "name" && langId === defaultLangId) {
|
||||
updatedLangData.slug = generateSlug(value);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[langId]: updatedLangData,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handlePhoneChange = (value, data) => {
|
||||
const dial = data?.dialCode || ""; // Dial code like "91", "1"
|
||||
const iso2 = data?.countryCode || ""; // Region code like "in", "us", "ae"
|
||||
setTranslations((prev) => {
|
||||
const pureMobile = value.startsWith(dial)
|
||||
? value.slice(dial.length)
|
||||
: value;
|
||||
return {
|
||||
...prev,
|
||||
[langId]: {
|
||||
...prev[langId],
|
||||
contact: pureMobile,
|
||||
country_code: dial,
|
||||
region_code: iso2,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleCurrencyChange = (currencyId) => {
|
||||
setTranslations((prev) => ({
|
||||
...prev,
|
||||
[langId]: {
|
||||
...prev[langId],
|
||||
currency_id: Number(currencyId),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="title"
|
||||
className={langId === defaultLangId ? "requiredInputLabel" : ""}
|
||||
>
|
||||
{t("title")}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="title"
|
||||
id="title"
|
||||
placeholder={t("enterTitle")}
|
||||
value={current.name || ""}
|
||||
onChange={handleField("name")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="description"
|
||||
className={langId === defaultLangId ? "requiredInputLabel" : ""}
|
||||
>
|
||||
{t("description")}
|
||||
</Label>
|
||||
<Textarea
|
||||
name="description"
|
||||
id="description"
|
||||
placeholder={t("enterDescription")}
|
||||
value={current.description || ""}
|
||||
onChange={handleField("description")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="currency">{t("currency")}</Label>
|
||||
{
|
||||
currencies?.length > 0 ?
|
||||
<Select
|
||||
value={current?.currency_id?.toString()}
|
||||
onValueChange={handleCurrencyChange}
|
||||
dir={isRTL ? "rtl" : "ltr"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("currency")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{/* <SelectLabel>Currencies</SelectLabel> */}
|
||||
{currencies?.map((currency) => (
|
||||
<SelectItem
|
||||
key={currency.id}
|
||||
value={currency.id.toString()}
|
||||
dir={isRTL ? "rtl" : "ltr"}
|
||||
>
|
||||
{currency.iso_code} ({currency.symbol})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
:
|
||||
<Select
|
||||
value={currencyIsoCode} // ✅ same default value you already have
|
||||
disabled
|
||||
dir={isRTL ? "rtl" : "ltr"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
{/* Required for RTL */}
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={currencyIsoCode}>
|
||||
{currencyIsoCode} ({currencySymbol})
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
</div>
|
||||
{langId === defaultLangId && (
|
||||
<>
|
||||
{is_job_category ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="min_salary">{t("salaryMin")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
name="min_salary"
|
||||
id="min_salary"
|
||||
placeholder={placeholderLabel}
|
||||
value={current.min_salary || ""}
|
||||
onChange={handleField("min_salary")}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="max_salary">{t("salaryMax")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
name="max_salary"
|
||||
id="max_salary"
|
||||
placeholder={placeholderLabel}
|
||||
value={current.max_salary || ""}
|
||||
onChange={handleField("max_salary")}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="price"
|
||||
className={
|
||||
!isPriceOptional && langId === defaultLangId
|
||||
? "requiredInputLabel"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{t("price")}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
name="price"
|
||||
id="price"
|
||||
placeholder={placeholderLabel}
|
||||
value={current.price || ""}
|
||||
onChange={handleField("price")}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="phonenumber"
|
||||
>
|
||||
{t("phoneNumber")}
|
||||
</Label>
|
||||
<PhoneInput
|
||||
country={process.env.NEXT_PUBLIC_DEFAULT_COUNTRY}
|
||||
value={`${current.country_code}${current.contact}`}
|
||||
onChange={(phone, data) => handlePhoneChange(phone, data)}
|
||||
inputProps={{
|
||||
name: "phonenumber",
|
||||
id: "phonenumber",
|
||||
}}
|
||||
enableLongNumbers
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="videoLink">{t("videoLink")}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="videoLink"
|
||||
id="videoLink"
|
||||
placeholder={t("enterAdditionalLinks")}
|
||||
value={current.video_link || ""}
|
||||
onChange={handleField("video_link")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="slug">
|
||||
{t("slug")}{" "}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({t("allowedSlug")})
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="slug"
|
||||
id="slug"
|
||||
placeholder={t("enterSlug")}
|
||||
onChange={handleField("slug")}
|
||||
value={current.slug || ""}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="bg-primary text-white px-4 py-2 rounded-md text-xl"
|
||||
onClick={handleDetailsSubmit}
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditComponentOne;
|
||||
298
components/PagesComponent/EditListing/EditComponentThree.jsx
Normal file
298
components/PagesComponent/EditListing/EditComponentThree.jsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { IoInformationCircleOutline } from "react-icons/io5";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { HiOutlineUpload } from "react-icons/hi";
|
||||
import { MdClose } from "react-icons/md";
|
||||
import { t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const EditComponentThree = ({
|
||||
uploadedImages,
|
||||
setUploadedImages,
|
||||
OtherImages,
|
||||
setOtherImages,
|
||||
handleImageSubmit,
|
||||
handleGoBack,
|
||||
setDeleteImagesId,
|
||||
}) => {
|
||||
|
||||
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
if (acceptedFiles.length == 0) {
|
||||
toast.error(t("wrongFile"));
|
||||
} else {
|
||||
setUploadedImages(acceptedFiles);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"image/jpeg": [".jpeg", ".jpg"],
|
||||
"image/png": [".png"],
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const files = useMemo(() => {
|
||||
if (typeof uploadedImages === "string") {
|
||||
return (
|
||||
<div className="relative">
|
||||
<CustomImage
|
||||
width={591}
|
||||
height={350}
|
||||
className="rounded-2xl object-cover aspect-[591/350]"
|
||||
src={uploadedImages}
|
||||
alt="Uploaded Image"
|
||||
/>
|
||||
<div className="absolute top-2 left-2 flex gap-2 items-center">
|
||||
<button
|
||||
className="bg-white p-1 rounded-full"
|
||||
onClick={() => removeImage(0)}
|
||||
>
|
||||
<MdClose
|
||||
size={14}
|
||||
color="black"
|
||||
className="flex items-center justify-center rounded-full"
|
||||
/>
|
||||
</button>
|
||||
<div className="text-white flex flex-col">
|
||||
<span>{t("uploadedImage")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
uploadedImages?.map((file, index) => (
|
||||
<div key={index} className="relative">
|
||||
<CustomImage
|
||||
width={591}
|
||||
height={350}
|
||||
className="rounded-2xl object-cover aspect-[591/350]"
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={index}
|
||||
/>
|
||||
<div className="absolute top-2 left-2 flex gap-2 items-center">
|
||||
<button
|
||||
className="bg-white p-1 rounded-full"
|
||||
onClick={() => removeImage(index)}
|
||||
>
|
||||
<MdClose
|
||||
size={14}
|
||||
color="black"
|
||||
className="flex items-center justify-center rounded-full"
|
||||
/>
|
||||
</button>
|
||||
<div className="text-white text-xs flex flex-col">
|
||||
<span>{file.name}</span>
|
||||
<span>{Math.round(file.size / 1024)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)) || []
|
||||
);
|
||||
}
|
||||
}, [uploadedImages]);
|
||||
|
||||
const removeImage = (index) => {
|
||||
if (typeof uploadedImages === "string") {
|
||||
setUploadedImages([]);
|
||||
} else {
|
||||
setUploadedImages((prevImages) =>
|
||||
prevImages?.filter((_, i) => i !== index)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onOtherDrop = useCallback(
|
||||
(acceptedFiles) => {
|
||||
const currentFilesCount = OtherImages.length; // Number of files already uploaded
|
||||
const remainingSlots = 5 - currentFilesCount; // How many more files can be uploaded
|
||||
|
||||
if (remainingSlots === 0) {
|
||||
// Show error if the limit has been reached
|
||||
toast.error(t("imageLimitExceeded"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (acceptedFiles.length > remainingSlots) {
|
||||
// Show error if the number of new files exceeds the remaining slots
|
||||
toast.error(
|
||||
t("youCanUpload") + " " + remainingSlots + " " + t("moreImages")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the new files to the state
|
||||
setOtherImages((prevImages) => [...prevImages, ...acceptedFiles]);
|
||||
},
|
||||
[OtherImages]
|
||||
);
|
||||
|
||||
const {
|
||||
getRootProps: getRootOtheProps,
|
||||
getInputProps: getInputOtherProps,
|
||||
isDragActive: isDragOtherActive,
|
||||
} = useDropzone({
|
||||
onDrop: onOtherDrop,
|
||||
accept: {
|
||||
"image/jpeg": [".jpeg", ".jpg"],
|
||||
"image/png": [".png"],
|
||||
},
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
const removeOtherImage = (index, file) => {
|
||||
setOtherImages((prevImages) => prevImages.filter((_, i) => i !== index));
|
||||
setDeleteImagesId((prevIds) => {
|
||||
const newId = file?.id;
|
||||
if (prevIds) {
|
||||
return `${prevIds},${newId}`;
|
||||
} else {
|
||||
return `${newId}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const filesOther = useMemo(
|
||||
() =>
|
||||
OtherImages &&
|
||||
OtherImages?.map((file, index) => (
|
||||
<div key={file.id || `${file?.name}-${file?.size}`} className="relative">
|
||||
<CustomImage
|
||||
width={591}
|
||||
height={350}
|
||||
className="rounded-2xl object-cover aspect-[591/350]"
|
||||
src={file.image ? file.image : URL.createObjectURL(file)}
|
||||
alt={index}
|
||||
/>
|
||||
<div className="absolute top-2 left-2 flex gap-2 items-center">
|
||||
<button
|
||||
className="bg-white p-1 rounded-full"
|
||||
onClick={() => removeOtherImage(index, file)}
|
||||
>
|
||||
<MdClose
|
||||
size={14}
|
||||
color="black"
|
||||
className="flex items-center justify-center rounded-full"
|
||||
/>
|
||||
</button>
|
||||
{
|
||||
(file?.name || file?.size) &&
|
||||
<div className="text-white text-xs flex flex-col">
|
||||
<span>{file.name}</span>
|
||||
<span>{Math.round(file.size / 1024)} KB</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)),
|
||||
[OtherImages]
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-col-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="requiredInputLabel text-sm">{t("mainPicture")}</span>
|
||||
<div className="border-2 border-dashed rounded-lg p-2">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className="flex flex-col min-h-[175px] items-center justify-center cursor-pointer"
|
||||
style={{ display: uploadedImages.length > 0 ? "none" : "" }}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isDragActive ? (
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{t("dropFiles")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<span className="text-muted-foreground">
|
||||
{t("dragFiles")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{t("or")}</span>
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<HiOutlineUpload size={24} />
|
||||
<span className="font-medium">{t("upload")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>{files}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1 font-semibold text-sm">
|
||||
{t("otherPicture")}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<IoInformationCircleOutline size={22} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="font-normal"
|
||||
>
|
||||
<p>{t("maxOtherImages")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
<div className="border-2 border-dashed rounded-lg p-2">
|
||||
<div
|
||||
{...getRootOtheProps()}
|
||||
className="flex flex-col items-center justify-center min-h-[175px] cursor-pointer"
|
||||
style={{ display: OtherImages.length >= 5 ? "none" : "" }}
|
||||
>
|
||||
<input {...getInputOtherProps()} />
|
||||
{isDragOtherActive ? (
|
||||
<span className="text-primary font-medium">
|
||||
{t("dropFiles")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 items-center text-center">
|
||||
<span className="text-muted-foreground">
|
||||
{t("dragFiles")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{t("or")}</span>
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<HiOutlineUpload size={24} />
|
||||
<span className="font-medium">{t("upload")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">{filesOther}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
<button
|
||||
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={handleImageSubmit}
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditComponentThree;
|
||||
322
components/PagesComponent/EditListing/EditComponentTwo.jsx
Normal file
322
components/PagesComponent/EditListing/EditComponentTwo.jsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { toast } from "sonner";
|
||||
import { HiOutlineUpload } from "react-icons/hi";
|
||||
import { MdOutlineAttachFile } from "react-icons/md";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { handleKeyDown, inpNum, t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const EditComponentTwo = ({
|
||||
customFields,
|
||||
setExtraDetails,
|
||||
handleGoBack,
|
||||
filePreviews,
|
||||
setFilePreviews,
|
||||
submitExtraDetails,
|
||||
currentExtraDetails,
|
||||
langId,
|
||||
defaultLangId,
|
||||
}) => {
|
||||
const write = (fieldId, value) =>
|
||||
setExtraDetails((prev) => ({
|
||||
...prev,
|
||||
[langId]: {
|
||||
...prev[langId],
|
||||
[fieldId]: value,
|
||||
},
|
||||
}));
|
||||
|
||||
const handleFileChange = (id, file) => {
|
||||
if (file) {
|
||||
const allowedExtensions = /\.(jpg|jpeg|svg|png|pdf)$/i;
|
||||
if (!allowedExtensions.test(file.name)) {
|
||||
toast.error(t("notAllowedFile"));
|
||||
return;
|
||||
}
|
||||
const fileUrl = URL.createObjectURL(file);
|
||||
setFilePreviews((prevPreviews) => ({
|
||||
...prevPreviews,
|
||||
[id]: {
|
||||
url: fileUrl,
|
||||
isPdf: /\.pdf$/i.test(file.name),
|
||||
},
|
||||
}));
|
||||
write(id, file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (id, value, checked) => {
|
||||
const list = currentExtraDetails[id] || [];
|
||||
const next = checked
|
||||
? list.includes(value)
|
||||
? list
|
||||
: [...list, value]
|
||||
: list.filter((v) => v !== value);
|
||||
write(id, next);
|
||||
};
|
||||
|
||||
const handleChange = (id, value) => write(id, value ?? "");
|
||||
|
||||
const renderCustomFields = (field) => {
|
||||
let {
|
||||
id,
|
||||
translated_name,
|
||||
name,
|
||||
type,
|
||||
translated_value,
|
||||
values,
|
||||
min_length,
|
||||
max_length,
|
||||
} = field;
|
||||
|
||||
const inputProps = {
|
||||
id,
|
||||
name: id,
|
||||
onChange: (e) => handleChange(id, e.target.value),
|
||||
value: currentExtraDetails[id] || "",
|
||||
...(type === "number"
|
||||
? { min: min_length, max: max_length }
|
||||
: { minLength: min_length, maxLength: max_length }),
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case "number":
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Input
|
||||
type={type}
|
||||
inputMode="numeric"
|
||||
placeholder={`${t("enter")} ${translated_name || name}`}
|
||||
{...inputProps}
|
||||
onKeyDown={(e) => handleKeyDown(e, max_length)}
|
||||
onKeyPress={(e) => inpNum(e)}
|
||||
/>
|
||||
{max_length && (
|
||||
<span className="self-end text-sm text-muted-foreground">
|
||||
{`${currentExtraDetails[id]?.length ?? 0}/${max_length}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "textbox":
|
||||
return (
|
||||
<div className=" flex flex-col">
|
||||
<Textarea
|
||||
placeholder={`${t("enter")} ${translated_name || name}`}
|
||||
{...inputProps}
|
||||
/>
|
||||
{max_length && (
|
||||
<span className="self-end text-sm text-muted-foreground">
|
||||
{`${currentExtraDetails[id]?.length ?? 0}/${max_length}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "dropdown":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
id={id}
|
||||
name={id}
|
||||
value={currentExtraDetails[id] || ""}
|
||||
onValueChange={(value) => handleChange(id, value)}
|
||||
>
|
||||
<SelectTrigger className="outline-none focus:outline-none">
|
||||
<SelectValue
|
||||
className="font-medium"
|
||||
placeholder={`${t("select")} ${translated_name || name}`}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel value="">
|
||||
{t("select")} {translated_name || name}
|
||||
</SelectLabel>
|
||||
{values?.map((option, index) => (
|
||||
<SelectItem
|
||||
id={option}
|
||||
className="font-medium"
|
||||
key={option}
|
||||
value={option}
|
||||
>
|
||||
{translated_value[index] || option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "checkbox":
|
||||
return (
|
||||
<div className="flex w-full flex-wrap gap-2">
|
||||
{values?.map((value, index) => {
|
||||
const uniqueId = `${id}-${value}-${index}`;
|
||||
return (
|
||||
<div key={uniqueId} className="flex gap-1 items-center">
|
||||
<Checkbox
|
||||
id={uniqueId}
|
||||
value={value}
|
||||
onCheckedChange={(checked) =>
|
||||
handleCheckboxChange(id, value, checked)
|
||||
}
|
||||
checked={currentExtraDetails[id]?.includes(value)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={uniqueId}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{translated_value[index] || value}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
case "radio":
|
||||
return (
|
||||
<RadioGroup
|
||||
value={currentExtraDetails[id] || ""}
|
||||
onValueChange={(value) => handleChange(id, value)}
|
||||
className="flex gap-2 flex-wrap"
|
||||
>
|
||||
{values?.map((option, index) => {
|
||||
const uniqueId = `${id}-${option}-${index}`;
|
||||
return (
|
||||
<div
|
||||
key={uniqueId}
|
||||
className="flex items-center gap-2 flex-wrap"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option}
|
||||
id={uniqueId}
|
||||
className="sr-only peer "
|
||||
/>
|
||||
<label
|
||||
htmlFor={uniqueId}
|
||||
className={`${currentExtraDetails[id] === option
|
||||
? "bg-primary text-white"
|
||||
: ""
|
||||
} border rounded-md px-4 py-2 cursor-pointer transition-colors`}
|
||||
>
|
||||
{translated_value[index] || option}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
);
|
||||
|
||||
case "fileinput":
|
||||
const fileUrl = filePreviews[id]?.url;
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={id} className="flex gap-2 items-center">
|
||||
<div className="cursor-pointer border px-2.5 py-1 rounded">
|
||||
<HiOutlineUpload size={24} fontWeight="400" />
|
||||
</div>
|
||||
{filePreviews[id] && (
|
||||
<div className="flex items-center gap-1 text-sm flex-nowrap break-words">
|
||||
{filePreviews[id]?.isPdf ? (
|
||||
<>
|
||||
<MdOutlineAttachFile />
|
||||
<CustomLink
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("viewPdf")}
|
||||
</CustomLink>
|
||||
</>
|
||||
) : (
|
||||
<CustomImage
|
||||
key={fileUrl}
|
||||
src={fileUrl}
|
||||
alt="Preview"
|
||||
className="h-9 w-9"
|
||||
height={36}
|
||||
width={36}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id={id}
|
||||
name={name}
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileChange(id, e.target.files[0])}
|
||||
accept=".jpg,.jpeg,.png,.svg,.pdf"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("allowedFileType")}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{customFields?.map((field) => {
|
||||
if (langId !== defaultLangId && field.type !== "textbox") return null;
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-2" key={field?.id}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<CustomImage
|
||||
src={field?.image}
|
||||
alt={field?.name}
|
||||
height={28}
|
||||
width={28}
|
||||
className="h-7 w-7 rounded-sm"
|
||||
/>
|
||||
<Label
|
||||
className={`${field?.required === 1 && defaultLangId === langId
|
||||
? "requiredInputLabel"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{field?.translated_name || field?.name}
|
||||
</Label>
|
||||
</div>
|
||||
{renderCustomFields(field)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
className="bg-black text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
<button
|
||||
className="bg-primary text-white px-4 py-2 rounded-md text-xl font-light"
|
||||
onClick={submitExtraDetails}
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default EditComponentTwo;
|
||||
538
components/PagesComponent/EditListing/EditListing.jsx
Normal file
538
components/PagesComponent/EditListing/EditListing.jsx
Normal file
@@ -0,0 +1,538 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
|
||||
import {
|
||||
editItemApi,
|
||||
getCurrenciesApi,
|
||||
getCustomFieldsApi,
|
||||
getMyItemsApi,
|
||||
getParentCategoriesApi,
|
||||
} from "@/utils/api";
|
||||
import {
|
||||
filterNonDefaultTranslations,
|
||||
getMainDetailsTranslations,
|
||||
isValidURL,
|
||||
prefillExtraDetails,
|
||||
prepareCustomFieldFiles,
|
||||
prepareCustomFieldTranslations,
|
||||
t,
|
||||
validateExtraDetails,
|
||||
} from "@/utils";
|
||||
import EditComponentOne from "./EditComponentOne";
|
||||
import EditComponentTwo from "./EditComponentTwo";
|
||||
import EditComponentThree from "./EditComponentThree";
|
||||
import EditComponentFour from "./EditComponentFour";
|
||||
import { toast } from "sonner";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import Checkauth from "@/HOC/Checkauth";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import { useSelector } from "react-redux";
|
||||
import AdSuccessModal from "../AdsListing/AdSuccessModal";
|
||||
import {
|
||||
getDefaultLanguageCode,
|
||||
getLanguages,
|
||||
} from "@/redux/reducer/settingSlice";
|
||||
import AdLanguageSelector from "../AdsListing/AdLanguageSelector";
|
||||
import PageLoader from "@/components/Common/PageLoader";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js/max";
|
||||
|
||||
const EditListing = ({ id }) => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const [step, setStep] = useState(1);
|
||||
const [CreatedAdSlug, setCreatedAdSlug] = useState("");
|
||||
const [openSuccessModal, setOpenSuccessModal] = useState(false);
|
||||
const [selectedCategoryPath, setSelectedCategoryPath] = useState([]);
|
||||
const [customFields, setCustomFields] = useState([]);
|
||||
const [uploadedImages, setUploadedImages] = useState([]);
|
||||
const [OtherImages, setOtherImages] = useState([]);
|
||||
const [Location, setLocation] = useState({});
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [filePreviews, setFilePreviews] = useState({});
|
||||
const [deleteImagesId, setDeleteImagesId] = useState("");
|
||||
const [isAdPlaced, setIsAdPlaced] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const languages = useSelector(getLanguages);
|
||||
const defaultLanguageCode = useSelector(getDefaultLanguageCode);
|
||||
const defaultLangId = languages?.find(
|
||||
(lang) => lang.code === defaultLanguageCode
|
||||
)?.id;
|
||||
|
||||
const [extraDetails, setExtraDetails] = useState({
|
||||
[defaultLangId]: {},
|
||||
});
|
||||
const [langId, setLangId] = useState(defaultLangId);
|
||||
|
||||
const [translations, setTranslations] = useState({
|
||||
[defaultLangId]: {},
|
||||
});
|
||||
const hasTextbox = customFields.some((field) => field.type === "textbox");
|
||||
|
||||
const defaultDetails = translations[defaultLangId] || {};
|
||||
const currentDetails = translations[langId] || {};
|
||||
const currentExtraDetails = extraDetails[langId] || {};
|
||||
|
||||
const is_job_category =
|
||||
Number(
|
||||
selectedCategoryPath[selectedCategoryPath.length - 1]?.is_job_category
|
||||
) === 1;
|
||||
const isPriceOptional =
|
||||
Number(
|
||||
selectedCategoryPath[selectedCategoryPath.length - 1]?.price_optional
|
||||
) === 1;
|
||||
|
||||
useEffect(() => {
|
||||
getSingleListingData();
|
||||
}, [CurrentLanguage.id]);
|
||||
|
||||
const fetchCategoryPath = async (childCategoryId) => {
|
||||
try {
|
||||
const categoryResponse =
|
||||
await getParentCategoriesApi.getPaymentCategories({
|
||||
child_category_id: childCategoryId,
|
||||
});
|
||||
setSelectedCategoryPath(categoryResponse?.data?.data);
|
||||
} catch (error) {
|
||||
console.log("Error fetching category path:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getCustomFields = async (categoryIds, extraFieldValue) => {
|
||||
try {
|
||||
const customFieldsRes = await getCustomFieldsApi.getCustomFields({
|
||||
category_ids: categoryIds,
|
||||
});
|
||||
const data = customFieldsRes?.data?.data;
|
||||
setCustomFields(data);
|
||||
const tempExtraDetails = prefillExtraDetails({
|
||||
data,
|
||||
languages,
|
||||
defaultLangId,
|
||||
extraFieldValue,
|
||||
setFilePreviews,
|
||||
});
|
||||
setExtraDetails(tempExtraDetails);
|
||||
setLangId(defaultLangId);
|
||||
} catch (error) {
|
||||
console.log("Error fetching custom fields:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrencies = async () => {
|
||||
try {
|
||||
const res = await getCurrenciesApi.getCurrencies();
|
||||
const currenciesData = res?.data?.data || [];
|
||||
setCurrencies(currenciesData);
|
||||
return currenciesData; // Return the currencies data
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getSingleListingData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await getMyItemsApi.getMyItems({ id: Number(id) });
|
||||
const listingData = res?.data?.data?.data?.[0];
|
||||
|
||||
if (!listingData) {
|
||||
throw new Error("Listing not found");
|
||||
}
|
||||
// Get currencies data directly
|
||||
const [_, __, currenciesData] = await Promise.all([
|
||||
getCustomFields(
|
||||
listingData.all_category_ids,
|
||||
listingData?.all_translated_custom_fields
|
||||
),
|
||||
fetchCategoryPath(listingData?.category_id),
|
||||
getCurrencies(),
|
||||
]);
|
||||
|
||||
setUploadedImages(listingData?.image);
|
||||
setOtherImages(listingData?.gallery_images);
|
||||
|
||||
const mainDetailsTranslation = getMainDetailsTranslations(
|
||||
listingData,
|
||||
languages,
|
||||
defaultLangId,
|
||||
currenciesData
|
||||
);
|
||||
setTranslations(mainDetailsTranslation);
|
||||
setLocation({
|
||||
country: listingData?.country,
|
||||
state: listingData?.state,
|
||||
city: listingData?.city,
|
||||
formattedAddress: listingData?.translated_address,
|
||||
lat: listingData?.latitude,
|
||||
long: listingData?.longitude,
|
||||
area_id: listingData?.area_id ? listingData?.area_id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetailsSubmit = () => {
|
||||
if (customFields?.length === 0) {
|
||||
setStep(3);
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleImageSubmit = () => {
|
||||
if (uploadedImages.length === 0) {
|
||||
toast.error(t("uploadMainPicture"));
|
||||
return;
|
||||
}
|
||||
setStep(4);
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (step == 3 && customFields?.length == 0) {
|
||||
setStep((prev) => prev - 2);
|
||||
} else {
|
||||
setStep((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
if (tab === 1) {
|
||||
setStep(1);
|
||||
} else if (tab === 2) {
|
||||
setStep(2);
|
||||
} else if (tab === 3) {
|
||||
setStep(3);
|
||||
} else if (tab === 4) {
|
||||
setStep(4);
|
||||
}
|
||||
};
|
||||
|
||||
const submitExtraDetails = () => {
|
||||
setStep(3);
|
||||
};
|
||||
|
||||
const SLUG_RE = /^[a-z0-9-]+$/i;
|
||||
const isEmpty = (x) => !x || !x.toString().trim();
|
||||
const isNegative = (n) => Number(n) < 0;
|
||||
|
||||
const handleFullSubmission = () => {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
slug,
|
||||
contact,
|
||||
video_link,
|
||||
min_salary,
|
||||
max_salary,
|
||||
country_code,
|
||||
} = defaultDetails;
|
||||
|
||||
if (!name.trim() || !description.trim()) {
|
||||
toast.error(t("completeDetails"));
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Validate phone number ONLY if user entered one as it is optional
|
||||
if (Boolean(contact) && !isValidPhoneNumber(`+${country_code}${contact}`)) {
|
||||
toast.error(t("invalidPhoneNumber"));
|
||||
return setStep(1);
|
||||
}
|
||||
|
||||
if (is_job_category) {
|
||||
const min = min_salary ? Number(min_salary) : null;
|
||||
const max = max_salary ? Number(max_salary) : null;
|
||||
|
||||
// Salary fields are optional, but validate if provided
|
||||
if (min !== null && min < 0) {
|
||||
toast.error(t("enterValidSalaryMin"));
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (max !== null && max < 0) {
|
||||
toast.error(t("enterValidSalaryMax"));
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
if (min !== null && max !== null) {
|
||||
if (min === max) {
|
||||
toast.error(t("salaryMinCannotBeEqualMax"));
|
||||
return setStep(1);
|
||||
}
|
||||
if (min > max) {
|
||||
toast.error(t("salaryMinCannotBeGreaterThanMax"));
|
||||
return setStep(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!isPriceOptional && isEmpty(price)) {
|
||||
toast.error(t("completeDetails"));
|
||||
return setStep(1);
|
||||
}
|
||||
|
||||
if (!isEmpty(price) && isNegative(price)) {
|
||||
toast.error(t("enterValidPrice"));
|
||||
return setStep(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEmpty(slug) && !SLUG_RE.test(slug.trim())) {
|
||||
toast.error(t("addValidSlug"));
|
||||
return setStep(1);
|
||||
}
|
||||
|
||||
if (!isEmpty(video_link) && !isValidURL(video_link)) {
|
||||
toast.error(t("enterValidUrl"));
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
customFields.length !== 0 &&
|
||||
!validateExtraDetails({
|
||||
languages,
|
||||
defaultLangId,
|
||||
extraDetails,
|
||||
customFields,
|
||||
filePreviews,
|
||||
})
|
||||
) {
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
if (uploadedImages.length === 0) {
|
||||
toast.error(t("uploadMainPicture"));
|
||||
setStep(3);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!Location?.country ||
|
||||
!Location?.state ||
|
||||
!Location?.city ||
|
||||
!Location?.formattedAddress
|
||||
) {
|
||||
toast.error(t("pleaseSelectCity"));
|
||||
return;
|
||||
}
|
||||
editAd();
|
||||
};
|
||||
|
||||
const editAd = async () => {
|
||||
const nonDefaultTranslations = filterNonDefaultTranslations(
|
||||
translations,
|
||||
defaultLangId
|
||||
);
|
||||
const customFieldTranslations =
|
||||
prepareCustomFieldTranslations(extraDetails);
|
||||
|
||||
const customFieldFiles = prepareCustomFieldFiles(
|
||||
extraDetails,
|
||||
defaultLangId
|
||||
);
|
||||
|
||||
const allData = {
|
||||
id: id,
|
||||
name: defaultDetails.name,
|
||||
slug: defaultDetails.slug.trim(),
|
||||
description: defaultDetails?.description,
|
||||
price: defaultDetails.price,
|
||||
contact: defaultDetails.contact,
|
||||
region_code: defaultDetails?.region_code?.toUpperCase() || "",
|
||||
video_link: defaultDetails?.video_link,
|
||||
// custom_fields: transformedCustomFields,
|
||||
image: typeof uploadedImages == "string" ? null : uploadedImages[0],
|
||||
gallery_images: OtherImages,
|
||||
address: Location?.formattedAddress,
|
||||
latitude: Location?.lat,
|
||||
longitude: Location?.long,
|
||||
custom_field_files: customFieldFiles,
|
||||
country: Location?.country,
|
||||
state: Location?.state,
|
||||
city: Location?.city,
|
||||
...(Location?.area_id ? { area_id: Number(Location?.area_id) } : {}),
|
||||
delete_item_image_id: deleteImagesId,
|
||||
...(Object.keys(nonDefaultTranslations).length > 0 && {
|
||||
translations: nonDefaultTranslations,
|
||||
}),
|
||||
...(defaultDetails?.currency_id && {
|
||||
currency_id: defaultDetails?.currency_id,
|
||||
}),
|
||||
...(Object.keys(customFieldTranslations).length > 0 && {
|
||||
custom_field_translations: customFieldTranslations,
|
||||
}),
|
||||
// expiry_date: '2025-10-13'
|
||||
};
|
||||
|
||||
if (is_job_category) {
|
||||
// Only add salary fields if they're provided
|
||||
allData.min_salary = defaultDetails.min_salary;
|
||||
allData.max_salary = defaultDetails.max_salary;
|
||||
} else {
|
||||
allData.price = defaultDetails.price;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAdPlaced(true);
|
||||
const res = await editItemApi.editItem(allData);
|
||||
if (res?.data?.error === false) {
|
||||
setOpenSuccessModal(true);
|
||||
setCreatedAdSlug(res?.data?.data[0]?.slug);
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsAdPlaced(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
{isLoading ? (
|
||||
<PageLoader />
|
||||
) : (
|
||||
<>
|
||||
<BreadCrumb title2={t("editListing")} />
|
||||
<div className="container">
|
||||
<div className="flex flex-col gap-6 mt-8">
|
||||
<h1 className="text-2xl font-medium">{t("editListing")}</h1>
|
||||
<div className="flex flex-col gap-6 border rounded-md p-4">
|
||||
<div className="flex items-center gap-3 justify-between bg-muted px-4 py-2 rounded-md flex-wrap">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div
|
||||
className={`transition-all duration-300 p-2 cursor-pointer ${step === 1 ? "bg-primary text-white" : ""
|
||||
} rounded-md `}
|
||||
onClick={() => handleTabClick(1)}
|
||||
>
|
||||
{t("details")}
|
||||
</div>
|
||||
{customFields?.length > 0 && (
|
||||
<div
|
||||
className={`transition-all duration-300 p-2 cursor-pointer ${step === 2 ? "bg-primary text-white" : ""
|
||||
} rounded-md`}
|
||||
onClick={() => handleTabClick(2)}
|
||||
>
|
||||
{t("extraDetails")}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`transition-all duration-300 p-2 cursor-pointer ${step === 3 ? "bg-primary text-white" : ""
|
||||
} rounded-md `}
|
||||
onClick={() => handleTabClick(3)}
|
||||
>
|
||||
{t("images")}
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 p-2 cursor-pointer ${step === 4 ? "bg-primary text-white" : ""
|
||||
} rounded-md `}
|
||||
onClick={() => handleTabClick(4)}
|
||||
>
|
||||
{t("location")}
|
||||
</div>
|
||||
</div>
|
||||
{(step === 1 || (step === 2 && hasTextbox)) && (
|
||||
<AdLanguageSelector
|
||||
langId={langId}
|
||||
setLangId={setLangId}
|
||||
languages={languages}
|
||||
setTranslations={setTranslations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{step === 1 &&
|
||||
selectedCategoryPath &&
|
||||
selectedCategoryPath?.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="font-medium text-xl">
|
||||
{t("selectedCategory")}
|
||||
</h1>
|
||||
<div className="flex">
|
||||
{selectedCategoryPath?.map((item, index) => {
|
||||
const shouldShowComma =
|
||||
selectedCategoryPath.length > 1 &&
|
||||
index !== selectedCategoryPath.length - 1;
|
||||
return (
|
||||
<span className="text-primary" key={item.id}>
|
||||
{item.name}
|
||||
{shouldShowComma && ", "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{step == 1 && (
|
||||
<EditComponentOne
|
||||
setTranslations={setTranslations}
|
||||
current={currentDetails}
|
||||
langId={langId}
|
||||
defaultLangId={defaultLangId}
|
||||
handleDetailsSubmit={handleDetailsSubmit}
|
||||
is_job_category={is_job_category}
|
||||
isPriceOptional={isPriceOptional}
|
||||
currencies={currencies}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step == 2 && customFields.length > 0 && (
|
||||
<EditComponentTwo
|
||||
customFields={customFields}
|
||||
extraDetails={extraDetails}
|
||||
setExtraDetails={setExtraDetails}
|
||||
handleGoBack={handleGoBack}
|
||||
filePreviews={filePreviews}
|
||||
setFilePreviews={setFilePreviews}
|
||||
submitExtraDetails={submitExtraDetails}
|
||||
currentExtraDetails={currentExtraDetails}
|
||||
langId={langId}
|
||||
defaultLangId={defaultLangId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step == 3 && (
|
||||
<EditComponentThree
|
||||
setUploadedImages={setUploadedImages}
|
||||
uploadedImages={uploadedImages}
|
||||
OtherImages={OtherImages}
|
||||
setOtherImages={setOtherImages}
|
||||
handleImageSubmit={handleImageSubmit}
|
||||
handleGoBack={handleGoBack}
|
||||
setDeleteImagesId={setDeleteImagesId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step == 4 && (
|
||||
<EditComponentFour
|
||||
handleGoBack={handleGoBack}
|
||||
location={Location}
|
||||
setLocation={setLocation}
|
||||
handleFullSubmission={handleFullSubmission}
|
||||
isAdPlaced={isAdPlaced}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdSuccessModal
|
||||
openSuccessModal={openSuccessModal}
|
||||
setOpenSuccessModal={setOpenSuccessModal}
|
||||
createdAdSlug={CreatedAdSlug}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkauth(EditListing);
|
||||
31
components/PagesComponent/Faq/FaqCard.jsx
Normal file
31
components/PagesComponent/Faq/FaqCard.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
const FaqCard = ({ faq }) => {
|
||||
return (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="border rounded-md overflow-hidden"
|
||||
>
|
||||
<AccordionItem value={faq?.id} className="border-none group">
|
||||
<AccordionTrigger
|
||||
className="text-start font-bold text-base px-4 hover:no-underline bg-transparent
|
||||
group-data-[state=open]:bg-muted group-data-[state=open]:text-primary
|
||||
group-data-[state=open]:border-b"
|
||||
>
|
||||
{faq?.translated_question || faq?.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="bg-muted p-4">
|
||||
<p className="text-base">{faq?.translated_answer || faq?.answer}</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqCard;
|
||||
57
components/PagesComponent/Faq/FaqsPage.jsx
Normal file
57
components/PagesComponent/Faq/FaqsPage.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
|
||||
import { t } from "@/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getFaqApi } from "@/utils/api";
|
||||
import FaqCard from "./FaqCard";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { useSelector } from "react-redux";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import PageLoader from "@/components/Common/PageLoader";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
|
||||
const FaqsPage = () => {
|
||||
const [faqs, setFaqs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFaqs();
|
||||
}, [CurrentLanguage.id]);
|
||||
|
||||
const fetchFaqs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getFaqApi.getFaq();
|
||||
setFaqs(res?.data?.data);
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<BreadCrumb title2={t("faqs")} />
|
||||
{loading ? (
|
||||
<PageLoader />
|
||||
) : faqs && faqs?.length > 0 ? (
|
||||
<div className="container">
|
||||
<div className="flex flex-col gap-6 mt-8">
|
||||
<h1 className="text-2xl font-semibold">{t("faqs")}</h1>
|
||||
<div className="flex flex-col gap-4 md:gap-8">
|
||||
{faqs?.map((faq) => {
|
||||
return <FaqCard faq={faq} key={faq?.id} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NoData name={t("faqs")} />
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqsPage;
|
||||
95
components/PagesComponent/Favorites/Favorites.jsx
Normal file
95
components/PagesComponent/Favorites/Favorites.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
import ProductCard from "@/components/Common/ProductCard";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import ProductCardSkeleton from "@/components/Common/ProductCardSkeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import { t } from "@/utils";
|
||||
import { getFavouriteApi } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const Favorites = () => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const [favoritesData, setFavoriteData] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [IsLoadMore, setIsLoadMore] = useState(false);
|
||||
|
||||
const fetchFavoriteItems = async (page) => {
|
||||
try {
|
||||
if (page === 1) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
const response = await getFavouriteApi.getFavouriteApi({ page });
|
||||
const data = response?.data?.data?.data;
|
||||
if (page === 1) {
|
||||
setFavoriteData(data);
|
||||
} else {
|
||||
setFavoriteData((prevData) => [...prevData, ...data]);
|
||||
}
|
||||
|
||||
setCurrentPage(response?.data?.data.current_page);
|
||||
|
||||
if (response?.data?.data.current_page < response?.data?.data.last_page) {
|
||||
setHasMore(true);
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFavoriteItems(currentPage);
|
||||
}, [currentPage, CurrentLanguage.id]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setIsLoadMore(true);
|
||||
setCurrentPage((prevPage) => prevPage + 1);
|
||||
};
|
||||
|
||||
const handleLike = (id) => {
|
||||
fetchFavoriteItems(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 lg:grid-cols-2 gap-3 sm:gap-6">
|
||||
{isLoading ? (
|
||||
[...Array(12)].map((_, index) => <ProductCardSkeleton key={index} />)
|
||||
) : favoritesData && favoritesData.length > 0 ? (
|
||||
favoritesData?.map(
|
||||
(fav) =>
|
||||
fav?.is_liked && (
|
||||
<ProductCard key={fav?.id} item={fav} handleLike={handleLike} />
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<div className="col-span-full">
|
||||
<NoData name={t("favorites")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{favoritesData && favoritesData.length > 0 && hasMore && (
|
||||
<div className="text-center mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-sm sm:text-base text-primary w-[256px]"
|
||||
disabled={isLoading || IsLoadMore}
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
{IsLoadMore ? t("loading") : t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Favorites;
|
||||
165
components/PagesComponent/Home/AllItems.jsx
Normal file
165
components/PagesComponent/Home/AllItems.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import ProductCard from "@/components/Common/ProductCard";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import AllItemsSkeleton from "@/components/PagesComponent/Home/AllItemsSkeleton";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resetBreadcrumb } from "@/redux/reducer/breadCrumbSlice";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import { t } from "@/utils";
|
||||
import { allItemApi } from "@/utils/api";
|
||||
import { Info, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
const AllItems = ({ cityData, KmRange }) => {
|
||||
const dispatch = useDispatch();
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const [AllItem, setAllItem] = useState([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadMore, setIsLoadMore] = useState(false);
|
||||
|
||||
// State to track if we should show location alert
|
||||
const [locationAlertMessage, setLocationAlertMessage] = useState("");
|
||||
|
||||
const getAllItemData = async (page) => {
|
||||
if (page === 1) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const params = {
|
||||
page,
|
||||
current_page: "home",
|
||||
};
|
||||
if (Number(KmRange) > 0 && (cityData?.areaId || cityData?.city)) {
|
||||
// Add location-based parameters for non-demo mode
|
||||
params.radius = KmRange;
|
||||
params.latitude = cityData.lat;
|
||||
params.longitude = cityData.long;
|
||||
} else {
|
||||
// Add location hierarchy parameters for non-demo mode
|
||||
if (cityData?.areaId) {
|
||||
params.area_id = cityData.areaId;
|
||||
} else if (cityData?.city) {
|
||||
params.city = cityData.city;
|
||||
} else if (cityData?.state) {
|
||||
params.state = cityData.state;
|
||||
} else if (cityData?.country) {
|
||||
params.country = cityData.country;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await allItemApi.getItems(params);
|
||||
if (response.data?.error === true) {
|
||||
throw new Error(response.data?.message);
|
||||
}
|
||||
|
||||
const apiMessage = response.data.message;
|
||||
// Check if message indicates no items in selected location
|
||||
const isNoItemsInLocation = apiMessage
|
||||
?.toLowerCase()
|
||||
.includes("no ads found");
|
||||
|
||||
// Show alert only if there are items but from different location
|
||||
if (isNoItemsInLocation && response?.data?.data?.data?.length > 0) {
|
||||
setLocationAlertMessage(apiMessage);
|
||||
} else {
|
||||
setLocationAlertMessage("");
|
||||
}
|
||||
|
||||
if (response?.data?.data?.data?.length > 0) {
|
||||
const data = response?.data?.data?.data;
|
||||
if (page === 1) {
|
||||
setAllItem(data);
|
||||
} else {
|
||||
setAllItem((prevData) => [...prevData, ...data]);
|
||||
}
|
||||
const currentPage = response?.data?.data?.current_page;
|
||||
const lastPage = response?.data?.data?.last_page;
|
||||
setHasMore(currentPage < lastPage);
|
||||
setCurrentPage(currentPage);
|
||||
} else {
|
||||
setAllItem([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getAllItemData(1);
|
||||
}, [cityData.lat, cityData.long, KmRange, CurrentLanguage?.id]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setIsLoadMore(true);
|
||||
getAllItemData(currentPage + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// reset breadcrumb path when in home page
|
||||
dispatch(resetBreadcrumb());
|
||||
}, []);
|
||||
|
||||
const handleLikeAllData = (id) => {
|
||||
const updatedItems = AllItem.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, is_liked: !item.is_liked };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
setAllItem(updatedItems);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="container mt-12">
|
||||
<h5 className="text-xl sm:text-2xl font-medium">
|
||||
{t("allAdvertisements")}
|
||||
</h5>
|
||||
|
||||
{/* Location Alert - shows when items are from different location */}
|
||||
{locationAlertMessage && AllItem.length > 0 && (
|
||||
<Alert variant="warning" className="mt-3">
|
||||
<Info className="size-4" />
|
||||
<AlertTitle>{locationAlertMessage}</AlertTitle>
|
||||
<AlertDescription className="sr-only"></AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-6 mt-6">
|
||||
{isLoading ? (
|
||||
<AllItemsSkeleton />
|
||||
) : AllItem && AllItem.length > 0 ? (
|
||||
AllItem?.map((item) => (
|
||||
<ProductCard
|
||||
key={item?.id}
|
||||
item={item}
|
||||
handleLike={handleLikeAllData}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full">
|
||||
<NoData name={t("advertisement")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{AllItem && AllItem.length > 0 && hasMore && (
|
||||
<div className="text-center mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-sm sm:text-base text-primary w-[256px]"
|
||||
disabled={isLoading || isLoadMore}
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
{isLoadMore ? t("loading") : t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllItems;
|
||||
14
components/PagesComponent/Home/AllItemsSkeleton.jsx
Normal file
14
components/PagesComponent/Home/AllItemsSkeleton.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useId } from "react";
|
||||
import ProductCardSkeleton from "../../Common/ProductCardSkeleton";
|
||||
|
||||
const AllItemsSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 8 }).map(() => (
|
||||
<ProductCardSkeleton key={useId()} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllItemsSkeleton;
|
||||
62
components/PagesComponent/Home/FeaturedSections.jsx
Normal file
62
components/PagesComponent/Home/FeaturedSections.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
import { t } from "@/utils";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import ProductCard from "@/components/Common/ProductCard";
|
||||
import { Fragment } from "react";
|
||||
|
||||
const FeaturedSections = ({ featuredData, setFeaturedData, allEmpty }) => {
|
||||
const handleLike = (id) => {
|
||||
const updatedData = featuredData.map((section) => {
|
||||
const updatedSectionData = section.section_data.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, is_liked: !item.is_liked };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return { ...section, section_data: updatedSectionData };
|
||||
});
|
||||
setFeaturedData(updatedData);
|
||||
};
|
||||
|
||||
return (
|
||||
featuredData &&
|
||||
featuredData.length > 0 &&
|
||||
!allEmpty && (
|
||||
<section className="container">
|
||||
{featuredData.map(
|
||||
(ele) =>
|
||||
ele?.section_data.length > 0 && (
|
||||
<Fragment key={ele?.id}>
|
||||
<div className="space-between gap-2 mt-12">
|
||||
<h5 className="text-xl sm:text-2xl font-medium">
|
||||
{ele?.translated_name || ele?.title}
|
||||
</h5>
|
||||
|
||||
{ele?.section_data.length > 4 && (
|
||||
<CustomLink
|
||||
href={`/ads?featured_section=${ele?.slug}`}
|
||||
className="text-sm sm:text-base font-medium whitespace-nowrap"
|
||||
prefetch={false}
|
||||
>
|
||||
{t("viewAll")}
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-6 mt-6">
|
||||
{ele?.section_data.slice(0, 4).map((data) => (
|
||||
<ProductCard
|
||||
key={data?.id}
|
||||
item={data}
|
||||
handleLike={handleLike}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturedSections;
|
||||
20
components/PagesComponent/Home/FeaturedSectionsSkeleton.jsx
Normal file
20
components/PagesComponent/Home/FeaturedSectionsSkeleton.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Skeleton } from "../../ui/skeleton";
|
||||
import ProductCardSkeleton from "../../Common/ProductCardSkeleton";
|
||||
|
||||
const FeaturedSectionsSkeleton = () => {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="space-between gap-2 mt-12">
|
||||
<Skeleton className="w-1/6 h-4" />
|
||||
<Skeleton className="w-1/12 h-4" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-6 mt-6">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<ProductCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturedSectionsSkeleton;
|
||||
300
components/PagesComponent/Home/HeaderCategories.jsx
Normal file
300
components/PagesComponent/Home/HeaderCategories.jsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import { t } from "@/utils";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IoIosMore } from "react-icons/io";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
const HeaderCategories = ({ cateData }) => {
|
||||
const containerRef = useRef(null);
|
||||
const measureRef = useRef(null);
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { navigate } = useNavigate();
|
||||
|
||||
const [fitCategoriesCount, setFitCategoriesCount] = useState(3);
|
||||
|
||||
useEffect(() => {
|
||||
const calculateFit = () => {
|
||||
if (!containerRef.current || !measureRef.current) return;
|
||||
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const otherWidth = 80; //approx width of other option
|
||||
const availableWidth = containerWidth - otherWidth;
|
||||
|
||||
const items = Array.from(measureRef.current.children);
|
||||
let totalWidth = 0;
|
||||
let visible = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const width = item.getBoundingClientRect().width + 48; // padding/gap buffer
|
||||
|
||||
if (totalWidth + width > availableWidth) break;
|
||||
totalWidth += width;
|
||||
visible++;
|
||||
}
|
||||
|
||||
setFitCategoriesCount(visible);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(calculateFit);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [cateData]);
|
||||
|
||||
// Helper function to build URL with category while preserving existing search params
|
||||
const buildCategoryUrl = (categorySlug) => {
|
||||
if (pathname.startsWith("/ads")) {
|
||||
// Preserve existing search params and update category
|
||||
const newSearchParams = new URLSearchParams(searchParams.toString());
|
||||
newSearchParams.delete("lang");
|
||||
newSearchParams.set("category", categorySlug);
|
||||
return `/ads?${newSearchParams.toString()}`;
|
||||
} else {
|
||||
// Not on ads page, just add category
|
||||
return `/ads?category=${categorySlug}`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryClick = (slug) => {
|
||||
if (pathname.startsWith("/ads")) {
|
||||
const newSearchParams = new URLSearchParams(searchParams.toString());
|
||||
newSearchParams.set("category", slug);
|
||||
const newUrl = `/ads?${newSearchParams.toString()}`;
|
||||
window.history.pushState(null, "", newUrl);
|
||||
} else {
|
||||
navigate(`/ads?category=${slug}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtherCategoryClick = () => {
|
||||
if (pathname.startsWith("/ads")) {
|
||||
const newSearchParams = new URLSearchParams(searchParams.toString());
|
||||
newSearchParams.delete("category");
|
||||
const newUrl = `/ads?${newSearchParams.toString()}`;
|
||||
window.history.pushState(null, "", newUrl);
|
||||
} else {
|
||||
navigate(`/ads`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hidden lg:block py-1.5 border-b">
|
||||
<div className="container" ref={containerRef}>
|
||||
{/* Hidden measurement row */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
className="absolute opacity-0 pointer-events-none flex"
|
||||
style={{ position: "fixed", top: -9999, left: -9999 }}
|
||||
>
|
||||
{cateData.map((category) => (
|
||||
<div key={category.id} className="px-2">
|
||||
<span className="whitespace-nowrap font-medium text-sm">
|
||||
{category.translated_name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList className="rtl:flex-row-reverse">
|
||||
{cateData?.slice(0, fitCategoriesCount)?.map((category) => (
|
||||
<NavigationMenuItem key={category.id}>
|
||||
{category.subcategories_count > 0 ? (
|
||||
<>
|
||||
<NavigationMenuTrigger
|
||||
onClick={() => handleCategoryClick(category.slug)}
|
||||
>
|
||||
{category.translated_name}
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent className="rtl:[direction:rtl]">
|
||||
<NavigationMenuLink asChild>
|
||||
<div
|
||||
style={{
|
||||
width: containerRef?.current?.offsetWidth - 32,
|
||||
}}
|
||||
className="flex overflow-x-auto"
|
||||
>
|
||||
<div className="w-[20%] p-4 bg-muted">
|
||||
<div className="flex gap-1">
|
||||
<CustomImage
|
||||
src={category?.image}
|
||||
alt={category?.translated_name}
|
||||
width={22}
|
||||
height={22}
|
||||
className="w-22 h-auto aspect-square"
|
||||
/>
|
||||
<p className="font-bold">
|
||||
{category?.translated_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[80%] p-4">
|
||||
<div className="flex flex-col flex-wrap w-min gap-8 h-[30vh] max-h-[30vh]">
|
||||
{/* <CustomLink
|
||||
href={`/ads?category=${category.slug}`}
|
||||
className="font-semibold whitespace-nowrap text-sm hover:text-primary"
|
||||
>
|
||||
{t("seeAllIn")} {category.translated_name}
|
||||
</CustomLink> */}
|
||||
|
||||
{category.subcategories.map((subcategory) => (
|
||||
<div key={subcategory.id}>
|
||||
<CustomLink
|
||||
href={buildCategoryUrl(subcategory.slug)}
|
||||
className="font-semibold whitespace-nowrap text-sm hover:text-primary"
|
||||
>
|
||||
{subcategory.translated_name}
|
||||
</CustomLink>
|
||||
|
||||
{subcategory.subcategories_count > 0 && (
|
||||
<ul className="flex flex-col gap-2 mt-2">
|
||||
{subcategory?.subcategories
|
||||
?.slice(0, 5)
|
||||
.map((nestedSubcategory) => (
|
||||
<li
|
||||
key={nestedSubcategory?.id}
|
||||
className="text-xs"
|
||||
>
|
||||
<CustomLink
|
||||
href={buildCategoryUrl(
|
||||
nestedSubcategory?.slug
|
||||
)}
|
||||
className="hover:text-primary whitespace-nowrap"
|
||||
>
|
||||
{
|
||||
nestedSubcategory?.translated_name
|
||||
}
|
||||
</CustomLink>
|
||||
</li>
|
||||
))}
|
||||
{subcategory.subcategories.length > 5 && (
|
||||
<li className="text-xs">
|
||||
<CustomLink
|
||||
href={buildCategoryUrl(
|
||||
subcategory.slug
|
||||
)}
|
||||
className="hover:text-primary"
|
||||
>
|
||||
{t("viewAll")}
|
||||
</CustomLink>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuContent>
|
||||
</>
|
||||
) : (
|
||||
<NavigationMenuLink
|
||||
className={navigationMenuTriggerStyle()}
|
||||
href={buildCategoryUrl(category?.slug)}
|
||||
asChild
|
||||
>
|
||||
<CustomLink href={buildCategoryUrl(category?.slug)}>
|
||||
{category.translated_name}
|
||||
</CustomLink>
|
||||
</NavigationMenuLink>
|
||||
)}
|
||||
</NavigationMenuItem>
|
||||
))}
|
||||
{cateData && cateData.length > fitCategoriesCount && (
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger onClick={handleOtherCategoryClick}>
|
||||
{t("other")}
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent className="rtl:[direction:rtl]">
|
||||
<NavigationMenuLink asChild>
|
||||
<div
|
||||
style={{ width: containerRef?.current?.offsetWidth - 32 }}
|
||||
className="flex overflow-x-auto w-[80vw]"
|
||||
>
|
||||
<div className="w-[20%] p-4 bg-muted">
|
||||
<div className="flex gap-1">
|
||||
<IoIosMore size={22} />
|
||||
<p className="font-bold">{t("other")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[80%] p-4">
|
||||
<div className="flex flex-col flex-wrap w-min gap-8 h-[30vh] max-h-[30vh]">
|
||||
{cateData
|
||||
.slice(fitCategoriesCount)
|
||||
.map((subcategory) => (
|
||||
<div key={subcategory.id}>
|
||||
<CustomLink
|
||||
href={buildCategoryUrl(subcategory.slug)}
|
||||
className="font-semibold whitespace-nowrap text-sm hover:text-primary"
|
||||
>
|
||||
{subcategory.translated_name}
|
||||
</CustomLink>
|
||||
|
||||
{subcategory.subcategories_count > 0 && (
|
||||
<ul className="flex flex-col gap-2 mt-2">
|
||||
{subcategory?.subcategories
|
||||
?.slice(0, 5)
|
||||
.map((nestedSubcategory) => (
|
||||
<li
|
||||
key={nestedSubcategory?.id}
|
||||
className="text-xs"
|
||||
>
|
||||
<CustomLink
|
||||
href={buildCategoryUrl(
|
||||
nestedSubcategory?.slug
|
||||
)}
|
||||
className="hover:text-primary"
|
||||
>
|
||||
{nestedSubcategory?.translated_name}
|
||||
</CustomLink>
|
||||
</li>
|
||||
))}
|
||||
{subcategory.subcategories.length > 5 && (
|
||||
<li className="text-xs">
|
||||
<CustomLink
|
||||
href={buildCategoryUrl(
|
||||
subcategory.slug
|
||||
)}
|
||||
className="hover:text-primary"
|
||||
>
|
||||
{t("viewAll")}
|
||||
</CustomLink>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
)}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderCategories;
|
||||
110
components/PagesComponent/Home/Home.jsx
Normal file
110
components/PagesComponent/Home/Home.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import AllItems from "./AllItems";
|
||||
import FeaturedSections from "./FeaturedSections";
|
||||
import { FeaturedSectionApi, sliderApi } from "@/utils/api";
|
||||
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getCityData, getKilometerRange } from "@/redux/reducer/locationSlice";
|
||||
import OfferSliderSkeleton from "@/components/PagesComponent/Home/OfferSliderSkeleton";
|
||||
import FeaturedSectionsSkeleton from "./FeaturedSectionsSkeleton";
|
||||
import PopularCategories from "./PopularCategories";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const OfferSlider = dynamic(() => import("./OfferSlider"), {
|
||||
ssr: false,
|
||||
loading: OfferSliderSkeleton,
|
||||
});
|
||||
|
||||
const Home = () => {
|
||||
const KmRange = useSelector(getKilometerRange);
|
||||
const cityData = useSelector(getCityData);
|
||||
const currentLanguageCode = useSelector(getCurrentLangCode);
|
||||
const [IsFeaturedLoading, setIsFeaturedLoading] = useState(false);
|
||||
const [featuredData, setFeaturedData] = useState([]);
|
||||
const [Slider, setSlider] = useState([]);
|
||||
const [IsSliderLoading, setIsSliderLoading] = useState(true);
|
||||
const allEmpty = featuredData?.every((ele) => ele?.section_data.length === 0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSliderData = async () => {
|
||||
let params = {};
|
||||
if (cityData?.city) {
|
||||
params.city = cityData.city;
|
||||
params.state = cityData.state;
|
||||
params.country = cityData.country;
|
||||
} else if (cityData?.state) {
|
||||
params.state = cityData.state;
|
||||
} else if (cityData?.country) {
|
||||
params.country = cityData.country;
|
||||
}
|
||||
try {
|
||||
const response = await sliderApi.getSlider(params);
|
||||
const data = response.data;
|
||||
setSlider(data.data);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
setIsSliderLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSliderData();
|
||||
}, [cityData?.city, cityData?.state, cityData?.country]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFeaturedSectionData = async () => {
|
||||
setIsFeaturedLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
if (Number(KmRange) > 0 && (cityData?.areaId || cityData?.city)) {
|
||||
params.radius = KmRange;
|
||||
params.latitude = cityData.lat;
|
||||
params.longitude = cityData.long;
|
||||
} else {
|
||||
if (cityData?.areaId) {
|
||||
params.area_id = cityData.areaId;
|
||||
} else if (cityData?.city) {
|
||||
params.city = cityData.city;
|
||||
} else if (cityData?.state) {
|
||||
params.state = cityData.state;
|
||||
} else if (cityData?.country) {
|
||||
params.country = cityData.country;
|
||||
}
|
||||
}
|
||||
const response = await FeaturedSectionApi.getFeaturedSections(params);
|
||||
const { data } = response.data;
|
||||
setFeaturedData(data);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
setIsFeaturedLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFeaturedSectionData();
|
||||
}, [cityData.lat, cityData.long, KmRange, currentLanguageCode]);
|
||||
return (
|
||||
<>
|
||||
{IsSliderLoading ? (
|
||||
<OfferSliderSkeleton />
|
||||
) : (
|
||||
Slider &&
|
||||
Slider.length > 0 && (
|
||||
<OfferSlider Slider={Slider} IsLoading={IsSliderLoading} />
|
||||
)
|
||||
)}
|
||||
<PopularCategories />
|
||||
{IsFeaturedLoading ? (
|
||||
<FeaturedSectionsSkeleton />
|
||||
) : (
|
||||
<FeaturedSections
|
||||
featuredData={featuredData}
|
||||
setFeaturedData={setFeaturedData}
|
||||
allEmpty={allEmpty}
|
||||
/>
|
||||
)}
|
||||
<AllItems cityData={cityData} KmRange={KmRange} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
435
components/PagesComponent/Home/HomeHeader.jsx
Normal file
435
components/PagesComponent/Home/HomeHeader.jsx
Normal file
@@ -0,0 +1,435 @@
|
||||
"use client";
|
||||
import LanguageDropdown from "@/components/Common/LanguageDropdown";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import {
|
||||
getIsFreAdListing,
|
||||
getOtpServiceProvider,
|
||||
settingsData,
|
||||
} from "@/redux/reducer/settingSlice";
|
||||
import { t, truncate } from "@/utils";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { useSelector } from "react-redux";
|
||||
import { GrLocation } from "react-icons/gr";
|
||||
import { getCityData } from "@/redux/reducer/locationSlice";
|
||||
import HomeMobileMenu from "./HomeMobileMenu.jsx";
|
||||
import MailSentSuccessModal from "@/components/Auth/MailSentSuccessModal.jsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getIsLoggedIn,
|
||||
logoutSuccess,
|
||||
userSignUpData,
|
||||
} from "@/redux/reducer/authSlice.js";
|
||||
import ProfileDropdown from "./ProfileDropdown.jsx";
|
||||
import { toast } from "sonner";
|
||||
import FirebaseData from "@/utils/Firebase.js";
|
||||
import { IoIosAddCircleOutline } from "react-icons/io";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
getIsLoginModalOpen,
|
||||
setIsLoginOpen,
|
||||
} from "@/redux/reducer/globalStateSlice.js";
|
||||
import ReusableAlertDialog from "@/components/Common/ReusableAlertDialog";
|
||||
import { deleteUserApi, getLimitsApi, logoutApi } from "@/utils/api.js";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import UnauthorizedModal from "@/components/Auth/UnauthorizedModal.jsx";
|
||||
import CustomImage from "@/components/Common/CustomImage.jsx";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "@/components/Common/useNavigate.jsx";
|
||||
import { usePathname } from "next/navigation.js";
|
||||
import { Skeleton } from "@/components/ui/skeleton.jsx";
|
||||
import HeaderCategories from "./HeaderCategories.jsx";
|
||||
import { deleteUser, getAuth } from "firebase/auth";
|
||||
import DeleteAccountVerifyOtpModal from "@/components/Auth/DeleteAccountVerifyOtpModal.jsx";
|
||||
import useGetCategories from "@/components/Layout/useGetCategories.jsx";
|
||||
|
||||
const Search = dynamic(() => import("./Search.jsx"), {
|
||||
ssr: false,
|
||||
});
|
||||
const LoginModal = dynamic(() => import("@/components/Auth/LoginModal.jsx"), {
|
||||
ssr: false,
|
||||
});
|
||||
const RegisterModal = dynamic(
|
||||
() => import("@/components/Auth/RegisterModal.jsx"),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
const LocationModal = dynamic(
|
||||
() => import("@/components/Location/LocationModal.jsx"),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const HeaderCategoriesSkeleton = () => {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="py-1.5 border-b">
|
||||
<Skeleton className="w-full h-[40px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HomeHeader = () => {
|
||||
// 📦 Framework & Firebase
|
||||
const { navigate } = useNavigate();
|
||||
const { signOut } = FirebaseData();
|
||||
const pathname = usePathname();
|
||||
|
||||
// 🔌 Redux State (via useSelector)
|
||||
|
||||
// User & Auth
|
||||
const userData = useSelector(userSignUpData);
|
||||
const IsLoggedin = useSelector(getIsLoggedIn);
|
||||
const IsLoginOpen = useSelector(getIsLoginModalOpen);
|
||||
const otp_service_provider = useSelector(getOtpServiceProvider);
|
||||
|
||||
|
||||
// Ads & Categories
|
||||
// const isCategoryLoading = useSelector(getIsCatLoading);
|
||||
// const cateData = useSelector(CategoryData);
|
||||
const { getCategories, cateData, isCatLoading: isCategoryLoading } = useGetCategories();
|
||||
const IsFreeAdListing = useSelector(getIsFreAdListing);
|
||||
|
||||
// Location
|
||||
const cityData = useSelector(getCityData);
|
||||
|
||||
// Language & Settings
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const settings = useSelector(settingsData);
|
||||
|
||||
// 🎛️ Local UI State (via useState)
|
||||
|
||||
// Modals
|
||||
const [IsRegisterModalOpen, setIsRegisterModalOpen] = useState(false);
|
||||
const [IsLocationModalOpen, setIsLocationModalOpen] = useState(false);
|
||||
const [IsVerifyOtpBeforeDelete, setIsVerifyOtpBeforeDelete] = useState(false);
|
||||
|
||||
// Auth State
|
||||
const [IsLogout, setIsLogout] = useState(false);
|
||||
const [IsLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
// Profile
|
||||
const [IsUpdatingProfile, setIsUpdatingProfile] = useState(false);
|
||||
|
||||
// Ad Listing
|
||||
const [IsAdListingClicked, setIsAdListingClicked] = useState(false);
|
||||
|
||||
// Email Status
|
||||
const [IsMailSentSuccess, setIsMailSentSuccess] = useState(false);
|
||||
|
||||
// 📱 Media Query
|
||||
const isLargeScreen = useMediaQuery("(min-width: 992px)");
|
||||
|
||||
//delete account state
|
||||
const [manageDeleteAccount, setManageDeleteAccount] = useState({
|
||||
IsDeleteAccount: false,
|
||||
IsDeleting: false,
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
getCategories(1);
|
||||
}, [CurrentLanguage.id]);
|
||||
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setIsLoggingOut(true);
|
||||
await signOut();
|
||||
const res = await logoutApi.logoutApi({
|
||||
...(userData?.fcm_id && { fcm_token: userData?.fcm_id }),
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
logoutSuccess();
|
||||
toast.success(t("signOutSuccess"));
|
||||
setIsLogout(false);
|
||||
// avoid redirect if already on home page otherwise router.push triggering server side api calls
|
||||
if (pathname !== "/") {
|
||||
navigate("/");
|
||||
}
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Failed to log out", error);
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdListing = async () => {
|
||||
if (!IsLoggedin) {
|
||||
setIsLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!userData?.name || !userData?.email) {
|
||||
setIsUpdatingProfile(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsFreeAdListing) {
|
||||
navigate("/ad-listing");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsAdListingClicked(true);
|
||||
const res = await getLimitsApi.getLimits({
|
||||
package_type: "item_listing",
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
navigate("/ad-listing");
|
||||
} else {
|
||||
toast.error(t("purchasePlan"));
|
||||
navigate("/subscription");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsAdListingClicked(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProfile = () => {
|
||||
setIsUpdatingProfile(false);
|
||||
navigate("/profile");
|
||||
};
|
||||
|
||||
const locationText =
|
||||
cityData?.address_translated || cityData?.formattedAddress;
|
||||
|
||||
const handleDeleteAcc = async () => {
|
||||
try {
|
||||
setManageDeleteAccount((prev) => ({ ...prev, IsDeleting: true }));
|
||||
const auth = getAuth();
|
||||
const user = auth.currentUser;
|
||||
const isMobileLogin = userData?.type == "phone";
|
||||
const needsOtpVerification = isMobileLogin && !user && otp_service_provider === "firebase";
|
||||
if (user) {
|
||||
await deleteUser(user);
|
||||
} else if (needsOtpVerification) {
|
||||
setManageDeleteAccount((prev) => ({ ...prev, IsDeleteAccount: false }));
|
||||
setIsVerifyOtpBeforeDelete(true);
|
||||
return;
|
||||
}
|
||||
await deleteUserApi.deleteUser();
|
||||
logoutSuccess();
|
||||
toast.success(t("userDeleteSuccess"));
|
||||
setManageDeleteAccount((prev) => ({ ...prev, IsDeleteAccount: false }));
|
||||
// avoid redirect if already on home page otherwise router.push triggering server side api calls
|
||||
if (pathname !== "/") {
|
||||
navigate("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error.message);
|
||||
const isMobileLogin = userData?.type === "phone";
|
||||
if (error.code === "auth/requires-recent-login") {
|
||||
if (isMobileLogin) {
|
||||
setManageDeleteAccount((prev) => ({
|
||||
...prev,
|
||||
IsDeleteAccount: false, // close delete modal
|
||||
}));
|
||||
setIsVerifyOtpBeforeDelete(true); // open OTP screen
|
||||
return;
|
||||
}
|
||||
logoutSuccess();
|
||||
toast.error(t("deletePop"));
|
||||
setManageDeleteAccount((prev) => ({ ...prev, IsDeleteAccount: false }));
|
||||
}
|
||||
} finally {
|
||||
setManageDeleteAccount((prev) => ({ ...prev, IsDeleting: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="py-5 border-b">
|
||||
<nav className="container">
|
||||
<div className="space-between">
|
||||
<CustomLink href="/">
|
||||
<CustomImage
|
||||
src={settings?.header_logo}
|
||||
alt="logo"
|
||||
width={195}
|
||||
height={52}
|
||||
className="w-full h-[52px] object-contain ltr:object-left rtl:object-right max-w-[195px]"
|
||||
/>
|
||||
</CustomLink>
|
||||
{/* desktop category search select */}
|
||||
|
||||
{isLargeScreen && (
|
||||
<div className="flex items-center border leading-none rounded">
|
||||
<Search />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="hidden lg:flex items-center gap-1"
|
||||
onClick={() => setIsLocationModalOpen(true)}
|
||||
>
|
||||
<GrLocation
|
||||
size={16}
|
||||
className="flex-shrink-0"
|
||||
title={locationText ? locationText : t("addLocation")}
|
||||
/>
|
||||
<p
|
||||
className="hidden xl:block text-sm"
|
||||
title={locationText ? locationText : t("addLocation")}
|
||||
>
|
||||
{locationText
|
||||
? truncate(locationText, 12)
|
||||
: truncate(t("addLocation"), 12)}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-2">
|
||||
{IsLoggedin ? (
|
||||
<ProfileDropdown
|
||||
setIsLogout={setIsLogout}
|
||||
IsLogout={IsLogout}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsLoginOpen(true)}
|
||||
title={t("login")}
|
||||
>
|
||||
{truncate(t("login"), 12)}
|
||||
</button>
|
||||
<span className="border-l h-6 self-center"></span>
|
||||
<button
|
||||
onClick={() => setIsRegisterModalOpen(true)}
|
||||
title={t("register")}
|
||||
>
|
||||
{truncate(t("register"), 12)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="bg-primary px-2 xl:px-4 py-2 items-center text-white rounded-md flex gap-1"
|
||||
disabled={IsAdListingClicked}
|
||||
onClick={handleAdListing}
|
||||
title={t("adListing")}
|
||||
>
|
||||
{IsAdListingClicked ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<IoIosAddCircleOutline size={18} />
|
||||
)}
|
||||
|
||||
<span className="hidden xl:inline">
|
||||
{truncate(t("adListing"), 12)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
<HomeMobileMenu
|
||||
setIsLocationModalOpen={setIsLocationModalOpen}
|
||||
setIsRegisterModalOpen={setIsRegisterModalOpen}
|
||||
setIsLogout={setIsLogout}
|
||||
locationText={locationText}
|
||||
handleAdListing={handleAdListing}
|
||||
IsAdListingClicked={IsAdListingClicked}
|
||||
setManageDeleteAccount={setManageDeleteAccount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLargeScreen && (
|
||||
<div className="flex items-center border leading-none rounded mt-2">
|
||||
<Search />
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
{isCategoryLoading && !cateData.length ? (
|
||||
<HeaderCategoriesSkeleton />
|
||||
) : (
|
||||
cateData &&
|
||||
cateData.length > 0 && <HeaderCategories cateData={cateData} />
|
||||
)}
|
||||
|
||||
<LoginModal
|
||||
key={IsLoginOpen}
|
||||
IsLoginOpen={IsLoginOpen}
|
||||
setIsRegisterModalOpen={setIsRegisterModalOpen}
|
||||
/>
|
||||
|
||||
<RegisterModal
|
||||
setIsMailSentSuccess={setIsMailSentSuccess}
|
||||
IsRegisterModalOpen={IsRegisterModalOpen}
|
||||
setIsRegisterModalOpen={setIsRegisterModalOpen}
|
||||
key={`${IsRegisterModalOpen}-register-modal`}
|
||||
/>
|
||||
<MailSentSuccessModal
|
||||
IsMailSentSuccess={IsMailSentSuccess}
|
||||
setIsMailSentSuccess={setIsMailSentSuccess}
|
||||
/>
|
||||
|
||||
{/* Reusable Alert Dialog for Logout */}
|
||||
<ReusableAlertDialog
|
||||
open={IsLogout}
|
||||
onCancel={() => setIsLogout(false)}
|
||||
onConfirm={handleLogout}
|
||||
title={t("confirmLogout")}
|
||||
description={t("areYouSureToLogout")}
|
||||
cancelText={t("cancel")}
|
||||
confirmText={t("yes")}
|
||||
confirmDisabled={IsLoggingOut}
|
||||
/>
|
||||
|
||||
{/* Reusable Alert Dialog for Updating Profile */}
|
||||
<ReusableAlertDialog
|
||||
open={IsUpdatingProfile}
|
||||
onCancel={() => setIsUpdatingProfile(false)}
|
||||
onConfirm={handleUpdateProfile}
|
||||
title={t("updateProfile")}
|
||||
description={t("youNeedToUpdateProfile")}
|
||||
confirmText={t("yes")}
|
||||
/>
|
||||
|
||||
{!isLargeScreen && (
|
||||
<ReusableAlertDialog
|
||||
open={manageDeleteAccount?.IsDeleteAccount}
|
||||
onCancel={() =>
|
||||
setManageDeleteAccount((prev) => ({
|
||||
...prev,
|
||||
IsDeleteAccount: false,
|
||||
}))
|
||||
}
|
||||
onConfirm={handleDeleteAcc}
|
||||
title={t("areYouSure")}
|
||||
description={
|
||||
<ul className="list-disc list-inside mt-2">
|
||||
<li>{t("adsAndTransactionWillBeDeleted")}</li>
|
||||
<li>{t("accountsDetailsWillNotRecovered")}</li>
|
||||
<li>{t("subWillBeCancelled")}</li>
|
||||
<li>{t("savedMesgWillBeLost")}</li>
|
||||
</ul>
|
||||
}
|
||||
cancelText={t("cancel")}
|
||||
confirmText={t("yes")}
|
||||
confirmDisabled={manageDeleteAccount?.IsDeleting}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LocationModal
|
||||
key={`${IsLocationModalOpen}-location-modal`}
|
||||
IsLocationModalOpen={IsLocationModalOpen}
|
||||
setIsLocationModalOpen={setIsLocationModalOpen}
|
||||
/>
|
||||
<UnauthorizedModal />
|
||||
<DeleteAccountVerifyOtpModal
|
||||
isOpen={IsVerifyOtpBeforeDelete}
|
||||
setIsOpen={setIsVerifyOtpBeforeDelete}
|
||||
key={`${IsVerifyOtpBeforeDelete}-delete-account-verify-otp-modal`}
|
||||
pathname={pathname}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeHeader;
|
||||
260
components/PagesComponent/Home/HomeMobileMenu.jsx
Normal file
260
components/PagesComponent/Home/HomeMobileMenu.jsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { t } from "@/utils";
|
||||
import { useState } from "react";
|
||||
import { GiHamburgerMenu } from "react-icons/gi";
|
||||
import LanguageDropdown from "../../Common/LanguageDropdown";
|
||||
import { GrLocation } from "react-icons/gr";
|
||||
import {
|
||||
IoIosAddCircleOutline,
|
||||
IoMdNotificationsOutline,
|
||||
} from "react-icons/io";
|
||||
import { setIsLoginOpen } from "@/redux/reducer/globalStateSlice";
|
||||
import { usePathname } from "next/navigation";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BiChat, BiDollarCircle, BiReceipt, BiTrashAlt } from "react-icons/bi";
|
||||
import { LiaAdSolid } from "react-icons/lia";
|
||||
import { LuHeart } from "react-icons/lu";
|
||||
import { MdOutlineRateReview, MdWorkOutline } from "react-icons/md";
|
||||
import { RiLogoutCircleLine } from "react-icons/ri";
|
||||
import { settingsData } from "@/redux/reducer/settingSlice";
|
||||
import FilterTree from "@/components/Filter/FilterTree";
|
||||
|
||||
const HomeMobileMenu = ({
|
||||
setIsLocationModalOpen,
|
||||
setIsRegisterModalOpen,
|
||||
setIsLogout,
|
||||
locationText,
|
||||
handleAdListing,
|
||||
IsAdListingClicked,
|
||||
setManageDeleteAccount,
|
||||
}) => {
|
||||
const UserData = useSelector(userSignUpData);
|
||||
const settings = useSelector(settingsData);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
const showMenu = !!UserData;
|
||||
const showCategories = !pathname.startsWith("/ads");
|
||||
|
||||
const openLocationEditModal = () => {
|
||||
setIsOpen(false);
|
||||
setIsLocationModalOpen(true);
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
setIsOpen(false);
|
||||
setIsLoginOpen(true);
|
||||
};
|
||||
|
||||
const handleRegister = () => {
|
||||
setIsOpen(false);
|
||||
setIsRegisterModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSignOut = () => {
|
||||
setIsOpen(false);
|
||||
setIsLogout(true);
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
setIsOpen(false);
|
||||
setManageDeleteAccount((prev) => ({
|
||||
...prev,
|
||||
IsDeleteAccount: true,
|
||||
}));
|
||||
};
|
||||
|
||||
// All user links
|
||||
const navItems = (
|
||||
<div className="flex flex-col px-4 pb-4">
|
||||
<CustomLink
|
||||
href="/notifications"
|
||||
className="flex items-center gap-1 py-4"
|
||||
>
|
||||
<IoMdNotificationsOutline size={24} />
|
||||
<span>{t("notifications")}</span>
|
||||
</CustomLink>
|
||||
<CustomLink href="/chat" className="flex items-center gap-1 py-4">
|
||||
<BiChat size={24} />
|
||||
<span>{t("chat")}</span>
|
||||
</CustomLink>
|
||||
<CustomLink
|
||||
href="/user-subscription"
|
||||
className="flex items-center gap-1 py-4"
|
||||
>
|
||||
<BiDollarCircle size={24} />
|
||||
<span>{t("subscription")}</span>
|
||||
</CustomLink>
|
||||
<CustomLink href="/my-ads" className="flex items-center gap-1 py-4">
|
||||
<LiaAdSolid size={24} />
|
||||
<span>{t("myAds")}</span>
|
||||
</CustomLink>
|
||||
<CustomLink href="/favorites" className="flex items-center gap-1 py-4">
|
||||
<LuHeart size={24} />
|
||||
<span>{t("favorites")}</span>
|
||||
</CustomLink>
|
||||
<CustomLink href="/transactions" className="flex items-center gap-1 py-4">
|
||||
<BiReceipt size={24} />
|
||||
<span>{t("transaction")}</span>
|
||||
</CustomLink>
|
||||
<CustomLink href="/reviews" className="flex items-center gap-1 py-4">
|
||||
<MdOutlineRateReview size={24} />
|
||||
<span>{t("myReviews")}</span>
|
||||
</CustomLink>
|
||||
<CustomLink
|
||||
href="/job-applications"
|
||||
className="flex items-center gap-1 py-4"
|
||||
>
|
||||
<MdWorkOutline size={24} />
|
||||
<span>{t("jobApplications")}</span>
|
||||
</CustomLink>
|
||||
<button onClick={handleSignOut} className="flex items-center gap-1 py-4">
|
||||
<RiLogoutCircleLine size={24} />
|
||||
<span>{t("signOut")}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
className="flex items-center gap-1 text-destructive py-4"
|
||||
>
|
||||
<BiTrashAlt size={24} />
|
||||
<span>{t("deleteAccount")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen} className="lg:hidden">
|
||||
<SheetTrigger asChild className="lg:hidden">
|
||||
<button
|
||||
id="hamburg"
|
||||
className="text-2xl cursor-pointer border rounded-lg p-1"
|
||||
>
|
||||
<GiHamburgerMenu size={25} className="text-primary" />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="[&>button:first-child]:hidden] p-0 overflow-y-auto">
|
||||
<SheetHeader className="p-4 border-b border">
|
||||
<SheetTitle>
|
||||
<CustomImage
|
||||
src={settings?.header_logo}
|
||||
width={195}
|
||||
height={92}
|
||||
alt="Logo"
|
||||
className="w-full h-[52px] object-contain ltr:object-left rtl:object-right max-w-[195px]"
|
||||
/>
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only"></SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{UserData ? (
|
||||
<CustomLink href="/profile" className="flex items-center gap-2">
|
||||
<CustomImage
|
||||
src={UserData?.profile}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={UserData?.name}
|
||||
className="rounded-full size-12 aspect-square object-cover border"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<p
|
||||
className="line-clamp-2"
|
||||
title={UserData?.name}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{UserData?.name}
|
||||
</p>
|
||||
</CustomLink>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleLogin}>{t("login")}</button>
|
||||
<span className="border-l h-6 self-center"></span>
|
||||
<button onClick={handleRegister}>{t("register")}</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-shrink-0">
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer"
|
||||
onClick={openLocationEditModal}
|
||||
>
|
||||
<GrLocation size={16} className="flex-shrink-0" />
|
||||
<p
|
||||
className="line-clamp-2"
|
||||
title={locationText ? locationText : t("addLocation")}
|
||||
>
|
||||
{locationText ? locationText : t("addLocation")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="flex items-center justify-center gap-2 bg-primary py-2 px-3 text-white rounded-md"
|
||||
disabled={IsAdListingClicked}
|
||||
onClick={handleAdListing}
|
||||
>
|
||||
{IsAdListingClicked ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<IoIosAddCircleOutline size={18} />
|
||||
)}
|
||||
<span>{t("adListing")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMenu && showCategories ? (
|
||||
<Tabs defaultValue="menu">
|
||||
<TabsList className="flex items-center justify-between bg-muted rounded-none">
|
||||
<TabsTrigger
|
||||
value="menu"
|
||||
className="flex-1 data-state-active:bg-primary"
|
||||
>
|
||||
{t("menu")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="categories"
|
||||
className="flex-1 data-state-active:bg-primary"
|
||||
>
|
||||
{t("multipleCategories")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="menu">
|
||||
{navItems}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="categories" className="mt-4 px-4 pb-4">
|
||||
<FilterTree />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : showMenu ? (
|
||||
navItems
|
||||
) : showCategories ? (
|
||||
<div className="px-4 pb-4 flex flex-col gap-4">
|
||||
<h1 className="font-medium">{t("multipleCategories")}</h1>
|
||||
<FilterTree />
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeMobileMenu;
|
||||
124
components/PagesComponent/Home/OfferSlider.jsx
Normal file
124
components/PagesComponent/Home/OfferSlider.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RiArrowLeftLine, RiArrowRightLine } from "react-icons/ri";
|
||||
import Autoplay from "embla-carousel-autoplay";
|
||||
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||
import { useSelector } from "react-redux";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const OfferSlider = ({ Slider }) => {
|
||||
const [api, setApi] = useState();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const userData = useSelector(userSignUpData);
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
setCurrent(api.selectedScrollSnap());
|
||||
api.on("select", () => {
|
||||
setCurrent(api.selectedScrollSnap());
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<section className="py-6 bg-muted">
|
||||
<div className="container">
|
||||
<Carousel
|
||||
key={isRTL ? "rtl" : "ltr"}
|
||||
className="w-full"
|
||||
setApi={setApi}
|
||||
opts={{
|
||||
align: "start",
|
||||
containScroll: "trim",
|
||||
direction: isRTL ? "rtl" : "ltr",
|
||||
}}
|
||||
plugins={[Autoplay({ delay: 3000 })]}
|
||||
>
|
||||
<CarouselContent className="-ml-3 md:-ml-[30px]">
|
||||
{Slider.map((ele, index) => {
|
||||
let href;
|
||||
if (ele?.model_type === "App\\Models\\Item") {
|
||||
if (userData && userData?.id === ele?.model?.user_id) {
|
||||
href = `/my-listing/${ele?.model?.slug}`;
|
||||
} else {
|
||||
href = `/ad-details/${ele?.model?.slug}`;
|
||||
}
|
||||
} else if (ele?.model_type === null) {
|
||||
href = ele?.third_party_link;
|
||||
} else if (ele?.model_type === "App\\Models\\Category") {
|
||||
href = `/ads?category=${ele.model.slug}`;
|
||||
} else {
|
||||
href = "/";
|
||||
}
|
||||
// First 2 images load with eager loading and priority for Lighthouse performance
|
||||
const isPriorityImage = index < 2;
|
||||
return (
|
||||
<CarouselItem
|
||||
className="basis-full md:basis-2/3 pl-3 md:pl-[30px]"
|
||||
key={ele?.id}
|
||||
>
|
||||
<CustomLink
|
||||
href={href}
|
||||
target={ele?.model_type === null ? "_blank" : ""}
|
||||
>
|
||||
<CustomImage
|
||||
src={ele.image}
|
||||
alt="slider imag"
|
||||
width={983}
|
||||
height={493}
|
||||
className="aspect-[983/493] w-full object-cover rounded-xl"
|
||||
loading={isPriorityImage ? "eager" : "lazy"}
|
||||
priority={isPriorityImage}
|
||||
/>
|
||||
</CustomLink>
|
||||
</CarouselItem>
|
||||
);
|
||||
})}
|
||||
</CarouselContent>
|
||||
|
||||
{Slider && Slider?.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => api?.scrollTo(current - 1)}
|
||||
className={`sm:block absolute z-10 top-1/2 -translate-y-1/2 ltr:left-2 rtl:right-2 bg-primary p-1 md:p-2 rounded-full ${
|
||||
!api?.canScrollPrev() ? "cursor-default" : ""
|
||||
}`}
|
||||
disabled={!api?.canScrollPrev()}
|
||||
>
|
||||
<RiArrowLeftLine
|
||||
size={24}
|
||||
color="white"
|
||||
className={isRTL ? "rotate-180" : ""}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => api?.scrollTo(current + 1)}
|
||||
className={`sm:block absolute z-10 top-1/2 -translate-y-1/2 ltr:right-2 rtl:left-2 bg-primary p-1 md:p-2 rounded-full ${
|
||||
!api?.canScrollNext() ? "cursor-default" : ""
|
||||
}`}
|
||||
disabled={!api?.canScrollNext()}
|
||||
>
|
||||
<RiArrowRightLine
|
||||
size={24}
|
||||
color="white"
|
||||
className={isRTL ? "rotate-180" : ""}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Carousel>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default OfferSlider;
|
||||
23
components/PagesComponent/Home/OfferSliderSkeleton.jsx
Normal file
23
components/PagesComponent/Home/OfferSliderSkeleton.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const OfferSliderSkeleton = () => {
|
||||
return (
|
||||
<section className="py-6 bg-muted">
|
||||
<div className="container overflow-hidden">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[66.66%_66.66%] gap-4">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<Skeleton
|
||||
className={`${
|
||||
index === 1 ? "hidden md:block" : ""
|
||||
} aspect-[983/493] w-full rounded-xl`}
|
||||
key={index}
|
||||
height={493}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default OfferSliderSkeleton;
|
||||
123
components/PagesComponent/Home/PopularCategories.jsx
Normal file
123
components/PagesComponent/Home/PopularCategories.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RiArrowLeftLine, RiArrowRightLine } from "react-icons/ri";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import PopularCategoriesSkeleton from "./PopularCategoriesSkeleton.jsx";
|
||||
import PopularCategoryCard from "@/components/PagesComponent/Home/PopularCategoryCard";
|
||||
import { useSelector } from "react-redux";
|
||||
import { t } from "@/utils";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice.js";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import useGetCategories from "@/components/Layout/useGetCategories.jsx";
|
||||
|
||||
const PopularCategories = () => {
|
||||
const {
|
||||
cateData,
|
||||
getCategories,
|
||||
isCatLoading,
|
||||
isCatLoadMore,
|
||||
catLastPage,
|
||||
catCurrentPage,
|
||||
} = useGetCategories();
|
||||
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
const [api, setApi] = useState();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const isNextDisabled =
|
||||
isCatLoadMore ||
|
||||
((!api || !api.canScrollNext()) && catCurrentPage >= catLastPage);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
setCurrent(api.selectedScrollSnap());
|
||||
api.on("select", () => {
|
||||
setCurrent(api.selectedScrollSnap());
|
||||
});
|
||||
}, [api, cateData.length]);
|
||||
|
||||
const handleNext = async () => {
|
||||
if (api && api.canScrollNext()) {
|
||||
api.scrollTo(current + 1);
|
||||
} else if (catCurrentPage < catLastPage) {
|
||||
await getCategories(catCurrentPage + 1);
|
||||
setTimeout(() => {
|
||||
api.scrollTo(current + 1);
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
return isCatLoading && !cateData.length ? (
|
||||
<PopularCategoriesSkeleton />
|
||||
) : (
|
||||
cateData && cateData.length > 0 && (
|
||||
<section className="container mt-12">
|
||||
<div className="space-between">
|
||||
<h5 className="text-xl sm:text-2xl font-medium">
|
||||
{t("popularCategories")}
|
||||
</h5>
|
||||
<div className="flex items-center justify-center gap-2 sm:gap-4">
|
||||
<button
|
||||
onClick={() => api && api.scrollTo(current - 1)}
|
||||
className={`bg-primary p-1 sm:p-2 rounded-full ${
|
||||
!api?.canScrollPrev() ? "opacity-65 cursor-default" : ""
|
||||
}`}
|
||||
disabled={!api?.canScrollPrev()}
|
||||
>
|
||||
<RiArrowLeftLine
|
||||
size={24}
|
||||
color="white"
|
||||
className={isRTL ? "rotate-180" : ""}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className={`bg-primary p-1 sm:p-2 rounded-full ${
|
||||
isNextDisabled ? "opacity-65 cursor-default" : ""
|
||||
}`}
|
||||
disabled={isNextDisabled}
|
||||
>
|
||||
{isCatLoadMore ? (
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
) : (
|
||||
<RiArrowRightLine
|
||||
size={24}
|
||||
color="white"
|
||||
className={isRTL ? "rotate-180" : ""}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Carousel
|
||||
key={isRTL ? "rtl" : "ltr"}
|
||||
className="w-full mt-6"
|
||||
setApi={setApi}
|
||||
opts={{
|
||||
align: "start",
|
||||
containScroll: "trim",
|
||||
direction: isRTL ? "rtl" : "ltr",
|
||||
}}
|
||||
>
|
||||
<CarouselContent className="-ml-3 md:-ml-[30px]">
|
||||
{cateData.map((item) => (
|
||||
<CarouselItem
|
||||
key={item?.id}
|
||||
className="basis-1/3 sm:basis-1/4 md:basis-1/5 lg:basis-[16.66%] xl:basis-[12.5%] 2xl:basis-[11.11%] md:pl-[30px]"
|
||||
>
|
||||
<PopularCategoryCard item={item} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default PopularCategories;
|
||||
38
components/PagesComponent/Home/PopularCategoriesSkeleton.jsx
Normal file
38
components/PagesComponent/Home/PopularCategoriesSkeleton.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Carousel, CarouselContent, CarouselItem } from "../../ui/carousel";
|
||||
import { Skeleton } from "../../ui/skeleton";
|
||||
|
||||
const PopularCategoriesSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="container mt-12">
|
||||
<div className="space-between">
|
||||
<Skeleton className="w-1/4 h-4" />
|
||||
<Skeleton className="w-1/12 h-4" />
|
||||
</div>
|
||||
<Carousel
|
||||
className="w-full mt-6"
|
||||
opts={{
|
||||
align: "start",
|
||||
containScroll: "trim",
|
||||
}}
|
||||
>
|
||||
<CarouselContent className="-ml-3 md:-ml-[30px]">
|
||||
{Array.from({ length: 9 }).map((_, index) => (
|
||||
<CarouselItem
|
||||
key={index}
|
||||
className="basis-1/3 sm:basis-1/4 md:basis-1/5 lg:basis-[16.66%] xl:basis-[12.5%] 2xl:basis-[11.11%] md:pl-[30px]"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="w-full aspect-square rounded-full" />
|
||||
<Skeleton className="w-full h-4" />
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopularCategoriesSkeleton;
|
||||
28
components/PagesComponent/Home/PopularCategoryCard.jsx
Normal file
28
components/PagesComponent/Home/PopularCategoryCard.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const PopularCategoryCard = ({ item }) => {
|
||||
return (
|
||||
<CustomLink
|
||||
href={`/ads?category=${item?.slug}`}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="border p-2.5 rounded-full">
|
||||
<CustomImage
|
||||
src={item?.image}
|
||||
width={96}
|
||||
height={96}
|
||||
className="aspect-square w-full rounded-full"
|
||||
alt="Category"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm sm:text-base line-clamp-2 font-medium text-center leading-tight">
|
||||
{item?.translated_name}
|
||||
</p>
|
||||
</CustomLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopularCategoryCard;
|
||||
121
components/PagesComponent/Home/ProfileDropdown.jsx
Normal file
121
components/PagesComponent/Home/ProfileDropdown.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||
import { t, truncate } from "@/utils";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FiUser } from "react-icons/fi";
|
||||
import { IoMdNotificationsOutline } from "react-icons/io";
|
||||
import { BiChat, BiDollarCircle, BiReceipt } from "react-icons/bi";
|
||||
import { LiaAdSolid } from "react-icons/lia";
|
||||
import { LuHeart } from "react-icons/lu";
|
||||
import { MdOutlineRateReview, MdWorkOutline } from "react-icons/md";
|
||||
import { RiLogoutCircleLine } from "react-icons/ri";
|
||||
import { FaAngleDown } from "react-icons/fa";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
|
||||
const ProfileDropdown = ({ IsLogout, setIsLogout }) => {
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1200px)");
|
||||
const { navigate } = useNavigate();
|
||||
const UserData = useSelector(userSignUpData);
|
||||
return (
|
||||
<DropdownMenu key={IsLogout}>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1">
|
||||
<CustomImage
|
||||
src={UserData?.profile}
|
||||
alt={UserData?.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full w-8 h-8 aspect-square object-cover border"
|
||||
/>
|
||||
<p>{truncate(UserData.name, 12)}</p>
|
||||
<FaAngleDown className="text-muted-foreground flex-shrink-0" size={12} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={isSmallScreen ? "start" : "center"}>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate("/profile")}
|
||||
>
|
||||
<FiUser size={16} />
|
||||
<span>{t("myProfile")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate("/notifications")}
|
||||
>
|
||||
<IoMdNotificationsOutline size={16} />
|
||||
{t("notification")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate("/chat")}
|
||||
>
|
||||
<BiChat size={16} />
|
||||
{t("chat")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate("/user-subscription")}
|
||||
>
|
||||
<BiDollarCircle size={16} />
|
||||
{t("subscription")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate("/my-ads")}
|
||||
>
|
||||
<LiaAdSolid size={16} />
|
||||
{t("myAds")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate("/favorites")}
|
||||
>
|
||||
<LuHeart size={16} />
|
||||
{t("favorites")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => navigate("/transactions")}>
|
||||
<BiReceipt size={16} />
|
||||
{t("transaction")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate("/reviews")}
|
||||
>
|
||||
<MdOutlineRateReview size={16} />
|
||||
{t("myReviews")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate("/job-applications")}
|
||||
>
|
||||
<MdWorkOutline size={16} />
|
||||
{t("jobApplications")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsLogout(true)}
|
||||
>
|
||||
<RiLogoutCircleLine size={16} />
|
||||
{t("signOut")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileDropdown;
|
||||
156
components/PagesComponent/Home/Search.jsx
Normal file
156
components/PagesComponent/Home/Search.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { t } from "@/utils";
|
||||
import { BiPlanet } from "react-icons/bi";
|
||||
import { FaSearch } from "react-icons/fa";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
import useGetCategories from "@/components/Layout/useGetCategories";
|
||||
|
||||
const Search = () => {
|
||||
const {
|
||||
cateData,
|
||||
getCategories,
|
||||
isCatLoadMore,
|
||||
catLastPage,
|
||||
catCurrentPage,
|
||||
} = useGetCategories();
|
||||
|
||||
const pathname = usePathname();
|
||||
const { navigate } = useNavigate();
|
||||
const categoryList = [
|
||||
{ slug: "all-categories", translated_name: t("allCategories") },
|
||||
...cateData,
|
||||
];
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState("all-categories");
|
||||
const selectedItem = categoryList.find((item) => item.slug === value);
|
||||
const hasMore = catCurrentPage < catLastPage;
|
||||
const { ref, inView } = useInView();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open && inView && hasMore && !isCatLoadMore) {
|
||||
getCategories(catCurrentPage + 1);
|
||||
}
|
||||
}, [hasMore, inView, isCatLoadMore, open]);
|
||||
|
||||
const handleSearchNav = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const query = encodeURIComponent(searchQuery);
|
||||
|
||||
// Build the base URL with query and language
|
||||
const baseUrl = `/ads?query=${query}`;
|
||||
|
||||
// Add category parameter if not "all-categories"
|
||||
const url =
|
||||
selectedItem?.slug === "all-categories"
|
||||
? baseUrl
|
||||
: `/ads?category=${selectedItem?.slug}&query=${query}`;
|
||||
|
||||
// Use consistent navigation method
|
||||
if (pathname === "/ads") {
|
||||
// If already on ads page, use history API to avoid full page reload
|
||||
window.history.pushState(null, "", url);
|
||||
} else {
|
||||
// If on different page, use router for navigation
|
||||
navigate(url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="min-w-[125px] max-w-[125px] sm:min-w-[156px] sm:max-w-[156px] py-1 px-1.5 sm:py-2 sm:px-3 justify-between border-none hover:bg-transparent font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedItem?.translated_name || t("selectCat")}
|
||||
</span>
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("searchACategory")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("noCategoryFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{categoryList.map((category, index) => {
|
||||
const isLast = open && index === categoryList.length - 1;
|
||||
return (
|
||||
<CommandItem
|
||||
key={category?.slug}
|
||||
value={category?.slug}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
ref={isLast ? ref : null}
|
||||
>
|
||||
{category.translated_name || category?.name}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === category.slug ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
{isCatLoadMore && (
|
||||
<div className="flex justify-center items-center pb-2 text-muted-foreground">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<form
|
||||
onSubmit={handleSearchNav}
|
||||
className="w-full flex items-center gap-2 ltr:border-l rtl:border-r py-1 px-1.5 sm:py-2 sm:px-3"
|
||||
>
|
||||
<BiPlanet color="#595B6C" className="min-w-4 min-h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("searchAd")}
|
||||
className="text-sm outline-none w-full"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="flex items-center gap-2 bg-primary text-white p-2 rounded"
|
||||
type="submit"
|
||||
>
|
||||
<FaSearch size={14} />
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
150
components/PagesComponent/JobApplications/JobApplications.jsx
Normal file
150
components/PagesComponent/JobApplications/JobApplications.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
import { formatDateMonthYear, t } from "@/utils";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import JobApplicationSkeleton from "@/components/Skeletons/JobApplicationSkeleton";
|
||||
import { toast } from "sonner";
|
||||
import Pagination from "@/components/Common/Pagination";
|
||||
import { getMyJobApplicationsList } from "@/utils/api";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
|
||||
const JobApplications = () => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const [jobApplication, setJobApplication] = useState({
|
||||
data: [],
|
||||
currentPage: 1,
|
||||
totalItems: 0,
|
||||
perPage: 15,
|
||||
totalPages: 0,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobApplicationsData(1);
|
||||
}, [jobApplication?.currentPage, CurrentLanguage.code]);
|
||||
|
||||
const fetchJobApplicationsData = async (page) => {
|
||||
try {
|
||||
setJobApplication((prev) => ({ ...prev, isLoading: true }));
|
||||
const response = await getMyJobApplicationsList.getMyJobApplications({
|
||||
page,
|
||||
});
|
||||
if (response.data.error === false) {
|
||||
setJobApplication((prev) => ({
|
||||
...prev,
|
||||
data: response?.data?.data?.data,
|
||||
totalItems: response?.data?.data?.total,
|
||||
perPage: response?.data?.data?.per_page,
|
||||
totalPages: response?.data?.data?.last_page,
|
||||
currentPage: response?.data?.data?.current_page,
|
||||
}));
|
||||
} else {
|
||||
toast.error(response.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
setJobApplication((prev) => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setJobApplication((prev) => ({ ...prev, currentPage: page }));
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return <Badge className="bg-yellow-500">{t("pending")}</Badge>;
|
||||
case "accepted":
|
||||
return <Badge className="bg-green-600">{t("accepted")}</Badge>;
|
||||
case "rejected":
|
||||
return <Badge className="bg-red-500">{t("rejected")}</Badge>;
|
||||
default:
|
||||
return <Badge>{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{jobApplication?.isLoading ? (
|
||||
<JobApplicationSkeleton />
|
||||
) : jobApplication?.data?.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-hidden border rounded-md">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
<TableRow className="text-xs sm:text-sm">
|
||||
<TableHead>{t("jobTitle")}</TableHead>
|
||||
<TableHead>{t("recruiter")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("appliedDate")}</TableHead>
|
||||
<TableHead>{t("resume")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-xs sm:text-sm">
|
||||
{jobApplication?.data?.map((application) => (
|
||||
<TableRow
|
||||
key={application?.id}
|
||||
className="hover:bg-muted text-center"
|
||||
>
|
||||
<TableCell>{application.item?.translated_name || "-"}</TableCell>
|
||||
<TableCell>{application.recruiter?.name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(application.status || "Pending")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDateMonthYear(application.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{application?.resume ? (
|
||||
<CustomLink
|
||||
href={application?.resume}
|
||||
target="_blank"
|
||||
className="text-primary underline font-medium"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("viewResume")}
|
||||
</CustomLink>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic text-sm">
|
||||
{t("notAvailable")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex justify-end items-center mb-2">
|
||||
<Pagination
|
||||
className="mt-7"
|
||||
currentPage={jobApplication?.currentPage}
|
||||
totalPages={jobApplication?.totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoData name={t("jobApplications")} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobApplications;
|
||||
172
components/PagesComponent/LandingPage/AnythingYouWant.jsx
Normal file
172
components/PagesComponent/LandingPage/AnythingYouWant.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
import { t } from "@/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { IoSearchOutline } from "react-icons/io5";
|
||||
import Img1 from "../../../public/assets/Image1.png";
|
||||
import Img2 from "../../../public/assets/Image2.png";
|
||||
import Img3 from "../../../public/assets/Image3.png";
|
||||
import Img4 from "../../../public/assets/Image4.png";
|
||||
import Img5 from "../../../public/assets/Image5.png";
|
||||
import Img6 from "../../../public/assets/Image6.png";
|
||||
import {
|
||||
getCityData,
|
||||
getIsBrowserSupported,
|
||||
getKilometerRange,
|
||||
saveCity,
|
||||
} from "@/redux/reducer/locationSlice";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import LocationModal from "../../Location/LocationModal";
|
||||
import { toast } from "sonner";
|
||||
import { FaLocationCrosshairs } from "react-icons/fa6";
|
||||
import { getCompanyName } from "@/redux/reducer/settingSlice";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { setIsVisitedLandingPage } from "@/redux/reducer/globalStateSlice";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
import LandingAdEditSearchAutocomplete from "@/components/Location/LandingAdEditSearchAutocomplete";
|
||||
|
||||
const AnythingYouWant = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { navigate } = useNavigate();
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const LocationData = useSelector(getCityData);
|
||||
const companyName = useSelector(getCompanyName);
|
||||
const [selectedCity, setSelectedCity] = useState(LocationData);
|
||||
const IsBrowserSupported = useSelector(getIsBrowserSupported);
|
||||
const [IsLocationModalOpen, setIsLocationModalOpen] = useState(false);
|
||||
const KmRange = useSelector(getKilometerRange);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setIsVisitedLandingPage(true));
|
||||
}, []);
|
||||
|
||||
const handleSearchLocation = () => {
|
||||
const isInvalidLocation =
|
||||
KmRange > 0
|
||||
? !selectedCity?.lat || !selectedCity?.long
|
||||
: !selectedCity?.areaId &&
|
||||
!selectedCity?.city &&
|
||||
!selectedCity?.state &&
|
||||
!selectedCity?.country;
|
||||
|
||||
if (isInvalidLocation) {
|
||||
toast.error(t("pleaseSelectLocation"));
|
||||
return;
|
||||
}
|
||||
|
||||
saveCity(selectedCity);
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
id="anythingYouWant"
|
||||
className="py-28 bg-muted flex items-center justify-center"
|
||||
>
|
||||
<div className="container relative">
|
||||
<div className="flex flex-col items-center gap-6 text-center">
|
||||
<div className="flex flex-col items-center font-bold text-4xl lg:text-5xl gap-3 relative">
|
||||
<h1 className="flex flex-column items-center relative z-10 after:content-[''] after:absolute after:bg-[#00b2ca] after:h-[40%] after:w-full after:z-[-1] after:bottom-0">
|
||||
{t("buySell")}
|
||||
</h1>
|
||||
<h1>{t("anythingYouWant")}</h1>
|
||||
</div>
|
||||
<p className="text-sm font-light md:w-1/2">
|
||||
{t("discoverEndlessPossibilitiesAt")} {companyName}{" "}
|
||||
{t("goToMarketplace")}
|
||||
</p>
|
||||
<div className="space-between gap-3 rounded border w-full lg:w-[60%] bg-white py-2 ltr:pr-2 rtl:pl-2 relative">
|
||||
<LandingAdEditSearchAutocomplete
|
||||
saveOnSuggestionClick={false}
|
||||
setSelectedLocation={setSelectedCity}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
{IsBrowserSupported && (
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsLocationModalOpen(true)}
|
||||
>
|
||||
<FaLocationCrosshairs size={22} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-2 bg-primary px-3 py-[6px] rounded text-white"
|
||||
onClick={handleSearchLocation}
|
||||
>
|
||||
<IoSearchOutline size={22} />
|
||||
<span className="hidden md:block">{t("search")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<CustomLink
|
||||
href="/"
|
||||
className="hidden sm:flex items-center gap-2 text-destructive"
|
||||
>
|
||||
<span className="whitespace-nowrap">{t("skip")}</span>
|
||||
<ArrowRight size={16} className="rtl:scale-x-[-1]" />
|
||||
</CustomLink>
|
||||
</div>
|
||||
<CustomLink
|
||||
href="/"
|
||||
className="sm:hidden flex items-center gap-2 text-destructive"
|
||||
>
|
||||
<span className="whitespace-nowrap">{t("skip")}</span>
|
||||
<ArrowRight size={16} className="rtl:scale-x-[-1]" />
|
||||
</CustomLink>
|
||||
</div>
|
||||
<CustomImage
|
||||
src={Img1}
|
||||
className="hidden xl:block absolute xl:-top-[38%] ltr:xl:left-[3%] rtl:xl:right-[3%] xl:w-[110px] rounded-full"
|
||||
height={135}
|
||||
width={90}
|
||||
alt="landing page image 1"
|
||||
/>
|
||||
<CustomImage
|
||||
src={Img2}
|
||||
className="hidden xl:block absolute xl:top-[38%] ltr:xl:left-[9%] rtl:xl:right-[9%] xl:w-[110px] rounded-full"
|
||||
height={135}
|
||||
width={90}
|
||||
alt="landing page image 2"
|
||||
/>
|
||||
<CustomImage
|
||||
src={Img3}
|
||||
className="hidden xl:block absolute xl:top-[120%] ltr:xl:left-[3%] rtl:xl:right-[3%] xl:w-[110px] rounded-full"
|
||||
height={90}
|
||||
width={90}
|
||||
alt="landing page image 3"
|
||||
/>
|
||||
<CustomImage
|
||||
src={Img4}
|
||||
className="hidden xl:block absolute xl:-top-[38%] ltr:xl:right-[3%] rtl:xl:left-[3%] xl:w-[110px] rounded-full"
|
||||
height={135}
|
||||
width={90}
|
||||
alt="landing page image 4"
|
||||
/>
|
||||
<CustomImage
|
||||
src={Img5}
|
||||
className="hidden xl:block absolute xl:top-[38%] ltr:xl:right-[9%] rtl:xl:left-[9%] xl:w-[110px] rounded-full"
|
||||
height={90}
|
||||
width={90}
|
||||
alt="landing page image 5"
|
||||
/>
|
||||
<CustomImage
|
||||
src={Img6}
|
||||
className="hidden xl:block absolute xl:top-[109%] ltr:xl:right-[3%] rtl:xl:left-[3%] xl:w-[110px] rounded-full"
|
||||
height={0}
|
||||
width={0}
|
||||
alt="landing page image 6"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<LocationModal
|
||||
key={`${IsLocationModalOpen}-location-modal`}
|
||||
IsLocationModalOpen={IsLocationModalOpen}
|
||||
setIsLocationModalOpen={setIsLocationModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnythingYouWant;
|
||||
37
components/PagesComponent/LandingPage/BlogCard.jsx
Normal file
37
components/PagesComponent/LandingPage/BlogCard.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
import { FaArrowRight } from "react-icons/fa";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const BlogCard = ({ blog }) => {
|
||||
return (
|
||||
<div className="p-4 rounded-3xl flex flex-col gap-4 border h-100 bg-white h-full">
|
||||
<CustomImage
|
||||
src={blog?.image}
|
||||
alt="Blog image"
|
||||
className="w-full object-cover rounded-[8px] aspect-[388/200]"
|
||||
width={378}
|
||||
height={195}
|
||||
/>
|
||||
<h5 className="text-lg font-semibold truncate">
|
||||
{blog?.translated_title || blog?.title}
|
||||
</h5>
|
||||
<p
|
||||
className="opacity-65 line-clamp-2"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: blog?.translated_description || blog?.description,
|
||||
}}
|
||||
></p>
|
||||
<CustomLink
|
||||
href={`/blogs/${blog?.slug}`}
|
||||
className="flex items-center gap-3 text-primary text-lg mt-auto"
|
||||
>
|
||||
<span>{t("readArticle")}</span>
|
||||
<FaArrowRight className="rtl:scale-x-[-1]" size={20} />
|
||||
</CustomLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogCard;
|
||||
141
components/PagesComponent/LandingPage/LandingHeader.jsx
Normal file
141
components/PagesComponent/LandingPage/LandingHeader.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
import { t } from "@/utils";
|
||||
import { useSelector } from "react-redux";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import { settingsData } from "@/redux/reducer/settingSlice";
|
||||
import LanguageDropdown from "../../Common/LanguageDropdown";
|
||||
import LandingMobileMenu from "@/components/PagesComponent/LandingPage/LandingMobileMenu";
|
||||
import { useState, useEffect } from "react";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
|
||||
const LandingHeader = () => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const settings = useSelector(settingsData);
|
||||
const [isShowMobileMenu, setIsShowMobileMenu] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("anythingYouWant");
|
||||
|
||||
const handleMobileMenuClose = () => {
|
||||
if (isShowMobileMenu) {
|
||||
setIsShowMobileMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Intersection Observer to track which section is currently visible
|
||||
useEffect(() => {
|
||||
const sections = ["anythingYouWant", "work_process", "faq", "ourBlogs"];
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: "0px", // Trigger when section is 20% from top
|
||||
threshold: 0.7,
|
||||
};
|
||||
const observerCallback = (entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
const observer = new IntersectionObserver(
|
||||
observerCallback,
|
||||
observerOptions
|
||||
);
|
||||
// Observe all sections
|
||||
sections.forEach((sectionId) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
});
|
||||
// Cleanup observer on component unmount
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 bg-white shadow-xs">
|
||||
<nav className="shadow-md">
|
||||
<div className="container py-5 lg:flex lg:items-center lg:justify-between">
|
||||
<div className="flex w-100 items-center justify-between">
|
||||
<CustomImage
|
||||
src={settings?.header_logo}
|
||||
className="w-full h-[52px] object-contain ltr:object-left rtl:object-right max-w-[195px]"
|
||||
alt="logo"
|
||||
width={195}
|
||||
height={52}
|
||||
/>
|
||||
|
||||
<LandingMobileMenu
|
||||
isOpen={isShowMobileMenu}
|
||||
setIsOpen={setIsShowMobileMenu}
|
||||
activeSection={activeSection}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden lg:flex gap-6">
|
||||
<ul className="flex items-center gap-6">
|
||||
<li>
|
||||
<CustomLink
|
||||
href="#anythingYouWant"
|
||||
className={`cursor-pointer transition-all duration-200 ${
|
||||
activeSection === "anythingYouWant"
|
||||
? "text-primary"
|
||||
: "hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
{t("home")}
|
||||
</CustomLink>
|
||||
</li>
|
||||
<li>
|
||||
<CustomLink
|
||||
href="#work_process"
|
||||
className={`cursor-pointer transition-all duration-200 ${
|
||||
activeSection === "work_process"
|
||||
? "text-primary"
|
||||
: "hover:text-primary"
|
||||
}`}
|
||||
onClick={handleMobileMenuClose}
|
||||
>
|
||||
{t("whyChooseUs")}
|
||||
</CustomLink>
|
||||
</li>
|
||||
<li>
|
||||
<CustomLink
|
||||
href="#faq"
|
||||
className={`cursor-pointer transition-all duration-200 ${
|
||||
activeSection === "faq"
|
||||
? "text-primary"
|
||||
: "hover:text-primary"
|
||||
}`}
|
||||
onClick={handleMobileMenuClose}
|
||||
>
|
||||
{t("faqs")}
|
||||
</CustomLink>
|
||||
</li>
|
||||
<li>
|
||||
<CustomLink
|
||||
href="#ourBlogs"
|
||||
className={`cursor-pointer transition-all duration-200 ${
|
||||
activeSection === "ourBlogs"
|
||||
? "text-primary"
|
||||
: "hover:text-primary"
|
||||
}`}
|
||||
onClick={handleMobileMenuClose}
|
||||
>
|
||||
{t("blog")}
|
||||
</CustomLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="hidden lg:flex">
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingHeader;
|
||||
103
components/PagesComponent/LandingPage/LandingMobileMenu.jsx
Normal file
103
components/PagesComponent/LandingPage/LandingMobileMenu.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { settingsData } from "@/redux/reducer/settingSlice";
|
||||
import { t } from "@/utils";
|
||||
import { GiHamburgerMenu } from "react-icons/gi";
|
||||
import { useSelector } from "react-redux";
|
||||
import LanguageDropdown from "../../Common/LanguageDropdown";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const LandingMobileMenu = ({ isOpen, setIsOpen, activeSection }) => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const settings = useSelector(settingsData);
|
||||
|
||||
const scrollToSection = (id) => {
|
||||
const section = document.getElementById(id);
|
||||
if (section) {
|
||||
setIsOpen(false);
|
||||
section.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen} className="lg:hidden">
|
||||
<SheetTrigger asChild className="lg:hidden">
|
||||
<button
|
||||
id="hamburg"
|
||||
className="text-2xl cursor-pointer border rounded-lg p-1"
|
||||
>
|
||||
<GiHamburgerMenu size={25} className="text-primary" />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="[&>button:first-child]:hidden] p-0">
|
||||
<SheetHeader className="py-4 px-6 border-b border">
|
||||
<SheetTitle>
|
||||
<CustomImage
|
||||
src={settings?.header_logo}
|
||||
width={195}
|
||||
height={52}
|
||||
alt="Logo"
|
||||
className="w-full h-[52px] object-contain ltr:object-left rtl:object-right max-w-[195px]"
|
||||
/>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col list-none">
|
||||
<li
|
||||
className={`cursor-pointer py-3 border-b border-dashed transition-all duration-200 ${
|
||||
activeSection === "anythingYouWant"
|
||||
? "text-primary"
|
||||
: "hover:text-primary"
|
||||
}`}
|
||||
onClick={() => scrollToSection("anythingYouWant")}
|
||||
>
|
||||
{t("home")}
|
||||
</li>
|
||||
<li
|
||||
className={`cursor-pointer py-3 border-b border-dashed transition-all duration-200 ${
|
||||
activeSection === "work_process"
|
||||
? "text-primary"
|
||||
: "hover:text-primary"
|
||||
}`}
|
||||
onClick={() => scrollToSection("work_process")}
|
||||
>
|
||||
{t("whyChooseUs")}
|
||||
</li>
|
||||
<li
|
||||
className={`cursor-pointer py-3 border-b border-dashed transition-all duration-200 ${
|
||||
activeSection === "faq"
|
||||
? "text-primary font-semibold bg-primary/5"
|
||||
: "hover:text-primary"
|
||||
}`}
|
||||
onClick={() => scrollToSection("faq")}
|
||||
>
|
||||
{t("faqs")}
|
||||
</li>
|
||||
<li
|
||||
className={`cursor-pointer py-3 border-b border-dashed transition-all duration-200 ${
|
||||
activeSection === "ourBlogs"
|
||||
? "text-primary font-semibold bg-primary/5"
|
||||
: "hover:text-primary"
|
||||
}`}
|
||||
onClick={() => scrollToSection("ourBlogs")}
|
||||
>
|
||||
{t("blog")}
|
||||
</li>
|
||||
<li className="py-3">
|
||||
<LanguageDropdown />
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingMobileMenu;
|
||||
122
components/PagesComponent/LandingPage/OurBlogs.jsx
Normal file
122
components/PagesComponent/LandingPage/OurBlogs.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
import { t } from "@/utils";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RiArrowLeftLine, RiArrowRightLine } from "react-icons/ri";
|
||||
import { getBlogsApi } from "@/utils/api";
|
||||
import BlogCardSkeleton from "../../Skeletons/BlogCardSkeleton.jsx";
|
||||
import BlogCard from "./BlogCard.jsx";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice.js";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const OurBlogs = () => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const isRTL = CurrentLanguage?.rtl;
|
||||
const [api, setApi] = useState();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [Blogs, setBlogs] = useState([]);
|
||||
const [IsLoading, setIsLoading] = useState(true);
|
||||
|
||||
const getBlogsData = async () => {
|
||||
try {
|
||||
const res = await getBlogsApi.getBlogs();
|
||||
setBlogs(res?.data?.data?.data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getBlogsData();
|
||||
}, [CurrentLanguage.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
setCurrent(api.selectedScrollSnap());
|
||||
api.on("select", () => {
|
||||
setCurrent(api.selectedScrollSnap());
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<section className="py-28 bg-muted" id="ourBlogs">
|
||||
<div className="container">
|
||||
<div className="flex items-center flex-col gap-6">
|
||||
<p className="outlinedSecHead">{t("ourBlog")}</p>
|
||||
<h1 className="landingSecHeader">
|
||||
{t("masteringMarketplace")}
|
||||
<br />
|
||||
{t("withOurBlog")}
|
||||
</h1>
|
||||
</div>
|
||||
<Carousel
|
||||
key={isRTL ? "rtl" : "ltr"}
|
||||
className="w-full mt-20"
|
||||
setApi={setApi}
|
||||
opts={{ align: "start", direction: isRTL ? "rtl" : "ltr" }}
|
||||
>
|
||||
<CarouselContent className="-ml-3 md:-ml-[30px]">
|
||||
{IsLoading
|
||||
? Array.from({ length: 6 }).map((_, i) => (
|
||||
<CarouselItem
|
||||
key={i}
|
||||
className="sm:basis-1/2 xl:basis-1/3 pl-3 md:pl-[30px]"
|
||||
>
|
||||
<BlogCardSkeleton />
|
||||
</CarouselItem>
|
||||
))
|
||||
: Blogs &&
|
||||
Blogs.length > 0 &&
|
||||
Blogs.map((blog) => (
|
||||
<CarouselItem
|
||||
key={blog?.id}
|
||||
className="sm:basis-1/2 xl:basis-1/3 pl-3 md:pl-[30px]"
|
||||
>
|
||||
<BlogCard blog={blog} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
|
||||
<div className="flex items-center justify-center mt-[30px] gap-4">
|
||||
<button
|
||||
onClick={() => api?.scrollTo(current - 1)}
|
||||
className={`bg-primary p-2 rounded ${
|
||||
!api?.canScrollPrev() ? "opacity-65 cursor-default" : ""
|
||||
}`}
|
||||
disabled={!api?.canScrollPrev()}
|
||||
>
|
||||
<RiArrowLeftLine
|
||||
size={24}
|
||||
color="white"
|
||||
className={isRTL ? "rotate-180" : ""}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => api?.scrollTo(current + 1)}
|
||||
className={`bg-primary p-2 rounded ${
|
||||
!api?.canScrollNext() ? "opacity-65 cursor-default" : ""
|
||||
}`}
|
||||
disabled={!api?.canScrollNext()}
|
||||
>
|
||||
<RiArrowRightLine
|
||||
size={24}
|
||||
color="white"
|
||||
className={isRTL ? "rotate-180" : ""}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default OurBlogs;
|
||||
70
components/PagesComponent/LandingPage/QuickAnswers.jsx
Normal file
70
components/PagesComponent/LandingPage/QuickAnswers.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import { t } from "@/utils";
|
||||
import { getFaqApi } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const QuickAnswers = () => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const [Faqs, setFaqs] = useState([]);
|
||||
|
||||
const getFaqData = async () => {
|
||||
try {
|
||||
const res = await getFaqApi.getFaq();
|
||||
setFaqs(res?.data?.data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getFaqData();
|
||||
}, [CurrentLanguage.id]);
|
||||
|
||||
return (
|
||||
<section className="py-28" id="faq">
|
||||
<div className="container">
|
||||
<div className="flex items-center flex-col gap-6">
|
||||
<p className="outlinedSecHead">{t("navigating")}</p>
|
||||
<h1 className="landingSecHeader">{t("quickAnswers")}</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 md:gap-8 mt-20">
|
||||
{Faqs &&
|
||||
Faqs.length > 0 &&
|
||||
Faqs.map((faq) => (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="border rounded-md overflow-hidden"
|
||||
key={faq?.id}
|
||||
>
|
||||
<AccordionItem value={faq?.id} className="border-none group">
|
||||
<AccordionTrigger
|
||||
className="text-start font-bold text-base px-4 hover:no-underline bg-transparent
|
||||
group-data-[state=open]:bg-muted group-data-[state=open]:text-primary
|
||||
group-data-[state=open]:border-b"
|
||||
>
|
||||
{faq?.translated_question || faq?.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="bg-muted p-4">
|
||||
<p className="text-base">
|
||||
{faq?.translated_answer || faq?.answer}
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAnswers;
|
||||
56
components/PagesComponent/LandingPage/WorkProcess.jsx
Normal file
56
components/PagesComponent/LandingPage/WorkProcess.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { t } from "@/utils";
|
||||
import Arrow from "../../../public/assets/Arrow.svg";
|
||||
import { workProcessSteps } from "@/utils/constants";
|
||||
import { useSelector } from "react-redux";
|
||||
import { settingsData } from "@/redux/reducer/settingSlice";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const WorkProcess = () => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const settings = useSelector(settingsData);
|
||||
|
||||
return (
|
||||
<section className="py-28" id="work_process">
|
||||
<div className="container">
|
||||
<div className="flex items-center flex-col gap-6">
|
||||
<p className="outlinedSecHead">
|
||||
{t("how")} {settings?.company_name} {t("getsYouResults")}
|
||||
</p>
|
||||
<h1 className="landingSecHeader">
|
||||
{t("unravelingThe")} {settings?.company_name} <br />{" "}
|
||||
{t("workProcess")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="mt-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 text-center">
|
||||
{workProcessSteps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex flex-col items-center gap-4 relative"
|
||||
>
|
||||
{index !== workProcessSteps.length - 1 && (
|
||||
<CustomImage
|
||||
src={Arrow}
|
||||
alt="arrow"
|
||||
width={128}
|
||||
height={22}
|
||||
className="absolute hidden w-32 top-[5%] lg:block lg:-right-[34%] xl:-right-[30%] 2xl:-right-[25%] rtl:right-auto rtl:lg:-left-[34%] rtl:xl:-left-[30%] rtl:2xl:-left-[25%] rtl:scale-x-[-1]"
|
||||
/>
|
||||
)}
|
||||
<span className="flex items-center justify-center text-white font-bold w-[40px] h-[40px] bg-primary rounded-full">
|
||||
{step.id}
|
||||
</span>
|
||||
<h5 className="font-bold">{t(step.title)}</h5>
|
||||
<p>{t(step.description)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkProcess;
|
||||
173
components/PagesComponent/MyAds/ChoosePackageModal.jsx
Normal file
173
components/PagesComponent/MyAds/ChoosePackageModal.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { formatPriceAbbreviated, t } from "@/utils";
|
||||
import { getPackageApi } from "@/utils/api";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ChoosePackageModal = ({
|
||||
IsChoosePackage,
|
||||
setIsChoosePackage,
|
||||
selectedPackageId,
|
||||
setSelectedPackageId,
|
||||
ItemPackages,
|
||||
setItemPackages,
|
||||
isRenewingAd,
|
||||
handleRenew,
|
||||
}) => {
|
||||
const [IsLoading, setIsLoading] = useState(false);
|
||||
const currentLanguageCode = useSelector(getCurrentLangCode);
|
||||
|
||||
useEffect(() => {
|
||||
getItemsPackageData();
|
||||
}, [currentLanguageCode]);
|
||||
|
||||
const getItemsPackageData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await getPackageApi.getPackage({ type: "item_listing" });
|
||||
const { data } = res?.data;
|
||||
setItemPackages(data || []);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={IsChoosePackage} onOpenChange={setIsChoosePackage}>
|
||||
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
{t("selectPackage")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only"></DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{IsLoading ? (
|
||||
Array.from({ length: 3 }).map((_, index) => (
|
||||
<PackageCardSkeleton key={index} />
|
||||
))
|
||||
) : ItemPackages.length > 0 ? (
|
||||
ItemPackages.map((item) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 border rounded-lg hover:bg-muted transition-colors cursor-pointer",
|
||||
item?.id == selectedPackageId &&
|
||||
"bg-primary text-white hover:bg-primary"
|
||||
)}
|
||||
key={item?.id}
|
||||
onClick={() => setSelectedPackageId(item?.id)}
|
||||
>
|
||||
<CustomImage
|
||||
src={item.icon}
|
||||
width={58}
|
||||
height={58}
|
||||
alt={"Dummy image"}
|
||||
className="rounded"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<h3 className="text-lg font-medium ltr:text-left rtl:text-right line-clamp-2">
|
||||
{item.translated_name}
|
||||
{item?.is_active && t("activePlan")}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 justify-between w-full">
|
||||
<span className="text-lg font-bold whitespace-nowrap">
|
||||
{formatPriceAbbreviated(item.final_price)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground text-xs sm:text-sm ltr:text-right rtl:text-left",
|
||||
item?.id == selectedPackageId && "text-white"
|
||||
)}
|
||||
>
|
||||
<strong
|
||||
className={cn(
|
||||
item?.id == selectedPackageId
|
||||
? "text-white"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{item.item_limit === "unlimited"
|
||||
? t("unlimited")
|
||||
: item.item_limit}
|
||||
</strong>{" "}
|
||||
{t("ads")} |
|
||||
<strong
|
||||
className={cn(
|
||||
item?.id == selectedPackageId
|
||||
? "text-white"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{item.duration === "unlimited"
|
||||
? t("unlimited")
|
||||
: item.duration}
|
||||
</strong>{" "}
|
||||
{t("days")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<NoData name="packages" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ItemPackages.length > 0 && (
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button className="bg-black text-white">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
className="bg-primary text-white"
|
||||
type="button"
|
||||
onClick={handleRenew}
|
||||
disabled={isRenewingAd}
|
||||
>
|
||||
{isRenewingAd ? t("loading") : t("renewAd")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChoosePackageModal;
|
||||
|
||||
const PackageCardSkeleton = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 border rounded-lg">
|
||||
{/* Image Skeleton */}
|
||||
<Skeleton className="h-[58px] w-[58px] rounded" />
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{/* Title Skeleton */}
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
|
||||
{/* Price + Meta Skeleton */}
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
75
components/PagesComponent/MyAds/GetMyAdStatus.jsx
Normal file
75
components/PagesComponent/MyAds/GetMyAdStatus.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// 📌 components/Common/GetMyAdStatus.jsx
|
||||
import {
|
||||
MdAirplanemodeInactive,
|
||||
MdOutlineDone,
|
||||
MdOutlineLiveTv,
|
||||
MdOutlineSell,
|
||||
} from "react-icons/md";
|
||||
import { BiBadgeCheck } from "react-icons/bi";
|
||||
import { RxCross2 } from "react-icons/rx";
|
||||
import { RiPassExpiredLine } from "react-icons/ri";
|
||||
import { IoTimerOutline } from "react-icons/io5";
|
||||
import { t } from "@/utils";
|
||||
|
||||
const GetMyAdStatus = ({
|
||||
status,
|
||||
isApprovedSort = false,
|
||||
isFeature = false,
|
||||
isJobCategory = false,
|
||||
}) => {
|
||||
const statusComponents = {
|
||||
approved: isApprovedSort
|
||||
? { icon: <MdOutlineLiveTv size={16} color="white" />, text: t("live") }
|
||||
: isFeature
|
||||
? { icon: <BiBadgeCheck size={16} color="white" />, text: t("featured") }
|
||||
: { icon: <MdOutlineLiveTv size={16} color="white" />, text: t("live") },
|
||||
|
||||
review: {
|
||||
icon: <IoTimerOutline size={16} color="white" />,
|
||||
text: t("review"),
|
||||
},
|
||||
"permanent rejected": {
|
||||
icon: <RxCross2 size={16} color="white" />,
|
||||
text: t("permanentRejected"),
|
||||
bg: "bg-red-600",
|
||||
},
|
||||
"soft rejected": {
|
||||
icon: <RxCross2 size={16} color="white" />,
|
||||
text: t("softRejected"),
|
||||
bg: "bg-red-500",
|
||||
},
|
||||
inactive: {
|
||||
icon: <MdAirplanemodeInactive size={16} color="white" />,
|
||||
text: t("deactivate"),
|
||||
bg: "bg-gray-500",
|
||||
},
|
||||
"sold out": {
|
||||
icon: <MdOutlineSell size={16} color="white" />,
|
||||
text: isJobCategory ? t("positionFilled") : t("soldOut"),
|
||||
bg: "bg-yellow-600",
|
||||
},
|
||||
resubmitted: {
|
||||
icon: <MdOutlineDone size={16} color="white" />,
|
||||
text: t("resubmitted"),
|
||||
bg: "bg-green-600",
|
||||
},
|
||||
expired: {
|
||||
icon: <RiPassExpiredLine size={16} color="white" />,
|
||||
text: t("expired"),
|
||||
bg: "bg-gray-700",
|
||||
},
|
||||
};
|
||||
|
||||
const { icon, text, bg = "bg-primary" } = statusComponents[status] || {};
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${bg} rounded-sm py-0.5 px-1`}>
|
||||
{icon}
|
||||
<span className="text-white text-sm text-ellipsis">{text}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GetMyAdStatus;
|
||||
437
components/PagesComponent/MyAds/MyAds.jsx
Normal file
437
components/PagesComponent/MyAds/MyAds.jsx
Normal file
@@ -0,0 +1,437 @@
|
||||
"use client";
|
||||
import { t } from "@/utils";
|
||||
import { CgArrowsExchangeAltV } from "react-icons/cg";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import AdsCard from "./MyAdsCard.jsx";
|
||||
import { deleteItemApi, getMyItemsApi, renewItemApi } from "@/utils/api";
|
||||
import { useSelector } from "react-redux";
|
||||
import ProductCardSkeleton from "@/components/Common/ProductCardSkeleton.jsx";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CurrentLanguageData,
|
||||
getIsRtl,
|
||||
} from "@/redux/reducer/languageSlice.js";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import ReusableAlertDialog from "@/components/Common/ReusableAlertDialog.jsx";
|
||||
import { toast } from "sonner";
|
||||
import ChoosePackageModal from "./ChoosePackageModal.jsx";
|
||||
import { getIsFreAdListing } from "@/redux/reducer/settingSlice.js";
|
||||
|
||||
const MyAds = () => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const searchParams = useSearchParams();
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
|
||||
const sortValue = searchParams.get("sort") || "new-to-old";
|
||||
const status = searchParams.get("status") || "all";
|
||||
|
||||
const [totalAdsCount, setTotalAdsCount] = useState(0);
|
||||
const [MyItems, setMyItems] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [lastPage, setLastPage] = useState(1);
|
||||
const [IsLoading, setIsLoading] = useState(true);
|
||||
const [IsLoadMore, setIsLoadMore] = useState(false);
|
||||
|
||||
const isFreeAdListing = useSelector(getIsFreAdListing);
|
||||
const [ItemPackages, setItemPackages] = useState([]);
|
||||
const [renewIds, setRenewIds] = useState([]);
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [IsDeleting, setIsDeleting] = useState(false);
|
||||
const [IsDeleteDialog, setIsDeleteDialog] = useState(false);
|
||||
|
||||
const [IsChoosePackage, setIsChoosePackage] = useState(false);
|
||||
const [selectedPackageId, setSelectedPackageId] = useState("");
|
||||
const [isRenewingAd, setIsRenewingAd] = useState(false);
|
||||
|
||||
// Filter expired ads and check if selection is allowed
|
||||
const expiredAds = MyItems.filter((item) => item.status === "expired");
|
||||
const canMultiSelect = expiredAds.length > 1;
|
||||
|
||||
const getMyItemsData = async (page = 1) => {
|
||||
try {
|
||||
const params = {
|
||||
page,
|
||||
sort_by: sortValue,
|
||||
};
|
||||
if (status !== "all") {
|
||||
params.status = status;
|
||||
}
|
||||
|
||||
// Set loading states based on page
|
||||
if (page > 1) {
|
||||
setIsLoadMore(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
const res = await getMyItemsApi.getMyItems(params);
|
||||
const data = res?.data;
|
||||
if (data?.error === false) {
|
||||
setTotalAdsCount(data?.data?.total);
|
||||
page > 1
|
||||
? setMyItems((prevData) => [...prevData, ...data?.data?.data])
|
||||
: setMyItems(data?.data?.data);
|
||||
setCurrentPage(data?.data?.current_page);
|
||||
setLastPage(data?.data?.last_page);
|
||||
} else {
|
||||
console.log("Error in response: ", data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getMyItemsData(1);
|
||||
}, [sortValue, status, CurrentLanguage?.id]);
|
||||
|
||||
const updateURLParams = (key, value) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set(key, value);
|
||||
window.history.pushState(null, "", `?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSortChange = (value) => {
|
||||
updateURLParams("sort", value);
|
||||
};
|
||||
|
||||
const handleStatusChange = (value) => {
|
||||
updateURLParams("status", value);
|
||||
};
|
||||
|
||||
const handleAdSelection = (adId) => {
|
||||
const ad = MyItems.find((item) => item.id === adId);
|
||||
if (ad?.status !== "expired") return;
|
||||
|
||||
setRenewIds((prev) => {
|
||||
if (prev.includes(adId)) {
|
||||
return prev.filter((id) => id !== adId);
|
||||
} else {
|
||||
return [...prev, adId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const payload = { item_ids: selectedIds.join(",") };
|
||||
// Call API
|
||||
const res = await deleteItemApi.deleteItem(payload);
|
||||
|
||||
// Handle response
|
||||
if (res?.data?.error === false) {
|
||||
toast.success(res?.data?.message);
|
||||
setIsDeleteDialog(false);
|
||||
setSelectedIds([]);
|
||||
setRenewIds([]);
|
||||
await getMyItemsData(1);
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renewAds = async ({ ids, packageId }) => {
|
||||
try {
|
||||
setIsRenewingAd(true);
|
||||
let payload = {};
|
||||
if (Array.isArray(ids)) {
|
||||
payload = {
|
||||
item_ids: ids.join(","),
|
||||
...(isFreeAdListing ? {} : { package_id: packageId }),
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
item_ids: ids,
|
||||
...(isFreeAdListing ? {} : { package_id: packageId }),
|
||||
};
|
||||
}
|
||||
|
||||
const res = await renewItemApi.renewItem(payload);
|
||||
|
||||
if (res?.data?.error === false) {
|
||||
toast.success(res?.data?.message);
|
||||
setIsChoosePackage(false);
|
||||
setRenewIds([]);
|
||||
await getMyItemsData(1);
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsRenewingAd(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenew = (ids) => {
|
||||
const idsToRenew = Array.isArray(ids) ? ids : renewIds;
|
||||
|
||||
if (isFreeAdListing) {
|
||||
renewAds({ ids: idsToRenew });
|
||||
} else {
|
||||
if (!selectedPackageId) {
|
||||
toast.error(t("pleaseSelectPackage"));
|
||||
return;
|
||||
}
|
||||
|
||||
const subPackage = ItemPackages.find(
|
||||
(p) => Number(p.id) === Number(selectedPackageId)
|
||||
);
|
||||
if (!subPackage?.is_active) {
|
||||
toast.error(t("purchasePackageFirst"));
|
||||
navigate("/user-subscription");
|
||||
return;
|
||||
}
|
||||
renewAds({ ids: idsToRenew, packageId: selectedPackageId });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle context menu actions
|
||||
const handleContextMenuAction = (action, adId) => {
|
||||
const ad = MyItems.find((item) => item.id === adId);
|
||||
|
||||
switch (action) {
|
||||
case "select":
|
||||
// Only allow selection for expired ads
|
||||
if (ad && ad.status === "expired") {
|
||||
handleAdSelection(adId);
|
||||
}
|
||||
break;
|
||||
case "renew":
|
||||
if (isFreeAdListing) {
|
||||
handleRenew([adId]);
|
||||
} else {
|
||||
setRenewIds([adId]);
|
||||
setIsChoosePackage(true);
|
||||
}
|
||||
break;
|
||||
case "delete":
|
||||
setSelectedIds([adId]); // single ad
|
||||
setIsDeleteDialog(true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (renewIds.length === expiredAds.length) {
|
||||
setRenewIds([]);
|
||||
} else {
|
||||
setRenewIds(expiredAds.map((item) => item.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSelection = () => setRenewIds([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between py-2 px-4 bg-muted rounded-lg">
|
||||
<h1 className="font-semibold">
|
||||
{t("totalAds")} {totalAdsCount}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap sm:flex-nowrap items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<CgArrowsExchangeAltV size={25} />
|
||||
<span className="whitespace-nowrap">{t("sortBy")}</span>
|
||||
</div>
|
||||
<Select value={sortValue} onValueChange={handleSortChange}>
|
||||
<SelectTrigger className="bg-transparent border-black/23 whitespace-nowrap">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align={isRTL ? "start" : "end"}>
|
||||
<SelectGroup>
|
||||
<SelectItem value="new-to-old">
|
||||
{t("newestToOldest")}
|
||||
</SelectItem>
|
||||
<SelectItem value="old-to-new">
|
||||
{t("oldestToNewest")}
|
||||
</SelectItem>
|
||||
<SelectItem value="price-high-to-low">
|
||||
{t("priceHighToLow")}
|
||||
</SelectItem>
|
||||
<SelectItem value="price-low-to-high">
|
||||
{t("priceLowToHigh")}
|
||||
</SelectItem>
|
||||
<SelectItem value="popular_items">{t("popular")}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={status} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger className="bg-transparent border-black/23">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align={isRTL ? "start" : "end"}>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">{t("all")}</SelectItem>
|
||||
<SelectItem value="review">{t("review")}</SelectItem>
|
||||
<SelectItem value="approved">{t("live")}</SelectItem>
|
||||
<SelectItem value="soft rejected">
|
||||
{t("softRejected")}
|
||||
</SelectItem>
|
||||
<SelectItem value="permanent rejected">
|
||||
{t("permanentRejected")}
|
||||
</SelectItem>
|
||||
<SelectItem value="inactive">{t("deactivate")}</SelectItem>
|
||||
<SelectItem value="featured">{t("featured")}</SelectItem>
|
||||
<SelectItem value="sold out">{t("soldOut")}</SelectItem>
|
||||
<SelectItem value="resubmitted">{t("resubmitted")}</SelectItem>
|
||||
<SelectItem value="expired">{t("expired")}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-[30px] text-sm text-destructive">
|
||||
<span>{t("expiredAdsNote")}</span>
|
||||
</div>
|
||||
|
||||
{/* Selection controls - only show when there are expired ads and at least one is selected */}
|
||||
{canMultiSelect && renewIds.length > 0 && (
|
||||
<div className="flex items-center justify-between mt-[30px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={renewIds.length === expiredAds.length}
|
||||
onCheckedChange={handleSelectAll}
|
||||
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium">{t("selectAll")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{renewIds.length} {renewIds.length === 1 ? t("ad") : t("ads")}{" "}
|
||||
{t("selected")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 mt-[30px] xl:grid-cols-3 gap-3 sm:gap-6">
|
||||
{IsLoading ? (
|
||||
[...Array(6)].map((item, index) => (
|
||||
<ProductCardSkeleton key={index} />
|
||||
))
|
||||
) : MyItems && MyItems?.length > 0 ? (
|
||||
MyItems.map((item) => (
|
||||
<AdsCard
|
||||
key={item?.id}
|
||||
data={item}
|
||||
isApprovedSort={sortValue === "approved"}
|
||||
isSelected={renewIds.includes(item?.id)}
|
||||
isSelectable={renewIds.length > 0 && item.status === "expired"}
|
||||
onSelectionToggle={() => handleAdSelection(item?.id)}
|
||||
onContextMenuAction={(action) =>
|
||||
handleContextMenuAction(action, item?.id)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full">
|
||||
<NoData name={t("advertisement")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{currentPage < lastPage && (
|
||||
<div className="text-center mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-sm sm:text-base text-primary w-[256px]"
|
||||
disabled={IsLoading || IsLoadMore}
|
||||
onClick={() => getMyItemsData(currentPage + 1)}
|
||||
>
|
||||
{IsLoadMore ? t("loading") : t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons for selected ads - show at bottom */}
|
||||
{renewIds.length > 0 && (
|
||||
<div className="mt-[30px]">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
onClick={handleCancelSelection}
|
||||
className="bg-black text-white"
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (renewIds.length === 0) return; // no selection
|
||||
setSelectedIds([...renewIds]); // copy renewIds to selectedIds
|
||||
setIsDeleteDialog(true);
|
||||
}}
|
||||
className="bg-destructive text-white"
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isFreeAdListing) {
|
||||
handleRenew(); // directly renew
|
||||
} else {
|
||||
setIsChoosePackage(true);
|
||||
}
|
||||
}}
|
||||
disabled={isRenewingAd}
|
||||
className="bg-primary text-white"
|
||||
>
|
||||
{isRenewingAd ? t("loading") : t("renew")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChoosePackageModal
|
||||
key={IsChoosePackage}
|
||||
selectedPackageId={selectedPackageId}
|
||||
setSelectedPackageId={setSelectedPackageId}
|
||||
ItemPackages={ItemPackages}
|
||||
setItemPackages={setItemPackages}
|
||||
IsChoosePackage={IsChoosePackage}
|
||||
setIsChoosePackage={setIsChoosePackage}
|
||||
handleRenew={handleRenew}
|
||||
isRenewingAd={isRenewingAd}
|
||||
/>
|
||||
<ReusableAlertDialog
|
||||
open={IsDeleteDialog}
|
||||
onCancel={() => setIsDeleteDialog(false)}
|
||||
onConfirm={handleRemove}
|
||||
title={t("areYouSure")}
|
||||
description={
|
||||
selectedIds.length === 1
|
||||
? t("areYouSureToDeleteAd")
|
||||
: t("areYouSureToDeleteAds")
|
||||
}
|
||||
cancelText={t("cancel")}
|
||||
confirmText={t("yes")}
|
||||
confirmDisabled={IsDeleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyAds;
|
||||
162
components/PagesComponent/MyAds/MyAdsCard.jsx
Normal file
162
components/PagesComponent/MyAds/MyAdsCard.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { BiHeart } from "react-icons/bi";
|
||||
import { RxEyeOpen } from "react-icons/rx";
|
||||
import { t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import GetMyAdStatus from "./GetMyAdStatus";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RotateCcw, Trash2, CheckSquare } from "lucide-react";
|
||||
|
||||
const MyAdsCard = ({
|
||||
data,
|
||||
isApprovedSort,
|
||||
isSelected = false,
|
||||
isSelectable = false,
|
||||
onSelectionToggle,
|
||||
onContextMenuAction,
|
||||
}) => {
|
||||
const isJobCategory = Number(data?.category?.is_job_category) === 1;
|
||||
const isAdminEdited = Number(data?.is_edited_by_admin) === 1;
|
||||
const translated_item = data?.translated_item;
|
||||
|
||||
const isHidePrice = isJobCategory
|
||||
? !data?.formatted_salary_range
|
||||
: !data?.formatted_price;
|
||||
|
||||
const status = data?.status;
|
||||
const isExpired = status === "expired";
|
||||
|
||||
const price = isJobCategory
|
||||
? data?.formatted_salary_range
|
||||
: data?.formatted_price;
|
||||
|
||||
// Card content JSX to avoid duplication
|
||||
const cardContent = (
|
||||
<div
|
||||
className={`relative border flex flex-col gap-2 rounded-xl p-2 hover:shadow-md transition-all duration-200 ${
|
||||
isSelected ? "ring-2 ring-primary bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Selection checkbox - only show in selection mode */}
|
||||
{isSelectable && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onSelectionToggle}
|
||||
className="bg-white shadow-sm border-2 border-primary data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main card content */}
|
||||
<CustomLink
|
||||
href={`/my-listing/${data?.slug}`}
|
||||
className="flex flex-col gap-2"
|
||||
onClick={(e) => {
|
||||
if (isSelectable) {
|
||||
e.preventDefault();
|
||||
onSelectionToggle();
|
||||
} else {
|
||||
// For navigation, ensure the event propagates properly
|
||||
// Don't prevent default or stop propagation for normal clicks
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CustomImage
|
||||
src={data?.image}
|
||||
width={220}
|
||||
height={220}
|
||||
alt={data?.image}
|
||||
className="w-full h-auto aspect-square rounded-sm object-cover"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{status && (
|
||||
<GetMyAdStatus
|
||||
status={status}
|
||||
isApprovedSort={isApprovedSort}
|
||||
isFeature={data?.is_feature}
|
||||
isJobCategory={isJobCategory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAdminEdited && (
|
||||
<div className="py-1 px-2 bg-red-400/15 rounded-sm text-destructive text-sm">
|
||||
{t("adminEdited")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isHidePrice && (
|
||||
<p className="font-medium line-clamp-1">
|
||||
{translated_item?.name || data?.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-between gap-1">
|
||||
{isHidePrice ? (
|
||||
<p className="font-medium line-clamp-1">
|
||||
{translated_item?.name || data?.name}
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className="font-semibold text-lg text-balance break-all line-clamp-2"
|
||||
title={price}
|
||||
>
|
||||
{price}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<RxEyeOpen size={14} className="text-black/60" />
|
||||
<span>{data?.clicks}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<BiHeart size={14} className="text-black/60" />
|
||||
<span>{data?.total_likes}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CustomLink>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu modal={false}>
|
||||
<ContextMenuTrigger asChild disabled={!isExpired}>
|
||||
{cardContent}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => onContextMenuAction("select")}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<CheckSquare className="size-4" />
|
||||
{isSelected ? "Deselect" : "Select"}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onContextMenuAction("renew")}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="size-4 text-primary" />
|
||||
<span className="text-primary">{t("renew")}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onContextMenuAction("delete")}
|
||||
className="flex items-center gap-2 text-destructive focus:text-destructive cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{t("remove")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyAdsCard;
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const NotificationSkeleton = () => {
|
||||
return (
|
||||
<div className="overflow-hidden border rounded-md">
|
||||
<Table>
|
||||
<TableHeader className='bg-muted'>
|
||||
<TableRow className='text-xs sm:text-sm'>
|
||||
<TableHead>
|
||||
<div className="h-5 w-2/4 bg-gray-200 rounded animate-pulse"></div>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">
|
||||
<div className="h-5 w-2/4 bg-gray-200 rounded animate-pulse mx-auto"></div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className='text-xs sm:text-sm'>
|
||||
{Array.from({ length: 15 }).map((_, index) => (
|
||||
<TableRow key={index} className="hover:bg-muted">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-[48px] h-[48px] rounded bg-gray-200 animate-pulse"></div>
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded animate-pulse w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-full mx-auto"></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationSkeleton;
|
||||
134
components/PagesComponent/Notifications/Notifications.jsx
Normal file
134
components/PagesComponent/Notifications/Notifications.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
import { formatDateMonthYear, t } from "@/utils";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getNotificationList } from "@/utils/api";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import Pagination from "@/components/Common/Pagination";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import NotificationSkeleton from "./NotificationSkeleton";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Notifications = () => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const userData = useSelector(userSignUpData);
|
||||
const { navigate } = useNavigate();
|
||||
|
||||
const fetchNotificationData = async (page) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getNotificationList.getNotification({ page });
|
||||
if (response?.data?.error === false) {
|
||||
setNotifications(response?.data.data.data);
|
||||
setTotalPages(response?.data?.data?.last_page);
|
||||
} else {
|
||||
toast.error(data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotificationData(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handleNotificationClick = (notification) => {
|
||||
// Check if notification has item slug
|
||||
if (notification?.item?.slug) {
|
||||
const currentUserId = userData?.id; // Get current user ID
|
||||
const notificationUserId = notification?.item?.user_id; // Get notification user ID
|
||||
|
||||
if (currentUserId == notificationUserId) {
|
||||
// If current user is the same as notification user, redirect to my-listing
|
||||
navigate(`/my-listing/${notification.item.slug}`);
|
||||
} else {
|
||||
// Otherwise, redirect to ad-details
|
||||
navigate(`/ad-details/${notification.item.slug}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
return isLoading ? (
|
||||
<NotificationSkeleton />
|
||||
) : notifications.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-hidden border rounded-md">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
<TableRow className="text-xs sm:text-sm">
|
||||
<TableHead className="text-black font-bold ltr:text-left rtl:text-right">
|
||||
{t("notification")}
|
||||
</TableHead>
|
||||
<TableHead className="text-black font-bold text-center">
|
||||
{t("date")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-xs sm:text-sm">
|
||||
{notifications.map((notification, index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
className={cn(
|
||||
"hover:bg-muted",
|
||||
notification?.item?.slug && "cursor-pointer"
|
||||
)}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<CustomImage
|
||||
src={notification?.image}
|
||||
width={48}
|
||||
height={48}
|
||||
alt="notification icon"
|
||||
className="w-[48px] h-[48px] object-cover rounded"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium">{notification.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{notification.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{formatDateMonthYear(notification.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Pagination
|
||||
className="mt-7"
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<NoData name={t("notifications")} />
|
||||
);
|
||||
};
|
||||
export default Notifications;
|
||||
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
|
||||
116
components/PagesComponent/ProfileDashboard/ProfileDashboard.jsx
Normal file
116
components/PagesComponent/ProfileDashboard/ProfileDashboard.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
import ProfileSidebar from "@/components/Profile/ProfileSidebar";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Checkauth from "@/HOC/Checkauth";
|
||||
import Notifications from "../Notifications/Notifications";
|
||||
import Profile from "@/components/Profile/Profile";
|
||||
import MyAds from "../MyAds/MyAds";
|
||||
import Favorites from "../Favorites/Favorites";
|
||||
import Transactions from "../Transactions/Transactions";
|
||||
import Reviews from "../Reviews/Reviews";
|
||||
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
|
||||
import Chat from "../Chat/Chat";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import ProfileSubscription from "../Subscription/ProfileSubscription";
|
||||
import JobApplications from "../JobApplications/JobApplications";
|
||||
import { t } from "@/utils";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import BlockedUsersMenu from "../Chat/BlockedUsersMenu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ProfileDashboard = () => {
|
||||
const pathname = usePathname();
|
||||
const isNotifications = pathname === "/notifications";
|
||||
const isSubscriptions = pathname === "/user-subscription";
|
||||
const isProfile = pathname === "/profile";
|
||||
const isAds = pathname === "/my-ads";
|
||||
const isFavorite = pathname === "/favorites";
|
||||
const isTransaction = pathname === "/transactions";
|
||||
const isReviews = pathname === "/reviews";
|
||||
const isChat = pathname == "/chat";
|
||||
const isJobApplications = pathname == "/job-applications";
|
||||
|
||||
const isLargeScreen = useMediaQuery("(min-width: 992px)");
|
||||
const isSmallerThanLaptop = useMediaQuery("(max-width: 1200px)");
|
||||
|
||||
const renderHeading = () => {
|
||||
if (isProfile) {
|
||||
return t("myProfile");
|
||||
} else if (isNotifications) {
|
||||
return t("notifications");
|
||||
} else if (isSubscriptions) {
|
||||
return t("subscription");
|
||||
} else if (isAds) {
|
||||
return t("myAds");
|
||||
} else if (isFavorite) {
|
||||
return t("myFavorites");
|
||||
} else if (isTransaction) {
|
||||
return t("myTransaction");
|
||||
} else if (isReviews) {
|
||||
return t("reviews");
|
||||
} else if (isChat) {
|
||||
return "chat";
|
||||
} else if (isJobApplications) {
|
||||
return t("jobApplications");
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isProfile) {
|
||||
return <Profile />;
|
||||
} else if (isNotifications) {
|
||||
return <Notifications />;
|
||||
} else if (isSubscriptions) {
|
||||
return <ProfileSubscription />;
|
||||
} else if (isAds) {
|
||||
return <MyAds />;
|
||||
} else if (isFavorite) {
|
||||
return <Favorites />;
|
||||
} else if (isTransaction) {
|
||||
return <Transactions />;
|
||||
} else if (isReviews) {
|
||||
return <Reviews />;
|
||||
} else if (isChat) {
|
||||
return <Chat />;
|
||||
} else if (isJobApplications) {
|
||||
return <JobApplications />;
|
||||
}
|
||||
};
|
||||
const breadCrumbTitle = renderHeading();
|
||||
return (
|
||||
<Layout>
|
||||
<BreadCrumb title2={breadCrumbTitle} />
|
||||
<div className="container mt-8">
|
||||
{isChat && isSmallerThanLaptop ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="sectionTitle">{renderHeading()}</h1>
|
||||
<BlockedUsersMenu />
|
||||
</div>
|
||||
) : (
|
||||
<h1 className="sectionTitle">{renderHeading()}</h1>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 lg:grid-cols-4 lg:border rounded-lg mt-6",
|
||||
isChat && "border"
|
||||
)}
|
||||
>
|
||||
{isLargeScreen && (
|
||||
<div className="max-h-fit lg:col-span-1 ltr:lg:border-r rtl:lg:border-l">
|
||||
<ProfileSidebar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn("lg:col-span-3 lg:border-t-0", !isChat && "lg:p-7")}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkauth(ProfileDashboard);
|
||||
126
components/PagesComponent/Reviews/MyReviewsCard.jsx
Normal file
126
components/PagesComponent/Reviews/MyReviewsCard.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
import { GoReport } from "react-icons/go";
|
||||
import StarRating from "./StarRating";
|
||||
import { formatDate, t } from "@/utils";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import ReportReviewModal from "./ReportReviewModal";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const MyReviewsCard = ({ rating, setMyReviews }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isTextOverflowing, setIsTextOverflowing] = useState(false);
|
||||
const textRef = useRef(null);
|
||||
const [IsReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [SellerReviewId, setSellerReviewId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const checkTextOverflow = () => {
|
||||
if (textRef.current) {
|
||||
const isOverflowing =
|
||||
textRef.current.scrollHeight > textRef.current.clientHeight;
|
||||
setIsTextOverflowing(isOverflowing);
|
||||
}
|
||||
};
|
||||
|
||||
checkTextOverflow();
|
||||
window.addEventListener("resize", checkTextOverflow);
|
||||
|
||||
return () => window.removeEventListener("resize", checkTextOverflow);
|
||||
}, []);
|
||||
|
||||
const handleReportClick = (id) => {
|
||||
setSellerReviewId(id);
|
||||
setIsReportModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-lg flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row flex-1 gap-4">
|
||||
<div className="relative w-fit">
|
||||
<CustomImage
|
||||
src={rating?.buyer?.profile}
|
||||
width={72}
|
||||
height={72}
|
||||
alt="Reviewer"
|
||||
className="aspect-square rounded-full object-cover"
|
||||
/>
|
||||
<CustomImage
|
||||
src={rating?.item?.image}
|
||||
width={36}
|
||||
height={36}
|
||||
alt="Reviewer"
|
||||
className="absolute top-12 bottom-[-6px] right-[-6px] w-[36px] h-auto aspect-square rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<p className="font-semibold">{rating?.buyer?.name}</p>
|
||||
{rating?.report_status ? (
|
||||
<div></div>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button onClick={() => handleReportClick(rating?.id)}>
|
||||
<GoReport />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" align="center">
|
||||
<p>{t("reportReview")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-sm line-clamp-1">
|
||||
{rating?.item?.translated_name || rating?.item?.name}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<StarRating rating={Number(rating?.ratings)} size={24} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{rating?.ratings}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mt-1 justify-self-end">
|
||||
{formatDate(rating?.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b"></div>
|
||||
|
||||
<div>
|
||||
<p ref={textRef} className={`${!isExpanded ? "line-clamp-2" : ""}`}>
|
||||
{rating?.review}
|
||||
</p>
|
||||
{isTextOverflowing && (
|
||||
<div className="flex justify-center mt-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-primary text-sm hover:underline"
|
||||
>
|
||||
{isExpanded ? "See Less" : "See More"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Report Review Modal */}
|
||||
<ReportReviewModal
|
||||
isOpen={IsReportModalOpen}
|
||||
setIsOpen={setIsReportModalOpen}
|
||||
reviewId={SellerReviewId}
|
||||
setMyReviews={setMyReviews}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default MyReviewsCard;
|
||||
60
components/PagesComponent/Reviews/MyReviewsCardSkeleton.jsx
Normal file
60
components/PagesComponent/Reviews/MyReviewsCardSkeleton.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const ReviewCardItem = () => {
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-lg flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row flex-1 gap-4">
|
||||
{/* Profile image with smaller image overlay */}
|
||||
<div className="relative w-fit">
|
||||
<Skeleton className="w-[72px] h-[72px] rounded-full" />
|
||||
<Skeleton className="w-[36px] h-[36px] rounded-full absolute top-12 bottom-[-6px] right-[-6px]" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Reviewer name and report icon */}
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<Skeleton className="h-5 w-32 rounded-md" />
|
||||
<Skeleton className="h-5 w-5 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Item name */}
|
||||
<Skeleton className="h-4 w-48 mt-1 rounded-md" />
|
||||
|
||||
{/* Star rating */}
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<Skeleton className="h-6 w-32 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<Skeleton className="h-4 w-24 mt-1 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-b"></div>
|
||||
|
||||
{/* Review text */}
|
||||
<div>
|
||||
<Skeleton className="h-4 w-full rounded-md mb-2" />
|
||||
<Skeleton className="h-4 w-full rounded-md" />
|
||||
|
||||
{/* See More button (optional) */}
|
||||
<div className="flex justify-center mt-1">
|
||||
<Skeleton className="h-4 w-16 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MyReviewsCardSkeleton = () => {
|
||||
return (
|
||||
<div className="mt-[30px] p-2 sm:p-4 bg-muted rounded-xl flex flex-col gap-[30px]">
|
||||
{Array(8).fill(0).map((_, index) => (
|
||||
<ReviewCardItem key={`review-skeleton-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyReviewsCardSkeleton;
|
||||
45
components/PagesComponent/Reviews/RatingsSummary.jsx
Normal file
45
components/PagesComponent/Reviews/RatingsSummary.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
import StarRating from "./StarRating";
|
||||
import { calculateRatingPercentages, t } from "@/utils";
|
||||
import { TiStarFullOutline } from "react-icons/ti";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
const RatingsSummary = ({ averageRating, reviews }) => {
|
||||
|
||||
|
||||
const { ratingCount, ratingPercentages } = reviews?.length
|
||||
? calculateRatingPercentages(reviews)
|
||||
: { ratingCount: {}, ratingPercentages: {} };
|
||||
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 p-4 rounded-xl border">
|
||||
{/* Average Rating Section */}
|
||||
<div className="col-span-4 border-b md:border-b-0 ltr:md:border-r rtl:md:border-l pb-4 md:pb-0 ltr:md:pr-4 rtl:md:pl-4">
|
||||
<h1 className="font-bold text-6xl text-center">{Number(averageRating).toFixed(2)}</h1>
|
||||
<div className="mt-4 flex flex-col items-center justify-center gap-1">
|
||||
<StarRating rating={Number(averageRating)} size={40} />
|
||||
<p className="text-sm">{reviews.length} {t('ratings')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rating Progress Bars Section */}
|
||||
<div className="col-span-8 pt-4 md:pt-0 ltr:md:pl-8 rtl:md:pr-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
{[5, 4, 3, 2, 1].map((rating) => (
|
||||
<div className="flex items-center gap-2" key={`rating-${rating}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
{rating}
|
||||
<TiStarFullOutline color="black" size={24} />
|
||||
</div>
|
||||
<Progress value={ratingPercentages?.[rating] || 0} className='h-3' />
|
||||
<span>{ratingCount?.[rating] || 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingsSummary;
|
||||
31
components/PagesComponent/Reviews/RatingsSummarySkeleton.jsx
Normal file
31
components/PagesComponent/Reviews/RatingsSummarySkeleton.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const RatingsSummarySkeleton = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 p-4 rounded-xl border">
|
||||
{/* Average Rating Section Skeleton */}
|
||||
<div className="col-span-4 border-b md:border-b-0 ltr:md:border-r pb-4 md:pb-0 md:pr-4">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Skeleton className="h-24 w-24 rounded-md mb-4" />
|
||||
<Skeleton className="h-6 w-36 rounded-md mb-2" />
|
||||
<Skeleton className="h-4 w-20 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rating Progress Bars Section Skeleton */}
|
||||
<div className="col-span-8 pt-4 md:pt-0 md:pl-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
{[1, 2, 3, 4, 5].map((index) => (
|
||||
<div className="flex items-center gap-2" key={`skeleton-rating-${index}`}>
|
||||
<Skeleton className="h-6 w-10 rounded-md" />
|
||||
<Skeleton className="h-3 flex-1 rounded-md" />
|
||||
<Skeleton className="h-4 w-6 rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingsSummarySkeleton;
|
||||
109
components/PagesComponent/Reviews/ReportReviewModal.jsx
Normal file
109
components/PagesComponent/Reviews/ReportReviewModal.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { addReportReviewApi } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
import { t } from "@/utils";
|
||||
|
||||
const ReportReviewModal = ({ isOpen, setIsOpen, reviewId, setMyReviews }) => {
|
||||
const [reason, setReason] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [validationError, setValidationError] = useState("");
|
||||
|
||||
// Clear reason and validation error when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setReason("");
|
||||
setValidationError("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reason.trim()) {
|
||||
setValidationError("Please provide a reason for the report");
|
||||
return;
|
||||
}
|
||||
setValidationError("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const res = await addReportReviewApi.addReportReview({
|
||||
seller_review_id: reviewId,
|
||||
report_reason: reason,
|
||||
});
|
||||
|
||||
if (res?.data?.error === false) {
|
||||
toast.success(res?.data?.message)
|
||||
setMyReviews(prevReviews =>
|
||||
prevReviews.map(review =>
|
||||
review.id === reviewId ? { ...review, report_reason: res?.data?.data.report_reason, report_status: res?.data?.data.report_status } : review
|
||||
)
|
||||
);
|
||||
setReason("");
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
toast.error(res?.data?.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reporting review:", error);
|
||||
toast.error(t("somethingWentWrong"))
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear validation error when user types
|
||||
const handleReasonChange = (e) => {
|
||||
setReason(e.target.value);
|
||||
if (validationError) {
|
||||
setValidationError("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent onInteractOutside={(e) => e.preventDefault()} className="px-6 py-6 sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-medium">
|
||||
{t('reportReview')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-gray-600 mt-2">
|
||||
{t('reportReviewDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4">
|
||||
<Textarea
|
||||
placeholder="Write your reason here"
|
||||
className={`h-32 resize-none ${validationError ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
|
||||
value={reason}
|
||||
onChange={handleReasonChange}
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="text-red-500 text-sm mt-1">{validationError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t("submitting") : t("report")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportReviewModal;
|
||||
90
components/PagesComponent/Reviews/Reviews.jsx
Normal file
90
components/PagesComponent/Reviews/Reviews.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
import { t } from "@/utils";
|
||||
import RatingsSummary from "./RatingsSummary";
|
||||
import RatingsSummarySkeleton from "./RatingsSummarySkeleton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getMyReviewsApi } from "@/utils/api";
|
||||
import MyReviewsCard from "./MyReviewsCard.jsx";
|
||||
import MyReviewsCardSkeleton from "@/components/PagesComponent/Reviews/MyReviewsCardSkeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
|
||||
const Reviews = () => {
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const [MyReviews, setMyReviews] = useState([]);
|
||||
const [AverageRating, setAverageRating] = useState("");
|
||||
const [CurrentPage, setCurrentPage] = useState(1);
|
||||
const [ReviewHasMore, setReviewHasMore] = useState(false);
|
||||
const [IsLoading, setIsLoading] = useState(false);
|
||||
const [IsLoadMore, setIsLoadMore] = useState(false);
|
||||
|
||||
const getReveiws = async (page) => {
|
||||
try {
|
||||
if (page === 1) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
const res = await getMyReviewsApi.getMyReviews({ page });
|
||||
setAverageRating(res?.data?.data?.average_rating);
|
||||
setMyReviews(res?.data?.data?.ratings?.data);
|
||||
setCurrentPage(res?.data?.data?.ratings?.current_page);
|
||||
if (
|
||||
res?.data?.data?.ratings?.current_page <
|
||||
res?.data?.data?.ratings?.last_page
|
||||
) {
|
||||
setReviewHasMore(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getReveiws(1);
|
||||
}, [CurrentLanguage?.id]);
|
||||
|
||||
const handleReviewLoadMore = () => {
|
||||
setIsLoadMore(true);
|
||||
getReveiws(CurrentPage + 1);
|
||||
};
|
||||
|
||||
return IsLoading ? (
|
||||
<>
|
||||
<RatingsSummarySkeleton />
|
||||
<MyReviewsCardSkeleton />
|
||||
</>
|
||||
) : MyReviews && MyReviews.length > 0 ? (
|
||||
<>
|
||||
<RatingsSummary averageRating={AverageRating} reviews={MyReviews} />
|
||||
<div className="mt-[30px] p-2 sm:p-4 bg-muted rounded-xl flex flex-col gap-[30px]">
|
||||
{MyReviews?.map((rating) => (
|
||||
<MyReviewsCard
|
||||
rating={rating}
|
||||
key={rating?.id}
|
||||
setMyReviews={setMyReviews}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{ReviewHasMore && (
|
||||
<div className="text-center mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-sm sm:text-base text-primary w-[256px]"
|
||||
disabled={IsLoading || IsLoadMore}
|
||||
onClick={handleReviewLoadMore}
|
||||
>
|
||||
{IsLoadMore ? t("loading") : t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<NoData name={t("reviews")} />
|
||||
);
|
||||
};
|
||||
|
||||
export default Reviews;
|
||||
70
components/PagesComponent/Reviews/SellerReviewCard.jsx
Normal file
70
components/PagesComponent/Reviews/SellerReviewCard.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { formatDate, t } from "@/utils";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import StarRating from "./StarRating";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const SellerReviewCard = ({ rating }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isTextOverflowing, setIsTextOverflowing] = useState(false);
|
||||
const textRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
const content = textRef.current;
|
||||
if (content) {
|
||||
setIsTextOverflowing(content.scrollHeight > content.clientHeight);
|
||||
}
|
||||
};
|
||||
checkOverflow();
|
||||
window.addEventListener("resize", checkOverflow);
|
||||
return () => window.removeEventListener("resize", checkOverflow);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-lg flex flex-col gap-4">
|
||||
<div className="flex sm:items-center gap-4 flex-col sm:flex-row">
|
||||
<CustomImage
|
||||
src={rating?.buyer?.profile}
|
||||
width={72}
|
||||
height={72}
|
||||
alt="Reviewer"
|
||||
className="aspect-square rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="font-semibold">{rating?.buyer?.name}</p>
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-1 w-full">
|
||||
<div className="flex items-center gap-1">
|
||||
<StarRating rating={Number(rating?.ratings)} size={24} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{rating?.ratings}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm justify-self-end">
|
||||
{formatDate(rating?.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b"></div>
|
||||
|
||||
<div>
|
||||
<p ref={textRef} className={`${!isExpanded ? "line-clamp-2" : ""}`}>
|
||||
{rating?.review}
|
||||
</p>
|
||||
{isTextOverflowing && (
|
||||
<div className="flex justify-center mt-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-primary text-sm hover:underline"
|
||||
>
|
||||
{isExpanded ? t("seeLess") : t("seeMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SellerReviewCard;
|
||||
38
components/PagesComponent/Reviews/StarRating.jsx
Normal file
38
components/PagesComponent/Reviews/StarRating.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
import { TiStarFullOutline, TiStarHalfOutline, TiStarOutline } from "react-icons/ti";
|
||||
|
||||
const StarRating = ({ rating = 0, size = 16, maxStars = 5, showEmpty = true }) => {
|
||||
|
||||
// Get the integer part of the rating (full stars)
|
||||
const fullStars = Math.floor(rating);
|
||||
|
||||
// Check if there's any decimal part at all (0.1, 0.2, ..., 0.9)
|
||||
const hasDecimal = rating % 1 !== 0;
|
||||
|
||||
// If there's any decimal, always show a half star
|
||||
const hasHalfStar = hasDecimal;
|
||||
|
||||
// Calculate empty stars
|
||||
const emptyStars = maxStars - fullStars - (hasHalfStar ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 max-w-full">
|
||||
{/* Render full stars */}
|
||||
{[...Array(fullStars)].map((_, index) => (
|
||||
<TiStarFullOutline key={`full-${index}`} color="#FFD700" size={size} />
|
||||
))}
|
||||
|
||||
{/* Render half star if there's any decimal */}
|
||||
{hasHalfStar && (
|
||||
<TiStarHalfOutline key="half" className="rtl:scale-x-[-1]" color="#FFD700" size={size} />
|
||||
)}
|
||||
|
||||
{/* Render empty stars */}
|
||||
{showEmpty && [...Array(emptyStars)].map((_, index) => (
|
||||
<TiStarOutline key={`empty-${index}`} color="#0000002E" size={size} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StarRating;
|
||||
141
components/PagesComponent/Seller/Seller.jsx
Normal file
141
components/PagesComponent/Seller/Seller.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import SellerLsitings from "./SellerLsitings";
|
||||
import SellerDetailCard from "./SellerDetailCard";
|
||||
import { getSellerApi } from "@/utils/api";
|
||||
import { t } from "@/utils";
|
||||
import SellerRating from "./SellerRating";
|
||||
import SellerSkeleton from "./SellerSkeleton";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import OpenInAppDrawer from "@/components/Common/OpenInAppDrawer";
|
||||
import BreadCrumb from "@/components/BreadCrumb/BreadCrumb";
|
||||
import { useSelector } from "react-redux";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
|
||||
const Seller = ({ id, searchParams }) => {
|
||||
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const [steps, setSteps] = useState(1);
|
||||
const [IsNoUserFound, setIsNoUserFound] = useState(false);
|
||||
|
||||
const [seller, setSeller] = useState(null);
|
||||
const [ratings, setRatings] = useState(null);
|
||||
const [isSellerDataLoading, setIsSellerDataLoading] = useState(false);
|
||||
|
||||
const [isLoadMoreReview, setIsLoadMoreReview] = useState(false);
|
||||
const [reviewHasMore, setReviewHasMore] = useState(false);
|
||||
const [reviewCurrentPage, setReviewCurrentPage] = useState(1);
|
||||
|
||||
const [isOpenInApp, setIsOpenInApp] = useState(false);
|
||||
const isShare = searchParams?.share == "true" ? true : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth <= 768 && isShare) {
|
||||
setIsOpenInApp(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getSeller(reviewCurrentPage);
|
||||
}, []);
|
||||
|
||||
const getSeller = async (page) => {
|
||||
if (page === 1) {
|
||||
setIsSellerDataLoading(true);
|
||||
}
|
||||
try {
|
||||
const res = await getSellerApi.getSeller({ id: Number(id), page });
|
||||
if (res?.data.error && res?.data?.code === 103) {
|
||||
setIsNoUserFound(true);
|
||||
} else {
|
||||
const sellerData = res?.data?.data?.ratings;
|
||||
if (page === 1) {
|
||||
setRatings(sellerData);
|
||||
} else {
|
||||
setRatings({
|
||||
...ratings,
|
||||
data: [...ratings?.data, ...sellerData?.data],
|
||||
});
|
||||
}
|
||||
setSeller(res?.data?.data?.seller);
|
||||
setReviewCurrentPage(res?.data?.data?.ratings?.current_page);
|
||||
if (
|
||||
res?.data?.data?.ratings?.current_page <
|
||||
res?.data?.data?.ratings?.last_page
|
||||
) {
|
||||
setReviewHasMore(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsSellerDataLoading(false);
|
||||
setIsLoadMoreReview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSteps = (step) => {
|
||||
setSteps(step);
|
||||
};
|
||||
|
||||
if (IsNoUserFound) {
|
||||
return <NoData name={t("noSellerFound")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
{isSellerDataLoading ? (
|
||||
<SellerSkeleton steps={steps} />
|
||||
) : (
|
||||
<>
|
||||
<BreadCrumb title2={seller?.name} />
|
||||
<div className="container mx-auto mt-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<SellerDetailCard seller={seller} ratings={ratings} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-8 col-span-12 lg:col-span-8">
|
||||
<div className="p-4 flex items-center gap-4 bg-muted border rounded-md w-full">
|
||||
<button
|
||||
onClick={() => handleSteps(1)}
|
||||
className={`py-2 px-4 rounded-md ${
|
||||
steps === 1 ? "bg-primary text-white" : ""
|
||||
}`}
|
||||
>
|
||||
{t("liveAds")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSteps(2)}
|
||||
className={`py-2 px-4 rounded-md ${
|
||||
steps === 2 ? "bg-primary text-white" : ""
|
||||
}`}
|
||||
>
|
||||
{t("reviews")}
|
||||
</button>
|
||||
</div>
|
||||
{steps === 1 && <SellerLsitings id={id} />}
|
||||
{steps === 2 && (
|
||||
<SellerRating
|
||||
ratingsData={ratings}
|
||||
seller={seller}
|
||||
isLoadMoreReview={isLoadMoreReview}
|
||||
reviewHasMore={reviewHasMore}
|
||||
reviewCurrentPage={reviewCurrentPage}
|
||||
getSeller={getSeller}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<OpenInAppDrawer
|
||||
isOpenInApp={isOpenInApp}
|
||||
setIsOpenInApp={setIsOpenInApp}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Seller;
|
||||
114
components/PagesComponent/Seller/SellerDetailCard.jsx
Normal file
114
components/PagesComponent/Seller/SellerDetailCard.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MdOutlineMailOutline, MdVerifiedUser } from "react-icons/md";
|
||||
import { IoMdStar } from "react-icons/io";
|
||||
import { FiPhoneCall } from "react-icons/fi";
|
||||
import { extractYear, t } from "@/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getCompanyName } from "@/redux/reducer/settingSlice";
|
||||
import ShareDropdown from "@/components/Common/ShareDropdown";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import Link from "next/link";
|
||||
|
||||
const SellerDetailCard = ({ seller, ratings }) => {
|
||||
const pathname = usePathname();
|
||||
const memberSinceYear = seller?.created_at
|
||||
? extractYear(seller.created_at)
|
||||
: "";
|
||||
const currentUrl = `${process.env.NEXT_PUBLIC_WEB_URL}${pathname}`;
|
||||
const CompanyName = useSelector(getCompanyName);
|
||||
const FbTitle = seller?.name + " | " + CompanyName;
|
||||
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<div className="flex justify-between items-center p-4 bg-muted">
|
||||
<h1 className="text-lg font-bold">{t("seller_info")}</h1>
|
||||
<ShareDropdown
|
||||
url={currentUrl}
|
||||
title={FbTitle}
|
||||
headline={FbTitle}
|
||||
companyName={CompanyName}
|
||||
className="rounded-md p-2 border bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(seller?.is_verified === 1 || memberSinceYear) && (
|
||||
<div className="border-t p-4">
|
||||
<div className="flex items-center">
|
||||
{seller?.is_verified === 1 && (
|
||||
<Badge className="p-1 bg-[#FA6E53] flex items-center gap-1 rounded-md text-white text-sm">
|
||||
<MdVerifiedUser size={22} />
|
||||
{t("verified")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{memberSinceYear && (
|
||||
<div className="ltr:ml-auto rtl:mr-auto text-sm text-muted-foreground">
|
||||
{t("memberSince")}: {memberSinceYear}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t flex flex-col justify-center items-center p-4 gap-4">
|
||||
<CustomImage
|
||||
src={seller?.profile}
|
||||
alt="Seller Image"
|
||||
width={120}
|
||||
height={120}
|
||||
className="aspect-square rounded-xl object-cover"
|
||||
/>
|
||||
|
||||
<div className="text-center w-full">
|
||||
<h3 className="text-xl font-bold">{seller?.name}</h3>
|
||||
<div className="flex items-center justify-center gap-1 text-sm mt-1">
|
||||
<IoMdStar />
|
||||
<span>
|
||||
{Number(seller?.average_rating).toFixed(2)} |{" "}
|
||||
{ratings?.data?.length} {t("ratings")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{seller?.show_personal_details === 1 &&
|
||||
(seller?.email || seller?.mobile) && (
|
||||
<div className="border-t p-4 flex flex-col gap-4">
|
||||
{seller?.email && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-3 bg-muted rounded-md border">
|
||||
<MdOutlineMailOutline className="size-4" />
|
||||
</div>
|
||||
<CustomLink
|
||||
href={`mailto:${seller?.email}`}
|
||||
className="break-all"
|
||||
>
|
||||
{seller?.email}
|
||||
</CustomLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{seller?.mobile && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-3 bg-muted rounded-md border">
|
||||
<FiPhoneCall className="size-4" />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`tel:${seller?.mobile}`}
|
||||
className="break-all"
|
||||
>
|
||||
{seller?.mobile}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SellerDetailCard;
|
||||
200
components/PagesComponent/Seller/SellerLsitings.jsx
Normal file
200
components/PagesComponent/Seller/SellerLsitings.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TbTransferVertical } from "react-icons/tb";
|
||||
import { IoGrid } from "react-icons/io5";
|
||||
import { allItemApi } from "@/utils/api";
|
||||
import ProductHorizontalCardSkeleton from "@/components/Common/ProductHorizontalCardSkeleton";
|
||||
import ProductCardSkeleton from "@/components/Common/ProductCardSkeleton";
|
||||
import ProductCard from "@/components/Common/ProductCard";
|
||||
import NoData from "@/components/EmptyStates/NoData";
|
||||
import ProductHorizontalCard from "@/components/Common/ProductHorizontalCard";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { MdViewStream } from "react-icons/md";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
|
||||
import { useSelector } from "react-redux";
|
||||
import { t } from "@/utils";
|
||||
|
||||
const SellerLsitings = ({ id }) => {
|
||||
const searchParams = useSearchParams();
|
||||
const view = searchParams.get("view") || "grid";
|
||||
const sortBy = searchParams.get("sort") || "new-to-old";
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const [isSellerItemsLoading, setIsSellerItemsLoading] = useState(false);
|
||||
const [sellerItems, setSellerItems] = useState([]);
|
||||
const [isSellerItemLoadMore, setIsSellerItemLoadMore] = useState(false);
|
||||
const [CurrentPage, setCurrentPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getSellerItems(1);
|
||||
}, [sortBy, CurrentLanguage.id]);
|
||||
|
||||
const getSellerItems = async (page) => {
|
||||
try {
|
||||
if (page === 1) {
|
||||
setIsSellerItemsLoading(true);
|
||||
}
|
||||
const res = await allItemApi.getItems({
|
||||
user_id: id,
|
||||
sort_by: sortBy,
|
||||
page,
|
||||
});
|
||||
|
||||
if (page > 1) {
|
||||
// Append new data to existing sellerItems
|
||||
setSellerItems((prevItems) => [...prevItems, ...res?.data?.data?.data]);
|
||||
} else {
|
||||
// Set new data if CurrentPage is 1 or initial load
|
||||
setSellerItems(res?.data?.data?.data);
|
||||
}
|
||||
|
||||
setCurrentPage(res?.data?.data?.current_page);
|
||||
if (res?.data?.data.current_page === res?.data?.data.last_page) {
|
||||
setHasMore(false); // Check if there's more data
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsSellerItemsLoading(false);
|
||||
setIsSellerItemLoadMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = (id) => {
|
||||
const updatedItems = sellerItems.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, is_liked: !item.is_liked };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
setSellerItems(updatedItems);
|
||||
};
|
||||
|
||||
const handleProdLoadMore = () => {
|
||||
setIsSellerItemLoadMore(true);
|
||||
getSellerItems(CurrentPage + 1);
|
||||
};
|
||||
|
||||
const toggleView = (newView) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("view", newView);
|
||||
window.history.pushState(null, "", `?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSortBy = (value) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("sort", value);
|
||||
window.history.pushState(null, "", `?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<TbTransferVertical />
|
||||
<span className="whitespace-nowrap">{t("sortBy")}</span>
|
||||
</div>
|
||||
<Select value={sortBy} onValueChange={handleSortBy}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("sortBy")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="new-to-old">
|
||||
{t("newestToOldest")}
|
||||
</SelectItem>
|
||||
<SelectItem value="old-to-new">
|
||||
{t("oldestToNewest")}
|
||||
</SelectItem>
|
||||
<SelectItem value="price-high-to-low">
|
||||
{t("priceHighToLow")}
|
||||
</SelectItem>
|
||||
<SelectItem value="price-low-to-high">
|
||||
{t("priceLowToHigh")}
|
||||
</SelectItem>
|
||||
<SelectItem value="popular_items">{t("popular")}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => toggleView("grid")}
|
||||
className={`flex items-center justify-center size-8 sm:size-10 text-muted-foreground transition-colors duration-300 cursor-pointer gap-2 rounded-full ${
|
||||
view === "grid"
|
||||
? "bg-primary text-white"
|
||||
: "hover:bg-black/15 hover:text-black"
|
||||
}`}
|
||||
>
|
||||
<IoGrid className="size-5 sm:size-6" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleView("list")}
|
||||
className={`flex items-center justify-center size-8 sm:size-10 text-muted-foreground hover:text-black transition-colors duration-300 cursor-pointer gap-2 rounded-full ${
|
||||
view === "list"
|
||||
? "bg-primary text-white"
|
||||
: "hover:text-black hover:bg-black/15"
|
||||
}`}
|
||||
>
|
||||
<MdViewStream className="size-5 sm:size-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{isSellerItemsLoading ? (
|
||||
Array.from({ length: 12 }).map((_, index) =>
|
||||
view === "list" ? (
|
||||
<div className="col-span-12" key={index}>
|
||||
<ProductHorizontalCardSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div key={index} className="col-span-6 lg:col-span-4">
|
||||
<ProductCardSkeleton />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : sellerItems && sellerItems.length > 0 ? (
|
||||
sellerItems?.map((item, index) =>
|
||||
view === "list" ? (
|
||||
<div className="col-span-12" key={index}>
|
||||
<ProductHorizontalCard item={item} handleLike={handleLike} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-span-6 lg:col-span-4" key={index}>
|
||||
<ProductCard item={item} handleLike={handleLike} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<div className="col-span-12">
|
||||
<NoData name={t("ads")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sellerItems && sellerItems.length > 0 && hasMore && (
|
||||
<div className="text-center mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-sm sm:text-base text-primary w-[256px]"
|
||||
disabled={isSellerItemsLoading || isSellerItemLoadMore}
|
||||
onClick={handleProdLoadMore}
|
||||
>
|
||||
{isSellerItemLoadMore ? t("loading") : t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SellerLsitings;
|
||||
39
components/PagesComponent/Seller/SellerRating.jsx
Normal file
39
components/PagesComponent/Seller/SellerRating.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { t } from '@/utils';
|
||||
import RatingsSummary from '../Reviews/RatingsSummary';
|
||||
import SellerReviewCard from "@/components/PagesComponent/Reviews/SellerReviewCard";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import NoData from '@/components/EmptyStates/NoData';
|
||||
|
||||
|
||||
const SellerRating = ({ ratingsData, seller, isLoadMoreReview, reviewHasMore, reviewCurrentPage, getSeller }) => {
|
||||
|
||||
return (
|
||||
ratingsData?.data?.length > 0 ?
|
||||
<>
|
||||
<RatingsSummary averageRating={seller?.average_rating} reviews={ratingsData?.data} />
|
||||
<div className='flex flex-col gap-4 bg-muted p-4 rounded-lg'>
|
||||
{ratingsData?.data?.map((rating) => (
|
||||
<SellerReviewCard key={rating.id} rating={rating} />
|
||||
))}
|
||||
{
|
||||
ratingsData?.data?.length > 0 && reviewHasMore && (
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-sm sm:text-base text-primary w-[256px]"
|
||||
disabled={isLoadMoreReview}
|
||||
onClick={() => getSeller(reviewCurrentPage + 1)}
|
||||
>
|
||||
{isLoadMoreReview ? t("loading") : t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
<NoData name={t('reviews')} />
|
||||
);
|
||||
};
|
||||
|
||||
export default SellerRating;
|
||||
75
components/PagesComponent/Seller/SellerSkeleton.jsx
Normal file
75
components/PagesComponent/Seller/SellerSkeleton.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
const SellerSkeleton = ({ steps }) => {
|
||||
return (
|
||||
<div className="container mx-auto mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||
{/* Left Side - Seller Detail Card Skeleton */}
|
||||
<div className="col-span-12 md:col-span-4">
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Skeleton className="h-32 w-32 rounded-full" />
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Skeleton key={i} className="h-5 w-5 rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Content Skeleton */}
|
||||
<div className="flex flex-col gap-4 col-span-12 md:col-span-8">
|
||||
{/* Tabs Skeleton */}
|
||||
<div className="p-4 flex items-center gap-4 bg-muted rounded-md w-full">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
|
||||
{/* Content Area Skeleton */}
|
||||
|
||||
{steps === 1 ? (
|
||||
// Listings Skeleton
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map(i => (
|
||||
<div key={i} className="border rounded-lg p-4">
|
||||
<Skeleton className="h-40 w-full mb-4" />
|
||||
<Skeleton className="h-4 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Ratings Skeleton
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex gap-6 items-center">
|
||||
<div className="flex flex-col gap-3 items-center">
|
||||
<Skeleton className="h-16 w-16" />
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Skeleton key={i} className="h-5 w-5" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{[5, 4, 3, 2, 1].map(i => (
|
||||
<div key={i} className="flex items-center space-x-3 mb-3">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-2 flex-1" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SellerSkeleton;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user