redesign flavor page

This commit is contained in:
nvrl committed 2026-04-26 13:10:34 +02:00
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"])
+22
View File
@@ -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:
+29
View File
@@ -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",
+1
View File
@@ -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;
+396
View File
@@ -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;
+189
View File
@@ -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;
+3
View File
@@ -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';
+40
View File
@@ -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',
+46 -95
View File
@@ -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;