icon selected
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { type FC, Fragment, type ReactNode, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import HeaderModal from './HeaderModal'
|
||||
|
||||
interface Props {
|
||||
@@ -21,37 +22,41 @@ const DefaulModal: FC<Props> = (props: Props) => {
|
||||
}, [props.open])
|
||||
|
||||
|
||||
return (
|
||||
const modalContent = props.open ? (
|
||||
<Fragment>
|
||||
{
|
||||
props.open && (
|
||||
<Fragment>
|
||||
<div style={{ maxWidth: props.width }} className='xl:justify-center xl:items-center items-end flex overflow-x-hidden overflow-y-auto fixed inset-0 z-[60] h-auto top-0 bottom-0 m-auto outline-none focus:outline-none xl:max-w-xl mx-auto'>
|
||||
<div className='relative xl:h-full h-[80%] bottom-0 left-0 flex xl:items-center sm:h-auto w-full xl:my-6 xl:p-2'>
|
||||
<div className='border-0 h-auto p-5 lg:min-w-full overflow-y-auto rounded-3xl rounded-b-none xl:rounded-b-3xl relative flex flex-col w-full bg-white outline-none focus:outline-none'>
|
||||
<div
|
||||
onClick={props.close}
|
||||
className='fixed size-full top-0 bottom-0 right-0 left-0 bg-black/40 inset-0 z-[9998]'
|
||||
/>
|
||||
<div
|
||||
style={{ maxWidth: props.width }}
|
||||
className='xl:justify-center xl:items-center items-end flex overflow-x-hidden overflow-y-auto fixed inset-0 z-[9999] h-auto top-0 bottom-0 m-auto outline-none focus:outline-none xl:max-w-xl mx-auto pointer-events-none'
|
||||
>
|
||||
<div
|
||||
className='relative xl:h-full h-[80%] bottom-0 left-0 flex xl:items-center sm:h-auto w-full xl:my-6 xl:p-2 pointer-events-auto'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='border-0 h-auto p-5 lg:min-w-full overflow-y-auto rounded-3xl rounded-b-none xl:rounded-b-3xl relative flex flex-col w-full bg-white outline-none focus:outline-none shadow-2xl'>
|
||||
|
||||
|
||||
{
|
||||
props.isHeader && props.title_header &&
|
||||
<div className='pb-3 border-b border-border'>
|
||||
<div className='h-[5px] w-[200px] mx-auto bg-[#D1D3D7] rounded-full mb-4 xl:hidden'></div>
|
||||
<HeaderModal close={props.close} label={props.title_header} />
|
||||
</div>
|
||||
}
|
||||
|
||||
{props.children}
|
||||
|
||||
|
||||
</div>
|
||||
{
|
||||
props.isHeader && props.title_header &&
|
||||
<div className='pb-3 border-b border-border'>
|
||||
<div className='h-[5px] w-[200px] mx-auto bg-[#D1D3D7] rounded-full mb-4 xl:hidden'></div>
|
||||
<HeaderModal close={props.close} label={props.title_header} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div onClick={props.close} className='fixed size-full top-0 bottom-0 right-0 bg-black/40 inset-0 z-50 '></div>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
{props.children}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
) : null
|
||||
|
||||
return createPortal(modalContent, document.body)
|
||||
}
|
||||
|
||||
export default DefaulModal
|
||||
@@ -25,6 +25,7 @@ type CategoryFormValues = {
|
||||
const CategoryFood: FC = () => {
|
||||
const [isActive, setIsActive] = useState<boolean>(true)
|
||||
const [iconFiles, setIconFiles] = useState<File[]>([])
|
||||
const [selectedIconUrl, setSelectedIconUrl] = useState<string>('')
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [filters, setFilters] = useState<{ search?: string | null }>({})
|
||||
|
||||
@@ -56,7 +57,7 @@ const CategoryFood: FC = () => {
|
||||
const categoryData: CreateCategoryType = {
|
||||
title: values.title,
|
||||
isActive,
|
||||
avatarUrl: avatarUrl || editingCategory?.avatarUrl || ''
|
||||
avatarUrl: avatarUrl || selectedIconUrl || editingCategory?.avatarUrl || ''
|
||||
}
|
||||
|
||||
if (editingCategory) {
|
||||
@@ -85,7 +86,9 @@ const CategoryFood: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (iconFiles.length > 0) {
|
||||
if (selectedIconUrl) {
|
||||
submitCategory(selectedIconUrl)
|
||||
} else if (iconFiles.length > 0) {
|
||||
singleUpload(iconFiles[0], {
|
||||
onSuccess: (response) => {
|
||||
const avatarUrl = response?.data?.url || ''
|
||||
@@ -105,6 +108,7 @@ const CategoryFood: FC = () => {
|
||||
formik.resetForm()
|
||||
setIsActive(true)
|
||||
setIconFiles([])
|
||||
setSelectedIconUrl('')
|
||||
setEditingCategory(null)
|
||||
}
|
||||
|
||||
@@ -115,9 +119,21 @@ const CategoryFood: FC = () => {
|
||||
})
|
||||
setIsActive(category.isActive)
|
||||
setIconFiles([])
|
||||
setSelectedIconUrl(category.avatarUrl || '')
|
||||
setEditingCategory(category)
|
||||
}
|
||||
|
||||
const handleIconUrlChange = (url: string) => {
|
||||
setSelectedIconUrl(url)
|
||||
setIconFiles([])
|
||||
formik.setFieldValue('icon', [])
|
||||
}
|
||||
|
||||
const handleIconFileChange = (files: File[]) => {
|
||||
setIconFiles(files)
|
||||
setSelectedIconUrl('')
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
deleteCategory(id, {
|
||||
onSuccess: () => {
|
||||
@@ -189,10 +205,12 @@ const CategoryFood: FC = () => {
|
||||
formik={formik}
|
||||
isActive={isActive}
|
||||
onActiveChange={setIsActive}
|
||||
onIconChange={setIconFiles}
|
||||
onIconChange={handleIconFileChange}
|
||||
iconFiles={iconFiles}
|
||||
isSubmitting={isCreating || isUpdating || isUploading}
|
||||
isEditing={!!editingCategory}
|
||||
onIconUrlChange={handleIconUrlChange}
|
||||
selectedIconUrl={selectedIconUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { type FC } from 'react'
|
||||
import { Add } from 'iconsax-react'
|
||||
import { type FC, useState } from 'react'
|
||||
import { Add, Image } from 'iconsax-react'
|
||||
import Input from '@/components/Input'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import SwitchComponent from '@/components/Switch'
|
||||
import Button from '@/components/Button'
|
||||
import type { FormikProps } from 'formik'
|
||||
import { useGetIcons } from '../hooks/useFoodData'
|
||||
import IconSelectModal from './IconSelectModal'
|
||||
|
||||
type CategoryFormValues = {
|
||||
title: string
|
||||
@@ -15,21 +16,34 @@ type Props = {
|
||||
formik: FormikProps<CategoryFormValues>
|
||||
isActive: boolean
|
||||
onActiveChange: (value: boolean) => void
|
||||
onIconChange: (files: File[]) => void
|
||||
iconFiles: File[]
|
||||
onIconChange?: (files: File[]) => void
|
||||
iconFiles?: File[]
|
||||
isSubmitting: boolean
|
||||
isEditing?: boolean
|
||||
onIconUrlChange?: (url: string) => void
|
||||
selectedIconUrl?: string
|
||||
}
|
||||
|
||||
const CategoryForm: FC<Props> = ({
|
||||
formik,
|
||||
isActive,
|
||||
onActiveChange,
|
||||
onIconChange,
|
||||
iconFiles,
|
||||
isSubmitting,
|
||||
isEditing = false
|
||||
isEditing = false,
|
||||
onIconUrlChange,
|
||||
selectedIconUrl
|
||||
}) => {
|
||||
const { data: iconsData } = useGetIcons()
|
||||
const icons = iconsData?.data || []
|
||||
const [isIconModalOpen, setIsIconModalOpen] = useState(false)
|
||||
|
||||
const handleIconSelect = (url: string) => {
|
||||
if (onIconUrlChange) {
|
||||
onIconUrlChange(url)
|
||||
}
|
||||
setIsIconModalOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[300px] bg-white rounded-3xl p-6 h-fit sticky top-6">
|
||||
<h2 className="font-light mb-6">{isEditing ? 'ویرایش دسته بندی' : 'اضافه کردن دسته بندی'}</h2>
|
||||
@@ -53,16 +67,61 @@ const CategoryForm: FC<Props> = ({
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<UploadBox
|
||||
label="آیکون دسته بندی"
|
||||
onChange={(files) => {
|
||||
onIconChange(files)
|
||||
formik.setFieldValue('icon', files)
|
||||
}}
|
||||
isReset={iconFiles.length === 0 && formik.values.icon.length === 0}
|
||||
/>
|
||||
<div className="text-sm mb-2 font-medium">آیکون دسته بندی</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setIsIconModalOpen(true)}
|
||||
className="w-full h-12 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Image size={20} color="#fff" />
|
||||
<span>انتخاب آیکون</span>
|
||||
</Button>
|
||||
{selectedIconUrl && (
|
||||
<div className="mt-3 flex items-center gap-3 p-3 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<div className="w-12 h-12 flex items-center justify-center bg-white rounded-lg border border-gray-200 p-2 flex-shrink-0">
|
||||
<img
|
||||
src={selectedIconUrl}
|
||||
alt="selected icon"
|
||||
className="w-full h-full object-contain max-w-full max-h-full"
|
||||
style={{
|
||||
imageRendering: 'auto',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-gray-500 mb-1">آیکون انتخاب شده</div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
{selectedIconUrl}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (onIconUrlChange) {
|
||||
onIconUrlChange('')
|
||||
}
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors text-xl leading-none flex-shrink-0 w-6 h-6 flex items-center justify-center"
|
||||
title="حذف آیکون"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<IconSelectModal
|
||||
open={isIconModalOpen}
|
||||
onClose={() => setIsIconModalOpen(false)}
|
||||
icons={icons}
|
||||
onSelect={handleIconSelect}
|
||||
selectedUrl={selectedIconUrl}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={() => formik.handleSubmit()}
|
||||
className="w-full"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { type FC, useState, useEffect } from 'react'
|
||||
import DefaulModal from '@/components/DefaulModal'
|
||||
import type { Icon, IconGroup } from '../types/Types'
|
||||
import { TickCircle } from 'iconsax-react'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
icons: IconGroup[]
|
||||
onSelect: (url: string) => void
|
||||
selectedUrl?: string
|
||||
}
|
||||
|
||||
const IconSelectModal: FC<Props> = ({
|
||||
open,
|
||||
onClose,
|
||||
icons,
|
||||
onSelect,
|
||||
selectedUrl
|
||||
}) => {
|
||||
const [selectedIconUrl, setSelectedIconUrl] = useState<string | undefined>(
|
||||
selectedUrl
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIconUrl(selectedUrl)
|
||||
}, [selectedUrl, open])
|
||||
|
||||
const handleSelect = (url: string) => {
|
||||
setSelectedIconUrl(url)
|
||||
onSelect(url)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaulModal
|
||||
open={open}
|
||||
close={onClose}
|
||||
isHeader
|
||||
title_header="انتخاب آیکون"
|
||||
width={700}
|
||||
>
|
||||
<div className="max-h-[65vh] overflow-y-auto py-2">
|
||||
{icons.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="text-base mb-2">آیکونی یافت نشد</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
لطفاً بعداً تلاش کنید
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{icons.map((group) => (
|
||||
<div key={group.id} className="border-b border-gray-100 last:border-0 pb-6 last:pb-0">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">
|
||||
{group.name}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded-full">
|
||||
{group.icons.length} آیکون
|
||||
</span>
|
||||
</div>
|
||||
{group.icons.length === 0 ? (
|
||||
<div className="text-sm text-gray-400 bg-gray-50 rounded-lg p-4 text-center">
|
||||
این گروه آیکونی ندارد
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid px-2 grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-3">
|
||||
{group.icons.map((icon: Icon) => (
|
||||
<button
|
||||
key={icon.id}
|
||||
onClick={() => handleSelect(icon.url)}
|
||||
className={`group relative aspect-square border-2 rounded-xl p-2.5 transition-all duration-200 hover:scale-105 hover:shadow-md flex items-center justify-center ${selectedIconUrl === icon.url
|
||||
? 'border-primary scale-105'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={icon.url}
|
||||
alt="icon"
|
||||
className="w-full h-full object-contain transition-opacity duration-200 max-w-full max-h-full"
|
||||
style={{
|
||||
imageRendering: 'auto',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"%3E%3Cpath fill="%23ccc" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/%3E%3C/svg%3E'
|
||||
}}
|
||||
/>
|
||||
{selectedIconUrl === icon.url && (
|
||||
<div className="absolute -top-1 -right-1 bg-primary rounded-full z-10">
|
||||
<TickCircle
|
||||
size={18}
|
||||
color="#fff"
|
||||
variant="Bold"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute inset-0 rounded-xl transition-opacity duration-200 ${selectedIconUrl === icon.url
|
||||
? 'opacity-0'
|
||||
: 'opacity-0 group-hover:opacity-100 bg-primary/5'
|
||||
}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DefaulModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconSelectModal
|
||||
|
||||
@@ -80,3 +80,10 @@ export const useUpdateCategory = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetIcons = () => {
|
||||
return useQuery({
|
||||
queryKey: ["icons"],
|
||||
queryFn: api.getIcons,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
GetFoodDetailsResponseType,
|
||||
GetFoodsParams,
|
||||
GetFoodsResponseType,
|
||||
GetIconsResponseType,
|
||||
} from "../types/Types";
|
||||
|
||||
export const createFood = async (params: CreateFoodType) => {
|
||||
@@ -65,3 +66,8 @@ export const updateCategory = async (
|
||||
const { data } = await axios.patch(`/admin/categories/${id}`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getIcons = async (): Promise<GetIconsResponseType> => {
|
||||
const { data } = await axios.get<GetIconsResponseType>(`/admin/groups/icons`);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -129,3 +129,23 @@ export type CreateCategoryType = {
|
||||
isActive: boolean;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export type Icon = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
url: string;
|
||||
group: string;
|
||||
};
|
||||
|
||||
export type IconGroup = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
name: string;
|
||||
icons: Icon[];
|
||||
};
|
||||
|
||||
export type GetIconsResponseType = IResponse<IconGroup[]>;
|
||||
|
||||
Reference in New Issue
Block a user