hPiBot openclaw ve Opencode ilk versiyonu[B

This commit is contained in:
2026-03-04 05:17:51 +03:00
commit d49edbfba3
75 changed files with 42117 additions and 0 deletions

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,308 @@
import React, { useState } from 'react';
import {
Container,
Paper,
Box,
Typography,
TextField,
Button,
Link,
Alert,
InputAdornment,
IconButton,
Divider,
} from '@mui/material';
import {
Visibility,
VisibilityOff,
Email,
Lock,
ShoppingCart,
Google,
} from '@mui/icons-material';
import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { LoginForm } from '../../types';
import toast from 'react-hot-toast';
const schema = yup.object({
login: yup
.string()
.required('E-posta veya kullanıcı adı gerekli'),
password: yup
.string()
.min(6, 'Şifre en az 6 karakter olmalı')
.required('Şifre gerekli'),
});
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { login, isLoading } = useAuth();
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string>('');
const from = (location.state as any)?.from?.pathname || '/dashboard';
const {
control,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: yupResolver(schema),
defaultValues: {
login: '',
password: '',
},
});
const onSubmit = async (data: LoginForm) => {
try {
setError('');
await login(data.login, data.password);
toast.success('Başarıyla giriş yapıldı!');
navigate(from, { replace: true });
} catch (err: any) {
const errorMessage = err.response?.data?.message || 'Giriş başarısız. Lütfen tekrar deneyin.';
setError(errorMessage);
toast.error(errorMessage);
}
};
const handleClickShowPassword = () => {
setShowPassword(!showPassword);
};
const handleGoogleLogin = () => {
// Backend'deki Google OAuth endpoint'ine yönlendir
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:7001/api';
window.location.href = `${apiUrl}/auth/google`;
};
return (
<Container component="main" maxWidth="sm">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Paper
elevation={3}
sx={{
padding: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
}}
>
{/* Logo and Title */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<ShoppingCart sx={{ fontSize: 40, color: 'primary.main', mr: 1 }} />
<Typography component="h1" variant="h4" color="primary.main" fontWeight="bold">
hMarket
</Typography>
</Box>
<Typography component="h2" variant="h5" sx={{ mb: 3 }}>
Giriş Yap
</Typography>
{error && (
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ width: '100%' }}>
<Controller
name="login"
control={control}
render={({ field }) => (
<TextField
{...field}
margin="normal"
required
fullWidth
id="login"
label="E-posta veya Kullanıcı Adı"
autoComplete="username"
autoFocus
error={!!errors.login}
helperText={errors.login?.message}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Email />
</InputAdornment>
),
}}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field }) => (
<TextField
{...field}
margin="normal"
required
fullWidth
label="Şifre"
type={showPassword ? 'text' : 'password'}
id="password"
autoComplete="current-password"
error={!!errors.password}
helperText={errors.password?.message}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2, py: 1.5 }}
disabled={isLoading}
>
{isLoading ? 'Giriş Yapılıyor...' : 'Giriş Yap'}
</Button>
<Divider sx={{ my: 3 }}>
<Typography variant="body2" color="text.secondary">
VEYA
</Typography>
</Divider>
<Button
fullWidth
variant="outlined"
onClick={handleGoogleLogin}
disabled={isLoading}
startIcon={<Google />}
sx={{
mb: 2,
py: 1.5,
borderColor: '#db4437',
color: '#db4437',
'&:hover': {
borderColor: '#c23321',
backgroundColor: 'rgba(219, 68, 55, 0.04)'
}
}}
>
Google ile Giriş Yap
</Button>
<Box sx={{ textAlign: 'center', mt: 2 }}>
<Link
component="button"
variant="body2"
onClick={() => navigate('/forgot-password')}
sx={{ textDecoration: 'none' }}
>
Şifremi unuttum?
</Link>
</Box>
<Box sx={{ textAlign: 'center', mt: 2 }}>
<Typography variant="body2" color="text.secondary">
Hesabınız yok mu?{' '}
<Link
component="button"
variant="body2"
onClick={() => navigate('/register')}
sx={{ textDecoration: 'none', fontWeight: 'bold' }}
>
Kayıt Ol
</Link>
</Typography>
</Box>
<Box sx={{ textAlign: 'center', mt: 3, pt: 2, borderTop: '1px solid rgba(0,0,0,0.05)', display: 'flex', justifyContent: 'center', gap: 2 }}>
<Link
href="https://www.mustafaozkaya.tr/hmarket-kullanim-sartlari/"
target="_blank"
rel="noopener"
variant="caption"
sx={{ textDecoration: 'none', color: 'text.secondary' }}
>
Kullanım Şartları
</Link>
<Link
href="https://www.mustafaozkaya.tr/hmarket-gizlilik-politikasi/"
target="_blank"
rel="noopener"
variant="caption"
sx={{ textDecoration: 'none', color: 'text.secondary' }}
>
Gizlilik Politikası
</Link>
</Box>
</Box>
</Paper>
{/* Features */}
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Typography variant="h6" gutterBottom>
Neden hMarket?
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 4, mt: 2 }}>
<Box>
<Typography variant="body2" fontWeight="bold">
📝 Akıllı Listeler
</Typography>
<Typography variant="body2" color="text.secondary">
Alışveriş listeleri oluştur ve paylaş
</Typography>
</Box>
<Box>
<Typography variant="body2" fontWeight="bold">
🔄 Gerçek Zamanlı Senkronizasyon
</Typography>
<Typography variant="body2" color="text.secondary">
Tüm cihazlarda güncellemeler
</Typography>
</Box>
<Box>
<Typography variant="body2" fontWeight="bold">
💰 Fiyat Takibi
</Typography>
<Typography variant="body2" color="text.secondary">
Fiyatları takip et ve karşılaştır
</Typography>
</Box>
</Box>
</Box>
</Box>
</Container>
);
};
export default LoginPage;

