From fccac10e64bcff6b96934d4feb73bf97d1f6b3ed Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 16 Jun 2026 13:55:24 +0200 Subject: [PATCH] translation and back button --- frontend/src/App.tsx | 4 +- frontend/src/components/ui/BackButton.tsx | 58 ++ frontend/src/components/ui/index.ts | 1 + frontend/src/i18n.ts | 120 +++ frontend/src/pages/AdminDashboard.tsx | 873 ------------------ frontend/src/pages/CommunityFeed.tsx | 28 +- frontend/src/pages/FlavorList.tsx | 240 ----- frontend/src/pages/Login.tsx | 61 +- frontend/src/pages/PublicProfile.tsx | 38 +- .../src/pages/categories/CategoryFlavors.tsx | 35 +- frontend/src/pages/dashboard/MyReviewCard.tsx | 12 +- frontend/src/pages/flavors/FlavorDetail.tsx | 63 +- frontend/ux-improvements.md | 38 + 13 files changed, 317 insertions(+), 1254 deletions(-) create mode 100644 frontend/src/components/ui/BackButton.tsx delete mode 100644 frontend/src/pages/AdminDashboard.tsx delete mode 100644 frontend/src/pages/FlavorList.tsx create mode 100644 frontend/ux-improvements.md diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aa9fbef..af971b2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -252,9 +252,9 @@ const App: React.FC = () => { path="*" element={ - 404 - Page Not Found + {t('common.notFoundTitle')} } diff --git a/frontend/src/components/ui/BackButton.tsx b/frontend/src/components/ui/BackButton.tsx new file mode 100644 index 0000000..c8f4a7b --- /dev/null +++ b/frontend/src/components/ui/BackButton.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Button } from '@mui/material'; +import type { SxProps, Theme } from '@mui/material/styles'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { Link, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +export interface BackButtonProps { + /** + * Fixed destination. When set, the button always navigates here (rendered as a + * router Link). When omitted, it goes back in history, falling back to `/`. + */ + to?: string; + sx?: SxProps; +} + +const baseSx: SxProps = { + alignSelf: 'flex-start', + borderRadius: 2, + textTransform: 'none', + fontWeight: 'bold', + color: 'text.secondary', +}; + +/** + * Shared "go back" button. Replaces the per-page copies that drifted in styling + * and label logic. With `to`, it's a static link; otherwise it pops history and + * falls back to home, with the label reflecting which it'll do. + */ +export const BackButton: React.FC = ({ to, sx }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const canGoBack = typeof window !== 'undefined' && window.history.length > 1; + + const handleGoBack = () => { + if (canGoBack) navigate(-1); + else navigate('/'); + }; + + const label = to || !canGoBack ? t('common.backToHome') : t('common.back'); + + const linkProps = to + ? { component: Link as React.ElementType, to } + : { onClick: handleGoBack }; + + return ( + + ); +}; + +export default BackButton; diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index d8d21dd..1bd5746 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -7,6 +7,7 @@ export { ReviewRow, type ReviewRowProps, type ReviewRowData } from './ReviewRow' export { FlavorCard, type FlavorCardProps, type FlavorCardData } from './FlavorCard'; export { FormCard, type FormCardProps } from './FormCard'; export { EmptyState, type EmptyStateProps } from './EmptyState'; +export { BackButton, type BackButtonProps } from './BackButton'; export { default as ScoreInput, type ScoreInputProps } from './ScoreInput'; export { default as HeroGallery, type HeroGalleryProps } from './HeroGallery'; export { default as RatingDistribution, type RatingDistributionProps } from './RatingDistribution'; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 6e9986e..4e69b2a 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -129,6 +129,9 @@ const resources = { previousFlavor: 'Previous flavor', nextFlavor: 'Next flavor', goToFlavor: 'Go to flavor {{n}}', + noCategoryFlavors: 'No flavors here yet', + noCategoryFlavorsSub: 'Check back soon.', + flavorsFallback: 'Flavors', }, common: { search: 'Search...', @@ -147,6 +150,13 @@ const resources = { save: 'Save', cancel: 'Cancel', all: 'All', + noComment: 'No comment provided.', + replyFailed: 'Failed to send reply', + actionFailed: 'Something went wrong. Please try again.', + confirmDeleteReview: 'Delete this review?', + confirmDeleteReply: 'Delete this reply?', + confirmDeleteComment: 'Delete this comment?', + notFoundTitle: '404 — Page Not Found', }, status: { limited: 'Limited', @@ -174,6 +184,38 @@ const resources = { noImage: 'No Image', ratingDistribution: 'Rating distribution', flavorNotFound: 'Flavor not found', + editProductAdmin: 'Edit Product (Admin)', + nameLabel: 'Name', + descriptionLabel: 'Description', + shopUrlLabel: 'Shop URL', + uploadImage: 'Upload Custom Image', + uploading: 'Uploading...', + inStock: 'In Stock', + legacyLimited: 'Legacy / Limited', + adminUpdateFailed: 'Admin update failed', + imageUploadFailed: 'Image upload failed', + replyUpdateFailed: 'Failed to update reply', + replyDeleteFailed: 'Failed to delete reply', + reviewUpdateFailed: 'Failed to update review', + reviewDeleteFailed: 'Failed to delete review', + }, + profile: { + notFound: 'Profile not found.', + tabRatings: 'Ratings', + tabGuestbook: 'Guestbook', + tabFollowers: 'Followers', + tabFollowing: 'Following', + masterCollector: 'Master Collector', + noRatingsCategory: 'No ratings in this category', + leaveMessage: 'Leave a message', + guestbookPlaceholder: 'Say something about their taste...', + guestbookEmpty: 'No entries in the guestbook yet.', + guestbookEmptySub: 'Be the first to leave a message!', + noFollowers: 'No followers yet.', + noFollowing: 'Not following anyone yet.', + followFailed: 'Failed to update follow status. Please login.', + commentAddFailed: 'Failed to add comment', + commentDeleteFailed: 'Failed to delete comment', }, auth: { loginTitle: 'Login', @@ -188,6 +230,24 @@ const resources = { signupSuccess: 'Signup successful! Please enter the verification code sent to your email.', forgotPassword: 'Forgot Password?', backToAuth: 'Back to Signup/Login', + usernameLabel: 'Username', + passwordLabel: 'Password', + emailLabel: 'Email', + newPasswordLabel: 'New Password', + verificationCodeLabel: 'Verification Code', + resetCodeLabel: 'Reset Code', + verifyButton: 'Verify', + resetTitle: 'Reset Password', + sendResetCode: 'Send Reset Code', + resetButton: 'Reset Password', + backToLogin: 'Back to Login', + resetRequested: 'If an account exists, a reset code has been sent.', + resetSuccess: 'Password reset successful! You can now login.', + resetFailed: 'Reset failed', + signupFailed: 'Signup failed', + verifyFailed: 'Verification failed', + resendFailed: 'Failed to resend code', + genericError: 'An error occurred. Please try again later.', }, community: { title: 'Community Activity', @@ -451,6 +511,9 @@ const resources = { previousFlavor: 'Vorherige Sorte', nextFlavor: 'Nächste Sorte', goToFlavor: 'Zu Sorte {{n}}', + noCategoryFlavors: 'Hier gibt es noch keine Sorten', + noCategoryFlavorsSub: 'Schau bald wieder vorbei.', + flavorsFallback: 'Sorten', }, common: { search: 'Suchen...', @@ -469,6 +532,13 @@ const resources = { save: 'Speichern', cancel: 'Abbrechen', all: 'Alle', + noComment: 'Kein Kommentar abgegeben.', + replyFailed: 'Antwort konnte nicht gesendet werden', + actionFailed: 'Etwas ist schiefgelaufen. Bitte versuche es erneut.', + confirmDeleteReview: 'Diese Bewertung löschen?', + confirmDeleteReply: 'Diese Antwort löschen?', + confirmDeleteComment: 'Diesen Kommentar löschen?', + notFoundTitle: '404 — Seite nicht gefunden', }, status: { limited: 'Limitiert', @@ -496,6 +566,38 @@ const resources = { noImage: 'Kein Bild', ratingDistribution: 'Bewertungsverteilung', flavorNotFound: 'Sorte nicht gefunden', + editProductAdmin: 'Produkt bearbeiten (Admin)', + nameLabel: 'Name', + descriptionLabel: 'Beschreibung', + shopUrlLabel: 'Shop-URL', + uploadImage: 'Eigenes Bild hochladen', + uploading: 'Wird hochgeladen...', + inStock: 'Auf Lager', + legacyLimited: 'Legacy / Limitiert', + adminUpdateFailed: 'Admin-Update fehlgeschlagen', + imageUploadFailed: 'Bild-Upload fehlgeschlagen', + replyUpdateFailed: 'Antwort konnte nicht aktualisiert werden', + replyDeleteFailed: 'Antwort konnte nicht gelöscht werden', + reviewUpdateFailed: 'Bewertung konnte nicht aktualisiert werden', + reviewDeleteFailed: 'Bewertung konnte nicht gelöscht werden', + }, + profile: { + notFound: 'Profil nicht gefunden.', + tabRatings: 'Bewertungen', + tabGuestbook: 'Gästebuch', + tabFollowers: 'Follower', + tabFollowing: 'Folge ich', + masterCollector: 'Meister-Sammler', + noRatingsCategory: 'Keine Bewertungen in dieser Kategorie', + leaveMessage: 'Nachricht hinterlassen', + guestbookPlaceholder: 'Sag etwas über ihren Geschmack...', + guestbookEmpty: 'Noch keine Einträge im Gästebuch.', + guestbookEmptySub: 'Sei der Erste, der eine Nachricht hinterlässt!', + noFollowers: 'Noch keine Follower.', + noFollowing: 'Folgt noch niemandem.', + followFailed: 'Follow-Status konnte nicht aktualisiert werden. Bitte anmelden.', + commentAddFailed: 'Kommentar konnte nicht hinzugefügt werden', + commentDeleteFailed: 'Kommentar konnte nicht gelöscht werden', }, auth: { loginTitle: 'Anmelden', @@ -513,6 +615,24 @@ const resources = { 'Registrierung erfolgreich! Bitte gib den Verifizierungscode ein, der an deine E-Mail gesendet wurde.', forgotPassword: 'Passwort vergessen?', backToAuth: 'Zurück zur Anmeldung', + usernameLabel: 'Benutzername', + passwordLabel: 'Passwort', + emailLabel: 'E-Mail', + newPasswordLabel: 'Neues Passwort', + verificationCodeLabel: 'Verifizierungscode', + resetCodeLabel: 'Reset-Code', + verifyButton: 'Verifizieren', + resetTitle: 'Passwort zurücksetzen', + sendResetCode: 'Reset-Code senden', + resetButton: 'Passwort zurücksetzen', + backToLogin: 'Zurück zur Anmeldung', + resetRequested: 'Falls ein Konto existiert, wurde ein Reset-Code gesendet.', + resetSuccess: 'Passwort erfolgreich zurückgesetzt! Du kannst dich jetzt anmelden.', + resetFailed: 'Zurücksetzen fehlgeschlagen', + signupFailed: 'Registrierung fehlgeschlagen', + verifyFailed: 'Verifizierung fehlgeschlagen', + resendFailed: 'Code konnte nicht erneut gesendet werden', + genericError: 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.', }, community: { title: 'Community-Aktivität', diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx deleted file mode 100644 index e605c2e..0000000 --- a/frontend/src/pages/AdminDashboard.tsx +++ /dev/null @@ -1,873 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { - Typography, - Box, - Grid, - CardContent, - Button, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Stack, - alpha, - CircularProgress, - Chip, - TextField, - IconButton, - useMediaQuery, - Tabs, - Tab, - List, - ListItem, - ListItemText, - Switch, - Divider, - Alert, - Select, - MenuItem, - FormControl, -} from '@mui/material'; -import { PageShell, GlassCard, GlassPaper, EmptyState } from '../components/ui'; -import { useToast } from '../hooks/useToast'; -import EditIcon from '@mui/icons-material/Edit'; -import EmailIcon from '@mui/icons-material/Email'; -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 { - useAdminConfig, - useAdminJobs, - useAdminStats, - useAdminUsers, -} from '../api/queries/useAdminQueries'; -import { useBannersList } from '../api/queries/useBanners'; -import { - useActivateBanner, - useSendTestEmail, - useToggleBannerEnabled, - useTriggerJob, - useUpdateAdminConfig, - useUpdateJobSchedule, -} from '../api/mutations/useAdminMutations'; -import { useTitle } from '../hooks/useTitle'; -import { formatDate } from '../utils/date'; -import BannerSettingsDialog from '../components/BannerSettingsDialog'; - -interface Stats { - total_users: number; - total_ratings: number; - total_replies: number; - open_tickets: number; - email_config: { - host: string; - port: number; - use_tls: boolean; - use_ssl: boolean; - skip_verify: boolean; - }; - server_info: { - debug: boolean; - allowed_hosts: string[]; - frontend_url: string; - media_root: string; - static_root: string; - }; -} - -interface AdminUser { - id: number; - username: string; - email: string; - is_active: boolean; - is_superuser: boolean; - date_joined: string; - last_login: string; - ips: string[]; -} - -interface BannerSettingSchema { - key: string; - label: string; - type: 'number' | 'slider' | 'text' | 'boolean'; - min?: number; - max?: number; - step?: number; - description?: string; -} - -interface Banner { - id: number; - name: string; - slug: string; - description: string; - is_active: boolean; - is_enabled: boolean; - settings: Record; - schema: BannerSettingSchema[]; -} - -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(); - const isMobile = useMediaQuery('(max-width:900px)'); - useTitle(t('admin.title')); - const qc = useQueryClient(); - const { data: stats = null, isLoading: statsLoading } = useAdminStats() as unknown as { - data: Stats | null | undefined; - isLoading: boolean; - }; - const { data: users = [], isLoading: usersLoading } = useAdminUsers() as unknown as { - data: AdminUser[]; - isLoading: boolean; - }; - const { data: banners = [], isLoading: bannersLoading } = useBannersList() as unknown as { - data: Banner[]; - isLoading: boolean; - }; - const { data: jobs = [], isLoading: jobsLoading } = useAdminJobs() as unknown as { - data: Job[]; - isLoading: boolean; - }; - const { data: config = null, isLoading: configLoading } = useAdminConfig() as unknown as { - data: SystemConfig | null | undefined; - isLoading: boolean; - }; - const updateConfigMutation = useUpdateAdminConfig(); - const triggerJobMutation = useTriggerJob(); - const updateJobScheduleMutation = useUpdateJobSchedule(); - const activateBannerMutation = useActivateBanner(); - const toggleBannerEnabledMutation = useToggleBannerEnabled(); - const sendTestEmailMutation = useSendTestEmail(); - - const loading = statsLoading || usersLoading || bannersLoading || jobsLoading || configLoading; - - const [search, setSearch] = useState(''); - const [currentTab, setCurrentTab] = useState(0); - const [selectedBanner, setSelectedBanner] = useState(null); - const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); - const { notify } = useToast(); - - // Poll the jobs query faster while anything is running on the Jobs tab. - useEffect(() => { - const anyRunning = jobs.some((j) => j.status === 'running' || j.status === 'pending'); - if (anyRunning && currentTab === 3) { - const interval = setInterval(() => { - qc.invalidateQueries({ queryKey: ['adminJobs'] }); - }, 3000); - return () => clearInterval(interval); - } - }, [jobs, currentTab, qc]); - - const handleUpdateConfig = async (key: string, value: unknown) => { - try { - await updateConfigMutation.mutateAsync({ key, value }); - } catch { - notify({ message: 'Failed to update configuration', severity: 'error' }); - } - }; - - const handleTriggerJob = async (id: number) => { - try { - await triggerJobMutation.mutateAsync(id); - } catch { - notify({ message: 'Failed to trigger job', severity: 'error' }); - } - }; - - const handleUpdateJobSchedule = async (id: number, interval: number, nextRun?: string) => { - try { - const data: Record = { interval_hours: interval }; - if (nextRun) data.next_run = nextRun; - await updateJobScheduleMutation.mutateAsync({ id, data }); - } catch { - notify({ message: 'Failed to update schedule', severity: 'error' }); - } - }; - - const handleActivateBanner = async (id: number) => { - try { - await activateBannerMutation.mutateAsync(id); - } catch (err) { - console.error(err); - } - }; - - const handleToggleEnabled = async (id: number) => { - try { - await toggleBannerEnabledMutation.mutateAsync(id); - } catch (err) { - const error = err as { response?: { data?: { error?: string } } }; - notify({ - message: error.response?.data?.error || 'Failed to toggle visibility', - severity: 'error', - }); - } - }; - - const handleSendTestEmail = async () => { - try { - await sendTestEmailMutation.mutateAsync(); - notify({ message: t('admin.testEmailSuccess'), severity: 'success' }); - } catch (err) { - const error = err as { response?: { data?: { error?: string } } }; - notify({ - message: `Error: ${error.response?.data?.error || 'Failed to send'}`, - severity: 'error', - }); - } - }; - - const filteredUsers = users.filter( - (u) => - u.username.toLowerCase().includes(search.toLowerCase()) || - u.email.toLowerCase().includes(search.toLowerCase()), - ); - - if (loading) - return ( - - - - ); - - return ( - - - - {t('admin.title')} - - - setCurrentTab(v)} - sx={{ mb: 4, borderBottom: 1, borderColor: 'divider' }} - variant={isMobile ? 'scrollable' : 'standard'} - > - } label={t('admin.overview')} iconPosition="start" /> - } label={t('admin.users')} iconPosition="start" /> - } label={t('admin.banners')} iconPosition="start" /> - } label="Jobs & Workers" iconPosition="start" /> - - - {currentTab === 0 && ( - - - - alpha(theme.palette.primary.main, 0.1) }} - > - - - {t('admin.totalUsers')} - - - {stats?.total_users} - - - - - - alpha(theme.palette.error.main, 0.1) }} - > - - - {t('admin.openTickets')} - - - {stats?.open_tickets} - - - - - - - - - - - {t('admin.config')}: SMTP - - - {stats?.email_config.host}:{stats?.email_config.port} • TLS:{' '} - {String(stats?.email_config.use_tls)} • Insecure:{' '} - {String(stats?.email_config.skip_verify)} - - - - - - - - - - - {/* Live Config */} - - - System Configuration (Live) - - - - {[ - { 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) => ( - - )?.[item.key] - : null - } - /> - {item.type === 'switch' ? ( - )?.[item.key] || false - } - onChange={(e: React.ChangeEvent) => - handleUpdateConfig(item.key, e.target.checked) - } - /> - ) : ( - - )} - - ))} - - - - - {/* Environment Info */} - - - Environment Info (.env) - - - - - - API Status - - - DEBUG={String(stats?.server_info.debug)} - - - - - Frontend URL - - - {stats?.server_info.frontend_url} - - - - - Allowed Hosts - - - {stats?.server_info.allowed_hosts.join(', ')} - - - - - - - These values are read from your server environment or .env file. To change - them, you must update the file and restart the backend container. - - - - - - - - )} - - {currentTab === 1 && ( - - - - {t('admin.users')} - - setSearch(e.target.value)} - sx={{ width: isMobile ? '100%' : 300 }} - /> - - - {isMobile ? ( - - {filteredUsers.map((user) => ( - - - - - {user.username} - - navigate(`/admin-panel/user/${user.id}`)} - > - - - - - {user.email} - - - - {user.is_superuser && ( - - )} - - - - ))} - - ) : ( - - - - - ID - User - Email - Status - Joined - IP History - - Actions - - - - - {filteredUsers.map((user) => ( - - {user.id} - - - {user.username} - - {user.is_superuser && ( - - )} - - {user.email} - - - - {formatDate(user.date_joined)} - - - {user.ips.slice(0, 3).map((ip) => ( - - ))} - {user.ips.length > 3 && ( - +{user.ips.length - 3} - )} - - - - navigate(`/admin-panel/user/${user.id}`)} - > - - - - - ))} - -
-
- )} -
- )} - - {currentTab === 2 && ( - - - {t('admin.banners')} ({banners.length}) - - - {banners.length === 0 ? ( - qc.invalidateQueries({ queryKey: ['banners'] })} - > - Retry Loading - - } - /> - ) : ( - - {banners.map((banner) => ( - - - - - {banner.name} (Slug: {banner.slug}) - - - {!banner.is_enabled && ( - - )} - {banner.is_active && ( - - )} - - - - - {banner.description} - - - -
-                        {JSON.stringify(banner.settings, null, 2)}
-                      
-
- - - - - {!banner.is_active && ( - - )} - -
-
- ))} -
- )} -
- )} - - {currentTab === 3 && ( - - - Background Worker Jobs - - - {jobs.map((job) => ( - - - - - {job.name_display} - - - - - - - - Last Run - - - {job.last_run ? formatDate(job.last_run) : 'Never'} - - - - - Next Run - - - {job.next_run - ? formatDate(job.next_run) - : job.interval_hours > 0 - ? 'Queued...' - : 'Disabled'} - - - - - Schedule - - - - - - - - {job.last_output && ( - - - Last Run Output - - -
-                          {job.last_output}
-                        
-
-
- )} - - {job.error_message && ( - - - Last Error - - - {job.error_message} - - - )} - - -
-
- ))} -
-
- )} - - { - setIsSettingsDialogOpen(false); - setSelectedBanner(null); - }} - banner={selectedBanner} - onSave={() => qc.invalidateQueries({ queryKey: ['banners'] })} - /> -
- ); -}; - -export default AdminDashboard; diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx index bebc8bd..2bcc0c6 100644 --- a/frontend/src/pages/CommunityFeed.tsx +++ b/frontend/src/pages/CommunityFeed.tsx @@ -24,7 +24,6 @@ import SendIcon from '@mui/icons-material/Send'; import WhatshotIcon from '@mui/icons-material/Whatshot'; import NotificationsIcon from '@mui/icons-material/Notifications'; import PeopleIcon from '@mui/icons-material/People'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { Link, useNavigate } from 'react-router-dom'; import { useCommunityFeed, @@ -41,7 +40,14 @@ import { formatDate } from '../utils/date'; import { useTranslation } from 'react-i18next'; import MentionTextField from '../components/MentionTextField'; import RatingBadge from '../components/RatingBadge'; -import { PageShell, HeroBackdrop, SectionHeader, GlassCard, EmptyState } from '../components/ui'; +import { + PageShell, + HeroBackdrop, + SectionHeader, + GlassCard, + EmptyState, + BackButton, +} from '../components/ui'; import { useToast } from '../hooks/useToast'; const SidebarCard: React.FC<{ @@ -96,11 +102,6 @@ const CommunityFeed: React.FC = () => { user.username.toLowerCase().includes(followingSearch.toLowerCase()), ); - const handleGoBack = () => { - if (window.history.length > 1) navigate(-1); - else navigate('/'); - }; - useEffect(() => { if (!isAuthed) navigate('/login'); }, [isAuthed, navigate]); @@ -126,7 +127,7 @@ const CommunityFeed: React.FC = () => { await createReply.mutateAsync({ ratingId, text }); setReplyInputs((prev) => ({ ...prev, [ratingId]: '' })); } catch { - notify({ message: 'Failed to send reply', severity: 'error' }); + notify({ message: t('common.replyFailed'), severity: 'error' }); } }; @@ -141,14 +142,7 @@ const CommunityFeed: React.FC = () => { return ( }> - + @@ -249,7 +243,7 @@ const CommunityFeed: React.FC = () => { color="text.secondary" sx={{ fontStyle: 'italic' }} > - No comment provided. + {t('common.noComment')} )} diff --git a/frontend/src/pages/FlavorList.tsx b/frontend/src/pages/FlavorList.tsx deleted file mode 100644 index 11d3b5f..0000000 --- a/frontend/src/pages/FlavorList.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, { useState } from 'react'; -import { - Typography, - CardContent, - Box, - TextField, - Button, - Accordion, - AccordionSummary, - AccordionDetails, - Rating as MuiRating, - CircularProgress, -} from '@mui/material'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { useCategories, useFlavorsList } from '../api/queries/useFlavorsList'; -import { useCreateRating } from '../api/mutations/useRatingMutations'; -import RatingBadge from '../components/RatingBadge'; -import StatusBadge from '../components/StatusBadge'; -import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui'; -import { useToast } from '../hooks/useToast'; - -interface RatingInput { - score: number; - comment: string; -} - -const FlavorList: React.FC = () => { - const { data: categories = [], isLoading: catLoading } = useCategories(); - const { data: flavors = [], isLoading: flavLoading } = useFlavorsList(); - const createRating = useCreateRating(); - const [ratingInput, setRatingInput] = useState>({}); - const { notify } = useToast(); - - const loading = catLoading || flavLoading; - - const handleRate = async (flavorId: number) => { - const input = ratingInput[flavorId]; - if (!input) return; - try { - await createRating.mutateAsync({ - flavor: flavorId, - score: input.score, - comment: input.comment, - }); - notify({ message: 'Rating submitted!', severity: 'success' }); - } catch { - notify({ - message: "Failed to submit rating. Make sure you are logged in and haven't rated this yet.", - severity: 'error', - }); - } - }; - - if (loading) - return ( - - - - - - ); - - return ( - }> - - - {categories.map((category) => ( - - - - {flavors - .filter((f) => f.category_name === category.name) - .map((flavor) => ( - - - - - - {flavor.image_url && ( - theme.tokens.surface.sunken, - }} - /> - )} - - - {flavor.name} - - {flavor.description} - - {flavor.shop_url && ( - - )} - - - Avg: - - - - {flavor.ratings && flavor.ratings.length > 0 && ( - - }> - - Comments ({flavor.ratings.filter((r) => r.comment).length}) - - - - {flavor.ratings - .filter((r) => r.comment) - .map((r) => ( - - - {r.user} ({r.score}/10): - - - {r.comment} - - - ))} - - - )} - - {flavor.user_rating ? ( - - Your Rating: {flavor.user_rating}/10 - - ) : ( - - }> - Rate this flavor - - - - - setRatingInput({ - ...ratingInput, - [flavor.id]: { - ...(ratingInput[flavor.id] || { comment: '' }), - score: val || 0, - }, - }) - } - /> - - setRatingInput({ - ...ratingInput, - [flavor.id]: { - ...(ratingInput[flavor.id] || { score: 0 }), - comment: e.target.value, - }, - }) - } - /> - - - - - )} - - - ))} - - - ))} - - ); -}; - -export default FlavorList; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index f24ae2f..1d016af 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,12 +1,11 @@ import React, { useState } from 'react'; import { Typography, TextField, Button, Tab, Tabs, Alert, Stack, Box } from '@mui/material'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { Link, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import api from '../lib/api'; import { useTitle } from '../hooks/useTitle'; import { useAuth } from '../hooks/useAuth'; import { useTranslation } from 'react-i18next'; -import { PageShell, GlassCard, FormCard } from '../components/ui'; +import { PageShell, GlassCard, FormCard, BackButton } from '../components/ui'; interface ApiError { response?: { data?: { error?: string; non_field_errors?: string[] } }; @@ -63,7 +62,10 @@ const Login: React.FC = () => { setIsVerifying(true); setMessage({ type: 'success', text: t('auth.signupSuccess') }); } catch (err) { - setMessage({ type: 'error', text: readError(err).response?.data?.error || 'Signup failed' }); + setMessage({ + type: 'error', + text: readError(err).response?.data?.error || t('auth.signupFailed'), + }); } }; @@ -79,7 +81,7 @@ const Login: React.FC = () => { } catch (err) { setMessage({ type: 'error', - text: readError(err).response?.data?.error || 'Verification failed', + text: readError(err).response?.data?.error || t('auth.verifyFailed'), }); setShowResend(true); } @@ -94,7 +96,7 @@ const Login: React.FC = () => { } catch (err) { setMessage({ type: 'error', - text: readError(err).response?.data?.error || 'Failed to resend code', + text: readError(err).response?.data?.error || t('auth.resendFailed'), }); } }; @@ -105,9 +107,9 @@ const Login: React.FC = () => { try { await api.post('users/request_password_reset/', { email }); setResetStep(2); - setMessage({ type: 'success', text: 'If an account exists, a reset code has been sent.' }); + setMessage({ type: 'success', text: t('auth.resetRequested') }); } catch { - setMessage({ type: 'error', text: 'An error occurred. Please try again later.' }); + setMessage({ type: 'error', text: t('auth.genericError') }); } }; @@ -120,14 +122,17 @@ const Login: React.FC = () => { code: verificationCode, password, }); - setMessage({ type: 'success', text: 'Password reset successful! You can now login.' }); + setMessage({ type: 'success', text: t('auth.resetSuccess') }); setIsResetting(false); setResetStep(1); setTab(0); setPassword(''); setVerificationCode(''); } catch (err) { - setMessage({ type: 'error', text: readError(err).response?.data?.error || 'Reset failed' }); + setMessage({ + type: 'error', + text: readError(err).response?.data?.error || t('auth.resetFailed'), + }); } }; @@ -158,7 +163,7 @@ const Login: React.FC = () => { {t('auth.backToAuth')} } @@ -170,7 +175,7 @@ const Login: React.FC = () => { /> setVerificationCode(e.target.value)} autoFocus @@ -182,7 +187,7 @@ const Login: React.FC = () => { if (isResetting) { return ( @@ -193,17 +198,17 @@ const Login: React.FC = () => { setMessage(null); }} > - Back to Login + {t('auth.backToLogin')} } > setEmail(e.target.value)} disabled={resetStep === 2} @@ -212,13 +217,13 @@ const Login: React.FC = () => { <> setVerificationCode(e.target.value)} /> setPassword(e.target.value)} @@ -254,13 +259,13 @@ const Login: React.FC = () => { > setUsername(e.target.value)} /> setPassword(e.target.value)} @@ -281,20 +286,20 @@ const Login: React.FC = () => { > setUsername(e.target.value)} /> setEmail(e.target.value)} /> setPassword(e.target.value)} @@ -307,15 +312,7 @@ const Login: React.FC = () => { return ( - + diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index d5d2769..9e94bff 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -110,7 +110,7 @@ const PublicProfile: React.FC = () => { }); refetchFollowing(); } catch { - notify({ message: 'Failed to update follow status. Please login.', severity: 'error' }); + notify({ message: t('profile.followFailed'), severity: 'error' }); } }; @@ -122,7 +122,7 @@ const PublicProfile: React.FC = () => { }); refetchFollowing(); } catch { - notify({ message: 'Failed', severity: 'error' }); + notify({ message: t('common.actionFailed'), severity: 'error' }); } }; @@ -133,17 +133,17 @@ const PublicProfile: React.FC = () => { await addComment.mutateAsync({ userId: data.id, text: newComment }); setNewComment(''); } catch { - notify({ message: 'Failed to add comment', severity: 'error' }); + notify({ message: t('profile.commentAddFailed'), severity: 'error' }); } }; const handleDeleteComment = async (commentId: number) => { if (!data) return; - if (!(await confirm({ message: 'Delete this comment?', danger: true }))) return; + if (!(await confirm({ message: t('common.confirmDeleteComment'), danger: true }))) return; try { await deleteCommentMutation.mutateAsync({ userId: data.id, commentId }); } catch { - notify({ message: 'Failed to delete comment', severity: 'error' }); + notify({ message: t('profile.commentDeleteFailed'), severity: 'error' }); } }; @@ -160,7 +160,7 @@ const PublicProfile: React.FC = () => { if (!data) return ( - Profile not found. + {t('profile.notFound')} ); @@ -344,7 +344,7 @@ const PublicProfile: React.FC = () => { {!data.avatar && data.username.charAt(0).toUpperCase()} {data.ratings.length > 50 && ( - + { '& .Mui-selected': { color: 'primary.main' }, }} > - - - - + + + + @@ -515,7 +515,7 @@ const PublicProfile: React.FC = () => { {currentRatings.length === 0 ? ( - + ) : ( {tiers.map((tier) => { @@ -676,7 +676,7 @@ const PublicProfile: React.FC = () => { {currentUser && ( { endIcon={} sx={{ borderRadius: 2, px: 5, fontWeight: '900', textTransform: 'none' }} > - Send Message + {t('support.reply')} } > @@ -693,7 +693,7 @@ const PublicProfile: React.FC = () => { fullWidth multiline rows={3} - placeholder="Say something about their taste..." + placeholder={t('profile.guestbookPlaceholder')} value={newComment} onChange={(e) => setNewComment(e.target.value)} /> @@ -704,8 +704,8 @@ const PublicProfile: React.FC = () => { {data.comments.length === 0 ? ( ) : ( data.comments.map((comment) => ( @@ -786,7 +786,7 @@ const PublicProfile: React.FC = () => { {data.followers.length === 0 ? ( - + ) : ( data.followers.map(renderUserCard) @@ -798,7 +798,7 @@ const PublicProfile: React.FC = () => { {data.following.length === 0 ? ( - + ) : ( data.following.map(renderUserCard) diff --git a/frontend/src/pages/categories/CategoryFlavors.tsx b/frontend/src/pages/categories/CategoryFlavors.tsx index c468442..34ee339 100644 --- a/frontend/src/pages/categories/CategoryFlavors.tsx +++ b/frontend/src/pages/categories/CategoryFlavors.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { Box, CircularProgress, Button } from '@mui/material'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { useParams } from 'react-router-dom'; +import { Box, CircularProgress } from '@mui/material'; import { useTitle } from '../../hooks/useTitle'; import { useTranslation } from 'react-i18next'; import { useCategoryFlavors } from '../../api/queries/useCategoryFlavors'; @@ -11,24 +10,16 @@ import { SectionHeader, FlavorCard, EmptyState, + BackButton, } from '../../components/ui'; const CategoryFlavors: React.FC = () => { const { t } = useTranslation(); const { slug } = useParams<{ slug: string }>(); - const navigate = useNavigate(); const { data: flavors = [], isLoading: loading } = useCategoryFlavors(slug); const categoryName = flavors[0]?.category_name || ''; - const handleGoBack = () => { - if (window.history.length > 1) { - navigate(-1); - } else { - navigate('/'); - } - }; - - useTitle(categoryName || 'Flavors'); + useTitle(categoryName || t('home.flavorsFallback')); if (loading) return ( @@ -41,19 +32,15 @@ const CategoryFlavors: React.FC = () => { return ( }> - + - + {flavors.length === 0 ? ( - + ) : ( { key={flavor.id} flavor={flavor} showCategory={false} - caption={`${flavor.ratings.length} reviews`} + caption={`${flavor.ratings.length} ${t('common.reviews')}`} /> ))} diff --git a/frontend/src/pages/dashboard/MyReviewCard.tsx b/frontend/src/pages/dashboard/MyReviewCard.tsx index b6a5648..2caaca5 100644 --- a/frontend/src/pages/dashboard/MyReviewCard.tsx +++ b/frontend/src/pages/dashboard/MyReviewCard.tsx @@ -73,16 +73,16 @@ const MyReviewCard: React.FC = ({ rating, currentUsername }) await updateRating.mutateAsync({ id: rating.id, score: editScore, comment: editComment }); setIsEditing(false); } catch { - notify({ message: 'Failed to update review', severity: 'error' }); + notify({ message: t('flavorDetail.reviewUpdateFailed'), severity: 'error' }); } }; const handleDelete = async () => { - if (!(await confirm({ message: 'Delete this review?', danger: true }))) return; + if (!(await confirm({ message: t('common.confirmDeleteReview'), danger: true }))) return; try { await deleteRating.mutateAsync(rating.id); } catch { - notify({ message: 'Failed to delete review', severity: 'error' }); + notify({ message: t('flavorDetail.reviewDeleteFailed'), severity: 'error' }); } }; @@ -93,16 +93,16 @@ const MyReviewCard: React.FC = ({ rating, currentUsername }) setReplyInput(''); setExpanded(true); } catch { - notify({ message: 'Failed to send reply', severity: 'error' }); + notify({ message: t('common.replyFailed'), severity: 'error' }); } }; const handleDeleteReply = async (replyId: number) => { - if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; + if (!(await confirm({ message: t('common.confirmDeleteReply'), danger: true }))) return; try { await deleteReply.mutateAsync(replyId); } catch { - notify({ message: 'Failed to delete reply', severity: 'error' }); + notify({ message: t('flavorDetail.replyDeleteFailed'), severity: 'error' }); } }; diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index 24c9be8..bd2f8f9 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -30,7 +30,6 @@ import { } from '../../api/mutations/useRatingMutations'; import { useUpdateReply } from '../../api/mutations/useReplyMutations'; import { useTitle } from '../../hooks/useTitle'; -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'; @@ -51,6 +50,7 @@ import { HeroGallery, ScoreInput, RatingDistribution, + BackButton, } from '../../components/ui'; import RatingForm from './components/RatingForm'; import SimilarFlavorsSection from './components/SimilarFlavorsSection'; @@ -119,7 +119,7 @@ const FlavorDetail: React.FC = () => { await updateFlavor.mutateAsync({ id, data: editFlavorData }); setIsAdminEditing(false); } catch { - notify({ message: 'Admin update failed', severity: 'error' }); + notify({ message: t('flavorDetail.adminUpdateFailed'), severity: 'error' }); } }; @@ -134,7 +134,7 @@ const FlavorDetail: React.FC = () => { setUploadingImage(true); await updateFlavor.mutateAsync({ id, data: formData, isFormData: true }); } catch { - notify({ message: 'Image upload failed', severity: 'error' }); + notify({ message: t('flavorDetail.imageUploadFailed'), severity: 'error' }); } finally { setUploadingImage(false); } @@ -153,7 +153,7 @@ const FlavorDetail: React.FC = () => { setReplyInputs({ ...replyInputs, [ratingId]: '' }); setExpandedReplies((prev) => ({ ...prev, [ratingId]: true })); } catch { - notify({ message: 'Failed to submit reply', severity: 'error' }); + notify({ message: t('common.replyFailed'), severity: 'error' }); } }; @@ -163,25 +163,25 @@ const FlavorDetail: React.FC = () => { await updateReply.mutateAsync({ replyId, text: editReplyText }); setEditingReplyId(null); } catch { - notify({ message: 'Failed to update reply', severity: 'error' }); + notify({ message: t('flavorDetail.replyUpdateFailed'), severity: 'error' }); } }; const handleDeleteReply = async (replyId: number) => { - if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; + if (!(await confirm({ message: t('common.confirmDeleteReply'), danger: true }))) return; try { await deleteReplyMutation.mutateAsync(replyId); } catch { - notify({ message: 'Failed to delete reply', severity: 'error' }); + notify({ message: t('flavorDetail.replyDeleteFailed'), severity: 'error' }); } }; const handleDeleteRating = async (ratingId: number) => { - if (!(await confirm({ message: 'Delete this review?', danger: true }))) return; + if (!(await confirm({ message: t('common.confirmDeleteReview'), danger: true }))) return; try { await deleteRatingMutation.mutateAsync(ratingId); } catch { - notify({ message: 'Failed to delete review', severity: 'error' }); + notify({ message: t('flavorDetail.reviewDeleteFailed'), severity: 'error' }); } }; @@ -196,15 +196,7 @@ const FlavorDetail: React.FC = () => { await updateRating.mutateAsync({ id: ratingId, score: editScore, comment: editComment }); setEditMode(null); } catch { - notify({ message: 'Failed to update review', severity: 'error' }); - } - }; - - const handleGoBack = () => { - if (window.history.length > 1) { - navigate(-1); - } else { - navigate('/'); + notify({ message: t('flavorDetail.reviewUpdateFailed'), severity: 'error' }); } }; @@ -225,20 +217,7 @@ const FlavorDetail: React.FC = () => { return ( }> - + {/* Left: Product Info Card */} @@ -268,7 +247,7 @@ const FlavorDetail: React.FC = () => { size="small" onClick={() => setIsAdminEditing(true)} > - Edit Product (Admin) + {t('flavorDetail.editProductAdmin')} ) : ( @@ -279,14 +258,14 @@ const FlavorDetail: React.FC = () => { variant="contained" onClick={handleAdminUpdate} > - Save + {t('common.save')} )} @@ -296,7 +275,7 @@ const FlavorDetail: React.FC = () => { {isAdminEditing ? ( { } /> { } /> { fullWidth disabled={uploadingImage} > - {uploadingImage ? 'Uploading...' : 'Upload Custom Image'} + {uploadingImage + ? t('flavorDetail.uploading') + : t('flavorDetail.uploadImage')} @@ -347,7 +328,7 @@ const FlavorDetail: React.FC = () => { } /> } - label="In Stock" + label={t('flavorDetail.inStock')} /> { } /> } - label="Legacy / Limited" + label={t('flavorDetail.legacyLimited')} /> diff --git a/frontend/ux-improvements.md b/frontend/ux-improvements.md new file mode 100644 index 0000000..77ed9ea --- /dev/null +++ b/frontend/ux-improvements.md @@ -0,0 +1,38 @@ +# UX & Design Improvements + +Tracking doc from the frontend/UX audit. Items grouped by priority. Check off as done. + +## 🔴 High impact + +- [ ] **#1 i18n sweep** — app claims EN/DE but ~30+ user-facing strings are English-only. + - [x] `App.tsx` 404 page + - [x] `CategoryFlavors.tsx` empty state + review caption + - [x] `CommunityFeed.tsx` "No comment provided.", reply-failed toast + - [x] `FlavorDetail.tsx` admin edit labels, confirm dialogs, error toasts + - [x] `Login.tsx` reset/verify flow labels, buttons, messages + - [x] `MyReviewCard.tsx` confirm dialogs, error toasts + - [x] `PublicProfile.tsx` tabs, empty states, confirm dialogs, error toasts + - [ ] **Deferred (admin-only, superuser surface):** `AdminUserDetail.tsx`, `admin/*` (Banners, Tickets, Overview, Config, Jobs, Users), `AdminRatingDetail.tsx`, `AdminReplyDetail.tsx`, `Settings.tsx` theme-list subheaders. Lower UX priority — only the operator sees these. +- [x] **#2 Delete dead code** — `FlavorList.tsx` (240 LOC) + `AdminDashboard.tsx` (873 LOC). Zero imports, not in router (replaced by `admin/` folder + `flavors/FlavorDetail`). +- [ ] **#3 Add brand/logo** — no app identity anchor in sidebar or mobile appbar. +- [ ] **#4 Categories discoverable** — collapsed-by-default accordion hides primary browse axis. + +## 🟡 Medium + +- [ ] **#5 Hall of Fame carousel** — no swipe/auto-advance; arrows far from card on mobile; featured quote is arbitrary (first-with-comment). +- [ ] **#6 Admin edit leaks into product card** (`FlavorDetail.tsx`) — move to dialog/drawer. +- [ ] **#7 CommunityFeed duplication** — in-page notifications + following duplicate the nav/global menus. +- [ ] **#8 Reply Enter-to-submit on multiline** — surprising; blocks newlines. +- [ ] **#9 Login password UX** — no show/hide toggle, no strength hint, no inline validation. + +## 🟢 Design-system cleanup + +- [ ] **#10 Unify grid breakpoints** — 3 different responsive card grids across browse surfaces. +- [x] **#11 Extract ``** — repeated with drifting `sx` on 5+ pages. +- [ ] **#12 Font-weight scale** — 600/700/800/900/'bold' scattered; enforce via theme. +- [ ] **#13 Avatar fallback casing** — inconsistent `.toUpperCase()`. +- [ ] **#14 Image placeholder** — `CommunityFeed`/`MyReviewCard` raw `` can show broken-image icon; reuse `FlavorCard` pattern. +- [ ] **#15 Standardize empty states** — `EmptyState` component vs inline ``. + +## ✅ Good (keep) +ScoreInput a11y (radiogroup, arrow/number keys). Glass design system + skeletons. Optimistic mutations. Lazy routes. Status/Rating badges.