From 02b0cc1329552645bc1cd4c59ca8856a76187c74 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Mon, 21 Sep 2026 03:36:05 +0330 Subject: [PATCH 1/2] feat(explorer): add ImageSlider component, promo modal and support catalog gallery --- .gitignore | 3 +- .../__tests__/image-slider.test.ts | 47 ++ .../ui/image-slider/image-slider.tsx | 256 +++++++++++ src/components/ui/image-slider/index.ts | 3 + .../ui/image-slider/slider-utils.ts | 27 ++ .../ui/image-slider/use-image-slider.ts | 80 ++++ src/components/ui/index.ts | 2 + src/layouts/explorer/components/category.tsx | 165 ++----- .../explorer/components/content-banner.tsx | 126 ++++-- .../explorer/components/content-site.tsx | 80 ++-- .../explorer-popover-menu.tsx | 144 +++++++ .../components/promo-modal/promo-modal.tsx | 198 +++++++++ src/layouts/explorer/explorer.tsx | 401 +++++++++++++----- .../hooks/content/get-content.hook.ts | 37 +- 14 files changed, 1267 insertions(+), 302 deletions(-) create mode 100644 src/components/ui/image-slider/__tests__/image-slider.test.ts create mode 100644 src/components/ui/image-slider/image-slider.tsx create mode 100644 src/components/ui/image-slider/index.ts create mode 100644 src/components/ui/image-slider/slider-utils.ts create mode 100644 src/components/ui/image-slider/use-image-slider.ts create mode 100644 src/layouts/explorer/components/explorer-popover-menu/explorer-popover-menu.tsx create mode 100644 src/layouts/explorer/components/promo-modal/promo-modal.tsx diff --git a/.gitignore b/.gitignore index 078c93935..3a78e2e6a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ docs/* .claude .agents graphify-out -SOCIAL_ROADMAP.md \ No newline at end of file +SOCIAL_ROADMAP.md +explore-prototype*.html \ No newline at end of file diff --git a/src/components/ui/image-slider/__tests__/image-slider.test.ts b/src/components/ui/image-slider/__tests__/image-slider.test.ts new file mode 100644 index 000000000..09a06d43a --- /dev/null +++ b/src/components/ui/image-slider/__tests__/image-slider.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'bun:test' +import { + clampSlideIndex, + getNextSlideIndex, + getPrevSlideIndex, + resolveActiveSlideImage, +} from '../slider-utils' + +describe('slider-utils', () => { + it('increments index and wraps around to 0 on the last item', () => { + expect(getNextSlideIndex(0, 3)).toBe(1) + expect(getNextSlideIndex(1, 3)).toBe(2) + expect(getNextSlideIndex(2, 3)).toBe(0) + }) + + it('handles single item count without advancing', () => { + expect(getNextSlideIndex(0, 1)).toBe(0) + expect(getPrevSlideIndex(0, 1)).toBe(0) + }) + + it('decrements index and wraps around to last item when at 0', () => { + expect(getPrevSlideIndex(2, 3)).toBe(1) + expect(getPrevSlideIndex(1, 3)).toBe(0) + expect(getPrevSlideIndex(0, 3)).toBe(2) + }) + + it('clamps requested index into valid bounds', () => { + expect(clampSlideIndex(-2, 5)).toBe(0) + expect(clampSlideIndex(10, 5)).toBe(4) + expect(clampSlideIndex(3, 5)).toBe(3) + expect(clampSlideIndex(0, 0)).toBe(0) + }) + + it('resolves active slide image with bounds wrapping and fallback', () => { + const images = [ + 'https://example.com/1.png', + 'https://example.com/2.png', + 'https://example.com/3.png', + ] + expect(resolveActiveSlideImage(images, 0)).toBe('https://example.com/1.png') + expect(resolveActiveSlideImage(images, 1)).toBe('https://example.com/2.png') + expect(resolveActiveSlideImage(images, 2)).toBe('https://example.com/3.png') + expect(resolveActiveSlideImage([], 0, 'https://example.com/fallback.png')).toBe( + 'https://example.com/fallback.png' + ) + }) +}) diff --git a/src/components/ui/image-slider/image-slider.tsx b/src/components/ui/image-slider/image-slider.tsx new file mode 100644 index 000000000..f33557c48 --- /dev/null +++ b/src/components/ui/image-slider/image-slider.tsx @@ -0,0 +1,256 @@ +import { useState } from 'react' +import { Icon } from '@/icons' +import { cn } from '@/common/utils/cn' +import { useImageSlider, type UseImageSliderOptions } from './use-image-slider' + +export interface ImageSliderDotsProps { + count: number + currentIndex: number + onSelect: (index: number) => void + className?: string + size?: 'sm' | 'md' + variant?: 'light' | 'dark' +} + +export function ImageSliderDots({ + count, + currentIndex, + onSelect, + className, + size = 'sm', + variant = 'light', +}: ImageSliderDotsProps) { + if (count <= 1) return null + + return ( +
+ {Array.from({ length: count }).map((_, idx) => ( +
+ ) +} + +export interface ImageSliderArrowsProps { + onPrev: (e: React.MouseEvent) => void + onNext: (e: React.MouseEvent) => void + className?: string + variant?: 'dark' | 'glass' +} + +export function ImageSliderArrows({ + onPrev, + onNext, + className, + variant = 'dark', +}: ImageSliderArrowsProps) { + return ( +
+ + +
+ ) +} + +export interface ImageSliderProps extends UseImageSliderOptions { + alt?: string + mode?: 'background' | 'image' + showArrows?: boolean + arrowsVisibility?: 'always' | 'hover' + arrowVariant?: 'dark' | 'glass' + showDots?: boolean + dotsPosition?: 'bottom-center' | 'top-start' | 'none' + dotsVariant?: 'light' | 'dark' + className?: string + imageClassName?: string + overlay?: React.ReactNode + children?: + | React.ReactNode + | ((slider: ReturnType) => React.ReactNode) + onIndexChange?: (index: number) => void + onMouseEnter?: (e: React.MouseEvent) => void + onMouseLeave?: (e: React.MouseEvent) => void +} + +export function ImageSlider({ + images = [], + fallbackSrc, + alt = '', + autoPlay = true, + interval = 4500, + mode = 'background', + showArrows = true, + arrowsVisibility = 'hover', + arrowVariant = 'dark', + showDots = true, + dotsPosition = 'bottom-center', + dotsVariant = 'light', + className, + imageClassName, + overlay, + children, + onIndexChange, + onMouseEnter, + onMouseLeave, +}: ImageSliderProps) { + const [isHovered, setIsHovered] = useState(false) + + const slider = useImageSlider({ + images, + fallbackSrc, + autoPlay, + interval, + isPaused: isHovered, + onChange: onIndexChange, + }) + + const { currentImage, hasMultiple, count, currentIndex, next, prev, goTo } = slider + + const handleMouseEnter = (e: React.MouseEvent) => { + setIsHovered(true) + onMouseEnter?.(e) + } + + const handleMouseLeave = (e: React.MouseEvent) => { + setIsHovered(false) + onMouseLeave?.(e) + } + + return ( +
+ {mode === 'background' ? ( +
+ ) : ( + currentImage && ( + {alt} + ) + )} + + {overlay} + + {showArrows && hasMultiple && ( + + )} + + {showDots && hasMultiple && dotsPosition !== 'none' && ( +
+ +
+ )} + + {typeof children === 'function' ? children(slider) : children} +
+ ) +} + +ImageSlider.Dots = ImageSliderDots +ImageSlider.Arrows = ImageSliderArrows diff --git a/src/components/ui/image-slider/index.ts b/src/components/ui/image-slider/index.ts new file mode 100644 index 000000000..51b9a9a41 --- /dev/null +++ b/src/components/ui/image-slider/index.ts @@ -0,0 +1,3 @@ +export * from './image-slider' +export * from './use-image-slider' +export * from './slider-utils' diff --git a/src/components/ui/image-slider/slider-utils.ts b/src/components/ui/image-slider/slider-utils.ts new file mode 100644 index 000000000..b041bf7a6 --- /dev/null +++ b/src/components/ui/image-slider/slider-utils.ts @@ -0,0 +1,27 @@ +export function getNextSlideIndex(current: number, count: number): number { + if (count <= 1) return 0 + return (current + 1) % count +} + +export function getPrevSlideIndex(current: number, count: number): number { + if (count <= 1) return 0 + return (current - 1 + count) % count +} + +export function clampSlideIndex(index: number, count: number): number { + if (count <= 0) return 0 + if (index < 0) return 0 + if (index >= count) return count - 1 + return index +} + +export function resolveActiveSlideImage( + images: string[] = [], + index: number = 0, + fallbackSrc?: string +): string | undefined { + if (images.length === 0) return fallbackSrc + if (images.length === 1) return images[0] || fallbackSrc + const validIndex = (index + images.length) % images.length + return images[validIndex] || fallbackSrc +} diff --git a/src/components/ui/image-slider/use-image-slider.ts b/src/components/ui/image-slider/use-image-slider.ts new file mode 100644 index 000000000..f6942196d --- /dev/null +++ b/src/components/ui/image-slider/use-image-slider.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useState } from 'react' +import { + getNextSlideIndex, + getPrevSlideIndex, + resolveActiveSlideImage, +} from './slider-utils' + +export interface UseImageSliderOptions { + images?: string[] + fallbackSrc?: string + autoPlay?: boolean + interval?: number + isPaused?: boolean + onChange?: (index: number) => void +} + +export function useImageSlider({ + images = [], + fallbackSrc, + autoPlay = true, + interval = 4500, + isPaused = false, + onChange, +}: UseImageSliderOptions = {}) { + const count = images.length + const hasMultiple = count > 1 + const [currentIndex, setCurrentIndex] = useState(0) + + useEffect(() => { + if (currentIndex >= count && count > 0) { + setCurrentIndex(0) + } + }, [count, currentIndex]) + + const next = useCallback(() => { + if (!hasMultiple) return + setCurrentIndex((prev) => { + const nextIdx = getNextSlideIndex(prev, count) + onChange?.(nextIdx) + return nextIdx + }) + }, [count, hasMultiple, onChange]) + + const prev = useCallback(() => { + if (!hasMultiple) return + setCurrentIndex((prev) => { + const prevIdx = getPrevSlideIndex(prev, count) + onChange?.(prevIdx) + return prevIdx + }) + }, [count, hasMultiple, onChange]) + + const goTo = useCallback( + (index: number) => { + if (index >= 0 && index < count) { + setCurrentIndex(index) + onChange?.(index) + } + }, + [count, onChange] + ) + + useEffect(() => { + if (!autoPlay || !hasMultiple || isPaused) return + const timer = setInterval(next, interval) + return () => clearInterval(timer) + }, [autoPlay, hasMultiple, isPaused, interval, next]) + + const currentImage = resolveActiveSlideImage(images, currentIndex, fallbackSrc) + + return { + currentIndex, + count, + hasMultiple, + currentImage, + next, + prev, + goTo, + } +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index ae9034afe..486c5ee52 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -29,6 +29,8 @@ export * from './dropdown/dropdown-item.variants' export * from './filter-tooltip/filter-tooltip' +export * from './image-slider' + export * from './input/input' export * from './input/input.variants' diff --git a/src/layouts/explorer/components/category.tsx b/src/layouts/explorer/components/category.tsx index f65d5af4a..f5fe1979d 100644 --- a/src/layouts/explorer/components/category.tsx +++ b/src/layouts/explorer/components/category.tsx @@ -1,154 +1,75 @@ -import type { ExplorerCategoryBadge } from '@/services/hooks/content/get-content.hook' +import type { CatalogItem } from '../interfaces/catalog-item.interface' import type { CategoryItem } from '../interfaces/category.interface' import { RenderContentBanner } from './content-banner' import { RenderContentIframe } from './content-iframe' import { RenderContentSite } from './content-site' -import { twJoin } from 'tailwind-merge' interface Prop { category: CategoryItem categoryRefs: any - activeCategory: string + onOpenPromoModal?: (item: CatalogItem, triggerEl?: HTMLElement) => void } -export function ExplorerCategory({ category, categoryRefs, activeCategory }: Prop) { + +export function ExplorerCategory({ category, categoryRefs, onOpenPromoModal }: Prop) { if (!category.links?.length) return null const id = category.id.replaceAll(' ', '_') + return ( -
{ categoryRefs.current[category.id] = el }} - style={{ - gridColumn: category.span?.col - ? `span ${category.span.col} / span ${category.span.col}` - : undefined, - gridRow: category.span?.row - ? `span ${category.span.row} / span ${category.span.row}` - : undefined, - ...(category.banner && { - '--banner-url': `url(${category.banner})`, - }), - }} - className={twJoin( - 'relative overflow-hidden border scroll-mt-20 bg-content bg-glass border-base-200/70 hover:border-base-300 rounded-widget transition-all duration-300 shadow-sm hover:shadow-md break-inside-avoid mb-3.5', - category.id === activeCategory && - 'ring-2 ring-primary/40 border-primary/50', - category.banner - ? 'before:absolute before:inset-x-0 before:top-0 before:h-16 before:bg-cover before:bg-center before:bg-no-repeat before:brightness-75 before:contrast-110 before:pointer-events-none' - : '' - )} + className="w-full scroll-mt-20 mb-8" > - {category.banner && ( - - )} -
-
-
+
+
+
{category.icon ? ( ) : ( -
+
)} -

+

+
+

{category.category} -

+
- - {category.badges?.length ? ( -
- {category.badges?.map((f, i) => ( - - ))} -
- ) : ( -
- )}
- -
- ) -} - -interface HandleCatalogsProp { - category: CategoryItem -} - -function HandleCatalogs({ category }: HandleCatalogsProp) { - const totalLinks = category.links?.length || 0 - const gridColsClass = - category.span?.col && category.span.col >= 2 - ? 'grid-cols-4 sm:grid-cols-6 lg:grid-cols-8' - : totalLinks > 8 - ? 'grid-cols-4' - : 'grid-cols-4' - - return ( -
- {category.links?.map((link) => - link.type === 'REMOTE_IFRAME' ? ( - - ) : link.type === 'SITE' ? ( - - ) : link.type === 'BANNER' ? ( - - ) : ( -
- نیازمند بروزرسانی -
- ) - )} -
- ) -} -interface BadgeProp { - badge: ExplorerCategoryBadge -} -function CategoryBadge({ badge }: BadgeProp) { - const render = ( -
- {badge.label} - {badge.iconSrc && } -
+
+ {category.links.map((link) => + link.type === 'REMOTE_IFRAME' ? ( + + ) : link.type === 'BANNER' ? ( + + ) : link.type === 'SITE' ? ( + + ) : ( +
+ نیازمند بروزرسانی +
+ ) + )} +
+ ) - - if (badge.url) { - return ( - - {render} - - ) - } - - return render } diff --git a/src/layouts/explorer/components/content-banner.tsx b/src/layouts/explorer/components/content-banner.tsx index 3f3527153..657caffa9 100644 --- a/src/layouts/explorer/components/content-banner.tsx +++ b/src/layouts/explorer/components/content-banner.tsx @@ -1,51 +1,117 @@ +import { useState } from 'react' +import { Icon } from '@/icons' +import { ImageSlider, useImageSlider } from '@/components/ui' import type { CatalogItem } from '../interfaces/catalog-item.interface' -interface SiteProp { +interface BannerProp { link: CatalogItem + onOpenPromoModal?: (item: CatalogItem, triggerEl?: HTMLElement) => void } function getUrl(url: string) { return url.startsWith('http') ? url : `https://${url}` } -export function RenderContentBanner({ link }: SiteProp) { - const col = link?.span?.col - const row = link?.span?.row +export function RenderContentBanner({ link, onOpenPromoModal }: BannerProp) { const badge = link.badge?.trim() + const gallery = link.meta?.gallery || [] + const [isHovered, setIsHovered] = useState(false) - const pos = row && row >= 2 ? 'justify-center' : '' + const isModalAction = + link.meta?.action === 'OPEN_MODAL' || + link.meta?.action === 'OPEN_POPOVER' || + link.meta?.type === 'POPOVER_MENU' || + Boolean(link.meta?.promo) + + const { currentIndex, count, hasMultiple, currentImage, next, prev, goTo } = + useImageSlider({ + images: gallery, + fallbackSrc: link.backgroundSrc, + isPaused: isHovered, + autoPlay: true, + interval: 4500, + }) + + const handleClick = (e: React.MouseEvent) => { + if (isModalAction && onOpenPromoModal) { + e.preventDefault() + onOpenPromoModal(link, e.currentTarget as HTMLElement) + } + } - const style = link.backgroundSrc - ? { - backgroundImage: `url(${link.backgroundSrc})`, - backgroundSize: 'cover', - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center', - } - : {} return ( setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className="col-span-2 row-span-2 group relative rounded-2xl overflow-hidden border border-base-content/10 hover:border-base-content/25 transition-all duration-300 flex flex-col justify-between p-4 shadow-xs hover:shadow-sm active:scale-[0.99] select-none h-full cursor-pointer" > - {badge && ( - - {badge} - - )}{' '} +
+ +
+ + {hasMultiple && ( + + )} + +
+
+ {badge && ( + + {badge} + + )} + + {hasMultiple && ( + + )} +
+
+ +
+
+

+ {link.name} +

+ {link.description && ( +

+ {link.description} +

+ )} +
+ +
+ {isModalAction ? ( + + ) : ( + + )} +
+
) } diff --git a/src/layouts/explorer/components/content-site.tsx b/src/layouts/explorer/components/content-site.tsx index 2d1d2ca3f..7b7790f2f 100644 --- a/src/layouts/explorer/components/content-site.tsx +++ b/src/layouts/explorer/components/content-site.tsx @@ -1,62 +1,86 @@ +import { getContrastingTextColor } from '@/common/color' import { NewBadge } from '@/components/ui' +import { Icon } from '@/icons' import type { CatalogItem } from '../interfaces/catalog-item.interface' interface SiteProp { link: CatalogItem -} -const ANIMATES = { - bounce: 'bounce 1.5s infinite', - pulse: 'pulse 2s infinite', - spin: 'spin 2s linear infinite', + onOpenPromoModal?: (item: CatalogItem, triggerEl?: HTMLElement) => void } function getUrl(url: string) { return url.startsWith('http') ? url : `https://${url}` } -export function RenderContentSite({ link }: SiteProp) { - const animate = link.badgeAnimate || null +export function RenderContentSite({ link, onOpenPromoModal }: SiteProp) { const badge = link.badge?.trim() - const col = link?.span?.col - const row = link?.span?.row - const pos = row && row >= 2 ? 'justify-center' : '' + const isModalAction = + link.meta?.action === 'OPEN_MODAL' || + link.meta?.action === 'OPEN_POPOVER' || + link.meta?.type === 'POPOVER_MENU' || + Boolean(link.meta?.promo) + + const handleClick = (e: React.MouseEvent) => { + if (isModalAction && onOpenPromoModal) { + e.preventDefault() + onOpenPromoModal(link, e.currentTarget as HTMLElement) + } + } return ( - {link.isNew && } + {link.isNew && } {badge && ( {badge} )} -
- {link.name + +
+ {link.icon ? ( + {link.name + ) : ( + + )}
- - {link.name} - +
+ + {link.name || 'بدون نام'} + + {link.description && ( + + {link.description} + + )} +
+ +
+ {isModalAction ? ( + + ) : ( + + )} +
) } diff --git a/src/layouts/explorer/components/explorer-popover-menu/explorer-popover-menu.tsx b/src/layouts/explorer/components/explorer-popover-menu/explorer-popover-menu.tsx new file mode 100644 index 000000000..b96e41fa4 --- /dev/null +++ b/src/layouts/explorer/components/explorer-popover-menu/explorer-popover-menu.tsx @@ -0,0 +1,144 @@ +import { showToast } from '@/common/toast' +import { + PopoverMenu, + PopoverMenuDivider, + PopoverMenuHeader, + PopoverMenuItem, +} from '@/components/ui' +import { Icon } from '@/icons' +import type { CatalogItem } from '../../interfaces/catalog-item.interface' + +export interface ExplorerPopoverMenuProps { + isOpen: boolean + onClose: () => void + item: CatalogItem | null + triggerRef?: React.RefObject + position?: { x: number; y: number } | null +} + +export function ExplorerPopoverMenu({ + isOpen, + onClose, + item, + triggerRef, + position, +}: ExplorerPopoverMenuProps) { + if (!item) return null + + const meta = item.meta + const title = meta?.title || item.name + const menuItems = Array.isArray(meta?.menuItems) ? meta.menuItems : [] + const promo = meta?.promo + + const handleItemClick = (url: string) => { + if (!url) return + const targetUrl = url.startsWith('http') ? url : `https://${url}` + window.open(targetUrl, '_blank', 'noopener,noreferrer') + onClose() + } + + const handleCopy = (code?: string) => { + if (!code) return + navigator.clipboard.writeText(code) + showToast(`کد تخفیف ${code} کپی شد`, 'success') + } + + return ( + + {/* Header */} + +
+ {item.icon && ( + {title} + )} + + {title} + +
+ {item.badge || meta?.badge || promo?.badge ? ( + + {item.badge || meta?.badge || promo?.badge} + + ) : null} +
+ + {meta?.description && ( +

+ {meta.description} +

+ )} + + {menuItems.length ? : null} + + {/* Dynamic Menu Items */} + {menuItems.map((mItem, idx) => ( + + {mItem.badge} + + ) : undefined + } + icon={} + onClick={() => handleItemClick(mItem.url)} + /> + ))} + + {promo?.code && ( + <> + +
+
+ + {promo.title || 'کد تخفیف'} + + {promo.discount && ( + + {promo.discount} + + )} +
+
+ + {promo.code} + + +
+
+ + )} + + {menuItems.length === 0 && item.url && ( + } + onClick={() => handleItemClick(item.url)} + /> + )} +
+ ) +} diff --git a/src/layouts/explorer/components/promo-modal/promo-modal.tsx b/src/layouts/explorer/components/promo-modal/promo-modal.tsx new file mode 100644 index 000000000..e6aebc4e6 --- /dev/null +++ b/src/layouts/explorer/components/promo-modal/promo-modal.tsx @@ -0,0 +1,198 @@ +import { useState } from 'react' +import { showToast } from '@/common/toast' +import { Button, ImageSlider, Modal } from '@/components/ui' +import { Icon } from '@/icons' +import type { CatalogItem } from '../../interfaces/catalog-item.interface' + +export interface ExplorerPromoModalProps { + isOpen: boolean + onClose: () => void + item: CatalogItem | null +} + +export function ExplorerPromoModal({ isOpen, onClose, item }: ExplorerPromoModalProps) { + const [isCopied, setIsCopied] = useState(false) + + if (!item) return null + + const meta = item.meta as any + const promo = meta?.promo + const rawGallery = meta?.gallery + const gallery: string[] = Array.isArray(rawGallery) + ? rawGallery + : item.backgroundSrc + ? [item.backgroundSrc] + : [] + const hasGallery = gallery.length > 1 + const title = meta?.title || item.name + const description = meta?.description || item.description + const highlights: string[] = Array.isArray(meta?.highlights) ? meta.highlights : [] + const isSponsor = + item.badge === 'اسپانسر' || item.badgeColor?.includes('amber') || meta?.isSponsor + + const handleCopy = async (e?: React.MouseEvent) => { + if (e) e.stopPropagation() + if (!promo?.code) return + + try { + await navigator.clipboard.writeText(promo.code) + setIsCopied(true) + showToast(`کد تخفیف ${promo.code} کپی شد`, 'success') + setTimeout(() => setIsCopied(false), 2500) + } catch { + showToast('خطا در کپی کردن کد', 'error') + } + } + + const handleAction = () => { + const targetUrl = promo?.targetUrl || item.url + if (targetUrl) { + const formattedUrl = targetUrl.startsWith('http') + ? targetUrl + : `https://${targetUrl}` + window.open(formattedUrl, '_blank', 'noopener,noreferrer') + } + if (promo?.code && !isCopied) { + handleCopy() + } + } + + const ctaText = promo?.code ? 'کپی کد و ورود به سایت' : 'مشاهده و ورود' + + return ( + +
+ {/* Visual / Gallery Container */} + {gallery.length > 0 && ( + + } + /> + )} + +
+
+
+ {item.icon && ( + {item.name} + )} +

+ {title} +

+
+ {isSponsor && ( + + اسپانسر + + )} +
+ + {description && ( +

+ {description} +

+ )} +
+ + {highlights.length > 0 && ( +
+ {highlights.map((h, i) => ( +
+ + {h} +
+ ))} +
+ )} + + {promo && ( +
+
+ + {promo.title || 'کد تخفیف اختصاصی'} + + {promo.discount && ( + + {promo.discount} + + )} +
+ {promo.code && ( +
+ + {promo.code} + + +
+ )} +
+ )} + +
+ + +
+
+
+ ) +} diff --git a/src/layouts/explorer/explorer.tsx b/src/layouts/explorer/explorer.tsx index d00e1013f..120558a60 100644 --- a/src/layouts/explorer/explorer.tsx +++ b/src/layouts/explorer/explorer.tsx @@ -1,27 +1,28 @@ -import { useGetContents } from '@/services/hooks/content/get-content.hook' -import { useRef, useState, useEffect } from 'react' import Analytics from '@/analytics' -import type { CategoryItem } from './interfaces/category.interface' +import { useGetContents } from '@/services/hooks/content/get-content.hook' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ExplorerCategory } from './components/category' +import { ExplorerPromoModal } from './components/promo-modal/promo-modal' +import { ExplorerPopoverMenu } from './components/explorer-popover-menu/explorer-popover-menu' +import type { CatalogItem } from './interfaces/catalog-item.interface' +import type { CategoryItem } from './interfaces/category.interface' function ExplorerSkeleton() { return ( -
- {[1, 2, 3, 4, 5, 6].map((i) => ( -
-
-
-
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
-
- {[1, 2, 3, 4, 5, 6, 7, 8].map((j) => ( -
-
-
-
+
+
+ {[1, 2, 3, 4, 5, 6].map((j) => ( +
))}
@@ -33,134 +34,300 @@ function ExplorerSkeleton() { export function ExplorerContent() { const { data: catalogData, isLoading } = useGetContents() const [activeCategory, setActiveCategory] = useState(null) - const categoryRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}) + const [searchQuery, setSearchQuery] = useState('') + const [selectedPromoItem, setSelectedPromoItem] = useState(null) + const [activePopover, setActivePopover] = useState<{ + item: CatalogItem + position: { x: number; y: number } | null + } | null>(null) + const popoverTriggerRef = useRef(null) + + const handleItemAction = useCallback((item: CatalogItem, triggerEl?: HTMLElement) => { + const meta = item.meta + const isPopover = + meta?.type === 'POPOVER_MENU' || + meta?.action === 'OPEN_POPOVER' || + Boolean(meta?.menuItems && meta.menuItems.length > 0) + + if (isPopover) { + popoverTriggerRef.current = triggerEl || null + const rect = triggerEl?.getBoundingClientRect() + setActivePopover({ + item, + position: rect ? { x: rect.left, y: rect.bottom + 6 } : null, + }) + } else { + setSelectedPromoItem(item) + } + }, []) + + const categoryRefs = useRef<{ [key: string]: HTMLElement | null }>({}) const tabRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({}) const scrollContainerRef = useRef(null) + const isManualScrollRef = useRef(false) + const rafIdRef = useRef(null) + + const contents = useMemo(() => { + return catalogData?.contents || [] + }, [catalogData?.contents]) + + const categories = useMemo(() => { + return contents.filter((f) => f.links?.length) + }, [contents]) + + const filteredContents = useMemo(() => { + if (!searchQuery.trim()) return contents + const q = searchQuery.trim().toLowerCase() + + return contents + .map((cat) => ({ + ...cat, + links: cat.links.filter( + (link) => + link.name?.toLowerCase().includes(q) || + cat.category?.toLowerCase().includes(q) || + link.badge?.toLowerCase().includes(q) + ), + })) + .filter((cat) => cat.links.length > 0) + }, [contents, searchQuery]) useEffect(() => { - if (!catalogData?.contents || !scrollContainerRef.current) return + if (categories.length > 0 && !activeCategory) { + setActiveCategory(categories[0].id) + } + }, [categories, activeCategory]) + + const updateActiveCategoryOnScroll = useCallback(() => { + if (isManualScrollRef.current) return + const container = scrollContainerRef.current + if (!container) return + + const visibleCategories = categories.filter((c) => + filteredContents.some((fc) => fc.id === c.id) + ) + if (visibleCategories.length === 0) return + + if (container.scrollTop < 40) { + const firstCat = visibleCategories[0] + if (activeCategory !== firstCat.id) { + setActiveCategory(firstCat.id) + tabRefs.current[firstCat.id]?.scrollIntoView({ + behavior: 'smooth', + inline: 'center', + block: 'nearest', + }) + } + return + } + + const isAtBottom = + container.scrollHeight - container.scrollTop - container.clientHeight < 50 - const observerOptions = { - root: scrollContainerRef.current, - rootMargin: '0px 0px -40% 0px', - threshold: 0.1, + if (isAtBottom && visibleCategories.length > 0) { + const lastCat = visibleCategories[visibleCategories.length - 1] + if (activeCategory !== lastCat.id) { + setActiveCategory(lastCat.id) + tabRefs.current[lastCat.id]?.scrollIntoView({ + behavior: 'smooth', + inline: 'center', + block: 'nearest', + }) + } + return } - const observerCallback = (entries: IntersectionObserverEntry[]) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - setActiveCategory(entry.target.id) - tabRefs.current[entry.target.id]?.scrollIntoView({ + const containerTop = container.getBoundingClientRect().top + + for (const cat of visibleCategories) { + const el = categoryRefs.current[cat.id] + if (!el) continue + + const rect = el.getBoundingClientRect() + const relativeTop = rect.top - containerTop + const relativeBottom = rect.bottom - containerTop + + if (relativeTop <= 110 && relativeBottom > 40) { + if (activeCategory !== cat.id) { + setActiveCategory(cat.id) + tabRefs.current[cat.id]?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest', }) } - }) + break + } } + }, [activeCategory, categories, filteredContents]) - const observer = new IntersectionObserver(observerCallback, observerOptions) - - const currentRefs = categoryRefs.current - Object.values(currentRefs).forEach((div) => { - if (div) observer.observe(div) - }) + const handleScroll = () => { + if (rafIdRef.current) { + cancelAnimationFrame(rafIdRef.current) + } + rafIdRef.current = requestAnimationFrame(updateActiveCategoryOnScroll) + } - return () => observer.disconnect() - }, [catalogData?.contents]) + useEffect(() => { + return () => { + if (rafIdRef.current) { + cancelAnimationFrame(rafIdRef.current) + } + } + }, []) const scrollToCategory = (id: string) => { + isManualScrollRef.current = true setActiveCategory(id) - const tabBtn = tabRefs.current[id] - if (tabBtn) { - tabBtn.scrollIntoView({ - behavior: 'smooth', - inline: 'center', - block: 'nearest', - }) - } + + tabRefs.current[id]?.scrollIntoView({ + behavior: 'smooth', + inline: 'center', + block: 'nearest', + }) + const element = categoryRefs.current[id] - if (element) { - element.scrollIntoView({ + const container = scrollContainerRef.current + + if (element && container) { + const containerRect = container.getBoundingClientRect() + const elementRect = element.getBoundingClientRect() + const targetScrollTop = + container.scrollTop + (elementRect.top - containerRect.top) - 12 + + container.scrollTo({ + top: targetScrollTop, behavior: 'smooth', - block: 'start', }) } + + setTimeout(() => { + isManualScrollRef.current = false + }, 600) + Analytics.event('explorer_click_category') } - const contents = catalogData?.contents || [] - const categories = contents.filter((f) => !f.hideName && f.links.length) - return ( -
-
- {isLoading ? ( -
- {[1, 2, 3, 4, 5, 6].map((i) => ( -
- ))} -
- ) : ( -
-
-
- {categories.map((cat: CategoryItem) => { - const active = activeCategory === cat.id - - return ( - - ) - })} -
-
+
+
+
+
+ {categories.map((cat: CategoryItem) => { + const active = activeCategory === cat.id + + return ( + + ) + })}
- )} -
-
- {isLoading ? ( - - ) : ( -
- {contents.map((category: CategoryItem) => ( - - ))} -
+ +
+ setSearchQuery(e.target.value)} + placeholder="جستجو در تمام سایت‌ها و دسته‌ها..." + className="w-full bg-base-200/50 text-xs text-base-content placeholder-base-content/40 px-3 py-2 rounded-xl border border-base-content/10 focus:outline-none focus:border-primary/50 focus:bg-base-200 transition-all pr-8" + /> + + + + + {searchQuery && ( + )}
-
+
+ +
+ {isLoading ? ( + + ) : filteredContents.length === 0 ? ( +
+
🔍
+

+ نتیجه‌ای برای «{searchQuery}» پیدا نشد +

+

+ می‌توانید عنوان دیگری را جستجو کنید یا فیلتر را پاک کنید +

+ +
+ ) : ( +
+ {filteredContents.map((category: CategoryItem) => ( + + ))} +
+ )} +
+ + setSelectedPromoItem(null)} + /> + + { + setActivePopover(null) + popoverTriggerRef.current = null + }} + triggerRef={popoverTriggerRef} + position={activePopover?.position} + />
) diff --git a/src/services/hooks/content/get-content.hook.ts b/src/services/hooks/content/get-content.hook.ts index 8ec9b3f8b..6d4d100fe 100644 --- a/src/services/hooks/content/get-content.hook.ts +++ b/src/services/hooks/content/get-content.hook.ts @@ -8,10 +8,39 @@ export interface ExplorerCategoryBadge { textColor?: string url?: string } +export interface CatalogItemPromo { + title?: string + code?: string + discount?: string + badge?: string + expiresText?: string + targetUrl?: string + highlights?: string[] +} + +export interface CatalogItemMenuItem { + id?: string + title: string + url: string + badge?: string + icon?: string + isExternal?: boolean +} + +export interface CatalogItemMeta { + type?: 'POPOVER_MENU' + action?: 'OPEN_MODAL' | 'OPEN_POPOVER' | 'DIRECT_URL' + title?: string + description?: string + badge?: string + gallery?: string[] + promo?: CatalogItemPromo + menuItems?: CatalogItemMenuItem[] +} + export interface FetchedContent { id: string category: string - hideName?: boolean icon?: string banner?: string links: { @@ -24,16 +53,16 @@ export interface FetchedContent { row?: number | null } height?: number - hasBorder: boolean isNew: boolean badge?: string badgeColor?: string + description?: string backgroundSrc?: string - badgeAnimate?: 'bounce' | 'pulse' miniAppAuthRequired?: boolean + meta?: CatalogItemMeta }[] lockHeight?: boolean - badges: ExplorerCategoryBadge[] + span?: { col?: number | null row?: number | null From 261f4d155cc88d4635b3482ea1e5c0649572de17 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Mon, 21 Sep 2026 03:58:49 +0330 Subject: [PATCH 2/2] feat(cache): implement cache-first strategy for '/contents' API path --- background/cache-config.ts | 4 +++- background/cache.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/background/cache-config.ts b/background/cache-config.ts index a88d2fe52..c393c946c 100644 --- a/background/cache-config.ts +++ b/background/cache-config.ts @@ -1,8 +1,10 @@ +// Cache-first: return cached response if available, only hit network when missing. +export const CACHE_FIRST_API_PATHS = ['/contents'] + // Network-first: try the network, fall back to cache when offline/slow. export const NETWORK_FIRST_API_PATHS = [ '/date/events', '/news/rss', - '/contents', '/extension/notifications', '/extension/searchbox', ] diff --git a/background/cache.ts b/background/cache.ts index 28df20ac0..6e1cb9ab8 100644 --- a/background/cache.ts +++ b/background/cache.ts @@ -5,6 +5,7 @@ import { registerRoute } from 'workbox-routing' import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies' import { CacheNames } from './cache-names' import { + CACHE_FIRST_API_PATHS, CDN_NO_CACHE_PREFIXES, NETWORK_FIRST_API_PATHS, NEVER_CACHE_API_PATHS, @@ -55,6 +56,14 @@ export function setupCaching() { }), ] + registerRoute( + ({ url, request }) => matchesApiPaths(url, request, CACHE_FIRST_API_PATHS), + new CacheFirst({ + cacheName: CacheNames.api, + plugins: apiCachePlugins(), + }) + ) + registerRoute( ({ url, request }) => matchesApiPaths(url, request, SWR_API_PATHS), new StaleWhileRevalidate({