From 88c1a2f935af62c99f1a7853b44333ec2c8fc190 Mon Sep 17 00:00:00 2001 From: Batyr Date: Fri, 21 Nov 2025 15:53:33 +0500 Subject: [PATCH] added reviews --- app/(root)/(mainMenu)/layout.tsx | 2 +- app/(root)/page.tsx | 3 +- app/globals.css | 16 +++++ components/shared/Reviews.tsx | 115 +++++++++++++++++++++++++++++++ components/ui/title.tsx | 16 ++++- hooks/useFetch.ts | 69 +++++++++++++++++++ lib/types/Reviews.type.ts | 32 +++++++++ 7 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 components/shared/Reviews.tsx create mode 100644 hooks/useFetch.ts create mode 100644 lib/types/Reviews.type.ts diff --git a/app/(root)/(mainMenu)/layout.tsx b/app/(root)/(mainMenu)/layout.tsx index 0509bf4..9e097c6 100644 --- a/app/(root)/(mainMenu)/layout.tsx +++ b/app/(root)/(mainMenu)/layout.tsx @@ -3,7 +3,7 @@ import React from "react"; const CompanyLayout = ({ children }: { children: React.ReactNode }) => { return ( -
+
diff --git a/app/globals.css b/app/globals.css index 46d45c6..ad079c9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -416,6 +416,22 @@ p img { overflow: hidden; } + .dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #d1d5db; + cursor: pointer; + transition: all 0.3s ease; + margin: 0 4px; + } + + .dot-active { + @apply bg-PRIMARY; + transform: scale(1.2); + } + .input { @apply flex h-14 rounded-[2px] p-4 focus:ring-2 border-0 focus:outline-none focus:ring-white transition-all hover:ring-white ring-slate-300 ring-[1px] bg-[#E3E8E5] text-base file:border-0 file:bg-SECONDARY_CONTAINER file:outline-none file:text-sm file:w-fit file:text-ON_SECONDARY_CONTAINER file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50; } 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/title.tsx b/components/ui/title.tsx index 0e8ac0d..a42e5e0 100644 --- a/components/ui/title.tsx +++ b/components/ui/title.tsx @@ -1,8 +1,20 @@ +import clsx from "clsx"; import React from "react"; -export const Title = ({ text }: { text: string | boolean }) => { +export const Title = ({ + text, + className, +}: { + text: string | boolean; + className?: string; +}) => { return ( - <h2 className={`text-[26px] sm:text-[34px] leading-[115%] font-normal`}> + <h2 + className={clsx( + `text-[26px] sm:text-[34px] leading-[115%] font-normal`, + className + )} + > {text} </h2> ); 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; +}