added story, flash sale, satyjy button

This commit is contained in:
@jcarymuhammedow
2026-04-20 17:51:02 +05:00
parent 76c819848b
commit d92ec369a9
16 changed files with 1235 additions and 204 deletions

View File

@@ -8,7 +8,10 @@ export const mediaApi = baseApi.injectEndpoints({
getBanners: builder.query({
query: () => '/media/banners',
}),
getStories: builder.query({
query: () => '/media/stories',
}),
}),
});
export const { useGetCarouselsQuery, useGetBannersQuery } = mediaApi;
export const { useGetCarouselsQuery, useGetBannersQuery, useGetStoriesQuery } = mediaApi;

View File

@@ -0,0 +1,11 @@
import { baseApi } from "./baseApi";
export const flashSalesApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getFlashSales: builder.query({
query: () => "/flash-sales",
}),
}),
});
export const { useGetFlashSalesQuery } = flashSalesApi;

View File

@@ -0,0 +1,109 @@
.storiesContainer {
width: 100%;
max-width: 1366px;
margin: 0 auto 24px;
padding: 0 0.75rem;
box-sizing: border-box;
@media screen and (max-width: 768px) {
padding: 0;
margin-bottom: 16px;
}
}
.storiesWrapper {
display: flex;
gap: 14px;
padding: 10px 4px;
overflow-x: auto;
scroll-behavior: smooth;
scrollbar-width: none;
max-width: 1336px;
cursor: grab;
user-select: none;
&.dragging {
cursor: grabbing;
}
&::-webkit-scrollbar {
display: none;
}
}
.storyButton {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
background: none;
border: none;
padding: 0;
cursor: pointer;
flex-shrink: 0;
&:active .storyAvatar {
transform: scale(0.9);
}
}
/* Gradient ring — conic-gradient, padding trick, temiz */
.storyAvatar {
position: relative;
width: 68px;
height: 68px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
box-sizing: border-box;
transition: transform 0.15s ease;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background: conic-gradient(#f44336, #e91e63, #ff9800, #f44336);
z-index: 0;
}
img {
position: relative;
z-index: 1;
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
background: #fff;
border: 2px solid #fff;
display: block;
box-sizing: border-box;
}
&.viewed::before {
background: #d0d0d0;
}
&.viewed img {
opacity: 0.5;
}
}
/* Badge/viewedIndicator tamamen kaldırıldı */
.storyLabel {
font-size: 11px;
color: #333;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 68px;
font-weight: 400;
transition: color 0.2s;
.storyButton:hover & {
color: #e91e63;
}
}

View File

@@ -0,0 +1,56 @@
import { useRef, useEffect } from "react";
export function useDragScroll() {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
let isDown = false;
let startX = 0;
let scrollLeft = 0;
const onMouseDown = (e) => {
isDown = true;
el.classList.add("dragging");
startX = e.pageX - el.offsetLeft;
scrollLeft = el.scrollLeft;
delete el.dataset.dragged;
};
const onMouseLeave = () => {
isDown = false;
el.classList.remove("dragging");
};
const onMouseUp = () => {
isDown = false;
el.classList.remove("dragging");
setTimeout(() => delete el.dataset.dragged, 0);
};
const onMouseMove = (e) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - el.offsetLeft;
const walk = (x - startX) * 1.2;
if (Math.abs(walk) > 5) el.dataset.dragged = "true";
el.scrollLeft = scrollLeft - walk;
};
el.addEventListener("mousedown", onMouseDown);
el.addEventListener("mouseleave", onMouseLeave);
el.addEventListener("mouseup", onMouseUp);
el.addEventListener("mousemove", onMouseMove);
return () => {
el.removeEventListener("mousedown", onMouseDown);
el.removeEventListener("mouseleave", onMouseLeave);
el.removeEventListener("mouseup", onMouseUp);
el.removeEventListener("mousemove", onMouseMove);
};
}, []);
return ref;
}

View File

