From ecedabe8b0d39aeaa05d0b9ab5ea21f56e71d16f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 26 Apr 2026 13:10:34 +0200 Subject: [PATCH] redesign flavor page --- .../api/management/commands/sync_flavors.py | 2 + backend/api/serializers/flavor.py | 22 + frontend/package-lock.json | 29 ++ frontend/package.json | 1 + frontend/src/api/queries/useFlavorDetail.ts | 2 + frontend/src/components/ui/HeroGallery.tsx | 396 ++++++++++++++++++ .../src/components/ui/RatingDistribution.tsx | 125 ++++++ frontend/src/components/ui/ScoreInput.tsx | 189 +++++++++ frontend/src/components/ui/index.ts | 3 + frontend/src/i18n.ts | 40 ++ frontend/src/pages/flavors/FlavorDetail.tsx | 149 +++---- .../pages/flavors/components/RatingForm.tsx | 73 ++++ 12 files changed, 932 insertions(+), 99 deletions(-) create mode 100644 frontend/src/components/ui/HeroGallery.tsx create mode 100644 frontend/src/components/ui/RatingDistribution.tsx create mode 100644 frontend/src/components/ui/ScoreInput.tsx create mode 100644 frontend/src/pages/flavors/components/RatingForm.tsx diff --git a/backend/api/management/commands/sync_flavors.py b/backend/api/management/commands/sync_flavors.py index 6129168..3040dae 100644 --- a/backend/api/management/commands/sync_flavors.py +++ b/backend/api/management/commands/sync_flavors.py @@ -132,6 +132,7 @@ class Command(BaseCommand): is_available = any(v.get("available") for v in variants) images = p.get("images", []) image_url = images[0].get("src") if images else None + image_urls_list = [img.get("src") for img in images if img.get("src")] description = self.clean_html(p.get("body_html", "")) handle = p.get("handle") shop_url = f"https://weareholy.com/products/{handle}" if handle else None @@ -196,6 +197,7 @@ class Command(BaseCommand): ) flavor.image_url = image_url + flavor.image_urls = image_urls_list flavor.save() synced_external_ids.append(p["id"]) diff --git a/backend/api/serializers/flavor.py b/backend/api/serializers/flavor.py index c05f91d..4f949d3 100644 --- a/backend/api/serializers/flavor.py +++ b/backend/api/serializers/flavor.py @@ -1,3 +1,4 @@ +from django.db.models import Count from rest_framework import serializers from api.models import Category, Flavor, Rating @@ -19,6 +20,8 @@ class FlavorSerializer(serializers.ModelSerializer): user_rating = serializers.SerializerMethodField() ratings = RatingSerializer(many=True, read_only=True) image_url = serializers.SerializerMethodField() + image_urls = serializers.SerializerMethodField() + rating_distribution = serializers.SerializerMethodField() class Meta: model = Flavor @@ -34,10 +37,12 @@ class FlavorSerializer(serializers.ModelSerializer): "user_rating", "ratings", "image_url", + "image_urls", "image", "is_available", "is_legacy", "shop_url", + "rating_distribution", ] extra_kwargs = {"image": {"write_only": True}} @@ -46,6 +51,23 @@ class FlavorSerializer(serializers.ModelSerializer): return absolute_image_url(self, obj.image) return obj.image_url + def get_image_urls(self, obj: Flavor) -> list[str]: + urls: list[str] = [] + if obj.image: + absolute = absolute_image_url(self, obj.image) + if absolute: + urls.append(absolute) + urls.extend(obj.image_urls or []) + return urls + + def get_rating_distribution(self, obj: Flavor) -> dict[str, int]: + counts = dict.fromkeys((str(i) for i in range(1, 11)), 0) + for row in obj.ratings.values("score").annotate(c=Count("id")): + score = row["score"] + if 1 <= score <= 10: + counts[str(score)] = row["c"] + return counts + def get_user_rating(self, obj: Flavor) -> int | None: request = self.context.get("request") if request and request.user.is_authenticated: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 984c44c..e8e7f6e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@tanstack/react-query-devtools": "^5.100.3", "axios": "^1.13.5", "colorthief": "^2.6.0", + "embla-carousel-react": "^8.6.0", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.2", "react": "^19.2.0", @@ -3740,6 +3741,34 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 256261c..4c778a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@tanstack/react-query-devtools": "^5.100.3", "axios": "^1.13.5", "colorthief": "^2.6.0", + "embla-carousel-react": "^8.6.0", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.2", "react": "^19.2.0", diff --git a/frontend/src/api/queries/useFlavorDetail.ts b/frontend/src/api/queries/useFlavorDetail.ts index e1ce6b5..6587ea6 100644 --- a/frontend/src/api/queries/useFlavorDetail.ts +++ b/frontend/src/api/queries/useFlavorDetail.ts @@ -29,6 +29,8 @@ export interface FlavorDetailData { user_rating: number | null; ratings: FlavorDetailRating[]; image_url: string | null; + image_urls: string[]; + rating_distribution: Record; is_available: boolean; is_legacy: boolean; shop_url: string | null; diff --git a/frontend/src/components/ui/HeroGallery.tsx b/frontend/src/components/ui/HeroGallery.tsx new file mode 100644 index 0000000..08af4a0 --- /dev/null +++ b/frontend/src/components/ui/HeroGallery.tsx @@ -0,0 +1,396 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, ButtonBase, IconButton, Modal, Typography, alpha, useTheme } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import CloseIcon from '@mui/icons-material/Close'; +import useEmblaCarousel from 'embla-carousel-react'; +import { GlassCard, GlassSurface } from './Glass'; + +export interface HeroGalleryProps { + images: string[]; + alt: string; + badge?: React.ReactNode; + enableLightbox?: boolean; +} + +interface CarouselProps { + images: string[]; + alt: string; + onMainClick?: () => void; + variant: 'inline' | 'lightbox'; +} + +const Carousel: React.FC = ({ images, alt, onMainClick, variant }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false, align: 'center' }); + const [selectedIndex, setSelectedIndex] = useState(0); + const [canPrev, setCanPrev] = useState(false); + const [canNext, setCanNext] = useState(false); + const [counterVisible, setCounterVisible] = useState(true); + const counterTimeoutRef = useRef(null); + + const updateState = useCallback(() => { + if (!emblaApi) return; + setSelectedIndex(emblaApi.selectedScrollSnap()); + setCanPrev(emblaApi.canScrollPrev()); + setCanNext(emblaApi.canScrollNext()); + }, [emblaApi]); + + const flashCounter = useCallback(() => { + setCounterVisible(true); + if (counterTimeoutRef.current) window.clearTimeout(counterTimeoutRef.current); + counterTimeoutRef.current = window.setTimeout(() => setCounterVisible(false), 1500); + }, []); + + useEffect(() => { + if (!emblaApi) return; + updateState(); + emblaApi.on('select', updateState); + emblaApi.on('reInit', updateState); + emblaApi.on('select', flashCounter); + flashCounter(); + return () => { + if (counterTimeoutRef.current) window.clearTimeout(counterTimeoutRef.current); + }; + }, [emblaApi, updateState, flashCounter]); + + const scrollPrev = () => emblaApi?.scrollPrev(); + const scrollNext = () => emblaApi?.scrollNext(); + const scrollTo = (idx: number) => emblaApi?.scrollTo(idx); + + const isLightbox = variant === 'lightbox'; + + return ( + + + + {images.map((src, i) => ( + + + + ))} + + + + {/* Counter pill */} + {images.length > 1 && ( + + + {selectedIndex + 1} / {images.length} + + + )} + + {/* Chevrons (desktop only) */} + {images.length > 1 && ( + + { + e.stopPropagation(); + scrollPrev(); + }} + disabled={!canPrev} + aria-label={t('flavorDetail.previousImage')} + sx={{ + pointerEvents: 'auto', + bgcolor: alpha(theme.palette.background.paper, 0.6), + backdropFilter: theme.tokens.glass.blur, + opacity: canPrev ? 1 : 0, + transition: 'opacity 200ms ease', + '&:hover': { bgcolor: alpha(theme.palette.background.paper, 0.85) }, + }} + > + + + { + e.stopPropagation(); + scrollNext(); + }} + disabled={!canNext} + aria-label={t('flavorDetail.nextImage')} + sx={{ + pointerEvents: 'auto', + bgcolor: alpha(theme.palette.background.paper, 0.6), + backdropFilter: theme.tokens.glass.blur, + opacity: canNext ? 1 : 0, + transition: 'opacity 200ms ease', + '&:hover': { bgcolor: alpha(theme.palette.background.paper, 0.85) }, + }} + > + + + + )} + + {/* Thumbnails */} + {images.length > 1 && !isLightbox && ( + + {images.map((src, i) => { + const active = selectedIndex === i; + return ( + scrollTo(i)} + aria-label={t('flavorDetail.showImage', { n: i + 1 })} + aria-current={active ? 'true' : undefined} + sx={{ + flex: '0 0 auto', + width: 44, + height: 44, + borderRadius: theme.tokens.radius.sm + 'px', + border: '2px solid', + borderColor: active ? 'primary.main' : alpha(theme.palette.text.primary, 0.12), + overflow: 'hidden', + bgcolor: 'action.hover', + transition: 'border-color 160ms ease, transform 160ms ease', + '&:hover': { borderColor: 'primary.main', transform: 'translateY(-1px)' }, + '&:focus-visible': { + outline: `2px solid ${alpha(theme.palette.primary.main, 0.6)}`, + outlineOffset: 2, + }, + }} + > + + + ); + })} + + )} + + ); +}; + +const HeroGallery: React.FC = ({ images, alt, badge, enableLightbox = true }) => { + const { t } = useTranslation(); + const [lightboxOpen, setLightboxOpen] = useState(false); + + const hasImages = images.length > 0; + const single = images.length === 1; + + return ( + <> + + {badge && {badge}} + + {!hasImages ? ( + + {t('flavorDetail.noImage')} + + ) : single ? ( + setLightboxOpen(true) : undefined} + sx={{ + p: 4, + aspectRatio: '1 / 1', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + bgcolor: 'action.hover', + cursor: enableLightbox ? 'zoom-in' : 'default', + }} + > + + + ) : ( + setLightboxOpen(true) : undefined} + /> + )} + + + {enableLightbox && hasImages && ( + setLightboxOpen(false)} + aria-label={t('flavorDetail.galleryLabel', { name: alt })} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backdropFilter: 'blur(8px)', + }} + slotProps={{ + backdrop: { + sx: { bgcolor: alpha('#000', 0.85) }, + }, + }} + > + + setLightboxOpen(false)} + aria-label="Close" + sx={{ + position: 'absolute', + top: 8, + right: 8, + zIndex: 3, + color: 'common.white', + bgcolor: alpha('#000', 0.4), + '&:hover': { bgcolor: alpha('#000', 0.6) }, + }} + > + + + {single ? ( + + + + ) : ( + + )} + + + )} + + ); +}; + +export default HeroGallery; diff --git a/frontend/src/components/ui/RatingDistribution.tsx b/frontend/src/components/ui/RatingDistribution.tsx new file mode 100644 index 0000000..8797f68 --- /dev/null +++ b/frontend/src/components/ui/RatingDistribution.tsx @@ -0,0 +1,125 @@ +import React, { useMemo } from 'react'; +import { Box, Tooltip, Typography, alpha, useTheme } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +export interface RatingDistributionProps { + distribution: Record; + total?: number; + height?: number; + showLegend?: boolean; +} + +const SCORES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const; + +const RatingDistribution: React.FC = ({ + distribution, + total, + height = 56, + showLegend = true, +}) => { + const theme = useTheme(); + const { t } = useTranslation(); + + const counts = useMemo(() => SCORES.map((n) => distribution[String(n)] ?? 0), [distribution]); + const sum = total ?? counts.reduce((a, b) => a + b, 0); + const max = Math.max(...counts, 1); + + const lowColor = alpha(theme.palette.text.primary, 0.18); + const highColor = theme.palette.primary.main; + + const interpolate = (n: number) => { + const ratio = (n - 1) / 9; + return alpha(highColor, 0.25 + ratio * 0.7); + }; + + if (sum === 0) return null; + + return ( + + {showLegend && ( + + {t('flavorDetail.ratingDistribution')} + + )} + + {counts.map((c, i) => { + const score = SCORES[i]; + const ratio = c / max; + const minH = c > 0 ? 4 : 2; + return ( + + + 0 ? `calc(${ratio * 100}% + ${minH}px)` : `${minH}px`, + minHeight: minH, + bgcolor: c > 0 ? interpolate(score) : lowColor, + borderRadius: '3px', + transition: 'height 240ms ease, background-color 200ms ease', + }} + /> + + + ); + })} + + + + 1 + + + 10 + + + + ); +}; + +export default RatingDistribution; diff --git a/frontend/src/components/ui/ScoreInput.tsx b/frontend/src/components/ui/ScoreInput.tsx new file mode 100644 index 0000000..7234a5b --- /dev/null +++ b/frontend/src/components/ui/ScoreInput.tsx @@ -0,0 +1,189 @@ +import React, { useRef } from 'react'; +import { Box, ButtonBase, Typography, alpha, useTheme } from '@mui/material'; + +type Size = 'small' | 'medium' | 'large'; + +export interface ScoreInputProps { + value: number | null; + onChange: (value: number) => void; + size?: Size; + disabled?: boolean; + ariaLabel?: string; + showReadout?: boolean; +} + +const sizeMap: Record = { + small: { tile: 32, font: '0.85rem', readoutVariant: 'h6' }, + medium: { tile: 38, font: '0.95rem', readoutVariant: 'h5' }, + large: { tile: 44, font: '1.1rem', readoutVariant: 'h4' }, +}; + +const SCORES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const; + +const ScoreInput: React.FC = ({ + value, + onChange, + size = 'large', + disabled = false, + ariaLabel, + showReadout = true, +}) => { + const theme = useTheme(); + const containerRef = useRef(null); + const dims = sizeMap[size]; + + const focusTile = (n: number) => { + const target = containerRef.current?.querySelector(`[data-score="${n}"]`); + target?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (disabled) return; + const current = value ?? 1; + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + e.preventDefault(); + const next = Math.min(10, current + 1); + onChange(next); + focusTile(next); + return; + } + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + e.preventDefault(); + const next = Math.max(1, current - 1); + onChange(next); + focusTile(next); + return; + } + if (e.key === 'Home') { + e.preventDefault(); + onChange(1); + focusTile(1); + return; + } + if (e.key === 'End') { + e.preventDefault(); + onChange(10); + focusTile(10); + return; + } + if (/^[0-9]$/.test(e.key)) { + e.preventDefault(); + const n = e.key === '0' ? 10 : Number(e.key); + onChange(n); + focusTile(n); + } + }; + + const focusableScore = value ?? 1; + + return ( + + + {SCORES.map((n) => { + const selected = value === n; + const filled = value !== null && n <= value; + const fillAlpha = selected ? 1 : filled ? 0.22 : 0; + + return ( + onChange(n)} + disabled={disabled} + sx={{ + width: dims.tile, + height: dims.tile, + borderRadius: theme.tokens.radius.sm + 'px', + border: '1px solid', + borderColor: selected ? 'primary.main' : alpha(theme.palette.text.primary, 0.16), + background: selected + ? theme.tokens.accent.gradient + : alpha(theme.palette.primary.main, fillAlpha), + backdropFilter: theme.tokens.glass.blur, + WebkitBackdropFilter: theme.tokens.glass.blur, + color: selected + ? theme.palette.primary.contrastText + : filled + ? 'text.primary' + : 'text.secondary', + fontWeight: 800, + fontSize: dims.font, + fontVariantNumeric: 'tabular-nums', + transition: 'transform 160ms ease, border-color 160ms ease, box-shadow 200ms ease', + boxShadow: selected ? theme.tokens.elevation.glow : 'none', + '&:hover': { + borderColor: 'primary.main', + transform: 'translateY(-1px)', + }, + '&:focus-visible': { + outline: `2px solid ${alpha(theme.palette.primary.main, 0.6)}`, + outlineOffset: 2, + }, + }} + > + {n} + + ); + })} + + + {showReadout && ( + + + {value ?? 0} + + + / 10 + + + )} + + ); +}; + +export default ScoreInput; diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 80f8ff4..d8d21dd 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -7,3 +7,6 @@ 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 { 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 09ab76c..7d6b08f 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -121,6 +121,26 @@ const resources = { save: 'Save', cancel: 'Cancel', }, + flavorDetail: { + rateThisFlavor: 'Rate this flavor', + scoreLabel: 'Your score, 1 to 10', + submitReview: 'Submit Review', + shareThoughts: 'Share your thoughts...', + selectScore: 'Please select a score', + submitFailed: 'Failed to submit rating', + noComment: 'No comment provided.', + ratingsAndComments: 'Ratings & Comments', + beTheFirst: 'Be the first to rate this flavor!', + joinDiscussion: 'Join the discussion with {{count}} other fans.', + imageOf: 'Image {{current}} of {{total}}', + previousImage: 'Previous image', + nextImage: 'Next image', + showImage: 'Show image {{n}}', + galleryLabel: '{{name}} images', + noImage: 'No Image', + ratingDistribution: 'Rating distribution', + flavorNotFound: 'Flavor not found', + }, auth: { loginTitle: 'Login', signupTitle: 'Signup', @@ -385,6 +405,26 @@ const resources = { save: 'Speichern', cancel: 'Abbrechen', }, + flavorDetail: { + rateThisFlavor: 'Diese Sorte bewerten', + scoreLabel: 'Deine Bewertung, 1 bis 10', + submitReview: 'Bewertung abschicken', + shareThoughts: 'Teile deine Gedanken...', + selectScore: 'Bitte wähle eine Bewertung', + submitFailed: 'Bewertung konnte nicht gesendet werden', + noComment: 'Kein Kommentar abgegeben.', + ratingsAndComments: 'Bewertungen & Kommentare', + beTheFirst: 'Sei der Erste, der diese Sorte bewertet!', + joinDiscussion: 'Diskutiere mit {{count}} anderen Fans.', + imageOf: 'Bild {{current}} von {{total}}', + previousImage: 'Vorheriges Bild', + nextImage: 'Nächstes Bild', + showImage: 'Bild {{n}} anzeigen', + galleryLabel: 'Bilder von {{name}}', + noImage: 'Kein Bild', + ratingDistribution: 'Bewertungsverteilung', + flavorNotFound: 'Sorte nicht gefunden', + }, auth: { loginTitle: 'Anmelden', signupTitle: 'Registrieren', diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index e0ee52f..1dea1f4 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -1,11 +1,10 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Typography, Box, CardContent, - Rating as MuiRating, Button, Avatar, CircularProgress, @@ -24,7 +23,6 @@ import { useFlavorDetail, type FlavorDetailRating } from '../../api/queries/useF import { useCurrentUserLite } from '../../api/queries/useCurrentUserLite'; import { useUpdateFlavor } from '../../api/mutations/useFlavorMutations'; import { - useCreateRating, useDeleteRating, useCreateReply, useDeleteReply, @@ -44,7 +42,17 @@ import MentionTextField from '../../components/MentionTextField'; import RichText from '../../components/RichText'; import RatingBadge from '../../components/RatingBadge'; import StatusBadge from '../../components/StatusBadge'; -import { PageShell, HeroBackdrop, GlassCard, SectionHeader, EmptyState } from '../../components/ui'; +import { + PageShell, + HeroBackdrop, + GlassCard, + SectionHeader, + EmptyState, + HeroGallery, + ScoreInput, + RatingDistribution, +} from '../../components/ui'; +import RatingForm from './components/RatingForm'; import { useToast } from '../../hooks/useToast'; import { useConfirm } from '../../hooks/useConfirm'; @@ -63,7 +71,6 @@ const FlavorDetail: React.FC = ({ adminMode }) => { const { data: meData } = useCurrentUserLite(); const currentUser = meData?.username ?? null; const updateFlavor = useUpdateFlavor(); - const createRating = useCreateRating(); const deleteRatingMutation = useDeleteRating(); const updateRating = useUpdateRating(); const createReply = useCreateReply(); @@ -83,10 +90,6 @@ const FlavorDetail: React.FC = ({ adminMode }) => { is_legacy: false, }); - // Rating form state - const [newScore, setNewScore] = useState(null); - const [newComment, setNewComment] = useState(''); - // Edit state const [editMode, setEditMode] = useState(null); const [editScore, setEditScore] = useState(0); @@ -110,6 +113,8 @@ const FlavorDetail: React.FC = ({ adminMode }) => { useTitle(flavor?.name || t('common.loading')); + const galleryImages = useMemo(() => flavor?.image_urls ?? [], [flavor?.image_urls]); + const handleAdminUpdate = async () => { if (!id) return; try { @@ -137,23 +142,6 @@ const FlavorDetail: React.FC = ({ adminMode }) => { } }; - const handleRatingSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newScore) { - notify({ message: 'Please select a score', severity: 'warning' }); - return; - } - if (!flavor) return; - try { - await createRating.mutateAsync({ flavor: flavor.id, score: newScore, comment: newComment }); - setNewScore(null); - setNewComment(''); - } catch (err) { - const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error; - notify({ message: msg || 'Failed to submit rating', severity: 'error' }); - } - }; - const handleReplySubmit = async (ratingId: number) => { const text = replyInputs[ratingId]; if (!text) return; @@ -228,7 +216,7 @@ const FlavorDetail: React.FC = ({ adminMode }) => { if (!flavor) return ( - Flavor not found + {t('flavorDetail.flavorNotFound')} ); @@ -253,36 +241,21 @@ const FlavorDetail: React.FC = ({ adminMode }) => { {/* Left: Product Info Card */} - - - - - - {flavor.image_url ? ( - - ) : ( - No Image - )} - + } + /> + + {adminMode && ( @@ -402,7 +375,7 @@ const FlavorDetail: React.FC = ({ adminMode }) => { {flavor.name} - + = ({ adminMode }) => { + {flavor.ratings.length > 0 && ( + + + + )} + = ({ adminMode }) => { {/* Right: Ratings & Comments */} @@ -463,38 +445,7 @@ const FlavorDetail: React.FC = ({ adminMode }) => { {/* Add Rating Form */} {currentUser && flavor.user_rating === null && ( - - - - Rate this flavor - -
- - setNewScore(val)} - size="large" - /> - - setNewComment(val)} - /> - - -
-
+
)} @@ -507,11 +458,11 @@ const FlavorDetail: React.FC = ({ adminMode }) => { {editMode === rating.id ? ( - setEditScore(val || 0)} - size="large" + setEditScore(val)} + size="small" + ariaLabel={t('flavorDetail.scoreLabel')} /> = ({ adminMode }) => { color="text.secondary" sx={{ mb: 2, fontStyle: 'italic' }} > - No comment provided. + {t('flavorDetail.noComment')}
)} diff --git a/frontend/src/pages/flavors/components/RatingForm.tsx b/frontend/src/pages/flavors/components/RatingForm.tsx new file mode 100644 index 0000000..543947a --- /dev/null +++ b/frontend/src/pages/flavors/components/RatingForm.tsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import { Box, Button, CardContent, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { GlassCard } from '../../../components/ui'; +import ScoreInput from '../../../components/ui/ScoreInput'; +import MentionTextField from '../../../components/MentionTextField'; +import { useCreateRating } from '../../../api/mutations/useRatingMutations'; +import { useToast } from '../../../hooks/useToast'; + +export interface RatingFormProps { + flavorId: number; +} + +const RatingForm: React.FC = ({ flavorId }) => { + const { t } = useTranslation(); + const { notify } = useToast(); + const createRating = useCreateRating(); + const [score, setScore] = useState(null); + const [comment, setComment] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!score) { + notify({ message: t('flavorDetail.selectScore'), severity: 'warning' }); + return; + } + try { + await createRating.mutateAsync({ flavor: flavorId, score, comment }); + setScore(null); + setComment(''); + } catch (err) { + const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error; + notify({ message: msg || t('flavorDetail.submitFailed'), severity: 'error' }); + } + }; + + return ( + + + + {t('flavorDetail.rateThisFlavor')} + +
+ + + + + + +
+
+ ); +}; + +export default RatingForm;