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/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({
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 && (
+

+ )
+ )}
+
+ {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.icon ? (
+

+ ) : (
+
+ )}
-
- {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}
+
+
+ {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 && (
+

+ )}
+
+ {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 (
-
- )
- })}
-
-
+
+
)
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