Compare commits
16 Commits
81a786d2a4
...
9b00a22d11
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b00a22d11 | |||
| 03fc293ee9 | |||
| 2804cc4100 | |||
| 17717eb195 | |||
| 0138261643 | |||
| 6653e3db82 | |||
| 032050bacc | |||
| c65e8cd303 | |||
| d95a6e679e | |||
| 3c9bb6274f | |||
| 8dd00d4cc7 | |||
| 3c833df63c | |||
| 4daa03d0de | |||
| 172cbea22c | |||
| 8014fe7ad3 | |||
| d977c20744 |
@@ -2,3 +2,11 @@ VITE_API_URL=https://dpage-api.danakcorp.com
|
|||||||
# VITE_API_URL=http://192.168.99.131:4000
|
# VITE_API_URL=http://192.168.99.131:4000
|
||||||
VITE_TOKEN_NAME=dpage-editor-t
|
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_API_URL
|
||||||
ARG VITE_TOKEN_NAME
|
ARG VITE_TOKEN_NAME
|
||||||
ARG VITE_REFRESH_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 ./
|
COPY package*.json ./
|
||||||
RUN npm ci --legacy-peer-deps
|
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_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_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_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"
|
eval "env$env_args npm run build"
|
||||||
|
|
||||||
FROM nginx:stable-alpine AS production-stage
|
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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@liara/cli": "^9.4.7",
|
"@headlessui/react": "^2.2.10",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"formik": "^2.4.9",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"i18next": "^25.6.2",
|
"i18next": "^25.6.2",
|
||||||
"iconsax-react": "^0.0.8",
|
"iconsax-react": "^0.0.8",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"use-image": "^1.1.4",
|
"use-image": "^1.1.4",
|
||||||
|
"yup": "^1.7.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import persian_fa from 'react-date-object/locales/persian_fa';
|
|||||||
import DateObject from 'react-date-object';
|
import DateObject from 'react-date-object';
|
||||||
import { clx } from '../helpers/utils';
|
import { clx } from '../helpers/utils';
|
||||||
import { Calendar } from 'iconsax-react';
|
import { Calendar } from 'iconsax-react';
|
||||||
|
import Error from './Error';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onChange: (date: string) => void;
|
onChange: (date: string) => void;
|
||||||
@@ -88,10 +89,8 @@ const DatePickerComponent: FC<Props> = (props: Props) => {
|
|||||||
<div className='text-xs'>
|
<div className='text-xs'>
|
||||||
{props.label}
|
{props.label}
|
||||||
</div>}
|
</div>}
|
||||||
<div className={clx(
|
<div className={clx(props.label && 'mt-1.5')}>
|
||||||
'relative ',
|
<div className="relative">
|
||||||
props.label && 'mt-1.5'
|
|
||||||
)}>
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -101,13 +100,11 @@ const DatePickerComponent: FC<Props> = (props: Props) => {
|
|||||||
calendarPosition="bottom-right"
|
calendarPosition="bottom-right"
|
||||||
className={`rmdp-mobile ${props.className}`}
|
className={`rmdp-mobile ${props.className}`}
|
||||||
/>
|
/>
|
||||||
{props.error_text && props.error_text !== '' && (
|
<Calendar size={18} color='black' className='pointer-events-none absolute top-0 bottom-0 left-2 my-auto' />
|
||||||
<div className="text-xs text-right text-red-600 mt-2 mr-2 font-medium">
|
|
||||||
{props.error_text}
|
|
||||||
</div>
|
</div>
|
||||||
|
{props.error_text && props.error_text !== '' && (
|
||||||
|
<Error errorText={props.error_text} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Calendar size={18} color='black' className='absolute top-0 bottom-0 my-auto left-2' />
|
|
||||||
</div>
|
</div>
|
||||||
</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;
|
||||||
+93
-119
@@ -1,134 +1,108 @@
|
|||||||
import React from "react";
|
import DefaultTableSkeleton from "@/components/DefaultTableSkeleton";
|
||||||
import { Group, Rect, Text } from "react-konva";
|
import Td from "@/components/Td";
|
||||||
import Konva from "konva";
|
import type { ColumnType, RowDataType, TableProps } from "@/components/types/TableTypes";
|
||||||
import type { EditorObject } from "@/pages/editor/store/editorStore";
|
import { type ReactElement } from "react";
|
||||||
|
|
||||||
type TableProps = {
|
const Table = <T extends RowDataType>({
|
||||||
obj: EditorObject;
|
columns,
|
||||||
selectedCellId: string | null;
|
data,
|
||||||
onCellClick: (tableId: string, cellId: string, node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => void;
|
isLoading = false,
|
||||||
onCellDblClick: (tableId: string, cellId: string, x: number, y: number, width: number, height: number, text: string) => void;
|
onRowClick,
|
||||||
onDragEnd: (tableId: string, x: number, y: number) => void;
|
className = "",
|
||||||
draggable: boolean;
|
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 = ({
|
const getCellClassName = (column: ColumnType<T>): string => {
|
||||||
obj,
|
const alignClass = column.align ? `text-${column.align}` : "";
|
||||||
selectedCellId,
|
return `px-3 md:px-6 py-3 md:py-4 whitespace-nowrap text-xs ${alignClass} ${column.className || ""}`.trim();
|
||||||
onCellClick,
|
};
|
||||||
onCellDblClick,
|
|
||||||
onDragEnd,
|
|
||||||
draggable,
|
|
||||||
}: TableProps) => {
|
|
||||||
const groupRef = React.useRef<Konva.Group>(null);
|
|
||||||
const tableData = obj.tableData;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
const handleRowClick = (item: T) => {
|
||||||
if (groupRef.current) {
|
if (onRowClick) {
|
||||||
groupRef.current.setAttr("id", obj.id);
|
onRowClick(item.id, item);
|
||||||
}
|
|
||||||
}, [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>) => {
|
const renderTableBody = () => {
|
||||||
e.cancelBubble = true;
|
if (isLoading) {
|
||||||
const stage = e.target.getStage();
|
return <DefaultTableSkeleton tdCount={columns.length} trCount={emptyRowsCount} />;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Group
|
<tr>
|
||||||
ref={groupRef}
|
<td colSpan={columns.length} className="px-3 md:px-6 py-6 md:py-8 text-center text-gray-500">
|
||||||
x={obj.x}
|
{noDataMessage}
|
||||||
y={obj.y}
|
</td>
|
||||||
draggable={draggable}
|
</tr>
|
||||||
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>
|
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 renderHeader = () => {
|
||||||
|
if (!showHeader || actionsPosition === "header-replace") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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;
|
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",
|
viewerBySlug: "/catalogue",
|
||||||
catalog: {
|
catalog: {
|
||||||
list: "/catalogue",
|
list: "/catalogue",
|
||||||
|
business: "/business/",
|
||||||
},
|
},
|
||||||
designer: {
|
designer: {
|
||||||
request: "/designer/request",
|
request: "/designer/request",
|
||||||
|
list: "/designer/requests/list",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewerPathPrefix = `${Paths.viewer}/`;
|
const viewerPathPrefix = `${Paths.viewer}/`;
|
||||||
const viewerBySlugPathPrefix = `${Paths.viewerBySlug}/`;
|
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). */
|
/** Full-screen viewer: /viewer/:id or /catalogue/:slug (not the list at /catalogue). */
|
||||||
export const isViewerPath = (pathname: string): boolean => {
|
export const isViewerPath = (pathname: string): boolean => {
|
||||||
const isViewerById =
|
const isViewerById = pathname.startsWith(viewerPathPrefix) && pathname.length > viewerPathPrefix.length;
|
||||||
pathname.startsWith(viewerPathPrefix) &&
|
const isViewerBySlug = pathname.startsWith(viewerBySlugPathPrefix) && pathname.length > viewerBySlugPathPrefix.length;
|
||||||
pathname.length > viewerPathPrefix.length;
|
|
||||||
const isViewerBySlug =
|
|
||||||
pathname.startsWith(viewerBySlugPathPrefix) &&
|
|
||||||
pathname.length > viewerBySlugPathPrefix.length;
|
|
||||||
return isViewerById || isViewerBySlug;
|
return isViewerById || isViewerBySlug;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
"sidebar": {
|
"sidebar": {
|
||||||
"menu": "منو",
|
"menu": "منو",
|
||||||
"home_page": "صفحه اصلی",
|
"home_page": "صفحه اصلی",
|
||||||
"catalog": "کاتالوگ"
|
"catalog": "کاتالوگ",
|
||||||
|
"logout": "خروج",
|
||||||
|
"request_design": "درخواست طراحی"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "جستجو",
|
"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 GridWrapper from "@/components/GridWrapper";
|
||||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||||
import { type FC } from "react";
|
import { type FC } from "react";
|
||||||
import { useGetCatalog } from "../home/hooks/useHomeData";
|
import { useGetCatalog } from "./hooks/useCatalogueData";
|
||||||
import ButtonAddCatalogue from "./components/ButtonAddCatalogue";
|
import ButtonAddCatalogue from "./components/ButtonAddCatalogue";
|
||||||
|
import CatalogueGridSkeleton from "./components/CatalogueGridSkeleton";
|
||||||
import CatalogueItem from "./components/CatalogueItem";
|
import CatalogueItem from "./components/CatalogueItem";
|
||||||
|
|
||||||
const CatalogueList: FC = () => {
|
const CatalogueList: FC = () => {
|
||||||
usePageTitle("لیست کاتالوگها");
|
usePageTitle("لیست کاتالوگها");
|
||||||
const { data, refetch } = useGetCatalog();
|
const { data, isPending, refetch } = useGetCatalog();
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="w-full mt-5">
|
||||||
|
<CatalogueGridSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full mt-5">
|
<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 TrashWithConfrim from "@/components/TrashWithConfrim";
|
||||||
import { Paths } from "@/config/Paths";
|
import { Paths } from "@/config/Paths";
|
||||||
import { extractErrorMessage } from "@/helpers/utils";
|
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 { requestViewerFullscreen } from "@/pages/viewer/utils/viewerFullscreen";
|
||||||
import { Edit, Eye, Share } from "iconsax-react";
|
import { Edit, Eye, Share } from "iconsax-react";
|
||||||
import moment from "moment-jalaali";
|
import moment from "moment-jalaali";
|
||||||
@@ -17,9 +18,10 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CatalogueItem: FC<Props> = ({ item, refetch }) => {
|
const CatalogueItem: FC<Props> = ({ item, refetch }) => {
|
||||||
|
const { data: business } = useGetBusiness();
|
||||||
const deleteCatalog = useDleteCatalog();
|
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 () => {
|
const handleShare = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -68,7 +70,7 @@ const CatalogueItem: FC<Props> = ({ item, refetch }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-1 items-center mt-1">
|
<div className="flex justify-end gap-1 items-center mt-1">
|
||||||
<Link
|
<Link
|
||||||
to={Paths.viewerBySlug + `/${item.slug}`}
|
to={Paths.viewerBySlug + `/${business?.data?.slug}/${item.slug}`}
|
||||||
onClick={requestViewerFullscreen}
|
onClick={requestViewerFullscreen}
|
||||||
>
|
>
|
||||||
<div className="size-6 bg-[#EAECF4] rounded-md flex justify-center items-center">
|
<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";
|
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 = () => {
|
export const usePurchaseInitate = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: api.purchaseInitate,
|
mutationFn: api.purchaseInitate,
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
import axios from "@/config/axios";
|
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) => {
|
export const purchaseInitate = async (params: PurchaseInitiate) => {
|
||||||
const { data } = await axios.post(
|
const { data } = await axios.post(
|
||||||
|
|||||||
@@ -16,3 +16,16 @@ export type CatalogByIdResponseType = BaseResponse<CatalogItemType>;
|
|||||||
export type PurchaseInitiate = {
|
export type PurchaseInitiate = {
|
||||||
count: number;
|
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;
|
||||||
+13
-101
@@ -1,106 +1,18 @@
|
|||||||
import { usePageTitle } from '@/hooks/usePageTitle'
|
import DesignerRequestForm from "@/pages/designer/components/DesignerRequestForm";
|
||||||
import { useMemo, useState, type FC } from 'react'
|
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||||
import Button from '@/components/Button'
|
import { type FC } from "react";
|
||||||
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),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const DesignerRequest: FC = () => {
|
const DesignerRequest: FC = () => {
|
||||||
usePageTitle('درخواست طراحی')
|
usePageTitle("درخواست طراحی");
|
||||||
const [catalogTitle, setCatalogTitle] = useState('')
|
|
||||||
const [, setDeliveryDate] = useState('')
|
|
||||||
const [pageCount, setPageCount] = useState('1')
|
|
||||||
const [description, setDescription] = useState('')
|
|
||||||
const [, setAttachment] = useState<File[]>([])
|
|
||||||
|
|
||||||
const totalPrice = useMemo(() => {
|
|
||||||
return Number(pageCount || 0) * PRICE_PER_PAGE
|
|
||||||
}, [pageCount])
|
|
||||||
|
|
||||||
const handleAttachmentChange = (files: File[]) => {
|
|
||||||
setAttachment(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-4 w-full'>
|
<div className="mt-4 w-full">
|
||||||
<h1 className=''>درخواست طراحی کاتالوگ</h1>
|
<h1>درخواست طراحی کاتالوگ</h1>
|
||||||
<div className='rounded-[32px] bg-white p-4 md:p-6 lg:p-8 mt-6'>
|
<div className="mt-6 rounded-[32px] bg-white p-4 md:p-6 lg:p-8">
|
||||||
|
<DesignerRequestForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
<div className='mt-6 space-y-5'>
|
export default DesignerRequest;
|
||||||
<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
|
|
||||||
|
|||||||
@@ -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 { type FC, useEffect, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useSharedStore } from "@/shared/store/sharedStore";
|
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 EditorCanvas from "./components/EditorCanvas";
|
||||||
import EditorSidebar from "./components/EditorSidebar";
|
import EditorSidebar from "./components/EditorSidebar";
|
||||||
import LayersPanel from "./components/LayersPanel";
|
import LayersPanel from "./components/LayersPanel";
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
VideoShape,
|
VideoShape,
|
||||||
AudioShape,
|
AudioShape,
|
||||||
} from "../tools";
|
} from "../tools";
|
||||||
import Table from "@/components/Table";
|
import EditorTable from "@/components/EditorTable";
|
||||||
|
|
||||||
type ObjectRendererProps = {
|
type ObjectRendererProps = {
|
||||||
obj: EditorObject;
|
obj: EditorObject;
|
||||||
@@ -234,7 +234,7 @@ const ObjectRenderer = ({
|
|||||||
break;
|
break;
|
||||||
case "grid":
|
case "grid":
|
||||||
shapeElement = (
|
shapeElement = (
|
||||||
<Table
|
<EditorTable
|
||||||
key={obj.id}
|
key={obj.id}
|
||||||
obj={obj}
|
obj={obj}
|
||||||
selectedCellId={selectedCellId || null}
|
selectedCellId={selectedCellId || null}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { toast } from "@/components/Toast";
|
|||||||
import { Paths } from "@/config/Paths";
|
import { Paths } from "@/config/Paths";
|
||||||
import { clx, extractErrorMessage } from "@/helpers/utils";
|
import { clx, extractErrorMessage } from "@/helpers/utils";
|
||||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||||
|
import { useCreateCatalog } from "@/pages/catalogue/hooks/useCatalogueData";
|
||||||
import { ArrowLeft, DocumentText } from "iconsax-react";
|
import { ArrowLeft, DocumentText } from "iconsax-react";
|
||||||
import { useState, type FC } from "react";
|
import { useState, type FC } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import DirectLogin from "../auth/components/DirectLogin";
|
import DirectLogin from "../auth/components/DirectLogin";
|
||||||
import { useCreateCatalog } from "./hooks/useHomeData";
|
|
||||||
|
|
||||||
const CATALOG_SIZES = [
|
const CATALOG_SIZES = [
|
||||||
{ id: "a4", label: "A4" },
|
{ id: "a4", label: "A4" },
|
||||||
@@ -71,7 +71,7 @@ const Home: FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActive(id)}
|
onClick={() => setActive(id)}
|
||||||
className={clx(
|
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!",
|
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 {
|
import {
|
||||||
useGetCatalogById,
|
useGetCatalogById,
|
||||||
useGetCatalogBySlug,
|
useGetCatalogBySlug,
|
||||||
} from "@/pages/home/hooks/useHomeData";
|
} from "@/pages/catalogue/hooks/useCatalogueData";
|
||||||
import { type FC, useEffect, useState } from "react";
|
import { type FC, useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import BookViewer from "./components/BookViewer";
|
import BookViewer from "./components/BookViewer";
|
||||||
@@ -17,12 +17,19 @@ type ViewerProps = {
|
|||||||
bySlug?: boolean;
|
bySlug?: boolean;
|
||||||
};
|
};
|
||||||
const Viewer: FC<ViewerProps> = ({ bySlug = false }) => {
|
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 {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
error: queryError,
|
error: queryError,
|
||||||
} = bySlug ? useGetCatalogBySlug(id ?? "") : useGetCatalogById(id ?? "");
|
} = bySlug
|
||||||
|
? useGetCatalogBySlug(businessSlug ?? "", slug ?? "")
|
||||||
|
: useGetCatalogById(id ?? "");
|
||||||
usePageTitle(`کاتالوگ ${data?.data?.name}`);
|
usePageTitle(`کاتالوگ ${data?.data?.name}`);
|
||||||
const [pages, setPages] = useState<PageData[]>([]);
|
const [pages, setPages] = useState<PageData[]>([]);
|
||||||
const [documentSettings, setDocumentSettings] = useState<
|
const [documentSettings, setDocumentSettings] = useState<
|
||||||
@@ -30,7 +37,7 @@ const Viewer: FC<ViewerProps> = ({ bySlug = false }) => {
|
|||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data?.data?.content || !id) return;
|
if (!data?.data?.content || !catalogKey) return;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data.data.content);
|
const parsed = JSON.parse(data.data.content);
|
||||||
const pagesArray = Array.isArray(parsed) ? parsed : (parsed.pages ?? []);
|
const pagesArray = Array.isArray(parsed) ? parsed : (parsed.pages ?? []);
|
||||||
@@ -47,13 +54,13 @@ const Viewer: FC<ViewerProps> = ({ bySlug = false }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("خطا در پردازش محتوای کاتالوگ:", 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 } =
|
const { isReady: areInitialPagesReady, progress: preloadProgress } =
|
||||||
useInitialPagesPreload(pages, documentSettings);
|
useInitialPagesPreload(pages, documentSettings);
|
||||||
|
|
||||||
if (!id) {
|
if (!catalogKey || (bySlug && !businessSlug)) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<div className="text-gray-600">شناسه کاتالوگ مشخص نیست</div>
|
<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 { clx } from "@/helpers/utils";
|
||||||
|
import Business from "@/pages/business/Business";
|
||||||
import CatalogueList from "@/pages/catalogue/List";
|
import CatalogueList from "@/pages/catalogue/List";
|
||||||
|
import RequestDesignList from "@/pages/designer/List";
|
||||||
import DesignerRequest from "@/pages/designer/Request";
|
import DesignerRequest from "@/pages/designer/Request";
|
||||||
import Editor from "@/pages/editor/Editor";
|
import Editor from "@/pages/editor/Editor";
|
||||||
import Home from "@/pages/home/Home";
|
import Home from "@/pages/home/Home";
|
||||||
@@ -15,47 +17,28 @@ const MainRouter = () => {
|
|||||||
const { hasSubMenu } = useSharedStore();
|
const { hasSubMenu } = useSharedStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isViewerRoute = isViewerPath(location.pathname);
|
const isViewerRoute = isViewerPath(location.pathname);
|
||||||
|
const isBusinessCatalogRoute = isBusinessCatalogPath(location.pathname);
|
||||||
|
const hideAppChrome = isViewerRoute || isBusinessCatalogRoute;
|
||||||
const hiddenSideBarRoutes = [Paths.editor];
|
const hiddenSideBarRoutes = [Paths.editor];
|
||||||
const shouldRenderSideBar =
|
const shouldRenderSideBar = !hiddenSideBarRoutes.includes(location.pathname) && !hideAppChrome;
|
||||||
!hiddenSideBarRoutes.includes(location.pathname) && !isViewerRoute;
|
|
||||||
const routeHasLocalSidebar = location.pathname.includes("editor");
|
const routeHasLocalSidebar = location.pathname.includes("editor");
|
||||||
const hasSidebarSpace = shouldRenderSideBar || routeHasLocalSidebar;
|
const hasSidebarSpace = shouldRenderSideBar || routeHasLocalSidebar;
|
||||||
const headerSidebarSize = routeHasLocalSidebar ? "wide" : "default";
|
const headerSidebarSize = routeHasLocalSidebar ? "wide" : "default";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={clx("flex min-h-full flex-col overflow-hidden", isViewerRoute ? "fixed inset-0 z-50 bg-neutral-100" : "p-4")}>
|
||||||
className={clx(
|
{!hideAppChrome && shouldRenderSideBar ? <SideBar /> : null}
|
||||||
"flex min-h-full flex-col overflow-hidden",
|
{!hideAppChrome ? <Header hasMainSidebar={hasSidebarSpace} sidebarSize={headerSidebarSize} /> : null}
|
||||||
isViewerRoute ? "fixed inset-0 z-50 bg-neutral-100" : "p-4",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!isViewerRoute && shouldRenderSideBar ? <SideBar /> : null}
|
|
||||||
{!isViewerRoute ? (
|
|
||||||
<Header
|
|
||||||
hasMainSidebar={hasSidebarSpace}
|
|
||||||
sidebarSize={headerSidebarSize}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div
|
<div
|
||||||
className={clx(
|
className={clx(
|
||||||
"flex flex-1 flex-col min-h-0",
|
"flex flex-1 flex-col min-h-0",
|
||||||
!isViewerRoute && "mt-[68px] xl:mt-[81px]",
|
!hideAppChrome && "mt-[68px] xl:mt-[81px]",
|
||||||
!isViewerRoute && shouldRenderSideBar && "xl:ms-[269px]",
|
!hideAppChrome && shouldRenderSideBar && "xl:ms-[269px]",
|
||||||
!isViewerRoute &&
|
!hideAppChrome && shouldRenderSideBar && hasSubMenu && "xl:ms-[305px]",
|
||||||
shouldRenderSideBar &&
|
!hideAppChrome && routeHasLocalSidebar && "xl:mr-[374px]",
|
||||||
hasSubMenu &&
|
|
||||||
"xl:ms-[305px]",
|
|
||||||
!isViewerRoute && 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">
|
<div className="flex-1 h-full flex min-h-0">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
@@ -83,13 +66,16 @@ const MainRouter = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path={Paths.viewerBySlug + "/:id"}
|
path={Paths.viewerBySlug + "/:businessSlug/:slug"}
|
||||||
element={
|
element={
|
||||||
<PrivateRoute requireAuth={false}>
|
<PrivateRoute requireAuth={false}>
|
||||||
<Viewer bySlug={true} />
|
<Viewer bySlug={true} />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route path={Paths.catalog.business + ":slug"} element={<Business />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={Paths.catalog.list}
|
path={Paths.catalog.list}
|
||||||
element={
|
element={
|
||||||
@@ -106,6 +92,14 @@ const MainRouter = () => {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path={Paths.designer.list}
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<RequestDesignList />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-72
@@ -1,11 +1,11 @@
|
|||||||
import Input from "@/components/Input";
|
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 { Paths } from "@/config/Paths";
|
||||||
import { clx } from "@/helpers/utils";
|
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 { useSharedStore } from "@/shared/store/sharedStore";
|
||||||
import { Eye, HambergerMenu } from "iconsax-react";
|
import { Eye, HambergerMenu } from "iconsax-react";
|
||||||
|
import { type FC, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
@@ -34,8 +34,6 @@ const Header: FC<HeaderProps> = ({
|
|||||||
hasMainSidebar = true,
|
hasMainSidebar = true,
|
||||||
sidebarSize = "default",
|
sidebarSize = "default",
|
||||||
}) => {
|
}) => {
|
||||||
// const location = useLocation();
|
|
||||||
// const [popoverKey, setPopoverKey] = useState(0);
|
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
const {
|
const {
|
||||||
setOpenSidebar,
|
setOpenSidebar,
|
||||||
@@ -44,6 +42,8 @@ const Header: FC<HeaderProps> = ({
|
|||||||
setSearch,
|
setSearch,
|
||||||
editorCatalogSlug,
|
editorCatalogSlug,
|
||||||
} = useSharedStore();
|
} = useSharedStore();
|
||||||
|
|
||||||
|
const { data: business } = useGetBusiness();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const editorPathPrefix = `${Paths.editor}/`;
|
const editorPathPrefix = `${Paths.editor}/`;
|
||||||
const isEditorRoute =
|
const isEditorRoute =
|
||||||
@@ -51,16 +51,6 @@ const Header: FC<HeaderProps> = ({
|
|||||||
location.pathname.length > editorPathPrefix.length;
|
location.pathname.length > editorPathPrefix.length;
|
||||||
const viewerSlug = isEditorRoute ? (editorCatalogSlug ?? "") : "";
|
const viewerSlug = isEditorRoute ? (editorCatalogSlug ?? "") : "";
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// setPopoverKey((prevKey) => prevKey + 1);
|
|
||||||
// }, [location.pathname]);
|
|
||||||
|
|
||||||
// const handleLogout = () => {
|
|
||||||
// removeToken()
|
|
||||||
// removeRefreshToken()
|
|
||||||
// window.location.href = Pages.auth.login
|
|
||||||
// }
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearch("");
|
setSearch("");
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -83,79 +73,29 @@ const Header: FC<HeaderProps> = ({
|
|||||||
onChangeSearchFinal={(value) => setSearch(value)}
|
onChangeSearchFinal={(value) => setSearch(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setOpenSidebar(!openSidebar)}
|
onClick={() => setOpenSidebar(!openSidebar)}
|
||||||
className="xl:hidden block"
|
className="xl:hidden block"
|
||||||
>
|
>
|
||||||
<HambergerMenu size={24} color="black" />
|
<HambergerMenu size={24} color="black" />
|
||||||
</div>
|
</button>
|
||||||
{/* <img src={LogoImage} className='h-6 xl:hidden block absolute right-0 left-0 mx-auto' /> */}
|
|
||||||
<div className="flex xl:gap-6 gap-4 items-center">
|
<div className="flex xl:gap-6 gap-4 items-center">
|
||||||
{isEditorRoute && viewerSlug ? (
|
{isEditorRoute && viewerSlug ? (
|
||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
to={`${Paths.viewerBySlug}/${viewerSlug}`}
|
to={`${Paths.viewerBySlug}/${business?.data?.slug}/${viewerSlug}`}
|
||||||
className="flex items-center"
|
className="flex items-center"
|
||||||
aria-label={t("header.open_viewer")}
|
aria-label={t("header.open_viewer")}
|
||||||
title={t("header.open_viewer")}
|
title={t("header.open_viewer")}
|
||||||
// onClick={requestViewerFullscreen}
|
|
||||||
>
|
>
|
||||||
<Eye size={20} color="black" />
|
<Eye size={20} color="black" />
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* <Link className='xl:hidden' to={Paths.wallet}>
|
<HeaderUserMenu />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
} */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+61
-77
@@ -1,104 +1,91 @@
|
|||||||
import { type FC, useEffect } from 'react'
|
import { Brush2, DocumentText, Home2, Logout } from "iconsax-react";
|
||||||
import LogoImage from '../assets/images/logo.svg'
|
import { type FC, useEffect } from "react";
|
||||||
import LogoSmall from '../assets/images/logo-small.svg'
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useLocation } from "react-router-dom";
|
||||||
import {
|
import LogoSmall from "../assets/images/logo-small.svg";
|
||||||
DocumentText,
|
import LogoImage from "../assets/images/logo.svg";
|
||||||
Home2,
|
import { Paths } from "../config/Paths";
|
||||||
Logout,
|
import { clx } from "../helpers/utils";
|
||||||
} from 'iconsax-react'
|
import BuyCatalog from "./components/BuyCatalog";
|
||||||
import SideBarItem from './SideBarItem'
|
import SideBarItem from "./SideBarItem";
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useSharedStore } from "./store/sharedStore";
|
||||||
import { useSharedStore } from './store/sharedStore'
|
|
||||||
import { clx } from '../helpers/utils'
|
|
||||||
import { Paths } from '../config/Paths'
|
|
||||||
import BuyCatalog from './components/BuyCatalog'
|
|
||||||
|
|
||||||
|
|
||||||
const SideBar: FC = () => {
|
const SideBar: FC = () => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
const { t } = useTranslation('global')
|
const { openSidebar, setOpenSidebar, hasSubMenu, setSubMenuName, setSubtMenu, subMenuName } = useSharedStore();
|
||||||
const { openSidebar, setOpenSidebar, hasSubMenu, setSubMenuName, setSubtMenu, subMenuName } = useSharedStore()
|
const location = useLocation();
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
const pathname = location.pathname
|
const pathname = location.pathname;
|
||||||
if (path === '/') return pathname === '/'
|
if (path === "/") return pathname === "/";
|
||||||
return pathname === path || pathname.startsWith(path + '/')
|
return pathname === path || pathname.startsWith(path + "/");
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const split = location.pathname.split('/')
|
const split = location.pathname.split("/");
|
||||||
|
|
||||||
if (split[1] === 'dashboard' || split[1] === 'products') {
|
if (split[1] === "dashboard" || split[1] === "products") {
|
||||||
setSubMenuName(split[1])
|
setSubMenuName(split[1]);
|
||||||
setSubtMenu(true)
|
setSubtMenu(true);
|
||||||
} else {
|
} else {
|
||||||
setSubMenuName('')
|
setSubMenuName("");
|
||||||
setSubtMenu(false)
|
setSubtMenu(false);
|
||||||
}
|
}
|
||||||
}, [location.pathname, setSubMenuName, setSubtMenu])
|
}, [location.pathname, setSubMenuName, setSubtMenu]);
|
||||||
|
|
||||||
const iconSizeSideBar = 20
|
const iconSizeSideBar = 20;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{
|
{openSidebar && <div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 bg-opacity-50 z-10" onClick={() => setOpenSidebar(false)} />}
|
||||||
openSidebar && <div className='fixed top-0 left-0 right-0 bottom-0 bg-black/50 bg-opacity-50 z-10' onClick={() => setOpenSidebar(false)} />
|
|
||||||
}
|
|
||||||
<div
|
<div
|
||||||
className={clx(
|
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',
|
"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',
|
openSidebar && "opacity-100 visible translate-x-0 block z-40",
|
||||||
hasSubMenu && 'w-24 rounded-tl-none! rounded-bl-none!'
|
hasSubMenu && "w-24 rounded-tl-none! rounded-bl-none!",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className='flex justify-center'>
|
<div className="flex justify-center">{!hasSubMenu ? <img src={LogoImage} className="w-[140px]" /> : <img src={LogoSmall} className="w-7" />}</div>
|
||||||
{
|
|
||||||
!hasSubMenu ?
|
|
||||||
<img src={LogoImage} className='w-[140px]' />
|
|
||||||
:
|
|
||||||
<img src={LogoSmall} className='w-7' />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex-1 flex flex-col h-full overflow-y-auto no-scrollbar'>
|
<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>
|
||||||
|
|
||||||
<div className={clx(
|
<div className="text-xs text-[#8C90A3]">
|
||||||
'mt-10 px-12 text-header text-sm font-normal',
|
|
||||||
hasSubMenu && 'px-2 text-center'
|
|
||||||
)}>
|
|
||||||
{t('sidebar.menu')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='text-xs text-[#8C90A3]'>
|
|
||||||
<SideBarItem
|
<SideBarItem
|
||||||
icon={<Home2 variant={isActive(Paths.home) ? 'Bold' : 'Outline'} color={isActive(Paths.home) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
icon={<Home2 variant={isActive(Paths.home) ? "Bold" : "Outline"} color={isActive(Paths.home) ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
|
||||||
title={t('sidebar.home_page')}
|
title={t("sidebar.home_page")}
|
||||||
isActive={isActive(Paths.home)}
|
isActive={isActive(Paths.home)}
|
||||||
link={Paths.home}
|
link={Paths.home}
|
||||||
activeName={Paths.home}
|
activeName={Paths.home}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SideBarItem
|
<SideBarItem
|
||||||
icon={<DocumentText variant={isActive(Paths.catalog.list) ? 'Bold' : 'Outline'} color={isActive(Paths.catalog.list) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
icon={<DocumentText variant={isActive(Paths.catalog.list) ? "Bold" : "Outline"} color={isActive(Paths.catalog.list) ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
|
||||||
title={t('sidebar.catalog')}
|
title={t("sidebar.catalog")}
|
||||||
isActive={isActive(Paths.catalog.list)}
|
isActive={isActive(Paths.catalog.list)}
|
||||||
link={Paths.catalog.list}
|
link={Paths.catalog.list}
|
||||||
activeName={Paths.catalog.list}
|
activeName={Paths.catalog.list}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className='flex-1 flex flex-col justify-end mt-14'>
|
<div className="flex-1 flex flex-col justify-end mt-14">
|
||||||
<BuyCatalog />
|
<BuyCatalog />
|
||||||
<div className='text-xs text-[#8C90A3] mt-5'>
|
<div className="text-xs text-[#8C90A3] mt-5">
|
||||||
<SideBarItem
|
<SideBarItem
|
||||||
icon={<Logout variant={isActive('logout') ? 'Bold' : 'Outline'} color={isActive('logout') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
icon={<Logout variant={isActive("logout") ? "Bold" : "Outline"} color={isActive("logout") ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
|
||||||
title="خروج"
|
title="خروج"
|
||||||
isActive={isActive('logout')}
|
isActive={isActive("logout")}
|
||||||
link={`#`}
|
link={`#`}
|
||||||
isLogout
|
isLogout
|
||||||
activeName=''
|
activeName=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,19 +93,16 @@ const SideBar: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* منوی فرعی */}
|
{/* منوی فرعی */}
|
||||||
{
|
{hasSubMenu && (openSidebar || window.innerWidth > 1139) && (
|
||||||
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">
|
||||||
<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
|
||||||
subMenuName === 'dashboard' ? null
|
: // <DashboardSubMenu />
|
||||||
// <DashboardSubMenu />
|
null}
|
||||||
: null
|
|
||||||
}
|
|
||||||
</div>
|
</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