mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
expanded admin features: direct flavor editing, live configuration, and background job management
This commit is contained in:
1 parent
60eadae803
commit
be61b6bbcd
9 files changed
+567
-76
No files matched your search
@@ -3,7 +3,8 @@ import {
|
||||
Container, Typography, Box, Grid, Card, CardContent, Button,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, Stack, alpha, CircularProgress, Chip, TextField, IconButton,
|
||||
useMediaQuery, Tabs, Tab
|
||||
useMediaQuery, Tabs, Tab, List, ListItem, ListItemText, Switch, Divider,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
@@ -11,6 +12,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import WallpaperIcon from '@mui/icons-material/Wallpaper';
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
@@ -30,6 +34,13 @@ interface Stats {
|
||||
use_ssl: boolean;
|
||||
skip_verify: boolean;
|
||||
};
|
||||
server_info: {
|
||||
debug: boolean;
|
||||
allowed_hosts: string[];
|
||||
frontend_url: string;
|
||||
media_root: string;
|
||||
static_root: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AdminUser {
|
||||
@@ -54,6 +65,25 @@ interface Banner {
|
||||
schema: any[];
|
||||
}
|
||||
|
||||
interface Job {
|
||||
id: number;
|
||||
name: string;
|
||||
name_display: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
last_run: string | null;
|
||||
next_run: string | null;
|
||||
interval_hours: number;
|
||||
last_output: string;
|
||||
error_message: string;
|
||||
}
|
||||
|
||||
interface SystemConfig {
|
||||
site_name: string;
|
||||
maintenance_mode: boolean;
|
||||
allow_new_signups: boolean;
|
||||
require_email_verification: boolean;
|
||||
}
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -62,6 +92,8 @@ const AdminDashboard: React.FC = () => {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [config, setConfig] = useState<SystemConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
@@ -71,30 +103,57 @@ const AdminDashboard: React.FC = () => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const statsRes = await api.get('admin-custom/stats/');
|
||||
const [statsRes, usersRes, bannersRes, jobsRes, configRes] = await Promise.all([
|
||||
api.get('admin-custom/stats/'),
|
||||
api.get('admin-custom/users/'),
|
||||
api.get('banners/'),
|
||||
api.get('admin-custom/jobs/'),
|
||||
api.get('admin-custom/config/')
|
||||
]);
|
||||
|
||||
setStats(statsRes.data);
|
||||
} catch (err) {
|
||||
console.error('Stats fetch error:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
const usersRes = await api.get('admin-custom/users/');
|
||||
setUsers(usersRes.data);
|
||||
} catch (err) {
|
||||
console.error('Users fetch error:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
const bannersRes = await api.get('banners/');
|
||||
|
||||
const bannersData = Array.isArray(bannersRes.data) ? bannersRes.data : (bannersRes.data.results || []);
|
||||
setBanners(bannersData);
|
||||
|
||||
setJobs(jobsRes.data);
|
||||
setConfig(configRes.data);
|
||||
} catch (err) {
|
||||
console.error('Banners fetch error:', err);
|
||||
console.error('Data fetch error:', err);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleUpdateConfig = async (key: string, value: any) => {
|
||||
try {
|
||||
const res = await api.patch('admin-custom/config/', { [key]: value });
|
||||
setConfig(res.data);
|
||||
} catch (err) {
|
||||
alert('Failed to update configuration');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerJob = async (id: number) => {
|
||||
try {
|
||||
await api.post(`admin-custom/${id}/trigger_job/`);
|
||||
alert('Job queued successfully');
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
alert('Failed to trigger job');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateJobSchedule = async (id: number, interval: number) => {
|
||||
try {
|
||||
await api.patch(`admin-custom/${id}/update_job_schedule/`, { interval_hours: interval });
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
alert('Failed to update schedule');
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivateBanner = async (id: number) => {
|
||||
try {
|
||||
await api.post(`banners/${id}/activate/`);
|
||||
@@ -154,46 +213,113 @@ const AdminDashboard: React.FC = () => {
|
||||
<Tab icon={<SettingsIcon />} label={t('admin.overview')} iconPosition="start" />
|
||||
<Tab icon={<PeopleIcon />} label={t('admin.users')} iconPosition="start" />
|
||||
<Tab icon={<WallpaperIcon />} label={t('admin.banners')} iconPosition="start" />
|
||||
<Tab icon={<TerminalIcon />} label="Jobs & Workers" iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
{currentTab === 0 && (
|
||||
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.totalUsers')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold' }}>{stats?.total_users}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Box>
|
||||
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.totalUsers')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold' }}>{stats?.total_users}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.openTickets')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold', color: 'error.main' }}>{stats?.open_tickets}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Stack direction={isMobile ? "column" : "row"} justifyContent="space-between" alignItems={isMobile ? "flex-start" : "center"} spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('admin.config')}: SMTP</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{stats?.email_config.host}:{stats?.email_config.port} •
|
||||
TLS: {String(stats?.email_config.use_tls)} •
|
||||
Insecure: {String(stats?.email_config.skip_verify)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" fullWidth={isMobile} startIcon={<EmailIcon />} onClick={handleSendTestEmail} size="small">
|
||||
{t('admin.sendTestEmail')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.openTickets')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold', color: 'error.main' }}>{stats?.open_tickets}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Stack direction={isMobile ? "column" : "row"} justifyContent="space-between" alignItems={isMobile ? "flex-start" : "center"} spacing={2}>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* Live Config */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>System Configuration (Live)</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 0, borderRadius: 3, overflow: 'hidden' }}>
|
||||
<List disablePadding>
|
||||
{[
|
||||
{ key: 'site_name', label: 'Site Name', type: 'text' },
|
||||
{ key: 'maintenance_mode', label: 'Maintenance Mode', type: 'switch' },
|
||||
{ key: 'allow_new_signups', label: 'Allow Signups', type: 'switch' },
|
||||
{ key: 'require_email_verification', label: 'Require Email Verify', type: 'switch' }
|
||||
].map((item, idx) => (
|
||||
<ListItem key={item.key} divider={idx < 3} sx={{ py: 2 }}>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
secondary={item.type === 'text' ? (config as any)?.[item.key] : null}
|
||||
/>
|
||||
{item.type === 'switch' ? (
|
||||
<Switch
|
||||
checked={(config as any)?.[item.key] || false}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleUpdateConfig(item.key, e.target.checked)}
|
||||
/>
|
||||
) : (
|
||||
<Button size="small" onClick={() => {
|
||||
const val = prompt(`Enter new value for ${item.label}:`, (config as any)?.[item.key]);
|
||||
if (val !== null) handleUpdateConfig(item.key, val);
|
||||
}}>Edit</Button>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Environment Info */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>Environment Info (.env)</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 3, bgcolor: alpha('#000', 0.02) }}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('admin.config')}: SMTP</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{stats?.email_config.host}:{stats?.email_config.port} •
|
||||
TLS: {String(stats?.email_config.use_tls)} •
|
||||
Insecure: {String(stats?.email_config.skip_verify)}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>API Status</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>DEBUG={String(stats?.server_info.debug)}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>Frontend URL</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{stats?.server_info.frontend_url}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>Allowed Hosts</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{stats?.server_info.allowed_hosts.join(', ')}</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<InfoIcon fontSize="small" color="primary" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
These values are read from your server environment or .env file.
|
||||
To change them, you must update the file and restart the backend container.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" fullWidth={isMobile} startIcon={<EmailIcon />} onClick={handleSendTestEmail} size="small">
|
||||
{t('admin.sendTestEmail')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentTab === 1 && (
|
||||
@@ -349,6 +475,74 @@ const AdminDashboard: React.FC = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentTab === 3 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>Background Worker Jobs</Typography>
|
||||
<Stack spacing={3}>
|
||||
{jobs.map(job => (
|
||||
<Card key={job.id} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>{job.name_display}</Typography>
|
||||
<Chip
|
||||
label={job.status.toUpperCase()}
|
||||
size="small"
|
||||
color={job.status === 'completed' ? 'success' : job.status === 'running' ? 'info' : job.status === 'failed' ? 'error' : 'default'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Last Run</Typography>
|
||||
<Typography variant="body2">{job.last_run ? formatDate(job.last_run) : 'Never'}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Next Run</Typography>
|
||||
<Typography variant="body2">{job.next_run ? formatDate(job.next_run) : (job.interval_hours > 0 ? 'Pending' : 'Disabled')}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Interval (Hours)</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{job.interval_hours}h</Typography>
|
||||
<Button size="small" onClick={() => {
|
||||
const val = prompt('Set interval in hours (0 to disable):', String(job.interval_hours));
|
||||
if (val !== null) handleUpdateJobSchedule(job.id, parseInt(val));
|
||||
}}>Change</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{job.last_output && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>Last Run Output</Typography>
|
||||
<Box sx={{ p: 1.5, bgcolor: '#1e1e1e', color: '#d4d4d4', borderRadius: 2, maxHeight: 150, overflowY: 'auto', border: '1px solid #333' }}>
|
||||
<pre style={{ margin: 0, fontSize: '0.75rem', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>{job.last_output}</pre>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{job.error_message && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="error" display="block" gutterBottom>Last Error</Typography>
|
||||
<Alert severity="error" sx={{ py: 0 }}>{job.error_message}</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
disabled={job.status === 'running'}
|
||||
onClick={() => handleTriggerJob(job.id)}
|
||||
>
|
||||
Trigger Now
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<BannerSettingsDialog
|
||||
open={isSettingsDialogOpen}
|
||||
onClose={() => {
|
||||
@@ -363,3 +557,4 @@ const AdminDashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
Stack,
|
||||
alpha,
|
||||
useTheme,
|
||||
Collapse
|
||||
Collapse,
|
||||
TextField,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import api from '../../api';
|
||||
import { useTitle } from '../../hooks/useTitle';
|
||||
@@ -24,6 +28,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||
import CommentIcon from '@mui/icons-material/Comment';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { formatDate } from '../../utils/date';
|
||||
import MentionTextField from '../../components/MentionTextField';
|
||||
import RichText from '../../components/RichText';
|
||||
@@ -77,6 +84,16 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
const [expandedReplies, setExpandedReplies] = useState<{[key: number]: boolean}>({});
|
||||
const [currentUser, setCurrentUser] = useState<string | null>(null);
|
||||
|
||||
// Product Edit state (Admin)
|
||||
const [isAdminEditing, setIsAdminEditing] = useState(false);
|
||||
const [editFlavorData, setEditFlavorData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
shop_url: '',
|
||||
is_available: true,
|
||||
is_legacy: false
|
||||
});
|
||||
|
||||
// Rating form state
|
||||
const [newScore, setNewScore] = useState<number | null>(null);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
@@ -94,6 +111,13 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
try {
|
||||
const res = await api.get(`flavors/${id}/`);
|
||||
setFlavor(res.data);
|
||||
setEditFlavorData({
|
||||
name: res.data.name,
|
||||
description: res.data.description,
|
||||
shop_url: res.data.shop_url || '',
|
||||
is_available: res.data.is_available,
|
||||
is_legacy: res.data.is_legacy
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
@@ -114,6 +138,16 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
|
||||
useTitle(flavor?.name || t('common.loading'));
|
||||
|
||||
const handleAdminUpdate = async () => {
|
||||
try {
|
||||
await api.patch(`flavors/${id}/`, editFlavorData);
|
||||
setIsAdminEditing(false);
|
||||
fetchFlavor();
|
||||
} catch (err) {
|
||||
alert('Admin update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRatingSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newScore) {
|
||||
@@ -267,27 +301,77 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
)}
|
||||
</Box>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Chip
|
||||
label={flavor.category_name}
|
||||
size="small"
|
||||
sx={{ mb: 2, fontWeight: 'bold', bgcolor: alpha(theme.palette.primary.main, 0.1), color: 'primary.main' }}
|
||||
/>
|
||||
<Typography variant="h4" sx={{ fontWeight: '800', mb: 2, lineHeight: 1.2 }}>
|
||||
{flavor.name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="large" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
{flavor.ratings.length} {t('common.reviews')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{adminMode && (
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{!isAdminEditing ? (
|
||||
<Button startIcon={<EditIcon />} size="small" onClick={() => setIsAdminEditing(true)}>
|
||||
Edit Product (Admin)
|
||||
</Button>
|
||||
) : (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button startIcon={<SaveIcon />} size="small" color="success" variant="contained" onClick={handleAdminUpdate}>
|
||||
Save
|
||||
</Button>
|
||||
<Button startIcon={<CancelIcon />} size="small" onClick={() => setIsAdminEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7, color: 'text.secondary', mb: 4 }}>
|
||||
<RichText text={flavor.description} />
|
||||
</Typography>
|
||||
{isAdminEditing ? (
|
||||
<Stack spacing={2} sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
label="Name" fullWidth size="small"
|
||||
value={editFlavorData.name}
|
||||
onChange={(e) => setEditFlavorData({...editFlavorData, name: e.target.value})}
|
||||
/>
|
||||
<TextField
|
||||
label="Description" fullWidth size="small" multiline rows={4}
|
||||
value={editFlavorData.description}
|
||||
onChange={(e) => setEditFlavorData({...editFlavorData, description: e.target.value})}
|
||||
/>
|
||||
<TextField
|
||||
label="Shop URL" fullWidth size="small"
|
||||
value={editFlavorData.shop_url}
|
||||
onChange={(e) => setEditFlavorData({...editFlavorData, shop_url: e.target.value})}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={editFlavorData.is_available} onChange={(e) => setEditFlavorData({...editFlavorData, is_available: e.target.checked})} />}
|
||||
label="In Stock"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={editFlavorData.is_legacy} onChange={(e) => setEditFlavorData({...editFlavorData, is_legacy: e.target.checked})} />}
|
||||
label="Legacy / Limited"
|
||||
/>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Chip
|
||||
label={flavor.category_name}
|
||||
size="small"
|
||||
sx={{ mb: 2, fontWeight: 'bold', bgcolor: alpha(theme.palette.primary.main, 0.1), color: 'primary.main' }}
|
||||
/>
|
||||
<Typography variant="h4" sx={{ fontWeight: '800', mb: 2, lineHeight: 1.2 }}>
|
||||
{flavor.name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="large" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
{flavor.ratings.length} {t('common.reviews')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{flavor.shop_url && (
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7, color: 'text.secondary', mb: 4 }}>
|
||||
<RichText text={flavor.description} />
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{flavor.shop_url && !isAdminEditing && (
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
|
||||
Reference in new issue
Block a user