added new page

This commit is contained in:
Batyr 2025-05-20 18:14:26 +05:00
parent 15f05cbdc3
commit e6327ba6b8
16 changed files with 840 additions and 4 deletions

View File

@ -12,6 +12,9 @@ export { LangMenu } from "./lang-menu";
export { Menu } from "./menu";
export { Chevron } from "./chevron";
export { Loader } from "./loader";
export { Tabs } from "./tabs";
export { ParticipantItem } from "./participant-item";
export { MediaModal } from "./media-modal";
export * from "./about";
export * from "./home";

View File

@ -0,0 +1,122 @@
import { FC, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { ChevronLeftIcon, ChevronRightIcon, X } from "lucide-react";
import { motion } from "motion/react";
import useEmblaCarousel from "embla-carousel-react";
import { useScrollLock } from "usehooks-ts";
import { usePhotos } from "@/hooks/tanstack/use-photos";
import { useVideos } from "@/hooks/tanstack/use-videos";
interface Props {
setIsOpen: (isOpen: boolean) => void;
activeItem: { id: number; type: string };
setActiveItem: ({ id, type }: { id: number; type: "string" }) => void;
className?: string;
}
export const MediaModal: FC<Props> = ({ className, setIsOpen, activeItem }) => {
const [emblaRef, emblaApi] = useEmblaCarousel({
align: "center",
loop: false,
dragFree: false,
slidesToScroll: 1,
});
const { data } = usePhotos(1);
const { data: videos } = useVideos(1);
useScrollLock();
const [canScrollPrev, setCanScrollPrev] = useState(false);
const [canScrollNext, setCanScrollNext] = useState(false);
useEffect(() => {
if (!emblaApi) return;
const onSelect = () => {
setCanScrollPrev(emblaApi.canScrollPrev());
setCanScrollNext(emblaApi.canScrollNext());
};
emblaApi.on("select", onSelect);
onSelect();
return () => {
emblaApi.off("select", onSelect);
};
}, [emblaApi]);
useEffect(() => {
if (!emblaApi || !data) return;
emblaApi.scrollTo(activeItem.id, true);
}, [emblaApi, data, activeItem.id]);
const slides =
activeItem.type === "photo"
? data?.photos?.map((item) => (
<div
key={item.id}
className="embla__slide flex-[0_0_100%] h-[350px] md:h-[500px] lg:h-[700px] lg:px-[20%] flex items-center justify-center"
>
<img
src={item?.photo?.path}
alt={item.photo.file_name}
className="max-h-full max-w-full object-contain"
/>
</div>
))
: videos?.videos?.map((item) => (
<div
key={item.id}
className="embla__slide flex-[0_0_100%] h-[350px] md:h-[500px] lg:h-[700px] lg:px-[20%] flex items-center justify-center"
>
<video
src={item?.video?.path ?? ""}
controls
autoPlay
muted
className="max-h-full max-w-full object-contain"
/>
</div>
));
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cn(
"fixed z-[100] top-0 left-0 pb-40 pt-28 lg:px-28 px-6 overflow-hidden min-h-screen w-full bg-black/90",
className
)}
>
<X
onClick={() => setIsOpen(false)}
className="absolute top-10 right-6 p-3 md:size-20 size-16 cursor-pointer z-50 text-white hover:scale-110 transition-all duration-300"
/>
{
<div className="hidden md:block">
<ChevronLeftIcon
onClick={() => canScrollPrev && emblaApi?.scrollPrev()}
className={cn(
"absolute left-10 top-1/2 -translate-y-1/2 text-white size-20 cursor-pointer hover:scale-110 transition-all duration-300 z-40",
!canScrollPrev && "opacity-20 pointer-events-none"
)}
/>
<ChevronRightIcon
onClick={() => canScrollNext && emblaApi?.scrollNext()}
className={cn(
"absolute right-10 top-1/2 -translate-y-1/2 text-white size-20 cursor-pointer hover:scale-110 transition-all duration-300 z-40",
!canScrollNext && "opacity-20 pointer-events-none"
)}
/>
</div>
}
<div ref={emblaRef} className="embla overflow-hidden">
<div className="embla__container flex">{slides}</div>
</div>
</motion.div>
);
};

