diff --git a/src/common/toast.tsx b/src/common/toast.tsx index d11c48867..23fae4b29 100644 --- a/src/common/toast.tsx +++ b/src/common/toast.tsx @@ -177,8 +177,6 @@ export function showToast( type: ToastType = 'info', options?: ToastOptions ) { - raiseToastLayer() - const theme = TOAST_THEMES[type] || TOAST_THEMES.info const title = options?.title ?? theme.defaultTitle const actionText = options?.actionText ?? theme.defaultActionText @@ -187,10 +185,6 @@ export function showToast( playNativeToastSound(type) } - if (options?.alarmSound) { - playAlarm('success') - } - return toast.custom( (t) => { const handleAction = () => { @@ -204,9 +198,9 @@ export function showToast(
diff --git a/src/components/gallery/gallery-asset-purchase-modal.tsx b/src/components/gallery/gallery-asset-purchase-modal.tsx new file mode 100644 index 000000000..c3fd17eb3 --- /dev/null +++ b/src/components/gallery/gallery-asset-purchase-modal.tsx @@ -0,0 +1,175 @@ +import { Button, Modal } from '@/components/ui' +import { UserCoin } from '@/layouts/setting/tabs/account/components/user-coin' +import { callEvent } from '@/common/utils/call-event' +import { showToast } from '@/common/toast' +import type { GalleryAsset } from '@/services/hooks/gallery/get-gallery-assets.hook' +import { usePurchaseGalleryAsset } from '@/services/hooks/gallery/get-gallery-assets.hook' + +interface GalleryAssetPurchaseModalProps { + isOpen: boolean + onClose: () => void + asset: GalleryAsset | null + userCoins: number + isVip: boolean + onPurchaseSuccess: (asset: GalleryAsset) => void + onSelectDirectly?: (asset: GalleryAsset) => void +} + +export function GalleryAssetPurchaseModal({ + isOpen, + onClose, + asset, + userCoins, + isVip, + onPurchaseSuccess, + onSelectDirectly, +}: GalleryAssetPurchaseModalProps) { + const { mutate: purchase, isPending } = usePurchaseGalleryAsset() + + if (!asset) return null + + const isVipUnlocked = isVip && asset.accessVip + const canAfford = userCoins >= asset.price + + const handlePurchase = () => { + if (!canAfford) return + + purchase(asset.id, { + onSuccess: (response) => { + showToast(`${asset.title || 'آیتم'} برای همیشه خریداری شد`, 'success') + const updatedAsset = response?.data?.asset || { + ...asset, + isOwned: true, + isUnlocked: true, + } + onPurchaseSuccess(updatedAsset) + }, + onError: () => { + showToast('خطا در دریافت آیتم', 'error') + }, + }) + } + + const handleUseWithVip = () => { + if (onSelectDirectly) { + onSelectDirectly(asset) + } + onClose() + } + + const handleOpenCoins = () => { + onClose() + callEvent('openMarketModal') + setTimeout(() => { + callEvent('market_change_tab', 'coins') + }, 100) + } + + return ( + +
+
+ {asset.title +
+ +
+
+

+ {asset.title || 'تصویر گالری'} +

+ {asset.price > 0 && ( + + )} +
+

+ {isVipUnlocked + ? 'چون اشتراک پرو داری می‌تونی این تصویر رو رایگان فعال کنی یا با ویج‌کوین دائمی بخریش' + : 'این تصویر رو با ویج‌کوین باز کن و همیشه ازش استفاده کن'} +

+
+ + {!isVipUnlocked && !canAfford && ( +
+ + موجودی ویج‌کوین ناکافیه ({asset.price - userCoins} ویج‌کوین کسری + داری) + + +
+ )} + +
+ {isVipUnlocked ? ( +
+ + {asset.price > 0 && ( + + )} +
+ ) : ( +
+ + +
+ )} +
+
+
+ ) +} diff --git a/src/components/gallery/gallery-picker-modal.tsx b/src/components/gallery/gallery-picker-modal.tsx new file mode 100644 index 000000000..a7a08936d --- /dev/null +++ b/src/components/gallery/gallery-picker-modal.tsx @@ -0,0 +1,263 @@ +import { useState, useRef } from 'react' +import { Modal, Chip, VipBadge } from '@/components/ui' +import { Icon } from '@/src/icons' +import { playNativeToastSound, showToast } from '@/common/toast' +import { useAuth } from '@/context/auth.context' +import { callEvent } from '@/common/utils/call-event' +import { UserCoin } from '@/layouts/setting/tabs/account/components/user-coin' +import { useLazyLoad } from '@/layouts/setting/tabs/wallpapers/hooks/use-lazy-load' +import { + useGetGalleryAssets, + useGetGalleryCategories, + type GalleryAsset, + type GalleryAssetType, +} from '@/services/hooks/gallery/get-gallery-assets.hook' +import { GalleryAssetPurchaseModal } from './gallery-asset-purchase-modal' +import toast from 'react-hot-toast' + +interface GalleryPickerModalProps { + isOpen: boolean + onClose: () => void + type: GalleryAssetType + title?: string + onSelect: (asset: GalleryAsset) => void + selectedAssetId?: string +} + +export function GalleryPickerModal({ + isOpen, + onClose, + type, + title = 'گالری تصاویر', + onSelect, + selectedAssetId, +}: GalleryPickerModalProps) { + const [selectedCategory, setSelectedCategory] = useState('ALL') + const [purchasingAsset, setPurchasingAsset] = useState(null) + + const { user, isVip } = useAuth() + const { data: categories = [] } = useGetGalleryCategories(type, isOpen) + + const { data, isLoading } = useGetGalleryAssets( + { + type, + limit: 100, + category: selectedCategory === 'ALL' ? undefined : selectedCategory, + }, + isOpen + ) + + const assets = data?.data?.assets ?? [] + + const handleItemClick = (asset: GalleryAsset) => { + if (asset.isOwned || asset.price === 0) { + onSelect(asset) + onClose() + playNativeToastSound('success') + return + } + + if (!user) { + callEvent('openProfile') + showToast('برای استفاده از این مورد اول وارد حسابت شو', 'error') + return + } + + if (asset.accessVip && !isVip && asset.price === 0) { + callEvent('openSettings', 'vip') + return + } + + setPurchasingAsset(asset) + } + + const handlePurchaseSuccess = (asset: GalleryAsset) => { + setPurchasingAsset(null) + onSelect({ ...asset, isOwned: true, isUnlocked: true }) + onClose() + } + + const handleSelectDirectly = (asset: GalleryAsset) => { + setPurchasingAsset(null) + onSelect(asset) + onClose() + } + + return ( + <> + +
+ {categories.length > 0 && ( +
+ setSelectedCategory('ALL')} + className="text-xs" + > + همه + + {categories.map((cat) => ( + setSelectedCategory(cat)} + className="text-xs" + > + {cat} + + ))} +
+ )} + +
+ {isLoading ? ( +
+ {[...Array(8)].map((_, idx) => ( +
+ ))} +
+ ) : assets.length === 0 ? ( +
+ +

هیچ تصویری پیدا نشد

+
+ ) : ( +
+ {assets.map((asset) => ( + handleItemClick(asset)} + /> + ))} +
+ )} +
+
+ + + setPurchasingAsset(null)} + asset={purchasingAsset} + userCoins={user?.coins ?? 0} + isVip={Boolean(isVip)} + onPurchaseSuccess={handlePurchaseSuccess} + onSelectDirectly={handleSelectDirectly} + /> + + ) +} + +function GalleryPinterestItem({ + asset, + isSelected, + onClick, +}: { + asset: GalleryAsset + isSelected: boolean + onClick: () => void +}) { + const [loaded, setLoaded] = useState(false) + const [error, setError] = useState(false) + const imgRef = useRef(null) + + const loadContent = () => { + if (imgRef.current) { + imgRef.current.src = asset.previewUrl || asset.url + } + } + + const elementRef = useLazyLoad(loadContent) + + const itemOutlineStyle = isSelected + ? 'ring-2 ring-primary ring-offset-2 ring-offset-base-100' + : 'ring-1 ring-base-content/10 hover:ring-primary/70' + + return ( +
+ {!loaded && ( +
+
+
+ )} + + {error && ( +
+ +

خطا در بارگذاری

+
+ )} + + {asset.title { + setLoaded(true) + setError(false) + }} + onError={() => { + setLoaded(true) + setError(true) + }} + className="object-cover w-full h-auto transition-transform duration-300 group-hover:scale-103" + style={{ opacity: loaded && !error ? 1 : 0 }} + /> + + {loaded && !error && ( + <> +
+ + {asset.title || ''} + + + {asset.price > 0 && !asset.isOwned ? ( +
+ +
+ ) : null} +
+ + {isSelected && ( +
+ +
+ )} + + {asset.accessVip && !asset.isOwned && ( +
+ +
+ )} + + {asset.isOwned && !isSelected && ( +
+ + خریداری شده +
+ )} + + )} +
+ ) +} diff --git a/src/components/gallery/index.ts b/src/components/gallery/index.ts new file mode 100644 index 000000000..a9ac1f6e7 --- /dev/null +++ b/src/components/gallery/index.ts @@ -0,0 +1,2 @@ +export * from './gallery-picker-modal' +export * from './gallery-asset-purchase-modal' diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index e67bae401..1d2241883 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -43,6 +43,8 @@ export * from './offline-indicator/offline-indicator' export * from './pagination/pagination' +export * from './popover-menu' + export * from './portal/portal' export * from './section-panel/section-panel' diff --git a/src/components/ui/modal/modal.tsx b/src/components/ui/modal/modal.tsx index 848bccab3..5e28d6bc6 100644 --- a/src/components/ui/modal/modal.tsx +++ b/src/components/ui/modal/modal.tsx @@ -45,9 +45,9 @@ export function Modal({ if (!dialog) return if (isOpen) { - if (!dialog.open) dialog.showModal() - } else if (dialog.open) { - dialog.close() + dialog.setAttribute('open', '') + } else { + dialog.removeAttribute('open') } }, [isOpen]) @@ -55,6 +55,7 @@ export function Modal({ const dialog = dialogRef.current if (!dialog) return const handleCancel = (e: Event) => { + if (e.target !== dialog) return e.preventDefault() // keep it mounted so the exit animation can play onClose() } diff --git a/src/components/ui/modal/modal.variants.ts b/src/components/ui/modal/modal.variants.ts index 8e906ecce..01db276e5 100644 --- a/src/components/ui/modal/modal.variants.ts +++ b/src/components/ui/modal/modal.variants.ts @@ -60,6 +60,7 @@ export const modalDialogVariants = cva([ 'modal-middle', 'p-2', 'md:p-4', + 'z-[1000]', ]) export type ModalDialogVariant = typeof modalDialogVariants diff --git a/src/components/ui/popover-menu/index.ts b/src/components/ui/popover-menu/index.ts new file mode 100644 index 000000000..376fa7f08 --- /dev/null +++ b/src/components/ui/popover-menu/index.ts @@ -0,0 +1,3 @@ +export * from './popover-menu' +export * from './popover-menu-item' +export * from './popover-menu.variants' diff --git a/src/components/ui/popover-menu/popover-menu-item.tsx b/src/components/ui/popover-menu/popover-menu-item.tsx new file mode 100644 index 000000000..a24409e07 --- /dev/null +++ b/src/components/ui/popover-menu/popover-menu-item.tsx @@ -0,0 +1,54 @@ +import { cn } from '@/common/utils/cn' +import { popoverMenuItemVariants } from './popover-menu.variants' + +export interface PopoverMenuItemProps { + icon?: React.ReactNode + label: string + badge?: React.ReactNode + onClick?: () => void + variant?: 'default' | 'danger' | 'primary' + disabled?: boolean + className?: string +} + +export function PopoverMenuItem({ + icon, + label, + badge, + onClick, + variant = 'default', + disabled = false, + className, +}: PopoverMenuItemProps) { + return ( + + ) +} + +export function PopoverMenuDivider() { + return
+} + +export function PopoverMenuHeader({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/ui/popover-menu/popover-menu.tsx b/src/components/ui/popover-menu/popover-menu.tsx new file mode 100644 index 000000000..3fb4c4640 --- /dev/null +++ b/src/components/ui/popover-menu/popover-menu.tsx @@ -0,0 +1,143 @@ +import { useEffect, useRef, useState } from 'react' +import { Portal } from '@/components/ui/portal/portal' +import { cn } from '@/common/utils/cn' +import { popoverMenuVariants } from './popover-menu.variants' + +export interface PopoverMenuProps { + isOpen: boolean + onClose: () => void + position?: { x: number; y: number } | null + triggerRef?: React.RefObject + children: React.ReactNode + className?: string + width?: number | string + placement?: + | 'bottom-start' + | 'bottom-end' + | 'bottom-center' + | 'top-start' + | 'top-end' + | 'top-center' + offset?: number +} + +export function PopoverMenu({ + isOpen, + onClose, + position, + triggerRef, + children, + className, + width = 208, + placement = 'bottom-center', + offset = 8, +}: PopoverMenuProps) { + const menuRef = useRef(null) + const [coords, setCoords] = useState<{ top: number; left: number } | null>(null) + + useEffect(() => { + if (!isOpen) { + setCoords(null) + return + } + + const computePosition = () => { + if (position) { + const numericWidth = typeof width === 'number' ? width : 208 + const left = Math.min( + Math.max(12, position.x), + window.innerWidth - numericWidth - 12 + ) + const top = Math.min(Math.max(12, position.y), window.innerHeight - 260) + setCoords({ top, left }) + return + } + + if (triggerRef?.current) { + const rect = triggerRef.current.getBoundingClientRect() + const numericWidth = typeof width === 'number' ? width : 208 + let left = rect.left + rect.width / 2 - numericWidth / 2 + let top = rect.bottom + offset + + if (placement === 'bottom-start') { + left = rect.left + } else if (placement === 'bottom-end') { + left = rect.right - numericWidth + } else if (placement.startsWith('top')) { + top = rect.top - offset - 100 + if (placement === 'top-start') left = rect.left + if (placement === 'top-end') left = rect.right - numericWidth + } + + left = Math.min(Math.max(12, left), window.innerWidth - numericWidth - 12) + top = Math.min(Math.max(12, top), window.innerHeight - 180) + + setCoords({ top, left }) + } + } + + computePosition() + + const handleClickOutside = (e: MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + triggerRef?.current && + !triggerRef.current.contains(e.target as Node) + ) { + onClose() + } else if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + !triggerRef + ) { + onClose() + } + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + const handleScroll = () => { + onClose() + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleKeyDown) + window.addEventListener('scroll', handleScroll, true) + window.addEventListener('resize', computePosition) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('scroll', handleScroll, true) + window.removeEventListener('resize', computePosition) + } + }, [isOpen, position, triggerRef, width, placement, offset, onClose]) + + if (!isOpen || !coords) return null + + return ( + +
e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > + {children} +
+
+ ) +} diff --git a/src/components/ui/popover-menu/popover-menu.variants.ts b/src/components/ui/popover-menu/popover-menu.variants.ts new file mode 100644 index 000000000..9223a3e40 --- /dev/null +++ b/src/components/ui/popover-menu/popover-menu.variants.ts @@ -0,0 +1,50 @@ +import { cva } from 'class-variance-authority' + +export const popoverMenuVariants = cva([ + 'bg-content', + 'bg-glass', + 'rounded-3xl', + 'shadow-2xl', + 'border', + 'border-base-content/10', + 'p-2', + 'text-right', + 'text-xs', + 'flex', + 'flex-col', + 'gap-1', + 'animate-in', + 'fade-in', + 'zoom-in-95', + 'duration-150', +]) + +export const popoverMenuItemVariants = cva( + [ + 'flex', + 'items-center', + 'justify-between', + 'w-full', + 'px-2.5', + 'py-2', + 'rounded-xl', + 'font-medium', + 'transition-colors', + 'text-right', + 'cursor-pointer', + 'disabled:opacity-40', + 'disabled:cursor-not-allowed', + ], + { + variants: { + variant: { + default: 'text-content hover:bg-base-content/10 active:bg-base-300', + danger: 'text-error hover:bg-error/10 active:bg-error/20', + primary: 'text-primary hover:bg-primary/10 active:bg-primary/20', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) diff --git a/src/components/ui/portal/portal.tsx b/src/components/ui/portal/portal.tsx index ffc3378a7..15b548c44 100644 --- a/src/components/ui/portal/portal.tsx +++ b/src/components/ui/portal/portal.tsx @@ -40,23 +40,20 @@ export function raiseToTopLayer(host: HTMLElement | null) { if (host.parentElement !== parent) { parent.appendChild(host) - - if (parent !== document.body) { - parent.addEventListener('close', () => raiseToTopLayer(host), { - once: true, - }) - } } if (typeof host.showPopover !== 'function') return try { - host.hidePopover() - } catch {} - - try { - host.showPopover() - } catch {} + if (!host.matches(':popover-open')) { + host.showPopover() + } + } catch { + try { + host.hidePopover() + host.showPopover() + } catch {} + } } type TopLayerHostProps = Pick diff --git a/src/layouts/bookmark/components/bookmark-icon.picker.tsx b/src/layouts/bookmark/components/bookmark-icon.picker.tsx index 0e02e2028..649138831 100644 --- a/src/layouts/bookmark/components/bookmark-icon.picker.tsx +++ b/src/layouts/bookmark/components/bookmark-icon.picker.tsx @@ -3,30 +3,37 @@ import { getFaviconFromUrl } from '@/common/utils/icon' import { Icon } from '@/src/icons' import type React from 'react' import { useRef, useState } from 'react' +import { PopoverMenu, PopoverMenuItem, PopoverMenuDivider } from '@/components/ui' +import { GalleryPickerModal } from '@/components/gallery/gallery-picker-modal' +import type { GalleryAsset } from '@/services/hooks/gallery/get-gallery-assets.hook' type Props = { value: File | string | null url?: string | null - onChange: (file: File | null) => void + onChange: (value: File | string | null) => void + size?: 'md' | 'lg' } -export function BookmarkIconPicker({ value, url, onChange }: Props) { +export function BookmarkIconPicker({ value, url, onChange, size = 'md' }: Props) { + const triggerRef = useRef(null) const fileInputRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [isGalleryOpen, setIsGalleryOpen] = useState(false) const [isDragging, setIsDragging] = useState(false) const [error, setError] = useState(false) const openPicker = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - if (fileInputRef.current) { - fileInputRef.current.value = '' - fileInputRef.current.click() - } + setIsMenuOpen(true) } const handleFile = (file?: File) => { if (!file || !file.type.startsWith('image/')) - return showToast('فرمت نامعتبر است', 'error') + return showToast('فرمت فایل نامعتبر است', 'error') + if (file.size > 250 * 1024) { + return showToast('حجم فایل آیکون نباید بیشتر از ۲۵۰ کیلوبایت باشد', 'error') + } setError(false) onChange(file) } @@ -52,18 +59,27 @@ export function BookmarkIconPicker({ value, url, onChange }: Props) { } } + const handleGallerySelect = (asset: GalleryAsset) => { + setIsGalleryOpen(false) + setError(false) + onChange(asset.url) + } + const isFile = value instanceof File let iconSrc: string | null = null if (value && isFile) { iconSrc = URL.createObjectURL(value) - } else { - if (typeof value === 'string' && value) { - iconSrc = value - } else if (url && url !== 'null') { - iconSrc = getFaviconFromUrl(url || '') - } + } else if (typeof value === 'string' && value) { + iconSrc = value + } else if (url && url !== 'null') { + iconSrc = getFaviconFromUrl(url || '') } + const isLarge = size === 'lg' + const containerSizeClasses = isLarge + ? 'w-16 h-16 rounded-2xl' + : 'w-12 h-12 rounded-xl' + return ( <> - - {isFile && ( + {Boolean(value) && ( )} - +
+ + setIsMenuOpen(false)} + triggerRef={triggerRef} + width={190} + placement="bottom-start" + > + } + label="بارگذاری از دستگاه" + onClick={() => { + setIsMenuOpen(false) + if (fileInputRef.current) { + fileInputRef.current.value = '' + fileInputRef.current.click() + } + }} + /> + } + label="انتخاب از گالری" + onClick={() => { + setIsMenuOpen(false) + setIsGalleryOpen(true) + }} + /> + {Boolean(value) && ( + <> + + } + label="حذف آیکون" + variant="danger" + onClick={() => { + setIsMenuOpen(false) + handleRemove({ + preventDefault: () => {}, + stopPropagation: () => {}, + } as any) + }} + /> + + )} + + + {isGalleryOpen && ( + setIsGalleryOpen(false)} + type="BOOKMARK_ICON" + title="گالری آیکون بوکمارک" + onSelect={handleGallerySelect} + /> + )} ) } diff --git a/src/layouts/bookmark/components/bookmark/bookmark-icon.tsx b/src/layouts/bookmark/components/bookmark/bookmark-icon.tsx index ed767cbe1..fdd1c60a1 100644 --- a/src/layouts/bookmark/components/bookmark/bookmark-icon.tsx +++ b/src/layouts/bookmark/components/bookmark/bookmark-icon.tsx @@ -69,7 +69,6 @@ export function BookmarkIcon({ bookmark }: { bookmark: Bookmark }) { ? bookmark.customTextColor : 'rgba(255, 255, 255, 1)' const colorClass = hasCustomColors ? '' : getColorFromTitle(bookmark.title) - return (
{typeof displayIcon === 'string' && !imageError ? ( diff --git a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx index 60a59b2ef..08835b81c 100644 --- a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx +++ b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx @@ -28,7 +28,7 @@ export interface BookmarkCreateFormFields { customBackground: string | null customTextColor: string | null sticker: string | null - icon: File | null + icon: File | string | null widgetId?: string | null } @@ -100,7 +100,7 @@ export function AddBookmarkModal({ setFormData((prev) => ({ ...prev, url: newUrl, - icon: null, + icon: prev.icon, title: prev.title.trim() === '' && hostName ? hostName : prev.title, })) } @@ -146,7 +146,7 @@ export function AddBookmarkModal({ ...prev, title: suggestion.title, url: suggestion.url, - icon: null, + icon: suggestion.icon || null, })) } @@ -194,48 +194,44 @@ export function AddBookmarkModal({
e.stopPropagation()} - className="flex flex-col justify-between gap-2 overflow-y-auto h-96" + className="flex flex-col gap-4" > -
- - {onOpenImport && ( - - )} -
- updateFormData('title', v)} - className="w-full px-4 py-3 text-right transition-all duration-200 rounded-lg" - /> + +
+
updateFormData('icon', value)} + onChange={(value) => { + updateFormData('icon', value) + }} value={formData.icon} url={formData.url} /> + +
+ updateFormData('title', v)} + className="w-full px-3.5 py-2.5 text-right transition-all duration-200 rounded-xl" + /> +
{type === 'BOOKMARK' && ( -
+
handleUrlChange(v)} - className="w-full px-4 py-3 text-right transition-all duration-200 rounded-lg" + className="w-full px-3.5 py-2.5 text-left font-mono text-xs transition-all duration-200 rounded-xl" />
)} @@ -245,13 +241,26 @@ export function AddBookmarkModal({ )}
-
+ {onOpenImport && ( +
+ +
+ )} + +
-
+
- )} - - + /> + )} -
+ } + label="ویرایش" + onClick={() => { + onEdit() + onClose() + }} + /> - -
- + + + } + label="حذف" + variant="danger" + onClick={() => { + onDelete() + onClose() + }} + /> + ) } diff --git a/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx b/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx index 36e4430c1..ff56942f4 100644 --- a/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx +++ b/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx @@ -3,7 +3,7 @@ import { Button, Modal } from '@/components/ui' import { TextInput } from '@/components/ui' import { useEffect, useState } from 'react' import type { Bookmark } from '../../types/bookmark.types' -import { type IconSourceType, ShowAdvancedButton } from '../shared' +import { ShowAdvancedButton } from '../shared' import { AdvancedModal } from './advanced.modal' import { useIsMutating } from '@tanstack/react-query' import { BookmarkIconPicker } from '../bookmark-icon.picker' @@ -24,7 +24,7 @@ export interface BookmarkUpdateFormFields { customBackground: string | null customTextColor: string | null sticker: string | null - icon: File | null + icon: File | string | null isDeletedIcon: boolean } @@ -57,7 +57,6 @@ export function EditBookmarkModal({ const isUpdating = useIsMutating({ mutationKey: ['updateBookmark'] }) > 0 - const [iconSource, setIconSource] = useState('auto') const [showAdvanced, setShowAdvanced] = useState(false) const [icon, setIcon] = useState(null) @@ -86,14 +85,6 @@ export function EditBookmarkModal({ onlineId: bookmark.onlineId || undefined, isDeletedIcon: formData.isDeletedIcon, }) - // onClose() - } - - const onRemoveIcon = () => { - setIcon(null) - updateFormData('icon', null) - updateFormData('isDeletedIcon', true) - setIconSource('auto') } const handleAdvancedModalClose = ( @@ -134,8 +125,6 @@ export function EditBookmarkModal({ url: bookmark.url, isDeletedIcon: false, }) - setIconSource(bookmark.icon ? 'upload' : 'auto') - console.log(bookmark) if (bookmark.icon) { setIcon(bookmark.icon) } else if (bookmark.type === 'BOOKMARK' && bookmark.url) { @@ -157,72 +146,78 @@ export function EditBookmarkModal({ className="overflow-y-hidden!" closeOnBackdropClick={false} > -
-
-
-
- updateFormData('icon', value)} - value={icon} - url={formData.url} + { + e.preventDefault() + handleSave() + }} + onContextMenu={(e) => e.stopPropagation()} + className="flex flex-col gap-4" + > +
+
+ { + updateFormData('icon', value) + if (value) { + updateFormData('isDeletedIcon', false) + setIcon(value) + } else { + updateFormData('isDeletedIcon', true) + setIcon(null) + } + }} + value={icon} + url={formData.url} + /> + +
+ updateFormData('title', value)} + className="w-full px-3.5 py-2.5 text-right transition-all duration-200 rounded-xl" /> - {iconSource === 'upload' && Boolean(icon) && ( - - )}
- updateFormData('title', value)} - className={ - 'w-full px-4 py-3 text-right rounded-lg transition-all duration-200' - } - /> - -
- {type === 'BOOKMARK' && ( + {type === 'BOOKMARK' && ( +
updateFormData('url', value)} - className={ - 'w-full px-4 py-3 text-right absolute rounded-lg transition-all duration-300' - } + className="w-full px-3.5 py-2.5 text-left font-mono text-xs transition-all duration-200 rounded-xl" /> - )} -
+
+ )}
-
+
-
+
-
+ ) diff --git a/src/layouts/bookmark/context/bookmark.context.tsx b/src/layouts/bookmark/context/bookmark.context.tsx index d2fa12590..a16fce848 100644 --- a/src/layouts/bookmark/context/bookmark.context.tsx +++ b/src/layouts/bookmark/context/bookmark.context.tsx @@ -21,7 +21,7 @@ import { useGetBookmarks, } from '@/services/hooks/bookmark/get-bookmarks.hook' -const MAX_ICON_SIZE = 1 * 1024 * 1024 // 1 MB +const MAX_ICON_SIZE = 250 * 1024 // 250 KB export interface BookmarkStoreContext { bookmarks: Bookmark[] @@ -197,9 +197,12 @@ export const BookmarkProvider: React.FC<{ children: React.ReactNode }> = ({ return showToast(translateError('UNAUTHORIZED') as string, 'error') try { - if (inputBookmark.icon && inputBookmark.icon.size > MAX_ICON_SIZE) { - showToast(translateError('FILE_SIZE_EXCEEDED') as string, 'error') - cb() + if ( + inputBookmark.icon && + inputBookmark.icon instanceof File && + inputBookmark.icon.size > MAX_ICON_SIZE + ) { + showToast('حجم فایل آیکون نباید بیشتر از ۲۵۰ کیلوبایت باشد', 'error') return } @@ -312,12 +315,22 @@ export const BookmarkProvider: React.FC<{ children: React.ReactNode }> = ({ if (!input.title?.trim() || !bookmarks) return + if ( + input.icon && + input.icon instanceof File && + input.icon.size > MAX_ICON_SIZE + ) { + showToast('حجم فایل آیکون نباید بیشتر از ۲۵۰ کیلوبایت باشد', 'error') + return + } + const foundedBookmark = bookmarks.find( (b) => b.id === input.id || (typeof b.onlineId === 'string' && b.onlineId === input.onlineId) ) - if (!foundedBookmark) return showToast(translateError('ITEM_NOT_FOUND') as string, 'error') + if (!foundedBookmark) + return showToast(translateError('ITEM_NOT_FOUND') as string, 'error') let bookmarkIdToEdit = input.id if (validate(bookmarkIdToEdit)) { diff --git a/src/layouts/market/other-items/components/market-item-purchase-modal.tsx b/src/layouts/market/other-items/components/market-item-purchase-modal.tsx index 2e6696ecd..1e03a8217 100644 --- a/src/layouts/market/other-items/components/market-item-purchase-modal.tsx +++ b/src/layouts/market/other-items/components/market-item-purchase-modal.tsx @@ -1,12 +1,11 @@ import Analytics from '@/analytics' -import { ItemPrice } from '@/components/item-price/item-price' import { Button, Modal } from '@/components/ui' +import { UserCoin } from '@/layouts/setting/tabs/account/components/user-coin' import type { MarketItem } from '@/services/hooks/market/market.interface' import { usePurchaseMarketItem } from '@/services/hooks/market/purchase-market-item.hook' import { translateError } from '@/common/utils/translate-error' import { showToast } from '@/common/toast' import { RenderPreview } from './render-preview' -import { Icon } from '@/src/icons' interface MarketItemPurchaseModalProps { isOpen: boolean @@ -28,7 +27,6 @@ export function MarketItemPurchaseModal({ if (!item) return null const canAfford = userCoins >= item.price - const remainingCoins = userCoins - item.price const handlePurchase = () => { if (!canAfford) return @@ -37,7 +35,7 @@ export function MarketItemPurchaseModal({ { itemId: item.id }, { onSuccess: (_response) => { - showToast(`${item.name} با موفقیت خریداری شد! 🎉`, 'success', { + showToast(`${item.name} برای همیشه خریداری شد`, 'success', { alarmSound: true, }) Analytics.event('market_item_purchased') @@ -58,84 +56,69 @@ export function MarketItemPurchaseModal({ onClose(false)} - title="تایید خرید" + title=" " size="md" direction="rtl" closeOnBackdropClick={!isPending} showCloseButton={!isPending} > -
-
+
+
{}} /> -
-

