hMarket Trae ilk versiyon

This commit is contained in:
hOLOlu
2026-02-03 01:22:08 +03:00
commit 2b861156fe
74 changed files with 42127 additions and 0 deletions

46
frontend/README.md Normal file
View 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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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/).

17174
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
frontend/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View 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="Web site created using create-react-app"
/>
<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>React App</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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"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"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

202
frontend/src/App.tsx Normal file
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,3 @@
export { default as ShoppingListItem } from './ShoppingListItem';
export { default as ShoppingListHeader } from './ShoppingListHeader';
export { default as ShoppingListFilters } from './ShoppingListFilters';

View 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>;
};

View 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
View 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
View 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>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { default as AdminPage } from './AdminPage';

View File

@@ -0,0 +1,287 @@
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>
</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;

View File

@@ -0,0 +1,370 @@
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">
By creating an account, you agree to our{' '}
<Link href="#" sx={{ textDecoration: 'none' }}>
Terms of Service
</Link>{' '}
and{' '}
<Link href="#" sx={{ textDecoration: 'none' }}>
Privacy Policy
</Link>
</Typography>
</Box>
</Box>
</Container>
);
};
export default RegisterPage;

View File

@@ -0,0 +1,2 @@
export { default as LoginPage } from './LoginPage';
export { default as RegisterPage } from './RegisterPage';

View File

@@ -0,0 +1,331 @@
import React from 'react';
import {
Container,
Typography,
Box,
Card,
CardContent,
CardActions,
Button,
List,
ListItem,
ListItemText,
ListItemIcon,
Avatar,
LinearProgress,
IconButton,
} 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>
</Container>
);
};
export default DashboardPage;

View File

@@ -0,0 +1 @@
export { default as DashboardPage } from './DashboardPage';

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View File

@@ -0,0 +1,3 @@
export { default as ListsPage } from './ListsPage';
export { default as ListDetailPage } from './ListDetailPage';
export { default as ListEditPage } from './ListEditPage';

View 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;

View File

@@ -0,0 +1 @@
export { default as ProductsPage } from './ProductsPage';

View 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;

View File

@@ -0,0 +1 @@
export { default as ProfilePage } from './ProfilePage';

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View 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
View 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
View 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"
]
}