View File

@ -0,0 +1,116 @@
import { FC } from "react";
import { cn } from "@/lib/utils";
import { useLangStore } from "@/store/lang";
interface Props {
className?: string;
index: number;
name: string;
image?: {
path: string;
};
image_country: {
path?: string;
};
country: string;
about: string;
arr: number;
}
export const ParticipantItem: FC<Props> = ({
className,
index,
name,
image,
image_country,
country,
about,
arr,
}) => {
const lang = useLangStore((state) => state.lang);
return (
<>
{/* MOBILE */}
<div
key={index}
className={cn(
"flex border-b md:hidden py-2 border-outline_v",
className
)}
>
<div className="flex flex-1 flex-col items-start gap-4">
<div className="flex items-center w-full">
<div className="flex items-center flex-[0_0_80%] gap-3">
<h3 className="text-xs normal flex-[0_0_90px]">
{lang === "ru" ? "Название" : "Company"}:
</h3>
<h4 className="text-xs flex-[0_0_70%]">{name}</h4>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center w-full">
<div className="flex items-center flex-[0_0_80%] gap-3">
<h3 className="text-xs normal flex-[0_0_90px]">
{lang === "ru" ? "Страна" : "Country"}:
</h3>
<div className="flex items-center flex-[0_0_50%] gap-2">
<img
src={image_country?.path}
alt="flag"
className="size-4 flex-[0_0_16px] object-contain"
/>
<h4 className="text-xs flex-1">{country}</h4>
</div>
</div>
</div>
</div>
<div className="flex items-center ">
<div className="flex items-center flex-[0_0_80%] gap-3">
<h3 className="text-xs normal flex-[0_0_90px]">
{lang === "ru" ? "Сфера" : "Industry"}:
</h3>
<h4 className="text-xs flex-1">{about}</h4>
</div>
</div>
</div>
<div className="flex-[0_0_88px] size-[88px] bg-white ">
<img
src={image?.path}
alt="company"
className="size-full object-contain"
/>
</div>
</div>
{/* DESKTOP */}
<div
className={cn(
"md:flex hidden p-4 ",
arr !== index + 1 && "border-b border-outline_v"
)}
>
<div className="flex-[0_0_45.54%] flex gap-8">
<div className="flex-[0_0_88px] sm:flex-[0_0_128px] size-[88px] bg-white sm:size-[128px]">
<img
src={image?.path}
alt="company"
className="size-full object-contain"
/>
</div>
<h3 className="text-lg xl:mr-20 mr-10">{name}</h3>
</div>
<div className="flex-[0_0_19.80%] flex gap-2.5">
<img src={image_country?.path} alt="flag" className="size-4" />
<h4 className="text-sm normal">{country}</h4>
</div>
<h3 className="text-sm normal md:ml-1 flex-[0_0_34.65%]">{about}</h3>
</div>
</>
);
};

View File

