added story, flash sale, satyjy button
This commit is contained in:
@@ -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;
|
||||
11
src/app/api/flashSalesApi.js
Normal file
11
src/app/api/flashSalesApi.js
Normal 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;
|
||||
109
src/components/Banner/Stories.module.scss
Normal file
109
src/components/Banner/Stories.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
56
src/components/Banner/hook/useDragScroll.js
Normal file
56
src/components/Banner/hook/useDragScroll.js
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
206
src/components/FlashSales/FlashSales.module.scss
Normal file
206
src/components/FlashSales/FlashSales.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
148
src/components/FlashSales/index.jsx
Normal file
148
src/components/FlashSales/index.jsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
241
src/components/StoryViewer/StoryViewer.jsx
Normal file
241
src/components/StoryViewer/StoryViewer.jsx
Normal 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;
|
||||
245
src/components/StoryViewer/StoryViewer.module.scss
Normal file
245
src/components/StoryViewer/StoryViewer.module.scss
Normal 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); }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Дата заказа",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user