View File

@@ -0,0 +1,381 @@
import React, { useState } from 'react';
import {
Container,
Paper,
Box,
Typography,
TextField,
Button,
Link,
Alert,
InputAdornment,
IconButton,
} from '@mui/material';
import {
Visibility,
VisibilityOff,
Email,
Lock,
Person,
ShoppingCart,
Google,
} from '@mui/icons-material';
import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { RegisterForm } from '../../types';
import toast from 'react-hot-toast';
const schema = yup.object({
firstName: yup
.string()
.required('Ad gerekli')
.min(2, 'Ad en az 2 karakter olmalı'),
lastName: yup
.string()
.required('Soyad gerekli')
.min(2, 'Soyad en az 2 karakter olmalı'),
email: yup
.string()
.email('Lütfen geçerli bir e-posta adresi girin')
.required('E-posta gerekli'),
password: yup
.string()
.min(6, 'Şifre en az 6 karakter olmalı')
.matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Şifre en az bir büyük harf, bir küçük harf ve bir rakam içermeli'
)
.required('Şifre gerekli'),
confirmPassword: yup
.string()
.oneOf([yup.ref('password')], 'Şifreler eşleşmeli')
.required('Lütfen şifrenizi onaylayın'),
});
const RegisterPage: React.FC = () => {
const navigate = useNavigate();
const { register, isLoading } = useAuth();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState<string>('');
const {
control,
handleSubmit,
formState: { errors },
} = useForm<RegisterForm>({
resolver: yupResolver(schema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
},
});
const onSubmit = async (data: RegisterForm) => {
try {
setError('');
await register({
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
password: data.password,
confirmPassword: data.confirmPassword,
});
toast.success('Hesap başarıyla oluşturuldu!');
navigate('/dashboard');
} catch (err: any) {
const errorMessage = err.response?.data?.message || 'Kayıt başarısız. Lütfen tekrar deneyin.';
setError(errorMessage);
toast.error(errorMessage);
}
};
const handleClickShowPassword = () => {
setShowPassword(!showPassword);
};
const handleClickShowConfirmPassword = () => {
setShowConfirmPassword(!showConfirmPassword);
};
const handleGoogleLogin = () => {
// Backend'deki Google OAuth endpoint'ine yönlendir
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:7001/api';
window.location.href = `${apiUrl}/auth/google`;
};
return (
<Container component="main" maxWidth="sm">
<Box
sx={{
marginTop: 4,
marginBottom: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Paper
elevation={3}
sx={{
padding: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
}}
>
{/* Logo and Title */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<ShoppingCart sx={{ fontSize: 40, color: 'primary.main', mr: 1 }} />
<Typography component="h1" variant="h4" color="primary.main" fontWeight="bold">
hMarket
</Typography>
</Box>
<Typography component="h2" variant="h5" sx={{ mb: 3 }}>
Hesap Oluştur
</Typography>
{error && (
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ width: '100%' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2 }}>
<Box>
<Controller
name="firstName"
control={control}
render={({ field }) => (
<TextField
{...field}
required
fullWidth
id="firstName"
label="Ad"
autoComplete="given-name"
autoFocus
error={!!errors.firstName}
helperText={errors.firstName?.message}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Person />
</InputAdornment>
),
}}
/>
)}
/>
</Box>
<Box>
<Controller
name="lastName"
control={control}
render={({ field }) => (
<TextField
{...field}
required
fullWidth
id="lastName"
label="Soyad"
autoComplete="family-name"
error={!!errors.lastName}
helperText={errors.lastName?.message}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Person />
</InputAdornment>
),
}}
/>
)}
/>
</Box>
</Box>
<Controller
name="email"
control={control}
render={({ field }) => (
<TextField
{...field}
margin="normal"
required
fullWidth
id="email"
label="E-posta Adresi"
autoComplete="email"
error={!!errors.email}
helperText={errors.email?.message}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Email />
</InputAdornment>
),
}}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field }) => (
<TextField
{...field}
margin="normal"
required
fullWidth
label="Şifre"
type={showPassword ? 'text' : 'password'}
id="password"
autoComplete="new-password"
error={!!errors.password}
helperText={errors.password?.message}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
)}
/>
<Controller
name="confirmPassword"
control={control}
render={({ field }) => (
<TextField
{...field}
margin="normal"
required
fullWidth
label="Şifre Onayı"
type={showConfirmPassword ? 'text' : 'password'}
id="confirmPassword"
autoComplete="new-password"
error={!!errors.confirmPassword}
helperText={errors.confirmPassword?.message}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle confirm password visibility"
onClick={handleClickShowConfirmPassword}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2, py: 1.5 }}
disabled={isLoading}
>
{isLoading ? 'Hesap Oluşturuluyor...' : 'Hesap Oluştur'}
</Button>
<Button
fullWidth
variant="outlined"
onClick={handleGoogleLogin}
disabled={isLoading}
startIcon={<Google />}
sx={{
mb: 2,
py: 1.5,
borderColor: '#db4437',
color: '#db4437',
'&:hover': {
borderColor: '#c23321',
backgroundColor: 'rgba(219, 68, 55, 0.04)',
},
}}
>
Google ile Kayıt Ol
</Button>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Zaten hesabınız var mı?{' '}
<Link
component="button"
variant="body2"
onClick={() => navigate('/login')}
sx={{ textDecoration: 'none', fontWeight: 'bold' }}
>
Giriş Yap
</Link>
</Typography>
</Box>
</Box>
</Paper>
{/* Terms */}
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Hesap oluşturarak,{' '}
<Link
href="https://www.mustafaozkaya.tr/hmarket-kullanim-sartlari/"
target="_blank"
rel="noopener"
sx={{ textDecoration: 'none' }}
>
Kullanım Şartları
</Link>
{' '}ve{' '}
<Link
href="https://www.mustafaozkaya.tr/hmarket-gizlilik-politikasi/"
target="_blank"
rel="noopener"
sx={{ textDecoration: 'none' }}
>
Gizlilik Politikası
</Link>
'nı kabul etmiş olursunuz.
</Typography>
</Box>
</Box>
</Container>
);
};
export default RegisterPage;

