diff --git a/components/shared/Reviews.tsx b/components/shared/Reviews.tsx new file mode 100644 index 0000000..a84791f --- /dev/null +++ b/components/shared/Reviews.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Swiper, SwiperSlide } from "swiper/react"; +import { Autoplay, Pagination } from "swiper/modules"; + +import "swiper/css"; +import "swiper/css/pagination"; +import { useFetch } from "@/hooks/useFetch"; +import { ReviewsType } from "@/lib/types/Reviews.type"; +import { useAppSelector } from "@/redux/hooks"; +import { selectHeader } from "@/redux/slices/headerSlice"; +import { Title } from "../ui/title"; + +export const Reviews = () => { + const { data } = useFetch("reviews"); + + const { + activeLang: { localization }, + } = useAppSelector(selectHeader); + + const title = localization === "en" ? "Feedback" : "Обратная связь"; + + return ( +
+ + + <div className="max-w-[710px] mx-auto"> + <Swiper + modules={[Autoplay, Pagination]} + autoplay={{ + delay: 6000, + disableOnInteraction: false, + }} + speed={1000} + pagination={{ + clickable: true, + el: ".custom-pagination", + bulletClass: + "dot transition-all duration-300 bg-gray-300 rounded-full w-3 h-3 mx-1", + bulletActiveClass: "dot-active !bg-PRIMARY scale-125", + }} + spaceBetween={50} + slidesPerView={1} + className="testimonials-swiper" + > + {data?.data?.map((item, i) => ( + <SwiperSlide key={item?.id ?? i}> + <article className="relative flex flex-col text-center gap-3 justify-center items-center min-h-[300px]"> + {/* Левая кавычка */} + <div className="md:block hidden"> + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="#059784" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="absolute left-0 rotate-180 md:bottom-32 bottom-60" + > + <path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z" /> + <path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z" /> + </svg> + + {/* Правая кавычка */} + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="#059784" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="absolute right-0 bottom-2" + > + <path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z" /> + <path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z" /> + </svg> + </div> + + <div className="size-[100px]"> + {item?.image?.path && ( + <img + src={item.image.path} + alt={item.name} + className="size-[100px] object-contain bg-white rounded-full border border-gray-200" + /> + )} + </div> + + <div> + <h3 className="font-bold text-base">{item.name}</h3> + <h4 className="text-base text-text-secondary"> + {item.job_title} + </h4> + </div> + + <p className="text-base text-text-secondary md:px-14"> + {item.text} + </p> + </article> + </SwiperSlide> + ))} + </Swiper> + + {/* Кастомная пагинация */} + <div className="custom-pagination flex justify-center items-center mt-10"></div> + </div> + </section> + ); +}; diff --git a/components/ui/LangMenu.tsx b/components/ui/LangMenu.tsx new file mode 100644 index 0000000..9af3b16 --- /dev/null +++ b/components/ui/LangMenu.tsx @@ -0,0 +1,123 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import clsx from "clsx"; +import { motion, AnimatePresence } from "framer-motion"; + +import triangle from "@/public/assets/icons/drop-icon.svg"; +import { useAppSelector, useAppDispatch } from "@/redux/hooks"; +import { activeLangType, selectHeader } from "@/redux/slices/headerSlice"; +import { setActiveLang } from "@/redux/slices/headerSlice"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; + +export const lang = [ + { + title: "Русский", + localization: "ru", + }, + // { + // title: 'Tm', + // localization: 'tm', + // }, + { + title: "English", + localization: "en", + }, +]; + +export const LangMenu = () => { + const { activeLang } = useAppSelector(selectHeader); + const { refresh } = useRouter(); + const [active, setActive] = useState(false); + const dispatch = useAppDispatch(); + const menuRef = useRef<HTMLDivElement>(null); + const setLang = (lang: activeLangType) => { + // Устанавливаем cookie через document.cookie + document.cookie = `lang=${lang.localization}; path=/; max-age=31536000`; + dispatch(setActiveLang(lang)); + refresh(); + }; + + useEffect(() => { + const cookieLang = document.cookie + .split("; ") + .find((row) => row.startsWith("lang=")) + ?.split("=")[1]; + + if (cookieLang) { + const foundLang = lang.find((l) => l.localization === cookieLang); + if (foundLang) dispatch(setActiveLang(foundLang)); + } + }, [dispatch]); + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setActive(false); + } + }; + + document.addEventListener("click", handleClick); + + return () => document.removeEventListener("click", handleClick); + }, []); + + return ( + <div + ref={menuRef} + className="relative w-[90px] cursor-pointer flex items-center gap-x-5" + onClick={() => { + setActive(!active); + }} + > + <div className="flex items-center px-[12px]"> + <p>{activeLang.title}</p> + <Image + src={triangle} + alt="arrow" + className={clsx("transition-rotate duration-300 img-auto", { + "rotate-180": active, + })} + /> + </div> + <AnimatePresence> + {active && ( + <motion.div + initial={{ + opacity: 0, + }} + animate={{ + opacity: 1, + }} + exit={{ + opacity: 0, + }} + transition={{ + duration: 0.2, + }} + className="absolute rounded-[4px] w-[112px] overflow-hidden custom-shadow z-10 flex flex-col top-[27px] bg-[#EEF1F0] transition-all duration-300" + > + {lang + .filter((item) => item.title !== activeLang.title) + .map((item, i) => ( + <div + key={i} + onClick={() => setLang(item)} + className={clsx( + "p-3 pr-[22px] py-4 text-extraSm transition-all", + { + "hover:bg-ON_SECONDARY_CONTAINER/[8%]": + item.title === item.title, + } + )} + > + {item.title} + </div> + ))} + </motion.div> + )} + </AnimatePresence> + </div> + ); +}; diff --git a/hooks/useFetch.ts b/hooks/useFetch.ts new file mode 100644 index 0000000..cc814b0 --- /dev/null +++ b/hooks/useFetch.ts @@ -0,0 +1,69 @@ +"use client"; + +import { baseAPI } from "@/lib/API"; +import { useAppSelector } from "@/redux/hooks"; +import { selectHeader } from "@/redux/slices/headerSlice"; +import { useState, useEffect } from "react"; + +interface FetchState<T> { + data: T | null; + loading: boolean; + error: string | null; +} + +export const useFetch = <T>( + url: string, + options?: RequestInit +): FetchState<T> => { + const [state, setState] = useState<FetchState<T>>({ + data: null, + loading: true, + error: null, + }); + + const { + activeLang: { localization }, + } = useAppSelector(selectHeader); + + useEffect(() => { + const fetchData = async () => { + try { + setState((prev) => ({ ...prev, loading: true, error: null })); + + const response = await fetch(baseAPI + url, { + headers: { + "Accept-Language": localization, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = (await response.json()) as T; + + setState({ + data: result, + loading: false, + error: null, + }); + } catch (error) { + setState({ + data: null, + loading: false, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + fetchData(); + }, [url, localization]); // Добавьте options в зависимости, если они динамические + + return state; +}; + +// Пример использования +interface User { + id: number; + name: string; +} diff --git a/lib/types/Reviews.type.ts b/lib/types/Reviews.type.ts new file mode 100644 index 0000000..dc69eaf --- /dev/null +++ b/lib/types/Reviews.type.ts @@ -0,0 +1,32 @@ +export interface Image { + id: number; + disk_name: string; + file_name: string; + file_size: number; + content_type: string; + title: string | null; + description: string | null; + field: string; + sort_order: number; + created_at: string; + updated_at: string; + path: string; + extension: string; +} + +// Тип для элемента отзыва +export interface Review { + id: number; + name: string; + job_title: string; + text: string; + created_at: string; + updated_at: string; + image: Image | null; +} + +// Основной тип ответа API +export interface ReviewsType { + status: "success" | "error"; + data: Review[] | null; +}