Compare commits
No commits in common. "feature/WNPT-534" and "main" have entirely different histories.
feature/WN
...
main
53 changed files with 153 additions and 1615 deletions
4
.env
4
.env
|
|
@ -1,4 +1,4 @@
|
||||||
VITE_AUTH_BASE_URL=https://admin-backend-staging.winsoo.ir/api
|
VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir
|
||||||
VITE_API_BASE_URL=https://admin-backend-staging.winsoo.ir/api
|
VITE_API_BASE_URL=https://accounts-staging.gooyal.ir
|
||||||
VITE_AUTH_CLIENT_ID=PLWyn000M78oYPP2R7E9ctWeJG9Fta8uKifz7m42
|
VITE_AUTH_CLIENT_ID=PLWyn000M78oYPP2R7E9ctWeJG9Fta8uKifz7m42
|
||||||
VITE_AUTH_CLIENT_SECRET=PceNvDCoFyUSM4gCO1OSD3Rsvvg5iRAkYD3QhOQfTPFmiOOWqGsBH39o8FtYuyuWXPNhZcNiUegsnBth1am6rw798FHyWBTxquGVgWsF9Y9rfRUlSZRqavMYsPWcIlOF
|
VITE_AUTH_CLIENT_SECRET=PceNvDCoFyUSM4gCO1OSD3Rsvvg5iRAkYD3QhOQfTPFmiOOWqGsBH39o8FtYuyuWXPNhZcNiUegsnBth1am6rw798FHyWBTxquGVgWsF9Y9rfRUlSZRqavMYsPWcIlOF
|
||||||
|
|
|
||||||
25
package-lock.json
generated
25
package-lock.json
generated
|
|
@ -11,7 +11,6 @@
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"antd": "^6.5.2",
|
"antd": "^6.5.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"lucide": "^1.27.0",
|
"lucide": "^1.27.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|
@ -23,7 +22,6 @@
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
"@types/babel__core": "^7.20.5",
|
"@types/babel__core": "^7.20.5",
|
||||||
"@types/leaflet": "^1.9.22",
|
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|
@ -1882,13 +1880,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/geojson": {
|
|
||||||
"version": "7946.0.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
|
||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
|
|
@ -1896,16 +1887,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/leaflet": {
|
|
||||||
"version": "1.9.22",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
|
||||||
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/geojson": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
|
|
@ -3292,12 +3273,6 @@
|
||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/leaflet": {
|
|
||||||
"version": "1.9.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"antd": "^6.5.2",
|
"antd": "^6.5.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"lucide": "^1.27.0",
|
"lucide": "^1.27.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|
@ -25,7 +24,6 @@
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
"@types/babel__core": "^7.20.5",
|
"@types/babel__core": "^7.20.5",
|
||||||
"@types/leaflet": "^1.9.22",
|
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|
|
||||||
10
src/App.tsx
10
src/App.tsx
|
|
@ -1,13 +1,19 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAppSelector } from '@/utils/hooks';
|
import { useAppDispatch, useAppSelector } from './utils/hooks';
|
||||||
import AdminLayout from '@/components/layout/AdminLayout';
|
import { hydrateAuth } from './features/auth/authSlice';
|
||||||
|
import AdminLayout from './components/layout/AdminLayout';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);
|
const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(hydrateAuth());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated && location.pathname !== '/login' && location.pathname !== '/verify-otp') {
|
if (!isAuthenticated && location.pathname !== '/login' && location.pathname !== '/verify-otp') {
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { SendOtpResponse } from '@/features/auth/authTypes';
|
import type { SendOtpResponse } from '../features/auth/authTypes';
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
sendOtp: async (phoneNumber: string): Promise<SendOtpResponse> => {
|
sendOtp: async (phoneNumber: string): Promise<SendOtpResponse> => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import axios, { type AxiosError } from 'axios';
|
import axios, { type AxiosError } from 'axios';
|
||||||
import { env } from '@/config/env';
|
import { env } from '../config/env';
|
||||||
import { STORAGE_KEYS } from '@/utils/storage';
|
import { STORAGE_KEYS } from '../utils/storage';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: env.apiBaseUrl,
|
baseURL: env.apiBaseUrl,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import authReducer from '@/features/auth/authSlice.ts';
|
import authReducer from '../features/auth/authSlice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAppSelector } from '@/utils/hooks';
|
import { useAppSelector } from '../utils/hooks';
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,10 @@ import { Drawer, Layout, Menu, Typography, theme } from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import winsooLogo from "@/assets/images/winsoo.svg";
|
import winsooLogo from "../../assets/images/winsoo.svg";
|
||||||
import { getSidebarMenuItems } from "@/constants/navigation";
|
import { getSidebarMenuItems } from "../../constants/navigation";
|
||||||
import { colors } from "@/design/tokens/colors";
|
import { colors } from "../../design/tokens/colors";
|
||||||
import { spacing } from "@/design/tokens/spacing";
|
import { spacing } from "../../design/tokens/spacing";
|
||||||
import LogoutButton from "@/features/auth/LogoutButton";
|
|
||||||
import PageBreadcrumb from "@/components/layout/PageBreadcrumb";
|
|
||||||
|
|
||||||
const { Header, Content, Sider } = Layout;
|
const { Header, Content, Sider } = Layout;
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
@ -139,13 +137,9 @@ const AdminLayout = ({ children }: AdminLayoutProps) => {
|
||||||
<Title level={4} style={{ margin: 0, color: colors.background, flex: 1, textAlign: "center" }}>
|
<Title level={4} style={{ margin: 0, color: colors.background, flex: 1, textAlign: "center" }}>
|
||||||
پنل ادمین وینسو
|
پنل ادمین وینسو
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<LogoutButton />
|
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Content style={{ margin: 24 }}>
|
<Content style={{ margin: 24 }}>
|
||||||
<PageBreadcrumb />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { Breadcrumb } from 'antd';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { sidebarItems } from '@/constants/navigation';
|
|
||||||
import { spacing } from '@/design/tokens/spacing';
|
|
||||||
|
|
||||||
const HOME_PATH = '/';
|
|
||||||
|
|
||||||
const PageBreadcrumb = () => {
|
|
||||||
const location = useLocation();
|
|
||||||
const isHome = location.pathname === HOME_PATH;
|
|
||||||
const exactItem = sidebarItems.find((item) => item.key === location.pathname);
|
|
||||||
const parentItem = exactItem
|
|
||||||
? undefined
|
|
||||||
: sidebarItems.find((item) => item.key !== HOME_PATH && location.pathname.startsWith(`${item.key}/`));
|
|
||||||
|
|
||||||
const homeLabel = (
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
داشبورد
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
const items: { title: ReactNode }[] = [{ title: isHome ? homeLabel : <Link to={HOME_PATH}>{homeLabel}</Link> }];
|
|
||||||
|
|
||||||
if (exactItem) {
|
|
||||||
items.push({ title: exactItem.label });
|
|
||||||
} else if (parentItem) {
|
|
||||||
items.push({ title: <Link to={parentItem.key}>{parentItem.label}</Link> });
|
|
||||||
items.push({ title: 'جزئیات' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Breadcrumb items={items} style={{ margin: `${spacing.md}px 0` }} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageBreadcrumb;
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Menu } from 'antd';
|
import { Menu } from 'antd';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { getSidebarMenuItems } from '@/constants/navigation';
|
import { getSidebarMenuItems } from '../../constants/navigation';
|
||||||
import { colors } from '@/design/tokens/colors';
|
import { colors } from '../../design/tokens/colors';
|
||||||
import { spacing } from '@/design/tokens/spacing';
|
import { spacing } from '../../design/tokens/spacing';
|
||||||
|
|
||||||
const Sidebar = () => {
|
const Sidebar = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import { Typography } from 'antd';
|
|
||||||
import { colors } from '@/design/tokens/colors';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
const EmptyValue = () => <Text style={{ color: colors.textSecondary }}>—</Text>;
|
|
||||||
|
|
||||||
export default EmptyValue;
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { Typography } from 'antd';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { colors } from '@/design/tokens/colors';
|
|
||||||
import { spacing } from '@/design/tokens/spacing';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface InfoRowProps {
|
|
||||||
label: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InfoRow = ({ label, children }: InfoRowProps) => (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm }}>
|
|
||||||
<Text style={{ color: colors.textSecondary }}>{label}</Text>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default InfoRow;
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import L from 'leaflet';
|
|
||||||
import 'leaflet/dist/leaflet.css';
|
|
||||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
|
||||||
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
|
||||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
// Vite-bundled builds break Leaflet's default marker icon lookup unless the
|
|
||||||
// image URLs are pointed at the bundler-resolved assets explicitly.
|
|
||||||
delete (L.Icon.Default.prototype as { _getIconUrl?: unknown })._getIconUrl;
|
|
||||||
L.Icon.Default.mergeOptions({
|
|
||||||
iconRetinaUrl: markerIcon2x,
|
|
||||||
iconUrl: markerIcon,
|
|
||||||
shadowUrl: markerShadow,
|
|
||||||
});
|
|
||||||
|
|
||||||
const TILE_URL = 'https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png';
|
|
||||||
|
|
||||||
interface LocationMapProps {
|
|
||||||
lat: number;
|
|
||||||
lng: number;
|
|
||||||
popupTitle?: string;
|
|
||||||
popupDescription?: string;
|
|
||||||
zoom?: number;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildPopupContent = (title?: string, description?: string) => {
|
|
||||||
if (!title && !description) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = document.createElement('div');
|
|
||||||
|
|
||||||
if (title) {
|
|
||||||
const titleEl = document.createElement('b');
|
|
||||||
titleEl.textContent = title;
|
|
||||||
container.appendChild(titleEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (title && description) {
|
|
||||||
container.appendChild(document.createElement('br'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (description) {
|
|
||||||
const descriptionEl = document.createElement('span');
|
|
||||||
descriptionEl.textContent = description;
|
|
||||||
container.appendChild(descriptionEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return container;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LocationMap = ({ lat, lng, popupTitle, popupDescription, zoom = 15, height = 300 }: LocationMapProps) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = L.map(containerRef.current).setView([lat, lng], zoom);
|
|
||||||
|
|
||||||
L.tileLayer(TILE_URL, {
|
|
||||||
maxZoom: 19,
|
|
||||||
minZoom: 8,
|
|
||||||
attribution: '© Addwin Maps',
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
const marker = L.marker([lat, lng]).addTo(map);
|
|
||||||
const popupContent = buildPopupContent(popupTitle, popupDescription);
|
|
||||||
|
|
||||||
if (popupContent) {
|
|
||||||
marker.bindPopup(popupContent).openPopup();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.remove();
|
|
||||||
};
|
|
||||||
}, [lat, lng, zoom, popupTitle, popupDescription]);
|
|
||||||
|
|
||||||
return <div ref={containerRef} style={{ width: '100%', height, borderRadius: 8 }} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LocationMap;
|
|
||||||
|
|
@ -8,12 +8,10 @@ interface LucideIconProps extends SVGProps<SVGSVGElement> {
|
||||||
|
|
||||||
const toReactPropName = (name: string) => name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
const toReactPropName = (name: string) => name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
||||||
|
|
||||||
const LucideIcon = ({ icon, size, ...props }: LucideIconProps): ReactElement => {
|
const LucideIcon = ({ icon, ...props }: LucideIconProps): ReactElement => {
|
||||||
const svgProps = {
|
const svgProps = {
|
||||||
...props,
|
...props,
|
||||||
xmlns: 'http://www.w3.org/2000/svg',
|
xmlns: 'http://www.w3.org/2000/svg',
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
fill: 'none',
|
fill: 'none',
|
||||||
stroke: 'currentColor',
|
stroke: 'currentColor',
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,4 @@ export const AUTH_SCOPE = [
|
||||||
'crm.message:retrieve',
|
'crm.message:retrieve',
|
||||||
'accounts.profile:list',
|
'accounts.profile:list',
|
||||||
'accounts.users:list',
|
'accounts.users:list',
|
||||||
'accounts.profile:detailed_retrieve'
|
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Home, MonitorPlay, UserRound } from "lucide";
|
import { Home, MonitorPlay, UserRound } from 'lucide';
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from 'react';
|
||||||
import type { MenuProps } from "antd";
|
import type { MenuProps } from 'antd';
|
||||||
import LucideIcon from "@/components/ui/LucideIcon";
|
import LucideIcon from '../components/ui/LucideIcon';
|
||||||
|
|
||||||
export type SidebarItemKey = "/profile" | "/billboards" | "/";
|
export type SidebarItemKey = '/profile' | '/billboards' | '/';
|
||||||
|
|
||||||
export interface SidebarItem {
|
export interface SidebarItem {
|
||||||
key: SidebarItemKey;
|
key: SidebarItemKey;
|
||||||
|
|
@ -13,29 +13,25 @@ export interface SidebarItem {
|
||||||
|
|
||||||
export const sidebarItems: SidebarItem[] = [
|
export const sidebarItems: SidebarItem[] = [
|
||||||
{
|
{
|
||||||
key: "/",
|
key: '/profile',
|
||||||
label: "داشبورد",
|
label: 'پروفایل کاربر',
|
||||||
icon: <LucideIcon icon={Home} size={18} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "/profile",
|
|
||||||
label: "پروفایل کاربر",
|
|
||||||
icon: <LucideIcon icon={UserRound} size={18} />,
|
icon: <LucideIcon icon={UserRound} size={18} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "/billboards",
|
key: '/billboards',
|
||||||
label: "بیلبوردها",
|
label: 'بیلبوردها',
|
||||||
icon: <LucideIcon icon={MonitorPlay} size={18} />,
|
icon: <LucideIcon icon={MonitorPlay} size={18} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/',
|
||||||
|
label: 'داشبورد',
|
||||||
|
icon: <LucideIcon icon={Home} size={18} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getSidebarMenuItems = (): NonNullable<MenuProps["items"]> =>
|
export const getSidebarMenuItems = (): NonNullable<MenuProps['items']> =>
|
||||||
sidebarItems.map((item) => ({
|
sidebarItems.map((item) => ({
|
||||||
key: item.key,
|
key: item.key,
|
||||||
label: (
|
label: <span style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>{item.icon}<span>{item.label}</span></span>,
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
|
icon: <span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>{item.icon}</span>,
|
||||||
{item.icon}
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ThemeConfig } from 'antd';
|
import type { ThemeConfig } from 'antd';
|
||||||
import { colors } from '@/design/tokens/colors';
|
import { colors } from '../tokens/colors';
|
||||||
import { typography } from '@/design/tokens/typography';
|
import { typography } from '../tokens/typography';
|
||||||
|
|
||||||
export const antdTheme: ThemeConfig = {
|
export const antdTheme: ThemeConfig = {
|
||||||
token: {
|
token: {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export { colors } from '@/design/tokens/colors';
|
export { colors } from './colors';
|
||||||
export { spacing } from '@/design/tokens/spacing';
|
export { spacing } from './spacing';
|
||||||
export { typography } from '@/design/tokens/typography';
|
export { typography } from './typography';
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { Button, Modal, Typography } from 'antd';
|
|
||||||
import { LogOut } from 'lucide';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import LucideIcon from '@/components/ui/LucideIcon';
|
|
||||||
import { colors } from '@/design/tokens/colors';
|
|
||||||
import { spacing } from '@/design/tokens/spacing';
|
|
||||||
import { useLogout } from '@/features/auth/useLogout';
|
|
||||||
|
|
||||||
const { Title, Paragraph } = Typography;
|
|
||||||
|
|
||||||
const LogoutButton = () => {
|
|
||||||
const { logout, isLoggingOut } = useLogout();
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
void logout();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<LucideIcon icon={LogOut} size={16} />}
|
|
||||||
onClick={() => setIsModalOpen(true)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: spacing.xs,
|
|
||||||
height: 40,
|
|
||||||
paddingInline: spacing.md,
|
|
||||||
borderRadius: 8,
|
|
||||||
background: 'rgba(255,255,255,0.15)',
|
|
||||||
color: colors.background,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
خروج
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={isModalOpen}
|
|
||||||
onCancel={() => setIsModalOpen(false)}
|
|
||||||
centered
|
|
||||||
footer={null}
|
|
||||||
width={380}
|
|
||||||
mask={{ closable: !isLoggingOut }}
|
|
||||||
closable={!isLoggingOut}
|
|
||||||
>
|
|
||||||
<div style={{ textAlign: 'center', padding: `${spacing.sm}px 0` }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
margin: `0 auto ${spacing.md}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LucideIcon icon={LogOut} size={24} color={colors.accent} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Title level={4} style={{ marginBottom: spacing.md, color: colors.text }}>
|
|
||||||
خروج از حساب کاربری
|
|
||||||
</Title>
|
|
||||||
<Paragraph style={{ color: colors.textSecondary, marginTop: spacing.xs, marginBottom: 0 }}>
|
|
||||||
آیا مطمئن هستید که میخواهید از پنل ادمین خارج شوید؟
|
|
||||||
</Paragraph>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: spacing.sm, marginTop: spacing.lg }}>
|
|
||||||
<Button block disabled={isLoggingOut} onClick={() => setIsModalOpen(false)}>
|
|
||||||
انصراف
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
block
|
|
||||||
type="primary"
|
|
||||||
loading={isLoggingOut}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
style={{ background: colors.accent, borderColor: colors.accent, boxShadow: 'none' }}
|
|
||||||
>
|
|
||||||
خروج
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LogoutButton;
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { authApi } from '@/services/authApi';
|
import { authApi } from '../../services/authApi';
|
||||||
import type { AuthSessionPayload, RequestOtpResponse } from '@/types/auth';
|
import type { AuthSessionPayload, RequestOtpResponse } from '../../types/auth';
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
sendOtp: async (phoneNumber: string): Promise<RequestOtpResponse> => {
|
sendOtp: async (phoneNumber: string): Promise<RequestOtpResponse> => {
|
||||||
|
|
@ -19,8 +19,4 @@ export const authService = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: async (): Promise<void> => {
|
|
||||||
await authApi.logout();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,17 @@
|
||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||||
import type { AuthState, LoginSuccessPayload } from '@/features/auth/authTypes';
|
import type { AuthState, LoginSuccessPayload } from './authTypes';
|
||||||
import { STORAGE_KEYS } from '@/utils/storage';
|
import { STORAGE_KEYS } from '../../utils/storage';
|
||||||
import { clearAuthTokens, getAccessToken, isAccessTokenExpired, storeAuthTokens } from '@/utils/authToken';
|
import { getAccessToken, storeAuthTokens } from '../../utils/authToken';
|
||||||
|
|
||||||
const clearPersistedAuthState = () => {
|
const initialState: AuthState = {
|
||||||
localStorage.removeItem(STORAGE_KEYS.accessToken);
|
user: null,
|
||||||
localStorage.removeItem(STORAGE_KEYS.user);
|
accessToken: null,
|
||||||
localStorage.removeItem(STORAGE_KEYS.authState);
|
isAuthenticated: false,
|
||||||
localStorage.removeItem(STORAGE_KEYS.lastSearchedUserUuid);
|
|
||||||
clearAuthTokens();
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadPersistedAuthState = (): AuthState => {
|
|
||||||
const loggedOutState: AuthState = { user: null, accessToken: null, isAuthenticated: false };
|
|
||||||
const storedAuth = localStorage.getItem(STORAGE_KEYS.authState);
|
|
||||||
const storedAccessToken = getAccessToken();
|
|
||||||
|
|
||||||
if (!storedAuth) {
|
|
||||||
return loggedOutState;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(storedAuth) as AuthState;
|
|
||||||
const accessToken = parsed.accessToken || storedAccessToken;
|
|
||||||
|
|
||||||
if (accessToken && parsed.user && !isAccessTokenExpired()) {
|
|
||||||
return { user: parsed.user, accessToken, isAuthenticated: true };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through to the logged-out default below
|
|
||||||
}
|
|
||||||
|
|
||||||
clearPersistedAuthState();
|
|
||||||
return loggedOutState;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const authSlice = createSlice({
|
const authSlice = createSlice({
|
||||||
name: 'auth',
|
name: 'auth',
|
||||||
initialState: loadPersistedAuthState(),
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
loginSuccess: (state, action: PayloadAction<LoginSuccessPayload>) => {
|
loginSuccess: (state, action: PayloadAction<LoginSuccessPayload>) => {
|
||||||
state.user = action.payload.user;
|
state.user = action.payload.user;
|
||||||
|
|
@ -64,9 +38,32 @@ const authSlice = createSlice({
|
||||||
state.accessToken = null;
|
state.accessToken = null;
|
||||||
state.isAuthenticated = false;
|
state.isAuthenticated = false;
|
||||||
|
|
||||||
clearPersistedAuthState();
|
localStorage.removeItem(STORAGE_KEYS.accessToken);
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.user);
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.authState);
|
||||||
|
},
|
||||||
|
hydrateAuth: (state) => {
|
||||||
|
const storedAuth = localStorage.getItem(STORAGE_KEYS.authState);
|
||||||
|
const storedAccessToken = getAccessToken();
|
||||||
|
|
||||||
|
if (!storedAuth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(storedAuth) as AuthState;
|
||||||
|
|
||||||
|
if ((parsed.accessToken || storedAccessToken) && parsed.user) {
|
||||||
|
state.user = parsed.user;
|
||||||
|
state.accessToken = parsed.accessToken || storedAccessToken;
|
||||||
|
state.isAuthenticated = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
state.user = null;
|
||||||
|
state.accessToken = null;
|
||||||
|
state.isAuthenticated = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
hydrateAuth: () => loadPersistedAuthState(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useAppDispatch } from '@/utils/hooks';
|
|
||||||
import { authService } from '@/features/auth/authService';
|
|
||||||
import { logout as logoutAction } from '@/features/auth/authSlice';
|
|
||||||
|
|
||||||
export const useLogout = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
setIsLoggingOut(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await authService.logout();
|
|
||||||
} finally {
|
|
||||||
dispatch(logoutAction());
|
|
||||||
setIsLoggingOut(false);
|
|
||||||
navigate('/login', { replace: true });
|
|
||||||
}
|
|
||||||
}, [dispatch, navigate]);
|
|
||||||
|
|
||||||
return { logout, isLoggingOut };
|
|
||||||
};
|
|
||||||
12
src/main.tsx
12
src/main.tsx
|
|
@ -2,21 +2,19 @@ import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { App as AntdApp, ConfigProvider } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
import faIR from 'antd/locale/fa_IR';
|
import faIR from 'antd/locale/fa_IR';
|
||||||
import { store } from '@/app/store';
|
import { store } from './app/store';
|
||||||
import { router } from '@/routes/router';
|
import { router } from './routes/router';
|
||||||
import { antdTheme } from '@/design/theme/antdTheme';
|
import { antdTheme } from './design/theme/antdTheme';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<ConfigProvider direction="rtl" locale={faIR} theme={antdTheme}>
|
<ConfigProvider direction="rtl" locale={faIR} theme={antdTheme}>
|
||||||
<AntdApp>
|
<RouterProvider router={router} />
|
||||||
<RouterProvider router={router} />
|
|
||||||
</AntdApp>
|
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|
|
||||||
|
|
@ -1,333 +0,0 @@
|
||||||
import { App, Button, Card, Empty, Image, Spin, Tag, Tooltip, Typography } from 'antd';
|
|
||||||
import { ArrowRight, FileText, Tag as TagIcon } from 'lucide';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import LucideIcon from '@/components/ui/LucideIcon';
|
|
||||||
import InfoRow from '@/components/ui/InfoRow';
|
|
||||||
import EmptyValue from '@/components/ui/EmptyValue';
|
|
||||||
import LocationMap from '@/components/ui/LocationMap';
|
|
||||||
import { colors } from '@/design/tokens/colors';
|
|
||||||
import { spacing } from '@/design/tokens/spacing';
|
|
||||||
import { formatDateTime } from '@/utils/formatDate';
|
|
||||||
import { formatToman } from '@/utils/formatCurrency';
|
|
||||||
import { adsApi } from '@/services/adsApi';
|
|
||||||
import { AD_STATE_LABELS, AD_TYPE_LABELS } from '@/pages/Billboards/adLabels';
|
|
||||||
import type { Ad, AdMediaAsset } from '@/types/ad';
|
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
|
||||||
|
|
||||||
const MEDIA_KIND: Record<number, 'image' | 'audio' | 'video' | 'document'> = {
|
|
||||||
1: 'image',
|
|
||||||
2: 'audio',
|
|
||||||
3: 'video',
|
|
||||||
4: 'document',
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildGoogleMapsUrl = (lat: number, lng: number) => `https://www.google.com/maps?q=${lat},${lng}`;
|
|
||||||
|
|
||||||
const sectionCardStyle = { borderColor: colors.primaryLight };
|
|
||||||
|
|
||||||
const MediaCard = ({ item }: { item: AdMediaAsset }) => {
|
|
||||||
const kind = MEDIA_KIND[item.content_type];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ width: 220 }}>
|
|
||||||
{kind === 'image' && (
|
|
||||||
<Image
|
|
||||||
src={item.url}
|
|
||||||
alt={item.filename}
|
|
||||||
style={{ width: 220, height: 160, objectFit: 'cover', borderRadius: 8 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{kind === 'video' && (
|
|
||||||
<video
|
|
||||||
controls
|
|
||||||
src={item.url}
|
|
||||||
style={{ width: '100%', height: 160, borderRadius: 8, background: '#000' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{kind === 'audio' && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 160,
|
|
||||||
borderRadius: 8,
|
|
||||||
background: colors.primaryLight,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: spacing.sm,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<audio controls src={item.url} style={{ width: '100%' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(kind === 'document' || !kind) && (
|
|
||||||
<a
|
|
||||||
href={item.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: spacing.xs,
|
|
||||||
height: 160,
|
|
||||||
borderRadius: 8,
|
|
||||||
background: colors.primaryLight,
|
|
||||||
color: colors.primary,
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LucideIcon icon={FileText} size={32} />
|
|
||||||
<span style={{ fontSize: 12 }}>دانلود فایل</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<Tooltip title={item.filename}>
|
|
||||||
<Text
|
|
||||||
ellipsis
|
|
||||||
style={{ display: 'block', marginTop: spacing.xs, fontSize: 12, color: colors.textSecondary }}
|
|
||||||
>
|
|
||||||
{item.filename}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FetchResult {
|
|
||||||
uuid: string;
|
|
||||||
status: 'success' | 'error';
|
|
||||||
ad: Ad | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AdDetail = () => {
|
|
||||||
const { uuid = '' } = useParams<{ uuid: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { message } = App.useApp();
|
|
||||||
const [result, setResult] = useState<FetchResult | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
adsApi
|
|
||||||
.getAdminAdDetail(uuid)
|
|
||||||
.then((ad) => {
|
|
||||||
setResult({ uuid, status: 'success', ad });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setResult({ uuid, status: 'error', ad: null });
|
|
||||||
message.error('دریافت اطلاعات آگهی با خطا مواجه شد');
|
|
||||||
});
|
|
||||||
}, [uuid, message]);
|
|
||||||
|
|
||||||
const isCurrent = result?.uuid === uuid;
|
|
||||||
const isLoading = !isCurrent;
|
|
||||||
const hasError = isCurrent && result.status === 'error';
|
|
||||||
const ad = isCurrent && result.status === 'success' ? result.ad : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<LucideIcon icon={ArrowRight} size={16} />}
|
|
||||||
onClick={() => navigate('/billboards')}
|
|
||||||
style={{ marginBottom: spacing.md, paddingInline: 0 }}
|
|
||||||
>
|
|
||||||
بازگشت به لیست بیلبوردها
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: spacing.xl }}>
|
|
||||||
<Spin size="large" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasError && <Empty description="آگهی مورد نظر یافت نشد" style={{ padding: spacing.xl }} />}
|
|
||||||
|
|
||||||
{ad && (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: spacing.md }}>
|
|
||||||
{/* Hero */}
|
|
||||||
<Card variant="outlined" style={sectionCardStyle} styles={{ body: { padding: 0 } }}>
|
|
||||||
{ad.refined_banner ? (
|
|
||||||
<Image
|
|
||||||
src={ad.refined_banner.url}
|
|
||||||
alt={ad.title ?? 'بنر'}
|
|
||||||
style={{ width: '100%', maxHeight: 320, objectFit: 'cover' }}
|
|
||||||
styles={{ root: { display: 'block', lineHeight: 0 } }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 160,
|
|
||||||
background: colors.primaryLight,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: colors.textSecondary }}>بنری برای این آگهی ثبت نشده است</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ padding: spacing.lg }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: spacing.sm,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Title level={3} style={{ margin: 0 }}>
|
|
||||||
{ad.title ?? <EmptyValue />}
|
|
||||||
</Title>
|
|
||||||
<div style={{ display: 'flex', gap: spacing.xs, flexWrap: 'wrap' }}>
|
|
||||||
<Tag color={colors.primary}>{AD_TYPE_LABELS[ad.type] ?? ad.type}</Tag>
|
|
||||||
<Tag color={AD_STATE_LABELS[ad.state]?.color ?? 'default'}>
|
|
||||||
{AD_STATE_LABELS[ad.state]?.label ?? ad.state}
|
|
||||||
</Tag>
|
|
||||||
<Tag color={ad.is_active ? 'success' : 'default'}>{ad.is_active ? 'فعال' : 'غیرفعال'}</Tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* General info + reward */}
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: spacing.md }}>
|
|
||||||
<Card
|
|
||||||
title="اطلاعات کلی"
|
|
||||||
variant="outlined"
|
|
||||||
style={{ ...sectionCardStyle, flex: '1 1 320px' }}
|
|
||||||
styles={{ body: { display: 'flex', flexDirection: 'column', gap: spacing.sm } }}
|
|
||||||
>
|
|
||||||
<InfoRow label="شناسه">
|
|
||||||
<Text copyable={{ text: ad.uuid }} style={{ direction: 'ltr', fontSize: 12 }}>
|
|
||||||
{ad.uuid}
|
|
||||||
</Text>
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="کاربر (ثبتکننده)">
|
|
||||||
<Link to={`/profile?uuid=${ad.user}`} style={{ direction: 'ltr', fontSize: 12 }}>
|
|
||||||
{ad.user}
|
|
||||||
</Link>
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="تاریخ ایجاد">
|
|
||||||
<Text>{formatDateTime(ad.created_at)}</Text>
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="تاریخ آخرین ویرایش">
|
|
||||||
<Text>{formatDateTime(ad.updated_at)}</Text>
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="تعداد نظرات">
|
|
||||||
<Text>{ad.comments_count}</Text>
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="تعداد لایکها">
|
|
||||||
<Text>{ad.likes_count}</Text>
|
|
||||||
</InfoRow>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
title="جایزه و آدرس"
|
|
||||||
variant="outlined"
|
|
||||||
style={{ ...sectionCardStyle, flex: '1 1 280px' }}
|
|
||||||
styles={{ body: { display: 'flex', flexDirection: 'column', gap: spacing.sm } }}
|
|
||||||
>
|
|
||||||
<InfoRow label="مبلغ جایزه">
|
|
||||||
<Text>{formatToman(ad.reward_amount)}</Text>
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="تعداد جایزه">
|
|
||||||
<Text>{ad.reward_count}</Text>
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="آدرس">{ad.address ? <Text>{ad.address}</Text> : <EmptyValue />}</InfoRow>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<Card title="توضیحات" variant="outlined" style={sectionCardStyle}>
|
|
||||||
<Paragraph style={{ color: colors.text, whiteSpace: 'pre-wrap', margin: 0 }}>
|
|
||||||
{ad.description ?? <EmptyValue />}
|
|
||||||
</Paragraph>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<Card
|
|
||||||
title={
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: spacing.xs }}>
|
|
||||||
<LucideIcon icon={TagIcon} size={14} color={colors.textSecondary} />
|
|
||||||
برچسبها
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
variant="outlined"
|
|
||||||
style={sectionCardStyle}
|
|
||||||
>
|
|
||||||
{ad.tags.length ? (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: spacing.xs }}>
|
|
||||||
{ad.tags.map((tag) => (
|
|
||||||
<Tag key={tag} color={colors.primaryLight} style={{ color: colors.primary, fontSize: 13 }}>
|
|
||||||
{tag}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyValue />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Media */}
|
|
||||||
<Card title="محتوا" variant="outlined" style={sectionCardStyle}>
|
|
||||||
{ad.refined_contents.length ? (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: spacing.md }}>
|
|
||||||
{ad.refined_contents.map((item) => (
|
|
||||||
<MediaCard key={item.uuid} item={item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyValue />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Location */}
|
|
||||||
<Card title="موقعیت مکانی" variant="outlined" style={sectionCardStyle}>
|
|
||||||
{ad.location ? (
|
|
||||||
<>
|
|
||||||
{ad.location_is_approximate && (
|
|
||||||
<Tag color="warning" style={{ marginBottom: spacing.sm }}>
|
|
||||||
موقعیت مکانی تقریبی است
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
<div style={{ borderRadius: 8, overflow: 'hidden', border: `1px solid ${colors.primaryLight}` }}>
|
|
||||||
<LocationMap
|
|
||||||
lat={ad.location.coordinates[1]}
|
|
||||||
lng={ad.location.coordinates[0]}
|
|
||||||
popupTitle={ad.title ?? undefined}
|
|
||||||
popupDescription={ad.address || undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: spacing.sm,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ direction: 'ltr', color: colors.textSecondary }}>
|
|
||||||
{ad.location.coordinates[1]}, {ad.location.coordinates[0]}
|
|
||||||
</Text>
|
|
||||||
<a
|
|
||||||
href={buildGoogleMapsUrl(ad.location.coordinates[1], ad.location.coordinates[0])}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
مشاهده در Google Maps
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<EmptyValue />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdDetail;
|
|
||||||
|
|
@ -1,159 +1,17 @@
|
||||||
import { App, Button, Table, Tag, Tooltip, Typography } from 'antd';
|
import { Card, Typography } from 'antd';
|
||||||
import type { TableColumnsType } from 'antd';
|
import { colors } from '../../design/tokens/colors';
|
||||||
import { useEffect, useState } from 'react';
|
import { spacing } from '../../design/tokens/spacing';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { colors } from '@/design/tokens/colors';
|
|
||||||
import { formatDateTime } from '@/utils/formatDate';
|
|
||||||
import { adsApi } from '@/services/adsApi';
|
|
||||||
import EmptyValue from '@/components/ui/EmptyValue';
|
|
||||||
import { AD_STATE_LABELS, AD_TYPE_LABELS, truncateText, truncateUuid } from '@/pages/Billboards/adLabels';
|
|
||||||
import type { Ad } from '@/types/ad';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
|
||||||
|
|
||||||
interface FetchResult {
|
|
||||||
page: number;
|
|
||||||
status: 'success' | 'error';
|
|
||||||
ads: Ad[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Billboards = () => {
|
const Billboards = () => {
|
||||||
const { message } = App.useApp();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [result, setResult] = useState<FetchResult | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
adsApi
|
|
||||||
.getAdminAds({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE })
|
|
||||||
.then(({ results, count }) => {
|
|
||||||
setResult({ page, status: 'success', ads: results, total: count });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setResult({ page, status: 'error', ads: [], total: 0 });
|
|
||||||
message.error('دریافت اطلاعات بیلبوردها با خطا مواجه شد');
|
|
||||||
});
|
|
||||||
}, [page, message]);
|
|
||||||
|
|
||||||
const isCurrent = result?.page === page;
|
|
||||||
const isLoading = !isCurrent;
|
|
||||||
const ads = isCurrent ? result.ads : [];
|
|
||||||
const total = isCurrent ? result.total : 0;
|
|
||||||
|
|
||||||
const columns: TableColumnsType<Ad> = [
|
|
||||||
{
|
|
||||||
title: 'شناسه',
|
|
||||||
dataIndex: 'uuid',
|
|
||||||
key: 'uuid',
|
|
||||||
render: (value: string) => (
|
|
||||||
<Tooltip title={value}>
|
|
||||||
<Text copyable={{ text: value }} style={{ direction: 'ltr', fontSize: 12 }}>
|
|
||||||
{truncateUuid(value)}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'کاربر (ثبتکننده)',
|
|
||||||
dataIndex: 'user',
|
|
||||||
key: 'user',
|
|
||||||
render: (value: string) => (
|
|
||||||
<Tooltip title={value}>
|
|
||||||
<Link to={`/profile?uuid=${value}`} style={{ direction: 'ltr', fontSize: 12 }}>
|
|
||||||
{truncateUuid(value)}
|
|
||||||
</Link>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'عنوان',
|
|
||||||
dataIndex: 'title',
|
|
||||||
key: 'title',
|
|
||||||
ellipsis: true,
|
|
||||||
render: (value: string | null) => value ?? <EmptyValue />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'نوع',
|
|
||||||
dataIndex: 'type',
|
|
||||||
key: 'type',
|
|
||||||
render: (value: number) => <Tag color={colors.primary}>{AD_TYPE_LABELS[value] ?? value}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'توضیحات',
|
|
||||||
dataIndex: 'description',
|
|
||||||
key: 'description',
|
|
||||||
render: (value: string | null) =>
|
|
||||||
value ? (
|
|
||||||
<Tooltip title={value}>
|
|
||||||
<span>{truncateText(value)}</span>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<EmptyValue />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'برچسبها',
|
|
||||||
dataIndex: 'tags',
|
|
||||||
key: 'tags',
|
|
||||||
render: (tags: string[]) =>
|
|
||||||
tags?.length ? (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<Tag key={tag}>{tag}</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyValue />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: 'تاریخ ایجاد', dataIndex: 'created_at', key: 'created_at', render: formatDateTime },
|
|
||||||
{ title: 'تاریخ آخرین ویرایش', dataIndex: 'updated_at', key: 'updated_at', render: formatDateTime },
|
|
||||||
{
|
|
||||||
title: 'وضعیت',
|
|
||||||
dataIndex: 'state',
|
|
||||||
key: 'state',
|
|
||||||
render: (value: number) => {
|
|
||||||
const state = AD_STATE_LABELS[value];
|
|
||||||
return <Tag color={state?.color ?? 'default'}>{state?.label ?? value}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ title: 'تعداد نظرات', dataIndex: 'comments_count', key: 'comments_count' },
|
|
||||||
{ title: 'تعداد لایکها', dataIndex: 'likes_count', key: 'likes_count' },
|
|
||||||
{
|
|
||||||
title: 'فعال/غیرفعال',
|
|
||||||
dataIndex: 'is_active',
|
|
||||||
key: 'is_active',
|
|
||||||
render: (value: boolean) => <Tag color={value ? 'success' : 'default'}>{value ? 'فعال' : 'غیرفعال'}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'جزئیات',
|
|
||||||
key: 'details',
|
|
||||||
render: (_, record) => (
|
|
||||||
<Button size="small" onClick={() => navigate(`/billboards/${record.uuid}`)}>
|
|
||||||
مشاهده جزئیات
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<Card bordered={false} style={{ boxShadow: 'none', padding: 0 }}>
|
||||||
rowKey="uuid"
|
<Title level={2}>بیلبوردها</Title>
|
||||||
columns={columns}
|
<Paragraph style={{ color: colors.textSecondary, marginTop: spacing.sm }}>
|
||||||
dataSource={ads}
|
مدیریت جدول بیلبوردها بهزودی اضافه خواهد شد.
|
||||||
loading={isLoading}
|
</Paragraph>
|
||||||
scroll={{ x: 'max-content' }}
|
</Card>
|
||||||
pagination={{
|
|
||||||
current: page,
|
|
||||||
pageSize: PAGE_SIZE,
|
|
||||||
total,
|
|
||||||
showSizeChanger: false,
|
|
||||||
onChange: setPage,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
export const AD_TYPE_LABELS: Record<number, string> = {
|
|
||||||
1: 'بیلبورد',
|
|
||||||
2: 'بیلبورد کپچر شده',
|
|
||||||
99: 'تبلیغ/پروموشن',
|
|
||||||
100: 'poi',
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AdStateColor = 'default' | 'processing' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
export const AD_STATE_LABELS: Record<number, { label: string; color: AdStateColor }> = {
|
|
||||||
0: { label: 'پیشنویس', color: 'default' },
|
|
||||||
2: { label: 'در حال بررسی', color: 'processing' },
|
|
||||||
9: { label: 'رد شده', color: 'error' },
|
|
||||||
18: { label: 'متوقفشده (توسط کاربر)', color: 'warning' },
|
|
||||||
19: { label: 'لغو انتشار شده', color: 'warning' },
|
|
||||||
20: { label: 'منتشر شده', color: 'success' },
|
|
||||||
100: { label: 'حذف شده', color: 'error' },
|
|
||||||
200: { label: 'در حال اجرا', color: 'processing' },
|
|
||||||
400: { label: 'پایانیافته', color: 'success' },
|
|
||||||
500: { label: 'غیرفعال', color: 'default' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const truncateUuid = (value: string) => `${value.slice(0, 8)}…`;
|
|
||||||
|
|
||||||
const DESCRIPTION_MAX_LENGTH = 40;
|
|
||||||
export const truncateText = (value: string, maxLength = DESCRIPTION_MAX_LENGTH) =>
|
|
||||||
value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Card, Typography } from 'antd';
|
import { Card, Typography } from 'antd';
|
||||||
import { colors } from '@/design/tokens/colors';
|
import { colors } from '../../design/tokens/colors';
|
||||||
import { spacing } from '@/design/tokens/spacing';
|
import { spacing } from '../../design/tokens/spacing';
|
||||||
|
|
||||||
const { Title, Paragraph } = Typography;
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { App, Button, Card, Form, Input, Typography } from 'antd';
|
import { Button, Card, Form, Input, Typography, message } from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { authService } from '@/features/auth/authService';
|
import { authService } from '../../features/auth/authService';
|
||||||
import { colors } from '@/design/tokens/colors';
|
import { colors } from '../../design/tokens/colors';
|
||||||
import { spacing } from '@/design/tokens/spacing';
|
import { spacing } from '../../design/tokens/spacing';
|
||||||
|
|
||||||
const { Title, Paragraph } = Typography;
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
|
|
@ -13,7 +13,6 @@ interface LoginFormValues {
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm<LoginFormValues>();
|
const [form] = Form.useForm<LoginFormValues>();
|
||||||
const { message } = App.useApp();
|
|
||||||
|
|
||||||
const onFinish = async (values: LoginFormValues) => {
|
const onFinish = async (values: LoginFormValues) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import { App, Button, Table, Tooltip, Typography } from 'antd';
|
|
||||||
import type { TableColumnsType } from 'antd';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import EmptyValue from '@/components/ui/EmptyValue';
|
|
||||||
import { formatDate, formatDateTime } from '@/utils/formatDate';
|
|
||||||
import { usersApi } from '@/services/usersApi';
|
|
||||||
import type { AdminUserListItem } from '@/types/user';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
|
||||||
|
|
||||||
const GENDER_LABELS: Record<string, string> = {
|
|
||||||
m: 'مرد',
|
|
||||||
f: 'زن',
|
|
||||||
};
|
|
||||||
|
|
||||||
const truncateUuid = (value: string) => `${value.slice(0, 8)}…`;
|
|
||||||
|
|
||||||
const renderText = (value: string | null) => value ?? <EmptyValue />;
|
|
||||||
|
|
||||||
interface FetchResult {
|
|
||||||
page: number;
|
|
||||||
status: 'success' | 'error';
|
|
||||||
users: AdminUserListItem[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AllUsersTableProps {
|
|
||||||
onSelectUuid: (uuid: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AllUsersTable = ({ onSelectUuid }: AllUsersTableProps) => {
|
|
||||||
const { message } = App.useApp();
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [result, setResult] = useState<FetchResult | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
usersApi
|
|
||||||
.getAdminUsers({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE })
|
|
||||||
.then(({ results, count }) => {
|
|
||||||
setResult({ page, status: 'success', users: results, total: count });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setResult({ page, status: 'error', users: [], total: 0 });
|
|
||||||
message.error('دریافت لیست کاربران با خطا مواجه شد');
|
|
||||||
});
|
|
||||||
}, [page, message]);
|
|
||||||
|
|
||||||
const isCurrent = result?.page === page;
|
|
||||||
const isLoading = !isCurrent;
|
|
||||||
const users = isCurrent ? result.users : [];
|
|
||||||
const total = isCurrent ? result.total : 0;
|
|
||||||
|
|
||||||
const columns: TableColumnsType<AdminUserListItem> = [
|
|
||||||
{
|
|
||||||
title: 'شناسه یکتا',
|
|
||||||
dataIndex: 'uuid',
|
|
||||||
key: 'uuid',
|
|
||||||
render: (value: string) => (
|
|
||||||
<Tooltip title={value}>
|
|
||||||
<Text copyable={{ text: value }} style={{ direction: 'ltr', fontSize: 12 }}>
|
|
||||||
{truncateUuid(value)}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: 'نام کاربری', dataIndex: 'username', key: 'username', render: renderText },
|
|
||||||
{ title: 'ایمیل', dataIndex: 'email', key: 'email', render: renderText },
|
|
||||||
{
|
|
||||||
title: 'شماره تلفن',
|
|
||||||
dataIndex: 'phone_number',
|
|
||||||
key: 'phone_number',
|
|
||||||
render: (value: string | null) => (value ? <Text style={{ direction: 'ltr' }}>{value}</Text> : <EmptyValue />),
|
|
||||||
},
|
|
||||||
{ title: 'نام', dataIndex: 'first_name', key: 'first_name', render: renderText },
|
|
||||||
{ title: 'نام خانوادگی', dataIndex: 'last_name', key: 'last_name', render: renderText },
|
|
||||||
{ title: 'نام کامل', dataIndex: 'name', key: 'name', render: renderText },
|
|
||||||
{
|
|
||||||
title: 'جنسیت',
|
|
||||||
dataIndex: 'gender',
|
|
||||||
key: 'gender',
|
|
||||||
render: (value: string | null) => (value ? (GENDER_LABELS[value] ?? value) : <EmptyValue />),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'تاریخ تولد',
|
|
||||||
dataIndex: 'birthdate',
|
|
||||||
key: 'birthdate',
|
|
||||||
render: (value: string | null) => (value ? formatDate(value) : <EmptyValue />),
|
|
||||||
},
|
|
||||||
{ title: 'تاریخ عضویت', dataIndex: 'date_joined', key: 'date_joined', render: formatDateTime },
|
|
||||||
{ title: 'آخرین بروزرسانی', dataIndex: 'last_update', key: 'last_update', render: formatDateTime },
|
|
||||||
{
|
|
||||||
title: 'عملیات',
|
|
||||||
key: 'actions',
|
|
||||||
render: (_, record) => (
|
|
||||||
<Button size="small" onClick={() => onSelectUuid(record.uuid)}>
|
|
||||||
مشاهده جزئیات
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table
|
|
||||||
rowKey="uuid"
|
|
||||||
columns={columns}
|
|
||||||
dataSource={users}
|
|
||||||
loading={isLoading}
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
pagination={{
|
|
||||||
current: page,
|
|
||||||
pageSize: PAGE_SIZE,
|
|
||||||
total,
|
|
||||||
showSizeChanger: false,
|
|
||||||
onChange: setPage,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AllUsersTable;
|
|
||||||
|
|
@ -1,112 +1,17 @@
|
||||||
import { App, Empty, Input, Spin, Typography } from 'antd';
|
import { Card, Typography } from 'antd';
|
||||||
import { useEffect, useState } from 'react';
|
import { colors } from '../../design/tokens/colors';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { spacing } from '../../design/tokens/spacing';
|
||||||
import { spacing } from '@/design/tokens/spacing';
|
|
||||||
import { usersApi } from '@/services/usersApi';
|
|
||||||
import { STORAGE_KEYS } from '@/utils/storage';
|
|
||||||
import type { UserDetails } from '@/types/user';
|
|
||||||
import UserInfoCard from '@/pages/Profile/UserInfoCard';
|
|
||||||
import StatCardsRow from '@/pages/Profile/StatCards';
|
|
||||||
import ProfileTabs from '@/pages/Profile/ProfileTabs';
|
|
||||||
import AllUsersTable from '@/pages/Profile/AllUsersTable';
|
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
type SearchStatus = 'idle' | 'loading' | 'error' | 'success';
|
|
||||||
|
|
||||||
interface FetchResult {
|
|
||||||
uuid: string;
|
|
||||||
status: 'success' | 'error';
|
|
||||||
user: UserDetails | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UUID_PARAM = 'uuid';
|
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
const { message } = App.useApp();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const urlUuid = searchParams.get(UUID_PARAM) ?? '';
|
|
||||||
// In-app navigation away and back doesn't preserve the URL's query string
|
|
||||||
// (the sidebar links navigate to bare paths), so fall back to the last
|
|
||||||
// searched uuid to make that behave like a reload instead of a blank page.
|
|
||||||
const [fallbackUuid] = useState(() =>
|
|
||||||
urlUuid ? '' : (localStorage.getItem(STORAGE_KEYS.lastSearchedUserUuid) ?? ''),
|
|
||||||
);
|
|
||||||
const uuid = urlUuid || fallbackUuid;
|
|
||||||
const [result, setResult] = useState<FetchResult | null>(null);
|
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
|
|
||||||
if (!trimmed) {
|
|
||||||
message.warning('شناسه (UUID) کاربر را وارد کنید');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSearchParams({ [UUID_PARAM]: trimmed });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!uuid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(STORAGE_KEYS.lastSearchedUserUuid, uuid);
|
|
||||||
|
|
||||||
usersApi
|
|
||||||
.getUserDetails(uuid)
|
|
||||||
.then((details) => {
|
|
||||||
setResult({ uuid, status: 'success', user: details });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setResult({ uuid, status: 'error', user: null });
|
|
||||||
message.error('کاربری با این شناسه یافت نشد');
|
|
||||||
});
|
|
||||||
}, [uuid, message]);
|
|
||||||
|
|
||||||
const isCurrent = result?.uuid === uuid;
|
|
||||||
const status: SearchStatus = !uuid ? 'idle' : isCurrent ? result.status : 'loading';
|
|
||||||
const user = isCurrent ? result.user : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Card bordered={false} style={{ boxShadow: 'none', padding: 0 }}>
|
||||||
<div style={{ marginBottom: spacing.md }}>
|
<Title level={2}>پروفایل کاربر</Title>
|
||||||
<Input.Search
|
<Paragraph style={{ color: colors.textSecondary, marginTop: spacing.sm }}>
|
||||||
key={uuid}
|
جدول اطلاعات کاربر بهزودی اضافه خواهد شد.
|
||||||
placeholder="جستجو بر اساس شناسه (UUID) کاربر"
|
</Paragraph>
|
||||||
defaultValue={uuid}
|
</Card>
|
||||||
onSearch={handleSearch}
|
|
||||||
enterButton="جستجو"
|
|
||||||
size="large"
|
|
||||||
loading={status === 'loading'}
|
|
||||||
style={{ maxWidth: 480 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{status === 'loading' && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: spacing.xl }}>
|
|
||||||
<Spin size="large" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'error' && <Empty description="کاربری با این شناسه یافت نشد" style={{ padding: spacing.xl }} />}
|
|
||||||
|
|
||||||
{status === 'success' && user && (
|
|
||||||
<>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: spacing.md }}>
|
|
||||||
<UserInfoCard user={user} />
|
|
||||||
<StatCardsRow />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProfileTabs style={{ marginTop: spacing.lg }} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Title level={4} style={{ marginTop: spacing.xl, marginBottom: spacing.md }}>
|
|
||||||
لیست کاربران
|
|
||||||
</Title>
|
|
||||||
<AllUsersTable onSelectUuid={handleSearch} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { Empty, Tabs } from 'antd';
|
|
||||||
import type { CSSProperties } from 'react';
|
|
||||||
|
|
||||||
interface ProfileTabsProps {
|
|
||||||
style?: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_DEFINITIONS = [
|
|
||||||
{ key: 'profile', label: 'پروفایل' },
|
|
||||||
{ key: 'billboards', label: 'بیلبوردها' },
|
|
||||||
{ key: 'support-tickets', label: 'تیکتهای پشتیبانی' },
|
|
||||||
{ key: 'views', label: 'بازدیدها' },
|
|
||||||
{ key: 'withdrawals', label: 'برداشتها' },
|
|
||||||
{ key: 'deposits', label: 'واریزها' },
|
|
||||||
{ key: 'received-promotions', label: 'پروموشنهای دریافتی' },
|
|
||||||
{ key: 'comments', label: 'کامنتها' },
|
|
||||||
{ key: 'invite-friends', label: 'دعوت کردن از دوستان' },
|
|
||||||
{ key: 'invited-by', label: 'دعوت شده توسط' },
|
|
||||||
{ key: 'chats', label: 'چتها' },
|
|
||||||
{ key: 'secure-payment', label: 'پرداخت امن' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const ProfileTabs = ({ style }: ProfileTabsProps) => (
|
|
||||||
<Tabs
|
|
||||||
style={style}
|
|
||||||
items={TAB_DEFINITIONS.map(({ key, label }) => ({
|
|
||||||
key,
|
|
||||||
label,
|
|
||||||
children: <Empty description="جدول این بخش در گام بعدی اضافه خواهد شد" />,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ProfileTabs;
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import { Card, Typography } from 'antd';
|
|
||||||
import { ArrowDownToLine, ArrowUpFromLine, Eye, Megaphone, MonitorPlay } from 'lucide';
|
|
||||||
import type { IconNode } from 'lucide';
|
|
||||||
import LucideIcon from '@/components/ui/LucideIcon';
|
|
||||||
import { colors } from '@/design/tokens/colors';
|
|
||||||
import { spacing } from '@/design/tokens/spacing';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface StatCardData {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
icon: IconNode;
|
|
||||||
accent?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCount = (value: number) => value.toLocaleString('en-US');
|
|
||||||
const formatToman = (value: number) => `${value.toLocaleString('en-US')} ت`;
|
|
||||||
|
|
||||||
const STAT_CARDS: StatCardData[] = [
|
|
||||||
{ key: 'billboards', label: 'بیلبوردها', value: formatCount(12), icon: MonitorPlay },
|
|
||||||
{ key: 'views', label: 'بازدیدها', value: formatCount(4320), icon: Eye },
|
|
||||||
{ key: 'withdrawals', label: 'برداشتها', value: formatToman(2000000), icon: ArrowUpFromLine, accent: true },
|
|
||||||
{ key: 'deposits', label: 'واریزها', value: formatToman(8500000), icon: ArrowDownToLine, accent: true },
|
|
||||||
{ key: 'promotions', label: 'پروموشنها', value: formatToman(2300000), icon: Megaphone, accent: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const StatCard = ({ label, value, icon, accent }: Omit<StatCardData, 'key'>) => {
|
|
||||||
const iconColor = accent ? colors.accent : colors.primary;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
variant="outlined"
|
|
||||||
style={{ flex: '1 1 160px', borderColor: colors.primaryLight, height: '100%' }}
|
|
||||||
styles={{
|
|
||||||
body: {
|
|
||||||
height: '100%',
|
|
||||||
padding: spacing.lg,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
gap: spacing.xs,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LucideIcon icon={icon} size={22} color={iconColor} />
|
|
||||||
</div>
|
|
||||||
<Text style={{ color: colors.textSecondary, fontSize: 13 }}>{label}</Text>
|
|
||||||
<Text style={{ fontSize: 20, fontWeight: 700, color: colors.text }}>{value}</Text>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatCardsRow = () => (
|
|
||||||
<>
|
|
||||||
{STAT_CARDS.map(({ key, ...rest }) => (
|
|
||||||
<StatCard key={key} {...rest} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default StatCardsRow;
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import { Avatar, Card, Typography } from 'antd';
|
|
||||||
import { UserRound } from 'lucide';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import LucideIcon from '@/components/ui/LucideIcon';
|
|
||||||
import InfoRow from '@/components/ui/InfoRow';
|
|
||||||
import { colors } from '@/design/tokens/colors';
|
|
||||||
import { spacing } from '@/design/tokens/spacing';
|
|
||||||
import type { UserDetails } from '@/types/user';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
|
||||||
|
|
||||||
interface UserInfoCardProps {
|
|
||||||
user: UserDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserInfoCard = ({ user }: UserInfoCardProps) => {
|
|
||||||
const [avatarFailed, setAvatarFailed] = useState(false);
|
|
||||||
const fullName = user.name || `${user.first_name} ${user.last_name}`.trim() || 'بدون نام';
|
|
||||||
const showAvatarImage = Boolean(user.avatar) && !avatarFailed;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
variant="outlined"
|
|
||||||
style={{ flex: '1 1 320px', borderColor: colors.primaryLight }}
|
|
||||||
styles={{ body: { padding: spacing.lg } }}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: spacing.md, marginBottom: spacing.lg }}>
|
|
||||||
<Avatar
|
|
||||||
size={64}
|
|
||||||
src={showAvatarImage ? user.avatar : undefined}
|
|
||||||
icon={showAvatarImage ? undefined : <LucideIcon icon={UserRound} size={28} />}
|
|
||||||
onError={() => {
|
|
||||||
setAvatarFailed(true);
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
style={{ background: colors.primaryLight, color: colors.primary, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<Title level={4} style={{ margin: 0, color: colors.text }}>
|
|
||||||
{fullName}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: spacing.sm }}>
|
|
||||||
<InfoRow label="شناسه کاربر">
|
|
||||||
<Text copyable={{ text: user.uuid }} style={{ direction: 'ltr' }}>
|
|
||||||
{user.uuid}
|
|
||||||
</Text>
|
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="شماره موبایل">
|
|
||||||
<Text style={{ direction: 'ltr' }}>{user.phone_number}</Text>
|
|
||||||
</InfoRow>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UserInfoCard;
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { App, Button, Card, Form, Input, Typography, type InputRef } from 'antd';
|
import { Button, Card, Form, Input, Typography, message, type InputRef } from 'antd';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppDispatch } from '@/utils/hooks';
|
import { useAppDispatch } from '../../utils/hooks';
|
||||||
import { loginSuccess } from '@/features/auth/authSlice';
|
import { loginSuccess } from '../../features/auth/authSlice';
|
||||||
import { authService } from '@/features/auth/authService';
|
import { authService } from '../../features/auth/authService';
|
||||||
import { colors } from '@/design/tokens/colors';
|
import { colors } from '../../design/tokens/colors';
|
||||||
import { spacing } from '@/design/tokens/spacing';
|
import { spacing } from '../../design/tokens/spacing';
|
||||||
|
|
||||||
const { Title, Paragraph } = Typography;
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
|
|
@ -19,7 +19,6 @@ const VerifyOtp = () => {
|
||||||
const [phoneNumber] = useState(() => localStorage.getItem('pending_phone_number') ?? '');
|
const [phoneNumber] = useState(() => localStorage.getItem('pending_phone_number') ?? '');
|
||||||
const [form] = Form.useForm<VerifyOtpFormValues>();
|
const [form] = Form.useForm<VerifyOtpFormValues>();
|
||||||
const otpInputRef = useRef<InputRef>(null);
|
const otpInputRef = useRef<InputRef>(null);
|
||||||
const { message } = App.useApp();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||||
import App from '@/App';
|
import App from '../App';
|
||||||
import Login from '@/pages/Login/Login';
|
import Login from '../pages/Login/Login';
|
||||||
import VerifyOtp from '@/pages/VerifyOtp/VerifyOtp';
|
import VerifyOtp from '../pages/VerifyOtp/VerifyOtp';
|
||||||
import Home from '@/pages/Home/Home';
|
import Home from '../pages/Home/Home';
|
||||||
import Profile from '@/pages/Profile/Profile';
|
import Profile from '../pages/Profile/Profile';
|
||||||
import Billboards from '@/pages/Billboards/Billboards';
|
import Billboards from '../pages/Billboards/Billboards';
|
||||||
import AdDetail from '@/pages/Billboards/AdDetail';
|
import ProtectedRoute from '../components/ProtectedRoute';
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -45,14 +44,6 @@ export const router = createBrowserRouter([
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'billboards/:uuid',
|
|
||||||
element: (
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdDetail />
|
|
||||||
</ProtectedRoute>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
element: <Navigate to="/" replace />,
|
element: <Navigate to="/" replace />,
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import axios from 'axios';
|
|
||||||
import { env } from '@/config/env';
|
|
||||||
import { getAccessToken } from '@/utils/authToken';
|
|
||||||
import { buildAuthUrl } from '@/utils/authUrl';
|
|
||||||
import type { Ad, AdminAdsResponse } from '@/types/ad';
|
|
||||||
|
|
||||||
interface GetAdminAdsParams {
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const adsApi = {
|
|
||||||
getAdminAds: async ({ limit, offset }: GetAdminAdsParams): Promise<AdminAdsResponse> => {
|
|
||||||
const response = await axios.get<AdminAdsResponse>(
|
|
||||||
buildAuthUrl('/advertising/stores/admin-ads/', env.apiBaseUrl),
|
|
||||||
{
|
|
||||||
params: { limit, offset },
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${getAccessToken()}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
getAdminAdDetail: async (uuid: string): Promise<Ad> => {
|
|
||||||
const response = await axios.get<Ad>(buildAuthUrl(`/advertising/stores/admin-ads/${uuid}/`, env.apiBaseUrl), {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${getAccessToken()}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
||||||
import { AUTH_SCOPE } from '@/constants/auth';
|
import { AUTH_SCOPE } from '../constants/auth';
|
||||||
import { env } from '@/config/env';
|
import { env } from '../config/env';
|
||||||
import { getAccessToken, getRefreshToken, storeAuthTokens, clearAuthTokens } from '@/utils/authToken';
|
import { getAccessToken, getRefreshToken, storeAuthTokens, clearAuthTokens } from '../utils/authToken';
|
||||||
import { logout } from '@/utils/logout';
|
import { logout } from '../utils/logout';
|
||||||
import { buildAuthUrl } from '@/utils/authUrl';
|
import { buildAuthUrl } from '../utils/authUrl';
|
||||||
import { createBasicAuthHeader } from '@/config/env';
|
import { createBasicAuthHeader } from '../config/env';
|
||||||
import type { TokenResponse } from '@/types/auth';
|
import type { TokenResponse } from '../types/auth';
|
||||||
|
|
||||||
const apiBaseUrl = env.apiBaseUrl;
|
const apiBaseUrl = env.apiBaseUrl;
|
||||||
|
|
||||||
|
|
@ -51,7 +51,7 @@ api.interceptors.response.use(
|
||||||
params.append('refresh_token', refreshToken);
|
params.append('refresh_token', refreshToken);
|
||||||
params.append('scope', AUTH_SCOPE);
|
params.append('scope', AUTH_SCOPE);
|
||||||
|
|
||||||
const response = await axios.post<TokenResponse>(buildAuthUrl('/accounts/oauth2/token/'), params, {
|
const response = await axios.post<TokenResponse>(buildAuthUrl('/oauth2/token/'), params, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Authorization: createBasicAuthHeader(),
|
Authorization: createBasicAuthHeader(),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { AUTH_SCOPE } from '@/constants/auth';
|
import { AUTH_SCOPE } from '../constants/auth';
|
||||||
import { createBasicAuthHeader } from '@/config/env';
|
import { createBasicAuthHeader } from '../config/env';
|
||||||
import { clearAuthTokens, getAccessToken, storeAuthTokens } from '@/utils/authToken';
|
import { clearAuthTokens, storeAuthTokens } from '../utils/authToken';
|
||||||
import { normalizePhoneNumber, toE164PhoneNumber } from '@/utils/phone';
|
import { normalizePhoneNumber, toE164PhoneNumber } from '../utils/phone';
|
||||||
import { buildAuthUrl } from '@/utils/authUrl';
|
import { buildAuthUrl } from '../utils/authUrl';
|
||||||
import type { RequestOtpPayload, RequestOtpResponse, TokenResponse } from '@/types/auth';
|
import type { RequestOtpPayload, RequestOtpResponse, TokenResponse } from '../types/auth';
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
requestOtp: async (phoneNumber: string): Promise<RequestOtpResponse> => {
|
requestOtp: async (phoneNumber: string): Promise<RequestOtpResponse> => {
|
||||||
|
|
@ -13,7 +13,7 @@ export const authApi = {
|
||||||
phone_number: normalizedPhone,
|
phone_number: normalizedPhone,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await axios.post<RequestOtpResponse>(buildAuthUrl('/accounts/users/api/request_otp/'), payload);
|
const response = await axios.post<RequestOtpResponse>(buildAuthUrl('/users/api/request_otp/'), payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ export const authApi = {
|
||||||
params.append('scope', AUTH_SCOPE);
|
params.append('scope', AUTH_SCOPE);
|
||||||
params.append('auth_fields', 'phone_number:otp');
|
params.append('auth_fields', 'phone_number:otp');
|
||||||
|
|
||||||
const response = await axios.post<TokenResponse>(buildAuthUrl('/accounts/oauth2/token/'), params, {
|
const response = await axios.post<TokenResponse>(buildAuthUrl('/oauth2/token/'), params, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Authorization: createBasicAuthHeader(),
|
Authorization: createBasicAuthHeader(),
|
||||||
|
|
@ -36,7 +36,6 @@ export const authApi = {
|
||||||
storeAuthTokens({
|
storeAuthTokens({
|
||||||
access_token: response.data.access_token,
|
access_token: response.data.access_token,
|
||||||
refresh_token: response.data.refresh_token,
|
refresh_token: response.data.refresh_token,
|
||||||
expires_in: response.data.expires_in,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|
@ -48,7 +47,7 @@ export const authApi = {
|
||||||
params.append('refresh_token', refreshToken);
|
params.append('refresh_token', refreshToken);
|
||||||
params.append('scope', AUTH_SCOPE);
|
params.append('scope', AUTH_SCOPE);
|
||||||
|
|
||||||
const response = await axios.post<TokenResponse>(buildAuthUrl('/accounts/oauth2/token/'), params, {
|
const response = await axios.post<TokenResponse>(buildAuthUrl('/oauth2/token/'), params, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Authorization: createBasicAuthHeader(),
|
Authorization: createBasicAuthHeader(),
|
||||||
|
|
@ -58,36 +57,12 @@ export const authApi = {
|
||||||
storeAuthTokens({
|
storeAuthTokens({
|
||||||
access_token: response.data.access_token,
|
access_token: response.data.access_token,
|
||||||
refresh_token: response.data.refresh_token,
|
refresh_token: response.data.refresh_token,
|
||||||
expires_in: response.data.expires_in,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
revokeToken: async (token: string): Promise<void> => {
|
logout: () => {
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('token', token);
|
|
||||||
params.append('token_type_hint', 'access_token');
|
|
||||||
|
|
||||||
await axios.post(buildAuthUrl('/accounts/oauth2/revoke_token/'), params, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
Authorization: createBasicAuthHeader(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
logout: async (): Promise<void> => {
|
|
||||||
const accessToken = getAccessToken();
|
|
||||||
|
|
||||||
if (accessToken) {
|
|
||||||
try {
|
|
||||||
await authApi.revokeToken(accessToken);
|
|
||||||
} catch {
|
|
||||||
// Token may already be expired/invalid server-side; still clear the local session.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAuthTokens();
|
clearAuthTokens();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import axios from 'axios';
|
|
||||||
import { env } from '@/config/env';
|
|
||||||
import { getAccessToken } from '@/utils/authToken';
|
|
||||||
import { buildAuthUrl } from '@/utils/authUrl';
|
|
||||||
import type { AdminUsersResponse, UserDetails } from '@/types/user';
|
|
||||||
|
|
||||||
interface GetAdminUsersParams {
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usersApi = {
|
|
||||||
getUserDetails: async (uuid: string): Promise<UserDetails> => {
|
|
||||||
const response = await axios.get<UserDetails>(
|
|
||||||
buildAuthUrl(`/accounts/users/api/users/${uuid}/details`, env.apiBaseUrl, { trailingSlash: false }),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${getAccessToken()}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
getAdminUsers: async ({ limit, offset }: GetAdminUsersParams): Promise<AdminUsersResponse> => {
|
|
||||||
const response = await axios.get<AdminUsersResponse>(
|
|
||||||
buildAuthUrl('/accounts/users/api/admin/users/', env.apiBaseUrl),
|
|
||||||
{
|
|
||||||
params: { limit, offset },
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${getAccessToken()}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
export interface AdLocation {
|
|
||||||
type: string;
|
|
||||||
coordinates: [number, number];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdMediaAsset {
|
|
||||||
uuid: string;
|
|
||||||
object_key: string;
|
|
||||||
content_type: number;
|
|
||||||
filename: string;
|
|
||||||
state: number;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Ad {
|
|
||||||
uuid: string;
|
|
||||||
campaign: string | null;
|
|
||||||
title: string | null;
|
|
||||||
type: number;
|
|
||||||
details: Record<string, unknown>;
|
|
||||||
content: string;
|
|
||||||
contents: unknown[];
|
|
||||||
refined_contents: AdMediaAsset[];
|
|
||||||
description: string | null;
|
|
||||||
location: AdLocation | null;
|
|
||||||
location_is_approximate: boolean;
|
|
||||||
banner: string | null;
|
|
||||||
refined_banner: AdMediaAsset | null;
|
|
||||||
reward_amount: number;
|
|
||||||
reward_count: number;
|
|
||||||
address: string | null;
|
|
||||||
url: string | null;
|
|
||||||
tags: string[];
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
display_time: number;
|
|
||||||
state: number;
|
|
||||||
status: string;
|
|
||||||
is_active: boolean;
|
|
||||||
user: string;
|
|
||||||
user_uuid: string;
|
|
||||||
comments_count: number;
|
|
||||||
likes_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminAdsResponse {
|
|
||||||
count: number;
|
|
||||||
next: string | null;
|
|
||||||
previous: string | null;
|
|
||||||
results: Ad[];
|
|
||||||
}
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export * from '@/features/auth/authTypes';
|
export * from '../features/auth/authTypes';
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
export interface UserDetails {
|
|
||||||
uuid: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
phone_number: string;
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
gender: string;
|
|
||||||
education_degree: string;
|
|
||||||
education_field: string;
|
|
||||||
birthdate: string;
|
|
||||||
iban: string;
|
|
||||||
iban_verified: boolean;
|
|
||||||
province: number;
|
|
||||||
province_name: string;
|
|
||||||
city: number;
|
|
||||||
city_name: string;
|
|
||||||
postal_code: string;
|
|
||||||
address: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUserListItem {
|
|
||||||
uuid: string;
|
|
||||||
username: string;
|
|
||||||
email: string | null;
|
|
||||||
phone_number: string | null;
|
|
||||||
first_name: string | null;
|
|
||||||
last_name: string | null;
|
|
||||||
name: string | null;
|
|
||||||
gender: string | null;
|
|
||||||
birthdate: string | null;
|
|
||||||
date_joined: string;
|
|
||||||
last_update: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUsersResponse {
|
|
||||||
count: number;
|
|
||||||
next: string | null;
|
|
||||||
previous: string | null;
|
|
||||||
results: AdminUserListItem[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { AxiosError } from 'axios';
|
import type { AxiosError } from 'axios';
|
||||||
import api from '@/services/api';
|
import api from '../services/api';
|
||||||
|
|
||||||
export const attachAuthInterceptor = () => {
|
export const attachAuthInterceptor = () => {
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
const ACCESS_TOKEN_KEY = 'access_token';
|
const ACCESS_TOKEN_KEY = 'access_token';
|
||||||
const REFRESH_TOKEN_KEY = 'refresh_token';
|
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||||
const EXPIRES_AT_KEY = 'access_token_expires_at';
|
|
||||||
|
|
||||||
export const getAccessToken = (): string | null => {
|
export const getAccessToken = (): string | null => {
|
||||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
|
|
@ -10,22 +9,12 @@ export const getRefreshToken = (): string | null => {
|
||||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const storeAuthTokens = (tokens: { access_token: string; refresh_token: string; expires_in?: number }) => {
|
export const storeAuthTokens = (tokens: { access_token: string; refresh_token: string }) => {
|
||||||
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token);
|
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token);
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
|
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
|
||||||
|
|
||||||
if (tokens.expires_in) {
|
|
||||||
localStorage.setItem(EXPIRES_AT_KEY, String(Date.now() + tokens.expires_in * 1000));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isAccessTokenExpired = (): boolean => {
|
|
||||||
const expiresAt = localStorage.getItem(EXPIRES_AT_KEY);
|
|
||||||
return expiresAt !== null && Date.now() >= Number(expiresAt);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearAuthTokens = () => {
|
export const clearAuthTokens = () => {
|
||||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
localStorage.removeItem(EXPIRES_AT_KEY);
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,12 @@
|
||||||
import { env } from '@/config/env';
|
import { env } from '../config/env';
|
||||||
|
|
||||||
const normalizeBaseUrl = (value: string) => value.trim().replace(/\/+$/, '');
|
const normalizeBaseUrl = (value: string) => value.trim().replace(/\/+$/, '');
|
||||||
const ensureLeadingSlash = (value: string) => (value.startsWith('/') ? value : `/${value}`);
|
const ensureLeadingSlash = (value: string) => (value.startsWith('/') ? value : `/${value}`);
|
||||||
const ensureTrailingSlash = (value: string) => (value.endsWith('/') ? value : `${value}/`);
|
const ensureTrailingSlash = (value: string) => (value.endsWith('/') ? value : `${value}/`);
|
||||||
|
|
||||||
interface BuildAuthUrlOptions {
|
export const buildAuthUrl = (path: string, baseUrl = env.authBaseUrl) => {
|
||||||
trailingSlash?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const buildAuthUrl = (
|
|
||||||
path: string,
|
|
||||||
baseUrl = env.authBaseUrl,
|
|
||||||
{ trailingSlash = true }: BuildAuthUrlOptions = {},
|
|
||||||
) => {
|
|
||||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||||
const withLeadingSlash = ensureLeadingSlash(path);
|
const normalizedPath = ensureTrailingSlash(ensureLeadingSlash(path));
|
||||||
const normalizedPath = trailingSlash ? ensureTrailingSlash(withLeadingSlash) : withLeadingSlash.replace(/\/+$/, '');
|
|
||||||
|
|
||||||
if (!normalizedBaseUrl) {
|
if (!normalizedBaseUrl) {
|
||||||
return normalizedPath;
|
return normalizedPath;
|
||||||
|
|
@ -24,11 +15,10 @@ export const buildAuthUrl = (
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(normalizedBaseUrl);
|
const parsed = new URL(normalizedBaseUrl);
|
||||||
const basePath = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, '');
|
const basePath = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, '');
|
||||||
const joinedPath = `${basePath}${normalizedPath}`;
|
const joinedPath = ensureTrailingSlash(`${basePath}${normalizedPath}`);
|
||||||
|
|
||||||
return `${parsed.origin}${trailingSlash ? ensureTrailingSlash(joinedPath) : joinedPath}`;
|
return `${parsed.origin}${joinedPath}`;
|
||||||
} catch {
|
} catch {
|
||||||
const joinedPath = `${normalizedBaseUrl}${normalizedPath}`;
|
return ensureTrailingSlash(`${normalizedBaseUrl}${normalizedPath}`);
|
||||||
return trailingSlash ? ensureTrailingSlash(joinedPath) : joinedPath;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export const formatToman = (value: number): string => `${value.toLocaleString('en-US')} تومان`;
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
export const formatDateTime = (value: string): string => {
|
|
||||||
const date = new Date(value);
|
|
||||||
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return date
|
|
||||||
.toLocaleString('en-GB', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
.replace(',', '');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatDate = (value: string): string => {
|
|
||||||
const date = new Date(value);
|
|
||||||
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toLocaleDateString('en-GB', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import type { AppDispatch, RootState } from '@/app/store';
|
import type { AppDispatch, RootState } from '../app/store';
|
||||||
|
|
||||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
export const useAppSelector = useSelector.withTypes<RootState>();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { clearAuthTokens } from '@/utils/authToken';
|
import { clearAuthTokens } from './authToken';
|
||||||
|
|
||||||
export const logout = () => {
|
export const logout = () => {
|
||||||
clearAuthTokens();
|
clearAuthTokens();
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,4 @@ export const STORAGE_KEYS = {
|
||||||
accessToken: 'winsoo_access_token',
|
accessToken: 'winsoo_access_token',
|
||||||
user: 'winsoo_user',
|
user: 'winsoo_user',
|
||||||
authState: 'winsoo_auth_state',
|
authState: 'winsoo_auth_state',
|
||||||
lastSearchedUserUuid: 'winsoo_last_searched_user_uuid',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,6 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Path aliases */
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||||
import babel from '@rolldown/plugin-babel'
|
import babel from '@rolldown/plugin-babel'
|
||||||
|
|
@ -9,9 +8,4 @@ export default defineConfig({
|
||||||
react(),
|
react(),
|
||||||
babel({ presets: [reactCompilerPreset()] })
|
babel({ presets: [reactCompilerPreset()] })
|
||||||
],
|
],
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue