mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
redesign flavor page
This commit is contained in:
1 parent
c121380c6e
commit
ecedabe8b0
12 files changed
+928
-95
No files matched your search
@@ -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"])
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+29
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface FlavorDetailData {
|
||||
user_rating: number | null;
|
||||
ratings: FlavorDetailRating[];
|
||||
image_url: string | null;
|
||||
image_urls: string[];
|
||||
rating_distribution: Record<string, number>;
|
||||
is_available: boolean;
|
||||
is_legacy: boolean;
|
||||
shop_url: string | null;
|
||||
|
||||
@@ -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<CarouselProps> = ({ 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<number | null>(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 (
|
||||
<Box sx={{ position: 'relative', width: '100%' }}>
|
||||
<Box
|
||||
ref={emblaRef}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label={t('flavorDetail.galleryLabel', { name: alt })}
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
aspectRatio: isLightbox ? 'auto' : '1 / 1',
|
||||
height: isLightbox ? '80vh' : 'auto',
|
||||
bgcolor: isLightbox ? 'transparent' : 'action.hover',
|
||||
borderRadius: isLightbox ? 0 : 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', height: '100%' }}>
|
||||
{images.map((src, i) => (
|
||||
<Box
|
||||
key={`${src}-${i}`}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
aria-label={t('flavorDetail.imageOf', { current: i + 1, total: images.length })}
|
||||
sx={{
|
||||
flex: '0 0 100%',
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: isLightbox ? 4 : 4,
|
||||
cursor: onMainClick ? 'zoom-in' : 'default',
|
||||
}}
|
||||
onClick={onMainClick}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={src}
|
||||
alt={`${alt} — ${i + 1}`}
|
||||
loading={i === 0 ? 'eager' : 'lazy'}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
filter: isLightbox
|
||||
? 'drop-shadow(0px 24px 48px rgba(0,0,0,0.5))'
|
||||
: 'drop-shadow(0px 10px 20px rgba(0,0,0,0.15))',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Counter pill */}
|
||||
{images.length > 1 && (
|
||||
<GlassSurface
|
||||
intensity="strong"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
px: 1.25,
|
||||
py: 0.4,
|
||||
borderRadius: theme.tokens.radius.pill + 'px',
|
||||
opacity: counterVisible ? 0.95 : 0,
|
||||
transition: 'opacity 300ms ease',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{selectedIndex + 1} / {images.length}
|
||||
</Typography>
|
||||
</GlassSurface>
|
||||
)}
|
||||
|
||||
{/* Chevrons (desktop only) */}
|
||||
{images.length > 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 1,
|
||||
'@media (hover: none)': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
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) },
|
||||
}}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
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) },
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Thumbnails */}
|
||||
{images.length > 1 && !isLightbox && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 0.75,
|
||||
mt: 1.5,
|
||||
mx: 2,
|
||||
mb: 1.5,
|
||||
overflowX: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
'&::-webkit-scrollbar': { height: 6 },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.2),
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{images.map((src, i) => {
|
||||
const active = selectedIndex === i;
|
||||
return (
|
||||
<ButtonBase
|
||||
key={`thumb-${src}-${i}`}
|
||||
onClick={() => 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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'contain', p: 0.5 }}
|
||||
/>
|
||||
</ButtonBase>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const HeroGallery: React.FC<HeroGalleryProps> = ({ images, alt, badge, enableLightbox = true }) => {
|
||||
const { t } = useTranslation();
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const hasImages = images.length > 0;
|
||||
const single = images.length === 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlassCard intensity="strong" sx={{ overflow: 'hidden', position: 'relative' }}>
|
||||
{badge && <Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 2 }}>{badge}</Box>}
|
||||
|
||||
{!hasImages ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
aspectRatio: '1 / 1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">{t('flavorDetail.noImage')}</Typography>
|
||||
</Box>
|
||||
) : single ? (
|
||||
<Box
|
||||
onClick={enableLightbox ? () => setLightboxOpen(true) : undefined}
|
||||
sx={{
|
||||
p: 4,
|
||||
aspectRatio: '1 / 1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
cursor: enableLightbox ? 'zoom-in' : 'default',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={images[0]}
|
||||
alt={alt}
|
||||
loading="eager"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
filter: 'drop-shadow(0px 10px 20px rgba(0,0,0,0.15))',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Carousel
|
||||
images={images}
|
||||
alt={alt}
|
||||
variant="inline"
|
||||
onMainClick={enableLightbox ? () => setLightboxOpen(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{enableLightbox && hasImages && (
|
||||
<Modal
|
||||
open={lightboxOpen}
|
||||
onClose={() => 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) },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: { xs: '100vw', sm: '90vw' },
|
||||
maxWidth: 1200,
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => 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) },
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
{single ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '80vh',
|
||||
p: 4,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={images[0]}
|
||||
alt={alt}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
filter: 'drop-shadow(0px 24px 48px rgba(0,0,0,0.5))',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Carousel images={images} alt={alt} variant="lightbox" />
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroGallery;
|
||||
@@ -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<string, number>;
|
||||
total?: number;
|
||||
height?: number;
|
||||
showLegend?: boolean;
|
||||
}
|
||||
|
||||
const SCORES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const;
|
||||
|
||||
const RatingDistribution: React.FC<RatingDistributionProps> = ({
|
||||
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 (
|
||||
<Box sx={{ width: '100%', mb: 1 }}>
|
||||
{showLegend && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
mb: 0.5,
|
||||
color: 'text.secondary',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
fontSize: '0.65rem',
|
||||
}}
|
||||
>
|
||||
{t('flavorDetail.ratingDistribution')}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
role="img"
|
||||
aria-label={t('flavorDetail.ratingDistribution')}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: 0.5,
|
||||
height,
|
||||
px: 0.5,
|
||||
py: 0.5,
|
||||
borderRadius: theme.tokens.radius.sm + 'px',
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.04),
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{counts.map((c, i) => {
|
||||
const score = SCORES[i];
|
||||
const ratio = c / max;
|
||||
const minH = c > 0 ? 4 : 2;
|
||||
return (
|
||||
<Tooltip
|
||||
key={score}
|
||||
title={`${score} / 10 — ${c} ${c === 1 ? 'rating' : 'ratings'}`}
|
||||
placement="top"
|
||||
arrow
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
height: '100%',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: c > 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',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
mt: 0.25,
|
||||
px: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.65rem' }}>
|
||||
1
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.65rem' }}>
|
||||
10
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingDistribution;
|
||||
@@ -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<Size, { tile: number; font: string; readoutVariant: 'h6' | 'h5' | 'h4' }> = {
|
||||
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<ScoreInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
size = 'large',
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
showReadout = true,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dims = sizeMap[size];
|
||||
|
||||
const focusTile = (n: number) => {
|
||||
const target = containerRef.current?.querySelector<HTMLButtonElement>(`[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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.25,
|
||||
alignItems: 'flex-start',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
pointerEvents: disabled ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={containerRef}
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel ?? 'Score 1 to 10'}
|
||||
onKeyDown={handleKeyDown}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 0.75,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{SCORES.map((n) => {
|
||||
const selected = value === n;
|
||||
const filled = value !== null && n <= value;
|
||||
const fillAlpha = selected ? 1 : filled ? 0.22 : 0;
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
key={n}
|
||||
data-score={n}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={`Score ${n}`}
|
||||
tabIndex={focusableScore === n ? 0 : -1}
|
||||
onClick={() => 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}
|
||||
</ButtonBase>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{showReadout && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 0.5,
|
||||
opacity: value === null ? 0 : 1,
|
||||
transition: 'opacity 200ms ease',
|
||||
minHeight: 28,
|
||||
}}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Typography
|
||||
variant={dims.readoutVariant}
|
||||
sx={{
|
||||
fontWeight: 900,
|
||||
color: 'primary.main',
|
||||
lineHeight: 1,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{value ?? 0}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.secondary', fontWeight: 700, lineHeight: 1 }}
|
||||
>
|
||||
/ 10
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScoreInput;
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
|
||||
@@ -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<FlavorDetailProps> = ({ 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<FlavorDetailProps> = ({ adminMode }) => {
|
||||
is_legacy: false,
|
||||
});
|
||||
|
||||
// Rating form state
|
||||
const [newScore, setNewScore] = useState<number | null>(null);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
// Edit state
|
||||
const [editMode, setEditMode] = useState<number | null>(null);
|
||||
const [editScore, setEditScore] = useState(0);
|
||||
@@ -110,6 +113,8 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ 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<FlavorDetailProps> = ({ 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<FlavorDetailProps> = ({ adminMode }) => {
|
||||
if (!flavor)
|
||||
return (
|
||||
<PageShell>
|
||||
<Typography>Flavor not found</Typography>
|
||||
<Typography>{t('flavorDetail.flavorNotFound')}</Typography>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -253,36 +241,21 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
{/* Left: Product Info Card */}
|
||||
<Grid size={{ xs: 12, lg: 4 }}>
|
||||
<Box sx={{ position: { md: 'sticky' }, top: 100 }}>
|
||||
<GlassCard intensity="strong" sx={{ overflow: 'hidden' }}>
|
||||
<HeroGallery
|
||||
images={galleryImages}
|
||||
alt={flavor.name}
|
||||
badge={<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} />}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
position: 'relative',
|
||||
aspectRatio: '1/1',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 1 }}>
|
||||
<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} />
|
||||
</Box>
|
||||
{flavor.image_url ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
filter: 'drop-shadow(0px 10px 20px rgba(0,0,0,0.15))',
|
||||
display: { xs: 'block', lg: 'none' },
|
||||
height: 2,
|
||||
my: 2,
|
||||
background: theme.tokens.accent.softGradient,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography color="text.secondary">No Image</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<GlassCard sx={{ mt: { xs: 0, lg: 2 } }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{adminMode && (
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
@@ -402,7 +375,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
{flavor.name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="large" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
@@ -413,6 +386,15 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{flavor.ratings.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<RatingDistribution
|
||||
distribution={flavor.rating_distribution}
|
||||
total={flavor.ratings.length}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ lineHeight: 1.7, color: 'text.secondary', mb: 4 }}
|
||||
@@ -451,11 +433,11 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
{/* Right: Ratings & Comments */}
|
||||
<Grid size={{ xs: 12, lg: 8 }}>
|
||||
<SectionHeader
|
||||
title="Ratings & Comments"
|
||||
title={t('flavorDetail.ratingsAndComments')}
|
||||
subtitle={
|
||||
flavor.ratings.length === 0
|
||||
? 'Be the first to rate this flavor!'
|
||||
: `Join the discussion with ${flavor.ratings.length} other fans.`
|
||||
? t('flavorDetail.beTheFirst')
|
||||
: t('flavorDetail.joinDiscussion', { count: flavor.ratings.length })
|
||||
}
|
||||
compact
|
||||
/>
|
||||
@@ -463,38 +445,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
{/* Add Rating Form */}
|
||||
{currentUser && flavor.user_rating === null && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<GlassCard intensity="subtle">
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
Rate this flavor
|
||||
</Typography>
|
||||
<form onSubmit={handleRatingSubmit}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={newScore}
|
||||
onChange={(_, val) => setNewScore(val)}
|
||||
size="large"
|
||||
/>
|
||||
</Box>
|
||||
<MentionTextField
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="Share your thoughts..."
|
||||
value={newComment}
|
||||
onChange={(val) => setNewComment(val)}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={!newScore}
|
||||
sx={{ mt: 2, borderRadius: 2, fontWeight: 'bold' }}
|
||||
>
|
||||
Submit Review
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
<RatingForm flavorId={flavor.id} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -507,11 +458,11 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{editMode === rating.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={editScore}
|
||||
onChange={(_, val) => setEditScore(val || 0)}
|
||||
size="large"
|
||||
<ScoreInput
|
||||
value={editScore || null}
|
||||
onChange={(val) => setEditScore(val)}
|
||||
size="small"
|
||||
ariaLabel={t('flavorDetail.scoreLabel')}
|
||||
/>
|
||||
<MentionTextField
|
||||
multiline
|
||||
@@ -598,7 +549,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
color="text.secondary"
|
||||
sx={{ mb: 2, fontStyle: 'italic' }}
|
||||
>
|
||||
No comment provided.
|
||||
{t('flavorDetail.noComment')}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<RatingFormProps> = ({ flavorId }) => {
|
||||
const { t } = useTranslation();
|
||||
const { notify } = useToast();
|
||||
const createRating = useCreateRating();
|
||||
const [score, setScore] = useState<number | null>(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 (
|
||||
<GlassCard intensity="default">
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('flavorDetail.rateThisFlavor')}
|
||||
</Typography>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<ScoreInput
|
||||
value={score}
|
||||
onChange={setScore}
|
||||
size="large"
|
||||
ariaLabel={t('flavorDetail.scoreLabel')}
|
||||
/>
|
||||
</Box>
|
||||
<MentionTextField
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder={t('flavorDetail.shareThoughts')}
|
||||
value={comment}
|
||||
onChange={setComment}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={!score || createRating.isPending}
|
||||
sx={{ mt: 2, borderRadius: 2, fontWeight: 'bold' }}
|
||||
>
|
||||
{t('flavorDetail.submitReview')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingForm;
|
||||
Reference in new issue
Block a user