request list + structure

This commit is contained in:
hamid zarghami
2025-10-20 12:28:48 +03:30
commit dbe37e371e
89 changed files with 9547 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
import type { ButtonHTMLAttributes, FC, ReactNode } from 'react'
import { memo } from 'react'
import MoonLoader from "react-spinners/MoonLoader"
import { type XOR } from '@/helpers/types'
import { clx } from '@/helpers/utils'
type Props = {
className?: string;
isLoading?: boolean,
percentage?: number,
} & ButtonHTMLAttributes<HTMLButtonElement> &
XOR<{ children: ReactNode }, { label: string }>;
const Button: FC<Props> = memo((props: Props) => {
const buttonClass = clx(
'flex rounded-xl items-center justify-center text-center h-10 text-sm bg-primary w-full',
props.disabled && 'cursor-not-allowed opacity-60',
props.className
);
return (
<button {...props} className={`${buttonClass} ${props.className}`} disabled={props.disabled || props.isLoading}>
{
props.isLoading ?
<div className='flex gap-2 items-center'>
{
!props.percentage ?
<MoonLoader size={20} color='#fff' />
:
<span>{props.percentage}%</span>
}
</div>
:
props.label || props.children
}
</button>
)
})
export default Button
+85
View File
@@ -0,0 +1,85 @@
import { useState, useEffect, type FC } from 'react';
import DatePicker from 'react-multi-date-picker';
import persian from 'react-date-object/calendars/persian';
import persian_fa from 'react-date-object/locales/persian_fa';
import DateObject from 'react-date-object';
import { Calendar } from 'iconsax-react';
import { clx } from '@/helpers/utils';
type Props = {
onChange: (date: string) => void;
defaultValue?: string;
error_text?: string;
placeholder: string;
reset?: boolean;
isDateTime?: boolean;
className?: string;
label?: string,
readOnly?: boolean;
};
const DatePickerComponent: FC<Props> = (props) => {
const [value, setValue] = useState<DateObject | null>(null);
useEffect(() => {
if (props.reset) {
setValue(null);
}
}, [props.reset]);
useEffect(() => {
if (value) {
const formattedDate = `${value.year}/${value.month.number}/${value.day}`;
props.onChange(formattedDate);
}
}, [value]);
useEffect(() => {
if (props.defaultValue && !value) {
const defaultDate = new DateObject({
date: props.defaultValue,
calendar: persian,
locale: persian_fa,
});
setValue(defaultDate);
}
}, [props.defaultValue, value]);
return (
<div className="w-full">
{
props.label &&
<div className='text-sm text-foreground font-medium mb-2'>{props.label}</div>
}
<div className={clx(
'relative',
props.readOnly && 'readOny',
props.label && 'mt-1'
)}>
<DatePicker
placeholder={props.placeholder}
value={value}
onChange={(date) => setValue(date as DateObject)}
calendar={persian}
locale={persian_fa}
calendarPosition="bottom-right"
className={props.className}
readOnly={props.readOnly}
/>
{props.error_text && props.error_text !== '' && (
<div className="text-xs text-right text-red-600 mt-2 mr-2 font-medium">
{props.error_text}
</div>
)}
<Calendar
size={20}
color='#8c90a3'
className='absolute top-0 bottom-0 my-auto left-2 pointer-events-none'
/>
</div>
</div>
);
};
export default DatePickerComponent;
+56
View File
@@ -0,0 +1,56 @@
import { type FC, Fragment, type ReactNode, useEffect } from 'react'
import HeaderModal from './HeaderModal'
interface Props {
open: boolean,
close: () => void,
children: ReactNode,
isHeader?: boolean,
title_header?: string,
width?: number
}
const DefaulModal: FC<Props> = (props: Props) => {
useEffect(() => {
if (props.open) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'auto'
}
}, [props.open])
return (
<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-[9999999] 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 max-h-[80%] bottom-0 left-0 flex xl:items-center sm:h-auto w-full xl:my-6 xl:p-2'>
<div className='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 outline-none focus:outline-none bg-card border border-border'>
{
props.isHeader && props.title_header &&
<div onClick={props.close} className='pb-6 border-b border-border border-opacity-20'>
<div className={`h-[5px] w-[200px] mx-auto rounded-full mb-4 xl:hidden bg-[#D1D3D7]'
}`}></div>
<HeaderModal close={props.close} label={props.title_header} />
</div>
}
{props.children}
</div>
</div>
</div>
<div onClick={props.close} className='fixed size-full bg-black/65 top-0 bottom-0 right-0 inset-0 z-50 '></div>
</Fragment>
)
}
</Fragment>
)
}
export default DefaulModal
+30
View File
@@ -0,0 +1,30 @@
import { type FC, Fragment } from 'react'
import Td from './Td'
import Skeleton from 'react-loading-skeleton'
type Props = {
tdCount: number,
trCount?: number,
}
const DefaultTableSkeleton: FC<Props> = ({ tdCount, trCount = 5 }) => {
return (
<Fragment>
{
Array.from({ length: trCount }).map((_, rowIndex) => (
<tr className="w-full h-[64px] md:h-[74px] bg-white border-t border-[#EAEDF5]" key={rowIndex}>
{
Array.from({ length: tdCount }).map((_, colIndex) => (
<Td text={''} key={colIndex}>
<Skeleton />
</Td>
))
}
</tr>
))
}
</Fragment>
)
}
export default DefaultTableSkeleton
+15
View File
@@ -0,0 +1,15 @@
import { type FC } from 'react'
type Props = {
errorText: string
}
const Error: FC<Props> = (props: Props) => {
return (
<div className='mt-1.5 font-normal text-red-500 text-[11px]'>
{props.errorText}
</div>
)
}
export default Error
+224
View File
@@ -0,0 +1,224 @@
import { type FC, useEffect, useState, type ChangeEvent, useRef } from 'react';
import { Filter } from 'iconsax-react';
import DatePicker from './DatePicker';
import Input from './Input';
import Select, { type ItemsSelectType } from './Select';
import DefaulModal from './DefaulModal';
import moment from 'moment-jalaali'
import Button from './Button';
export type DateFieldType = {
type: 'date';
name: string;
placeholder: string;
defaultValue?: string;
};
export type SelectFieldType = {
type: 'select';
name: string;
placeholder: string;
options: ItemsSelectType[];
defaultValue?: string;
};
export type InputFieldType = {
type: 'input';
name: string;
placeholder: string;
defaultValue?: string;
};
export type FieldType = DateFieldType | SelectFieldType | InputFieldType;
export type FilterValues = Record<string, string | null>;
interface FiltersProps {
fields: FieldType[];
onChange: (filters: FilterValues) => void;
initialValues?: FilterValues;
className?: string;
fieldClassName?: string;
searchField?: string;
}
const Filters: FC<FiltersProps> = ({
fields,
onChange,
initialValues = {},
className = "flex flex-col md:flex-row justify-between items-start md:items-center gap-4",
fieldClassName = "flex flex-col sm:flex-row gap-3 md:gap-4 w-full md:w-auto",
searchField = "search"
}) => {
const [filters, setFilters] = useState<FilterValues>({});
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
if (Object.keys(initialValues).length > 0) {
setFilters(initialValues);
const inputFields: Record<string, string> = {};
fields.forEach(field => {
if (field.type === 'input' && initialValues[field.name]) {
inputFields[field.name] = initialValues[field.name] as string;
}
});
setInputValues(inputFields);
} else {
const defaultValues: FilterValues = {};
const defaultInputs: Record<string, string> = {};
fields.forEach(field => {
if ('defaultValue' in field && field.defaultValue !== undefined) {
defaultValues[field.name] = field.defaultValue;
if (field.type === 'input') {
defaultInputs[field.name] = field.defaultValue;
}
}
});
if (Object.keys(defaultValues).length > 0) {
setFilters(defaultValues);
setInputValues(defaultInputs);
onChange(defaultValues);
}
}
}, [fields, initialValues, onChange]);
useEffect(() => {
const timeouts: Record<string, number> = {};
Object.keys(inputValues).forEach(fieldName => {
timeouts[fieldName] = setTimeout(() => {
setFilters(currentFilters => {
const currentFilter = currentFilters[fieldName];
const newValue = inputValues[fieldName];
if (currentFilter !== newValue) {
const newFilters = { ...currentFilters, [fieldName]: newValue || null };
onChangeRef.current(newFilters);
return newFilters;
}
return currentFilters;
});
}, 1000);
});
return () => {
Object.values(timeouts).forEach(timeout => clearTimeout(timeout));
};
}, [inputValues]);
const handleChange = (name: string, value: string | null) => {
const newFilters = { ...filters, [name]: value };
setFilters(newFilters);
onChange(newFilters);
};
const handleInputChange = (name: string, event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setInputValues(prev => ({ ...prev, [name]: value }));
};
const renderField = (field: FieldType) => {
const currentValue = field.type === 'input' ? inputValues[field.name] : filters[field.name];
switch (field.type) {
case 'date':
return (
<DatePicker
key={field.name}
placeholder={field.placeholder}
onChange={(value) => handleChange(field.name, moment(value, 'jYYYY/jMM/jDD').format('YYYY-MM-DD'))}
defaultValue={currentValue || field.defaultValue || ''}
/>
);
case 'select':
return (
<Select
key={field.name}
placeholder={field.placeholder}
onChange={(e) => handleChange(field.name, e.target.value)}
defaultValue={currentValue || field.defaultValue || ''}
items={field.options}
/>
);
case 'input':
return (
<Input
key={field.name}
placeholder={field.placeholder}
variant="search"
className="w-full md:min-w-[230px] md:max-w-[230px]"
value={currentValue || field.defaultValue || ''}
onChange={(e) => handleInputChange(field.name, e)}
/>
);
}
};
const searchFieldObj = fields.find(field => field.name === searchField);
const otherFields = fields.filter(field => field.name !== searchField);
return (
<div className={className}>
<div className="flex md:hidden items-center justify-between w-full mb-2">
<button
onClick={() => setIsFiltersOpen(!isFiltersOpen)}
className='flex items-center gap-2 px-3 py-2 rounded-lg transition-colors bg-card text-card-foreground border border-border hover:bg-secondary hover:bg-opacity-80'
>
<Filter
size={18}
color='#000000'
/>
<span className="text-sm">فیلترها</span>
</button>
{searchFieldObj && (
<div className="flex-1 mr-4">
{renderField(searchFieldObj)}
</div>
)}
</div>
<div className="hidden md:flex md:flex-row justify-between items-center gap-4 w-full">
<div className={fieldClassName}>
{fields.map(renderField)}
</div>
</div>
<DefaulModal
open={isFiltersOpen}
close={() => setIsFiltersOpen(false)}
isHeader={true}
title_header="فیلتر کردن نتایج"
>
<div className="pb-5 gap-4 flex flex-col-reverse mt-3">
{otherFields.map(field => (
<div key={field.name} className="w-full">
<label className="block text-sm font-medium text-foreground mb-2">
{field.placeholder}
</label>
<div className="w-full">
{renderField(field)}
</div>
</div>
))}
</div>
<div className='mt-6'>
<Button
label='بستن'
onClick={() => setIsFiltersOpen(false)}
/>
</div>
</DefaulModal>
</div>
);
};
export default Filters;
+125
View File
@@ -0,0 +1,125 @@
import React from "react";
import { clx } from "@/helpers/utils";
type GridWrapperProps = {
children: React.ReactNode;
desktop: number; // تعداد ستون‌ها در دسکتاپ
mobile: number; // تعداد ستون‌ها در موبایل
gapDesktop?: number; // فاصله بین آیتم‌ها در دسکتاپ. اگر مقدار > 12 باشد به عنوان px تفسیر می‌شود (مثلاً 24 => 24px)
gapMobile?: number; // فاصله بین آیتم‌ها در موبایل. اگر مقدار > 12 باشد به عنوان px تفسیر می‌شود
className?: string;
};
const GRID_COLS_CLASS: Record<number, string> = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
9: "grid-cols-9",
10: "grid-cols-10",
11: "grid-cols-11",
12: "grid-cols-12",
};
const MD_GRID_COLS_CLASS: Record<number, string> = {
1: "md:grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
5: "md:grid-cols-5",
6: "md:grid-cols-6",
7: "md:grid-cols-7",
8: "md:grid-cols-8",
9: "md:grid-cols-9",
10: "md:grid-cols-10",
11: "md:grid-cols-11",
12: "md:grid-cols-12",
};
// نسخه‌ی دسکتاپ با پیشوند lg: به صورت literal تا با purge مشکلی نداشته باشد
const LG_GRID_COLS_CLASS: Record<number, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
5: "lg:grid-cols-5",
6: "lg:grid-cols-6",
7: "lg:grid-cols-7",
8: "lg:grid-cols-8",
9: "lg:grid-cols-9",
10: "lg:grid-cols-10",
11: "lg:grid-cols-11",
12: "lg:grid-cols-12",
};
// نگاشت Gap بر اساس scale رایج Tailwind: gap-N که هر N معادل 4px است (N * 4px)
const GAP_CLASS: Record<number, string> = {
0: "gap-0", 1: "gap-1", 2: "gap-2", 3: "gap-3", 4: "gap-4", 5: "gap-5",
6: "gap-6", 7: "gap-7", 8: "gap-8", 9: "gap-9", 10: "gap-10", 11: "gap-11",
12: "gap-12", 13: "gap-13", 14: "gap-14", 15: "gap-15", 16: "gap-16", 17: "gap-17",
18: "gap-18", 19: "gap-19", 20: "gap-20", 21: "gap-21", 22: "gap-22", 23: "gap-23", 24: "gap-24",
};
const LG_GAP_CLASS: Record<number, string> = {
0: "lg:gap-0", 1: "lg:gap-1", 2: "lg:gap-2", 3: "lg:gap-3", 4: "lg:gap-4", 5: "lg:gap-5",
6: "lg:gap-6", 7: "lg:gap-7", 8: "lg:gap-8", 9: "lg:gap-9", 10: "lg:gap-10", 11: "lg:gap-11",
12: "lg:gap-12", 13: "lg:gap-13", 14: "lg:gap-14", 15: "lg:gap-15", 16: "lg:gap-16", 17: "lg:gap-17",
18: "lg:gap-18", 19: "lg:gap-19", 20: "lg:gap-20", 21: "lg:gap-21", 22: "lg:gap-22", 23: "lg:gap-23", 24: "lg:gap-24",
};
function clampToRange(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function getGridColsClass(count: number): string {
const safe = clampToRange(count, 1, 12);
return GRID_COLS_CLASS[safe];
}
function getLgGridColsClass(count: number): string {
const safe = clampToRange(count, 1, 12);
return LG_GRID_COLS_CLASS[safe];
}
function getMdGridColsClass(count: number): string {
const safe = clampToRange(count, 1, 12);
return MD_GRID_COLS_CLASS[safe];
}
function getGapClass(value?: number): string | undefined {
if (value === undefined) return undefined;
// اگر کاربر مقدار را به صورت px فرستاده باشد (بزرگ‌تر از 12)، آن را به scale تبدیل می‌کنیم: N = round(px/4)
const interpretedAsScale = value > 12 ? Math.round(value / 4) : value;
const safe = clampToRange(interpretedAsScale, 0, 24);
return GAP_CLASS[safe];
}
function getLgGapClass(value?: number): string | undefined {
if (value === undefined) return undefined;
const interpretedAsScale = value > 12 ? Math.round(value / 4) : value;
const safe = clampToRange(interpretedAsScale, 0, 24);
return LG_GAP_CLASS[safe];
}
export default function GridWrapper(props: GridWrapperProps) {
const { children, desktop, mobile, className, gapDesktop = 4, gapMobile = 4 } = props;
const mobileCols = getGridColsClass(mobile);
const desktopColsMd = getMdGridColsClass(desktop);
const desktopColsLg = getLgGridColsClass(desktop);
const mobileGap = getGapClass(gapMobile);
const desktopGap = getLgGapClass(gapDesktop);
return (
<div className={clx("grid", mobileCols, desktopColsMd, desktopColsLg, mobileGap, desktopGap, className)}>
{children}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { type FC } from 'react'
import { CloseCircle } from 'iconsax-react'
type Props = {
label: string,
close: () => void,
}
const HeaderModal: FC<Props> = (props: Props) => {
return (
<div className='flex justify-between items-center'>
<div className='text-sm text-foreground font-medium'>{props.label}</div>
<div className='size-7 rounded-full flex justify-center items-center transition-colors bg-white bg-opacity-35 hover:bg-opacity-50'>
<CloseCircle
size={20}
color='#000000'
onClick={props.close}
className="cursor-pointer"
/>
</div>
</div>
)
}
export default HeaderModal
+117
View File
@@ -0,0 +1,117 @@
import { type FC, type InputHTMLAttributes, useEffect, useState } from 'react'
import { clx } from '../helpers/utils';
import { Eye, EyeSlash, SearchNormal } from 'iconsax-react';
import Error from './Error';
type Variant = "floating_outlined" | "primary" | "search";
type Props = {
label?: string;
className?: string;
variant?: Variant;
error_text?: string;
onEnter?: () => void;
unit?: string;
seprator?: boolean;
isNotRequired?: boolean;
onChangeSearchFinal?: (value: string) => void;
} & InputHTMLAttributes<HTMLInputElement>
const formatNumber = (value: string | number): string => {
if (!value) return '';
const inputValue = String(value).replace(/,/g, '');
return inputValue.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
const Input: FC<Props> = (props: Props) => {
const [formattedValue, setFormattedValue] = useState<string>(
props.value ? formatNumber(props.value as string) : ''
);
const [showPassword, setShowPassword] = useState<boolean>(false)
const [search, setSearch] = useState<string>('')
const inputClass = clx(
'w-full bg-white h-10 text-black block px-4 text-xs rounded-xl border border-border',
props.readOnly && 'bg-gray-100 border-0 text-description',
props.variant === 'search' && 'border-0 ps-10 mt-0',
props.className
);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (props.seprator) {
const inputValue = event.target.value.replace(/,/g, ''); // حذف کاماها
const formatted = formatNumber(inputValue);
// به‌روزرسانی مقدار قالب‌بندی‌شده
setFormattedValue(formatted);
// ارسال مقدار خام به `onChange` والد
props.onChange?.({
...event,
target: {
...event.target,
value: inputValue,
},
});
} else {
props.onChange?.(event);
}
};
useEffect(() => {
if (props.value) {
setFormattedValue(formatNumber(props.value as string));
} else {
setFormattedValue('');
}
}, [props.value])
useEffect(() => {
if (props.variant === 'search' && props.onChangeSearchFinal) {
const timeout = setTimeout(() => {
props.onChangeSearchFinal?.(search)
}, 1000)
return () => clearTimeout(timeout)
}
}, [search, props])
return (
<div className='w-full'>
<label className='text-sm'>
{props.label}
</label>
<div className={clx('w-full relative', props.label && 'mt-1')}>
<input {...props} onChange={(e) => {
setSearch(e.target.value)
handleInputChange(e)
}} value={props.seprator ? formattedValue : props.value} type={props.type === 'password' && showPassword ? 'text' : props.type === 'password' ? 'password' : undefined} className={inputClass} />
{
props.type === 'password' &&
(showPassword ?
<Eye onClick={() => setShowPassword((oldValue) => !oldValue)} className='w-5 absolute top-0 bottom-0 cursor-pointer my-auto left-3' /> :
<EyeSlash onClick={() => setShowPassword((oldValue) => !oldValue)} className='w-5 absolute top-0 bottom-0 cursor-pointer my-auto left-3' />
)
}
{
props.variant === 'search' &&
<SearchNormal size={20} color='#8C90A3' className='absolute top-0 w-5 bottom-0 my-auto right-3' />
}
{
props.error_text &&
<Error
errorText={props.error_text}
/>
}
</div>
</div>
)
}
export default Input
+109
View File
@@ -0,0 +1,109 @@
import React from "react";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
}) => {
// اگر فقط یک صفحه داریم، pagination نمایش داده نمی‌شود
if (totalPages <= 1) return null;
const getPageNumbers = () => {
const pageNumbers: (number | string)[] = [];
const maxVisiblePages = window.innerWidth < 768 ? 3 : 5; // تعداد کمتر برای موبایل
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// نمایش صفحه اول
pageNumbers.push(1);
if (currentPage > 3) {
pageNumbers.push("...");
}
// صفحات میانی
const start = Math.max(2, Math.min(currentPage - 1, totalPages - 3));
const end = Math.min(totalPages - 1, Math.max(currentPage + 1, 4));
for (let i = start; i <= end; i++) {
if (!pageNumbers.includes(i)) {
pageNumbers.push(i);
}
}
if (currentPage < totalPages - 2) {
pageNumbers.push("...");
}
// نمایش صفحه آخر
if (!pageNumbers.includes(totalPages)) {
pageNumbers.push(totalPages);
}
}
return pageNumbers;
};
const pageNumbers = getPageNumbers();
return (
<div className="flex justify-center items-center gap-1 md:gap-2 py-4 md:py-6 mt-3 md:mt-4 border-t border-gray-100">
{/* دکمه صفحه قبل */}
<button
className={`px-2 md:px-3 py-2 text-xs md:text-sm rounded-lg transition-colors ${currentPage === 1
? "text-gray-400 cursor-not-allowed"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
}`}
onClick={() => currentPage > 1 && onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
قبلی
</button>
{/* شماره صفحات */}
<div className="flex gap-1">
{pageNumbers.map((page, index) =>
typeof page === "number" ? (
<button
key={index}
className={`w-8 h-8 md:w-10 md:h-10 text-xs md:text-sm rounded-lg transition-colors ${currentPage === page
? "bg-primary text-white shadow-sm"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
}`}
onClick={() => onPageChange(page)}
>
{page}
</button>
) : (
<span key={index} className="flex items-center px-1 md:px-2 text-gray-400 text-xs md:text-sm">
{page}
</span>
)
)}
</div>
{/* دکمه صفحه بعد */}
<button
className={`px-2 md:px-3 py-2 text-xs md:text-sm rounded-lg transition-colors ${currentPage === totalPages
? "text-gray-400 cursor-not-allowed"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
}`}
onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
بعدی
</button>
</div>
);
};
export default Pagination;
+145
View File
@@ -0,0 +1,145 @@
import React, { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { More } from 'iconsax-react'
export interface RowActionItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
className?: string;
}
interface RowActionsDropdownProps {
actions: RowActionItem[];
className?: string;
trigger?: React.ReactNode;
topOffset?: number;
leftOffset?: number;
topOffsetMobile?: number;
leftOffsetMobile?: number;
}
const RowActionsDropdown: React.FC<RowActionsDropdownProps> = ({
actions,
className = '',
trigger,
topOffset = 0,
leftOffset = 0,
topOffsetMobile = 0,
leftOffsetMobile = 0
}) => {
const [isOpen, setIsOpen] = useState(false)
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 })
const dropdownRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const calculatePosition = (rect: DOMRect) => {
const isMobile = window.innerWidth < 768
const dropdownWidth = isMobile ? 140 : 150
const dropdownHeight = actions.length * (isMobile ? 36 : 40)
// انتخاب offset بر اساس سایز صفحه
const currentTopOffset = isMobile ? topOffsetMobile : topOffset
const currentLeftOffset = isMobile ? leftOffsetMobile : leftOffset
let left = rect.left + window.scrollX - dropdownWidth + rect.width + currentLeftOffset
let top = rect.bottom + window.scrollY + currentTopOffset
if (left + dropdownWidth > window.innerWidth) {
left = rect.left + window.scrollX - dropdownWidth + currentLeftOffset
}
if (left < 0) {
left = isMobile ? 20 : 40
}
if (top + dropdownHeight > window.innerHeight + window.scrollY) {
top = rect.top + window.scrollY - dropdownHeight + currentTopOffset
}
// اطمینان از اینکه dropdown از صفحه خارج نشود
if (left < 0) left = 20
if (left + dropdownWidth > window.innerWidth) left = window.innerWidth - dropdownWidth - 20
if (top < 0) top = 20
if (top + dropdownHeight > window.innerHeight) top = window.innerHeight - dropdownHeight - 20
return { top, left }
}
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation()
if (!isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
const calculatedPosition = calculatePosition(rect)
setDropdownPosition(calculatedPosition)
}
setIsOpen(!isOpen)
}
const handleActionClick = (action: RowActionItem) => {
action.onClick()
setIsOpen(false)
}
const renderDropdown = () => {
if (!isOpen) return null
return createPortal(
<div
ref={dropdownRef}
className="fixed py-3 md:py-3 bg-white dark:bg-[#252526] rounded-xl md:rounded-2xl shadow-lg z-[5] min-w-[140px] md:min-w-[150px] border border-[#d0d0d0]"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
}}
>
{actions.map((action, index) => (
<button
key={index}
onClick={() => handleActionClick(action)}
className={`w-full mt-1 text-right px-2 md:px-3 py-1 md:py-1.5 text-xs md:text-[13px] flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-[#2d2d30] ${index === 0 ? 'rounded-t-lg' : ''} ${action.className || ''}`}
>
{action.icon && <span className="ml-2">{action.icon}</span>}
<span className={`${action.label === 'حذف' ? 'text-red-500' : ''} dark:text-gray-200`}>{action.label}</span>
</button>
))}
</div>,
document.body
)
}
return (
<div className={className}>
<button
ref={buttonRef}
onClick={handleToggle}
className="mt-2 hover:bg-gray-100 dark:hover:bg-[#2d2d30] rounded-full transition-colors"
aria-haspopup="menu"
aria-expanded={isOpen}
>
{trigger ? trigger : (
<More size={16} color={'black'} className="rotate-90" />
)}
</button>
{renderDropdown()}
</div>
)
}
export default RowActionsDropdown
+64
View File
@@ -0,0 +1,64 @@
import type { FC, SelectHTMLAttributes } from 'react'
import { clx } from '../helpers/utils'
import { ArrowDown2 } from 'iconsax-react'
export type ItemsSelectType = {
value: string,
label: string,
}
type Props = {
className?: string,
items: ItemsSelectType[],
error_text?: string,
placeholder?: string,
label?: string,
readOnly?: boolean,
} & SelectHTMLAttributes<HTMLSelectElement>
const Select: FC<Props> = (props: Props) => {
return (
<div className='w-full'>
{
props.label &&
<label className='text-sm text-primary-content'>
{props.label}
</label>
}
<div className='relative'>
<select {...props} className={clx(
'w-full bg-white border border-border input-surface relative block appearance-none px-2.5 h-10 text-sm rounded-[10px] transition-colors',
props.readOnly && 'bg-muted border-0 text-muted-foreground',
props.className,
props.label && 'mt-1'
)}>
{
props.placeholder &&
<option value="" disabled selected>{props.placeholder}</option>
}
{
props.items?.map((item) => {
return (
<option key={item.value} value={item.value}>
{item.label}
</option>
)
})
}
</select>
<ArrowDown2 size={16} color='#8c90a3' className='absolute z-0 top-3 left-2 pointer-events-none'
/>
</div>
{
props.error_text && props.error_text !== '' ?
<div className='text-xs text-right text-red-600 mt-2 mr-2 font-medium'>
{props.error_text}
</div>
: null
}
</div>
)
}
export default Select
+15
View File
@@ -0,0 +1,15 @@
import { type FC } from 'react'
type Props = {
color: string
}
const StatusCircle: FC<Props> = (props: Props) => {
return (
<div style={{ background: props.color }} className='size-1.5 rounded-full'>
</div>
)
}
export default StatusCircle
+15
View File
@@ -0,0 +1,15 @@
import { type FC } from 'react'
type Props = {
color: string
}
const StatusCircle: FC<Props> = (props: Props) => {
return (
<div style={{ background: props.color }} className='size-1.5 rounded-full'>
</div>
)
}
export default StatusCircle
+363
View File
@@ -0,0 +1,363 @@
import React, { Fragment, useState, memo } from 'react';
import DefaultTableSkeleton from '@/components/DefaultTableSkeleton';
import Td from '@/components/Td';
import type { TableProps, RowDataType, ColumnType } from '@/components/types/TableTypes';
import { Checkbox } from '@/components/ui/checkbox';
import RowActionsDropdown from '@/components/RowActionsDropdown';
import Pagination from '@/components/Pagination';
const Table = <T extends RowDataType>({
columns,
data,
isLoading = false,
onRowClick,
className = '',
rowClassName = '',
noDataMessage = 'هیچ داده‌ای یافت نشد',
headerClassName = 'bg-gray-50',
emptyRowsCount = 5,
showHeader = true,
actions = null,
actionsPosition = 'top',
selectable = false,
selectedActions = null,
onSelectionChange,
rowActions,
pagination,
}: TableProps<T>): React.ReactElement => {
const [selectedRows, setSelectedRows] = useState<T[]>([]);
const getRowClassName = (item: T, index: number): string => {
if (typeof rowClassName === 'function') {
return rowClassName(item, index);
}
return rowClassName;
};
const getCellClassName = (column: ColumnType<T>): string => {
const alignClass = column.align ? `text-${column.align}` : '';
return `px-3 md:px-6 py-3 md:py-4 whitespace-nowrap text-xs ${alignClass} ${column.className || ''}`.trim();
};
const handleRowClick = (item: T) => {
if (onRowClick) {
onRowClick(item.id, item);
}
};
const handleSelectAll = () => {
const dataArray = data || [];
const newSelectedRows = selectedRows.length === dataArray.length ? [] : [...dataArray];
setSelectedRows(newSelectedRows);
if (onSelectionChange) {
onSelectionChange(newSelectedRows);
}
};
const handleSingleRowSelect = (item: T, checked: boolean) => {
const newSelectedRows = checked
? [...selectedRows, item]
: selectedRows.filter((row) => row.id !== item.id);
setSelectedRows(newSelectedRows);
if (onSelectionChange) {
onSelectionChange(newSelectedRows);
}
};
const isRowSelected = (item: T): boolean => {
return selectedRows.some((row) => row.id === item.id);
};
// Gmail-style layout برای وقتی header نمایش داده نمی‌شود
const renderGmailStyleRows = () => {
const hasActions = (actionsPosition === 'top' || actionsPosition === 'above-header') && (actions || (selectable && selectedRows.length > 0 && selectedActions));
const roundedClass = hasActions ? 'rounded-b-2xl' : 'rounded-2xl';
if (isLoading) {
return (
<div className={`bg-white ${roundedClass} overflow-hidden`}>
{Array.from({ length: emptyRowsCount }).map((_, index) => (
<div key={index} className={`h-[48px] bg-gray-100 animate-pulse ${index !== 0 ? 'border-t border-[#EAEDF5]' : ''}`}></div>
))}
</div>
);
}
if (!data || data.length === 0) {
return (
<div className={`bg-white ${roundedClass} py-6 md:py-8 text-center text-gray-500`}>
{noDataMessage}
</div>
);
}
return (
<div className={`bg-white ${roundedClass} overflow-hidden`}>
{data.map((item, rowIndex) => (
<div
key={item.id}
className={`w-full min-h-[48px] hover:bg-[#F4F5F9] ${onRowClick ? 'cursor-pointer' : ''} ${getRowClassName(item, rowIndex)} px-4 py-3 flex items-center gap-3 ${rowIndex !== 0 ? 'border-t border-[#EAEDF5]' : ''}`}
onClick={() => handleRowClick(item)}
>
{selectable && (
<div
className="flex-shrink-0 relative z-[5]"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={isRowSelected(item)}
onCheckedChange={(checked) => handleSingleRowSelect(item, !!checked)}
/>
</div>
)}
<div className="flex-1 min-w-0">
{/* محتوای اصلی - ستون اول */}
<div className="flex items-center mb-0.5 text-sm font-medium">
{columns[0]?.render ? columns[0].render(item, rowIndex) : (item[columns[0]?.key] as React.ReactNode)}
</div>
{/* اطلاعات اضافی - سه ستون آخر در یک خط */}
{columns.length > 1 && (
<div className="flex items-center gap-3 text-xs text-gray-500">
{columns.slice(1).map((col) => (
<div key={col.key} className="truncate">
{col.render ? col.render(item, rowIndex) : (item[col.key] as React.ReactNode)}
</div>
))}
</div>
)}
</div>
{rowActions && (
<div
className="flex-shrink-0 relative z-[5]"
onClick={(e) => e.stopPropagation()}
>
<RowActionsDropdown actions={rowActions(item)} />
</div>
)}
</div>
))}
</div>
);
};
const renderTableBody = () => {
if (isLoading) {
return (
<Fragment>
<DefaultTableSkeleton tdCount={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)} trCount={emptyRowsCount} />
</Fragment>
);
}
if (!data || data.length === 0) {
return (
<tr>
<td colSpan={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)} className="px-3 md:px-6 py-6 md:py-8 text-center text-gray-500">
{noDataMessage}
</td>
</tr>
);
}
return (data || []).map((item, rowIndex) => (
<tr
key={item.id}
className={`w-full h-[64px] md:h-[74px] bg-white border-t border-[#EAEDF5] ${onRowClick ? 'cursor-pointer' : ''} ${getRowClassName(item, rowIndex)}`}
onClick={() => handleRowClick(item)}
>
{selectable && (
<td
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 relative z-[5]"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={isRowSelected(item)}
onCheckedChange={(checked) => handleSingleRowSelect(item, !!checked)}
/>
</td>
)}
{columns.map((col) => (
<td
key={`${item.id}-${col.key}`}
className={getCellClassName(col)}
style={{ width: col.width }}
>
{col.render ? col.render(item, rowIndex) : item[col.key] as React.ReactNode}
</td>
))}
{rowActions && (
<td
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 relative z-[5]"
onClick={(e) => e.stopPropagation()}
>
<RowActionsDropdown actions={rowActions(item)} />
</td>
)}
</tr>
));
};
const renderHeader = () => {
if (!showHeader || actionsPosition === 'header-replace') return null;
return (
<thead className={`h-[60px] md:h-[69px] bg-white text-sm text-[#8C90A3] ${headerClassName}`}>
<tr>
{selectable && (
<th
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 relative z-[5] first:rounded-tr-2xl md:first:rounded-tr-3xl"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={(data?.length || 0) > 0 && selectedRows.length === (data?.length || 0)}
onCheckedChange={handleSelectAll}
/>
</th>
)}
{columns.map((col) => (
<Td key={col.key} text={col.title} />
))}
{rowActions && (
<th className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 rounded-tl-2xl md:rounded-tl-3xl"></th>
)}
</tr>
</thead >
);
};
const renderActions = () => {
if (!actions && (!selectable || selectedRows.length === 0 || !selectedActions)) return null;
return (
<div className="h-[64px] rounded-t-2xl md:rounded-t-3xl md:h-[74px] bg-white flex items-center px-3 md:px-6">
<div className='flex items-center gap-2 md:gap-4'>
{actions}
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
</div>
</div>
);
};
// اگر header نشان داده نمی‌شود، از Gmail-style layout فقط در موبایل استفاده کن
if (!showHeader) {
return (
<div className={`relative mt-6 md:mt-9 w-full ${className}`}>
{/* نسخه موبایل - Gmail style */}
<div className={`md:hidden`}>
{(actionsPosition === 'top' || actionsPosition === 'above-header') && (
<div className="h-[64px] bg-white flex items-center px-4 rounded-t-2xl border-b border-[#EAEDF5]">
<div className='flex items-center gap-2'>
{actions}
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
</div>
</div>
)}
{renderGmailStyleRows()}
</div>
{/* نسخه دسکتاپ - Table معمولی */}
<div className={`hidden md:block`}>
{(actionsPosition === 'top' || actionsPosition === 'above-header') && (
<div className="h-[74px] bg-white flex items-center px-6 rounded-t-3xl">
<div className='flex items-center gap-4'>
{actions}
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
</div>
</div>
)}
<div className={`overflow-x-auto overflow-hidden ${(actionsPosition === 'top' || actionsPosition === 'above-header') && (actions || (selectable && selectedRows.length > 0 && selectedActions)) ? 'rounded-b-3xl' : 'rounded-3xl'} bg-white`}>
<table className="w-full text-sm border-collapse min-w-[600px]">
<tbody>
{renderTableBody()}
</tbody>
</table>
</div>
</div>
{pagination && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={pagination.onPageChange}
/>
)}
</div>
);
}
return (
<div className={`relative mt-6 md:mt-9 w-full ${className}`}>
{(actionsPosition === 'top' || actionsPosition === 'above-header') && renderActions()}
<div className={`overflow-x-auto overflow-hidden ${showHeader && actionsPosition !== 'header-replace' ? 'rounded-2xl md:rounded-3xl' : 'rounded-b-2xl md:rounded-b-3xl'} bg-white`}>
<table className="w-full text-sm border-collapse min-w-[600px]">
{renderHeader()}
{actionsPosition === 'header-replace' && (
<thead>
<tr>
<th colSpan={columns.length} className="p-0">
{renderActions()}
</th>
</tr>
</thead>
)}
<tbody>
{renderTableBody()}
</tbody>
</table>
</div>
{pagination && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={pagination.onPageChange}
/>
)}
</div>
);
};
export default memo(Table) as <T extends RowDataType>(props: TableProps<T>) => React.ReactElement;
/*
مثال استفاده با pagination:
import Table from '@/components/Table';
import { useState } from 'react';
const MyComponent = () => {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = 10;
const columns = [
{ title: 'نام', key: 'name' },
{ title: 'ایمیل', key: 'email' },
];
const data = [
{ id: 1, name: 'علی', email: 'ali@example.com' },
{ id: 2, name: 'فاطمه', email: 'fateme@example.com' },
];
const handlePageChange = (page: number) => {
setCurrentPage(page);
// اینجا می‌توانید API call جدید برای دریافت داده‌های صفحه جدید بزنید
};
return (
<Table
columns={columns}
data={data}
pagination={{
currentPage,
totalPages,
onPageChange: handlePageChange
}}
/>
);
};
*/
+41
View File
@@ -0,0 +1,41 @@
import { clx } from '@/helpers/utils'
import { type FC } from 'react'
type TabItem = {
label: string
value: string
}
type Props = {
items: TabItem[]
activeTab: string
onTabChange: (tab: string) => void
}
const Tabs: FC<Props> = (props) => {
const { items, activeTab, onTabChange } = props
return (
<div className='flex justify-center text-sm'>
<div className='flex gap-4 h-[52px] rounded-full bg-white px-4 items-center'>
{
items.map((item) => {
return (
<div onClick={() => onTabChange(item.value)} key={item.value} className={clx(
'h-[32px] flex items-center justify-center w-[122px] cursor-pointer rounded-full',
activeTab === item.value && 'bg-primary'
)}>
{item.label}
</div>
)
})
}
</div>
</div>
)
}
export default Tabs
+23
View File
@@ -0,0 +1,23 @@
import type { FC, ReactNode } from 'react'
interface Props {
text: string | number | ReactNode,
children?: ReactNode,
dir?: string,
className?: string,
}
const Td: FC<Props> = (props: Props) => {
return (
<td className={`px-3 md:px-6 py-3 md:py-4 whitespace-nowrap text-xs ${props.className}`} style={{ direction: props.dir === "ltr" ? "ltr" : "rtl" }}>
{
props.text ?
props.text
:
props.children
}
</td>
)
}
export default Td
+67
View File
@@ -0,0 +1,67 @@
import { CloseCircle, InfoCircle, TickCircle } from 'iconsax-react';
import React, { useState, useEffect } from 'react';
import { setAddToast } from '../shared/toast';
interface Toast {
id: string;
message: string;
type?: 'success' | 'error' | 'info';
isExiting?: boolean;
}
const ToastContainer: React.FC = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
// ثبت toast جدید
const showToast = (toast: Toast) => {
setToasts((prev) => [...prev, toast]);
// حذف خودکار بعد از ۳ ثانیه
setTimeout(() => {
setToasts((prev) => prev.map(t =>
t.id === toast.id ? { ...t, isExiting: true } : t
));
// حذف از DOM بعد از اتمام انیمیشن
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== toast.id));
}, 300); // مدت زمان انیمیشن خروج
}, 3000);
};
// تخصیص تابع نمایش toast به متغیر سراسری
useEffect(() => {
setAddToast(showToast);
}, []);
return (
<div className="fixed top-4 text-sm right-0 left-0 mx-auto w-fit z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`px-4 flex items-center gap-2 backdrop-blur-2xl h-16 min-w-[300px] rounded-2xl shadow-md
${toast.isExiting ? 'animate-slide-out-right' : 'animate-slide-in-right'} ${toast.type === 'success'
? 'bg-white/70 text-black'
: toast.type === 'error'
? 'bg-white/70 text-black'
: 'bg-white/70 text-black'
}`}
style={{
animationFillMode: 'forwards',
animationDuration: '0.3s',
animationTimingFunction: 'ease-out'
}}
>
{
toast.type === 'success' ? <TickCircle className='size-5' color='green' /> :
toast.type === 'error' ? <CloseCircle className='size-5' color='red' /> :
<InfoCircle className='size-5' color='blue' />
}
{toast.message}
</div>
))}
</div>
);
};
export default ToastContainer;
+90
View File
@@ -0,0 +1,90 @@
import { type FC, useCallback, useEffect, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import { CloseCircle } from 'iconsax-react'
import { t } from '@/locale';
type Props = {
label: string,
isMultiple?: boolean,
onChange?: (file: File[]) => void;
isReset?: boolean;
}
const UploadBox: FC<Props> = (props: Props) => {
const [files, setFiles] = useState<File[]>([])
const onDrop = useCallback((acceptedFiles: File[]) => {
if (props.isMultiple) {
const array = [...files]
array.push(acceptedFiles[0])
setFiles(array)
props.onChange?.(array)
} else {
setFiles([acceptedFiles[0]])
props.onChange?.([acceptedFiles[0]])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files])
const { getRootProps, getInputProps } = useDropzone({ onDrop })
const handleRemove = (index: number) => {
const array = [...files]
array.splice(index, 1)
setFiles(array)
props.onChange?.(array)
}
useEffect(() => {
if (props.isReset) {
setFiles([])
}
}, [props.isReset])
return (
<div>
<div className='text-sm'>{props.label}</div>
<div className='w-full h-10 mt-1 border border-border rounded-xl items-center px-2 flex gap-2.5'>
<button {...getRootProps()} className=' w-fit h-7 rounded-lg text-xs px-6 bg-secondary'>
<input {...getInputProps()} />
{t('uploadBox.selectFile')}
</button>
<div className='lg:flex gap-2 hidden'>
{
files.length === 0 ? (
<div className='text-xs text-description'>هیچ فایلی انتخاب نشده است</div>
) : (
files?.map((item, index) => (
<div key={index} className='flex bg-gray-200 py-1.5 px-2 rounded-lg items-center gap-1'>
<CloseCircle onClick={() => handleRemove(index)} size={16} color='red' />
<div className='text-xs'>{item.name}</div>
</div>
))
)
}
</div>
</div>
{files.length > 0 && (
<div className='lg:hidden gap-2 flex flex-wrap mt-4'>
{
files?.map((item, index) => (
<div key={index} className='flex bg-gray-200 py-1.5 px-2 rounded-lg items-center gap-1'>
<CloseCircle onClick={() => handleRemove(index)} size={16} color='red' />
<div className='text-xs'>{item.name}</div>
</div>
))
}
</div>
)}
</div>
)
}
export default UploadBox
+189
View File
@@ -0,0 +1,189 @@
import { type FC, useState, useEffect, useRef } from 'react'
import { Microphone, Play, Pause } from 'iconsax-react'
type Props = {
label?: string
placeholder?: string
value?: string
onChange?: (value: string) => void
onRecordingComplete?: (audioBlob: Blob) => void
}
const VoiceRecorder: FC<Props> = ({
label = 'پیام شما',
placeholder = 'پیام شما',
value,
onChange,
onRecordingComplete
}) => {
const [isRecording, setIsRecording] = useState(false)
const [audioUrl, setAudioUrl] = useState<string | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const chunksRef = useRef<Blob[]>([])
const animationFrameRef = useRef<number | null>(null)
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const mediaRecorder = new MediaRecorder(stream)
mediaRecorderRef.current = mediaRecorder
chunksRef.current = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data)
}
}
mediaRecorder.onstop = () => {
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' })
const url = URL.createObjectURL(audioBlob)
setAudioUrl(url)
onRecordingComplete?.(audioBlob)
stream.getTracks().forEach(track => track.stop())
}
mediaRecorder.start()
setIsRecording(true)
} catch (error) {
console.error('خطا در دسترسی به میکروفون:', error)
}
}
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop()
setIsRecording(false)
}
}
const togglePlayPause = () => {
if (!audioRef.current) return
if (isPlaying) {
audioRef.current.pause()
} else {
audioRef.current.play()
}
setIsPlaying(!isPlaying)
}
const updateProgress = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime)
if (isPlaying) {
animationFrameRef.current = requestAnimationFrame(updateProgress)
}
}
}
useEffect(() => {
if (audioUrl && audioRef.current) {
audioRef.current.addEventListener('loadedmetadata', () => {
if (audioRef.current) {
setDuration(audioRef.current.duration)
}
})
audioRef.current.addEventListener('ended', () => {
setIsPlaying(false)
setCurrentTime(0)
})
}
}, [audioUrl])
useEffect(() => {
if (isPlaying) {
updateProgress()
}
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPlaying])
const generateWaveBars = () => {
const bars = []
const barCount = 100
const progress = duration > 0 ? currentTime / duration : 0
for (let i = 0; i < barCount; i++) {
const position = i / barCount
const isPassed = position <= progress
const baseHeight = 3
const randomHeight = Math.random() * 20 + baseHeight
bars.push(
<div
key={i}
className="w-[2px] rounded-full transition-all duration-100"
style={{
height: `${randomHeight}px`,
backgroundColor: isPassed ? '#3B82F6' : '#E5E7EB'
}}
/>
)
}
return bars
}
return (
<div className="w-full">
{label && <div className="text-sm mb-1">{label}</div>}
<div className="w-full">
<div className="w-full h-10 bg-white border border-border rounded-xl flex items-center px-4 gap-2">
<input
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => onChange?.(e.target.value)}
className="flex-1 bg-transparent outline-none text-sm"
/>
<button
onClick={isRecording ? stopRecording : startRecording}
className="flex-shrink-0"
>
<Microphone
size={20}
color={isRecording ? '#EF4444' : '#000'}
variant={isRecording ? 'Bold' : 'Outline'}
/>
</button>
</div>
{audioUrl && (
<div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-3">
<button
onClick={togglePlayPause}
className="w-8 h-8 rounded-full bg-black flex items-center justify-center flex-shrink-0"
>
{isPlaying ? (
<Pause size={16} color="#fff" variant="Bold" />
) : (
<Play size={16} color="#fff" variant="Bold" />
)}
</button>
<div className="flex-1 flex items-center gap-[2px] h-8">
{generateWaveBars()}
</div>
<audio ref={audioRef} src={audioUrl} className="hidden" />
</div>
)}
</div>
</div>
)
}
export default VoiceRecorder
+47
View File
@@ -0,0 +1,47 @@
export interface RowDataType {
id: string | number;
[key: string]: unknown;
}
export interface ColumnType<T extends RowDataType> {
title: string;
key: string;
align?: "left" | "center" | "right";
className?: string;
width?: string | number;
render?: (item: T, index?: number) => React.ReactNode;
}
export interface PaginationType {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export interface ActionType {
label: string;
onClick: () => void;
icon?: React.ReactNode;
className?: string;
disabled?: boolean;
}
export interface TableProps<T extends RowDataType> {
columns: ColumnType<T>[];
data?: T[];
isLoading?: boolean;
onRowClick?: (id: string | number, item: T) => void;
className?: string;
rowClassName?: string | ((item: T, index: number) => string);
noDataMessage?: string;
headerClassName?: string;
emptyRowsCount?: number;
showHeader?: boolean;
actions?: React.ReactNode;
actionsPosition?: "top" | "above-header" | "bottom" | "header-replace";
selectable?: boolean;
selectedActions?: React.ReactNode;
onSelectionChange?: (selectedRows: T[]) => void;
rowActions?: (item: T) => ActionType[];
pagination?: PaginationType;
}
+37
View File
@@ -0,0 +1,37 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { clx } from "@/helpers/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={clx(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={clx("flex items-center justify-center text-current")}
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20,6 9,17 4,12" />
</svg>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }