Compare commits

..

16 Commits

Author SHA1 Message Date
hamid zarghami 9b00a22d11 original base url
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-07 14:54:53 +03:30
hamid zarghami 03fc293ee9 list of requests 2026-06-07 14:47:11 +03:30
hamid zarghami 2804cc4100 update request 2026-06-07 14:27:48 +03:30
hamid zarghami 17717eb195 clean code 2026-06-07 14:23:51 +03:30
hamid zarghami 0138261643 save request and link to invoice 2026-06-07 12:56:49 +03:30
hamid zarghami 6653e3db82 save request 2026-06-07 12:43:43 +03:30
hamid zarghami 032050bacc fix bug design datepcker 2026-06-07 12:37:41 +03:30
hamid zarghami c65e8cd303 formik and yup in request design 2026-06-07 12:30:05 +03:30
hamid zarghami d95a6e679e business slug 2026-06-07 12:04:15 +03:30
hamid zarghami 3c9bb6274f update link 2026-06-07 11:48:13 +03:30
hamid zarghami 8dd00d4cc7 user in header 2026-06-07 11:43:24 +03:30
hamid zarghami 3c833df63c business page 2026-06-07 10:40:15 +03:30
hamid zarghami 4daa03d0de catalogue service , hook 2026-06-07 10:38:31 +03:30
hamid zarghami 172cbea22c remove button add 2026-06-07 10:24:57 +03:30
hamid zarghami 8014fe7ad3 skeleton for catalogues 2026-06-07 10:24:29 +03:30
hamid zarghami d977c20744 get business catalog 2026-06-07 10:21:57 +03:30
41 changed files with 1733 additions and 3949 deletions
+9 -1
View File
@@ -1,4 +1,12 @@
VITE_API_URL=https://dpage-api.danakcorp.com 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
View File
@@ -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
+356 -3287
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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": {
+15 -18
View File
@@ -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,26 +89,22 @@ 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
)}> placeholder={props.placeholder}
<DatePicker value={value}
placeholder={props.placeholder} onChange={(date) => setValue(date as DateObject)}
value={value} calendar={persian}
onChange={(date) => setValue(date as DateObject)} locale={persian_fa}
calendar={persian} calendarPosition="bottom-right"
locale={persian_fa} className={`rmdp-mobile ${props.className}`}
calendarPosition="bottom-right" />
className={`rmdp-mobile ${props.className}`} <Calendar size={18} color='black' className='pointer-events-none absolute top-0 bottom-0 left-2 my-auto' />
/> </div>
{props.error_text && props.error_text !== '' && ( {props.error_text && props.error_text !== '' && (
<div className="text-xs text-right text-red-600 mt-2 mr-2 font-medium"> <Error errorText={props.error_text} />
{props.error_text}
</div>
)} )}
<Calendar size={18} color='black' className='absolute top-0 bottom-0 my-auto left-2' />
</div> </div>
</div> </div>
); );
+25
View File
@@ -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;
+114
View File
@@ -0,0 +1,114 @@
import React from "react";
import { Group, Rect, Text } from "react-konva";
import Konva from "konva";
import type { EditorObject } from "@/pages/editor/store/editorStore";
type EditorTableProps = {
obj: EditorObject;
selectedCellId: string | null;
onCellClick: (tableId: string, cellId: string, node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => void;
onCellDblClick: (tableId: string, cellId: string, x: number, y: number, width: number, height: number, text: string) => void;
onDragEnd: (tableId: string, x: number, y: number) => void;
draggable: boolean;
};
const EditorTable = ({ obj, selectedCellId, onCellClick, onCellDblClick, onDragEnd, draggable }: EditorTableProps) => {
const groupRef = React.useRef<Konva.Group>(null);
const tableData = obj.tableData;
React.useEffect(() => {
if (groupRef.current) {
groupRef.current.setAttr("id", obj.id);
}
}, [obj.id]);
if (!tableData) return null;
const { rows, cols, cells, cellWidth, cellHeight, stroke, strokeWidth } = tableData;
const handleCellClick = (cellId: string, e: Konva.KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
if (groupRef.current) {
onCellClick(obj.id, cellId, groupRef.current, e);
}
};
const handleCellDblClick = (cellId: string, e: Konva.KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
const stage = e.target.getStage();
if (!stage || !groupRef.current) return;
const cell = cells[cellId];
if (!cell) return;
const targetNode = e.target;
const box = targetNode.getClientRect();
const stageBox = stage.container().getBoundingClientRect();
const x = stageBox.left + box.x;
const y = stageBox.top + box.y;
const width = box.width;
const height = box.height;
onCellDblClick(obj.id, cellId, x, y, width, height, cell.text);
};
const handleDragEnd = () => {
if (groupRef.current) {
const pos = groupRef.current.position();
onDragEnd(obj.id, pos.x, pos.y);
}
};
return (
<Group ref={groupRef} x={obj.x} y={obj.y} draggable={draggable} onDragEnd={handleDragEnd}>
{Array.from({ length: rows }).map((_, row) =>
Array.from({ length: cols }).map((_, col) => {
const cellId = `cell-${row}-${col}`;
const cell = cells[cellId];
if (!cell) return null;
const x = col * cellWidth;
const y = row * cellHeight;
const isCellSelected = selectedCellId === cellId;
const isTopLeft = row === 0 && col === 0;
const isTopRight = row === 0 && col === cols - 1;
return (
<Group key={cellId}>
<Rect
x={x}
y={y}
width={cellWidth}
height={cellHeight}
fill={cell.background}
stroke={isCellSelected ? "#3b82f6" : stroke || "#808080"}
strokeWidth={isCellSelected ? 3 : strokeWidth || 1}
cornerRadius={isTopLeft ? [8, 0, 0, 0] : isTopRight ? [0, 8, 0, 0] : 0}
onClick={(e) => handleCellClick(cellId, e)}
onDblClick={(e) => handleCellDblClick(cellId, e)}
/>
{cell.text && (
<Text
x={x}
y={y}
width={cellWidth}
height={cellHeight}
text={cell.text}
fontSize={14}
fontFamily="Arial"
fill="#000000"
align="center"
verticalAlign="middle"
onClick={(e) => handleCellClick(cellId, e)}
onDblClick={(e) => handleCellDblClick(cellId, e)}
/>
)}
</Group>
);
}),
)}
</Group>
);
};
export default EditorTable;
+95 -121
View File
@@ -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 renderTableBody = () => {
if (isLoading) {
return <DefaultTableSkeleton tdCount={columns.length} trCount={emptyRowsCount} />;
}
const { rows, cols, cells, cellWidth, cellHeight, stroke, strokeWidth } = tableData; if (!data || data.length === 0) {
return (
<tr>
<td colSpan={columns.length} className="px-3 md:px-6 py-6 md:py-8 text-center text-gray-500">
{noDataMessage}
</td>
</tr>
);
}
const handleCellClick = (cellId: string, e: Konva.KonvaEventObject<MouseEvent>) => { return data.map((item, rowIndex) => (
e.cancelBubble = true; <tr
if (groupRef.current) { key={item.id}
onCellClick(obj.id, cellId, groupRef.current, e); className={`w-full h-[64px] md:h-[74px] bg-white border-t border-[#EAEDF5] hover:bg-[#F4F5F9] ${onRowClick ? "cursor-pointer" : ""} ${getRowClassName(item, rowIndex)}`}
} onClick={() => handleRowClick(item)}
}; >
{columns.map((col) => (
<td key={`${item.id}-${col.key}`} className={getCellClassName(col)} style={{ width: col.width }}>
{col.render ? col.render(item) : (item[col.key] as React.ReactNode)}
</td>
))}
</tr>
));
};
const handleCellDblClick = (cellId: string, e: Konva.KonvaEventObject<MouseEvent>) => { const renderHeader = () => {
e.cancelBubble = true; if (!showHeader || actionsPosition === "header-replace") return null;
const stage = e.target.getStage();
if (!stage || !groupRef.current) return;
const cell = cells[cellId];
if (!cell) return;
// Get the target node (Rect or Text)
const targetNode = e.target;
// Get the client rect which includes all transformations (scale, position, etc.)
const box = targetNode.getClientRect();
const stageBox = stage.container().getBoundingClientRect();
// Calculate position relative to viewport
const x = stageBox.left + box.x;
const y = stageBox.top + box.y;
const width = box.width;
const height = box.height;
onCellDblClick(obj.id, cellId, x, y, width, height, cell.text);
};
const handleDragEnd = () => {
if (groupRef.current) {
const pos = groupRef.current.position();
onDragEnd(obj.id, pos.x, pos.y);
}
};
return ( return (
<Group <thead className={`h-[60px] md:h-[69px] bg-white text-sm text-[#8C90A3] ${headerClassName}`}>
ref={groupRef} <tr>
x={obj.x} {columns.map((col) => (
y={obj.y} <Td key={col.key} text={col.title} />
draggable={draggable} ))}
onDragEnd={handleDragEnd} </tr>
> </thead>
{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>
); );
};
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;
+21
View File
@@ -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;
+27
View File
@@ -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
View File
@@ -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
View File
@@ -2,7 +2,9 @@
"sidebar": { "sidebar": {
"menu": "منو", "menu": "منو",
"home_page": "صفحه اصلی", "home_page": "صفحه اصلی",
"catalog": "کاتالوگ" "catalog": "کاتالوگ",
"logout": "خروج",
"request_design": "درخواست طراحی"
}, },
"header": { "header": {
"search": "جستجو", "search": "جستجو",
+31
View File
@@ -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;
};
+27
View File
@@ -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(" ");
+11 -2
View File
@@ -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">
+128 -1
View File
@@ -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(
+13
View File
@@ -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;
};
+94
View File
@@ -0,0 +1,94 @@
import Button from "@/components/Button";
import Table from "@/components/Table";
import type { ColumnType } from "@/components/types/TableTypes";
import { Paths } from "@/config/Paths";
import { formatDateShort, formatPrice } from "@/helpers/func";
import { usePageTitle } from "@/hooks/usePageTitle";
import { getTotalPrice } from "@/pages/designer/designerRequestForm";
import { useGetDesignRequests } from "@/pages/designer/hooks/useDesignerData";
import type { RequestDesignItemType } from "@/pages/designer/types/Types";
import { type FC, useMemo } from "react";
import { Link } from "react-router-dom";
const getInvoiceUrl = (invoiceId: string) => `${import.meta.env.VITE_INVOICE_URL}${invoiceId}`;
const RequestDesignList: FC = () => {
usePageTitle("لیست درخواست‌های طراحی");
const { data, isPending } = useGetDesignRequests();
const requests = data?.data ?? [];
const columns = useMemo<ColumnType<RequestDesignItemType>[]>(
() => [
{ title: "عنوان", key: "title", className: "font-medium text-black" },
{ title: "تاریخ ثبت", key: "createdAt", render: (item) => formatDateShort(item.createdAt) },
{ title: "زمان تحویل", key: "expectedDate", render: (item) => formatDateShort(item.expectedDate) },
{ title: "تعداد صفحات", key: "count", render: (item) => item.count.toLocaleString("fa-IR") },
{
title: "مبلغ",
key: "totalPrice",
render: (item) => `${formatPrice(getTotalPrice(item.count))} تومان`,
},
{
title: "وضعیت",
key: "paidAt",
render: (item) => {
const isPaid = Boolean(item.paidAt);
return (
<span className={`inline-flex rounded-full px-3 py-1 text-xs ${isPaid ? "bg-[#E8F5E9] text-[#2E7D32]" : "bg-[#FFF3E0] text-[#E65100]"}`}>{isPaid ? "پرداخت شده" : "در انتظار پرداخت"}</span>
);
},
},
{
title: "عملیات",
key: "actions",
render: (item) => {
if (item.paidAt) {
return <span className="text-[#999999]"></span>;
}
return (
<Button
type="button"
className="w-fit px-4 py-1.5 text-xs"
onClick={(event) => {
event.stopPropagation();
window.open(getInvoiceUrl(item.invoiceId), "_blank");
}}
>
پرداخت
</Button>
);
},
},
],
[],
);
return (
<div className="mt-4 w-full">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1> درخواستهای طراحی</h1>
<Link to={Paths.designer.request}>
<Button className="w-fit px-6">درخواست جدید</Button>
</Link>
</div>
<Table
columns={columns}
data={requests}
isLoading={isPending}
className="mt-6"
noDataMessage={
<div className="flex flex-col items-center gap-5 py-2">
<span>هنوز درخواست طراحی ثبت نشده است.</span>
<Link to={Paths.designer.request}>
<Button className="w-fit px-6">ثبت درخواست طراحی</Button>
</Link>
</div>
}
/>
</div>
);
};
export default RequestDesignList;
+14 -102
View File
@@ -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 (
return Number(pageCount || 0) * PRICE_PER_PAGE <div className="mt-4 w-full">
}, [pageCount]) <h1>درخواست طراحی کاتالوگ</h1>
<div className="mt-6 rounded-[32px] bg-white p-4 md:p-6 lg:p-8">
<DesignerRequestForm />
</div>
</div>
);
};
const handleAttachmentChange = (files: File[]) => { export default DesignerRequest;
setAttachment(files)
}
return (
<div className='mt-4 w-full'>
<h1 className=''>درخواست طراحی کاتالوگ</h1>
<div className='rounded-[32px] bg-white p-4 md:p-6 lg:p-8 mt-6'>
<div className='mt-6 space-y-5'>
<Input
label='عنوان کاتالوگ'
value={catalogTitle}
onChange={(event) => setCatalogTitle(event.target.value)}
/>
<div className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
<DatePicker
label='زمان تحویل موردنظر'
placeholder='تاریخ را انتخاب کنید'
onChange={(date) => setDeliveryDate(date)}
/>
<Select
label='تعداد صفحات'
value={pageCount}
items={pageCountOptions}
onChange={(event) => setPageCount(event.target.value)}
/>
</div>
<Textarea
label='توضیحات'
value={description}
onChange={(event) => setDescription(event.target.value)}
className='min-h-[96px]'
/>
<div>
<div className='mb-2 text-sm'>فایل های ضمیمه</div>
<UploadBoxDraggble
label='فایل مورد نظر را آپلود کنید'
onChange={handleAttachmentChange}
isMultiple={false}
isFile
/>
</div>
<div className='flex'>
<div className='flex-1'></div>
<div className='flex-1 flex gap-4'>
<div className='w-[250px]'>
<Input
label='قیمت به ازای هر صفحه'
value={`${PRICE_PER_PAGE.toLocaleString('fa-IR')} تومان`}
readOnly
/>
</div>
<Input
label='مبلغ قابل پرداخت برای شما'
value={`${totalPrice.toLocaleString('fa-IR')} تومان`}
readOnly
/>
</div>
</div>
<div className='flex justify-end mt-5'>
<Button className='w-fit px-6'>ثبت درخواست</Button>
</div>
</div>
</div >
</div >
)
}
export default DesignerRequest
@@ -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;
+79
View File
@@ -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;
};
+66
View File
@@ -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 -1
View File
@@ -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}
+2 -2
View File
@@ -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!",
)} )}
> >
-123
View File
@@ -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,
};
};
-45
View File
@@ -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;
};
-12
View File
@@ -1,12 +0,0 @@
export type CreateCatalogParamsType = {
name: string;
slug: string;
size: string;
content?: string;
id?: string;
};
export type UpdateCatalogParamsType = {
id: string;
content: string;
};
+14 -7
View File
@@ -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
View File
@@ -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 <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)]")}>
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
View File
@@ -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>
); );
+93 -109
View File
@@ -1,124 +1,108 @@
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 { openSidebar, setOpenSidebar, hasSubMenu, setSubMenuName, setSubtMenu, subMenuName } = useSharedStore();
const location = useLocation();
const { t } = useTranslation('global') const isActive = (path: string) => {
const { openSidebar, setOpenSidebar, hasSubMenu, setSubMenuName, setSubtMenu, subMenuName } = useSharedStore() const pathname = location.pathname;
const location = useLocation() if (path === "/") return pathname === "/";
return pathname === path || pathname.startsWith(path + "/");
};
const isActive = (path: string) => { useEffect(() => {
const pathname = location.pathname const split = location.pathname.split("/");
if (path === '/') return pathname === '/'
return pathname === path || pathname.startsWith(path + '/') if (split[1] === "dashboard" || split[1] === "products") {
setSubMenuName(split[1]);
setSubtMenu(true);
} else {
setSubMenuName("");
setSubtMenu(false);
} }
}, [location.pathname, setSubMenuName, setSubtMenu]);
useEffect(() => { const iconSizeSideBar = 20;
const split = location.pathname.split('/')
if (split[1] === 'dashboard' || split[1] === 'products') { return (
setSubMenuName(split[1]) <>
setSubtMenu(true) {openSidebar && <div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 bg-opacity-50 z-10" onClick={() => setOpenSidebar(false)} />}
} else { <div
setSubMenuName('') className={clx(
setSubtMenu(false) "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",
}, [location.pathname, setSubMenuName, setSubtMenu]) hasSubMenu && "w-24 rounded-tl-none! rounded-bl-none!",
)}
>
<div className="flex justify-center">{!hasSubMenu ? <img src={LogoImage} className="w-[140px]" /> : <img src={LogoSmall} className="w-7" />}</div>
const iconSizeSideBar = 20 <div className="flex-1 flex flex-col h-full overflow-y-auto no-scrollbar">
<div className={clx("mt-10 px-12 text-header text-sm font-normal", hasSubMenu && "px-2 text-center")}>{t("sidebar.menu")}</div>
return ( <div className="text-xs text-[#8C90A3]">
<> <SideBarItem
{ icon={<Home2 variant={isActive(Paths.home) ? "Bold" : "Outline"} color={isActive(Paths.home) ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
openSidebar && <div className='fixed top-0 left-0 right-0 bottom-0 bg-black/50 bg-opacity-50 z-10' onClick={() => setOpenSidebar(false)} /> title={t("sidebar.home_page")}
} isActive={isActive(Paths.home)}
<div link={Paths.home}
className={clx( activeName={Paths.home}
'fixed xl:flex translate-x-[400px] xl:translate-x-0 transition-all ease-in-out opacity-0 invisible xl:visible xl:opacity-100 xl:right-4 right-0 xl:top-4 top-0 xl:bottom-4 bottom-0 xl:rounded-[32px] w-[250px] bg-white flex-col py-12', />
openSidebar && 'opacity-100 visible translate-x-0 block z-40',
hasSubMenu && 'w-24 rounded-tl-none! rounded-bl-none!'
)}
>
<div className='flex justify-center'>
{
!hasSubMenu ?
<img src={LogoImage} className='w-[140px]' />
:
<img src={LogoSmall} className='w-7' />
}
</div>
<div className='flex-1 flex flex-col h-full overflow-y-auto no-scrollbar'> <SideBarItem
icon={<DocumentText variant={isActive(Paths.catalog.list) ? "Bold" : "Outline"} color={isActive(Paths.catalog.list) ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
title={t("sidebar.catalog")}
isActive={isActive(Paths.catalog.list)}
link={Paths.catalog.list}
activeName={Paths.catalog.list}
/>
<div className={clx( <SideBarItem
'mt-10 px-12 text-header text-sm font-normal', icon={<Brush2 variant={isActive(Paths.designer.list) ? "Bold" : "Outline"} color={isActive(Paths.designer.list) ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
hasSubMenu && 'px-2 text-center' title={t("sidebar.request_design")}
)}> isActive={isActive(Paths.designer.list)}
{t('sidebar.menu')} link={Paths.designer.list}
</div> activeName={Paths.designer.list}
/>
</div>
<div className='text-xs text-[#8C90A3]'> <div className="flex-1 flex flex-col justify-end mt-14">
<SideBarItem <BuyCatalog />
icon={<Home2 variant={isActive(Paths.home) ? 'Bold' : 'Outline'} color={isActive(Paths.home) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />} <div className="text-xs text-[#8C90A3] mt-5">
title={t('sidebar.home_page')} <SideBarItem
isActive={isActive(Paths.home)} icon={<Logout variant={isActive("logout") ? "Bold" : "Outline"} color={isActive("logout") ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
link={Paths.home} title="خروج"
activeName={Paths.home} isActive={isActive("logout")}
/> link={`#`}
isLogout
<SideBarItem activeName=""
icon={<DocumentText variant={isActive(Paths.catalog.list) ? 'Bold' : 'Outline'} color={isActive(Paths.catalog.list) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />} />
title={t('sidebar.catalog')}
isActive={isActive(Paths.catalog.list)}
link={Paths.catalog.list}
activeName={Paths.catalog.list}
/>
</div>
<div className='flex-1 flex flex-col justify-end mt-14'>
<BuyCatalog />
<div className='text-xs text-[#8C90A3] mt-5'>
<SideBarItem
icon={<Logout variant={isActive('logout') ? 'Bold' : 'Outline'} color={isActive('logout') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title="خروج"
isActive={isActive('logout')}
link={`#`}
isLogout
activeName=''
/>
</div>
</div>
</div>
</div> </div>
</div>
</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
+116
View File
@@ -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;
+19
View File
@@ -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;
}