@ -0,0 +1,163 @@
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import useEmblaCarousel from "embla-carousel-react";
import { useMediaQuery } from "usehooks-ts";
import { useLangStore } from "@/store/lang";
const tabs = [
{ id: 0, title: "Все компании", titleEn: "All companies" },
{
id: 3,
title: "Государственные учреждения ",
titleEn: "Government Institutions",
},
{
id: 1,
title: "Местные компании",
titleEn: "Local companies",
},
{
id: 2,
title: "Иностранные компании",
titleEn: "Foreign companies",
},
];
interface Props {
className?: string;
state: number;
setState: (val: number) => void;
data?: typeof tabs;
}
export const Tabs: FC<Props> = ({
className,
setState,
state,
data = tabs,
}) => {
const lang = useLangStore((state) => state.lang);
const isDesktop = useMediaQuery("(min-width: 768px)");
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
const [emblaRef, emblaApi] = useEmblaCarousel(
{
axis: "x",
align: "start",
containScroll: "trimSnaps",
},
[]
);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const updateIndicator = useCallback(() => {
const activeTabElement = tabRefs.current[state];
if (!activeTabElement) return;
if (isDesktop) {
setIndicatorStyle({
left: activeTabElement.offsetLeft,
width: activeTabElement.offsetWidth,
});
} else if (emblaApi) {
const emblaNode = emblaApi.rootNode();
const emblaContainer = emblaApi.containerNode();
if (!emblaNode || !emblaContainer) return;
const scrollLeft = emblaContainer.scrollLeft;
const tabLeft = activeTabElement.offsetLeft;
setIndicatorStyle({
left: tabLeft - scrollLeft,
width: activeTabElement.offsetWidth,
});
}
}, [state, isDesktop, emblaApi]);
useEffect(() => {
if (!isDesktop && emblaApi) return;
emblaApi?.scrollTo(state);
updateIndicator();
}, [emblaApi, state, updateIndicator, isDesktop]);
useEffect(() => {
if (!emblaApi) return;
updateIndicator();
emblaApi.on("scroll", updateIndicator);
emblaApi.on("resize", updateIndicator);
return () => {
emblaApi.off("scroll", updateIndicator);
emblaApi.off("resize", updateIndicator);
};
}, [emblaApi, updateIndicator]);
const handleTabClick = useCallback(
(index: number) => {
setState(index);
if (!isDesktop && emblaApi) {
emblaApi.scrollTo(index);
}
},
[emblaApi, setState, isDesktop]
);
return (
<div
ref={containerRef}
className={cn("relative mx-auto", className)}
style={{ width: "fit-content", maxWidth: "100%" }}
>
{isDesktop ? (
<div className="flex" role="tablist">
{data?.map((tab, index) => (
<button
ref={(el) => (tabRefs.current[index] = el)}
key={tab.id}
role="tab"
aria-selected={state === index}
className={cn(
"shrink-0 text-center relative h-12 mx-4 py-2 text-sm md:text-base whitespace-nowrap transition-all",
state === index ? "text-primary" : "text-on_surface_v"
)}
onClick={() => handleTabClick(index)}
>
{lang === "ru" ? tab.title : tab.titleEn}
</button>
))}
</div>
) : (
<div ref={emblaRef} className="" role="tablist">
<div className="flex">
{data?.map((tab, index) => (
<button
ref={(el) => (tabRefs.current[index] = el)}
key={tab.id}
role="tab"
aria-selected={state === index}
className={cn(
"shrink-0 text-center relative after:transition-all after:rounded after:w-full after:h-0.5 after:bg-primary after:opacity-0 after:absolute after:bottom-0 after:left-0 h-12 mx-4 py-2 text-sm md:text-base whitespace-nowrap transition-all",
state === index
? "text-primary after:opacity-100"
: "text-on_surface_v"
)}
onClick={() => handleTabClick(index)}
>
{lang === "ru" ? tab.title : tab.titleEn}
</button>
))}
</div>
</div>
)}
<div
className="absolute md:block hidden bottom-0 h-[3px] rounded-t-[2px] bg-primary transition-all duration-200"
style={indicatorStyle}
/>
</div>
);
};

View File

@ -0,0 +1,15 @@
import { getParticipants } from "@/services/service";
import { useLangStore } from "@/store/lang";
import { useQuery } from "@tanstack/react-query";
export const useParticipants = () => {
const lang = useLangStore((state) => state.lang);
const { data, isPending } = useQuery({
queryKey: ["participants", lang],
queryFn: () => getParticipants(lang),
select: ({ data }) => data.data,
});
return { data, isPending };
};