+

+ +
+
+

{item.name}

- {item.description && ( -

- {item.description} -

+ {item.price > 0 && ( + )}
-
- -
-
- موجودی فعلی - -
-
- قیمت آیتم - -
-
- - موجودی باقی‌مانده - - -
+

+ {item.description || + 'این آیتم را با ویج‌کوین باز کنید و برای همیشه از آن استفاده کنید'} +

{!canAfford && ( -
- -
-

- موجودی ناکافی -

-

- برای خرید این آیتم به {item.price - userCoins} ویج‌کوین - بیشتر نیاز دارید -

-
-
- )} - -
- {!canAfford && ( +
+ + موجودی ویج‌کوین ناکافیه ({item.price - userCoins} ویج‌کوین کسری + داری) + - )} +
+ )} + +
+
diff --git a/src/layouts/setting/tabs/account/components/profile-display.tsx b/src/layouts/setting/tabs/account/components/profile-display.tsx index 922b678f3..10240c3d2 100644 --- a/src/layouts/setting/tabs/account/components/profile-display.tsx +++ b/src/layouts/setting/tabs/account/components/profile-display.tsx @@ -13,9 +13,13 @@ import { ChangeOccupationModal } from './modals/edit-occupation' import { ChangeInterestsModal } from './modals/edit-interests' import { ChangeNameModal } from './modals/edit-name' import { ChangeCityModal } from './modals/edit-city' -import { EditAvatarModal } from './modals/avatar/edit.avatar' import { AddEmailModal } from './modals/add-email.modal' import { ChangeUsernameModal } from './modals/edit-username' +import { AvatarCropModal } from './modals/avatar/avatar-crop.modal' +import { GalleryPickerModal } from '@/components/gallery/gallery-picker-modal' +import type { GalleryAsset } from '@/services/hooks/gallery/get-gallery-assets.hook' +import { useUpdateUserProfile } from '@/services/hooks/auth/auth-service.hook' +import { showToast } from '@/common/toast' import { Icon } from '@/src/icons' import { useState } from 'react' @@ -40,7 +44,9 @@ const formatJalaliDate = (dateString: string | null | undefined): string => { export const ProfileDisplay = () => { const { refetchUser, user } = useAuth() const [showModal, setShowModal] = useState(false) - const [showAvatar, setShowAvatar] = useState(false) + const [showGallery, setShowGallery] = useState(false) + const [cropImage, setCropImage] = useState(null) + const updateProfileMutation = useUpdateUserProfile() const genderInfo = getGenderInfo(user?.gender) const showEditBadge = (field: string) => { @@ -61,14 +67,66 @@ export const ProfileDisplay = () => { Analytics.event('open_add_phone_modal') } - const onClickAvatar = () => { - setShowAvatar(true) - Analytics.event('edit_avatar_opened') + const handleUploadFile = (file: File) => { + if (file.size > 2 * 1024 * 1024) { + showToast('فایل بزرگتر از ۲ مگابایت است', 'error') + return + } + const validTypes = ['image/png', 'image/jpeg', 'image/webp'] + if (!validTypes.includes(file.type)) { + showToast('فرمت فایل نامعتبر است', 'error') + return + } + setCropImage(URL.createObjectURL(file)) + Analytics.event('edit_avatar_file_selected') + } + + const handleCropComplete = async (croppedFile: File) => { + if (cropImage) { + URL.revokeObjectURL(cropImage) + setCropImage(null) + } + try { + const formData = new FormData() + formData.append('avatar', croppedFile) + await updateProfileMutation.mutateAsync(formData) + await refetchUser() + Analytics.event('avatar_updated') + } catch { + showToast('خطا در بارگذاری تصویر', 'error') + } + } + + const handleCropCancel = () => { + if (cropImage) { + URL.revokeObjectURL(cropImage) + setCropImage(null) + } + } + + const onSelectAvatarAsset = async (asset: GalleryAsset) => { + setShowGallery(false) + try { + const formData = new FormData() + formData.append('avatarKey', asset.id) + await updateProfileMutation.mutateAsync(formData) + await refetchUser() + Analytics.event('avatar_updated_from_gallery') + } catch { + showToast('خطا در تغییر آواتار', 'error') + } } return (
- + { + setShowGallery(true) + Analytics.event('gallery_avatar_opened') + }} + showEditBadge={showEditBadge} + />
{
)} - {showAvatar && ( - setShowAvatar(false)} show={true} /> + {cropImage && ( + )} + setShowGallery(false)} + type="AVATAR" + title="گالری آواتارها" + onSelect={onSelectAvatarAsset} + /> + onCloseModal()} />
) diff --git a/src/layouts/setting/tabs/account/components/profile-header.tsx b/src/layouts/setting/tabs/account/components/profile-header.tsx index 2dbe6d40c..52eb73ab6 100644 --- a/src/layouts/setting/tabs/account/components/profile-header.tsx +++ b/src/layouts/setting/tabs/account/components/profile-header.tsx @@ -1,5 +1,12 @@ +import { useRef, useState } from 'react' import moment from 'jalali-moment' -import { AvatarComponent, FloatingBadge, Tooltip } from '@/components/ui' +import { + AvatarComponent, + FloatingBadge, + PopoverMenu, + PopoverMenuItem, + Tooltip, +} from '@/components/ui' import { UserCoin } from './user-coin' import { Icon } from '@/src/icons' import { useAuth } from '@/context/auth.context' @@ -7,7 +14,8 @@ import { formatVipExpiryDate, formatVipRemaining } from '@/common/utils/vip-expi import { callEvent } from '@/common/utils/call-event' interface ProfileHeaderProps { - onClickAvatar: () => void + onUploadFile: (file: File) => void + onSelectFromGallery: () => void showEditBadge: (field: string) => boolean } const slots = [ @@ -20,8 +28,15 @@ const slots = [ { dx: 55, dy: 65, baseRotate: 10 }, { dx: -92, dy: 10, baseRotate: 25 }, ] -export const ProfileHeader = ({ onClickAvatar, showEditBadge }: ProfileHeaderProps) => { +export const ProfileHeader = ({ + onUploadFile, + onSelectFromGallery, + showEditBadge, +}: ProfileHeaderProps) => { const { user } = useAuth() + const [menuOpen, setMenuOpen] = useState(false) + const avatarAnchorRef = useRef(null) + const fileInputRef = useRef(null) const badges = user?.badges || [] @@ -30,8 +45,24 @@ export const ProfileHeader = ({ onClickAvatar, showEditBadge }: ProfileHeaderPro const vipRemaining = formatVipRemaining(user?.vipExpiresAt) const vipExpiryDate = formatVipExpiryDate(user?.vipExpiresAt) + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + onUploadFile(file) + } + e.target.value = '' + } + return (
+ +
@@ -71,23 +102,51 @@ export const ProfileHeader = ({ onClickAvatar, showEditBadge }: ProfileHeaderPro
-
+
setMenuOpen((prev) => !prev)} className="w-16 h-16 text-2xl transition-all cursor-pointer ring-4 ring-primary/20" />
+ setMenuOpen(false)} + triggerRef={avatarAnchorRef} + placement="bottom-center" + width={200} + > + } + label="بارگذاری از دستگاه" + onClick={() => { + setMenuOpen(false) + fileInputRef.current?.click() + }} + /> + } + label="انتخاب از گالری" + onClick={() => { + setMenuOpen(false) + onSelectFromGallery() + }} + /> + + {showEditBadge('avatar') && ( )} diff --git a/src/layouts/setting/tabs/wallpapers/components/coin-purchase-modal.tsx b/src/layouts/setting/tabs/wallpapers/components/coin-purchase-modal.tsx index 3fe106b53..c0160026e 100644 --- a/src/layouts/setting/tabs/wallpapers/components/coin-purchase-modal.tsx +++ b/src/layouts/setting/tabs/wallpapers/components/coin-purchase-modal.tsx @@ -20,14 +20,26 @@ export function CoinPurchaseModal({ onPurchase, isPurchasing = false, }: CoinPurchaseModalProps) { - const { isAuthenticated } = useAuth() + const { isAuthenticated, user } = useAuth() if (!wallpaper) return null + const userCoins = user?.coins || 0 + const wallpaperPrice = wallpaper.coin || 0 + const canAfford = userCoins >= wallpaperPrice + const onLogin = () => { onClose() callEvent('openProfile') } + const handleOpenCoins = () => { + onClose() + callEvent('openMarketModal') + setTimeout(() => { + callEvent('market_change_tab', 'coins') + }, 100) + } + return (
-
+
{wallpaper.type === 'IMAGE' ? ( {wallpaper.name ) : ( { e.stopPropagation() }} @@ -62,45 +74,67 @@ export function CoinPurchaseModal({ )}
-
+
-

- {wallpaper.name || 'تصویر تصویر زمینه'} +

+ {wallpaper.name || 'تصویر زمینه'}

- + {wallpaperPrice > 0 && ( + + )}
-

- این تصویر زمینه زیبا را با ویج‌کوین باز کنید و برای همیشه استفاده - 🎨 کنید +

+ این تصویر زمینه را با ویج‌کوین باز کنید و برای همیشه از آن استفاده + کنید

-
+ {isAuthenticated && !canAfford && wallpaperPrice > 0 && ( +
+ + موجودی ویج‌کوین ناکافیه ({wallpaperPrice - userCoins} ویج‌کوین + کسری داری) + + +
+ )} + +
{isAuthenticated ? ( <> ) : ( @@ -108,8 +142,8 @@ export function CoinPurchaseModal({ size="md" onClick={onLogin} className="w-full" - variant={'primary'} - rounded={'2xl'} + variant="primary" + rounded="2xl" > ورود به حساب کاربری diff --git a/src/layouts/widgets-manager/add-widget-modal/index.tsx b/src/layouts/widgets-manager/add-widget-modal/index.tsx index f1ad1ad06..d4340f832 100644 --- a/src/layouts/widgets-manager/add-widget-modal/index.tsx +++ b/src/layouts/widgets-manager/add-widget-modal/index.tsx @@ -23,7 +23,7 @@ import { AddWidgetActions } from './actions' export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalProps) { const { isVip } = useAuth() const { isWidgetVipOnly, isVariantVipOnly, isSizeVipOnly, maxFreeWidgets } = - useWidgetVipResolver() + useWidgetVipResolver(isOpen) const freeWidgets = useOptionalFreeWidgets() const runtimeLayout = freeWidgets?.runtimeLayout || [] diff --git a/src/layouts/widgets/canvas/canvas-context-menu.tsx b/src/layouts/widgets/canvas/canvas-context-menu.tsx index 6496dec2b..6ac04fe59 100644 --- a/src/layouts/widgets/canvas/canvas-context-menu.tsx +++ b/src/layouts/widgets/canvas/canvas-context-menu.tsx @@ -1,5 +1,9 @@ -import { useEffect, useRef } from 'react' import { Icon } from '@/src/icons' +import { + PopoverMenu, + PopoverMenuItem, + PopoverMenuDivider, +} from '@/components/ui' interface CanvasContextMenuProps { x: number @@ -24,108 +28,64 @@ export function CanvasContextMenu({ onOpenAppearanceSettings, onOpenHelp, }: CanvasContextMenuProps) { - const menuRef = useRef(null) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - onClose() - } - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose() - } - } - - document.addEventListener('mousedown', handleClickOutside) - document.addEventListener('keydown', handleKeyDown) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - document.removeEventListener('keydown', handleKeyDown) - } - }, [onClose]) - - const adjustedLeft = Math.min(Math.max(10, x), window.innerWidth - 220) - const adjustedTop = Math.min(Math.max(10, y), window.innerHeight - 220) - return ( -
e.stopPropagation()} - onContextMenu={(e) => e.preventDefault()} + - + /> - + /> {onOpenPresets && ( - + /> )} - + /> {onOpenHelp && ( - + <> + + } + label="راهنمای ویجت‌ها" + onClick={() => { + onOpenHelp() + onClose() + }} + /> + )} -
+ ) } diff --git a/src/layouts/widgets/canvas/widget-context-menu.tsx b/src/layouts/widgets/canvas/widget-context-menu.tsx index daf6139d7..eee3d52c7 100644 --- a/src/layouts/widgets/canvas/widget-context-menu.tsx +++ b/src/layouts/widgets/canvas/widget-context-menu.tsx @@ -1,7 +1,12 @@ -import { useEffect, useRef } from 'react' import { Icon } from '@/src/icons' import type { StoredWidget, WidgetDefinition, WidgetSize } from '../layout-engine/types' -import { Chip, VipBadge } from '@/components/ui' +import { + Chip, + PopoverMenu, + PopoverMenuItem, + PopoverMenuDivider, + VipBadge, +} from '@/components/ui' import { useAuth } from '@/context/auth.context' import { useWidgetVipResolver } from '@/services/hooks/widgets/widget-catalog.hook' import { callEvent } from '@/common/utils/call-event' @@ -36,51 +41,20 @@ export function WidgetContextMenu({ onEditVariant, onDelete, }: WidgetContextMenuProps) { - const menuRef = useRef(null) const { isVip } = useAuth() const { isSizeVipOnly } = useWidgetVipResolver() - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - onClose() - } - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose() - } - } - - document.addEventListener('mousedown', handleClickOutside) - document.addEventListener('keydown', handleKeyDown) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - document.removeEventListener('keydown', handleKeyDown) - } - }, [onClose]) - const hasVariants = Boolean(definition.variants && definition.variants.length > 0) const fittingSizes = definition.allowedSizes.filter((s) => s.w <= cols) const showResize = (!hasVariants || definition.canResize === true) && fittingSizes.length > 1 - const adjustedLeft = Math.min(Math.max(10, x), window.innerWidth - 220) - const adjustedTop = Math.min(Math.max(10, y), window.innerHeight - 260) - return ( -
e.stopPropagation()} - onContextMenu={(e) => e.preventDefault()} +
@@ -137,39 +111,35 @@ export function WidgetContextMenu({
)} -
+ {onMove && ( - + /> )} {onEditVariant && ( - + /> )} {onDuplicate && definition.canDuplicate && ( - + /> )} {onSettings && ( - + /> )} -
+ - -
+ /> + ) } diff --git a/src/layouts/widgets/photo/photo.widget.tsx b/src/layouts/widgets/photo/photo.widget.tsx index 203391578..7156b232d 100644 --- a/src/layouts/widgets/photo/photo.widget.tsx +++ b/src/layouts/widgets/photo/photo.widget.tsx @@ -10,7 +10,16 @@ import { translateError } from '@/common/utils/translate-error' import { safeAwait } from '@/services/api' import { uploadWidgetMediaApi } from '@/services/hooks/widgets/widget-media.hook' import { callEvent } from '@/common/utils/call-event' +import { GalleryPickerModal } from '@/components/gallery' +import { + PopoverMenu, + PopoverMenuItem, + PopoverMenuDivider, + PopoverMenuHeader, + VipBadge, +} from '@/components/ui' import type { AxiosError } from 'axios' +import type { GalleryAsset } from '@/services/hooks/gallery/get-gallery-assets.hook' interface PhotoWidgetProps { size?: WidgetSize @@ -18,12 +27,20 @@ interface PhotoWidgetProps { instanceId?: string } -export function PhotoWidget({ size = { w: 2, h: 2 }, meta, instanceId }: PhotoWidgetProps) { +export function PhotoWidget({ + size = { w: 2, h: 2 }, + meta, + instanceId, +}: PhotoWidgetProps) { const { updateWidgetSettings } = useFreeWidgets() const { canvasMode } = useAppearance() const { isVip } = useAuth() const inputRef = useRef(null) + const triggerRef = useRef(null) + const [isUploading, setIsUploading] = useState(false) + const [isGalleryOpen, setIsGalleryOpen] = useState(false) + const [isMenuOpen, setIsMenuOpen] = useState(false) const is1x1 = size.w === 1 && size.h === 1 const imageSrc = meta?.imageSrc @@ -61,75 +78,142 @@ export function PhotoWidget({ size = { w: 2, h: 2 }, meta, instanceId }: PhotoWi } updateWidgetSettings(instanceId, { imageSrc: res.url }) - showToast('عکس با موفقیت ذخیره شد', 'success') e.target.value = '' } - const handleClick = () => { - if (canvasMode !== 'edit' && !isUploading) { - if (!isVip) { - callEvent('openSettings', 'vip') - return - } - inputRef.current?.click() + const handleOpenMenu = (e: React.MouseEvent) => { + e.stopPropagation() + if (canvasMode === 'edit' || isUploading) return + setIsMenuOpen(true) + } + + const handleSelectFromSystem = () => { + setIsMenuOpen(false) + if (!isVip) { + callEvent('openSettings', 'vip') + return } + inputRef.current?.click() } - return ( - - + const handleOpenGallery = () => { + setIsMenuOpen(false) + setIsGalleryOpen(true) + } - {imageSrc ? ( -
- عکس - {isUploading && ( -
- -
- )} -
- ) : ( - - )} -
+
+ + + + + setIsMenuOpen(false)} + triggerRef={triggerRef} + width={210} + placement="bottom-center" + > + + مدیریت قاب عکس + + + } + label="بارگذاری از دستگاه" + badge={!isVip ? : undefined} + onClick={handleSelectFromSystem} + /> + + } + label="گالری ویجتیفای" + onClick={handleOpenGallery} + /> + + {imageSrc && ( + <> + + } + label="حذف عکس فعلی" + variant="danger" + onClick={handleRemovePhoto} + /> + + )} + + + setIsGalleryOpen(false)} + type="PHOTO_FRAME" + title="گالری تصاویر قاب عکس" + onSelect={handleGallerySelect} + /> + ) } diff --git a/src/layouts/widgets/widget-registry.tsx b/src/layouts/widgets/widget-registry.tsx index 3faa50024..818ffbef9 100644 --- a/src/layouts/widgets/widget-registry.tsx +++ b/src/layouts/widgets/widget-registry.tsx @@ -483,9 +483,9 @@ export const WIDGET_DEFINITIONS: Record = { label: 'قاب عکس', emoji: '🖼️', category: 'lifestyle', - isVipOnly: true, + isVipOnly: false, allowedSizes: [ - { w: 1, h: 1 }, + { w: 1, h: 1, isVipOnly: true }, { w: 2, h: 1 }, { w: 2, h: 2 }, ], diff --git a/src/pages/root.tsx b/src/pages/root.tsx index 41e142886..5caad924c 100644 --- a/src/pages/root.tsx +++ b/src/pages/root.tsx @@ -41,7 +41,7 @@ export function RootLayout() {
- +
- +
) diff --git a/src/services/hooks/bookmark/add-bookmark.hook.ts b/src/services/hooks/bookmark/add-bookmark.hook.ts index fba372a59..8b972549e 100644 --- a/src/services/hooks/bookmark/add-bookmark.hook.ts +++ b/src/services/hooks/bookmark/add-bookmark.hook.ts @@ -11,7 +11,7 @@ export interface BookmarkCreationPayload { order: number | null customTextColor: string | null customBackground: string | null - icon: File | null + icon: File | string | null widgetId?: string | null } diff --git a/src/services/hooks/bookmark/update-bookmark.hook.ts b/src/services/hooks/bookmark/update-bookmark.hook.ts index ba6d79d32..919516953 100644 --- a/src/services/hooks/bookmark/update-bookmark.hook.ts +++ b/src/services/hooks/bookmark/update-bookmark.hook.ts @@ -9,7 +9,7 @@ export interface BookmarkUpdatePayload { sticker: string | null customTextColor: string | null customBackground: string | null - icon: File | null + icon: File | string | null isDeletedIcon: boolean } diff --git a/src/services/hooks/gallery/get-gallery-assets.hook.ts b/src/services/hooks/gallery/get-gallery-assets.hook.ts new file mode 100644 index 000000000..cc09c4386 --- /dev/null +++ b/src/services/hooks/gallery/get-gallery-assets.hook.ts @@ -0,0 +1,109 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { getMainClient } from '@/services/api' + +export type GalleryAssetType = 'PHOTO_FRAME' | 'BOOKMARK_ICON' | 'AVATAR' + +export interface GalleryAsset { + id: string + title: string | null + fileKey: string + url: string + previewUrl: string | null + type: GalleryAssetType + price: number + accessVip: boolean + category: string | null + isUnlocked: boolean + isOwned: boolean + usedCount: number + order: number + isActive: boolean + createdAt: string +} + +export interface GalleryPagination { + page: number + limit: number + total: number + totalPages: number +} + +export interface GalleryResponse { + data: { + assets: GalleryAsset[] + pagination: GalleryPagination + } +} + +export interface GetGalleryParams { + page?: number + limit?: number + type?: GalleryAssetType + category?: string +} + +export async function getGalleryAssets( + params: GetGalleryParams = {} +): Promise { + const client = getMainClient() + const { data } = await client.get('/gallery', { + params: { + page: params.page ?? 1, + limit: params.limit ?? 50, + ...(params.type ? { type: params.type } : {}), + ...(params.category ? { category: params.category } : {}), + }, + }) + return data +} + +export async function getGalleryCategories(type?: GalleryAssetType): Promise { + const client = getMainClient() + const { data } = await client.get<{ data: { categories: string[] } }>( + '/gallery/categories', + { + params: type ? { type } : {}, + } + ) + return data.data.categories +} + +export async function purchaseGalleryAsset( + assetId: string +): Promise<{ data: { success: boolean; asset: GalleryAsset } }> { + const client = getMainClient() + const { data } = await client.post<{ + data: { success: boolean; asset: GalleryAsset } + }>(`/gallery/${assetId}/purchase`) + return data +} + +export function useGetGalleryAssets(params: GetGalleryParams = {}, enabled = true) { + return useQuery({ + queryKey: ['gallery-assets', params], + queryFn: () => getGalleryAssets(params), + staleTime: 1000 * 60 * 5, + enabled, + }) +} + +export function useGetGalleryCategories(type?: GalleryAssetType, enabled = true) { + return useQuery({ + queryKey: ['gallery-categories', type], + queryFn: () => getGalleryCategories(type), + staleTime: 1000 * 60 * 10, + enabled, + }) +} + +export function usePurchaseGalleryAsset() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (assetId: string) => purchaseGalleryAsset(assetId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['gallery-assets'] }) + queryClient.invalidateQueries({ queryKey: ['getUser'] }) + }, + }) +} diff --git a/src/services/hooks/widgets/widget-catalog.hook.ts b/src/services/hooks/widgets/widget-catalog.hook.ts index a02d48c48..2927ee514 100644 --- a/src/services/hooks/widgets/widget-catalog.hook.ts +++ b/src/services/hooks/widgets/widget-catalog.hook.ts @@ -56,16 +56,17 @@ export async function getWidgetCatalogApi(): Promise { +export const useGetWidgetCatalog = (enabled = false) => { return useQuery({ queryKey: ['widgetCatalog'], queryFn: getWidgetCatalogApi, staleTime: 1000 * 60 * 30, + enabled, }) } -export function useWidgetVipResolver() { - const { data: serverCatalog } = useGetWidgetCatalog() +export function useWidgetVipResolver(enabled = false) { + const { data: serverCatalog } = useGetWidgetCatalog(enabled) const maxFreeWidgets = serverCatalog?.config?.maxFreeWidgets ?? DEFAULT_MAX_FREE_WIDGETS