hPiBot openclaw ve Opencode ilk versiyonu[B
This commit is contained in:
46
frontend/README.md
Normal file
46
frontend/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
17184
frontend/package-lock.json
generated
Normal file
17184
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
frontend/package.json
Normal file
59
frontend/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@mui/icons-material": "^7.3.4",
|
||||
"@mui/material": "^7.3.4",
|
||||
"@mui/x-date-pickers": "^8.14.1",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"axios": "^1.12.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-scripts": "5.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
43
frontend/public/index.html
Normal file
43
frontend/public/index.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="hMarket - Akıllı Alışveriş Listesi ve Fiyat Takibi"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>hMarket - Akıllı Alışveriş Listesi</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
frontend/public/logo192.png
Normal file
BIN
frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
BIN
frontend/public/logo512.png
Normal file
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "hMarket",
|
||||
"name": "hMarket - Akıllı Alışveriş Listesi",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
202
frontend/src/App.tsx
Normal file
202
frontend/src/App.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import { CssBaseline, Box } from '@mui/material';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
// Context providers
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { SocketProvider } from './contexts/SocketContext';
|
||||
|
||||
// Components
|
||||
import Navbar from './components/Layout/Navbar';
|
||||
import ProtectedRoute from './components/Auth/ProtectedRoute';
|
||||
import GoogleCallback from './components/Auth/GoogleCallback';
|
||||
|
||||
// Pages
|
||||
import { LoginPage, RegisterPage } from './pages/Auth';
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { ListsPage, ListDetailPage, ListEditPage } from './pages/Lists';
|
||||
import { ProductsPage } from './pages/Products';
|
||||
import { ProfilePage } from './pages/Profile';
|
||||
import { AdminPage } from './pages/Admin';
|
||||
|
||||
// Create theme
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#FF5722',
|
||||
light: '#FF8A65',
|
||||
dark: '#D84315',
|
||||
},
|
||||
secondary: {
|
||||
main: '#4CAF50',
|
||||
light: '#81C784',
|
||||
dark: '#388E3C',
|
||||
},
|
||||
background: {
|
||||
default: '#F5F5F5',
|
||||
paper: '#FFFFFF',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h4: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
h5: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
borderRadius: 8,
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
borderRadius: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create query client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<Router>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<Navbar />
|
||||
<Box component="main" sx={{ flexGrow: 1, pt: 2 }}>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/auth/google/callback" element={<GoogleCallback />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Navigate to="/dashboard" replace />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/lists" element={
|
||||
<ProtectedRoute>
|
||||
<ListsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/lists/:listId" element={
|
||||
<ProtectedRoute>
|
||||
<ListDetailPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/lists/:listId/edit" element={
|
||||
<ProtectedRoute>
|
||||
<ListEditPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/products" element={
|
||||
<ProtectedRoute>
|
||||
<ProductsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/admin" element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Catch all route */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Toast notifications */}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
success: {
|
||||
duration: 3000,
|
||||
iconTheme: {
|
||||
primary: '#4CAF50',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
duration: 5000,
|
||||
iconTheme: {
|
||||
primary: '#f44336',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Router>
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
61
frontend/src/components/Auth/GoogleCallback.tsx
Normal file
61
frontend/src/components/Auth/GoogleCallback.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const GoogleCallback: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { handleGoogleCallback } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
const token = searchParams.get('token');
|
||||
const error = searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
toast.error('Google ile giriş yapılamadı: ' + error);
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
await handleGoogleCallback(token);
|
||||
toast.success('Google ile başarıyla giriş yapıldı!');
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Google callback error:', error);
|
||||
toast.error('Giriş işlemi sırasında bir hata oluştu');
|
||||
navigate('/login');
|
||||
}
|
||||
} else {
|
||||
toast.error('Geçersiz callback');
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [searchParams, navigate, handleGoogleCallback]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Google ile giriş yapılıyor...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleCallback;
|
||||
39
frontend/src/components/Auth/ProtectedRoute.tsx
Normal file
39
frontend/src/components/Auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requireAdmin?: boolean;
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, requireAdmin = false }) => {
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="100vh"
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (requireAdmin && user?.role !== 'ADMIN') {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
282
frontend/src/components/Layout/Navbar.tsx
Normal file
282
frontend/src/components/Layout/Navbar.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Notifications as NotificationsIcon,
|
||||
AccountCircle,
|
||||
Dashboard,
|
||||
List as ListIcon,
|
||||
Inventory,
|
||||
Settings,
|
||||
AdminPanelSettings,
|
||||
Logout,
|
||||
ShoppingCart,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { notificationsAPI } from '../../services/api';
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { user, logout, isAuthenticated } = useAuth();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// Get unread notifications count
|
||||
const { data: unreadCount } = useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: () => notificationsAPI.getUnreadCount(),
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
});
|
||||
|
||||
const handleProfileMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
handleMenuClose();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'Anasayfa', icon: <Dashboard />, path: '/dashboard' },
|
||||
{ text: 'Listeler', icon: <ListIcon />, path: '/lists' },
|
||||
{ text: 'Ürünler', icon: <Inventory />, path: '/products' },
|
||||
];
|
||||
|
||||
if (user?.role === 'ADMIN') {
|
||||
menuItems.push({ text: 'Yönetim', icon: <AdminPanelSettings />, path: '/admin' });
|
||||
}
|
||||
|
||||
const drawer = (
|
||||
<Box sx={{ width: 250 }}>
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center' }}>
|
||||
<ShoppingCart sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="h6" color="primary.main" fontWeight="bold">
|
||||
hMarket
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
selected={location.pathname === item.path}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate('/profile');
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
selected={location.pathname === '/profile'}
|
||||
>
|
||||
<ListItemIcon><Settings /></ListItemIcon>
|
||||
<ListItemText primary="Profil" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={handleLogout}>
|
||||
<ListItemIcon><Logout /></ListItemIcon>
|
||||
<ListItemText primary="Çıkış" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<AppBar position="sticky" color="primary">
|
||||
<Toolbar>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexGrow: 1 }}>
|
||||
<ShoppingCart sx={{ mr: 1 }} />
|
||||
<Typography variant="h6" component="div" fontWeight="bold">
|
||||
hMarket
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={() => navigate('/login')}
|
||||
variant={location.pathname === '/login' ? 'outlined' : 'text'}
|
||||
>
|
||||
Giriş
|
||||
</Button>
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={() => navigate('/register')}
|
||||
variant={location.pathname === '/register' ? 'outlined' : 'text'}
|
||||
>
|
||||
Kayıt
|
||||
</Button>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar position="sticky" color="primary">
|
||||
<Toolbar>
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexGrow: 1 }}>
|
||||
<ShoppingCart sx={{ mr: 1 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="div"
|
||||
fontWeight="bold"
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
hMarket
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{!isMobile && (
|
||||
<Box sx={{ display: 'flex', gap: 1, mr: 2 }}>
|
||||
{menuItems.map((item) => (
|
||||
<Button
|
||||
key={item.text}
|
||||
color="inherit"
|
||||
onClick={() => navigate(item.path)}
|
||||
variant={location.pathname === item.path ? 'outlined' : 'text'}
|
||||
startIcon={item.icon}
|
||||
>
|
||||
{item.text}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton color="inherit" onClick={() => navigate('/notifications')}>
|
||||
<Badge badgeContent={unreadCount?.data?.data?.count || 0} color="error">
|
||||
<NotificationsIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="end"
|
||||
aria-label="account of current user"
|
||||
aria-controls="primary-search-account-menu"
|
||||
aria-haspopup="true"
|
||||
onClick={handleProfileMenuOpen}
|
||||
color="inherit"
|
||||
>
|
||||
{user?.avatar ? (
|
||||
<Avatar src={user.avatar} sx={{ width: 32, height: 32 }} />
|
||||
) : (
|
||||
<AccountCircle />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={handleDrawerToggle}
|
||||
ModalProps={{
|
||||
keepMounted: true, // Better open performance on mobile.
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', md: 'none' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: 250 },
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
|
||||
{/* Profile menu */}
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
id="primary-search-account-menu"
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem onClick={() => { navigate('/profile'); handleMenuClose(); }}>
|
||||
<Settings sx={{ mr: 1 }} />
|
||||
Profil
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<Logout sx={{ mr: 1 }} />
|
||||
Çıkış
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
317
frontend/src/components/ShoppingList/ShoppingListFilters.tsx
Normal file
317
frontend/src/components/ShoppingList/ShoppingListFilters.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Chip,
|
||||
Collapse,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Paper,
|
||||
useTheme,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search,
|
||||
FilterList,
|
||||
Clear,
|
||||
Sort,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface ShoppingListFiltersProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
filterCompleted?: boolean;
|
||||
onFilterCompletedChange: (value: boolean | undefined) => void;
|
||||
filterCategory: string;
|
||||
onFilterCategoryChange: (value: string) => void;
|
||||
sortBy: string;
|
||||
onSortByChange: (value: string) => void;
|
||||
sortOrder: string;
|
||||
onSortOrderChange: (value: string) => void;
|
||||
categories: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
const ShoppingListFilters: React.FC<ShoppingListFiltersProps> = ({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
filterCompleted,
|
||||
onFilterCompletedChange,
|
||||
filterCategory,
|
||||
onFilterCategoryChange,
|
||||
sortBy,
|
||||
onSortByChange,
|
||||
sortOrder,
|
||||
onSortOrderChange,
|
||||
categories,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
|
||||
const hasActiveFilters =
|
||||
searchTerm ||
|
||||
filterCompleted !== undefined ||
|
||||
filterCategory !== 'all' ||
|
||||
sortBy !== 'createdAt' ||
|
||||
sortOrder !== 'desc';
|
||||
|
||||
const clearAllFilters = () => {
|
||||
onSearchChange('');
|
||||
onFilterCompletedChange(undefined);
|
||||
onFilterCategoryChange('all');
|
||||
onSortByChange('createdAt');
|
||||
onSortOrderChange('desc');
|
||||
setSearchExpanded(false);
|
||||
setFiltersExpanded(false);
|
||||
};
|
||||
|
||||
const getFilterStatusLabel = (filterCompleted: boolean | undefined) => {
|
||||
if (filterCompleted === undefined) return 'Tümü';
|
||||
return filterCompleted ? 'Tamamlanan' : 'Bekleyen';
|
||||
};
|
||||
|
||||
const getCategoryLabel = (categoryId: string) => {
|
||||
if (categoryId === 'all') return 'Tüm Kategoriler';
|
||||
const category = categories.find(c => c.id === categoryId);
|
||||
return category?.name || 'Bilinmeyen';
|
||||
};
|
||||
|
||||
const getSortLabel = (sortBy: string) => {
|
||||
switch (sortBy) {
|
||||
case 'createdAt': return 'Eklenme Tarihi';
|
||||
case 'product.name': return 'Ürün Adı';
|
||||
case 'quantity': return 'Miktar';
|
||||
case 'priority': return 'Öncelik';
|
||||
default: return 'Eklenme Tarihi';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{/* Search and Filter Toggle */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
{/* Search Toggle */}
|
||||
<IconButton
|
||||
onClick={() => setSearchExpanded(!searchExpanded)}
|
||||
sx={{
|
||||
backgroundColor: searchTerm
|
||||
? alpha(theme.palette.primary.main, 0.1)
|
||||
: alpha(theme.palette.grey[500], 0.1),
|
||||
color: searchTerm ? 'primary.main' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: searchTerm
|
||||
? alpha(theme.palette.primary.main, 0.2)
|
||||
: alpha(theme.palette.grey[500], 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Search />
|
||||
</IconButton>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<IconButton
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
sx={{
|
||||
backgroundColor: hasActiveFilters
|
||||
? alpha(theme.palette.primary.main, 0.1)
|
||||
: alpha(theme.palette.grey[500], 0.1),
|
||||
color: hasActiveFilters ? 'primary.main' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: hasActiveFilters
|
||||
? alpha(theme.palette.primary.main, 0.2)
|
||||
: alpha(theme.palette.grey[500], 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<FilterList />
|
||||
</IconButton>
|
||||
|
||||
{/* Active Filter Chips */}
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
{searchTerm && (
|
||||
<Chip
|
||||
label={`"${searchTerm}"`}
|
||||
size="small"
|
||||
onDelete={() => onSearchChange('')}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
|
||||
{filterCompleted !== undefined && (
|
||||
<Chip
|
||||
label={getFilterStatusLabel(filterCompleted)}
|
||||
size="small"
|
||||
onDelete={() => onFilterCompletedChange(undefined)}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
|
||||
{filterCategory !== 'all' && (
|
||||
<Chip
|
||||
label={getCategoryLabel(filterCategory)}
|
||||
size="small"
|
||||
onDelete={() => onFilterCategoryChange('all')}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(sortBy !== 'createdAt' || sortOrder !== 'desc') && (
|
||||
<Chip
|
||||
label={`${getSortLabel(sortBy)} (${sortOrder === 'asc' ? 'Artan' : 'Azalan'})`}
|
||||
size="small"
|
||||
onDelete={() => {
|
||||
onSortByChange('createdAt');
|
||||
onSortOrderChange('desc');
|
||||
}}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Chip
|
||||
label="Temizle"
|
||||
size="small"
|
||||
onClick={clearAllFilters}
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
icon={<Clear />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Collapsible Search Bar */}
|
||||
<Collapse in={searchExpanded}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Ürünlerde ara..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchTerm && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onSearchChange('')}
|
||||
>
|
||||
<Clear />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Collapse>
|
||||
|
||||
|
||||
|
||||
{/* Expanded Filters */}
|
||||
<Collapse in={filtersExpanded}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: alpha(theme.palette.grey[50], 0.5),
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr 1fr' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{/* Status Filter */}
|
||||
<FormControl size="small">
|
||||
<InputLabel>Durum</InputLabel>
|
||||
<Select
|
||||
value={filterCompleted === undefined ? 'all' : filterCompleted ? 'completed' : 'pending'}
|
||||
label="Durum"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onFilterCompletedChange(
|
||||
value === 'all' ? undefined : value === 'completed'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">Tüm Ürünler</MenuItem>
|
||||
<MenuItem value="pending">Bekleyen</MenuItem>
|
||||
<MenuItem value="completed">Tamamlanan</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Category Filter */}
|
||||
<FormControl size="small">
|
||||
<InputLabel>Kategori</InputLabel>
|
||||
<Select
|
||||
value={filterCategory}
|
||||
label="Kategori"
|
||||
onChange={(e) => onFilterCategoryChange(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">Tüm Kategoriler</MenuItem>
|
||||
{categories.map((category) => (
|
||||
<MenuItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Sort By */}
|
||||
<FormControl size="small">
|
||||
<InputLabel>Sırala</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="Sırala"
|
||||
onChange={(e) => onSortByChange(e.target.value)}
|
||||
>
|
||||
<MenuItem value="createdAt">Eklenme Tarihi</MenuItem>
|
||||
<MenuItem value="product.name">Ürün Adı</MenuItem>
|
||||
<MenuItem value="quantity">Miktar</MenuItem>
|
||||
<MenuItem value="priority">Öncelik</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Sort Order */}
|
||||
<FormControl size="small">
|
||||
<InputLabel>Sıralama</InputLabel>
|
||||
<Select
|
||||
value={sortOrder}
|
||||
label="Sıralama"
|
||||
onChange={(e) => onSortOrderChange(e.target.value)}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<Sort />
|
||||
</InputAdornment>
|
||||
}
|
||||
>
|
||||
<MenuItem value="asc">Artan</MenuItem>
|
||||
<MenuItem value="desc">Azalan</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShoppingListFilters;
|
||||
215
frontend/src/components/ShoppingList/ShoppingListHeader.tsx
Normal file
215
frontend/src/components/ShoppingList/ShoppingListHeader.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Avatar,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
useTheme,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Share,
|
||||
People,
|
||||
ShoppingCart,
|
||||
CheckCircle,
|
||||
Schedule,
|
||||
CalendarToday,
|
||||
} from '@mui/icons-material';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { tr } from 'date-fns/locale';
|
||||
|
||||
interface ShoppingListHeaderProps {
|
||||
list: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
isShared: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
stats: {
|
||||
totalItems: number;
|
||||
completedItems: number;
|
||||
remainingItems: number;
|
||||
completionRate: number;
|
||||
};
|
||||
onBack: () => void;
|
||||
onShare: () => void;
|
||||
}
|
||||
|
||||
const ShoppingListHeader: React.FC<ShoppingListHeaderProps> = ({
|
||||
list,
|
||||
stats,
|
||||
onBack,
|
||||
onShare,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{/* Navigation Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<IconButton
|
||||
onClick={onBack}
|
||||
sx={{
|
||||
mr: 1,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: list?.color || theme.palette.primary.main,
|
||||
width: 48,
|
||||
height: 48,
|
||||
mr: 2,
|
||||
}}
|
||||
>
|
||||
{list?.isShared ? <Share /> : <ShoppingCart />}
|
||||
</Avatar>
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: { xs: '1.25rem', sm: '1.5rem' },
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{list?.name}
|
||||
</Typography>
|
||||
|
||||
{/* Creation Date */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 0.5, mb: list?.description ? 0.5 : 0 }}>
|
||||
<CalendarToday sx={{ fontSize: 14, color: 'text.secondary', mr: 0.5 }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
{formatDistanceToNow(new Date(list?.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: tr
|
||||
})} oluşturuldu
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{list?.description && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{list?.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
onClick={onShare}
|
||||
sx={{
|
||||
backgroundColor: alpha(theme.palette.secondary.main, 0.1),
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.secondary.main, 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<People />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Progress Section */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.05)} 0%, ${alpha(theme.palette.secondary.main, 0.05)} 100%)`,
|
||||
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
}}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" fontWeight={500}>
|
||||
İlerleme
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary" fontWeight={600}>
|
||||
{Math.round(stats.completionRate)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={stats.completionRate}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
background: `linear-gradient(90deg, ${theme.palette.success.main} 0%, ${theme.palette.primary.main} 100%)`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1 }}>
|
||||
<ShoppingCart sx={{ fontSize: 20, color: 'primary.main', mr: 0.5 }} />
|
||||
<Typography variant="h6" color="primary" fontWeight={600}>
|
||||
{stats.totalItems}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||
Toplam
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1 }}>
|
||||
<CheckCircle sx={{ fontSize: 20, color: 'success.main', mr: 0.5 }} />
|
||||
<Typography variant="h6" color="success.main" fontWeight={600}>
|
||||
{stats.completedItems}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||
Tamamlanan
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1 }}>
|
||||
<Schedule sx={{ fontSize: 20, color: 'warning.main', mr: 0.5 }} />
|
||||
<Typography variant="h6" color="warning.main" fontWeight={600}>
|
||||
{stats.remainingItems}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||
Kalan
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShoppingListHeader;
|
||||
480
frontend/src/components/ShoppingList/ShoppingListItem.tsx
Normal file
480
frontend/src/components/ShoppingList/ShoppingListItem.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Chip,
|
||||
Avatar,
|
||||
Collapse,
|
||||
useTheme,
|
||||
alpha,
|
||||
Slide,
|
||||
Fade,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
MoreVert,
|
||||
ExpandMore,
|
||||
ExpandLess,
|
||||
Category as CategoryIcon,
|
||||
AccessTime,
|
||||
StickyNote2,
|
||||
LocalOffer,
|
||||
Delete,
|
||||
Edit,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Fastfood,
|
||||
LocalDrink,
|
||||
CleaningServices,
|
||||
HealthAndSafety,
|
||||
Pets,
|
||||
Home,
|
||||
SportsEsports,
|
||||
MenuBook,
|
||||
LocalGroceryStore,
|
||||
} from '@mui/icons-material';
|
||||
import { tr } from 'date-fns/locale';
|
||||
|
||||
// Kategori simgelerini döndüren fonksiyon
|
||||
const getCategoryIcon = (category?: { name: string; icon?: string; color?: string }, categoryName?: string) => {
|
||||
// Eğer category objesi varsa ve icon varsa, emoji ikonunu kullan
|
||||
if (category?.icon && category.icon.length <= 4) {
|
||||
return <span style={{ fontSize: '20px' }}>{category.icon}</span>;
|
||||
}
|
||||
|
||||
// Fallback olarak kategori adına göre Material-UI ikonları
|
||||
const name = category?.name || categoryName;
|
||||
if (!name) return <CategoryIcon />;
|
||||
|
||||
const categoryLower = name.toLowerCase();
|
||||
|
||||
if (categoryLower.includes('gıda') || categoryLower.includes('yiyecek') || categoryLower.includes('food') || categoryLower.includes('meyve') || categoryLower.includes('sebze')) {
|
||||
return <Fastfood />;
|
||||
}
|
||||
if (categoryLower.includes('içecek') || categoryLower.includes('drink') || categoryLower.includes('beverage')) {
|
||||
return <LocalDrink />;
|
||||
}
|
||||
if (categoryLower.includes('temizlik') || categoryLower.includes('cleaning')) {
|
||||
return <CleaningServices />;
|
||||
}
|
||||
if (categoryLower.includes('sağlık') || categoryLower.includes('health') || categoryLower.includes('ilaç')) {
|
||||
return <HealthAndSafety />;
|
||||
}
|
||||
if (categoryLower.includes('pet') || categoryLower.includes('hayvan') || categoryLower.includes('evcil') || categoryLower.includes('bebek')) {
|
||||
return <Pets />;
|
||||
}
|
||||
if (categoryLower.includes('ev') || categoryLower.includes('home') || categoryLower.includes('house')) {
|
||||
return <Home />;
|
||||
}
|
||||
if (categoryLower.includes('oyun') || categoryLower.includes('game') || categoryLower.includes('sport')) {
|
||||
return <SportsEsports />;
|
||||
}
|
||||
if (categoryLower.includes('kitap') || categoryLower.includes('book') || categoryLower.includes('dergi')) {
|
||||
return <MenuBook />;
|
||||
}
|
||||
if (categoryLower.includes('market') || categoryLower.includes('grocery') || categoryLower.includes('alışveriş')) {
|
||||
return <LocalGroceryStore />;
|
||||
}
|
||||
|
||||
return <CategoryIcon />;
|
||||
};
|
||||
|
||||
interface ShoppingListItemProps {
|
||||
item: {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
isPurchased: boolean;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
|
||||
category?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
imageUrl?: string;
|
||||
product?: {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
category?: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
onTogglePurchased: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onMenuClick: (event: React.MouseEvent<HTMLElement>, itemId: string) => void;
|
||||
}
|
||||
|
||||
const ShoppingListItem: React.FC<ShoppingListItemProps> = ({
|
||||
item,
|
||||
onTogglePurchased,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMenuClick,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const [isSwipeActive, setIsSwipeActive] = useState(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [checkboxChecked, setCheckboxChecked] = useState(item.isPurchased);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const startX = useRef(0);
|
||||
const currentX = useRef(0);
|
||||
const theme = useTheme();
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority.toUpperCase()) {
|
||||
case 'HIGH':
|
||||
return theme.palette.error.main;
|
||||
case 'MEDIUM':
|
||||
return theme.palette.warning.main;
|
||||
case 'LOW':
|
||||
return theme.palette.success.main;
|
||||
default:
|
||||
return theme.palette.grey[500];
|
||||
}
|
||||
};
|
||||
|
||||
// Touch event handlers for swipe gesture
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
// Disable swipe for purchased items
|
||||
if (item.isPurchased) return;
|
||||
|
||||
startX.current = e.touches[0].clientX;
|
||||
setIsSwipeActive(true);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
// Disable swipe for purchased items
|
||||
if (!isSwipeActive || item.isPurchased) return;
|
||||
|
||||
currentX.current = e.touches[0].clientX;
|
||||
const diffX = startX.current - currentX.current;
|
||||
|
||||
// Only allow left swipe (positive diffX)
|
||||
if (diffX > 0 && diffX <= 120) {
|
||||
setSwipeOffset(diffX);
|
||||
if (diffX > 60) {
|
||||
setShowActions(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
// Disable swipe for purchased items
|
||||
if (item.isPurchased) {
|
||||
setIsSwipeActive(false);
|
||||
setSwipeOffset(0);
|
||||
setShowActions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSwipeActive(false);
|
||||
|
||||
if (swipeOffset > 60) {
|
||||
setSwipeOffset(120); // Snap to show actions
|
||||
setShowActions(true);
|
||||
} else {
|
||||
setSwipeOffset(0); // Snap back
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset swipe when clicking outside
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if (showActions) {
|
||||
setSwipeOffset(0);
|
||||
setShowActions(false);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Checkbox animation handler
|
||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
setCheckboxChecked(e.target.checked);
|
||||
setTimeout(() => {
|
||||
onTogglePurchased(item.id);
|
||||
}, 150); // Small delay for animation
|
||||
};
|
||||
|
||||
// Action handlers
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onEdit(item.id);
|
||||
setSwipeOffset(0);
|
||||
setShowActions(false);
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priority: string) => {
|
||||
switch (priority.toUpperCase()) {
|
||||
case 'HIGH':
|
||||
return 'Yüksek';
|
||||
case 'MEDIUM':
|
||||
return 'Orta';
|
||||
case 'LOW':
|
||||
return 'Düşük';
|
||||
default:
|
||||
return 'Normal';
|
||||
}
|
||||
};
|
||||
|
||||
const hasDetails = item.notes || item.category;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mb: 1,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{/* Action Buttons Background - Only show for non-purchased items */}
|
||||
{!item.isPurchased && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 120,
|
||||
display: 'flex',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleEdit}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: '100%',
|
||||
borderRadius: 0,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.primary.dark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleDelete}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: '100%',
|
||||
borderRadius: 0,
|
||||
backgroundColor: theme.palette.error.main,
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.error.dark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Main Card */}
|
||||
<Card
|
||||
ref={cardRef}
|
||||
onClick={handleCardClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
transform: `translateX(-${swipeOffset}px)`,
|
||||
transition: isSwipeActive ? 'none' : 'transform 0.3s ease-out',
|
||||
opacity: checkboxChecked ? 0.7 : 1,
|
||||
backgroundColor: checkboxChecked
|
||||
? alpha(theme.palette.success.main, 0.1)
|
||||
: theme.palette.background.paper,
|
||||
border: checkboxChecked
|
||||
? `1px solid ${alpha(theme.palette.success.main, 0.3)}`
|
||||
: `1px solid ${theme.palette.divider}`,
|
||||
'&:hover': {
|
||||
boxShadow: theme.shadows[2],
|
||||
},
|
||||
cursor: showActions ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{/* Animated Checkbox */}
|
||||
<Fade in={true} timeout={300}>
|
||||
<Checkbox
|
||||
checked={checkboxChecked}
|
||||
onChange={handleCheckboxChange}
|
||||
icon={<Circle />}
|
||||
checkedIcon={<CheckCircle />}
|
||||
sx={{
|
||||
color: theme.palette.primary.main,
|
||||
'&.Mui-checked': {
|
||||
color: theme.palette.success.main,
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: 32,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
|
||||
},
|
||||
'&.Mui-checked .MuiSvgIcon-root': {
|
||||
transform: 'scale(1.2)',
|
||||
filter: 'drop-shadow(0 3px 6px rgba(0,0,0,0.2))',
|
||||
},
|
||||
'&:hover .MuiSvgIcon-root': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Main Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
{/* Category Icon */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
color: item.product?.category?.color || theme.palette.primary.main,
|
||||
flexShrink: 0,
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: 20,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{getCategoryIcon(item.product?.category, item.category)}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 500,
|
||||
textDecoration: item.isPurchased ? 'line-through' : 'none',
|
||||
color: item.isPurchased
|
||||
? theme.palette.text.secondary
|
||||
: theme.palette.text.primary,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Quantity and Basic Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Chip
|
||||
label={`${item.quantity} ${item.unit || 'adet'}`}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
height: 28,
|
||||
fontWeight: 600,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: 'white',
|
||||
'& .MuiChip-label': {
|
||||
px: 1.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Expand Button */}
|
||||
{hasDetails && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
{expanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Priority Indicator - moved to right */}
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{getPriorityLabel(item.priority)}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: getPriorityColor(item.priority),
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
{/* Expanded Details */}
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: `1px solid ${theme.palette.divider}` }}>
|
||||
|
||||
|
||||
{item.category && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<LocalOffer sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
<Chip
|
||||
label={item.category}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.75rem', height: 24 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{item.notes && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<StickyNote2 sx={{ fontSize: 16, color: 'text.secondary', mt: 0.2 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{item.notes}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* Menu Button */}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => onMenuClick(e, item.id)}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShoppingListItem;
|
||||
3
frontend/src/components/ShoppingList/index.ts
Normal file
3
frontend/src/components/ShoppingList/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ShoppingListItem } from './ShoppingListItem';
|
||||
export { default as ShoppingListHeader } from './ShoppingListHeader';
|
||||
export { default as ShoppingListFilters } from './ShoppingListFilters';
|
||||
205
frontend/src/contexts/AuthContext.tsx
Normal file
205
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { createContext, useContext, useReducer, useEffect, ReactNode } from 'react';
|
||||
import { authAPI } from '../services/api';
|
||||
import { User } from '../types';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (login: string, password: string) => Promise<void>;
|
||||
register: (userData: RegisterData) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateProfile: (userData: Partial<User>) => Promise<void>;
|
||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
||||
handleGoogleCallback: (token: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface RegisterData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
type AuthAction =
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_USER'; payload: { user: User; token: string } }
|
||||
| { type: 'CLEAR_USER' }
|
||||
| { type: 'UPDATE_USER'; payload: Partial<User> };
|
||||
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: localStorage.getItem('token'),
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
};
|
||||
|
||||
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
|
||||
switch (action.type) {
|
||||
case 'SET_LOADING':
|
||||
return { ...state, isLoading: action.payload };
|
||||
case 'SET_USER':
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
};
|
||||
case 'CLEAR_USER':
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
};
|
||||
case 'UPDATE_USER':
|
||||
return {
|
||||
...state,
|
||||
user: state.user ? { ...state.user, ...action.payload } : null,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
// Backend'ten gelen kullanıcı nesnesini frontend User tipine dönüştür
|
||||
const normalizeUser = (apiUser: any): User => {
|
||||
return {
|
||||
id: apiUser.id,
|
||||
firstName: apiUser.firstName,
|
||||
lastName: apiUser.lastName,
|
||||
email: apiUser.email,
|
||||
role: apiUser.isAdmin ? 'ADMIN' : 'USER',
|
||||
isActive: apiUser.isActive ?? true,
|
||||
avatar: apiUser.avatar ?? undefined,
|
||||
createdAt: apiUser.createdAt,
|
||||
updatedAt: apiUser.updatedAt ?? apiUser.createdAt,
|
||||
settings: apiUser.settings ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// Check if user is authenticated on app load
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
try {
|
||||
const response = await authAPI.getProfile();
|
||||
dispatch({
|
||||
type: 'SET_USER',
|
||||
payload: { user: normalizeUser(response.data.data.user), token },
|
||||
});
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token');
|
||||
dispatch({ type: 'CLEAR_USER' });
|
||||
}
|
||||
} else {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (login: string, password: string) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
try {
|
||||
const response = await authAPI.login({ login, password });
|
||||
const { user, token } = response.data.data;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
dispatch({ type: 'SET_USER', payload: { user: normalizeUser(user), token } });
|
||||
} catch (error) {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (userData: RegisterData) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
try {
|
||||
const response = await authAPI.register(userData);
|
||||
const { user, token } = response.data.data;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
dispatch({ type: 'SET_USER', payload: { user: normalizeUser(user), token } });
|
||||
} catch (error) {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
dispatch({ type: 'CLEAR_USER' });
|
||||
};
|
||||
|
||||
const updateProfile = async (userData: Partial<User>) => {
|
||||
try {
|
||||
const response = await authAPI.updateProfile(userData);
|
||||
dispatch({ type: 'UPDATE_USER', payload: normalizeUser(response.data.data.user) });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (currentPassword: string, newPassword: string) => {
|
||||
try {
|
||||
await authAPI.changePassword({ currentPassword, newPassword });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleCallback = async (token: string) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
try {
|
||||
localStorage.setItem('token', token);
|
||||
const response = await authAPI.getProfile();
|
||||
dispatch({
|
||||
type: 'SET_USER',
|
||||
payload: { user: normalizeUser(response.data.data.user), token },
|
||||
});
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token');
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
...state,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
handleGoogleCallback,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
149
frontend/src/contexts/SocketContext.tsx
Normal file
149
frontend/src/contexts/SocketContext.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { createContext, useContext, useEffect, useRef, ReactNode } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { useAuth } from './AuthContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface SocketContextType {
|
||||
socket: Socket | null;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
const SocketContext = createContext<SocketContextType | undefined>(undefined);
|
||||
|
||||
export const useSocket = () => {
|
||||
const context = useContext(SocketContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSocket must be used within a SocketProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface SocketProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SocketProvider: React.FC<SocketProviderProps> = ({ children }) => {
|
||||
const { user, token, isAuthenticated } = useAuth();
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && token && user) {
|
||||
// Initialize socket connection
|
||||
const socketUrl = process.env.NODE_ENV === 'production'
|
||||
? `http://${window.location.hostname}:7001`
|
||||
: 'http://localhost:7001';
|
||||
|
||||
socketRef.current = io(socketUrl, {
|
||||
auth: {
|
||||
token,
|
||||
},
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
const socket = socketRef.current;
|
||||
|
||||
// Connection event handlers
|
||||
socket.on('connect', () => {
|
||||
console.log('Socket connected:', socket.id);
|
||||
setIsConnected(true);
|
||||
|
||||
// Join user's personal room
|
||||
socket.emit('join-user-room', user.id);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Socket disconnected');
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('Socket connection error:', error);
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
// Notification handlers
|
||||
socket.on('notification', (notification) => {
|
||||
toast.success(notification.message, {
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
// List update handlers
|
||||
socket.on('list-updated', (data) => {
|
||||
// This will be handled by specific components using the socket
|
||||
console.log('List updated:', data);
|
||||
});
|
||||
|
||||
socket.on('list-item-added', (data) => {
|
||||
toast.success(`${data.item.name} added to ${data.list.name}`, {
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('list-item-updated', (data) => {
|
||||
if (data.item.isPurchased) {
|
||||
toast.success(`${data.item.name} marked as purchased`, {
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('list-item-removed', (data) => {
|
||||
toast(`${data.item.name} removed from ${data.list.name}`, {
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
// List member handlers
|
||||
socket.on('list-member-added', (data) => {
|
||||
toast.success(`You've been added to ${data.list.name}`, {
|
||||
duration: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('list-member-removed', (data) => {
|
||||
toast(`You've been removed from ${data.list.name}`, {
|
||||
duration: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
// List invitation handlers
|
||||
socket.on('list-invitation', (data) => {
|
||||
toast.success(`You've been invited to join ${data.list.name}`, {
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
// Error handlers
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error);
|
||||
toast.error(error.message || 'An error occurred', {
|
||||
duration: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Disconnect socket if user is not authenticated
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
setIsConnected(false);
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, token, user]);
|
||||
|
||||
const value: SocketContextType = {
|
||||
socket: socketRef.current,
|
||||
isConnected,
|
||||
};
|
||||
|
||||
return <SocketContext.Provider value={value}>{children}</SocketContext.Provider>;
|
||||
};
|
||||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
13
frontend/src/index.tsx
Normal file
13
frontend/src/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
1260
frontend/src/pages/Admin/AdminPage.tsx
Normal file
1260
frontend/src/pages/Admin/AdminPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/src/pages/Admin/index.ts
Normal file
1
frontend/src/pages/Admin/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AdminPage } from './AdminPage';
|
||||
308
frontend/src/pages/Auth/LoginPage.tsx
Normal file
308
frontend/src/pages/Auth/LoginPage.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Link,
|
||||
Alert,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Email,
|
||||
Lock,
|
||||
ShoppingCart,
|
||||
Google,
|
||||
} from '@mui/icons-material';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { LoginForm } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const schema = yup.object({
|
||||
login: yup
|
||||
.string()
|
||||
.required('E-posta veya kullanıcı adı gerekli'),
|
||||
password: yup
|
||||
.string()
|
||||
.min(6, 'Şifre en az 6 karakter olmalı')
|
||||
.required('Şifre gerekli'),
|
||||
});
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login, isLoading } = useAuth();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const from = (location.state as any)?.from?.pathname || '/dashboard';
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
login: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
try {
|
||||
setError('');
|
||||
await login(data.login, data.password);
|
||||
toast.success('Başarıyla giriş yapıldı!');
|
||||
navigate(from, { replace: true });
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || 'Giriş başarısız. Lütfen tekrar deneyin.';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickShowPassword = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Backend'deki Google OAuth endpoint'ine yönlendir
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:7001/api';
|
||||
window.location.href = `${apiUrl}/auth/google`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Logo and Title */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<ShoppingCart sx={{ fontSize: 40, color: 'primary.main', mr: 1 }} />
|
||||
<Typography component="h1" variant="h4" color="primary.main" fontWeight="bold">
|
||||
hMarket
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography component="h2" variant="h5" sx={{ mb: 3 }}>
|
||||
Giriş Yap
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ width: '100%' }}>
|
||||
<Controller
|
||||
name="login"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="login"
|
||||
label="E-posta veya Kullanıcı Adı"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
error={!!errors.login}
|
||||
helperText={errors.login?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Email />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
label="Şifre"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2, py: 1.5 }}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Giriş Yapılıyor...' : 'Giriş Yap'}
|
||||
</Button>
|
||||
|
||||
<Divider sx={{ my: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
VEYA
|
||||
</Typography>
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isLoading}
|
||||
startIcon={<Google />}
|
||||
sx={{
|
||||
mb: 2,
|
||||
py: 1.5,
|
||||
borderColor: '#db4437',
|
||||
color: '#db4437',
|
||||
'&:hover': {
|
||||
borderColor: '#c23321',
|
||||
backgroundColor: 'rgba(219, 68, 55, 0.04)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Google ile Giriş Yap
|
||||
</Button>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mt: 2 }}>
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => navigate('/forgot-password')}
|
||||
sx={{ textDecoration: 'none' }}
|
||||
>
|
||||
Şifremi unuttum?
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Hesabınız yok mu?{' '}
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => navigate('/register')}
|
||||
sx={{ textDecoration: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
Kayıt Ol
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mt: 3, pt: 2, borderTop: '1px solid rgba(0,0,0,0.05)', display: 'flex', justifyContent: 'center', gap: 2 }}>
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-kullanim-sartlari/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
variant="caption"
|
||||
sx={{ textDecoration: 'none', color: 'text.secondary' }}
|
||||
>
|
||||
Kullanım Şartları
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-gizlilik-politikasi/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
variant="caption"
|
||||
sx={{ textDecoration: 'none', color: 'text.secondary' }}
|
||||
>
|
||||
Gizlilik Politikası
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Features */}
|
||||
<Box sx={{ mt: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Neden hMarket?
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 4, mt: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
📝 Akıllı Listeler
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Alışveriş listeleri oluştur ve paylaş
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
🔄 Gerçek Zamanlı Senkronizasyon
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Tüm cihazlarda güncellemeler
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
💰 Fiyat Takibi
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Fiyatları takip et ve karşılaştır
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
381
frontend/src/pages/Auth/RegisterPage.tsx
Normal file
381
frontend/src/pages/Auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Link,
|
||||
Alert,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Email,
|
||||
Lock,
|
||||
Person,
|
||||
ShoppingCart,
|
||||
Google,
|
||||
} from '@mui/icons-material';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { RegisterForm } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const schema = yup.object({
|
||||
firstName: yup
|
||||
.string()
|
||||
.required('Ad gerekli')
|
||||
.min(2, 'Ad en az 2 karakter olmalı'),
|
||||
lastName: yup
|
||||
.string()
|
||||
.required('Soyad gerekli')
|
||||
.min(2, 'Soyad en az 2 karakter olmalı'),
|
||||
email: yup
|
||||
.string()
|
||||
.email('Lütfen geçerli bir e-posta adresi girin')
|
||||
.required('E-posta gerekli'),
|
||||
password: yup
|
||||
.string()
|
||||
.min(6, 'Şifre en az 6 karakter olmalı')
|
||||
.matches(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||
'Şifre en az bir büyük harf, bir küçük harf ve bir rakam içermeli'
|
||||
)
|
||||
.required('Şifre gerekli'),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.oneOf([yup.ref('password')], 'Şifreler eşleşmeli')
|
||||
.required('Lütfen şifrenizi onaylayın'),
|
||||
});
|
||||
|
||||
const RegisterPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { register, isLoading } = useAuth();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterForm>({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterForm) => {
|
||||
try {
|
||||
setError('');
|
||||
await register({
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
confirmPassword: data.confirmPassword,
|
||||
});
|
||||
toast.success('Hesap başarıyla oluşturuldu!');
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || 'Kayıt başarısız. Lütfen tekrar deneyin.';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickShowPassword = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
const handleClickShowConfirmPassword = () => {
|
||||
setShowConfirmPassword(!showConfirmPassword);
|
||||
};
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Backend'deki Google OAuth endpoint'ine yönlendir
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:7001/api';
|
||||
window.location.href = `${apiUrl}/auth/google`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Logo and Title */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<ShoppingCart sx={{ fontSize: 40, color: 'primary.main', mr: 1 }} />
|
||||
<Typography component="h1" variant="h4" color="primary.main" fontWeight="bold">
|
||||
hMarket
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography component="h2" variant="h5" sx={{ mb: 3 }}>
|
||||
Hesap Oluştur
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2 }}>
|
||||
<Box>
|
||||
<Controller
|
||||
name="firstName"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
required
|
||||
fullWidth
|
||||
id="firstName"
|
||||
label="Ad"
|
||||
autoComplete="given-name"
|
||||
autoFocus
|
||||
error={!!errors.firstName}
|
||||
helperText={errors.firstName?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Person />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Controller
|
||||
name="lastName"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
required
|
||||
fullWidth
|
||||
id="lastName"
|
||||
label="Soyad"
|
||||
autoComplete="family-name"
|
||||
error={!!errors.lastName}
|
||||
helperText={errors.lastName?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Person />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="E-posta Adresi"
|
||||
autoComplete="email"
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Email />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
label="Şifre"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
autoComplete="new-password"
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="confirmPassword"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
label="Şifre Onayı"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
id="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
error={!!errors.confirmPassword}
|
||||
helperText={errors.confirmPassword?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle confirm password visibility"
|
||||
onClick={handleClickShowConfirmPassword}
|
||||
edge="end"
|
||||
>
|
||||
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2, py: 1.5 }}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Hesap Oluşturuluyor...' : 'Hesap Oluştur'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isLoading}
|
||||
startIcon={<Google />}
|
||||
sx={{
|
||||
mb: 2,
|
||||
py: 1.5,
|
||||
borderColor: '#db4437',
|
||||
color: '#db4437',
|
||||
'&:hover': {
|
||||
borderColor: '#c23321',
|
||||
backgroundColor: 'rgba(219, 68, 55, 0.04)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Google ile Kayıt Ol
|
||||
</Button>
|
||||
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Zaten hesabınız var mı?{' '}
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => navigate('/login')}
|
||||
sx={{ textDecoration: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
Giriş Yap
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Terms */}
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Hesap oluşturarak,{' '}
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-kullanim-sartlari/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{ textDecoration: 'none' }}
|
||||
>
|
||||
Kullanım Şartları
|
||||
</Link>
|
||||
{' '}ve{' '}
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-gizlilik-politikasi/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{ textDecoration: 'none' }}
|
||||
>
|
||||
Gizlilik Politikası
|
||||
</Link>
|
||||
'nı kabul etmiş olursunuz.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
2
frontend/src/pages/Auth/index.ts
Normal file
2
frontend/src/pages/Auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LoginPage } from './LoginPage';
|
||||
export { default as RegisterPage } from './RegisterPage';
|
||||
359
frontend/src/pages/Dashboard/DashboardPage.tsx
Normal file
359
frontend/src/pages/Dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Avatar,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
Link,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
List as ListIcon,
|
||||
ShoppingCart,
|
||||
CheckCircle,
|
||||
PendingActions,
|
||||
Share,
|
||||
TrendingUp,
|
||||
Refresh,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { dashboardAPI, listsAPI } from '../../services/api';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ListsResponse } from '../../types';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Fetch dashboard stats
|
||||
const { data: statsData, isLoading: statsLoading, refetch: refetchStats } = useQuery({
|
||||
queryKey: ['dashboard', 'stats'],
|
||||
queryFn: () => dashboardAPI.getStats(),
|
||||
});
|
||||
|
||||
// Fetch recent lists
|
||||
const { data: listsData, isLoading: listsLoading } = useQuery<ListsResponse>({
|
||||
queryKey: ['lists', 'recent'],
|
||||
queryFn: () => listsAPI.getLists({ limit: 5, sortBy: 'updatedAt', sortOrder: 'desc' }).then(response => response.data),
|
||||
});
|
||||
|
||||
// Fetch recent activity
|
||||
const { data: activityData, isLoading: activityLoading } = useQuery({
|
||||
queryKey: ['dashboard', 'activity'],
|
||||
queryFn: () => dashboardAPI.getRecentActivity({ limit: 10 }),
|
||||
});
|
||||
|
||||
const stats = statsData?.data?.data;
|
||||
const recentLists = listsData?.data?.lists || [];
|
||||
const recentActivity = activityData?.data?.data?.activities || [];
|
||||
|
||||
const completionRate = stats ? Math.round((stats.completedItems / stats.totalItems) * 100) || 0 : 0;
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Welcome Section */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Tekrar hoş geldin, {user?.firstName}! 👋
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Bugün alışveriş listelerinizde neler oluyor, işte özet.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr 1fr' }, gap: 3 }}>
|
||||
{/* Stats Cards */}
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ListIcon sx={{ fontSize: 40, color: 'primary.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Toplam Liste
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{statsLoading ? '-' : stats?.totalLists || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ShoppingCart sx={{ fontSize: 40, color: 'info.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Toplam Ürün
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{statsLoading ? '-' : stats?.totalItems || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CheckCircle sx={{ fontSize: 40, color: 'success.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Tamamlanan
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{statsLoading ? '-' : stats?.completedItems || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<PendingActions sx={{ fontSize: 40, color: 'warning.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Bekleyen
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{statsLoading ? '-' : stats?.pendingItems || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3, mt: 3 }}>
|
||||
{/* Completion Rate */}
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6">Alışveriş İlerlemesi</Typography>
|
||||
<IconButton onClick={() => refetchStats()}>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<TrendingUp sx={{ mr: 1, color: 'success.main' }} />
|
||||
<Typography variant="h4" color="success.main">
|
||||
{completionRate}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
|
||||
tamamlanma oranı
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={completionRate}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{stats?.totalItems || 0} üründen {stats?.completedItems || 0} tanesi tamamlandı
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Hızlı İşlemler
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => navigate('/lists?action=create')}
|
||||
>
|
||||
Yeni Liste Oluştur
|
||||
</Button>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<ListIcon />}
|
||||
onClick={() => navigate('/lists')}
|
||||
>
|
||||
Tüm Listeler
|
||||
</Button>
|
||||
</Box>
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<ShoppingCart />}
|
||||
onClick={() => navigate('/products')}
|
||||
>
|
||||
Ürünlere Gözat
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3, mt: 3 }}>
|
||||
{/* Recent Lists */}
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Son Listeler
|
||||
</Typography>
|
||||
{listsLoading ? (
|
||||
<Typography>Yükleniyor...</Typography>
|
||||
) : recentLists.length === 0 ? (
|
||||
<Typography color="text.secondary">
|
||||
Henüz liste yok. İlk alışveriş listenizi oluşturun!
|
||||
</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{recentLists.map((list: any) => (
|
||||
<ListItem
|
||||
key={list.id}
|
||||
onClick={() => navigate(`/lists/${list.id}`)}
|
||||
sx={{ px: 0, cursor: 'pointer' }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Avatar sx={{ bgcolor: list.color || 'primary.main', width: 32, height: 32 }}>
|
||||
{list.isShared ? <Share /> : <ListIcon />}
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={list.name}
|
||||
secondary={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '0.875rem', color: 'rgba(0, 0, 0, 0.6)' }}>
|
||||
{list._count?.items || 0} ürün
|
||||
</span>
|
||||
{list.isShared && (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
Paylaşılan
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button size="small" onClick={() => navigate('/lists')}>
|
||||
Tüm Listeleri Görüntüle
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Son Aktiviteler
|
||||
</Typography>
|
||||
{activityLoading ? (
|
||||
<Typography>Yükleniyor...</Typography>
|
||||
) : recentActivity.length === 0 ? (
|
||||
<Typography color="text.secondary">
|
||||
Son aktivite yok.
|
||||
</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{recentActivity.slice(0, 5).map((activity: any) => (
|
||||
<ListItem key={activity.id} sx={{ px: 0 }}>
|
||||
<ListItemIcon>
|
||||
<Avatar sx={{ width: 32, height: 32 }}>
|
||||
{activity.user?.firstName?.[0] || '?'}
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={activity.description}
|
||||
secondary={formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button size="small" onClick={() => navigate('/activity')}>
|
||||
Tüm Aktiviteleri Görüntüle
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box sx={{ mt: 8, pt: 4, borderTop: '1px solid rgba(0,0,0,0.05)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-kullanim-sartlari/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
variant="body2"
|
||||
sx={{ textDecoration: 'none', color: 'text.secondary' }}
|
||||
>
|
||||
Kullanım Şartları
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-gizlilik-politikasi/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
variant="body2"
|
||||
sx={{ textDecoration: 'none', color: 'text.secondary' }}
|
||||
>
|
||||
Gizlilik Politikası
|
||||
</Link>
|
||||
</Box>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 1, color: 'text.disabled' }}>
|
||||
© 2026 hMarket - Mustafa ÖZKAYA
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
1
frontend/src/pages/Dashboard/index.ts
Normal file
1
frontend/src/pages/Dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DashboardPage } from './DashboardPage';
|
||||
1570
frontend/src/pages/Lists/ListDetailPage.tsx
Normal file
1570
frontend/src/pages/Lists/ListDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
249
frontend/src/pages/Lists/ListEditPage.tsx
Normal file
249
frontend/src/pages/Lists/ListEditPage.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Box,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Save } from '@mui/icons-material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { listsAPI } from '../../services/api';
|
||||
import { UpdateListForm } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Validation schema
|
||||
const schema: yup.ObjectSchema<UpdateListForm> = yup.object({
|
||||
name: yup.string().optional().test('min-length', 'Liste adı en az 1 karakter olmalıdır', function(value) {
|
||||
if (value === undefined || value === null) return true; // optional field
|
||||
return value.length >= 1 && value.length <= 100;
|
||||
}),
|
||||
description: yup.string().optional().max(500, 'Açıklama en fazla 500 karakter olabilir'),
|
||||
color: yup.string().optional().matches(/^#[0-9A-F]{6}$/i, 'Geçerli bir hex renk kodu girin'),
|
||||
isShared: yup.boolean().optional(),
|
||||
});
|
||||
|
||||
// Color options
|
||||
const colors = [
|
||||
'#FF5722', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
|
||||
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
|
||||
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
|
||||
];
|
||||
|
||||
const ListEditPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { listId } = useParams<{ listId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch list data
|
||||
const { data: listData, isLoading, error } = useQuery({
|
||||
queryKey: ['list', listId],
|
||||
queryFn: () => listsAPI.getList(listId!),
|
||||
enabled: !!listId,
|
||||
});
|
||||
|
||||
// Update list mutation
|
||||
const updateListMutation = useMutation({
|
||||
mutationFn: (data: UpdateListForm) => listsAPI.updateList(listId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['list', listId] });
|
||||
toast.success('Liste başarıyla güncellendi!');
|
||||
navigate(`/lists/${listId}`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Liste güncellenemedi');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<UpdateListForm>({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#FF5722',
|
||||
isShared: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when data is loaded
|
||||
useEffect(() => {
|
||||
if (listData?.data?.data?.list) {
|
||||
const list = listData.data.data.list;
|
||||
reset({
|
||||
name: list.name,
|
||||
description: list.description || '',
|
||||
color: list.color,
|
||||
isShared: list.isShared,
|
||||
});
|
||||
}
|
||||
}, [listData, reset]);
|
||||
|
||||
const handleUpdateList = (data: UpdateListForm) => {
|
||||
updateListMutation.mutate(data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Liste yüklenirken bir hata oluştu
|
||||
</Alert>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/lists')}
|
||||
>
|
||||
Listelere Dön
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const list = listData?.data?.data?.list;
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate(`/lists/${listId}`)}
|
||||
variant="outlined"
|
||||
>
|
||||
Geri
|
||||
</Button>
|
||||
<Typography variant="h4" component="h1">
|
||||
Liste Düzenle
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<form onSubmit={handleSubmit(handleUpdateList)}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Liste Adı"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Açıklama (isteğe bağlı)"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
variant="outlined"
|
||||
error={!!errors.description}
|
||||
helperText={errors.description?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Renk
|
||||
</Typography>
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{colors.map((color) => (
|
||||
<Box
|
||||
key={color}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
bgcolor: color,
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
border: field.value === color ? '3px solid #000' : '2px solid transparent',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onClick={() => field.onChange(color)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Controller
|
||||
name="isShared"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
}
|
||||
label="Paylaşımlı Liste"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate(`/lists/${listId}`)}
|
||||
>
|
||||
İptal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={<Save />}
|
||||
disabled={!isDirty || updateListMutation.isPending}
|
||||
>
|
||||
{updateListMutation.isPending ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListEditPage;
|
||||
682
frontend/src/pages/Lists/ListsPage.tsx
Normal file
682
frontend/src/pages/Lists/ListsPage.tsx
Normal file
@@ -0,0 +1,682 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
Fab,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Chip,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Search,
|
||||
MoreVert,
|
||||
Share,
|
||||
Edit,
|
||||
Delete,
|
||||
List as ListIcon,
|
||||
People,
|
||||
ShoppingCart,
|
||||
FilterList,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { listsAPI, itemsAPI } from '../../services/api';
|
||||
import { CreateListForm, ShoppingList, ListsResponse } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const createListSchema = yup.object().shape({
|
||||
name: yup.string().required('Liste adı gerekli').min(2, 'Ad en az 2 karakter olmalı'),
|
||||
description: yup.string().optional(),
|
||||
color: yup.string().optional(),
|
||||
isShared: yup.boolean().required(),
|
||||
});
|
||||
|
||||
const ListsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(searchParams.get('search') || '');
|
||||
const [filterShared, setFilterShared] = useState<boolean | undefined>(
|
||||
searchParams.get('shared') ? searchParams.get('shared') === 'true' : undefined
|
||||
);
|
||||
const [filterCompleted, setFilterCompleted] = useState<boolean | undefined>(
|
||||
searchParams.get('completed') ? searchParams.get('completed') === 'true' : false
|
||||
);
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || 'updatedAt');
|
||||
const [sortOrder, setSortOrder] = useState(searchParams.get('sortOrder') || 'desc');
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(searchParams.get('action') === 'create');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [selectedList, setSelectedList] = useState<ShoppingList | null>(null);
|
||||
|
||||
// Fetch lists
|
||||
const { data: listsData, isLoading, error } = useQuery<ListsResponse>({
|
||||
queryKey: ['lists', { search: searchTerm, isShared: filterShared, sortBy, sortOrder }],
|
||||
queryFn: () => {
|
||||
console.log('🔍 Fetching lists with params:', { search: searchTerm, isShared: filterShared, sortBy, sortOrder });
|
||||
return listsAPI.getLists({
|
||||
search: searchTerm || undefined,
|
||||
isShared: filterShared,
|
||||
sortBy: sortBy as any,
|
||||
sortOrder: sortOrder as any,
|
||||
limit: 50,
|
||||
}).then(response => {
|
||||
console.log('📋 Lists API response:', response.data);
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Create list mutation
|
||||
const createListMutation = useMutation({
|
||||
mutationFn: (data: CreateListForm) => listsAPI.createList(data),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||
toast.success('Liste başarıyla oluşturuldu!');
|
||||
setCreateDialogOpen(false);
|
||||
reset();
|
||||
// Yeni oluşturulan listeye otomatik yönlendir
|
||||
const newListId = response.data.data.list.id;
|
||||
navigate(`/lists/${newListId}`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Liste oluşturulamadı');
|
||||
},
|
||||
});
|
||||
|
||||
// Delete list mutation
|
||||
const deleteListMutation = useMutation({
|
||||
mutationFn: (id: string) => listsAPI.deleteList(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||
toast.success('Liste başarıyla silindi!');
|
||||
handleMenuClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Liste silinemedi');
|
||||
},
|
||||
});
|
||||
|
||||
// Add sample data mutation
|
||||
const addSampleDataMutation = useMutation({
|
||||
mutationFn: async (listId: string) => {
|
||||
const sampleItems = [
|
||||
{ name: 'Ekmek', quantity: 2, unit: 'adet', priority: 'MEDIUM' as const, notes: 'Tam buğday ekmeği' },
|
||||
{ name: 'Süt', quantity: 1, unit: 'litre', priority: 'HIGH' as const, notes: '3.5% yağlı' },
|
||||
{ name: 'Yumurta', quantity: 12, unit: 'adet', priority: 'MEDIUM' as const, notes: 'Organik' },
|
||||
{ name: 'Domates', quantity: 1, unit: 'kg', priority: 'LOW' as const, notes: 'Taze' },
|
||||
{ name: 'Soğan', quantity: 500, unit: 'gram', priority: 'LOW' as const, notes: 'Kuru soğan' },
|
||||
{ name: 'Peynir', quantity: 250, unit: 'gram', priority: 'MEDIUM' as const, notes: 'Beyaz peynir' },
|
||||
{ name: 'Zeytin', quantity: 200, unit: 'gram', priority: 'LOW' as const, notes: 'Siyah zeytin' },
|
||||
{ name: 'Çay', quantity: 1, unit: 'paket', priority: 'MEDIUM' as const, notes: 'Bergamot aromalı' },
|
||||
];
|
||||
|
||||
// Add each sample item to the list
|
||||
for (const item of sampleItems) {
|
||||
await itemsAPI.createItem(listId, item);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||
toast.success('Örnek ürünler başarıyla eklendi!');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Örnek ürünler eklenemedi');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<CreateListForm>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#FF5722',
|
||||
isShared: false,
|
||||
},
|
||||
});
|
||||
|
||||
const lists = listsData?.data?.lists || [];
|
||||
|
||||
// Filter lists based on completion status
|
||||
const filteredLists = lists.filter((list: ShoppingList) => {
|
||||
if (filterCompleted === undefined) return true;
|
||||
|
||||
const totalItems = list._count?.items || 0;
|
||||
const completedItems = list._count?.completedItems || 0;
|
||||
const isCompleted = totalItems > 0 && completedItems === totalItems;
|
||||
|
||||
return filterCompleted ? isCompleted : !isCompleted;
|
||||
});
|
||||
|
||||
// Debug logs
|
||||
console.log('🔍 listsData:', listsData);
|
||||
console.log('📋 lists array:', lists);
|
||||
console.log('🎯 filteredLists:', filteredLists);
|
||||
console.log('📊 lists length:', lists.length);
|
||||
console.log('📊 filteredLists length:', filteredLists.length);
|
||||
console.log('🎯 isLoading:', isLoading);
|
||||
console.log('❌ error:', error);
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, list: ShoppingList) => {
|
||||
setMenuAnchor(event.currentTarget);
|
||||
setSelectedList(list);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchor(null);
|
||||
setSelectedList(null);
|
||||
};
|
||||
|
||||
const handleCreateList = (data: CreateListForm) => {
|
||||
createListMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleDeleteList = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
setMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (selectedList) {
|
||||
deleteListMutation.mutate(selectedList.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedList(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedList(null);
|
||||
};
|
||||
|
||||
const handleAddSampleData = (listId: string) => {
|
||||
addSampleDataMutation.mutate(listId);
|
||||
};
|
||||
|
||||
const updateSearchParams = (key: string, value: string | undefined) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const colors = [
|
||||
'#FF5722', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
|
||||
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
|
||||
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
|
||||
];
|
||||
|
||||
// Render function for lists grid
|
||||
const renderListsGrid = () => {
|
||||
console.log('🎨 Render condition check:', { isLoading, listsLength: filteredLists.length, filteredLists });
|
||||
|
||||
if (isLoading) {
|
||||
console.log('🔄 Rendering loading state');
|
||||
return <Typography>Yükleniyor...</Typography>;
|
||||
}
|
||||
|
||||
if (filteredLists.length === 0) {
|
||||
console.log('📭 Rendering empty state');
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<ListIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Liste bulunamadı
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{searchTerm || filterCompleted !== undefined ? 'Arama kriterlerinizi ayarlamayı deneyin' : 'Başlamak için ilk alışveriş listenizi oluşturun'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
İlk Listenizi Oluşturun
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('📋 Rendering lists grid with', filteredLists.length, 'lists');
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 3 }}>
|
||||
{filteredLists.map((list: ShoppingList) => (
|
||||
<Box key={list.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { boxShadow: 4 },
|
||||
}}
|
||||
onClick={() => navigate(`/lists/${list.id}`)}
|
||||
>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: list.color || '#FF5722',
|
||||
width: 40,
|
||||
height: 40,
|
||||
mr: 2,
|
||||
}}
|
||||
>
|
||||
{list.isShared ? <Share /> : <ListIcon />}
|
||||
</Avatar>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" noWrap>
|
||||
{list.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatDistanceToNow(new Date(list.updatedAt), { addSuffix: true })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMenuOpen(e, list);
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{list.description && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{list.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
{list.isShared && (
|
||||
<Chip
|
||||
icon={<People />}
|
||||
label={`${list._count?.members || 0} üye`}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
icon={<ShoppingCart />}
|
||||
label={`${list._count?.completedItems || 0}/${list._count?.items || 0} ürün`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={(list._count?.completedItems || 0) === (list._count?.items || 0) && (list._count?.items || 0) > 0 ? "success" : "default"}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
<CardActions>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/lists/${list.id}`);
|
||||
}}
|
||||
>
|
||||
Listeyi Görüntüle
|
||||
</Button>
|
||||
{(list._count?.items || 0) === 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddSampleData(list.id);
|
||||
}}
|
||||
disabled={addSampleDataMutation.isPending}
|
||||
>
|
||||
{addSampleDataMutation.isPending ? 'Ekleniyor...' : 'Örnek Ürünler Ekle'}
|
||||
</Button>
|
||||
)}
|
||||
{list.isShared && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Share />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// TODO: Implement share functionality
|
||||
}}
|
||||
>
|
||||
Paylaş
|
||||
</Button>
|
||||
)}
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Alışveriş Listeleri
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
sx={{ display: { xs: 'none', sm: 'flex' } }}
|
||||
>
|
||||
Liste Oluştur
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flex: '1 1 300px', minWidth: '200px' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Listelerde ara..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
updateSearchParams('search', e.target.value || undefined);
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Paylaşım</InputLabel>
|
||||
<Select
|
||||
value={filterShared === undefined ? 'all' : filterShared ? 'shared' : 'private'}
|
||||
label="Paylaşım"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const shared = value === 'all' ? undefined : value === 'shared';
|
||||
setFilterShared(shared);
|
||||
updateSearchParams('shared', shared === undefined ? undefined : shared.toString());
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">Tüm Listeler</MenuItem>
|
||||
<MenuItem value="shared">Paylaşılan Listeler</MenuItem>
|
||||
<MenuItem value="private">Özel Listeler</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Durum</InputLabel>
|
||||
<Select
|
||||
value={filterCompleted === undefined ? 'all' : filterCompleted ? 'completed' : 'ongoing'}
|
||||
label="Durum"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const completed = value === 'all' ? undefined : value === 'completed';
|
||||
setFilterCompleted(completed);
|
||||
updateSearchParams('completed', completed === undefined ? undefined : completed.toString());
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">Tüm Listeler</MenuItem>
|
||||
<MenuItem value="completed">Tamamlananlar</MenuItem>
|
||||
<MenuItem value="ongoing">Devam Edenler</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Sırala</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="Sırala"
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value);
|
||||
updateSearchParams('sortBy', e.target.value);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="name">İsim</MenuItem>
|
||||
<MenuItem value="createdAt">Oluşturulma Tarihi</MenuItem>
|
||||
<MenuItem value="updatedAt">Güncellenme Tarihi</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Sıralama</InputLabel>
|
||||
<Select
|
||||
value={sortOrder}
|
||||
label="Sıralama"
|
||||
onChange={(e) => {
|
||||
setSortOrder(e.target.value);
|
||||
updateSearchParams('sortOrder', e.target.value);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="asc">Artan</MenuItem>
|
||||
<MenuItem value="desc">Azalan</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Lists Grid */}
|
||||
{renderListsGrid()}
|
||||
|
||||
{/* Floating Action Button for Mobile */}
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label="add"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
display: { xs: 'flex', sm: 'none' },
|
||||
}}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Add />
|
||||
</Fab>
|
||||
|
||||
{/* Create List Dialog */}
|
||||
<Dialog
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Yeni Liste Oluştur</DialogTitle>
|
||||
<form onSubmit={handleSubmit(handleCreateList)}>
|
||||
<DialogContent>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Liste Adı"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Açıklama (isteğe bağlı)"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Renk
|
||||
</Typography>
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{colors.map((color) => (
|
||||
<Box
|
||||
key={color}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: color,
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
border: field.value === color ? '3px solid #000' : '2px solid transparent',
|
||||
}}
|
||||
onClick={() => field.onChange(color)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Controller
|
||||
name="isShared"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
}
|
||||
label="Bu listeyi paylaşımlı yap"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateDialogOpen(false)}>İptal</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={createListMutation.isPending}
|
||||
>
|
||||
{createListMutation.isPending ? 'Oluşturuluyor...' : 'Liste Oluştur'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={handleCancelDelete}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Liste Silme Onayı</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
"{selectedList?.name}" listesini silmek istediğinizden emin misiniz?
|
||||
Bu işlem geri alınamaz ve listedeki tüm ürünler de silinecektir.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancelDelete}>İptal</Button>
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={deleteListMutation.isPending}
|
||||
>
|
||||
{deleteListMutation.isPending ? 'Siliniyor...' : 'Sil'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* List Menu */}
|
||||
<Menu
|
||||
anchorEl={menuAnchor}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (selectedList) {
|
||||
navigate(`/lists/${selectedList.id}/edit`);
|
||||
}
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
<Edit sx={{ mr: 1 }} />
|
||||
Düzenle
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// TODO: Implement share functionality
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
<Share sx={{ mr: 1 }} />
|
||||
Paylaş
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleDeleteList}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<Delete sx={{ mr: 1 }} />
|
||||
Sil
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListsPage;
|
||||
3
frontend/src/pages/Lists/index.ts
Normal file
3
frontend/src/pages/Lists/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ListsPage } from './ListsPage';
|
||||
export { default as ListDetailPage } from './ListDetailPage';
|
||||
export { default as ListEditPage } from './ListEditPage';
|
||||
809
frontend/src/pages/Products/ProductsPage.tsx
Normal file
809
frontend/src/pages/Products/ProductsPage.tsx
Normal file
@@ -0,0 +1,809 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
Fab,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Chip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
Autocomplete,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Search,
|
||||
MoreVert,
|
||||
Edit,
|
||||
Delete,
|
||||
Category as CategoryIcon,
|
||||
ShoppingCart,
|
||||
Fastfood,
|
||||
LocalDrink,
|
||||
Cake,
|
||||
LocalGroceryStore,
|
||||
ChildCare,
|
||||
CleaningServices,
|
||||
Face,
|
||||
AcUnit,
|
||||
LocalFlorist,
|
||||
LocalDining,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { productsAPI, categoriesAPI } from '../../services/api';
|
||||
import { CreateProductForm, Product, Category } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
// Icon mapping for categories - now supports emoji icons from database
|
||||
const getCategoryIcon = (iconName?: string) => {
|
||||
// If iconName exists and looks like an emoji (short string), return it directly
|
||||
if (iconName && iconName.length <= 4) {
|
||||
return <span style={{ fontSize: '16px' }}>{iconName}</span>;
|
||||
}
|
||||
|
||||
// Fallback to Material-UI icons for backward compatibility
|
||||
const iconMap: { [key: string]: React.ReactElement } = {
|
||||
'Fastfood': <Fastfood />,
|
||||
'LocalDrink': <LocalDrink />,
|
||||
'Cake': <Cake />,
|
||||
'LocalGroceryStore': <LocalGroceryStore />,
|
||||
'ChildCare': <ChildCare />,
|
||||
'CleaningServices': <CleaningServices />,
|
||||
'Face': <Face />,
|
||||
'AcUnit': <AcUnit />,
|
||||
'LocalFlorist': <LocalFlorist />,
|
||||
'LocalDining': <LocalDining />,
|
||||
};
|
||||
|
||||
return iconMap[iconName || ''] || <CategoryIcon />;
|
||||
};
|
||||
|
||||
const ProductsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(searchParams.get('search') || '');
|
||||
const [selectedCategory, setSelectedCategory] = useState(searchParams.get('categoryId') || '');
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || 'name');
|
||||
const [sortOrder, setSortOrder] = useState(searchParams.get('sortOrder') || 'asc');
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(searchParams.get('action') === 'create');
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
|
||||
// Fetch products
|
||||
const { data: productsData, isLoading } = useQuery({
|
||||
queryKey: ['products', { search: searchTerm, categoryId: selectedCategory, sortBy, sortOrder }],
|
||||
queryFn: () => productsAPI.getProducts({
|
||||
search: searchTerm || undefined,
|
||||
categoryId: selectedCategory || undefined,
|
||||
sortBy: sortBy as any,
|
||||
sortOrder: sortOrder as any,
|
||||
limit: 50,
|
||||
}),
|
||||
});
|
||||
|
||||
// Fetch categories
|
||||
const { data: categoriesData } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => categoriesAPI.getCategories({ limit: 100 }),
|
||||
});
|
||||
|
||||
// Create product mutation
|
||||
const createProductMutation = useMutation({
|
||||
mutationFn: (data: CreateProductForm) => productsAPI.createProduct(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
toast.success('Ürün başarıyla oluşturuldu!');
|
||||
setCreateDialogOpen(false);
|
||||
reset();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Ürün oluşturulamadı');
|
||||
},
|
||||
});
|
||||
|
||||
// Update product mutation
|
||||
const updateProductMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: CreateProductForm }) =>
|
||||
productsAPI.updateProduct(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
toast.success('Ürün başarıyla güncellendi!');
|
||||
resetEdit();
|
||||
setEditDialogOpen(false);
|
||||
setSelectedProduct(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Ürün güncellenirken hata oluştu');
|
||||
},
|
||||
});
|
||||
|
||||
// Delete product mutation
|
||||
const deleteProductMutation = useMutation({
|
||||
mutationFn: (id: string) => productsAPI.deleteProduct(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
toast.success('Ürün başarıyla silindi!');
|
||||
handleMenuClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Ürün silinemedi');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<CreateProductForm>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
categoryId: '',
|
||||
barcode: '',
|
||||
brand: '',
|
||||
averagePrice: 0,
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const { control: editControl, handleSubmit: handleEditSubmit, reset: resetEdit, formState: { errors: editErrors } } = useForm<CreateProductForm>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
categoryId: '',
|
||||
averagePrice: 0,
|
||||
brand: '',
|
||||
barcode: '',
|
||||
},
|
||||
});
|
||||
|
||||
const products = productsData?.data?.data?.products || [];
|
||||
const categories = categoriesData?.data?.data?.categories || [];
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, product: Product) => {
|
||||
setMenuAnchor(event.currentTarget);
|
||||
setSelectedProduct(product);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchor(null);
|
||||
setSelectedProduct(null);
|
||||
};
|
||||
|
||||
const handleCreateProduct = (data: CreateProductForm) => {
|
||||
// averagePrice'ı price olarak gönder
|
||||
const cleanData = {
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
categoryId: data.categoryId,
|
||||
brand: data.brand || undefined,
|
||||
barcode: data.barcode || undefined,
|
||||
price: data.averagePrice || undefined,
|
||||
};
|
||||
createProductMutation.mutate(cleanData);
|
||||
};
|
||||
|
||||
const handleEditProduct = async (data: CreateProductForm) => {
|
||||
if (!selectedProduct) return;
|
||||
|
||||
const productData: any = {};
|
||||
|
||||
if (data.name) productData.name = data.name;
|
||||
if (data.barcode) productData.barcode = data.barcode;
|
||||
if (data.categoryId) productData.categoryId = data.categoryId;
|
||||
if (data.description !== undefined) productData.description = data.description;
|
||||
if (data.averagePrice !== undefined) productData.averagePrice = data.averagePrice;
|
||||
if (data.brand !== undefined) productData.brand = data.brand;
|
||||
|
||||
updateProductMutation.mutate({
|
||||
id: selectedProduct.id,
|
||||
data: productData,
|
||||
});
|
||||
};
|
||||
|
||||
const openEditDialog = (product: Product) => {
|
||||
setSelectedProduct(product);
|
||||
resetEdit({
|
||||
name: product.name,
|
||||
description: product.description || '',
|
||||
categoryId: product.categoryId || '',
|
||||
averagePrice: product.averagePrice || 0,
|
||||
brand: product.brand || '',
|
||||
barcode: product.barcode || '',
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteProduct = () => {
|
||||
if (selectedProduct) {
|
||||
deleteProductMutation.mutate(selectedProduct.id);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSearchParams = (key: string, value: string | undefined) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Ürünler
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
sx={{ display: { xs: 'none', sm: 'flex' } }}
|
||||
>
|
||||
Ürün Ekle
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 2, alignItems: 'center' }}>
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Ürün ara..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
updateSearchParams('search', e.target.value || undefined);
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Kategori</InputLabel>
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
label="Kategori"
|
||||
onChange={(e) => {
|
||||
setSelectedCategory(e.target.value);
|
||||
updateSearchParams('categoryId', e.target.value || undefined);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">Tüm Kategoriler</MenuItem>
|
||||
{categories && categories.map((category: Category) => (
|
||||
<MenuItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Sırala</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="Sırala"
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value);
|
||||
updateSearchParams('sortBy', e.target.value);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="name">İsim</MenuItem>
|
||||
<MenuItem value="createdAt">Oluşturma Tarihi</MenuItem>
|
||||
<MenuItem value="category.name">Kategori</MenuItem>
|
||||
<MenuItem value="brand">Marka</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Sıralama</InputLabel>
|
||||
<Select
|
||||
value={sortOrder}
|
||||
label="Sıralama"
|
||||
onChange={(e) => {
|
||||
setSortOrder(e.target.value);
|
||||
updateSearchParams('sortOrder', e.target.value);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="asc">Artan</MenuItem>
|
||||
<MenuItem value="desc">Azalan</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Products Grid */}
|
||||
{isLoading ? (
|
||||
<Typography>Yükleniyor...</Typography>
|
||||
) : products.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<ShoppingCart sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Ürün bulunamadı
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{searchTerm ? 'Arama kriterlerinizi ayarlamayı deneyin' : 'Başlamak için ilk ürününüzü ekleyin'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
İlk Ürününüzü Ekleyin
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 3 }}>
|
||||
{products.map((product: Product) => (
|
||||
<Box key={product.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { boxShadow: 4 },
|
||||
}}
|
||||
onClick={() => navigate(`/products/${product.id}`)}
|
||||
>
|
||||
|
||||
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Typography variant="h6" noWrap sx={{ flexGrow: 1 }}>
|
||||
{product.name}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMenuOpen(e, product);
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{product.brand && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{product.brand}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{product.description && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mb: 2,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{product.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
{product.category && (
|
||||
<Chip
|
||||
icon={getCategoryIcon(product.category.icon)}
|
||||
label={product.category.name}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
backgroundColor: product.category.color ? `${product.category.color}20` : undefined,
|
||||
borderColor: product.category.color || undefined,
|
||||
color: product.category.color || undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDistanceToNow(new Date(product.createdAt), { addSuffix: true })} eklendi
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Floating Action Button for Mobile */}
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label="add"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
display: { xs: 'flex', sm: 'none' },
|
||||
}}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Add />
|
||||
</Fab>
|
||||
|
||||
{/* Create Product Dialog */}
|
||||
<Dialog
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Yeni Ürün Ekle</DialogTitle>
|
||||
<form onSubmit={handleSubmit(handleCreateProduct)}>
|
||||
<DialogContent>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'Ürün adı zorunludur',
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: 'Ürün adı en az 2 karakter olmalıdır'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Ürün Adı"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Açıklama (isteğe bağlı)"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="categoryId"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'Kategori seçimi zorunludur'
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={categories || []}
|
||||
getOptionLabel={(option: Category) => option.name}
|
||||
value={categories?.find((c: Category) => c.id === field.value) || null}
|
||||
onChange={(_, value) => field.onChange(value?.id || '')}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Kategori"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
error={!!errors.categoryId}
|
||||
helperText={errors.categoryId?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="averagePrice"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'Fiyat zorunludur',
|
||||
min: {
|
||||
value: 0.01,
|
||||
message: 'Fiyat 0\'dan büyük olmalıdır'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Fiyat (₺)"
|
||||
fullWidth
|
||||
type="number"
|
||||
inputProps={{ step: "0.01", min: "0" }}
|
||||
variant="outlined"
|
||||
error={!!errors.averagePrice}
|
||||
helperText={errors.averagePrice?.message}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="brand"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Marka (isteğe bağlı)"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="barcode"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Barkod (isteğe bağlı)"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateDialogOpen(false)}>İptal</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={createProductMutation.isPending}
|
||||
>
|
||||
{createProductMutation.isPending ? 'Oluşturuluyor...' : 'Ürün Oluştur'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Product Dialog */}
|
||||
<Dialog
|
||||
open={editDialogOpen}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Ürün Düzenle</DialogTitle>
|
||||
<form onSubmit={handleEditSubmit(handleEditProduct)}>
|
||||
<DialogContent>
|
||||
<Controller
|
||||
name="name"
|
||||
control={editControl}
|
||||
rules={{
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: 'Ürün adı en az 2 karakter olmalıdır'
|
||||
},
|
||||
maxLength: {
|
||||
value: 100,
|
||||
message: 'Ürün adı en fazla 100 karakter olabilir'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Ürün Adı"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!editErrors.name}
|
||||
helperText={editErrors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={editControl}
|
||||
rules={{
|
||||
maxLength: {
|
||||
value: 500,
|
||||
message: 'Açıklama en fazla 500 karakter olabilir'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Açıklama"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
multiline
|
||||
rows={3}
|
||||
error={!!editErrors.description}
|
||||
helperText={editErrors.description?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="categoryId"
|
||||
control={editControl}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
{...field}
|
||||
options={categories}
|
||||
getOptionLabel={(option: Category) => option.name}
|
||||
value={categories.find(cat => cat.id === field.value) || null}
|
||||
onChange={(_, value: Category | null) => field.onChange(value?.id || '')}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
margin="dense"
|
||||
label="Kategori"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!editErrors.categoryId}
|
||||
helperText={editErrors.categoryId?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="averagePrice"
|
||||
control={editControl}
|
||||
rules={{
|
||||
min: {
|
||||
value: 0,
|
||||
message: 'Fiyat 0 veya daha büyük olmalıdır'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Fiyat (₺)"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
type="number"
|
||||
inputProps={{ step: 0.01, min: 0 }}
|
||||
error={!!editErrors.averagePrice}
|
||||
helperText={editErrors.averagePrice?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="brand"
|
||||
control={editControl}
|
||||
rules={{
|
||||
maxLength: {
|
||||
value: 100,
|
||||
message: 'Marka en fazla 100 karakter olabilir'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Marka"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!editErrors.brand}
|
||||
helperText={editErrors.brand?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="barcode"
|
||||
control={editControl}
|
||||
rules={{
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Barkod en az 8 karakter olmalıdır'
|
||||
},
|
||||
maxLength: {
|
||||
value: 20,
|
||||
message: 'Barkod en fazla 20 karakter olabilir'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Barkod"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!editErrors.barcode}
|
||||
helperText={editErrors.barcode?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditDialogOpen(false)}>
|
||||
İptal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={updateProductMutation.isPending}
|
||||
>
|
||||
{updateProductMutation.isPending ? 'Güncelleniyor...' : 'Ürün Güncelle'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
{/* Product Menu */}
|
||||
<Menu
|
||||
anchorEl={menuAnchor}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (selectedProduct) {
|
||||
openEditDialog(selectedProduct);
|
||||
}
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
<Edit sx={{ mr: 1 }} />
|
||||
Düzenle
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleDeleteProduct}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<Delete sx={{ mr: 1 }} />
|
||||
Sil
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductsPage;
|
||||
1
frontend/src/pages/Products/index.ts
Normal file
1
frontend/src/pages/Products/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ProductsPage } from './ProductsPage';
|
||||
632
frontend/src/pages/Profile/ProfilePage.tsx
Normal file
632
frontend/src/pages/Profile/ProfilePage.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
TextField,
|
||||
Avatar,
|
||||
Divider,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Tab,
|
||||
Tabs,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit,
|
||||
Save,
|
||||
Cancel,
|
||||
Person,
|
||||
Security,
|
||||
Notifications,
|
||||
Delete,
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { usersAPI, notificationsAPI } from '../../services/api';
|
||||
import { User, UserSettings } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const profileSchema = yup.object({
|
||||
firstName: yup.string().required('Ad gereklidir').min(2, 'Ad en az 2 karakter olmalıdır'),
|
||||
lastName: yup.string().required('Soyad gereklidir').min(2, 'Soyad en az 2 karakter olmalıdır'),
|
||||
email: yup.string().required('E-posta gereklidir').email('Geçersiz e-posta formatı'),
|
||||
});
|
||||
|
||||
const passwordSchema = yup.object({
|
||||
currentPassword: yup.string().required('Mevcut şifre gereklidir'),
|
||||
newPassword: yup.string().required('Yeni şifre gereklidir').min(6, 'Şifre en az 6 karakter olmalıdır'),
|
||||
confirmPassword: yup.string()
|
||||
.required('Şifre onayı gereklidir')
|
||||
.oneOf([yup.ref('newPassword')], 'Şifreler eşleşmelidir'),
|
||||
});
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function TabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`profile-tabpanel-${index}`}
|
||||
aria-labelledby={`profile-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const { user, updateProfile, changePassword } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [editingProfile, setEditingProfile] = useState(false);
|
||||
const [changePasswordDialogOpen, setChangePasswordDialogOpen] = useState(false);
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
// Fetch user settings
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['userSettings'],
|
||||
queryFn: () => usersAPI.getUserSettings(),
|
||||
});
|
||||
|
||||
// Fetch user activities - temporarily disabled
|
||||
// const { data: activitiesData } = useQuery({
|
||||
// queryKey: ['userActivities'],
|
||||
// queryFn: () => usersAPI.getUserActivities({ limit: 10 }),
|
||||
// });
|
||||
const activitiesData = { data: [] };
|
||||
|
||||
// Update profile mutation
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: (data: Partial<User>) => updateProfile(data),
|
||||
onSuccess: () => {
|
||||
toast.success('Profil başarıyla güncellendi!');
|
||||
setEditingProfile(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Profil güncellenemedi');
|
||||
},
|
||||
});
|
||||
|
||||
// Change password mutation
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
|
||||
changePassword(data.currentPassword, data.newPassword),
|
||||
onSuccess: () => {
|
||||
toast.success('Şifre başarıyla değiştirildi!');
|
||||
setChangePasswordDialogOpen(false);
|
||||
passwordReset();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Şifre değiştirilemedi');
|
||||
},
|
||||
});
|
||||
|
||||
// Update settings mutation with optimistic cache sync to avoid flicker
|
||||
const updateSettingsMutation = useMutation({
|
||||
mutationFn: (data: Partial<UserSettings>) => usersAPI.updateUserSettings(data),
|
||||
onSuccess: (res) => {
|
||||
const serverSettings = (res?.data?.data?.settings ?? {}) as Partial<UserSettings>;
|
||||
// Update local state to the authoritative server value
|
||||
setLocalSettings(serverSettings);
|
||||
// Update the react-query cache so refetch won't revert UI
|
||||
queryClient.setQueryData(['userSettings'], (prev: any) => {
|
||||
if (!prev) return res;
|
||||
try {
|
||||
const next = {
|
||||
...prev,
|
||||
data: {
|
||||
...prev.data,
|
||||
data: {
|
||||
...(prev?.data?.data || {}),
|
||||
settings: serverSettings,
|
||||
},
|
||||
},
|
||||
};
|
||||
return next;
|
||||
} catch {
|
||||
return res;
|
||||
}
|
||||
});
|
||||
toast.success('Ayarlar başarıyla güncellendi!');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Ayarlar güncellenemedi');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
control: profileControl,
|
||||
handleSubmit: handleProfileSubmit,
|
||||
reset: profileReset,
|
||||
formState: { errors: profileErrors },
|
||||
} = useForm({
|
||||
resolver: yupResolver(profileSchema),
|
||||
defaultValues: {
|
||||
firstName: user?.firstName || '',
|
||||
lastName: user?.lastName || '',
|
||||
email: user?.email || '',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
control: passwordControl,
|
||||
handleSubmit: handlePasswordSubmit,
|
||||
reset: passwordReset,
|
||||
formState: { errors: passwordErrors },
|
||||
} = useForm({
|
||||
resolver: yupResolver(passwordSchema),
|
||||
defaultValues: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Backend returns { success, data: { settings } }
|
||||
const settings = (settingsData?.data?.data?.settings ?? {}) as Partial<UserSettings>;
|
||||
const [localSettings, setLocalSettings] = useState<Partial<UserSettings>>({});
|
||||
const activities = activitiesData?.data || [];
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
const handleUpdateProfile = (data: any) => {
|
||||
updateProfileMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleChangePassword = (data: any) => {
|
||||
changePasswordMutation.mutate({
|
||||
currentPassword: data.currentPassword,
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSettingChange = async (key: keyof UserSettings, value: boolean) => {
|
||||
const previous = localSettings[key];
|
||||
setLocalSettings((prev) => ({ ...prev, [key]: value }));
|
||||
try {
|
||||
await updateSettingsMutation.mutateAsync({ [key]: value } as Partial<UserSettings>);
|
||||
} catch (error) {
|
||||
// Revert optimistic update on error
|
||||
setLocalSettings((prev) => ({ ...prev, [key]: previous as boolean }));
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
// Avoid overriding optimistic state while a mutation is pending
|
||||
if (!updateSettingsMutation.isPending) {
|
||||
setLocalSettings(settings);
|
||||
}
|
||||
}, [settings, updateSettingsMutation.isPending]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user) {
|
||||
profileReset({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
});
|
||||
}
|
||||
}, [user, profileReset]);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||
<Typography>Yükleniyor...</Typography>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 4 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
mr: 3,
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '2rem',
|
||||
}}
|
||||
>
|
||||
{user.firstName?.[0] || ''}{user.lastName?.[0] || ''}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{user.firstName || ''} {user.lastName || ''}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{user.email}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Üye olma tarihi: {new Date(user.createdAt).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Tabs */}
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
aria-label="profile tabs"
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab icon={<Person />} label="Profil" />
|
||||
<Tab icon={<Security />} label="Güvenlik" />
|
||||
<Tab icon={<Notifications />} label="Bildirimler" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Profile Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '2fr 1fr' }, gap: 3 }}>
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6">Kişisel Bilgiler</Typography>
|
||||
{!editingProfile ? (
|
||||
<Button
|
||||
startIcon={<Edit />}
|
||||
onClick={() => setEditingProfile(true)}
|
||||
>
|
||||
Düzenle
|
||||
</Button>
|
||||
) : (
|
||||
<Box>
|
||||
<Button
|
||||
startIcon={<Cancel />}
|
||||
onClick={() => {
|
||||
setEditingProfile(false);
|
||||
profileReset();
|
||||
}}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
İptal
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Save />}
|
||||
onClick={handleProfileSubmit(handleUpdateProfile)}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
Kaydet
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleProfileSubmit(handleUpdateProfile)}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2 }}>
|
||||
<Box>
|
||||
<Controller
|
||||
name="firstName"
|
||||
control={profileControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Ad"
|
||||
fullWidth
|
||||
disabled={!editingProfile}
|
||||
error={!!profileErrors.firstName}
|
||||
helperText={profileErrors.firstName?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Controller
|
||||
name="lastName"
|
||||
control={profileControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Soyad"
|
||||
fullWidth
|
||||
disabled={!editingProfile}
|
||||
error={!!profileErrors.lastName}
|
||||
helperText={profileErrors.lastName?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: '1 / -1' }}>
|
||||
<Controller
|
||||
name="email"
|
||||
control={profileControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="E-posta"
|
||||
fullWidth
|
||||
disabled={!editingProfile}
|
||||
error={!!profileErrors.email}
|
||||
helperText={profileErrors.email?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Son Aktiviteler
|
||||
</Typography>
|
||||
<List>
|
||||
{activities.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Son aktivite bulunmuyor
|
||||
</Typography>
|
||||
) : (
|
||||
activities.map((activity: any, index: number) => (
|
||||
<ListItem key={index} divider={index < activities.length - 1}>
|
||||
<ListItemText
|
||||
primary={activity.description}
|
||||
secondary={formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}
|
||||
/>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Security Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3 }}>
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Şifre
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Güçlü bir şifre kullanarak hesabınızı güvende tutun
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setChangePasswordDialogOpen(true)}
|
||||
>
|
||||
Şifre Değiştir
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Hesap Durumu
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ mr: 2 }}>
|
||||
Hesap Durumu:
|
||||
</Typography>
|
||||
<Chip
|
||||
label={user.isActive ? 'Aktif' : 'Pasif'}
|
||||
color={user.isActive ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Notifications Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Bildirim Tercihleri
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Hangi bildirimleri almak istediğinizi seçin
|
||||
</Typography>
|
||||
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="E-posta Bildirimleri"
|
||||
secondary="E-posta yoluyla bildirim alın"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={Boolean(localSettings.emailNotifications)}
|
||||
onChange={(e) => handleSettingChange('emailNotifications', e.target.checked)}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Anlık Bildirimler"
|
||||
secondary="Cihazınızda anlık bildirimler alın"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={Boolean(localSettings.pushNotifications)}
|
||||
onChange={(e) => handleSettingChange('pushNotifications', e.target.checked)}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Liste Güncellemeleri"
|
||||
secondary="Paylaşılan listeler güncellendiğinde bildirim alın"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={Boolean(localSettings.itemUpdateNotifications)}
|
||||
onChange={(e) => handleSettingChange('itemUpdateNotifications', e.target.checked)}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
|
||||
{/* Pazarlama E-postaları kaldırıldı */}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
{/* Change Password Dialog */}
|
||||
<Dialog
|
||||
open={changePasswordDialogOpen}
|
||||
onClose={() => setChangePasswordDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Şifre Değiştir</DialogTitle>
|
||||
<form onSubmit={handlePasswordSubmit(handleChangePassword)}>
|
||||
<DialogContent>
|
||||
<Controller
|
||||
name="currentPassword"
|
||||
control={passwordControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Mevcut Şifre"
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!passwordErrors.currentPassword}
|
||||
helperText={passwordErrors.currentPassword?.message}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showCurrentPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="newPassword"
|
||||
control={passwordControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Yeni Şifre"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!passwordErrors.newPassword}
|
||||
helperText={passwordErrors.newPassword?.message}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showNewPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="confirmPassword"
|
||||
control={passwordControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Yeni Şifre Tekrar"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!passwordErrors.confirmPassword}
|
||||
helperText={passwordErrors.confirmPassword?.message}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setChangePasswordDialogOpen(false)}>
|
||||
İptal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={changePasswordMutation.isPending}
|
||||
>
|
||||
{changePasswordMutation.isPending ? 'Değiştiriliyor...' : 'Şifre Değiştir'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
1
frontend/src/pages/Profile/index.ts
Normal file
1
frontend/src/pages/Profile/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ProfilePage } from './ProfilePage';
|
||||
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
312
frontend/src/services/api.ts
Normal file
312
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import {
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
ListsResponse,
|
||||
UsersResponse,
|
||||
User,
|
||||
ShoppingList,
|
||||
ListItem,
|
||||
Product,
|
||||
Category,
|
||||
Notification,
|
||||
Activity,
|
||||
DashboardStats,
|
||||
AdminStats,
|
||||
LoginForm,
|
||||
RegisterForm,
|
||||
CreateListForm,
|
||||
UpdateListForm,
|
||||
CreateItemForm,
|
||||
UpdateItemForm,
|
||||
CreateProductForm,
|
||||
CreateCategoryForm,
|
||||
ListFilters,
|
||||
ItemFilters,
|
||||
ProductFilters,
|
||||
UserSettings,
|
||||
} from '../types';
|
||||
|
||||
// Dinamik API URL belirleme
|
||||
const getApiBaseUrl = (): string => {
|
||||
// Önce environment variable'ı kontrol et
|
||||
if (process.env.REACT_APP_API_URL) {
|
||||
return process.env.REACT_APP_API_URL;
|
||||
}
|
||||
|
||||
// Eğer localhost'ta çalışıyorsa localhost:7001 kullan
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
return 'http://localhost:7001/api';
|
||||
}
|
||||
|
||||
// Uzak erişimde aynı host'u kullan ama port 7001
|
||||
return `http://${window.location.hostname}:7001/api`;
|
||||
};
|
||||
|
||||
// Create axios instance
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor to handle errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
login: (data: LoginForm): Promise<AxiosResponse<ApiResponse<{ user: User; token: string }>>> =>
|
||||
api.post('/auth/login', data),
|
||||
|
||||
register: (data: RegisterForm): Promise<AxiosResponse<ApiResponse<{ user: User; token: string }>>> =>
|
||||
api.post('/auth/register', data),
|
||||
|
||||
getProfile: (): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.get('/auth/profile'),
|
||||
|
||||
updateProfile: (data: Partial<User>): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.put('/auth/profile', data),
|
||||
|
||||
changePassword: (data: { currentPassword: string; newPassword: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put('/auth/change-password', data),
|
||||
|
||||
forgotPassword: (email: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post('/auth/forgot-password', { email }),
|
||||
|
||||
resetPassword: (data: { token: string; password: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post('/auth/reset-password', data),
|
||||
};
|
||||
|
||||
// Users API
|
||||
export const usersAPI = {
|
||||
getUsers: (params?: { page?: number; limit?: number; search?: string; status?: 'active' | 'inactive' }): Promise<AxiosResponse<UsersResponse>> =>
|
||||
api.get('/users', { params }),
|
||||
|
||||
getUser: (id: string): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.get(`/users/${id}`),
|
||||
|
||||
updateUser: (id: string, data: Partial<User>): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.put(`/users/${id}`, data),
|
||||
|
||||
deleteUser: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/users/${id}`),
|
||||
|
||||
updateUserStatus: (id: string, isActive: boolean): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.put(`/users/${id}/status`, { isActive }),
|
||||
|
||||
setUserAdmin: (id: string, isAdmin: boolean): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.put(`/users/${id}/admin`, { isAdmin }),
|
||||
|
||||
resetUserPassword: (id: string, newPassword: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put(`/users/${id}/password`, { newPassword }),
|
||||
|
||||
getUserSettings: (): Promise<AxiosResponse<ApiResponse<{ settings: UserSettings }>>> =>
|
||||
api.get('/users/settings'),
|
||||
|
||||
updateUserSettings: (data: Partial<UserSettings>): Promise<AxiosResponse<ApiResponse<{ settings: UserSettings }>>> =>
|
||||
api.put('/users/settings', data),
|
||||
};
|
||||
|
||||
// Lists API
|
||||
export const listsAPI = {
|
||||
getLists: (params?: ListFilters & { page?: number; limit?: number }): Promise<AxiosResponse<ListsResponse>> =>
|
||||
api.get('/lists', { params }),
|
||||
|
||||
getList: (id: string): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||
api.get(`/lists/${id}`),
|
||||
|
||||
createList: (data: CreateListForm): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||
api.post('/lists', data),
|
||||
|
||||
updateList: (id: string, data: UpdateListForm): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||
api.put(`/lists/${id}`, data),
|
||||
|
||||
deleteList: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/lists/${id}`),
|
||||
|
||||
getListMembers: (id: string): Promise<AxiosResponse<ApiResponse<{ members: any[] }>>> =>
|
||||
api.get(`/lists/${id}/members`),
|
||||
|
||||
addListMember: (id: string, data: { email: string; role: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post(`/lists/${id}/members`, data),
|
||||
|
||||
updateListMember: (id: string, memberId: string, data: { role: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put(`/lists/${id}/members/${memberId}`, data),
|
||||
|
||||
removeListMember: (id: string, memberId: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/lists/${id}/members/${memberId}`),
|
||||
|
||||
shareList: (id: string, data: { emails: string[]; message?: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post(`/lists/${id}/share`, data),
|
||||
|
||||
duplicateList: (id: string, data: { name: string; includeItems: boolean }): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||
api.post(`/lists/${id}/duplicate`, data),
|
||||
};
|
||||
|
||||
// Items API
|
||||
export const itemsAPI = {
|
||||
getListItems: (listId: string, params?: ItemFilters & { page?: number; limit?: number }): Promise<AxiosResponse<PaginatedResponse<ListItem>>> =>
|
||||
api.get(`/items/${listId}`, { params }),
|
||||
|
||||
getItem: (id: string): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||
api.get(`/items/${id}`),
|
||||
|
||||
createItem: (listId: string, data: CreateItemForm): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||
api.post(`/items/${listId}`, data),
|
||||
|
||||
updateItem: (listId: string, itemId: string, data: UpdateItemForm): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||
api.put(`/items/${listId}/${itemId}`, data),
|
||||
|
||||
deleteItem: (listId: string, itemId: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/items/${listId}/${itemId}`),
|
||||
|
||||
bulkUpdateItems: (data: { itemIds: string[]; updates: Partial<ListItem> }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put('/items/bulk', data),
|
||||
|
||||
bulkDeleteItems: (itemIds: string[]): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete('/items/bulk', { data: { itemIds } }),
|
||||
|
||||
purchaseItem: (id: string, data?: { actualPrice?: number }): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||
api.put(`/items/${id}/purchase`, data),
|
||||
|
||||
unpurchaseItem: (id: string): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||
api.put(`/items/${id}/unpurchase`),
|
||||
};
|
||||
|
||||
// Products API
|
||||
export const productsAPI = {
|
||||
getProducts: (params?: ProductFilters & { page?: number; limit?: number }): Promise<AxiosResponse<ApiResponse<{ products: Product[] }>>> =>
|
||||
api.get('/products', { params }),
|
||||
|
||||
getProduct: (id: string): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||
api.get(`/products/${id}`),
|
||||
|
||||
createProduct: (data: CreateProductForm): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||
api.post('/products', data),
|
||||
|
||||
updateProduct: (productId: string, data: Partial<CreateProductForm>): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||
api.put(`/products/${productId}`, data),
|
||||
|
||||
deleteProduct: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/products/${id}`),
|
||||
|
||||
searchProducts: (query: string): Promise<AxiosResponse<ApiResponse<{ products: Product[] }>>> =>
|
||||
api.get(`/products/search?q=${encodeURIComponent(query)}`),
|
||||
|
||||
getProductByBarcode: (barcode: string): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||
api.get(`/products/barcode/${barcode}`),
|
||||
|
||||
addPriceHistory: (id: string, data: { price: number; store?: string; location?: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post(`/products/${id}/price-history`, data),
|
||||
};
|
||||
|
||||
// Categories API
|
||||
export const categoriesAPI = {
|
||||
getCategories: (params?: { page?: number; limit?: number; search?: string; parentId?: string; includeInactive?: boolean }): Promise<AxiosResponse<ApiResponse<{ categories: Category[] }>>> =>
|
||||
api.get('/categories', { params }),
|
||||
|
||||
getCategory: (id: string): Promise<AxiosResponse<ApiResponse<{ category: Category }>>> =>
|
||||
api.get(`/categories/${id}`),
|
||||
|
||||
createCategory: (data: CreateCategoryForm): Promise<AxiosResponse<ApiResponse<{ category: Category }>>> =>
|
||||
api.post('/categories', data),
|
||||
|
||||
updateCategory: (id: string, data: Partial<CreateCategoryForm>): Promise<AxiosResponse<ApiResponse<{ category: Category }>>> =>
|
||||
api.put(`/categories/${id}`, data),
|
||||
|
||||
deleteCategory: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/categories/${id}`),
|
||||
|
||||
getCategoryTree: (): Promise<AxiosResponse<ApiResponse<{ categories: Category[] }>>> =>
|
||||
api.get('/categories/tree'),
|
||||
};
|
||||
|
||||
// Notifications API
|
||||
export const notificationsAPI = {
|
||||
getNotifications: (params?: { page?: number; limit?: number; isRead?: boolean; type?: string }): Promise<AxiosResponse<PaginatedResponse<Notification>>> =>
|
||||
api.get('/notifications', { params }),
|
||||
|
||||
markAsRead: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put(`/notifications/${id}/read`),
|
||||
|
||||
markAllAsRead: (): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put('/notifications/read-all'),
|
||||
|
||||
deleteNotification: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/notifications/${id}`),
|
||||
|
||||
clearAllRead: (): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete('/notifications/clear-read'),
|
||||
|
||||
getUnreadCount: (): Promise<AxiosResponse<ApiResponse<{ count: number }>>> =>
|
||||
api.get('/notifications/unread-count'),
|
||||
|
||||
updateSettings: (data: any): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put('/notifications/settings', data),
|
||||
|
||||
registerDevice: (data: { token: string; platform: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post('/notifications/device', data),
|
||||
|
||||
unregisterDevice: (token: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/notifications/device/${token}`),
|
||||
};
|
||||
|
||||
// Dashboard API
|
||||
export const dashboardAPI = {
|
||||
getStats: (): Promise<AxiosResponse<ApiResponse<DashboardStats>>> =>
|
||||
api.get('/dashboard/stats'),
|
||||
|
||||
getRecentActivity: (params?: { limit?: number }): Promise<AxiosResponse<ApiResponse<{ activities: Activity[] }>>> =>
|
||||
api.get('/dashboard/activity', { params }),
|
||||
};
|
||||
|
||||
// Admin API
|
||||
export const adminAPI = {
|
||||
getStats: (): Promise<AxiosResponse<ApiResponse<AdminStats>>> =>
|
||||
api.get('/admin/dashboard'),
|
||||
|
||||
getRecentActivity: (params?: { page?: number; limit?: number; type?: string; userId?: string }): Promise<AxiosResponse<PaginatedResponse<Activity>>> =>
|
||||
api.get('/admin/activities', { params }),
|
||||
|
||||
getSystemSettings: (): Promise<AxiosResponse<ApiResponse<{ settings: any }>>> =>
|
||||
api.get('/admin/settings'),
|
||||
|
||||
updateSystemSettings: (data: any): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put('/admin/settings', data),
|
||||
|
||||
getSystemStatus: (): Promise<AxiosResponse<ApiResponse<{ status: any }>>> =>
|
||||
api.get('/admin/status'),
|
||||
|
||||
backupDatabase: (): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post('/admin/backup'),
|
||||
|
||||
getSystemLogs: (params?: { level?: string; limit?: number }): Promise<AxiosResponse<ApiResponse<{ logs: any[] }>>> =>
|
||||
api.get('/admin/logs', { params }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
372
frontend/src/types/index.ts
Normal file
372
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
// User types
|
||||
export interface User {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
role: 'USER' | 'ADMIN';
|
||||
isActive: boolean;
|
||||
avatar?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
settings?: UserSettings;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
id: string;
|
||||
userId: string;
|
||||
emailNotifications: boolean;
|
||||
pushNotifications: boolean;
|
||||
listInviteNotifications: boolean;
|
||||
itemUpdateNotifications: boolean;
|
||||
priceAlertNotifications: boolean;
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
language: string;
|
||||
currency: string;
|
||||
timezone: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// List types
|
||||
export interface ShoppingList {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
isShared: boolean;
|
||||
ownerId: string;
|
||||
owner: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
members: ListMember[];
|
||||
items: ListItem[];
|
||||
_count?: {
|
||||
items: number;
|
||||
members: number;
|
||||
completedItems: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListMember {
|
||||
id: string;
|
||||
listId: string;
|
||||
userId: string;
|
||||
user: User;
|
||||
role: 'OWNER' | 'EDITOR' | 'VIEWER';
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
id: string;
|
||||
listId: string;
|
||||
list?: ShoppingList;
|
||||
name: string;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
|
||||
actualPrice?: number;
|
||||
isPurchased: boolean;
|
||||
purchasedAt?: string;
|
||||
purchasedBy?: string;
|
||||
purchaser?: User;
|
||||
productId?: string;
|
||||
product?: Product;
|
||||
categoryId?: string;
|
||||
category?: Category;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
notes?: string;
|
||||
imageUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
creator: User;
|
||||
}
|
||||
|
||||
// Product types
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
barcode?: string;
|
||||
brand?: string;
|
||||
unit?: string;
|
||||
categoryId?: string;
|
||||
category?: Category;
|
||||
averagePrice?: number;
|
||||
imageUrl?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
priceHistory: PriceHistory[];
|
||||
}
|
||||
|
||||
export interface PriceHistory {
|
||||
id: string;
|
||||
productId: string;
|
||||
product?: Product;
|
||||
price: number;
|
||||
store?: string;
|
||||
location?: string;
|
||||
userId?: string;
|
||||
user?: User;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
// Category types
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
parentId?: string;
|
||||
parent?: Category;
|
||||
children?: Category[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Notification types
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
user?: User;
|
||||
type: 'LIST_INVITE' | 'LIST_UPDATE' | 'ITEM_UPDATE' | 'PRICE_ALERT' | 'SYSTEM';
|
||||
title: string;
|
||||
message: string;
|
||||
data?: any;
|
||||
isRead: boolean;
|
||||
readAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Activity types
|
||||
export interface Activity {
|
||||
id: string;
|
||||
userId: string;
|
||||
user: User;
|
||||
type: 'LIST_CREATED' | 'LIST_UPDATED' | 'LIST_DELETED' | 'ITEM_ADDED' | 'ITEM_UPDATED' | 'ITEM_DELETED' | 'MEMBER_ADDED' | 'MEMBER_REMOVED';
|
||||
description: string;
|
||||
metadata?: any;
|
||||
listId?: string;
|
||||
list?: ShoppingList;
|
||||
itemId?: string;
|
||||
item?: ListItem;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T = any> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListsResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data: {
|
||||
lists: ShoppingList[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data: {
|
||||
users: User[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Form types
|
||||
export interface LoginForm {
|
||||
login: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterForm {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface CreateListForm {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
isShared: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateListForm {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateItemForm {
|
||||
name: string;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
|
||||
productId?: string;
|
||||
categoryId?: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateItemForm {
|
||||
name?: string;
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
|
||||
actualPrice?: number;
|
||||
isPurchased?: boolean;
|
||||
categoryId?: string;
|
||||
priority?: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateProductForm {
|
||||
name: string;
|
||||
categoryId: string;
|
||||
barcode?: string;
|
||||
description?: string;
|
||||
brand?: string;
|
||||
unit?: string;
|
||||
averagePrice?: number;
|
||||
price?: number;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface CreateCategoryForm {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
// Filter and search types
|
||||
export interface ListFilters {
|
||||
search?: string;
|
||||
isShared?: boolean;
|
||||
ownerId?: string;
|
||||
sortBy?: 'name' | 'createdAt' | 'updatedAt';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ItemFilters {
|
||||
search?: string;
|
||||
purchased?: string;
|
||||
categoryId?: string;
|
||||
priority?: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
sortBy?: 'name' | 'createdAt' | 'priority';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ProductFilters {
|
||||
search?: string;
|
||||
categoryId?: string;
|
||||
brand?: string;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
sortBy?: 'name' | 'averagePrice' | 'createdAt';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Dashboard types
|
||||
export interface DashboardStats {
|
||||
totalLists: number;
|
||||
totalItems: number;
|
||||
completedItems: number;
|
||||
pendingItems: number;
|
||||
sharedLists: number;
|
||||
recentActivity: Activity[];
|
||||
}
|
||||
|
||||
// Admin types
|
||||
export interface AdminStats {
|
||||
users: {
|
||||
total: number;
|
||||
active: number;
|
||||
new: number;
|
||||
};
|
||||
lists: {
|
||||
total: number;
|
||||
shared: number;
|
||||
private: number;
|
||||
};
|
||||
products: {
|
||||
total: number;
|
||||
active: number;
|
||||
};
|
||||
categories: {
|
||||
total: number;
|
||||
active: number;
|
||||
};
|
||||
notifications: {
|
||||
total: number;
|
||||
unread: number;
|
||||
};
|
||||
activities: {
|
||||
total: number;
|
||||
today: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Socket event types
|
||||
export interface SocketEvents {
|
||||
// List events
|
||||
'list-updated': { list: ShoppingList };
|
||||
'list-deleted': { listId: string };
|
||||
'list-member-added': { list: ShoppingList; member: ListMember };
|
||||
'list-member-removed': { list: ShoppingList; memberId: string };
|
||||
'list-invitation': { list: ShoppingList; inviter: User };
|
||||
|
||||
// Item events
|
||||
'list-item-added': { list: ShoppingList; item: ListItem };
|
||||
'list-item-updated': { list: ShoppingList; item: ListItem };
|
||||
'list-item-removed': { list: ShoppingList; item: ListItem };
|
||||
|
||||
// Notification events
|
||||
'notification': Notification;
|
||||
|
||||
// Error events
|
||||
'error': { message: string; code?: string };
|
||||
}
|
||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user