View File

@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { getPhotos } from "@/services/service";
export const usePhotos = (id: number) => {
const { data, isPending, isError } = useQuery({
queryKey: ["photos", id],
queryFn: () => getPhotos(id),
select: ({ data }) => data.data,
});
return { data, isPending, isError };
};

View File

@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { getVideos } from "@/services/service";
export const useVideos = (id: number) => {
const { data, isPending, isError } = useQuery({
queryKey: ["videos", id],
queryFn: () => getVideos(id),
select: ({ data }) => data.data,
});
return { data, isPending, isError };
};

View File

@ -25,7 +25,7 @@
{ "text": "About exhibition", "link": "/about" },
{
"text": "Media",
"link": ""
"link": "/media"
}
]
},
@ -36,7 +36,7 @@
{ "text": "Why visit?", "link": "" },
{
"text": "List of Participants",
"link": ""
"link": "/participants"
},
{
"text": "Programme",

View File

@ -24,7 +24,7 @@
{ "text": "О выставке", "link": "/about" },
{
"text": "Медиа",
"link": ""
"link": "/media"
}
]
},
@ -35,7 +35,7 @@
{ "text": "Почему стоит посетить?", "link": "" },
{
"text": "Список участников",
"link": ""
"link": "/participants"
},
{
"text": "Программа",

View File

@ -21,6 +21,10 @@ const News = lazy(() => import(/* webpackPrefetch: true */ "./pages/news"));
const NewsInner = lazy(
() => import(/* webpackPrefetch: true */ "./pages/news-inner")
);
const Participants = lazy(
() => import(/* webpackPrefetch: true */ "./pages/participants")
);
const Media = lazy(() => import(/* webpackPrefetch: true */ "./pages/media"));
const router = createBrowserRouter([
{
@ -35,6 +39,8 @@ const router = createBrowserRouter([
{ element: <Contacts />, path: "/contacts" },
{ element: <News />, path: "/news" },
{ element: <NewsInner />, path: "/news/:id" },
{ element: <Participants />, path: "/participants" },
{ element: <Media />, path: "/media" },
],
},
]);

134
src/pages/media.tsx Normal file
View File

@ -0,0 +1,134 @@
import { FC, useState } from "react";
import { cn } from "@/lib/utils";
import { useLangStore } from "@/store/lang";
import { Loader, MediaModal, Tabs } from "@/components/shared";
import { AnimatePresence } from "motion/react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Play } from "lucide-react";
import { Container, Cover } from "@/components/layout";
import { usePhotos } from "@/hooks/tanstack/use-photos";
import { useVideos } from "@/hooks/tanstack/use-videos";
interface Props {
className?: string;
}
const momentsTabs = [
{
id: 0,
title: "Фото",
titleEn: "Photo",
},
{
id: 1,
title: "Видео",
titleEn: "Video",
},
];
const Media: FC<Props> = ({ className }) => {
const [state, setState] = useState(0);
const { data, isPending } = usePhotos(1);
const { data: videos } = useVideos(1);
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeItem, setActiveItem] = useState({ id: 0, type: "photo" });
const lang = useLangStore((state) => state.lang);
const onItem = ({ id, type }: { id: number; type: string }) => {
setIsModalOpen(true);
setActiveItem({ id, type });
};
console.log(videos);
const { t } = useTranslation("main");
const [isCollapse, setIsCollapse] = useState(false);
return (
<>
<AnimatePresence>
{isModalOpen && (
<MediaModal
activeItem={activeItem}
setActiveItem={setActiveItem}
setIsOpen={setIsModalOpen}
/>
)}
</AnimatePresence>
<section className={cn("", className)}>
<Cover title={lang === "ru" ? "Моменты" : "Moments"} />
<Container className="page-bottom md:pt-10 pt-6">
{isPending ? (
<Loader />
) : (
<div className="flex justify-center flex-col">
<Tabs
state={state}
setState={setState}
data={momentsTabs}
className="mb-6"
/>
<h3 className="md:text-3xl text-2xl mb-6">2025</h3>
<div className="grid lg:grid-cols-4 lg:gap-y-4 lg:gap-x-6 md:gap-6 gap-4 grid-cols-2 place-items-center">
{state === 0
? data?.photos
?.slice(0, isCollapse ? 1000 : 16)
?.map((photo, i) => (
<div
onClick={() => onItem({ id: i, type: "photo" })}
key={i}
className="cursor-pointer embla__slide basis-1/1 overflow-hidden"
>
<img
src={photo?.photo?.path ?? ""}
alt={photo?.photo?.file_name ?? "photo"}
className="size-full object-cover hover:scale-105 duration-300 transition-all"
/>
</div>
))
: videos?.videos?.map((video, i) => (
<div
onClick={() => onItem({ id: i, type: "video" })}
key={i}
className="cursor-pointer group embla__slide basis-1/1 overflow-hidden relative"
>
<Play
fill="white"
size={20}
color="white"
className="absolute group-hover:scale-125 transition-all duration-300 top-1/2 z-10 left-1/2 -translate-x-1/2 -translate-y-1/2 "
/>
<div className="absolute top-0 left-0 size-full bg-[#2C57A752]/[32%]" />
<img
src={video?.video_photo?.path ?? ""}
className="size-full object-cover"
/>
</div>
))}
</div>
{data?.photos?.length &&
data?.photos?.length > 16 &&
!isCollapse && (
<Button
onClick={() => setIsCollapse(true)}
className="mx-auto w-[288px] mt-10 text-on_surface"
size={"lg"}
variant={"outline"}
>
{t("media.button")}
</Button>
)}
</div>
)}
</Container>
</section>
</>
);
};
export default Media;

View File

@ -0,0 +1,88 @@
import { FC, useState } from "react";
import { cn } from "@/lib/utils";
import { useLangStore } from "@/store/lang";
import { Container } from "@/components/layout";
import { Loader, ParticipantItem, Tabs } from "@/components/shared";
import { useParticipants } from "@/hooks/tanstack/use-participants";
interface Props {
className?: string;
}
const Participants: FC<Props> = ({ className }) => {
const lang = useLangStore((state) => state.lang);
const { data, isPending } = useParticipants();
const dataHeader = {
company: lang === "ru" ? "Название компании" : "Company name",
country: lang === "ru" ? "Страна" : "Country",
industry: lang === "ru" ? "Сфера деятельности" : "Industry",
};
const [activeTab, setActiveTab] = useState(0);
const splitedData = data?.[0]?.participants.concat(
data?.[1]?.participants,
data?.[2]?.participants
);
const filteredData = data?.filter((item) =>
activeTab === 1
? item.id === 3
: activeTab === 2
? item.id === 1
: item.id === 2
);
console.log(activeTab);
return (
<Container>
<section className={cn("page-padding overflow-x-hidden", className)}>
<h1 className="text-center md:text-5xl text-3xl mb-4">
{lang === "ru" ? "Список участников" : "List of participants"}
</h1>
<Tabs
state={activeTab}
setState={setActiveTab}
className="mb-6 md:w-fit"
/>
<div className="p-2 sm:p-0 bg-surface_container overflow-hidden">
<div className="sm:flex items-center hidden h-[52px] border-b border-outline_v">
<h3 className="flex-[0_0_45.54%] pl-4">{dataHeader?.company}</h3>
<h3 className="flex-[0_0_19.80%]">{dataHeader?.country}</h3>
<h3 className="flex-[0_0_34.65%]">{dataHeader?.industry}</h3>
</div>
{isPending ? (
<Loader />
) : activeTab === 0 ? (
splitedData?.map((item, index, arr) => (
<ParticipantItem
{...item}
arr={arr?.length}
index={index}
image={item.image ?? { path: "" }}
image_country={item.image_country ?? { path: "" }}
/>
))
) : (
filteredData?.[0]?.participants?.map((item, index, arr) => (
<ParticipantItem
{...item}
arr={arr?.length}
index={index}
image={item.image ?? { path: "" }}
image_country={item.image_country ?? { path: "" }}
/>
))
)}
</div>
</section>
</Container>
);
};
export default Participants;

View File

@ -11,6 +11,9 @@ import { IndustriesType } from "@/hooks/tanstack/use-industries";
import { TimeType } from "@/hooks/tanstack/use-exhibition-time";
import { PartnersType } from "@/hooks/tanstack/use-partners";
import { NewsInnerType, NewsType } from "./types/news.type";
import { ParticipantsType } from "./types/participants.type";
import { PhotoTypes } from "./types/photo.type";
import { VideoTypes } from "./types/videos.type";
const axios_url = axios.create({
baseURL: "https://turkmentextile.turkmenexpo.com/app/api/v1/",
@ -135,3 +138,25 @@ export const getNewsInner = async (id: number, lang: string) => {
return data;
};
export const getParticipants = async (lang: LangState["lang"]) => {
const data = axios_url<ParticipantsType>("participants", {
headers: {
"Accept-Language": lang,
},
});
return data;
};
export const getPhotos = async (id: number) => {
const data = axios_url<PhotoTypes>("photos/category/" + id);
return data;
};
export const getVideos = async (id: number) => {
const data = axios_url<VideoTypes>("videos/category/" + id);
return data;
};

View File

@ -0,0 +1,53 @@
export interface ParticipantsType {
status: string;
data: Datum[];
}
export interface Datum {
id: number;
name: string;
created_at: Date;
updated_at: Date;
participants: Participant[];
}
export interface Participant {
id: number;
name: string;
about: string;
country: string;
participant_category_id: number;
created_at: Date;
updated_at: Date;
image: Image | null;
image_country: Image | null;
}
export interface Image {
id: number;
disk_name: string;
file_name: string;
file_size: number;
content_type: ContentType;
title: null;
description: null;
field: Field;
sort_order: number;
created_at: Date;
updated_at: Date;
path: string;
extension: Extension;
}
export enum ContentType {
ImagePNG = "image/png",
}
export enum Extension {
PNG = "png",
}
export enum Field {
Image = "image",
ImageCountry = "image_country",
}

View File

@ -0,0 +1,49 @@
export interface PhotoTypes {
status: string;
data: Data;
}
export interface Data {
id: number;
name: string;
created_at: Date;
updated_at: Date;
photos: PhotoElement[];
}
export interface PhotoElement {
id: number;
category_photo_media_id: number;
name: string;
created_at: Date;
updated_at: Date;
photo: PhotoPhoto;
}
export interface PhotoPhoto {
id: number;
disk_name: string;
file_name: string;
file_size: number;
content_type: ContentType;
title: null;
description: null;
field: Field;
sort_order: number;
created_at: Date;
updated_at: Date;
path: string;
extension: Extension;
}
export enum ContentType {
ImageWebp = "image/webp",
}
export enum Extension {
Webp = "webp",
}
export enum Field {
Photo = "photo",
}

View File

@ -0,0 +1,38 @@
export interface VideoTypes {
status: string;
data: Data;
}
export interface Data {
id: number;
name: string;
created_at: Date;
updated_at: Date;
videos: VideoElement[];
}
export interface VideoElement {
id: number;
name: string;
category_video_media_id: number;
created_at: Date;
updated_at: Date;
video: VideoPhotoClass;
video_photo: VideoPhotoClass;
}
export interface VideoPhotoClass {
id: number;
disk_name: string;
file_name: string;
file_size: number;
content_type: string;
title: null;
description: null;
field: string;
sort_order: number;
created_at: Date;
updated_at: Date;
path: string;
extension: string;
}