classify web
This commit is contained in:
42
components/Common/CustomImage.jsx
Normal file
42
components/Common/CustomImage.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { getPlaceholderImage } from "@/redux/reducer/settingSlice";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const CustomImage = ({ src, alt, loading = "lazy", ...props }) => {
|
||||
const placeholderImage = useSelector(getPlaceholderImage);
|
||||
const fallback = "/assets/Transperant_Placeholder.png";
|
||||
// Initial source can be string OR object (StaticImageData)
|
||||
const initialSrc =
|
||||
(src && (typeof src === "string" ? src.trim() : src)) ||
|
||||
(placeholderImage && placeholderImage.trim?.()) ||
|
||||
fallback;
|
||||
|
||||
const [imgSrc, setImgSrc] = useState(initialSrc);
|
||||
|
||||
const handleError = () => {
|
||||
if (
|
||||
imgSrc !== placeholderImage &&
|
||||
typeof placeholderImage === "string" &&
|
||||
placeholderImage.trim()
|
||||
) {
|
||||
setImgSrc(placeholderImage);
|
||||
} else if (imgSrc !== fallback) {
|
||||
setImgSrc(fallback);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={imgSrc}
|
||||
alt={alt}
|
||||
onError={handleError}
|
||||
loading={loading} // Dynamic loading: defaults to "lazy" if not provided
|
||||
{...props} // width, height, className etc can still be passed
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomImage;
|
||||
29
components/Common/CustomLink.jsx
Normal file
29
components/Common/CustomLink.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||
import { getDefaultLanguageCode } from "@/redux/reducer/settingSlice";
|
||||
import Link from "next/link";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const CustomLink = ({ href, children, ...props }) => {
|
||||
const defaultLangCode = useSelector(getDefaultLanguageCode);
|
||||
const currentLangCode = useSelector(getCurrentLangCode);
|
||||
|
||||
const langCode = currentLangCode || defaultLangCode;
|
||||
|
||||
// Split hash (#) safely from href
|
||||
const [baseHref, hash = ""] = href.split("#");
|
||||
|
||||
// Append lang param safely
|
||||
const separator = baseHref.includes("?") ? "&" : "?";
|
||||
const newHref = `${baseHref}${separator}lang=${langCode}${
|
||||
hash ? `#${hash}` : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<Link href={newHref} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomLink;
|
||||
11
components/Common/Header.jsx
Normal file
11
components/Common/Header.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
import { usePathname } from "next/navigation";
|
||||
import LandingHeader from "../PagesComponent/LandingPage/LandingHeader";
|
||||
import HomeHeader from "../PagesComponent/Home/HomeHeader";
|
||||
|
||||
const Header = () => {
|
||||
const pathname = usePathname();
|
||||
return pathname === "/landing" ? <LandingHeader /> : <HomeHeader />;
|
||||
};
|
||||
|
||||
export default Header;
|
||||
218
components/Common/LanguageDropdown.jsx
Normal file
218
components/Common/LanguageDropdown.jsx
Normal file
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
CurrentLanguageData,
|
||||
setCurrentLanguage,
|
||||
} from "@/redux/reducer/languageSlice";
|
||||
import { getCityData, saveCity } from "@/redux/reducer/locationSlice";
|
||||
import { getIsPaidApi, settingsData } from "@/redux/reducer/settingSlice";
|
||||
import { isEmptyObject, updateStickyNoteTranslations } from "@/utils";
|
||||
import { getLanguageApi, getLocationApi } from "@/utils/api";
|
||||
import {
|
||||
setHasFetchedCategories,
|
||||
setHasFetchedSystemSettings,
|
||||
} from "@/utils/getFetcherStatus";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { toast } from "sonner";
|
||||
import CustomImage from "./CustomImage";
|
||||
|
||||
const LanguageDropdown = () => {
|
||||
const IsPaidApi = useSelector(getIsPaidApi);
|
||||
const router = useRouter();
|
||||
const dispatch = useDispatch();
|
||||
const pathname = usePathname();
|
||||
const location = useSelector(getCityData);
|
||||
const settings = useSelector(settingsData);
|
||||
const CurrentLanguage = useSelector(CurrentLanguageData);
|
||||
const currentLangCode = CurrentLanguage?.code;
|
||||
const languages = settings && settings?.languages;
|
||||
const isRTL = CurrentLanguage.rtl;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const langCode = searchParams?.get("lang");
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
const setDefaultLanguage = async () => {
|
||||
try {
|
||||
params.set("lang", settings?.default_language.toLowerCase());
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
const language_code = settings?.default_language;
|
||||
const res = await getLanguageApi.getLanguage({
|
||||
language_code,
|
||||
type: "web",
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
dispatch(setCurrentLanguage(res?.data?.data));
|
||||
document.documentElement.lang =
|
||||
res?.data?.data?.code?.toLowerCase() ||
|
||||
settings?.default_language.toLowerCase();
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Redux language is empty or invalid
|
||||
if (isEmptyObject(CurrentLanguage)) {
|
||||
setDefaultLanguage();
|
||||
return;
|
||||
}
|
||||
|
||||
// If URL has lang parameter and languages are loaded, check if valid and update if needed
|
||||
if (langCode && languages.length > 0) {
|
||||
const urlLang = languages.find(
|
||||
(lang) => lang.code?.toUpperCase() === langCode.toUpperCase()
|
||||
);
|
||||
if (
|
||||
urlLang &&
|
||||
currentLangCode?.toUpperCase() !== urlLang.code.toUpperCase()
|
||||
) {
|
||||
getLanguageData(urlLang.code);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current language code is no longer valid (language was removed from settings)
|
||||
if (languages && !languages.some((lang) => lang.code === currentLangCode)) {
|
||||
setDefaultLanguage();
|
||||
return;
|
||||
}
|
||||
if (!langCode) {
|
||||
params.set("lang", currentLangCode);
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
}
|
||||
}, [langCode]);
|
||||
|
||||
const getLanguageData = async (
|
||||
language_code = settings?.default_language
|
||||
) => {
|
||||
try {
|
||||
const res = await getLanguageApi.getLanguage({
|
||||
language_code,
|
||||
type: "web",
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
dispatch(setCurrentLanguage(res?.data?.data));
|
||||
getLocationAfterLanguageChange(language_code);
|
||||
document.documentElement.lang =
|
||||
res?.data?.data?.code?.toLowerCase() || "en";
|
||||
|
||||
setHasFetchedSystemSettings(false);
|
||||
setHasFetchedCategories(false);
|
||||
updateStickyNoteTranslations();
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getLocationAfterLanguageChange = async (language_code) => {
|
||||
if (IsPaidApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If no country/state/city/area stored, skip API call
|
||||
if (
|
||||
!location?.country &&
|
||||
!location?.state &&
|
||||
!location?.city &&
|
||||
!location?.area
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getLocationApi.getLocation({
|
||||
lat: location?.lat,
|
||||
lng: location?.long,
|
||||
lang: language_code,
|
||||
});
|
||||
|
||||
if (response?.data.error === false) {
|
||||
const result = response?.data?.data;
|
||||
const updatedLocation = {};
|
||||
|
||||
if (location?.country) updatedLocation.country = result?.country;
|
||||
if (location?.state) updatedLocation.state = result?.state;
|
||||
if (location?.city) updatedLocation.city = result?.city;
|
||||
if (location?.area) {
|
||||
updatedLocation.area = result?.area;
|
||||
updatedLocation.areaId = result?.area_id;
|
||||
}
|
||||
updatedLocation.lat = location?.lat;
|
||||
updatedLocation.long = location?.long;
|
||||
|
||||
// ✅ Dynamically build formattedAddress only with existing parts
|
||||
const parts = [];
|
||||
if (location?.area) parts.push(result?.area_translation);
|
||||
if (location?.city) parts.push(result?.city_translation);
|
||||
if (location?.state) parts.push(result?.state_translation);
|
||||
if (location?.country) parts.push(result?.country_translation);
|
||||
updatedLocation.address_translated = parts.filter(Boolean).join(", ");
|
||||
saveCity(updatedLocation);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageSelect = (id) => {
|
||||
const lang = languages?.find((item) => item.id === Number(id));
|
||||
if (CurrentLanguage.id === lang.id) {
|
||||
return;
|
||||
}
|
||||
params.set("lang", lang.code.toLowerCase()); // Store language code
|
||||
// Push new URL with lang param
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
getLanguageData(lang?.code);
|
||||
};
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="border rounded-full py-2 px-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<CustomImage
|
||||
key={CurrentLanguage?.id}
|
||||
src={CurrentLanguage?.image}
|
||||
alt={CurrentLanguage?.name || "language"}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<span>{CurrentLanguage?.code}</span>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-0 max-h-[250px] overflow-y-auto"
|
||||
align={isRTL ? "start" : "end"}
|
||||
>
|
||||
{languages &&
|
||||
languages.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang?.id}
|
||||
onClick={() => handleLanguageSelect(lang.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<CustomImage
|
||||
src={lang?.image}
|
||||
alt={lang.name || "english"}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<span>{lang.code}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageDropdown;
|
||||
12
components/Common/Loader.jsx
Normal file
12
components/Common/Loader.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
const Loader = () => {
|
||||
return (
|
||||
<div className="h-screen flex items-center 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loader;
|
||||
89
components/Common/MapComponent.jsx
Normal file
89
components/Common/MapComponent.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
getDefaultLatitude,
|
||||
getDefaultLongitude,
|
||||
} from "@/redux/reducer/settingSlice";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { MapContainer, Marker, TileLayer, useMapEvents } from "react-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import L from "leaflet";
|
||||
|
||||
// Fix Leaflet default marker icon issue
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl:
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
|
||||
iconUrl:
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
|
||||
shadowUrl:
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
|
||||
});
|
||||
|
||||
const MapClickHandler = ({ onMapClick }) => {
|
||||
useMapEvents({
|
||||
click: (e) => {
|
||||
onMapClick(e.latlng);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
const MapComponent = ({ getLocationWithMap, location }) => {
|
||||
const latitude = useSelector(getDefaultLatitude);
|
||||
const longitude = useSelector(getDefaultLongitude);
|
||||
|
||||
const mapRef = useRef();
|
||||
const position = {
|
||||
lat: Number(location?.lat) || latitude,
|
||||
lng: Number(location?.long) || longitude,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (mapRef.current && position.lat && position.lng) {
|
||||
mapRef.current.flyTo(
|
||||
[position.lat, position.lng],
|
||||
mapRef.current.getZoom()
|
||||
);
|
||||
}
|
||||
}, [position?.lat, position?.lng]);
|
||||
|
||||
const containerStyle = {
|
||||
width: "100%",
|
||||
height: "400px",
|
||||
zIndex: 0,
|
||||
};
|
||||
|
||||
const handleMapClick = (latlng) => {
|
||||
if (getLocationWithMap) {
|
||||
getLocationWithMap({
|
||||
lat: latlng.lat,
|
||||
lng: latlng.lng,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MapContainer
|
||||
style={containerStyle}
|
||||
center={[position?.lat, position?.lng]}
|
||||
zoom={6}
|
||||
ref={mapRef}
|
||||
whenCreated={(mapInstance) => {
|
||||
mapRef.current = mapInstance;
|
||||
}}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapClickHandler onMapClick={handleMapClick} />
|
||||
{position?.lat && position?.lng && (
|
||||
<Marker position={[position?.lat, position?.lng]}></Marker>
|
||||
)}
|
||||
</MapContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapComponent;
|
||||
83
components/Common/OpenInAppDrawer.jsx
Normal file
83
components/Common/OpenInAppDrawer.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer"
|
||||
import { settingsData } from "@/redux/reducer/settingSlice";
|
||||
import { t } from "@/utils"
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const OpenInAppDrawer = ({ isOpenInApp, setIsOpenInApp }) => {
|
||||
|
||||
const path = usePathname()
|
||||
const settings = useSelector(settingsData);
|
||||
const companyName = settings?.company_name;
|
||||
const scheme = settings?.deep_link_scheme;
|
||||
const playStoreLink = settings?.play_store_link;
|
||||
const appStoreLink = settings?.app_store_link;
|
||||
|
||||
|
||||
function handleOpenInApp() {
|
||||
|
||||
var appScheme = `${scheme}://${window.location.hostname}${path}`;
|
||||
var userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||
var isAndroid = /android/i.test(userAgent);
|
||||
var isIOS = /iPad|iPhone|iPod/.test(userAgent) && !window.MSStream;
|
||||
|
||||
let applicationLink;
|
||||
if (isAndroid) {
|
||||
applicationLink = playStoreLink;
|
||||
} else if (isIOS) {
|
||||
applicationLink = appStoreLink;
|
||||
} else {
|
||||
// Fallback for desktop or other platforms
|
||||
applicationLink = playStoreLink || appStoreLink;
|
||||
}
|
||||
|
||||
// Attempt to open the app
|
||||
window.location.href = appScheme;
|
||||
// Set a timeout to check if app opened
|
||||
setTimeout(function () {
|
||||
if (document.hidden || document.webkitHidden) {
|
||||
// App opened successfully
|
||||
} else {
|
||||
// App is not installed, ask user if they want to go to app store
|
||||
if (confirm(`${companyName} ${t('appIsNotInstalled')} ${isIOS ? t('appStore') : t('playStore')}?`)) {
|
||||
|
||||
if (!applicationLink) {
|
||||
toast.error(`${companyName} ${isIOS ? t('appStore') : t('playStore')} ${t('linkNotAvailable')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = applicationLink;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={isOpenInApp} onOpenChange={setIsOpenInApp}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{`${t('viewIn')} ${companyName} ${t('app')}`}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{t('getTheBestExperienceByOpeningThisInOurMobileApp')}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerFooter>
|
||||
<Button onClick={handleOpenInApp}>
|
||||
{t('openInApp')}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenInAppDrawer
|
||||
14
components/Common/PageLoader.jsx
Normal file
14
components/Common/PageLoader.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
const PageLoader = () => {
|
||||
return (
|
||||
<div className="h-[calc(100vh-20vh)] flex items-center 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>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageLoader
|
||||
113
components/Common/Pagination.jsx
Normal file
113
components/Common/Pagination.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
import {
|
||||
Pagination as PaginationContainer,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
const Pagination = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
className = "",
|
||||
}) => {
|
||||
const handlePageChange = (page) => {
|
||||
onPageChange(page);
|
||||
};
|
||||
|
||||
const generatePaginationItems = () => {
|
||||
const items = [];
|
||||
|
||||
if (totalPages <= 6) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
items.push(i);
|
||||
}
|
||||
} else {
|
||||
if (currentPage <= 3) {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
items.push(i);
|
||||
}
|
||||
items.push(null); // Ellipsis
|
||||
items.push(totalPages);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
items.push(1);
|
||||
items.push(null); // Ellipsis
|
||||
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||||
items.push(i);
|
||||
}
|
||||
} else {
|
||||
items.push(1);
|
||||
items.push(null); // Ellipsis
|
||||
items.push(currentPage - 1);
|
||||
items.push(currentPage);
|
||||
items.push(currentPage + 1);
|
||||
items.push(null); // Ellipsis
|
||||
items.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
// Don't render if there's only 1 page or no pages
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<PaginationContainer className={className}>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) handlePageChange(currentPage - 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === 1 ? "pointer-events-none opacity-50" : ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{generatePaginationItems().map((page, index) =>
|
||||
page === null ? (
|
||||
<PaginationItem key={`ellipsis-${index}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handlePageChange(page);
|
||||
}}
|
||||
isActive={page === currentPage}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages) handlePageChange(currentPage + 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === totalPages ? "pointer-events-none opacity-50" : ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</PaginationContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
120
components/Common/ProductCard.jsx
Normal file
120
components/Common/ProductCard.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { formatDate, t } from "@/utils";
|
||||
import { BiBadgeCheck } from "react-icons/bi";
|
||||
import { FaHeart, FaRegHeart } from "react-icons/fa";
|
||||
import { manageFavouriteApi } from "@/utils/api";
|
||||
import { useSelector } from "react-redux";
|
||||
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { toast } from "sonner";
|
||||
import { setIsLoginOpen } from "@/redux/reducer/globalStateSlice";
|
||||
import CustomImage from "./CustomImage";
|
||||
|
||||
const ProductCard = ({ item, handleLike }) => {
|
||||
const userData = useSelector(userSignUpData);
|
||||
const isJobCategory = Number(item?.category?.is_job_category) === 1;
|
||||
const translated_item = item.translated_item;
|
||||
|
||||
const isHidePrice = isJobCategory
|
||||
? !item?.formatted_salary_range
|
||||
: !item?.formatted_price;
|
||||
|
||||
const price = isJobCategory
|
||||
? item?.formatted_salary_range
|
||||
: item?.formatted_price;
|
||||
|
||||
const productLink =
|
||||
userData?.id === item?.user_id
|
||||
? `/my-listing/${item?.slug}`
|
||||
: `/ad-details/${item.slug}`;
|
||||
|
||||
const handleLikeItem = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (!userData) {
|
||||
setIsLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
const response = await manageFavouriteApi.manageFavouriteApi({
|
||||
item_id: item?.id,
|
||||
});
|
||||
if (response?.data?.error === false) {
|
||||
toast.success(response?.data?.message);
|
||||
handleLike(item?.id);
|
||||
} else {
|
||||
toast.error(t("failedToLike"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error(t("failedToLike"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomLink
|
||||
href={productLink}
|
||||
className="border p-2 rounded-2xl flex flex-col gap-2 h-full"
|
||||
>
|
||||
<div className="relative">
|
||||
<CustomImage
|
||||
src={item?.image}
|
||||
width={288}
|
||||
height={249}
|
||||
className="w-full aspect-square rounded object-cover"
|
||||
alt="Product"
|
||||
/>
|
||||
{item?.is_feature && (
|
||||
<div className="flex items-center gap-1 ltr:rounded-tl rtl:rounded-tr py-0.5 px-1 bg-primary absolute top-0 ltr:left-0 rtl:right-0">
|
||||
<BiBadgeCheck size={16} color="white" />
|
||||
<p className="text-white text-xs sm:text-sm">{t("featured")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onClick={handleLikeItem}
|
||||
className="absolute h-10 w-10 ltr:right-2 rtl:left-2 top-2 bg-white p-2 rounded-full flex items-center justify-center text-primary"
|
||||
>
|
||||
{item?.is_liked ? (
|
||||
<button>
|
||||
<FaHeart size={24} className="like_icon" />
|
||||
</button>
|
||||
) : (
|
||||
<button>
|
||||
<FaRegHeart size={24} className="like_icon" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-between gap-2">
|
||||
{isHidePrice ? (
|
||||
<p className="text-sm sm:text-base font-medium line-clamp-1">
|
||||
{translated_item?.name || item?.name}
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className="text-sm sm:text-lg font-bold break-all text-balance line-clamp-2"
|
||||
title={price}
|
||||
>
|
||||
{price}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs sm:text-sm opacity-65 whitespace-nowrap">
|
||||
{formatDate(item?.created_at)}‎
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isHidePrice && (
|
||||
<p className="text-sm sm:text-base font-medium line-clamp-1">
|
||||
{translated_item?.name || item?.name}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs sm:text-sm opacity-65 line-clamp-1">
|
||||
{item?.translated_address}
|
||||
</p>
|
||||
</CustomLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCard;
|
||||
17
components/Common/ProductCardSkeleton.jsx
Normal file
17
components/Common/ProductCardSkeleton.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
const ProductCardSkeleton = () => {
|
||||
return (
|
||||
<div className="border p-2 rounded-2xl flex flex-col gap-2">
|
||||
<Skeleton className="w-full aspect-square" />
|
||||
<div className="space-between">
|
||||
<Skeleton className="w-1/4 h-4" />
|
||||
<Skeleton className="w-1/4 h-4" />
|
||||
</div>
|
||||
<Skeleton className="w-3/4 h-4" />
|
||||
<Skeleton className="w-2/3 h-4" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCardSkeleton;
|
||||
116
components/Common/ProductHorizontalCard.jsx
Normal file
116
components/Common/ProductHorizontalCard.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { formatDate, t } from "@/utils";
|
||||
import { BiBadgeCheck } from "react-icons/bi";
|
||||
import { FaHeart, FaRegHeart } from "react-icons/fa";
|
||||
import { manageFavouriteApi } from "@/utils/api";
|
||||
import { useSelector } from "react-redux";
|
||||
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||
import { toast } from "sonner";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { setIsLoginOpen } from "@/redux/reducer/globalStateSlice";
|
||||
import CustomImage from "./CustomImage";
|
||||
|
||||
const ProductHorizontalCard = ({ item, handleLike }) => {
|
||||
const userData = useSelector(userSignUpData);
|
||||
const translated_item = item.translated_item;
|
||||
|
||||
const productLink =
|
||||
userData?.id === item?.user_id
|
||||
? `/my-listing/${item?.slug}`
|
||||
: `/ad-details/${item.slug}`;
|
||||
const isJobCategory = Number(item?.category?.is_job_category) === 1;
|
||||
|
||||
const isHidePrice = isJobCategory
|
||||
? !item?.formatted_salary_range
|
||||
: !item?.formatted_price;
|
||||
|
||||
const price = isJobCategory
|
||||
? item?.formatted_salary_range
|
||||
: item?.formatted_price;
|
||||
|
||||
const handleLikeItem = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (!userData) {
|
||||
setIsLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
const response = await manageFavouriteApi.manageFavouriteApi({
|
||||
item_id: item?.id,
|
||||
});
|
||||
if (response?.data?.error === false) {
|
||||
toast.success(response?.data?.message);
|
||||
handleLike(item?.id);
|
||||
} else {
|
||||
toast.error(t("failedToLike"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error(t("failedToLike"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomLink
|
||||
href={productLink}
|
||||
className="border p-2 rounded-md flex items-center gap-2 sm:gap-4 w-full relative"
|
||||
>
|
||||
<CustomImage
|
||||
src={item?.image}
|
||||
width={219}
|
||||
height={190}
|
||||
alt="Product"
|
||||
className="w-[100px] sm:w-[219px] h-auto aspect-square sm:aspect-[219/190] rounded object-cover"
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={handleLikeItem}
|
||||
className="absolute h-8 w-8 ltr:right-2 rtl:left-2 top-2 bg-white p-1.5 rounded-full flex items-center justify-center text-primary z-10"
|
||||
>
|
||||
{item?.is_liked ? (
|
||||
<button>
|
||||
<FaHeart size={20} className="like_icon" />
|
||||
</button>
|
||||
) : (
|
||||
<button>
|
||||
<FaRegHeart size={20} className="like_icon" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 sm:gap-2 flex-1 relative min-w-0">
|
||||
{item?.is_feature && (
|
||||
<div className="flex items-center gap-1 rounded-md py-0.5 px-1 bg-primary w-fit mb-1">
|
||||
<BiBadgeCheck size={16} color="white" />
|
||||
<p className="text-white text-xs sm:text-sm">{t("featured")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isHidePrice && (
|
||||
<p className="text-sm sm:text-lg font-bold truncate" title={price}>
|
||||
{price}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p
|
||||
className="text-xs sm:text-base font-medium line-clamp-1"
|
||||
title={translated_item?.name || item?.name}
|
||||
>
|
||||
{translated_item?.name || item?.name}
|
||||
</p>
|
||||
|
||||
<p className="text-xs sm:text-sm opacity-65 line-clamp-1">
|
||||
{item?.translated_address}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end mt-auto">
|
||||
<p className="text-xs sm:text-sm opacity-65 whitespace-nowrap">
|
||||
{formatDate(item?.created_at)}‎
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CustomLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductHorizontalCard;
|
||||
34
components/Common/ProductHorizontalCardSkeleton.jsx
Normal file
34
components/Common/ProductHorizontalCardSkeleton.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
const ProductHorizontalCardSkeleton = () => {
|
||||
return (
|
||||
<div className="border p-2 rounded-md flex gap-2 sm:gap-4 w-full relative">
|
||||
{/* Product image skeleton */}
|
||||
<Skeleton className="w-[100px] sm:w-[219px] h-auto aspect-square sm:aspect-[219/190] rounded" />
|
||||
|
||||
{/* Like button skeleton */}
|
||||
<Skeleton className="absolute h-8 w-8 ltr:right-2 rtl:left-2 top-2 rounded-full" />
|
||||
|
||||
<div className="flex flex-col gap-1 sm:gap-2 flex-1">
|
||||
{/* Featured badge skeleton */}
|
||||
<Skeleton className="h-6 w-24 rounded-md mb-1" />
|
||||
|
||||
{/* Price skeleton */}
|
||||
<Skeleton className="h-5 sm:h-6 w-24 rounded" />
|
||||
|
||||
{/* Name skeleton */}
|
||||
<Skeleton className="h-4 sm:h-5 w-3/4 rounded" />
|
||||
|
||||
{/* Location skeleton */}
|
||||
<Skeleton className="h-3 sm:h-4 w-1/2 rounded" />
|
||||
|
||||
{/* Date skeleton */}
|
||||
<div className="flex justify-end mt-auto">
|
||||
<Skeleton className="h-3 sm:h-4 w-24 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductHorizontalCardSkeleton;
|
||||
46
components/Common/ReusableAlertDialog.jsx
Normal file
46
components/Common/ReusableAlertDialog.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { t } from "@/utils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const ReusableAlertDialog = ({
|
||||
open,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
cancelText = t("cancel"),
|
||||
confirmText = t("confirm"),
|
||||
confirmDisabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<AlertDialog open={open}>
|
||||
<AlertDialogContent onInteractOutside={(e) => e.preventDefault()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
{description && (
|
||||
<AlertDialogDescription asChild={typeof description !== "string"}>
|
||||
{typeof description === "string" ? description : description}
|
||||
</AlertDialogDescription>
|
||||
)}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>{cancelText}</AlertDialogCancel>
|
||||
<AlertDialogAction disabled={confirmDisabled} onClick={onConfirm}>
|
||||
{confirmDisabled ? <Loader2 className="w-4 h-4 animate-spin" /> : confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReusableAlertDialog;
|
||||
107
components/Common/ShareDropdown.jsx
Normal file
107
components/Common/ShareDropdown.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
import {
|
||||
FacebookIcon,
|
||||
FacebookShareButton,
|
||||
TwitterShareButton,
|
||||
WhatsappIcon,
|
||||
WhatsappShareButton,
|
||||
XIcon,
|
||||
} from "react-share";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { IoShareSocialOutline } from "react-icons/io5";
|
||||
import { CiLink } from "react-icons/ci";
|
||||
import { toast } from "sonner";
|
||||
import { t } from "@/utils/index";
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const ShareDropdown = ({ url, title, headline, companyName, className }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const langCode = searchParams.get("lang");
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url + "?share=true&lang=" + langCode);
|
||||
toast.success(t("copyToClipboard"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error copying to clipboard:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className={className}>
|
||||
<IoShareSocialOutline size={20} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={isRTL ? "start" : "end"}>
|
||||
<DropdownMenuItem>
|
||||
<FacebookShareButton
|
||||
className="w-full"
|
||||
url={url}
|
||||
hashtag={title}
|
||||
onClick={handleShare}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FacebookIcon className="!size-6" round />
|
||||
<span>{t("facebook")}</span>
|
||||
</div>
|
||||
</FacebookShareButton>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<TwitterShareButton
|
||||
className="w-full"
|
||||
url={url}
|
||||
title={headline}
|
||||
onClick={handleShare}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<XIcon className="!size-6" round />
|
||||
<span>X</span>
|
||||
</div>
|
||||
</TwitterShareButton>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<WhatsappShareButton
|
||||
className="w-100"
|
||||
url={url}
|
||||
title={headline}
|
||||
hashtag={companyName}
|
||||
onClick={handleShare}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WhatsappIcon className="!size-6" round />
|
||||
<span>{t("whatsapp")}</span>
|
||||
</div>
|
||||
</WhatsappShareButton>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full"
|
||||
onClick={handleCopyUrl}
|
||||
>
|
||||
<CiLink className="!size-6" />
|
||||
<span>{t("copyLink")}</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareDropdown;
|
||||
17
components/Common/useAutoFocus.jsx
Normal file
17
components/Common/useAutoFocus.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
const useAutoFocus = () => {
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
return inputRef;
|
||||
};
|
||||
|
||||
export default useAutoFocus;
|
||||
27
components/Common/useNavigate.jsx
Normal file
27
components/Common/useNavigate.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||
import { getDefaultLanguageCode } from "@/redux/reducer/settingSlice";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
export const useNavigate = () => {
|
||||
const router = useRouter();
|
||||
const currentLangCode = useSelector(getCurrentLangCode);
|
||||
const defaultLangCode = useSelector(getDefaultLanguageCode);
|
||||
|
||||
const langCode = currentLangCode || defaultLangCode;
|
||||
|
||||
const navigate = (path, options = {}) => {
|
||||
if (path.includes("?")) {
|
||||
// Path already has query parameters, add lang parameter
|
||||
const langParam = langCode ? `&lang=${langCode}` : "";
|
||||
router.push(`${path}${langParam}`, options);
|
||||
} else {
|
||||
// Path has no query parameters, add lang parameter with ?
|
||||
const langParam = langCode ? `?lang=${langCode}` : "";
|
||||
router.push(`${path}${langParam}`, options);
|
||||
}
|
||||
};
|
||||
|
||||
return { navigate };
|
||||
};
|
||||
Reference in New Issue
Block a user