@@ -1,197 +1,152 @@
import React, { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import {
Autoplay,
Thumbs,
Pagination,
Navigation,
Mousewheel,
FreeMode,
} from "swiper/modules";
import { Autoplay, Thumbs, Pagination, Navigation, Mousewheel, FreeMode } from "swiper/modules";
import { Skeleton } from "antd";
import "swiper/css";
import "swiper/css/pagination";
import "swiper/css/thumbs";
import "swiper/css/navigation";
import styles from "./Banner.module.scss";
import { useGetCarouselsQuery } from "../../app/api/bannersApi.js";
import storiesStyles from "./Stories.module.scss";
import { Skeleton } from "antd";
import { useGetCarouselsQuery, useGetStoriesQuery } from "../../app/api/bannersApi.js";
import StoryViewer from "../StoryViewer/StoryViewer";
import { useDragScroll } from "./hook/useDragScroll.js";
function Carousel() {
const { data, isLoading, isError } = useGetCarouselsQuery();
const { data: carouselData, isLoading, isError } = useGetCarouselsQuery();
const { data: storiesData, isLoading: isStoriesLoading, isError: isStoriesError } = useGetStoriesQuery();
const [thumbsSwiper, setThumbsSwiper] = useState(null);
const [activeIndex, setActiveIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(true);
const thumbSliderRef = useRef(null);
const [selectedStoryIndex, setSelectedStoryIndex] = useState(null);
const [viewedStoryIds, setViewedStoryIds] = useState(new Set());
const storiesScrollRef = useDragScroll();
useEffect(() => {
setIsAnimating(false);
setTimeout(() => setIsAnimating(true), 50);
const timer = setTimeout(() => setIsAnimating(true), 50);
return () => clearTimeout(timer);
}, [activeIndex]);
const updateScrollPosition = (targetIndex) => {
if (!thumbSliderRef.current) return;
const container = thumbSliderRef.current.querySelector(".swiper-wrapper");
const slideHeight = container.children[0]?.offsetHeight || 0;
const spaceBetween = 15;
const scrollPosition = targetIndex * (slideHeight + spaceBetween);
container.parentNode.scrollTop = scrollPosition;
};
const handleSlideChange = (swiper) => {
const newActiveIndex = swiper.realIndex;
setActiveIndex(newActiveIndex);
setActiveIndex(swiper.realIndex);
};
if (thumbsSwiper?.slides) {
const slidesPerView = 4;
let targetIndex = newActiveIndex - Math.floor(slidesPerView / 2);
targetIndex = Math.max(
0,
Math.min(targetIndex, thumbsSwiper.slides.length - slidesPerView)
);
const handleImageClick = (link) => {
if (link) window.open(link, "_blank", "noopener,noreferrer");
};
thumbsSwiper.slideTo(targetIndex, 300);
updateScrollPosition(targetIndex);
const handleStoryViewed = (storyIndex) => {
const storyId = storiesData?.data[storyIndex]?.id;
if (storyId) {
setViewedStoryIds((prev) => new Set(prev).add(storyId));
}
};
// Handler for clicking on carousel images
const handleImageClick = (link) => {
if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
}
const handleStoryClick = (index) => {
if (storiesScrollRef.current?.dataset.dragged) return;
setSelectedStoryIndex(index);
};
if (isLoading) {
return (
<div className={styles.carouselContainer}>
{/* Main slider skeleton */}
<div className={`${styles.mainSlider} skeleton-main-slider`}>
<Skeleton.Image active={true} className="main-skeleton-image" />
<style jsx>{`
.skeleton-main-slider {
width: 100%;
aspect-ratio: 16/9;
position: relative;
margin-bottom: 20px;
border-radius: 8px;
overflow: hidden;
}
.main-skeleton-image {
width: 100% !important;
height: 100% !important;
}
`}</style>
</div>
{/* Thumbnail Slider skeleton */}
<div className={`${styles.thumbSlider} skeleton-thumb-slider`}>
{[...Array(5)].map((_, index) => (
<div
key={index}
className={`${styles.thumbWrapper} skeleton-thumb`}
>
<Skeleton.Image active={true} />
<style jsx>{`
.skeleton-thumb-slider {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
@media screen and (max-width:767px){
display: none;}
}
.skeleton-thumb {
width: 100%;
height: 100%;
margin-bottom: 10px;
border-radius: 4px;
overflow: hidden;
}
`}</style>
</div>
))}
<div className={styles.mainSliderSkeleton}>
<Skeleton.Image active className={styles.fullWidthSkeleton} />
</div>
</div>
);
}
if (isError || !data || !data.data || data.data.length === 0) {
return <div>No images available</div>;
}
if (isError || !carouselData?.data?.length) return null;
return (
<div className={styles.carouselContainer}>
{/* Main Slider */}
<Swiper
modules={[Thumbs, Pagination, Navigation, Autoplay]}
thumbs={{ swiper: thumbsSwiper }}
autoplay={{ delay: 3000, disableOnInteraction: false }}
loop={true}
pagination={{
clickable: true,
}}
navigation={true}
className={styles.mainSlider}
onSlideChange={handleSlideChange}
>
{data.data.map((item) => (
<SwiperSlide key={item.id}>
<div
className={styles.imageWrapper}
onClick={() => handleImageClick(item.link)}
style={{ cursor: item.link ? 'pointer' : 'default' }}
>
<img
src={item.image}
alt={item.title || `Carousel Image ${item.id}`}
/>
</div>
</SwiperSlide>
))}
</Swiper>
<>
{!isStoriesLoading && !isStoriesError && storiesData?.data?.length > 0 && (
<div className={storiesStyles.storiesContainer}>
<div className={storiesStyles.storiesWrapper} ref={storiesScrollRef}>
{storiesData.data.map((story, index) => {
const isViewed = viewedStoryIds.has(story.id);
return (
<button
key={story.id}
className={storiesStyles.storyButton}
onClick={() => handleStoryClick(index)}
>
<div className={`${storiesStyles.storyAvatar} ${isViewed ? storiesStyles.viewed : ""}`}>
<img src={story.thumbnail || story.photo} alt={story.title} />
</div>
<span className={storiesStyles.storyLabel}>{story.title}</span>
</button>
);
})}
</div>
</div>
)}
{/* Thumbnail Slider */}
<Swiper
ref={thumbSliderRef}
modules={[Thumbs, Autoplay, FreeMode, Mousewheel]}
onSwiper={setThumbsSwiper}
autoplay={{ delay: 3000 }}
slidesPerView={4}
spaceBetween={10}
direction="vertical"
watchSlidesProgress={true}
slideToClickedSlide={true}
cssMode={true}
loop={false}
allowTouchMove={true}
className={styles.thumbSlider}
>
{data.data.map((item, index) => (
<SwiperSlide key={item.id}>
<div
className={`${styles.thumbWrapper} ${
index === activeIndex ? styles.active : ""
}`}
>
<img
src={item.thumbnail}
alt={item.title || `Thumbnail ${index + 1}`}
/>
{index === activeIndex && isAnimating && (
<>
<div className={styles.progressBarImg}></div>
<div className={styles.progressBar}></div>
</>
)}
</div>
</SwiperSlide>
))}
</Swiper>
</div>
{selectedStoryIndex !== null && (
<StoryViewer
stories={storiesData.data}
initialIndex={selectedStoryIndex}
onClose={() => setSelectedStoryIndex(null)}
onStoryViewed={handleStoryViewed}
/>
)}
<div className={styles.carouselContainer}>
<Swiper
modules={[Thumbs, Pagination, Navigation, Autoplay]}
thumbs={{ swiper: thumbsSwiper && !thumbsSwiper.destroyed ? thumbsSwiper : null }}
autoplay={{ delay: 3000, disableOnInteraction: false }}
loop={true}
pagination={{ clickable: true }}
navigation={true}
className={styles.mainSlider}
onSlideChange={handleSlideChange}
>
{carouselData.data.map((item) => (
<SwiperSlide key={item.id}>
<div
className={styles.imageWrapper}
onClick={() => handleImageClick(item.link)}
style={{ cursor: item.link ? "pointer" : "default" }}
>
<img src={item.image} alt={item.title || "Banner"} />
</div>
</SwiperSlide>
))}
</Swiper>
<Swiper
modules={[Thumbs, FreeMode, Mousewheel]}
onSwiper={setThumbsSwiper}
slidesPerView={4}
spaceBetween={10}
direction="vertical"
watchSlidesProgress={true}
className={styles.thumbSlider}
>
{carouselData.data.map((item, index) => (
<SwiperSlide key={item.id}>
<div className={`${styles.thumbWrapper} ${index === activeIndex ? styles.active : ""}`}>
<img src={item.thumbnail} alt={`Thumb ${index}`} />
{index === activeIndex && isAnimating && (
<div className={styles.progressContainer}>
<div className={styles.progressBarImg}></div>
<div className={styles.progressBar}></div>
</div>
)}
</div>
</SwiperSlide>
))}
</Swiper>
</div>
</>
);
}

View File

@@ -0,0 +1,206 @@
// ── Design tokens ──────────────────────────────────────────────────────────
$flash-red: #e53935;
$flash-dark-red: #c62828;
$flash-accent: #ff6b6b;
$flash-yellow: #FFD54F;
$white: #fff;
// ── Section wrapper ────────────────────────────────────────────────────────
.flashSales {
margin: 28px 0;
border-radius: 12px;
// overflow: hidden;
background: $white;
box-shadow: 0 4px 24px rgba(229, 57, 53, 0.15);
border: 1.5px solid rgba(229, 57, 53, 0.18);
}
// ── Gradient header ────────────────────────────────────────────────────────
.header {
padding: 14px 20px;
background: linear-gradient(120deg, $flash-red 0%, $flash-dark-red 100%);
gap: 12px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
@media (max-width: 480px) {
padding: 10px 14px;
gap: 8px;
}
}
// ── Left: label ────────────────────────────────────────────────────────────
.flashLabel {
flex-shrink: 0;
}
.zapWrapper {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.18);
border-radius: 8px;
padding: 4px;
}
.zapIcon {
color: $flash-yellow;
filter: drop-shadow(0 0 6px rgba(255, 213, 79, 0.8));
animation: zapPulse 1s ease-in-out infinite alternate;
display: block;
}
@keyframes zapPulse {
from { opacity: 0.75; transform: scale(1); }
to { opacity: 1; transform: scale(1.2); }
}
.flashText {
font-size: 1.35rem;
font-weight: 900;
color: $white;
letter-spacing: 2.5px;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
white-space: nowrap;
@media (max-width: 480px) {
font-size: 1.05rem;
letter-spacing: 1.5px;
}
}
.saleTitle {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
@media (max-width: 480px) {
display: none;
}
}
// ── Right: timer ───────────────────────────────────────────────────────────
.timerWrapper {
flex-shrink: 0;
}
.timerLabel {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
font-weight: 500;
@media (max-width: 600px) {
display: none;
}
}
.timerBlock {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.28);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 7px;
padding: 5px 11px;
min-width: 44px;
backdrop-filter: blur(4px);
@media (max-width: 480px) {
min-width: 36px;
padding: 4px 8px;
}
}
.timerDigit {
font-size: 1.25rem;
font-weight: 800;
color: $white;
line-height: 1;
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
@media (max-width: 480px) {
font-size: 1rem;
}
}
.timerUnit {
font-size: 0.58rem;
color: rgba(255, 255, 255, 0.65);
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1;
margin-top: 2px;
}
.timerSep {
color: $white;
font-size: 1.2rem;
font-weight: 800;
line-height: 1;
margin-bottom: 10px; // optical alignment with digit row
user-select: none;
}
// ── Swiper container ───────────────────────────────────────────────────────
.swiperWrapper {
padding: 16px 24px;
background: #fff8f8;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
@media (max-width: 480px) {
padding: 12px 10px;
}
// Navigation arrows
:global(.swiper-button-next),
:global(.swiper-button-prev) {
color: $flash-red !important;
background: $white;
border-radius: 50%;
width: 34px;
height: 34px;
box-shadow: 0 2px 10px rgba(229, 57, 53, 0.25);
transition: background 0.2s, color 0.2s;
&::after {
font-size: 13px;
font-weight: 900;
}
&:hover {
background: $flash-red !important;
color: $white !important;
}
}
:global(.swiper-button-disabled) {
opacity: 0.3;
pointer-events: none;
}
}
.swiper {
padding: 6px 2px 10px !important;
}
.slide {
height: auto;
display: flex;
align-items: stretch;
// Make ProductCard fill the slide height
> * {
// height: 100%;
// min-height: 100%;
// max-height: 100%;
display: flex;
flex-direction: column;
}
}

View File

@@ -0,0 +1,148 @@
import React, { useEffect, useState } from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import { Zap } from "lucide-react";
import { Flex } from "antd";
import { useGetFlashSalesQuery } from "../../app/api/flashSalesApi";
import ProductCard from "../ProductCard";
import styles from "./FlashSales.module.scss";
import { useTranslation } from "react-i18next";
const parseTime = (timeStr) => {
if (!timeStr) return { hours: "00", minutes: "00", seconds: "00" };
const parts = timeStr.split(":");
return {
hours: parts[0] || "00",
minutes: parts[1] || "00",
seconds: parts[2] || "00",
};
};
const FlashSales = () => {
const { t } = useTranslation();
const { data, isLoading, isError } = useGetFlashSalesQuery();
const [timers, setTimers] = useState([]);
const getTimeLeft = (end) => {
const endTime = new Date(end).getTime();
const now = new Date().getTime();
let diff = endTime - now;
if (diff <= 0) return "00:00:00";
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
diff = diff % (1000 * 60 * 60 * 24);
const hours = String(Math.floor(diff / (1000 * 60 * 60))).padStart(2, "0");
const minutes = String(Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))).padStart(2, "0");
const seconds = String(Math.floor((diff % (1000 * 60)) / 1000)).padStart(2, "0");
if (days > 0) {
return `${days}g ${hours}:${minutes}:${seconds}`;
}
return `${hours}:${minutes}:${seconds}`;
};
useEffect(() => {
if (!data?.data) return;
const updateTimers = () => {
setTimers(data.data.map((flashSale) => getTimeLeft(flashSale.ends_at)));
};
updateTimers();
const interval = setInterval(updateTimers, 1000);
return () => clearInterval(interval);
}, [data]);
if (isLoading || isError || !data?.data?.length) return null;
return (
<div>
{data.data.map((flashSale, idx) => {
// Timer parse
let days = 0, hours = "00", minutes = "00", seconds = "00";
if (timers[idx]) {
const match = timers[idx].match(/(?:(\d+)g )?(\d{2}):(\d{2}):(\d{2})/);
if (match) {
days = match[1] ? Number(match[1]) : 0;
hours = match[2];
minutes = match[3];
seconds = match[4];
}
}
return (
<section className={styles.flashSales} key={flashSale.id}>
{/* ── Header ── */}
<Flex
align="center"
justify="space-between"
wrap="wrap"
className={styles.header}
>
{/* Left: icon + title */}
<Flex align="center" gap={10} className={styles.flashLabel}>
<span className={styles.zapWrapper}>
<Zap size={22} className={styles.zapIcon} />
</span>
<span className={styles.flashText}>{t("flashSales.flash_sale")}</span>
{flashSale.title && (
<span className={styles.saleTitle}>{flashSale.title}</span>
)}
</Flex>
{/* Right: countdown timer */}
<Flex align="center" gap={8} className={styles.timerWrapper}>
<span className={styles.timerLabel}>{t("flashSales.ends_in")}</span>
<Flex align="center" gap={4}>
{days > 0 && (
<div className={styles.timerBlock}>
<span className={styles.timerDigit}>{days}</span>
<span className={styles.timerUnit}>{t("flashSales.day")}</span>
</div>
)}
<div className={styles.timerBlock}>
<span className={styles.timerDigit}>{hours}</span>
<span className={styles.timerUnit}>{t("flashSales.hour")}</span>
</div>
<span className={styles.timerSep}>:</span>
<div className={styles.timerBlock}>
<span className={styles.timerDigit}>{minutes}</span>
<span className={styles.timerUnit}>{t("flashSales.minute")}</span>
</div>
<span className={styles.timerSep}>:</span>
<div className={styles.timerBlock}>
<span className={styles.timerDigit}>{seconds}</span>
<span className={styles.timerUnit}>{t("flashSales.second")}</span>
</div>
</Flex>
</Flex>
</Flex>
{/* ── Products Carousel ── */}
<div className={styles.swiperWrapper}>
<Swiper
modules={[Navigation]}
navigation
slidesPerView={4}
spaceBetween={16}
breakpoints={{
0: { slidesPerView: 1.5, spaceBetween: 10 },
480: { slidesPerView: 2.2, spaceBetween: 12 },
768: { slidesPerView: 3, spaceBetween: 14 },
1024: { slidesPerView: 4, spaceBetween: 16 },
}}
className={styles.swiper}
>
{flashSale.products.map((product) => (
<SwiperSlide key={product.id} className={styles.slide}>
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
</div>
</section>
);
})}
</div>
);
};
export default FlashSales;

View File

@@ -8,7 +8,7 @@
position: sticky;
top: 0;
z-index: 99;
.navbarUp {
width: 100%;
background-color: #fff;
@@ -19,20 +19,29 @@
z-index: 100;
}
.btn{
.btn {
display: flex;
width: max-content;
font-size: 16px;
border-radius: 4px;
border: #000000;
background-color: #000000;
padding: 6px 10px;
font-weight: bold;
color: #ffffff;
margin: 8px 14px 6px;
@media screen and (max-width: 500px) {
font-size: 14px;
margin: 8px 10px 6px;
background-color: #000000;
border: 1px solid #000000; // Border rengini belirtirken kalınlık da eklemelisin
border-radius: 4px;
padding: 6px 10px;
cursor: pointer;
// Mobil Görünüm (Ortak)
@media screen and (max-width: 500px) {
font-size: 14px;
margin: 8px 10px 6px;
}
&__satyjy {
@media screen and (max-width: 500px) {
display: none;
}
}
}
@@ -74,11 +83,11 @@
box-sizing: border-box;
justify-content: center;
flex-direction: column;
img{
img {
width: 300px;
@media screen and (max-width: 500px) {
width: 100%;
}
width: 100%;
}
}
@media screen and (max-width: 500px) {
width: 100%;
@@ -87,7 +96,6 @@
svg {
width: 100%;
height: 100%;
}
}
.stick {
@@ -99,9 +107,9 @@
}
}
.navLinks {
width: 100%;
}
.navLinks {
width: 100%;
}
.navLinks ul {
list-style: none;
display: flex;
@@ -132,7 +140,6 @@
align-items: center;
flex: 1;
flex-direction: row-reverse;
svg {
position: absolute;
@@ -271,18 +278,23 @@
border: none;
outline: none;
&::placeholder {
color: #9ca3af;
font-size: 0.75rem;
}
}
.langSelector {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
@media screen and (max-width: 500px) {
display: none;
}
}
.langSelector {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
@media screen and (max-width: 500px) {
display: none;
}
}
.buttonsContainer {
display: flex;
gap: 8px;
margin: 8px 14px 6px;
}

View File

@@ -47,12 +47,10 @@ const Navbar = () => {
className={styles.logoContainer}
onClick={() => navigate("/")}
>
<img src={Logo} alt="" />
<img src={Logo} alt="" />
</div>
</div>
<div className={styles.langSelector}
>
<div className={styles.langSelector}>
{languages.map((lang) => (
<button
key={lang.code}
@@ -83,16 +81,22 @@ const Navbar = () => {
</button>
))}
</div>
<div
>
<button className={styles.btn} onClick={showModal}>
Satyjy bol
</button>
<div>
<div className={styles.buttonsContainer}>
<button className={styles.btn} onClick={showModal}>
Satyjy bol
</button>
<button
className={`${styles.btn} ${styles.btn__satyjy}`}
onClick={() => navigate("/panel")}
>
Satyjy
</button>
</div>
</div>
</div>
</div>
<NavbarDown />
<NavbarDown />
</header>
<Modal
open={isModalVisible}

View File

@@ -12,24 +12,23 @@ import {
Info,
Edit,
MapPin,
Store,
LogOut,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import styles from "./ProfileMenu.module.scss";
import LoginModal from "../LogIn";
import SignUpModal from "../SignUp";
import ProfileModal from "..//MyProfileModal/index";
import ProfileModal from "..//MyProfileModal/index";
import tm from "../../assets/tm.png";
import ru from "../../assets/ru.png";
import en from "../../assets/en.png";
import { useAuth } from "../../context/authContext";
import { useGetProfileQuery } from "../../app/api/myProfileApi";
import { useGetProfileQuery } from "../../app/api/myProfileApi";
const ProfileMenu = () => {
const [activeModal, setActiveModal] = useState(null);
const { t, i18n } = useTranslation();
const { isAuthenticated, logout } = useAuth();
// Fetch profile data from API
const { data: profileData, isLoading } = useGetProfileQuery(undefined, {
skip: !isAuthenticated, // Skip the API call if not authenticated
@@ -55,6 +54,11 @@ const ProfileMenu = () => {
return;
}
if (item.action === "/panel") {
window.location.href = "/panel";
return;
}
if (item.action) {
setActiveModal(item.action);
}
@@ -62,7 +66,7 @@ const ProfileMenu = () => {
const handleLanguageChange = async (langCode) => {
await i18n.changeLanguage(langCode);
localStorage.setItem("preferredLanguage", langCode);
localStorage.setItem("preferredLanguage", langCode);
setActiveModal(null);
window.location.reload();
};
@@ -84,6 +88,7 @@ const ProfileMenu = () => {
{ icon: <Wallet />, text: t("profile.orders"), path: "/orders" },
{ icon: <Heart />, text: t("profile.favorites"), path: "/wishlist" },
{ icon: <Languages />, text: t("profile.language"), action: "language" },
{ icon: <Store />, text: t("profile.seller_panel"), action: "/panel" },
{
icon: <List />,
text: t("profile.delivery"),
@@ -102,7 +107,9 @@ const ProfileMenu = () => {
<User className={styles.userIcon} />
</div>
<div className={styles.userInfo}>
<div className={styles.phoneNumber}>+993 {userData.phone_number}</div>
<div className={styles.phoneNumber}>
+993 {userData.phone_number}
</div>
<button
onClick={handleEditProfile}
className={styles.editProfileLink}
@@ -192,6 +199,7 @@ const ProfileMenu = () => {
{ icon: <Wallet />, text: t("profile.orders"), path: "/orders" },
{ icon: <Heart />, text: t("profile.favorites"), path: "/wishlist" },
{ icon: <Languages />, text: t("profile.language"), action: "language" },
{ icon: <Store />, text: t("profile.seller_panel"), action: "/panel" },
{
icon: <List />,
text: t("profile.delivery"),

View File

@@ -0,0 +1,241 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
import styles from './StoryViewer.module.scss';
const STORY_DURATION = 6000;
const StoryViewer = ({ stories, onClose, initialIndex = 0, onStoryViewed }) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [animClass, setAnimClass] = useState(styles.slideInFromRight);
const [progress, setProgress] = useState(0); // 0..1
const touchStartX = useRef(null);
const touchStartY = useRef(null);
const isPausedRef = useRef(false);
const rafRef = useRef(null);
const startTimeRef = useRef(null);
const elapsedRef = useRef(0); // ms elapsed before last pause
const isFirst = currentIndex === 0;
const isLast = currentIndex === stories.length - 1;
const currentStory = stories[currentIndex];
// Scroll lock
useEffect(() => {
const orig = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = orig; };
}, []);
// ── Core timer: runs per-frame, advances progress, fires goNext ──
const startTimer = useCallback((onDone) => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
startTimeRef.current = Date.now();
elapsedRef.current = 0;
setProgress(0);
const tick = () => {
if (isPausedRef.current) {
// While paused keep scheduling but don't advance
rafRef.current = requestAnimationFrame(tick);
return;
}
const now = Date.now();
const delta = now - startTimeRef.current;
startTimeRef.current = now;
elapsedRef.current += delta;
const p = Math.min(elapsedRef.current / STORY_DURATION, 1);
setProgress(p);
if (p >= 1) {
onDone();
return;
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, []);
// ── Navigate ──
const goTo = useCallback((nextIndex, dir) => {
if (nextIndex < 0 || nextIndex >= stories.length) return;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
setAnimClass(dir === 'next' ? styles.slideInFromRight : styles.slideInFromLeft);
setCurrentIndex(nextIndex);
}, [stories.length]);
const goToNext = useCallback(() => {
if (!isLast) goTo(currentIndex + 1, 'next');
else onClose();
}, [currentIndex, isLast, goTo, onClose]);
const goToPrevious = useCallback(() => {
if (!isFirst) goTo(currentIndex - 1, 'prev');
}, [currentIndex, isFirst, goTo]);
// ── Start timer when index changes ──
useEffect(() => {
if (onStoryViewed) onStoryViewed(currentIndex);
const cancel = startTimer(() => {
if (currentIndex < stories.length - 1) {
setAnimClass(styles.slideInFromRight);
setCurrentIndex((i) => i + 1);
} else {
onClose();
}
});
return cancel;
}, [currentIndex, stories.length]); // NO isPaused here — handled by ref
// ── Keyboard ──
useEffect(() => {
const onKey = (e) => {
if (e.key === 'ArrowLeft') goToPrevious();
if (e.key === 'ArrowRight') goToNext();
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [goToPrevious, goToNext, onClose]);
// ── Touch swipe ──
const handleTouchStart = (e) => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
};
const handleTouchEnd = (e) => {
if (touchStartX.current === null) return;
const dx = e.changedTouches[0].clientX - touchStartX.current;
const dy = e.changedTouches[0].clientY - touchStartY.current;
touchStartX.current = null;
if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy)) return;
if (dx < 0) goToNext();
else goToPrevious();
};
const handleMouseEnter = () => {
isPausedRef.current = true;
// Snapshot elapsed so we resume correctly
if (startTimeRef.current !== null) {
elapsedRef.current += Date.now() - startTimeRef.current;
startTimeRef.current = null;
}
};
const handleMouseLeave = () => {
isPausedRef.current = false;
startTimeRef.current = Date.now(); // reset delta start
};
if (!currentStory) return null;
const getTimeAgo = (dateString) => {
if (!dateString) return '';
const diff = Math.floor((Date.now() - new Date(dateString)) / 1000);
if (diff < 60) return 'şimdi';
if (diff < 3600) return `${Math.floor(diff / 60)}d`;
if (diff < 86400) return `${Math.floor(diff / 3600)}s`;
return `${Math.floor(diff / 86400)}g`;
};
return (
<div className={styles.overlay} onClick={onClose}>
{!isFirst && (
<button
className={`${styles.outerNav} ${styles.outerNavLeft}`}
onClick={(e) => { e.stopPropagation(); goToPrevious(); }}
aria-label="Önceki"
>
<ChevronLeft size={28} />
</button>
)}
<div
className={styles.container}
onClick={(e) => e.stopPropagation()}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Progress bars — driven by JS progress state */}
<div className={styles.progressBars}>
{stories.map((_, idx) => (
<div key={idx} className={styles.track}>
{idx < currentIndex && (
<div className={styles.bar} style={{ width: '100%' }} />
)}
{idx === currentIndex && (
<div className={styles.bar} style={{ width: `${progress * 100}%` }} />
)}
</div>
))}
</div>
{/* Header */}
<div className={styles.header}>
<div className={styles.identity}>
<div className={styles.avatar}>
<img src={currentStory.thumbnail || currentStory.photo} alt={currentStory.title} />
</div>
<div>
<p className={styles.name}>{currentStory.title}</p>
{currentStory.createdAt && (
<p className={styles.time}>{getTimeAgo(currentStory.createdAt)}</p>
)}
</div>
</div>
<button className={styles.close} onClick={onClose} aria-label="Kapat">
<X size={20} />
</button>
</div>
{/* Image */}
<div className={styles.media}>
<img
key={currentIndex}
src={currentStory.photo || currentStory.image}
alt={currentStory.title}
className={animClass}
/>
<button
className={`${styles.tap} ${styles.tapLeft}`}
onClick={goToPrevious}
disabled={isFirst}
aria-label="Önceki"
/>
<button
className={`${styles.tap} ${styles.tapRight}`}
onClick={goToNext}
aria-label="Sonraki"
/>
</div>
{(currentStory.description || currentStory.caption) && (
<div className={styles.footer}>
<p className={styles.caption}>
{currentStory.description || currentStory.caption}
</p>
</div>
)}
</div>
{!isLast && (
<button
className={`${styles.outerNav} ${styles.outerNavRight}`}
onClick={(e) => { e.stopPropagation(); goToNext(); }}
aria-label="Sonraki"
>
<ChevronRight size={28} />
</button>
)}
</div>
);
};
export default StoryViewer;

View File

@@ -0,0 +1,245 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
overflow: hidden;
}
.container {
position: relative;
width: 100%;
max-width: 420px;
height: 90vh;
max-height: 780px;
display: flex;
flex-direction: column;
background: #111;
border-radius: 16px;
overflow: hidden;
touch-action: pan-y;
@media (max-width: 480px) {
max-width: 100vw;
height: 100dvh;
max-height: none;
border-radius: 0;
}
}
/* ── Outer nav ── */
.outerNav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: background 0.15s, transform 0.15s;
backdrop-filter: blur(4px);
&:hover {
background: rgba(255, 255, 255, 0.22);
transform: translateY(-50%) scale(1.08);
}
&:active { transform: translateY(-50%) scale(0.95); }
@media (max-width: 600px) { display: none; }
}
.outerNavLeft {
left: calc(50% - 210px - 60px);
@media (max-width: 700px) { left: 8px; }
}
.outerNavRight {
right: calc(50% - 210px - 60px);
@media (max-width: 700px) { right: 8px; }
}
/* ── Progress bars — JS driven, no CSS animation ── */
.progressBars {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
gap: 3px;
padding: 10px 10px 0;
z-index: 20;
}
.track {
flex: 1;
height: 2px;
background: rgba(255, 255, 255, 0.25);
border-radius: 2px;
overflow: hidden;
position: relative;
}
/* Single bar element — width set via inline style */
.bar {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: #e53935;
border-radius: 2px;
/* No transition — rAF updates are fast enough */
}
/* ── Header ── */
.header {
position: absolute;
top: 22px;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
z-index: 20;
background: linear-gradient(180deg, rgba(0,0,0,0.55) 0%, transparent 100%);
}
.identity {
display: flex;
align-items: center;
gap: 9px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.65);
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.name {
color: #fff;
font-size: 13px;
font-weight: 600;
margin: 0;
line-height: 1.2;
}
.time {
color: rgba(255, 255, 255, 0.55);
font-size: 11px;
margin: 0;
margin-top: 1px;
}
.close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s;
flex-shrink: 0;
&:hover { color: #fff; }
}
/* ── Media ── */
.media {
flex: 1;
position: relative;
background: #000;
overflow: hidden;
}
.slideInFromRight {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
animation: slideInRight 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
.slideInFromLeft {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
animation: slideInLeft 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
/* Tap zones */
.tap {
position: absolute;
top: 0;
bottom: 0;
width: 40%;
background: transparent;
border: none;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: transparent;
z-index: 10;
&:disabled { pointer-events: none; }
}
.tapLeft { left: 0; }
.tapRight { right: 0; }
/* ── Footer ── */
.footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 28px 16px 18px;
background: linear-gradient(0deg, rgba(0,0,0,0.72) 0%, transparent 100%);
z-index: 20;
}
.caption {
color: rgba(255, 255, 255, 0.88);
font-size: 13px;
line-height: 1.5;
margin: 0;
}
/* ── Keyframes ── */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(5%) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes slideInLeft {
from { opacity: 0; transform: translateX(-5%) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
}

View File

@@ -1,3 +1,4 @@
export default {
navbar: {
category: "Categories",
@@ -12,6 +13,14 @@ export default {
ru: "Русский",
en: "English",
},
},
flashSales: {
flash_sale: "FLASH SALE",
ends_in: "Ends in:",
day: "day",
hour: "hr",
minute: "min",
second: "sec",
},
cart: {
basket: "Basket",
@@ -129,6 +138,8 @@ export default {
verify: "Verify",
name: "Name",
address: "Address",
seller_panel: "Seller Panel",
},
order: {
orderDate: "Order Date",

View File

@@ -1,3 +1,4 @@
export default {
navbar: {
category: "Категории",
@@ -12,6 +13,14 @@ export default {
ru: "Русский",
en: "English",
},
},
flashSales: {
flash_sale: "ФЛЭШ-РАСПРОДАЖА",
ends_in: "До конца:",
day: "дн.",
hour: "ч.",
minute: "мин.",
second: "сек.",
},
cart: {
basket: "Корзина",
@@ -126,6 +135,7 @@ export default {
name: "Имя",
address: "Address",
lastname: "Фамилия",
seller_panel: "Панель продавца",
},
order: {
orderDate: "Дата заказа",

View File

@@ -1,3 +1,4 @@
export default {
navbar: {
category: "Kategoriýalar",
@@ -12,6 +13,14 @@ export default {
ru: "Русский",
en: "English",
},
},
flashSales: {
flash_sale: "GYSGA WAGTLYK ARZANLADYŞ",
ends_in: "Gutarýança:",
day: "gün",
hour: "sag",
minute: "min",
second: "sek",
},
cart: {
basket: "Sebet",
@@ -129,6 +138,7 @@ export default {
name: "Ady",
address: "Salgy",
lastname: "Familýaňyz",
seller_panel: "Satyjy paneli",
},
order: {
orderDate: "Sargyt senesi",

View File

@@ -3,6 +3,7 @@ import InfiniteScroll from "react-infinite-scroll-component";
import CategorySection from "../../components/CategorySection/index";
import Carousel from "../../components/Banner/index";
import CategoryCarousel from "../../components/CategoryCarousel/CategoryCarousel";
import FlashSales from "../../components/FlashSales";
import styles from "./Home.module.scss";
import { useGetCollectionsQuery } from "../../app/api/collectionsApi";
import PageLoader from "../../components/Loader/pageLoader";
@@ -97,6 +98,7 @@ const Home = () => {
<div className={styles.home}>
<Carousel />
<CategoryCarousel />
<FlashSales />
<div className={styles.sections}>
<InfiniteScroll
dataLength={visibleCollections.length}