View File

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

View File

@@ -0,0 +1,359 @@
import React from 'react';
import {
Container,
Typography,
Box,
Card,
CardContent,
CardActions,
Button,
List,
ListItem,
ListItemText,
ListItemIcon,
Avatar,
LinearProgress,
IconButton,
Link,
} from '@mui/material';
import {
Add,
List as ListIcon,
ShoppingCart,
CheckCircle,
PendingActions,
Share,
TrendingUp,
Refresh,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { dashboardAPI, listsAPI } from '../../services/api';
import { useAuth } from '../../contexts/AuthContext';
import { formatDistanceToNow } from 'date-fns';
import { ListsResponse } from '../../types';
const DashboardPage: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
// Fetch dashboard stats
const { data: statsData, isLoading: statsLoading, refetch: refetchStats } = useQuery({
queryKey: ['dashboard', 'stats'],
queryFn: () => dashboardAPI.getStats(),
});
// Fetch recent lists
const { data: listsData, isLoading: listsLoading } = useQuery<ListsResponse>({
queryKey: ['lists', 'recent'],
queryFn: () => listsAPI.getLists({ limit: 5, sortBy: 'updatedAt', sortOrder: 'desc' }).then(response => response.data),
});
// Fetch recent activity
const { data: activityData, isLoading: activityLoading } = useQuery({
queryKey: ['dashboard', 'activity'],
queryFn: () => dashboardAPI.getRecentActivity({ limit: 10 }),
});
const stats = statsData?.data?.data;
const recentLists = listsData?.data?.lists || [];
const recentActivity = activityData?.data?.data?.activities || [];
const completionRate = stats ? Math.round((stats.completedItems / stats.totalItems) * 100) || 0 : 0;
return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
{/* Welcome Section */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" gutterBottom>
Tekrar hoş geldin, {user?.firstName}! 👋
</Typography>
<Typography variant="body1" color="text.secondary">
Bugün alışveriş listelerinizde neler oluyor, işte özet.
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr 1fr' }, gap: 3 }}>
{/* Stats Cards */}
<Box>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<ListIcon sx={{ fontSize: 40, color: 'primary.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" gutterBottom>
Toplam Liste
</Typography>
<Typography variant="h4">
{statsLoading ? '-' : stats?.totalLists || 0}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Box>
<Box>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<ShoppingCart sx={{ fontSize: 40, color: 'info.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" gutterBottom>
Toplam Ürün
</Typography>
<Typography variant="h4">
{statsLoading ? '-' : stats?.totalItems || 0}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Box>
<Box>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CheckCircle sx={{ fontSize: 40, color: 'success.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" gutterBottom>
Tamamlanan
</Typography>
<Typography variant="h4">
{statsLoading ? '-' : stats?.completedItems || 0}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Box>
<Box>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PendingActions sx={{ fontSize: 40, color: 'warning.main', mr: 2 }} />
<Box>
<Typography color="textSecondary" gutterBottom>
Bekleyen
</Typography>
<Typography variant="h4">
{statsLoading ? '-' : stats?.pendingItems || 0}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Box>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3, mt: 3 }}>
{/* Completion Rate */}
<Box>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h6">Alışveriş İlerlemesi</Typography>
<IconButton onClick={() => refetchStats()}>
<Refresh />
</IconButton>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<TrendingUp sx={{ mr: 1, color: 'success.main' }} />
<Typography variant="h4" color="success.main">
{completionRate}%
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
tamamlanma oranı
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={completionRate}
sx={{ height: 8, borderRadius: 4 }}
/>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{stats?.totalItems || 0} üründen {stats?.completedItems || 0} tanesi tamamlandı
</Typography>
</CardContent>
</Card>
</Box>
{/* Quick Actions */}
<Box>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Hızlı İşlemler
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button
fullWidth
variant="contained"
startIcon={<Add />}
onClick={() => navigate('/lists?action=create')}
>
Yeni Liste Oluştur
</Button>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<Box>
<Button
fullWidth
variant="outlined"
startIcon={<ListIcon />}
onClick={() => navigate('/lists')}
>
Tüm Listeler
</Button>
</Box>
<Box>
<Button
fullWidth
variant="outlined"
startIcon={<ShoppingCart />}
onClick={() => navigate('/products')}
>
Ürünlere Gözat
</Button>
</Box>
</Box>
</Box>
</CardContent>
</Card>
</Box>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3, mt: 3 }}>
{/* Recent Lists */}
<Box>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Son Listeler
</Typography>
{listsLoading ? (
<Typography>Yükleniyor...</Typography>
) : recentLists.length === 0 ? (
<Typography color="text.secondary">
Henüz liste yok. İlk alışveriş listenizi oluşturun!
</Typography>
) : (
<List>
{recentLists.map((list: any) => (
<ListItem
key={list.id}
onClick={() => navigate(`/lists/${list.id}`)}
sx={{ px: 0, cursor: 'pointer' }}
>
<ListItemIcon>
<Avatar sx={{ bgcolor: list.color || 'primary.main', width: 32, height: 32 }}>
{list.isShared ? <Share /> : <ListIcon />}
</Avatar>
</ListItemIcon>
<ListItemText
primary={list.name}
secondary={
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '0.875rem', color: 'rgba(0, 0, 0, 0.6)' }}>
{list._count?.items || 0} ürün
</span>
{list.isShared && (
<span style={{
fontSize: '0.75rem',
backgroundColor: '#1976d2',
color: 'white',
padding: '2px 8px',
borderRadius: '12px',
fontWeight: 500
}}>
Paylaşılan
</span>
)}
</span>
}
/>
</ListItem>
))}
</List>
)}
</CardContent>
<CardActions>
<Button size="small" onClick={() => navigate('/lists')}>
Tüm Listeleri Görüntüle
</Button>
</CardActions>
</Card>
</Box>
{/* Recent Activity */}
<Box>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Son Aktiviteler
</Typography>
{activityLoading ? (
<Typography>Yükleniyor...</Typography>
) : recentActivity.length === 0 ? (
<Typography color="text.secondary">
Son aktivite yok.
</Typography>
) : (
<List>
{recentActivity.slice(0, 5).map((activity: any) => (
<ListItem key={activity.id} sx={{ px: 0 }}>
<ListItemIcon>
<Avatar sx={{ width: 32, height: 32 }}>
{activity.user?.firstName?.[0] || '?'}
</Avatar>
</ListItemIcon>
<ListItemText
primary={activity.description}
secondary={formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}
/>
</ListItem>
))}
</List>
)}
</CardContent>
<CardActions>
<Button size="small" onClick={() => navigate('/activity')}>
Tüm Aktiviteleri Görüntüle
</Button>
</CardActions>
</Card>
</Box>
</Box>
{/* Footer */}
<Box sx={{ mt: 8, pt: 4, borderTop: '1px solid rgba(0,0,0,0.05)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Link
href="https://www.mustafaozkaya.tr/hmarket-kullanim-sartlari/"
target="_blank"
rel="noopener"
variant="body2"
sx={{ textDecoration: 'none', color: 'text.secondary' }}
>
Kullanım Şartları
</Link>
<Link
href="https://www.mustafaozkaya.tr/hmarket-gizlilik-politikasi/"
target="_blank"
rel="noopener"
variant="body2"
sx={{ textDecoration: 'none', color: 'text.secondary' }}
>
Gizlilik Politikası
</Link>
</Box>
<Typography variant="caption" display="block" sx={{ mt: 1, color: 'text.disabled' }}>
© 2026 hMarket - Mustafa ÖZKAYA
</Typography>
</Box>
</Container>
);
};
export default DashboardPage;

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