Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b00a22d11 | |||
| 03fc293ee9 | |||
| 2804cc4100 | |||
| 17717eb195 | |||
| 0138261643 | |||
| 6653e3db82 | |||
| 032050bacc | |||
| c65e8cd303 | |||
| d95a6e679e | |||
| 3c9bb6274f | |||
| 8dd00d4cc7 | |||
| 3c833df63c | |||
| 4daa03d0de | |||
| 172cbea22c | |||
| 8014fe7ad3 | |||
| d977c20744 |
@@ -1,4 +1,12 @@
|
||||
VITE_API_URL=https://dpage-api.danakcorp.com
|
||||
# VITE_API_URL=http://192.168.99.131:4000
|
||||
VITE_TOKEN_NAME=dpage-editor-t
|
||||
VITE_REFRESH_TOKEN_NAME=dpage-editor-refresh-t
|
||||
VITE_REFRESH_TOKEN_NAME=dpage-editor-refresh-t
|
||||
VITE_INVOICE_URL=https://console.danakcorp.com/receipts/
|
||||
VITE_DESIGN_TIER_1_MAX=10
|
||||
VITE_DESIGN_PRICE_TIER_1=700000
|
||||
VITE_DESIGN_TIER_2_MAX=30
|
||||
VITE_DESIGN_PRICE_TIER_2=500000
|
||||
VITE_DESIGN_TIER_3_MAX=70
|
||||
VITE_DESIGN_PRICE_TIER_3=400000
|
||||
VITE_DESIGN_PRICE_TIER_4=350000
|
||||
+16
@@ -19,6 +19,14 @@ WORKDIR /build
|
||||
ARG VITE_API_URL
|
||||
ARG VITE_TOKEN_NAME
|
||||
ARG VITE_REFRESH_TOKEN_NAME
|
||||
ARG VITE_INVOICE_URL
|
||||
ARG VITE_DESIGN_TIER_1_MAX
|
||||
ARG VITE_DESIGN_PRICE_TIER_1
|
||||
ARG VITE_DESIGN_TIER_2_MAX
|
||||
ARG VITE_DESIGN_PRICE_TIER_2
|
||||
ARG VITE_DESIGN_TIER_3_MAX
|
||||
ARG VITE_DESIGN_PRICE_TIER_3
|
||||
ARG VITE_DESIGN_PRICE_TIER_4
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
@@ -28,6 +36,14 @@ RUN set -e; \
|
||||
[ -n "$VITE_API_URL" ] && env_args="$env_args VITE_API_URL=$VITE_API_URL"; \
|
||||
[ -n "$VITE_TOKEN_NAME" ] && env_args="$env_args VITE_TOKEN_NAME=$VITE_TOKEN_NAME"; \
|
||||
[ -n "$VITE_REFRESH_TOKEN_NAME" ] && env_args="$env_args VITE_REFRESH_TOKEN_NAME=$VITE_REFRESH_TOKEN_NAME"; \
|
||||
[ -n "$VITE_INVOICE_URL" ] && env_args="$env_args VITE_INVOICE_URL=$VITE_INVOICE_URL"; \
|
||||
[ -n "$VITE_DESIGN_TIER_1_MAX" ] && env_args="$env_args VITE_DESIGN_TIER_1_MAX=$VITE_DESIGN_TIER_1_MAX"; \
|
||||
[ -n "$VITE_DESIGN_PRICE_TIER_1" ] && env_args="$env_args VITE_DESIGN_PRICE_TIER_1=$VITE_DESIGN_PRICE_TIER_1"; \
|
||||
[ -n "$VITE_DESIGN_TIER_2_MAX" ] && env_args="$env_args VITE_DESIGN_TIER_2_MAX=$VITE_DESIGN_TIER_2_MAX"; \
|
||||
[ -n "$VITE_DESIGN_PRICE_TIER_2" ] && env_args="$env_args VITE_DESIGN_PRICE_TIER_2=$VITE_DESIGN_PRICE_TIER_2"; \
|
||||
[ -n "$VITE_DESIGN_TIER_3_MAX" ] && env_args="$env_args VITE_DESIGN_TIER_3_MAX=$VITE_DESIGN_TIER_3_MAX"; \
|
||||
[ -n "$VITE_DESIGN_PRICE_TIER_3" ] && env_args="$env_args VITE_DESIGN_PRICE_TIER_3=$VITE_DESIGN_PRICE_TIER_3"; \
|
||||
[ -n "$VITE_DESIGN_PRICE_TIER_4" ] && env_args="$env_args VITE_DESIGN_PRICE_TIER_4=$VITE_DESIGN_PRICE_TIER_4"; \
|
||||
eval "env$env_args npm run build"
|
||||
|
||||
FROM nginx:stable-alpine AS production-stage
|
||||
|
||||
Generated
+356
-3287
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -10,13 +10,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@liara/cli": "^9.4.7",
|
||||
"@headlessui/react": "^2.2.10",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"formik": "^2.4.9",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^25.6.2",
|
||||
"iconsax-react": "^0.0.8",
|
||||
@@ -34,6 +35,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"use-image": "^1.1.4",
|
||||
"yup": "^1.7.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import persian_fa from 'react-date-object/locales/persian_fa';
|
||||
import DateObject from 'react-date-object';
|
||||
import { clx } from '../helpers/utils';
|
||||
import { Calendar } from 'iconsax-react';
|
||||
import Error from './Error';
|
||||
|
||||
type Props = {
|
||||
onChange: (date: string) => void;
|
||||
@@ -88,26 +89,22 @@ const DatePickerComponent: FC<Props> = (props: Props) => {
|
||||
<div className='text-xs'>
|
||||
{props.label}
|
||||
</div>}
|
||||
<div className={clx(
|
||||
'relative ',
|
||||
props.label && 'mt-1.5'
|
||||
)}>
|
||||
<DatePicker
|
||||
placeholder={props.placeholder}
|
||||
value={value}
|
||||
onChange={(date) => setValue(date as DateObject)}
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
calendarPosition="bottom-right"
|
||||
className={`rmdp-mobile ${props.className}`}
|
||||
/>
|
||||
<div className={clx(props.label && 'mt-1.5')}>
|
||||
<div className="relative">
|
||||
<DatePicker
|
||||
placeholder={props.placeholder}
|
||||
value={value}
|
||||
onChange={(date) => setValue(date as DateObject)}
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
calendarPosition="bottom-right"
|
||||
className={`rmdp-mobile ${props.className}`}
|
||||
/>
|
||||
<Calendar size={18} color='black' className='pointer-events-none absolute top-0 bottom-0 left-2 my-auto' />
|
||||
</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>
|
||||
<Error errorText={props.error_text} />
|
||||
)}
|
||||
|
||||
<Calendar size={18} color='black' className='absolute top-0 bottom-0 my-auto left-2' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import Td from "@/components/Td";
|
||||
import { type FC, Fragment } from "react";
|
||||
|
||||
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 key={colIndex}>
|
||||
<div className="h-4 w-20 max-w-full rounded bg-gray-200 animate-pulse" />
|
||||
</Td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultTableSkeleton;
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from "react";
|
||||
import { Group, Rect, Text } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import type { EditorObject } from "@/pages/editor/store/editorStore";
|
||||
|
||||
type EditorTableProps = {
|
||||
obj: EditorObject;
|
||||
selectedCellId: string | null;
|
||||
onCellClick: (tableId: string, cellId: string, node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => void;
|
||||
onCellDblClick: (tableId: string, cellId: string, x: number, y: number, width: number, height: number, text: string) => void;
|
||||
onDragEnd: (tableId: string, x: number, y: number) => void;
|
||||
draggable: boolean;
|
||||
};
|
||||
|
||||
const EditorTable = ({ obj, selectedCellId, onCellClick, onCellDblClick, onDragEnd, draggable }: EditorTableProps) => {
|
||||
const groupRef = React.useRef<Konva.Group>(null);
|
||||
const tableData = obj.tableData;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (groupRef.current) {
|
||||
groupRef.current.setAttr("id", obj.id);
|
||||
}
|
||||
}, [obj.id]);
|
||||
|
||||
if (!tableData) return null;
|
||||
|
||||
const { rows, cols, cells, cellWidth, cellHeight, stroke, strokeWidth } = tableData;
|
||||
|
||||
const handleCellClick = (cellId: string, e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
e.cancelBubble = true;
|
||||
if (groupRef.current) {
|
||||
onCellClick(obj.id, cellId, groupRef.current, e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCellDblClick = (cellId: string, e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
e.cancelBubble = true;
|
||||
const stage = e.target.getStage();
|
||||
if (!stage || !groupRef.current) return;
|
||||
|
||||
const cell = cells[cellId];
|
||||
if (!cell) return;
|
||||
|
||||
const targetNode = e.target;
|
||||
const box = targetNode.getClientRect();
|
||||
const stageBox = stage.container().getBoundingClientRect();
|
||||
const x = stageBox.left + box.x;
|
||||
const y = stageBox.top + box.y;
|
||||
const width = box.width;
|
||||
const height = box.height;
|
||||
|
||||
onCellDblClick(obj.id, cellId, x, y, width, height, cell.text);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (groupRef.current) {
|
||||
const pos = groupRef.current.position();
|
||||
onDragEnd(obj.id, pos.x, pos.y);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group ref={groupRef} x={obj.x} y={obj.y} draggable={draggable} onDragEnd={handleDragEnd}>
|
||||
{Array.from({ length: rows }).map((_, row) =>
|
||||
Array.from({ length: cols }).map((_, col) => {
|
||||
const cellId = `cell-${row}-${col}`;
|
||||
const cell = cells[cellId];
|
||||
if (!cell) return null;
|
||||
|
||||
const x = col * cellWidth;
|
||||
const y = row * cellHeight;
|
||||
const isCellSelected = selectedCellId === cellId;
|
||||
const isTopLeft = row === 0 && col === 0;
|
||||
const isTopRight = row === 0 && col === cols - 1;
|
||||
|
||||
return (
|
||||
<Group key={cellId}>
|
||||
<Rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={cellWidth}
|
||||
height={cellHeight}
|
||||
fill={cell.background}
|
||||
stroke={isCellSelected ? "#3b82f6" : stroke || "#808080"}
|
||||
strokeWidth={isCellSelected ? 3 : strokeWidth || 1}
|
||||
cornerRadius={isTopLeft ? [8, 0, 0, 0] : isTopRight ? [0, 8, 0, 0] : 0}
|
||||
onClick={(e) => handleCellClick(cellId, e)}
|
||||
onDblClick={(e) => handleCellDblClick(cellId, e)}
|
||||
/>
|
||||
{cell.text && (
|
||||
<Text
|
||||
x={x}
|
||||
y={y}
|
||||
width={cellWidth}
|
||||
height={cellHeight}
|
||||
text={cell.text}
|
||||
fontSize={14}
|
||||
fontFamily="Arial"
|
||||
fill="#000000"
|
||||
align="center"
|
||||
verticalAlign="middle"
|
||||
onClick={(e) => handleCellClick(cellId, e)}
|
||||
onDblClick={(e) => handleCellDblClick(cellId, e)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorTable;
|
||||
+95
-121
@@ -1,134 +1,108 @@
|
||||
import React from "react";
|
||||
import { Group, Rect, Text } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import type { EditorObject } from "@/pages/editor/store/editorStore";
|
||||
import DefaultTableSkeleton from "@/components/DefaultTableSkeleton";
|
||||
import Td from "@/components/Td";
|
||||
import type { ColumnType, RowDataType, TableProps } from "@/components/types/TableTypes";
|
||||
import { type ReactElement } from "react";
|
||||
|
||||
type TableProps = {
|
||||
obj: EditorObject;
|
||||
selectedCellId: string | null;
|
||||
onCellClick: (tableId: string, cellId: string, node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => void;
|
||||
onCellDblClick: (tableId: string, cellId: string, x: number, y: number, width: number, height: number, text: string) => void;
|
||||
onDragEnd: (tableId: string, x: number, y: number) => void;
|
||||
draggable: boolean;
|
||||
};
|
||||
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",
|
||||
}: TableProps<T>): ReactElement => {
|
||||
const getRowClassName = (item: T, index: number): string => {
|
||||
if (typeof rowClassName === "function") {
|
||||
return rowClassName(item, index);
|
||||
}
|
||||
return rowClassName;
|
||||
};
|
||||
|
||||
const Table = ({
|
||||
obj,
|
||||
selectedCellId,
|
||||
onCellClick,
|
||||
onCellDblClick,
|
||||
onDragEnd,
|
||||
draggable,
|
||||
}: TableProps) => {
|
||||
const groupRef = React.useRef<Konva.Group>(null);
|
||||
const tableData = obj.tableData;
|
||||
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();
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (groupRef.current) {
|
||||
groupRef.current.setAttr("id", obj.id);
|
||||
}
|
||||
}, [obj.id]);
|
||||
const handleRowClick = (item: T) => {
|
||||
if (onRowClick) {
|
||||
onRowClick(item.id, item);
|
||||
}
|
||||
};
|
||||
|
||||
if (!tableData) return null;
|
||||
const renderTableBody = () => {
|
||||
if (isLoading) {
|
||||
return <DefaultTableSkeleton tdCount={columns.length} trCount={emptyRowsCount} />;
|
||||
}
|
||||
|
||||
const { rows, cols, cells, cellWidth, cellHeight, stroke, strokeWidth } = tableData;
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 md:px-6 py-6 md:py-8 text-center text-gray-500">
|
||||
{noDataMessage}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCellClick = (cellId: string, e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
e.cancelBubble = true;
|
||||
if (groupRef.current) {
|
||||
onCellClick(obj.id, cellId, groupRef.current, e);
|
||||
}
|
||||
};
|
||||
return data.map((item, rowIndex) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`w-full h-[64px] md:h-[74px] bg-white border-t border-[#EAEDF5] hover:bg-[#F4F5F9] ${onRowClick ? "cursor-pointer" : ""} ${getRowClassName(item, rowIndex)}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={`${item.id}-${col.key}`} className={getCellClassName(col)} style={{ width: col.width }}>
|
||||
{col.render ? col.render(item) : (item[col.key] as React.ReactNode)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
));
|
||||
};
|
||||
|
||||
const handleCellDblClick = (cellId: string, e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
e.cancelBubble = true;
|
||||
const stage = e.target.getStage();
|
||||
if (!stage || !groupRef.current) return;
|
||||
|
||||
const cell = cells[cellId];
|
||||
if (!cell) return;
|
||||
|
||||
// Get the target node (Rect or Text)
|
||||
const targetNode = e.target;
|
||||
|
||||
// Get the client rect which includes all transformations (scale, position, etc.)
|
||||
const box = targetNode.getClientRect();
|
||||
const stageBox = stage.container().getBoundingClientRect();
|
||||
|
||||
// Calculate position relative to viewport
|
||||
const x = stageBox.left + box.x;
|
||||
const y = stageBox.top + box.y;
|
||||
const width = box.width;
|
||||
const height = box.height;
|
||||
|
||||
onCellDblClick(obj.id, cellId, x, y, width, height, cell.text);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (groupRef.current) {
|
||||
const pos = groupRef.current.position();
|
||||
onDragEnd(obj.id, pos.x, pos.y);
|
||||
}
|
||||
};
|
||||
const renderHeader = () => {
|
||||
if (!showHeader || actionsPosition === "header-replace") return null;
|
||||
|
||||
return (
|
||||
<Group
|
||||
ref={groupRef}
|
||||
x={obj.x}
|
||||
y={obj.y}
|
||||
draggable={draggable}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{Array.from({ length: rows }).map((_, row) =>
|
||||
Array.from({ length: cols }).map((_, col) => {
|
||||
const cellId = `cell-${row}-${col}`;
|
||||
const cell = cells[cellId];
|
||||
if (!cell) return null;
|
||||
|
||||
const x = col * cellWidth;
|
||||
const y = row * cellHeight;
|
||||
const isCellSelected = selectedCellId === cellId;
|
||||
|
||||
const isTopLeft = row === 0 && col === 0;
|
||||
const isTopRight = row === 0 && col === cols - 1;
|
||||
|
||||
return (
|
||||
<Group key={cellId}>
|
||||
<Rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={cellWidth}
|
||||
height={cellHeight}
|
||||
fill={cell.background}
|
||||
stroke={isCellSelected ? "#3b82f6" : stroke || "#808080"}
|
||||
strokeWidth={isCellSelected ? 3 : strokeWidth || 1}
|
||||
cornerRadius={isTopLeft ? [8, 0, 0, 0] : isTopRight ? [0, 8, 0, 0] : 0}
|
||||
onClick={(e) => handleCellClick(cellId, e)}
|
||||
onDblClick={(e) => handleCellDblClick(cellId, e)}
|
||||
/>
|
||||
{cell.text && (
|
||||
<Text
|
||||
x={x}
|
||||
y={y}
|
||||
width={cellWidth}
|
||||
height={cellHeight}
|
||||
text={cell.text}
|
||||
fontSize={14}
|
||||
fontFamily="Arial"
|
||||
fill="#000000"
|
||||
align="center"
|
||||
verticalAlign="middle"
|
||||
onClick={(e) => handleCellClick(cellId, e)}
|
||||
onDblClick={(e) => handleCellDblClick(cellId, e)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Group>
|
||||
<thead className={`h-[60px] md:h-[69px] bg-white text-sm text-[#8C90A3] ${headerClassName}`}>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<Td key={col.key} text={col.title} />
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
};
|
||||
|
||||
const renderActions = () => {
|
||||
if (!actions) 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}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative 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()}
|
||||
<tbody>{renderTableBody()}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type FC, type ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
text?: string | number | ReactNode;
|
||||
children?: ReactNode;
|
||||
dir?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Td: FC<Props> = ({ text, children, dir, className }) => {
|
||||
return (
|
||||
<td
|
||||
className={`px-3 md:px-6 py-3 md:py-4 whitespace-nowrap text-xs ${className || ""}`}
|
||||
style={{ direction: dir === "ltr" ? "ltr" : "rtl" }}
|
||||
>
|
||||
{text ?? children}
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
export default Td;
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type RowDataType = Record<string, unknown> & { id: string | number };
|
||||
|
||||
export type ColumnType<T extends RowDataType = RowDataType> = {
|
||||
title: string;
|
||||
key: string;
|
||||
render?: (item: T) => ReactNode;
|
||||
width?: string | number;
|
||||
align?: "left" | "center" | "right";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type TableProps<T extends RowDataType = RowDataType> = {
|
||||
columns: ColumnType<T>[];
|
||||
data?: T[];
|
||||
isLoading?: boolean;
|
||||
onRowClick?: (id: T["id"], item: T) => void;
|
||||
className?: string;
|
||||
rowClassName?: string | ((item: T, index: number) => string);
|
||||
noDataMessage?: ReactNode;
|
||||
headerClassName?: string;
|
||||
emptyRowsCount?: number;
|
||||
showHeader?: boolean;
|
||||
actions?: ReactNode;
|
||||
actionsPosition?: "top" | "header-replace" | "above-header";
|
||||
};
|
||||
+11
-6
@@ -5,23 +5,28 @@ export const Paths = {
|
||||
viewerBySlug: "/catalogue",
|
||||
catalog: {
|
||||
list: "/catalogue",
|
||||
business: "/business/",
|
||||
},
|
||||
designer: {
|
||||
request: "/designer/request",
|
||||
list: "/designer/requests/list",
|
||||
},
|
||||
};
|
||||
|
||||
const viewerPathPrefix = `${Paths.viewer}/`;
|
||||
const viewerBySlugPathPrefix = `${Paths.viewerBySlug}/`;
|
||||
|
||||
const businessCatalogPathPrefix = Paths.catalog.business;
|
||||
|
||||
/** Public business catalogue grid: /business/:slug */
|
||||
export const isBusinessCatalogPath = (pathname: string): boolean => {
|
||||
return pathname.startsWith(businessCatalogPathPrefix) && pathname.length > businessCatalogPathPrefix.length;
|
||||
};
|
||||
|
||||
/** Full-screen viewer: /viewer/:id or /catalogue/:slug (not the list at /catalogue). */
|
||||
export const isViewerPath = (pathname: string): boolean => {
|
||||
const isViewerById =
|
||||
pathname.startsWith(viewerPathPrefix) &&
|
||||
pathname.length > viewerPathPrefix.length;
|
||||
const isViewerBySlug =
|
||||
pathname.startsWith(viewerBySlugPathPrefix) &&
|
||||
pathname.length > viewerBySlugPathPrefix.length;
|
||||
const isViewerById = pathname.startsWith(viewerPathPrefix) && pathname.length > viewerPathPrefix.length;
|
||||
const isViewerBySlug = pathname.startsWith(viewerBySlugPathPrefix) && pathname.length > viewerBySlugPathPrefix.length;
|
||||
return isViewerById || isViewerBySlug;
|
||||
};
|
||||
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
"sidebar": {
|
||||
"menu": "منو",
|
||||
"home_page": "صفحه اصلی",
|
||||
"catalog": "کاتالوگ"
|
||||
"catalog": "کاتالوگ",
|
||||
"logout": "خروج",
|
||||
"request_design": "درخواست طراحی"
|
||||
},
|
||||
"header": {
|
||||
"search": "جستجو",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import GridWrapper from "@/components/GridWrapper";
|
||||
import CatalogueGridSkeleton from "@/pages/catalogue/components/CatalogueGridSkeleton";
|
||||
import CatalogueItem from "@/pages/catalogue/components/CatalogueItem";
|
||||
import { type FC } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useGetBusinessCatalog } from "./hooks/useBusinessData";
|
||||
|
||||
const Business: FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { data, isPending, refetch } = useGetBusinessCatalog(slug ?? "");
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-full mt-5">
|
||||
<CatalogueGridSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full mt-5">
|
||||
<GridWrapper desktop={4} gapDesktop={40} mobile={1}>
|
||||
{data?.data?.map((item) => {
|
||||
return <CatalogueItem refetch={refetch} key={item.id} item={item} />;
|
||||
})}
|
||||
</GridWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Business;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getToken } from "@/config/func";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as api from "../service/BusinessService";
|
||||
|
||||
export const useGetBusinessCatalog = (slug: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["businessCatalog", slug],
|
||||
queryFn: () => api.getBusinessCatalog(slug),
|
||||
enabled: !!slug,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetBusinessBySlug = (slug: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["business", slug],
|
||||
queryFn: () => api.getBusinessBySlug(slug),
|
||||
enabled: !!slug,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetBusiness = () => {
|
||||
return useQuery({
|
||||
queryKey: ["mybusiness"],
|
||||
queryFn: api.getBusiness,
|
||||
enabled: !!getToken(),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import axios from "@/config/axios";
|
||||
import type { CatalogResponseType } from "@/pages/catalogue/types/Types";
|
||||
import type { BusinessResponseType } from "../types/Types";
|
||||
|
||||
export const getBusinessCatalog = async (slug: string) => {
|
||||
const { data } = await axios.get<CatalogResponseType>(
|
||||
`/public/catalogue/business/slug/${slug}`,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getBusinessBySlug = async (slug: string) => {
|
||||
const { data } = await axios.get(`/public/business/${slug}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getBusiness = async () => {
|
||||
const { data } = await axios.get<BusinessResponseType>(`/admin/business/me`);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { BaseResponse } from "@/shared/types/SharedTypes";
|
||||
|
||||
export type AdminType = {
|
||||
business: string;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
firstName: string;
|
||||
id: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
export type BusinessType = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
admin: AdminType;
|
||||
};
|
||||
|
||||
export type BusinessResponseType = BaseResponse<BusinessType>;
|
||||
|
||||
export const getAdminFullName = (admin?: AdminType | null): string =>
|
||||
[admin?.firstName, admin?.lastName].filter(Boolean).join(" ");
|
||||
@@ -1,13 +1,22 @@
|
||||
import GridWrapper from "@/components/GridWrapper";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { type FC } from "react";
|
||||
import { useGetCatalog } from "../home/hooks/useHomeData";
|
||||
import { useGetCatalog } from "./hooks/useCatalogueData";
|
||||
import ButtonAddCatalogue from "./components/ButtonAddCatalogue";
|
||||
import CatalogueGridSkeleton from "./components/CatalogueGridSkeleton";
|
||||
import CatalogueItem from "./components/CatalogueItem";
|
||||
|
||||
const CatalogueList: FC = () => {
|
||||
usePageTitle("لیست کاتالوگها");
|
||||
const { data, refetch } = useGetCatalog();
|
||||
const { data, isPending, refetch } = useGetCatalog();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-full mt-5">
|
||||
<CatalogueGridSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full mt-5">
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import GridWrapper from "@/components/GridWrapper";
|
||||
import { type FC } from "react";
|
||||
|
||||
const PREVIEW_WIDTH = 64;
|
||||
const PREVIEW_HEIGHT = 89;
|
||||
|
||||
export const CatalogueItemSkeleton: FC = () => (
|
||||
<div
|
||||
className="bg-white p-6 rounded-3xl w-full animate-pulse"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div
|
||||
className="bg-gray-200 rounded-lg shrink-0"
|
||||
style={{ width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="h-4 bg-gray-200 rounded-md w-3/4 max-w-[160px]" />
|
||||
<div className="mt-2 h-3 bg-gray-200 rounded-md w-1/3 max-w-[80px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-1 items-center mt-1">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="size-6 bg-gray-200 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
type CatalogueGridSkeletonProps = {
|
||||
count?: number;
|
||||
};
|
||||
|
||||
const CatalogueGridSkeleton: FC<CatalogueGridSkeletonProps> = ({ count = 8 }) => (
|
||||
<GridWrapper desktop={4} gapDesktop={40} mobile={1}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<CatalogueItemSkeleton key={index} />
|
||||
))}
|
||||
</GridWrapper>
|
||||
);
|
||||
|
||||
export default CatalogueGridSkeleton;
|
||||
@@ -2,7 +2,8 @@ import { toast } from "@/components/Toast";
|
||||
import TrashWithConfrim from "@/components/TrashWithConfrim";
|
||||
import { Paths } from "@/config/Paths";
|
||||
import { extractErrorMessage } from "@/helpers/utils";
|
||||
import { useDleteCatalog } from "@/pages/home/hooks/useHomeData";
|
||||
import { useGetBusiness } from "@/pages/business/hooks/useBusinessData";
|
||||
import { useDleteCatalog } from "@/pages/catalogue/hooks/useCatalogueData";
|
||||
import { requestViewerFullscreen } from "@/pages/viewer/utils/viewerFullscreen";
|
||||
import { Edit, Eye, Share } from "iconsax-react";
|
||||
import moment from "moment-jalaali";
|
||||
@@ -17,9 +18,10 @@ type Props = {
|
||||
};
|
||||
|
||||
const CatalogueItem: FC<Props> = ({ item, refetch }) => {
|
||||
const { data: business } = useGetBusiness();
|
||||
const deleteCatalog = useDleteCatalog();
|
||||
|
||||
const shareUrl = `${window.location.origin}${Paths.viewer}/${item.id}`;
|
||||
const shareUrl = `${window.location.origin}${Paths.viewerBySlug}/${business?.data?.slug}/${item.slug}`;
|
||||
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
@@ -68,7 +70,7 @@ const CatalogueItem: FC<Props> = ({ item, refetch }) => {
|
||||
</div>
|
||||
<div className="flex justify-end gap-1 items-center mt-1">
|
||||
<Link
|
||||
to={Paths.viewerBySlug + `/${item.slug}`}
|
||||
to={Paths.viewerBySlug + `/${business?.data?.slug}/${item.slug}`}
|
||||
onClick={requestViewerFullscreen}
|
||||
>
|
||||
<div className="size-6 bg-[#EAECF4] rounded-md flex justify-center items-center">
|
||||
|
||||
@@ -1,6 +1,133 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as api from "../service/CatalogueService";
|
||||
|
||||
export const useCreateCatalog = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.createCatalog,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDleteCatalog = () => {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteCatalogById(id),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCatalog = () => {
|
||||
return useQuery({
|
||||
queryKey: ["catalogs"],
|
||||
queryFn: api.getCatalogs,
|
||||
});
|
||||
};
|
||||
|
||||
const catalogMongoIdPattern = /^[a-f\d]{24}$/i;
|
||||
|
||||
export const isCatalogMongoId = (value: string) =>
|
||||
catalogMongoIdPattern.test(value);
|
||||
|
||||
export const useGetCatalogById = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["catalog", id],
|
||||
queryFn: () => api.getCatalogById(id),
|
||||
enabled: !!id,
|
||||
// جلوگیری از بازنویسی state محلی با refetch هنگام فوکوس پنجره (مثلاً دو کاربر همزمان)
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
/** Route param may be Mongo id (editor/admin) or slug (public viewer-style URLs). */
|
||||
export const useGetCatalogByIdOrSlug = (
|
||||
param: string,
|
||||
businessSlug = "",
|
||||
) => {
|
||||
const isId = isCatalogMongoId(param);
|
||||
const byId = useGetCatalogById(isId ? param : "");
|
||||
const bySlug = useGetCatalogBySlug(
|
||||
isId ? "" : businessSlug,
|
||||
isId ? "" : param,
|
||||
);
|
||||
return isId ? byId : bySlug;
|
||||
};
|
||||
|
||||
export const useGetCatalogBySlug = (businessSlug: string, slug: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["catalog", businessSlug, slug],
|
||||
queryFn: () => api.getCatalogBySlug(businessSlug, slug),
|
||||
enabled: !!businessSlug && !!slug,
|
||||
// جلوگیری از بازنویسی state محلی با refetch هنگام فوکوس پنجره (مثلاً دو کاربر همزمان)
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCatalog = () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: api.updateCatalog,
|
||||
});
|
||||
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingParamsRef = useRef<
|
||||
Parameters<typeof api.updateCatalog>[0] | null
|
||||
>(null);
|
||||
const [isScheduledSaving, setIsScheduledSaving] = useState(false);
|
||||
|
||||
const cancelPendingUpdate = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
pendingParamsRef.current = null;
|
||||
setIsScheduledSaving(false);
|
||||
}, []);
|
||||
|
||||
const scheduleUpdate = useCallback(
|
||||
(params: Parameters<typeof api.updateCatalog>[0], delayMs = 3000) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
pendingParamsRef.current = params;
|
||||
setIsScheduledSaving(true);
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
const pendingParams = pendingParamsRef.current;
|
||||
timeoutRef.current = null;
|
||||
pendingParamsRef.current = null;
|
||||
setIsScheduledSaving(false);
|
||||
if (pendingParams) {
|
||||
mutation.mutate(pendingParams);
|
||||
}
|
||||
}, delayMs);
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
// اگر کاربر قبل از پایان debounce از ادیتور خارج شد،
|
||||
// آخرین تغییرات معلق را همان لحظه ذخیره کن.
|
||||
if (pendingParamsRef.current) {
|
||||
mutation.mutate(pendingParamsRef.current);
|
||||
pendingParamsRef.current = null;
|
||||
}
|
||||
setIsScheduledSaving(false);
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
|
||||
return {
|
||||
...mutation,
|
||||
scheduleUpdate,
|
||||
cancelPendingUpdate,
|
||||
isSaving: isScheduledSaving || mutation.isPending,
|
||||
};
|
||||
};
|
||||
|
||||
export const usePurchaseInitate = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.purchaseInitate,
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
import axios from "@/config/axios";
|
||||
import type { PurchaseInitiate } from "../types/Types";
|
||||
import type {
|
||||
CatalogByIdResponseType,
|
||||
CatalogResponseType,
|
||||
CreateCatalogParamsType,
|
||||
PurchaseInitiate,
|
||||
UpdateCatalogParamsType,
|
||||
} from "../types/Types";
|
||||
|
||||
export const createCatalog = async (params: CreateCatalogParamsType) => {
|
||||
const { data } = await axios.post("/admin/catalogue", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCatalogs = async () => {
|
||||
const { data } = await axios.get<CatalogResponseType>("/admin/catalogue");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCatalogById = async (id: string) => {
|
||||
const { data } = await axios.get<CatalogByIdResponseType>(
|
||||
`/admin/catalogue/${id}`,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCatalogBySlug = async (businessSlug: string, slug: string) => {
|
||||
const { data } = await axios.get<CatalogByIdResponseType>(
|
||||
`/public/catalogue/${businessSlug}/${slug}`,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateCatalog = async (params: UpdateCatalogParamsType) => {
|
||||
const { data } = await axios.patch(`/admin/catalogue/${params.id}`, {
|
||||
content: params.content,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteCatalogById = async (id: string) => {
|
||||
const { data } = await axios.delete(`/admin/catalogue/${id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const purchaseInitate = async (params: PurchaseInitiate) => {
|
||||
const { data } = await axios.post(
|
||||
|
||||
@@ -16,3 +16,16 @@ export type CatalogByIdResponseType = BaseResponse<CatalogItemType>;
|
||||
export type PurchaseInitiate = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type CreateCatalogParamsType = {
|
||||
name: string;
|
||||
slug: string;
|
||||
size: string;
|
||||
content?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type UpdateCatalogParamsType = {
|
||||
id: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import Button from "@/components/Button";
|
||||
import Table from "@/components/Table";
|
||||
import type { ColumnType } from "@/components/types/TableTypes";
|
||||
import { Paths } from "@/config/Paths";
|
||||
import { formatDateShort, formatPrice } from "@/helpers/func";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { getTotalPrice } from "@/pages/designer/designerRequestForm";
|
||||
import { useGetDesignRequests } from "@/pages/designer/hooks/useDesignerData";
|
||||
import type { RequestDesignItemType } from "@/pages/designer/types/Types";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const getInvoiceUrl = (invoiceId: string) => `${import.meta.env.VITE_INVOICE_URL}${invoiceId}`;
|
||||
|
||||
const RequestDesignList: FC = () => {
|
||||
usePageTitle("لیست درخواستهای طراحی");
|
||||
const { data, isPending } = useGetDesignRequests();
|
||||
const requests = data?.data ?? [];
|
||||
|
||||
const columns = useMemo<ColumnType<RequestDesignItemType>[]>(
|
||||
() => [
|
||||
{ title: "عنوان", key: "title", className: "font-medium text-black" },
|
||||
{ title: "تاریخ ثبت", key: "createdAt", render: (item) => formatDateShort(item.createdAt) },
|
||||
{ title: "زمان تحویل", key: "expectedDate", render: (item) => formatDateShort(item.expectedDate) },
|
||||
{ title: "تعداد صفحات", key: "count", render: (item) => item.count.toLocaleString("fa-IR") },
|
||||
{
|
||||
title: "مبلغ",
|
||||
key: "totalPrice",
|
||||
render: (item) => `${formatPrice(getTotalPrice(item.count))} تومان`,
|
||||
},
|
||||
{
|
||||
title: "وضعیت",
|
||||
key: "paidAt",
|
||||
render: (item) => {
|
||||
const isPaid = Boolean(item.paidAt);
|
||||
return (
|
||||
<span className={`inline-flex rounded-full px-3 py-1 text-xs ${isPaid ? "bg-[#E8F5E9] text-[#2E7D32]" : "bg-[#FFF3E0] text-[#E65100]"}`}>{isPaid ? "پرداخت شده" : "در انتظار پرداخت"}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "عملیات",
|
||||
key: "actions",
|
||||
render: (item) => {
|
||||
if (item.paidAt) {
|
||||
return <span className="text-[#999999]">—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-fit px-4 py-1.5 text-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
window.open(getInvoiceUrl(item.invoiceId), "_blank");
|
||||
}}
|
||||
>
|
||||
پرداخت
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4 w-full">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1> درخواستهای طراحی</h1>
|
||||
<Link to={Paths.designer.request}>
|
||||
<Button className="w-fit px-6">درخواست جدید</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
data={requests}
|
||||
isLoading={isPending}
|
||||
className="mt-6"
|
||||
noDataMessage={
|
||||
<div className="flex flex-col items-center gap-5 py-2">
|
||||
<span>هنوز درخواست طراحی ثبت نشده است.</span>
|
||||
<Link to={Paths.designer.request}>
|
||||
<Button className="w-fit px-6">ثبت درخواست طراحی</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestDesignList;
|
||||
+14
-102
@@ -1,106 +1,18 @@
|
||||
import { usePageTitle } from '@/hooks/usePageTitle'
|
||||
import { useMemo, useState, type FC } from 'react'
|
||||
import Button from '@/components/Button'
|
||||
import DatePicker from '@/components/DatePicker'
|
||||
import Input from '@/components/Input'
|
||||
import Select from '@/components/Select'
|
||||
import Textarea from '@/components/Textarea'
|
||||
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
|
||||
|
||||
const PRICE_PER_PAGE = 1000
|
||||
|
||||
const pageCountOptions = Array.from({ length: 20 }, (_, index) => {
|
||||
const value = index + 1
|
||||
return {
|
||||
value: String(value),
|
||||
label: String(value),
|
||||
}
|
||||
})
|
||||
import DesignerRequestForm from "@/pages/designer/components/DesignerRequestForm";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { type FC } from "react";
|
||||
|
||||
const DesignerRequest: FC = () => {
|
||||
usePageTitle('درخواست طراحی')
|
||||
const [catalogTitle, setCatalogTitle] = useState('')
|
||||
const [, setDeliveryDate] = useState('')
|
||||
const [pageCount, setPageCount] = useState('1')
|
||||
const [description, setDescription] = useState('')
|
||||
const [, setAttachment] = useState<File[]>([])
|
||||
usePageTitle("درخواست طراحی");
|
||||
|
||||
const totalPrice = useMemo(() => {
|
||||
return Number(pageCount || 0) * PRICE_PER_PAGE
|
||||
}, [pageCount])
|
||||
return (
|
||||
<div className="mt-4 w-full">
|
||||
<h1>درخواست طراحی کاتالوگ</h1>
|
||||
<div className="mt-6 rounded-[32px] bg-white p-4 md:p-6 lg:p-8">
|
||||
<DesignerRequestForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleAttachmentChange = (files: File[]) => {
|
||||
setAttachment(files)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-4 w-full'>
|
||||
<h1 className=''>درخواست طراحی کاتالوگ</h1>
|
||||
<div className='rounded-[32px] bg-white p-4 md:p-6 lg:p-8 mt-6'>
|
||||
|
||||
<div className='mt-6 space-y-5'>
|
||||
<Input
|
||||
label='عنوان کاتالوگ'
|
||||
value={catalogTitle}
|
||||
onChange={(event) => setCatalogTitle(event.target.value)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
|
||||
<DatePicker
|
||||
label='زمان تحویل موردنظر'
|
||||
placeholder='تاریخ را انتخاب کنید'
|
||||
onChange={(date) => setDeliveryDate(date)}
|
||||
/>
|
||||
<Select
|
||||
label='تعداد صفحات'
|
||||
value={pageCount}
|
||||
items={pageCountOptions}
|
||||
onChange={(event) => setPageCount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
label='توضیحات'
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className='min-h-[96px]'
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className='mb-2 text-sm'>فایل های ضمیمه</div>
|
||||
<UploadBoxDraggble
|
||||
label='فایل مورد نظر را آپلود کنید'
|
||||
onChange={handleAttachmentChange}
|
||||
isMultiple={false}
|
||||
isFile
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex'>
|
||||
<div className='flex-1'></div>
|
||||
<div className='flex-1 flex gap-4'>
|
||||
<div className='w-[250px]'>
|
||||
<Input
|
||||
label='قیمت به ازای هر صفحه'
|
||||
value={`${PRICE_PER_PAGE.toLocaleString('fa-IR')} تومان`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label='مبلغ قابل پرداخت برای شما'
|
||||
value={`${totalPrice.toLocaleString('fa-IR')} تومان`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end mt-5'>
|
||||
<Button className='w-fit px-6'>ثبت درخواست</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default DesignerRequest
|
||||
export default DesignerRequest;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import Button from "@/components/Button";
|
||||
import DatePicker from "@/components/DatePicker";
|
||||
import Error from "@/components/Error";
|
||||
import Input from "@/components/Input";
|
||||
import Textarea from "@/components/Textarea";
|
||||
import { toast } from "@/components/Toast";
|
||||
import UploadBoxDraggble from "@/components/UploadBoxDraggble";
|
||||
import { Paths } from "@/config/Paths";
|
||||
import { formatPrice } from "@/helpers/func";
|
||||
import { extractErrorMessage } from "@/helpers/utils";
|
||||
import { designerRequestInitialValues, designerRequestValidationSchema, getPricePerPage, getTotalPrice, toDesignRequestParams } from "@/pages/designer/designerRequestForm";
|
||||
import { useInitiateDesignRequest } from "@/pages/designer/hooks/useDesignerData";
|
||||
import type { DesignerRequestFormValues } from "@/pages/designer/types/Types";
|
||||
import { useMultipleUpload } from "@/pages/uploader/hooks/useUploaderData";
|
||||
import { Form, Formik, useFormikContext, type FormikHelpers } from "formik";
|
||||
import { type FC } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const DesignerRequestFormFields: FC = () => {
|
||||
const { values, errors, touched, isSubmitting, setFieldValue, setFieldTouched, handleChange, handleBlur } = useFormikContext<DesignerRequestFormValues>();
|
||||
|
||||
const pageCount = values.count === "" ? 0 : values.count;
|
||||
const pricePerPage = pageCount > 0 ? getPricePerPage(pageCount) : 0;
|
||||
const totalPrice = getTotalPrice(values.count);
|
||||
|
||||
const handleAttachmentChange = (files: File[]) => {
|
||||
setFieldValue("attachments", files);
|
||||
setFieldTouched("attachments", true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form className="mt-6 space-y-5">
|
||||
<Input label="عنوان کاتالوگ" name="title" value={values.title} onChange={handleChange} onBlur={handleBlur} error_text={touched.title ? errors.title : undefined} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<DatePicker
|
||||
label="زمان تحویل موردنظر"
|
||||
placeholder="تاریخ را انتخاب کنید"
|
||||
defaulValue={values.expectedDate}
|
||||
onChange={(date) => {
|
||||
setFieldValue("expectedDate", date);
|
||||
setFieldTouched("expectedDate", true);
|
||||
}}
|
||||
error_text={touched.expectedDate ? errors.expectedDate : undefined}
|
||||
/>
|
||||
<Input
|
||||
label="تعداد صفحات"
|
||||
name="count"
|
||||
type="number"
|
||||
isNotRequired
|
||||
value={values.count}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setFieldValue("count", value === "" ? "" : Number(value));
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
error_text={touched.count ? errors.count : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea label="توضیحات" name="desc" value={values.desc} onChange={handleChange} onBlur={handleBlur} className="min-h-[96px]" error_text={touched.desc ? errors.desc : undefined} />
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-sm">فایل های ضمیمه</div>
|
||||
<UploadBoxDraggble label="فایل مورد نظر را آپلود کنید" onChange={handleAttachmentChange} isMultiple isFile isLoading={isSubmitting} loadingLabel="در حال ثبت درخواست..." />
|
||||
{touched.attachments && errors.attachments ? <Error errorText={String(errors.attachments)} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<div className="flex-1" />
|
||||
<div className="flex flex-1 gap-4">
|
||||
<div className="w-[250px]">
|
||||
<Input label="قیمت به ازای هر صفحه" value={pageCount > 0 ? `${formatPrice(pricePerPage)} تومان` : "—"} readOnly />
|
||||
</div>
|
||||
<Input label="مبلغ قابل پرداخت برای شما" value={`${formatPrice(totalPrice)} تومان`} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button type="submit" className="w-fit px-6" disabled={isSubmitting}>
|
||||
ثبت درخواست
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
const DesignerRequestForm: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { mutateAsync: uploadFiles } = useMultipleUpload();
|
||||
const { mutateAsync: initiateDesignRequest } = useInitiateDesignRequest();
|
||||
|
||||
const handleSubmit = async (values: DesignerRequestFormValues, { setSubmitting, resetForm }: FormikHelpers<DesignerRequestFormValues>) => {
|
||||
let attachmentUrls: string[] = [];
|
||||
|
||||
if (values.attachments.length > 0) {
|
||||
const uploadResponse = await uploadFiles(values.attachments);
|
||||
attachmentUrls = uploadResponse.data.map((item) => item.url);
|
||||
}
|
||||
|
||||
await initiateDesignRequest(toDesignRequestParams(values, attachmentUrls), {
|
||||
onSuccess: (data) => {
|
||||
toast("صورت حساب با موفقیت ثبت شد", "success");
|
||||
window.open(`${import.meta.env.VITE_INVOICE_URL}${data?.data?.invoiceId}`, "_blank");
|
||||
setSubmitting(false);
|
||||
resetForm();
|
||||
navigate(Paths.designer.list);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), "error");
|
||||
setSubmitting(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik initialValues={designerRequestInitialValues} validationSchema={designerRequestValidationSchema} onSubmit={handleSubmit}>
|
||||
<DesignerRequestFormFields />
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesignerRequestForm;
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
DesignRequestInitiateParams,
|
||||
DesignerRequestFormValues,
|
||||
} from "@/pages/designer/types/Types";
|
||||
import * as Yup from "yup";
|
||||
|
||||
const parseEnvNumber = (value: string | undefined, fallback: number): number => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
type DesignPriceTier = {
|
||||
maxPages: number;
|
||||
pricePerPage: number;
|
||||
};
|
||||
|
||||
const DESIGN_PRICE_TIERS: DesignPriceTier[] = [
|
||||
{
|
||||
maxPages: parseEnvNumber(import.meta.env.VITE_DESIGN_TIER_1_MAX, 10),
|
||||
pricePerPage: parseEnvNumber(import.meta.env.VITE_DESIGN_PRICE_TIER_1, 700_000),
|
||||
},
|
||||
{
|
||||
maxPages: parseEnvNumber(import.meta.env.VITE_DESIGN_TIER_2_MAX, 30),
|
||||
pricePerPage: parseEnvNumber(import.meta.env.VITE_DESIGN_PRICE_TIER_2, 500_000),
|
||||
},
|
||||
{
|
||||
maxPages: parseEnvNumber(import.meta.env.VITE_DESIGN_TIER_3_MAX, 70),
|
||||
pricePerPage: parseEnvNumber(import.meta.env.VITE_DESIGN_PRICE_TIER_3, 400_000),
|
||||
},
|
||||
];
|
||||
|
||||
const DESIGN_PRICE_TIER_4 = parseEnvNumber(
|
||||
import.meta.env.VITE_DESIGN_PRICE_TIER_4,
|
||||
350_000,
|
||||
);
|
||||
|
||||
export const getPricePerPage = (count: number): number => {
|
||||
const tier = DESIGN_PRICE_TIERS.find((item) => count <= item.maxPages);
|
||||
return tier?.pricePerPage ?? DESIGN_PRICE_TIER_4;
|
||||
};
|
||||
|
||||
export const designerRequestInitialValues: DesignerRequestFormValues = {
|
||||
title: "",
|
||||
count: "",
|
||||
desc: "",
|
||||
expectedDate: "",
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
export const designerRequestValidationSchema = Yup.object({
|
||||
title: Yup.string().trim().required("عنوان کاتالوگ الزامی است"),
|
||||
count: Yup.number()
|
||||
.transform((value, originalValue) =>
|
||||
originalValue === "" ? undefined : value,
|
||||
)
|
||||
.optional()
|
||||
.min(1, "حداقل تعداد صفحات ۱ است"),
|
||||
desc: Yup.string().trim(),
|
||||
expectedDate: Yup.string().required("زمان تحویل الزامی است"),
|
||||
attachments: Yup.array(),
|
||||
});
|
||||
|
||||
export const getTotalPrice = (count: DesignerRequestFormValues["count"]) => {
|
||||
if (count === "" || count < 1) return 0;
|
||||
return count * getPricePerPage(count);
|
||||
};
|
||||
|
||||
export const toDesignRequestParams = (
|
||||
values: DesignerRequestFormValues,
|
||||
attachmentUrls: string[],
|
||||
): DesignRequestInitiateParams => {
|
||||
const { attachments: _attachments, count, ...rest } = values;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
count: count === "" ? undefined : count,
|
||||
attachments: attachmentUrls,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import * as api from "../service/DesignerService";
|
||||
|
||||
export const useInitiateDesignRequest = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.initiateDesignRequest,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetDesignRequests = () => {
|
||||
return useQuery({
|
||||
queryKey: ["design-requests"],
|
||||
queryFn: api.getDesignRequests,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import axios from "@/config/axios";
|
||||
import type { DesignRequestInitiateParams, DesignRequestsResponse, RequestDesignResponse } from "../types/Types";
|
||||
|
||||
export const initiateDesignRequest = async (params: DesignRequestInitiateParams): Promise<RequestDesignResponse> => {
|
||||
const { data } = await axios.post("/admin/design-request/initiate", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getDesignRequests = async (): Promise<DesignRequestsResponse> => {
|
||||
const { data } = await axios.get("/admin/design-request");
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { BaseResponse } from "@/shared/types/SharedTypes";
|
||||
|
||||
export type DesignerRequestFormValues = {
|
||||
title: string;
|
||||
count: number | "";
|
||||
desc: string;
|
||||
expectedDate: string;
|
||||
attachments: File[];
|
||||
};
|
||||
|
||||
export type DesignRequestInitiateParams = Omit<
|
||||
DesignerRequestFormValues,
|
||||
"attachments" | "count"
|
||||
> & {
|
||||
count?: number;
|
||||
attachments: string[];
|
||||
};
|
||||
|
||||
export type DesignRequestBusinessType = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
danakSubscriptionId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
domain: string | null;
|
||||
isDomainVerified: boolean;
|
||||
domainVerificationToken: string | null;
|
||||
domainVerifiedAt: string | null;
|
||||
logoUrl: string | null;
|
||||
maxCataloguesCount: number;
|
||||
admin: string;
|
||||
};
|
||||
|
||||
export type DesignRequestType = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
count: number;
|
||||
desc: string;
|
||||
expectedDate: string;
|
||||
attachments: string[];
|
||||
invoiceId: string;
|
||||
business: DesignRequestBusinessType;
|
||||
};
|
||||
|
||||
export type RequestDesignResponse = BaseResponse<DesignRequestType>;
|
||||
|
||||
export type RequestDesignItemType = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
title: string;
|
||||
count: number;
|
||||
desc: string;
|
||||
expectedDate: string;
|
||||
attachments: string[];
|
||||
invoiceId: string;
|
||||
paidAt: string | null;
|
||||
business: string;
|
||||
};
|
||||
|
||||
export type DesignRequestsResponse = BaseResponse<RequestDesignItemType[]>;
|
||||
@@ -4,7 +4,10 @@ import Konva from "konva";
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useSharedStore } from "@/shared/store/sharedStore";
|
||||
import { useGetCatalogById, useUpdateCatalog } from "../home/hooks/useHomeData";
|
||||
import {
|
||||
useGetCatalogById,
|
||||
useUpdateCatalog,
|
||||
} from "@/pages/catalogue/hooks/useCatalogueData";
|
||||
import EditorCanvas from "./components/EditorCanvas";
|
||||
import EditorSidebar from "./components/EditorSidebar";
|
||||
import LayersPanel from "./components/LayersPanel";
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
VideoShape,
|
||||
AudioShape,
|
||||
} from "../tools";
|
||||
import Table from "@/components/Table";
|
||||
import EditorTable from "@/components/EditorTable";
|
||||
|
||||
type ObjectRendererProps = {
|
||||
obj: EditorObject;
|
||||
@@ -234,7 +234,7 @@ const ObjectRenderer = ({
|
||||
break;
|
||||
case "grid":
|
||||
shapeElement = (
|
||||
<Table
|
||||
<EditorTable
|
||||
key={obj.id}
|
||||
obj={obj}
|
||||
selectedCellId={selectedCellId || null}
|
||||
|
||||
@@ -6,11 +6,11 @@ import { toast } from "@/components/Toast";
|
||||
import { Paths } from "@/config/Paths";
|
||||
import { clx, extractErrorMessage } from "@/helpers/utils";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { useCreateCatalog } from "@/pages/catalogue/hooks/useCatalogueData";
|
||||
import { ArrowLeft, DocumentText } from "iconsax-react";
|
||||
import { useState, type FC } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DirectLogin from "../auth/components/DirectLogin";
|
||||
import { useCreateCatalog } from "./hooks/useHomeData";
|
||||
|
||||
const CATALOG_SIZES = [
|
||||
{ id: "a4", label: "A4" },
|
||||
@@ -71,7 +71,7 @@ const Home: FC = () => {
|
||||
type="button"
|
||||
onClick={() => setActive(id)}
|
||||
className={clx(
|
||||
"flex w-full h-[126px] sm:w-[120px] cursor-pointer items-center justify-center rounded-[20px] border border-border sm:h-[146px] sm:w-[140px] lg:h-[166px] lg:w-[160px]",
|
||||
"flex w-full h-[126px] cursor-pointer items-center justify-center rounded-[20px] border border-border sm:h-[146px] sm:w-[140px] lg:h-[166px] lg:w-[160px]",
|
||||
active === id && "border-black!",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as api from "../service/HomeService";
|
||||
|
||||
export const useCreateCatalog = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.createCatalog,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDleteCatalog = () => {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteCatalogById(id),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCatalog = () => {
|
||||
return useQuery({
|
||||
queryKey: ["catalogs"],
|
||||
queryFn: api.getCatalogs,
|
||||
});
|
||||
};
|
||||
|
||||
const catalogMongoIdPattern = /^[a-f\d]{24}$/i;
|
||||
|
||||
export const isCatalogMongoId = (value: string) =>
|
||||
catalogMongoIdPattern.test(value);
|
||||
|
||||
export const useGetCatalogById = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["catalog", id],
|
||||
queryFn: () => api.getCatalogById(id),
|
||||
enabled: !!id,
|
||||
// جلوگیری از بازنویسی state محلی با refetch هنگام فوکوس پنجره (مثلاً دو کاربر همزمان)
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
/** Route param may be Mongo id (editor/admin) or slug (public viewer-style URLs). */
|
||||
export const useGetCatalogByIdOrSlug = (param: string) => {
|
||||
const isId = isCatalogMongoId(param);
|
||||
const byId = useGetCatalogById(isId ? param : "");
|
||||
const bySlug = useGetCatalogBySlug(isId ? "" : param);
|
||||
return isId ? byId : bySlug;
|
||||
};
|
||||
|
||||
export const useGetCatalogBySlug = (slug: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["catalog", slug],
|
||||
queryFn: () => api.getCatalogBySlug(slug),
|
||||
enabled: !!slug,
|
||||
// جلوگیری از بازنویسی state محلی با refetch هنگام فوکوس پنجره (مثلاً دو کاربر همزمان)
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCatalog = () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: api.updateCatalog,
|
||||
});
|
||||
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingParamsRef = useRef<
|
||||
Parameters<typeof api.updateCatalog>[0] | null
|
||||
>(null);
|
||||
const [isScheduledSaving, setIsScheduledSaving] = useState(false);
|
||||
|
||||
const cancelPendingUpdate = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
pendingParamsRef.current = null;
|
||||
setIsScheduledSaving(false);
|
||||
}, []);
|
||||
|
||||
const scheduleUpdate = useCallback(
|
||||
(params: Parameters<typeof api.updateCatalog>[0], delayMs = 3000) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
pendingParamsRef.current = params;
|
||||
setIsScheduledSaving(true);
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
const pendingParams = pendingParamsRef.current;
|
||||
timeoutRef.current = null;
|
||||
pendingParamsRef.current = null;
|
||||
setIsScheduledSaving(false);
|
||||
if (pendingParams) {
|
||||
mutation.mutate(pendingParams);
|
||||
}
|
||||
}, delayMs);
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
// اگر کاربر قبل از پایان debounce از ادیتور خارج شد،
|
||||
// آخرین تغییرات معلق را همان لحظه ذخیره کن.
|
||||
if (pendingParamsRef.current) {
|
||||
mutation.mutate(pendingParamsRef.current);
|
||||
pendingParamsRef.current = null;
|
||||
}
|
||||
setIsScheduledSaving(false);
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
|
||||
return {
|
||||
...mutation,
|
||||
scheduleUpdate,
|
||||
cancelPendingUpdate,
|
||||
isSaving: isScheduledSaving || mutation.isPending,
|
||||
};
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import axios from "@/config/axios";
|
||||
import type {
|
||||
CatalogByIdResponseType,
|
||||
CatalogResponseType,
|
||||
} from "@/pages/catalogue/types/Types";
|
||||
import type {
|
||||
CreateCatalogParamsType,
|
||||
UpdateCatalogParamsType,
|
||||
} from "../types/Types";
|
||||
|
||||
export const createCatalog = async (params: CreateCatalogParamsType) => {
|
||||
const { data } = await axios.post("/admin/catalogue", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCatalogs = async () => {
|
||||
const { data } = await axios.get<CatalogResponseType>("/admin/catalogue");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCatalogById = async (id: string) => {
|
||||
const { data } = await axios.get<CatalogByIdResponseType>(
|
||||
`/admin/catalogue/${id}`,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCatalogBySlug = async (slug: string) => {
|
||||
const { data } = await axios.get<CatalogByIdResponseType>(
|
||||
`/public/catalogue/slug/${slug}`,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateCatalog = async (params: UpdateCatalogParamsType) => {
|
||||
const { data } = await axios.patch(`/admin/catalogue/${params.id}`, {
|
||||
content: params.content,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteCatalogById = async (id: string) => {
|
||||
const { data } = await axios.delete(`/admin/catalogue/${id}`);
|
||||
return data;
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
export type CreateCatalogParamsType = {
|
||||
name: string;
|
||||
slug: string;
|
||||
size: string;
|
||||
content?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type UpdateCatalogParamsType = {
|
||||
id: string;
|
||||
content: string;
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import type { DocumentSettings } from "@/pages/editor/store/editorStore";
|
||||
import {
|
||||
useGetCatalogById,
|
||||
useGetCatalogBySlug,
|
||||
} from "@/pages/home/hooks/useHomeData";
|
||||
} from "@/pages/catalogue/hooks/useCatalogueData";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import BookViewer from "./components/BookViewer";
|
||||
@@ -17,12 +17,19 @@ type ViewerProps = {
|
||||
bySlug?: boolean;
|
||||
};
|
||||
const Viewer: FC<ViewerProps> = ({ bySlug = false }) => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { id, businessSlug, slug } = useParams<{
|
||||
id?: string;
|
||||
businessSlug?: string;
|
||||
slug?: string;
|
||||
}>();
|
||||
const catalogKey = bySlug ? slug : id;
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error: queryError,
|
||||
} = bySlug ? useGetCatalogBySlug(id ?? "") : useGetCatalogById(id ?? "");
|
||||
} = bySlug
|
||||
? useGetCatalogBySlug(businessSlug ?? "", slug ?? "")
|
||||
: useGetCatalogById(id ?? "");
|
||||
usePageTitle(`کاتالوگ ${data?.data?.name}`);
|
||||
const [pages, setPages] = useState<PageData[]>([]);
|
||||
const [documentSettings, setDocumentSettings] = useState<
|
||||
@@ -30,7 +37,7 @@ const Viewer: FC<ViewerProps> = ({ bySlug = false }) => {
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.data?.content || !id) return;
|
||||
if (!data?.data?.content || !catalogKey) return;
|
||||
try {
|
||||
const parsed = JSON.parse(data.data.content);
|
||||
const pagesArray = Array.isArray(parsed) ? parsed : (parsed.pages ?? []);
|
||||
@@ -47,13 +54,13 @@ const Viewer: FC<ViewerProps> = ({ bySlug = false }) => {
|
||||
} catch (err) {
|
||||
console.error("خطا در پردازش محتوای کاتالوگ:", err);
|
||||
}
|
||||
}, [data?.data?.content, id]);
|
||||
}, [data?.data?.content, catalogKey]);
|
||||
|
||||
useBrowserFullscreen({ enabled: Boolean(id) });
|
||||
useBrowserFullscreen({ enabled: Boolean(catalogKey) });
|
||||
const { isReady: areInitialPagesReady, progress: preloadProgress } =
|
||||
useInitialPagesPreload(pages, documentSettings);
|
||||
|
||||
if (!id) {
|
||||
if (!catalogKey || (bySlug && !businessSlug)) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-gray-600">شناسه کاتالوگ مشخص نیست</div>
|
||||
|
||||
+26
-32
@@ -1,6 +1,8 @@
|
||||
import { isViewerPath, Paths } from "@/config/Paths";
|
||||
import { isBusinessCatalogPath, isViewerPath, Paths } from "@/config/Paths";
|
||||
import { clx } from "@/helpers/utils";
|
||||
import Business from "@/pages/business/Business";
|
||||
import CatalogueList from "@/pages/catalogue/List";
|
||||
import RequestDesignList from "@/pages/designer/List";
|
||||
import DesignerRequest from "@/pages/designer/Request";
|
||||
import Editor from "@/pages/editor/Editor";
|
||||
import Home from "@/pages/home/Home";
|
||||
@@ -15,47 +17,28 @@ const MainRouter = () => {
|
||||
const { hasSubMenu } = useSharedStore();
|
||||
const location = useLocation();
|
||||
const isViewerRoute = isViewerPath(location.pathname);
|
||||
const isBusinessCatalogRoute = isBusinessCatalogPath(location.pathname);
|
||||
const hideAppChrome = isViewerRoute || isBusinessCatalogRoute;
|
||||
const hiddenSideBarRoutes = [Paths.editor];
|
||||
const shouldRenderSideBar =
|
||||
!hiddenSideBarRoutes.includes(location.pathname) && !isViewerRoute;
|
||||
const shouldRenderSideBar = !hiddenSideBarRoutes.includes(location.pathname) && !hideAppChrome;
|
||||
const routeHasLocalSidebar = location.pathname.includes("editor");
|
||||
const hasSidebarSpace = shouldRenderSideBar || routeHasLocalSidebar;
|
||||
const headerSidebarSize = routeHasLocalSidebar ? "wide" : "default";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"flex min-h-full flex-col overflow-hidden",
|
||||
isViewerRoute ? "fixed inset-0 z-50 bg-neutral-100" : "p-4",
|
||||
)}
|
||||
>
|
||||
{!isViewerRoute && shouldRenderSideBar ? <SideBar /> : null}
|
||||
{!isViewerRoute ? (
|
||||
<Header
|
||||
hasMainSidebar={hasSidebarSpace}
|
||||
sidebarSize={headerSidebarSize}
|
||||
/>
|
||||
) : null}
|
||||
<div className={clx("flex min-h-full flex-col overflow-hidden", isViewerRoute ? "fixed inset-0 z-50 bg-neutral-100" : "p-4")}>
|
||||
{!hideAppChrome && shouldRenderSideBar ? <SideBar /> : null}
|
||||
{!hideAppChrome ? <Header hasMainSidebar={hasSidebarSpace} sidebarSize={headerSidebarSize} /> : null}
|
||||
<div
|
||||
className={clx(
|
||||
"flex flex-1 flex-col min-h-0",
|
||||
!isViewerRoute && "mt-[68px] xl:mt-[81px]",
|
||||
!isViewerRoute && shouldRenderSideBar && "xl:ms-[269px]",
|
||||
!isViewerRoute &&
|
||||
shouldRenderSideBar &&
|
||||
hasSubMenu &&
|
||||
"xl:ms-[305px]",
|
||||
!isViewerRoute && routeHasLocalSidebar && "xl:mr-[374px]",
|
||||
!hideAppChrome && "mt-[68px] xl:mt-[81px]",
|
||||
!hideAppChrome && shouldRenderSideBar && "xl:ms-[269px]",
|
||||
!hideAppChrome && shouldRenderSideBar && hasSubMenu && "xl:ms-[305px]",
|
||||
!hideAppChrome && routeHasLocalSidebar && "xl:mr-[374px]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"flex-1 flex flex-col w-full min-h-0",
|
||||
isViewerRoute
|
||||
? "h-full overflow-hidden"
|
||||
: "overflow-auto max-h-[calc(100vh-113px)]",
|
||||
)}
|
||||
>
|
||||
<div className={clx("flex-1 flex flex-col w-full min-h-0", isViewerRoute ? "h-full overflow-hidden" : "overflow-auto max-h-[calc(100vh-113px)]")}>
|
||||
<div className="flex-1 h-full flex min-h-0">
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -83,13 +66,16 @@ const MainRouter = () => {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={Paths.viewerBySlug + "/:id"}
|
||||
path={Paths.viewerBySlug + "/:businessSlug/:slug"}
|
||||
element={
|
||||
<PrivateRoute requireAuth={false}>
|
||||
<Viewer bySlug={true} />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path={Paths.catalog.business + ":slug"} element={<Business />} />
|
||||
|
||||
<Route
|
||||
path={Paths.catalog.list}
|
||||
element={
|
||||
@@ -106,6 +92,14 @@ const MainRouter = () => {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={Paths.designer.list}
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<RequestDesignList />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+12
-72
@@ -1,11 +1,11 @@
|
||||
import Input from "@/components/Input";
|
||||
import { type FC, useEffect } from "react";
|
||||
// import { ArrowDown2, CloseCircle, HambergerMenu, Logout, Wallet } from 'iconsax-react'
|
||||
// import AvatarImage from '../assets/images/avatar_image.png'
|
||||
import { Paths } from "@/config/Paths";
|
||||
import { clx } from "@/helpers/utils";
|
||||
import { useGetBusiness } from "@/pages/business/hooks/useBusinessData";
|
||||
import HeaderUserMenu from "@/shared/components/HeaderUserMenu";
|
||||
import { useSharedStore } from "@/shared/store/sharedStore";
|
||||
import { Eye, HambergerMenu } from "iconsax-react";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
@@ -34,8 +34,6 @@ const Header: FC<HeaderProps> = ({
|
||||
hasMainSidebar = true,
|
||||
sidebarSize = "default",
|
||||
}) => {
|
||||
// const location = useLocation();
|
||||
// const [popoverKey, setPopoverKey] = useState(0);
|
||||
const { t } = useTranslation("global");
|
||||
const {
|
||||
setOpenSidebar,
|
||||
@@ -44,6 +42,8 @@ const Header: FC<HeaderProps> = ({
|
||||
setSearch,
|
||||
editorCatalogSlug,
|
||||
} = useSharedStore();
|
||||
|
||||
const { data: business } = useGetBusiness();
|
||||
const location = useLocation();
|
||||
const editorPathPrefix = `${Paths.editor}/`;
|
||||
const isEditorRoute =
|
||||
@@ -51,16 +51,6 @@ const Header: FC<HeaderProps> = ({
|
||||
location.pathname.length > editorPathPrefix.length;
|
||||
const viewerSlug = isEditorRoute ? (editorCatalogSlug ?? "") : "";
|
||||
|
||||
// useEffect(() => {
|
||||
// setPopoverKey((prevKey) => prevKey + 1);
|
||||
// }, [location.pathname]);
|
||||
|
||||
// const handleLogout = () => {
|
||||
// removeToken()
|
||||
// removeRefreshToken()
|
||||
// window.location.href = Pages.auth.login
|
||||
// }
|
||||
|
||||
useEffect(() => {
|
||||
setSearch("");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -83,79 +73,29 @@ const Header: FC<HeaderProps> = ({
|
||||
onChangeSearchFinal={(value) => setSearch(value)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenSidebar(!openSidebar)}
|
||||
className="xl:hidden block"
|
||||
>
|
||||
<HambergerMenu size={24} color="black" />
|
||||
</div>
|
||||
{/* <img src={LogoImage} className='h-6 xl:hidden block absolute right-0 left-0 mx-auto' /> */}
|
||||
</button>
|
||||
|
||||
<div className="flex xl:gap-6 gap-4 items-center">
|
||||
{isEditorRoute && viewerSlug ? (
|
||||
<Link
|
||||
target="_blank"
|
||||
to={`${Paths.viewerBySlug}/${viewerSlug}`}
|
||||
to={`${Paths.viewerBySlug}/${business?.data?.slug}/${viewerSlug}`}
|
||||
className="flex items-center"
|
||||
aria-label={t("header.open_viewer")}
|
||||
title={t("header.open_viewer")}
|
||||
// onClick={requestViewerFullscreen}
|
||||
>
|
||||
<Eye size={20} color="black" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{/* <Link className='xl:hidden' to={Paths.wallet}>
|
||||
<Wallet className='xl:size-[18px] size-[17px]' color='#da2129' />
|
||||
</Link> */}
|
||||
{/* <Notifications /> */}
|
||||
{/* {
|
||||
data && (
|
||||
<Popover className="relative" key={popoverKey}>
|
||||
<PopoverButton >
|
||||
<div className='flex gap-2 items-center mt-2.5'>
|
||||
<div className='size-6 rounded-full bg-description overflow-hidden'>
|
||||
<img src={data?.data?.user?.profilePic ? data?.data?.user?.profilePic : AvatarImage} className='size-full object-cover' />
|
||||
</div>
|
||||
<div className='xl:flex hidden gap-1 items-center'>
|
||||
<div className='text-xs'>
|
||||
{data?.data?.user?.firstName + ' ' + data?.data?.user?.lastName}
|
||||
</div>
|
||||
<ArrowDown2 size={14} color='#8C90A3' />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel style={{ minHeight: window.innerWidth < 1140 ? window.innerHeight : undefined }} anchor="bottom" className="flex xl:ml-6 overflow-auto flex-col gap-3 bg-white boxShadow xl:mt-7 z-30 py-4 text-xs rounded-2.5 xl:w-[300px] -mt-[50px] w-full fixed xl:h-fit h-full shadow-md">
|
||||
<div className='absolute xl:hidden top-6 left-6'>
|
||||
<CloseCircle onClick={() => setPopoverKey((prevKey) => prevKey + 1)} size={20} color='#da2129' />
|
||||
</div>
|
||||
<Link to={Pages.profile} className='flex flex-col gap-2 items-center justify-center border-b border-[#EAEDF5] pb-4'>
|
||||
<div className='size-14 rounded-full overflow-hidden'>
|
||||
<img src={data?.data?.user?.profilePic ? data?.data?.user?.profilePic : AvatarImage} className='size-full object-cover' />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{data?.data?.user?.firstName + ' ' + data?.data?.user?.lastName}
|
||||
</div>
|
||||
|
||||
<div className='text-description'>
|
||||
{data?.data?.user?.email}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className='px-6 mt-2'>
|
||||
<div onClick={() => handleLogout()} className='flex gap-2 items-center cursor-pointer'>
|
||||
<Logout size={20} color='#da2129' />
|
||||
<div>
|
||||
{t('sidebar.logout')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
)
|
||||
} */}
|
||||
<HeaderUserMenu />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+93
-109
@@ -1,124 +1,108 @@
|
||||
import { type FC, useEffect } from 'react'
|
||||
import LogoImage from '../assets/images/logo.svg'
|
||||
import LogoSmall from '../assets/images/logo-small.svg'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DocumentText,
|
||||
Home2,
|
||||
Logout,
|
||||
} from 'iconsax-react'
|
||||
import SideBarItem from './SideBarItem'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useSharedStore } from './store/sharedStore'
|
||||
import { clx } from '../helpers/utils'
|
||||
import { Paths } from '../config/Paths'
|
||||
import BuyCatalog from './components/BuyCatalog'
|
||||
|
||||
import { Brush2, DocumentText, Home2, Logout } from "iconsax-react";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import LogoSmall from "../assets/images/logo-small.svg";
|
||||
import LogoImage from "../assets/images/logo.svg";
|
||||
import { Paths } from "../config/Paths";
|
||||
import { clx } from "../helpers/utils";
|
||||
import BuyCatalog from "./components/BuyCatalog";
|
||||
import SideBarItem from "./SideBarItem";
|
||||
import { useSharedStore } from "./store/sharedStore";
|
||||
|
||||
const SideBar: FC = () => {
|
||||
const { t } = useTranslation("global");
|
||||
const { openSidebar, setOpenSidebar, hasSubMenu, setSubMenuName, setSubtMenu, subMenuName } = useSharedStore();
|
||||
const location = useLocation();
|
||||
|
||||
const { t } = useTranslation('global')
|
||||
const { openSidebar, setOpenSidebar, hasSubMenu, setSubMenuName, setSubtMenu, subMenuName } = useSharedStore()
|
||||
const location = useLocation()
|
||||
const isActive = (path: string) => {
|
||||
const pathname = location.pathname;
|
||||
if (path === "/") return pathname === "/";
|
||||
return pathname === path || pathname.startsWith(path + "/");
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
const pathname = location.pathname
|
||||
if (path === '/') return pathname === '/'
|
||||
return pathname === path || pathname.startsWith(path + '/')
|
||||
useEffect(() => {
|
||||
const split = location.pathname.split("/");
|
||||
|
||||
if (split[1] === "dashboard" || split[1] === "products") {
|
||||
setSubMenuName(split[1]);
|
||||
setSubtMenu(true);
|
||||
} else {
|
||||
setSubMenuName("");
|
||||
setSubtMenu(false);
|
||||
}
|
||||
}, [location.pathname, setSubMenuName, setSubtMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const split = location.pathname.split('/')
|
||||
const iconSizeSideBar = 20;
|
||||
|
||||
if (split[1] === 'dashboard' || split[1] === 'products') {
|
||||
setSubMenuName(split[1])
|
||||
setSubtMenu(true)
|
||||
} else {
|
||||
setSubMenuName('')
|
||||
setSubtMenu(false)
|
||||
}
|
||||
}, [location.pathname, setSubMenuName, setSubtMenu])
|
||||
return (
|
||||
<>
|
||||
{openSidebar && <div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 bg-opacity-50 z-10" onClick={() => setOpenSidebar(false)} />}
|
||||
<div
|
||||
className={clx(
|
||||
"fixed xl:flex translate-x-[400px] xl:translate-x-0 transition-all ease-in-out opacity-0 invisible xl:visible xl:opacity-100 xl:right-4 right-0 xl:top-4 top-0 xl:bottom-4 bottom-0 xl:rounded-[32px] w-[250px] bg-white flex-col py-12",
|
||||
openSidebar && "opacity-100 visible translate-x-0 block z-40",
|
||||
hasSubMenu && "w-24 rounded-tl-none! rounded-bl-none!",
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-center">{!hasSubMenu ? <img src={LogoImage} className="w-[140px]" /> : <img src={LogoSmall} className="w-7" />}</div>
|
||||
|
||||
const iconSizeSideBar = 20
|
||||
<div className="flex-1 flex flex-col h-full overflow-y-auto no-scrollbar">
|
||||
<div className={clx("mt-10 px-12 text-header text-sm font-normal", hasSubMenu && "px-2 text-center")}>{t("sidebar.menu")}</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
openSidebar && <div className='fixed top-0 left-0 right-0 bottom-0 bg-black/50 bg-opacity-50 z-10' onClick={() => setOpenSidebar(false)} />
|
||||
}
|
||||
<div
|
||||
className={clx(
|
||||
'fixed xl:flex translate-x-[400px] xl:translate-x-0 transition-all ease-in-out opacity-0 invisible xl:visible xl:opacity-100 xl:right-4 right-0 xl:top-4 top-0 xl:bottom-4 bottom-0 xl:rounded-[32px] w-[250px] bg-white flex-col py-12',
|
||||
openSidebar && 'opacity-100 visible translate-x-0 block z-40',
|
||||
hasSubMenu && 'w-24 rounded-tl-none! rounded-bl-none!'
|
||||
)}
|
||||
>
|
||||
<div className='flex justify-center'>
|
||||
{
|
||||
!hasSubMenu ?
|
||||
<img src={LogoImage} className='w-[140px]' />
|
||||
:
|
||||
<img src={LogoSmall} className='w-7' />
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-[#8C90A3]">
|
||||
<SideBarItem
|
||||
icon={<Home2 variant={isActive(Paths.home) ? "Bold" : "Outline"} color={isActive(Paths.home) ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
|
||||
title={t("sidebar.home_page")}
|
||||
isActive={isActive(Paths.home)}
|
||||
link={Paths.home}
|
||||
activeName={Paths.home}
|
||||
/>
|
||||
|
||||
<div className='flex-1 flex flex-col h-full overflow-y-auto no-scrollbar'>
|
||||
<SideBarItem
|
||||
icon={<DocumentText variant={isActive(Paths.catalog.list) ? "Bold" : "Outline"} color={isActive(Paths.catalog.list) ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
|
||||
title={t("sidebar.catalog")}
|
||||
isActive={isActive(Paths.catalog.list)}
|
||||
link={Paths.catalog.list}
|
||||
activeName={Paths.catalog.list}
|
||||
/>
|
||||
|
||||
<div className={clx(
|
||||
'mt-10 px-12 text-header text-sm font-normal',
|
||||
hasSubMenu && 'px-2 text-center'
|
||||
)}>
|
||||
{t('sidebar.menu')}
|
||||
</div>
|
||||
<SideBarItem
|
||||
icon={<Brush2 variant={isActive(Paths.designer.list) ? "Bold" : "Outline"} color={isActive(Paths.designer.list) ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
|
||||
title={t("sidebar.request_design")}
|
||||
isActive={isActive(Paths.designer.list)}
|
||||
link={Paths.designer.list}
|
||||
activeName={Paths.designer.list}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='text-xs text-[#8C90A3]'>
|
||||
<SideBarItem
|
||||
icon={<Home2 variant={isActive(Paths.home) ? 'Bold' : 'Outline'} color={isActive(Paths.home) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||
title={t('sidebar.home_page')}
|
||||
isActive={isActive(Paths.home)}
|
||||
link={Paths.home}
|
||||
activeName={Paths.home}
|
||||
/>
|
||||
|
||||
<SideBarItem
|
||||
icon={<DocumentText variant={isActive(Paths.catalog.list) ? 'Bold' : 'Outline'} color={isActive(Paths.catalog.list) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||
title={t('sidebar.catalog')}
|
||||
isActive={isActive(Paths.catalog.list)}
|
||||
link={Paths.catalog.list}
|
||||
activeName={Paths.catalog.list}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 flex flex-col justify-end mt-14'>
|
||||
<BuyCatalog />
|
||||
<div className='text-xs text-[#8C90A3] mt-5'>
|
||||
<SideBarItem
|
||||
icon={<Logout variant={isActive('logout') ? 'Bold' : 'Outline'} color={isActive('logout') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||
title="خروج"
|
||||
isActive={isActive('logout')}
|
||||
link={`#`}
|
||||
isLogout
|
||||
activeName=''
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-end mt-14">
|
||||
<BuyCatalog />
|
||||
<div className="text-xs text-[#8C90A3] mt-5">
|
||||
<SideBarItem
|
||||
icon={<Logout variant={isActive("logout") ? "Bold" : "Outline"} color={isActive("logout") ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
|
||||
title="خروج"
|
||||
isActive={isActive("logout")}
|
||||
link={`#`}
|
||||
isLogout
|
||||
activeName=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* منوی فرعی */}
|
||||
{
|
||||
hasSubMenu && (openSidebar || window.innerWidth > 1139) &&
|
||||
<div className='fixed xl:right-[112px] right-[96px] bg-white z-20 xl:top-4 xl:bottom-4 top-0 bottom-0 rounded-tl-[32px] rounded-bl-[32px] w-[190px] border-r border-border'>
|
||||
{
|
||||
subMenuName === 'dashboard' ? null
|
||||
// <DashboardSubMenu />
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{/* منوی فرعی */}
|
||||
{hasSubMenu && (openSidebar || window.innerWidth > 1139) && (
|
||||
<div className="fixed xl:right-[112px] right-[96px] bg-white z-20 xl:top-4 xl:bottom-4 top-0 bottom-0 rounded-tl-[32px] rounded-bl-[32px] w-[190px] border-r border-border">
|
||||
{subMenuName === "dashboard"
|
||||
? null
|
||||
: // <DashboardSubMenu />
|
||||
null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default SideBar
|
||||
export default SideBar;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { removeRefreshToken, removeToken } from "@/config/func";
|
||||
import { Paths } from "@/config/Paths";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useGetBusiness } from "@/pages/business/hooks/useBusinessData";
|
||||
import {
|
||||
getAdminFullName,
|
||||
type BusinessType,
|
||||
} from "@/pages/business/types/Types";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import { ArrowDown2, CloseCircle, Logout } from "iconsax-react";
|
||||
import { useState, type FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type BusinessLogoProps = {
|
||||
business: BusinessType;
|
||||
size?: "sm" | "lg";
|
||||
};
|
||||
|
||||
const BusinessLogo: FC<BusinessLogoProps> = ({ business, size = "sm" }) => {
|
||||
const initial = business.name.trim().charAt(0).toUpperCase();
|
||||
const sizeClass = size === "lg" ? "size-14 text-xl" : "size-6 text-[11px]";
|
||||
|
||||
if (business.logoUrl) {
|
||||
return (
|
||||
<div
|
||||
className={`${sizeClass} rounded-full overflow-hidden bg-description`}
|
||||
>
|
||||
<img src={business.logoUrl} className="size-full object-cover" alt="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${sizeClass} rounded-full pt-[2px] bg-secondary flex items-center justify-center font-medium text-desc leading-none select-none`}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HeaderUserMenu: FC = () => {
|
||||
const [popoverKey, setPopoverKey] = useState(0);
|
||||
const { t } = useTranslation("global");
|
||||
const { data: businessResponse } = useGetBusiness();
|
||||
const { refreshAuth } = useAuth();
|
||||
|
||||
const business = businessResponse?.data;
|
||||
const admin = business?.admin;
|
||||
const adminName = getAdminFullName(admin);
|
||||
|
||||
if (!business) return null;
|
||||
|
||||
const handleLogout = () => {
|
||||
removeToken();
|
||||
removeRefreshToken();
|
||||
refreshAuth();
|
||||
window.location.href = Paths.home;
|
||||
};
|
||||
|
||||
const closeMobilePanel = () => setPopoverKey((key) => key + 1);
|
||||
|
||||
return (
|
||||
<Popover className="relative" key={popoverKey}>
|
||||
<PopoverButton className="flex items-center gap-2 outline-none">
|
||||
<BusinessLogo business={business} />
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
<span className="text-xs">{adminName}</span>
|
||||
<ArrowDown2 size={14} color="#8C90A3" />
|
||||
</div>
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel
|
||||
style={{
|
||||
minHeight: window.innerWidth < 1140 ? window.innerHeight : undefined,
|
||||
}}
|
||||
anchor="bottom"
|
||||
className="flex xl:ml-6 overflow-auto flex-col gap-3 bg-white boxShadow xl:mt-7 z-30 py-4 text-xs rounded-2.5 xl:w-[300px] -mt-[50px] w-full fixed xl:h-fit h-full shadow-md"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute xl:hidden top-6 left-6"
|
||||
onClick={closeMobilePanel}
|
||||
>
|
||||
<CloseCircle size={20} color="#da2129" />
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to={`${Paths.catalog.business}${business.slug}`}
|
||||
className="flex flex-col gap-2 items-center justify-center border-b border-[#EAEDF5] pb-4"
|
||||
>
|
||||
<BusinessLogo business={business} size="lg" />
|
||||
<div>{adminName}</div>
|
||||
<div className="text-description">{business.name}</div>
|
||||
{admin?.phone ? (
|
||||
<div className="text-description">{admin.phone}</div>
|
||||
) : null}
|
||||
</Link>
|
||||
|
||||
<div className="px-6 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex gap-2 items-center cursor-pointer"
|
||||
>
|
||||
<Logout size={20} color="#da2129" />
|
||||
<span>{t("sidebar.logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderUserMenu;
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_TOKEN_NAME: string;
|
||||
readonly VITE_REFRESH_TOKEN_NAME: string;
|
||||
readonly VITE_INVOICE_URL: string;
|
||||
readonly VITE_DESIGN_TIER_1_MAX: string;
|
||||
readonly VITE_DESIGN_PRICE_TIER_1: string;
|
||||
readonly VITE_DESIGN_TIER_2_MAX: string;
|
||||
readonly VITE_DESIGN_PRICE_TIER_2: string;
|
||||
readonly VITE_DESIGN_TIER_3_MAX: string;
|
||||
readonly VITE_DESIGN_PRICE_TIER_3: string;
|
||||
readonly VITE_DESIGN_PRICE_TIER_4: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user