changed flavor cards

This commit is contained in:
nvrl committed 2026-04-26 13:38:01 +02:00
1 parent 6f5f3eaf37
commit b521c82042
4 files changed
+271 -113

No files matched your search

+50 -22
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Box, Typography, useTheme } from '@mui/material'; import { Box, Typography, alpha, useTheme } from '@mui/material';
interface RatingBadgeProps { interface RatingBadgeProps {
score: number; score: number;
@@ -7,46 +7,74 @@ interface RatingBadgeProps {
sx?: any; sx?: any;
} }
const sizeMap = {
small: {
px: 1,
py: 0.25,
score: '0.75rem',
suffix: '0.55rem',
gap: 0.25,
},
medium: {
px: 1.25,
py: 0.4,
score: '0.95rem',
suffix: '0.625rem',
gap: 0.35,
},
large: {
px: 1.75,
py: 0.6,
score: '1.4rem',
suffix: '0.7rem',
gap: 0.5,
},
} as const;
const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => { const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => {
const theme = useTheme(); const theme = useTheme();
const dims = sizeMap[size];
const isLarge = size === 'large'; const isLarge = size === 'large';
const isSmall = size === 'small'; const numeric = typeof score === 'number';
const display = numeric ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score;
return ( return (
<Box <Box
sx={{ sx={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'baseline',
justifyContent: 'center', justifyContent: 'center',
gap: 0.5, gap: dims.gap,
bgcolor: 'primary.main', px: dims.px,
color: 'primary.contrastText', py: dims.py,
px: isLarge ? 2 : isSmall ? 1 : 1.5, borderRadius: theme.tokens.radius.pill + 'px',
py: isLarge ? 0.5 : isSmall ? 0.2 : 0.4, background: theme.tokens.accent.gradient,
borderRadius: 2, color: theme.palette.primary.contrastText,
boxShadow: `0 6px 16px ${alpha(theme.palette.primary.main, 0.28)}, inset 0 1px 0 ${alpha('#ffffff', 0.2)}`,
minWidth: 'fit-content', minWidth: 'fit-content',
height: 'fit-content',
boxShadow:
theme.palette.mode === 'dark'
? '0 4px 12px rgba(0,0,0,0.4)'
: '0 2px 8px rgba(0,0,0,0.1)',
...sx, ...sx,
}} }}
> >
<Typography <Typography
variant={isLarge ? 'h5' : isSmall ? 'caption' : 'subtitle1'} component="span"
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }} sx={{
fontWeight: 800,
lineHeight: 1,
fontSize: dims.score,
fontVariantNumeric: 'tabular-nums',
letterSpacing: '-0.02em',
}}
> >
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score} {display}
</Typography> </Typography>
<Typography <Typography
variant="caption" component="span"
sx={{ sx={{
fontSize: isLarge ? '0.75rem' : isSmall ? '0.55rem' : '0.65rem', fontWeight: 700,
fontWeight: '900',
opacity: 0.9,
lineHeight: 1, lineHeight: 1,
mt: 0.2, fontSize: dims.suffix,
opacity: 0.75,
letterSpacing: '0.02em',
}} }}
> >
/ 10 / 10
+41 -21
View File
@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { Box, Typography, alpha, useTheme } from '@mui/material'; import { Box, Typography, alpha, useTheme } from '@mui/material';
import HistoryIcon from '@mui/icons-material/History'; import { useTranslation } from 'react-i18next';
import BlockIcon from '@mui/icons-material/Block';
interface StatusBadgeProps { interface StatusBadgeProps {
isLegacy?: boolean; isLegacy?: boolean;
@@ -10,6 +9,11 @@ interface StatusBadgeProps {
sx?: any; sx?: any;
} }
const sizeMap = {
small: { px: 0.85, py: 0.3, dot: 5, font: '0.55rem', gap: 0.5 },
medium: { px: 1.1, py: 0.45, dot: 6, font: '0.65rem', gap: 0.6 },
} as const;
const StatusBadge: React.FC<StatusBadgeProps> = ({ const StatusBadge: React.FC<StatusBadgeProps> = ({
isLegacy, isLegacy,
isAvailable, isAvailable,
@@ -17,41 +21,57 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({
sx, sx,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const isSmall = size === 'small'; const { t } = useTranslation();
if (isAvailable && !isLegacy) return null; if (isAvailable && !isLegacy) return null;
const label = isLegacy ? 'Limited' : 'Out of Stock'; const dims = sizeMap[size];
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main; const accent = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
const Icon = isLegacy ? HistoryIcon : BlockIcon; const label = isLegacy
? t('status.limited', { defaultValue: 'Limited' })
: t('status.outOfStock', { defaultValue: 'Out of Stock' });
const isLight = theme.palette.mode === 'light';
const surface = alpha(theme.palette.background.paper, isLight ? 0.85 : 0.55);
return ( return (
<Box <Box
sx={{ sx={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: 0.5, gap: dims.gap,
bgcolor: alpha(color, 0.9), bgcolor: surface,
color: '#fff', backdropFilter: theme.tokens.glass.blur,
px: isSmall ? 0.8 : 1.2, WebkitBackdropFilter: theme.tokens.glass.blur,
py: isSmall ? 0.2 : 0.4, color: 'text.primary',
borderRadius: 1.5, px: dims.px,
boxShadow: '0 4px 12px rgba(0,0,0,0.2)', py: dims.py,
backdropFilter: 'blur(4px)', borderRadius: theme.tokens.radius.pill + 'px',
border: '1px solid', border: '1px solid',
borderColor: alpha('#fff', 0.2), borderColor: alpha(accent, 0.4),
boxShadow: `0 2px 8px ${alpha(accent, 0.18)}`,
...sx, ...sx,
}} }}
> >
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} /> <Box
<Typography
variant="caption"
sx={{ sx={{
fontWeight: '900', width: dims.dot,
height: dims.dot,
borderRadius: '50%',
bgcolor: accent,
boxShadow: `0 0 0 2px ${alpha(accent, 0.2)}`,
flexShrink: 0,
}}
/>
<Typography
component="span"
sx={{
fontWeight: 700,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: '0.06em',
fontSize: isSmall ? '0.55rem' : '0.65rem', fontSize: dims.font,
lineHeight: 1, lineHeight: 1,
color: 'text.primary',
}} }}
> >
{label} {label}
+172 -70
View File
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CardContent from '@mui/material/CardContent'; import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { alpha, useTheme } from '@mui/material/styles';
import { GlassCard } from './Glass'; import { GlassCard } from './Glass';
import RatingBadge from '../RatingBadge'; import RatingBadge from '../RatingBadge';
import StatusBadge from '../StatusBadge'; import StatusBadge from '../StatusBadge';
@@ -28,92 +29,193 @@ export const FlavorCard = ({
caption, caption,
showCategory = true, showCategory = true,
compact = false, compact = false,
}: FlavorCardProps) => ( }: FlavorCardProps) => {
<Link const theme = useTheme();
to={`/flavor/${flavor.id}`} const hasRating = typeof flavor.average_rating === 'number' && flavor.average_rating > 0;
style={{ textDecoration: 'none', color: 'inherit', display: 'block', height: '100%' }}
>
<GlassCard
intensity="subtle"
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
transition: 'transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease',
'&:hover': {
transform: 'translateY(-3px)',
borderColor: 'primary.main',
boxShadow: (theme) => theme.tokens.elevation.md,
},
}}
>
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}>
<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} size="small" />
</Box>
{flavor.image_url && ( return (
<Link
to={`/flavor/${flavor.id}`}
style={{ textDecoration: 'none', color: 'inherit', display: 'block', height: '100%' }}
>
<GlassCard
intensity="subtle"
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
transition:
'border-color 200ms ease, box-shadow 200ms ease, background-color 200ms ease',
'&:hover': {
borderColor: alpha(theme.palette.primary.main, 0.5),
boxShadow: theme.tokens.elevation.md,
'& .flavor-card-image': { transform: 'scale(1.04)' },
},
}}
>
<Box <Box
sx={{ sx={{
position: 'relative',
width: '100%', width: '100%',
aspectRatio: '1 / 1', aspectRatio: '1 / 1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'background.default', bgcolor: 'background.default',
borderBottom: '1px solid', borderBottom: '1px solid',
borderColor: 'divider', borderColor: 'divider',
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{flavor.image_url ? (
<Box
component="img"
className="flavor-card-image"
src={flavor.image_url}
loading="lazy"
alt={flavor.name}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
transition: 'transform 600ms ease',
}}
/>
) : (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: alpha(theme.palette.primary.main, 0.04),
}}
>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
{flavor.name.charAt(0).toUpperCase()}
</Typography>
</Box>
)}
{/* Bottom fade for legibility of overlays */}
<Box <Box
component="img"
src={flavor.image_url}
loading="lazy"
alt={flavor.name}
sx={{ sx={{
width: '100%', position: 'absolute',
height: '100%', inset: 'auto 0 0 0',
objectFit: 'cover', height: '40%',
transition: 'transform 500ms ease', pointerEvents: 'none',
'&:hover': { transform: 'scale(1.05)' }, background: `linear-gradient(to top, ${alpha(theme.palette.background.paper, 0.55)} 0%, transparent 100%)`,
}} }}
/> />
</Box>
)}
<CardContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 0.75 }}> {/* Status badge — top-right */}
<Typography <Box sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1 }}>
variant="h6" <StatusBadge
sx={{ fontSize: compact ? '0.95rem' : '1rem', lineHeight: 1.25 }} isLegacy={flavor.is_legacy}
noWrap isAvailable={flavor.is_available}
> size="small"
{flavor.name} />
</Typography> </Box>
{showCategory && flavor.category_name && ( {/* Category chip — bottom-left */}
<Typography variant="caption" color="text.secondary" noWrap> {showCategory && flavor.category_name && (
{flavor.category_name} <Box
</Typography> sx={{
)} position: 'absolute',
bottom: 8,
<Box left: 8,
sx={{ zIndex: 1,
display: 'flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', px: 0.85,
mt: 'auto', py: 0.35,
}} borderRadius: theme.tokens.radius.pill + 'px',
> bgcolor: alpha(theme.palette.background.paper, 0.85),
<RatingBadge score={flavor.average_rating || 0} size="small" /> backdropFilter: theme.tokens.glass.blur,
{caption && ( WebkitBackdropFilter: theme.tokens.glass.blur,
<Typography variant="caption" color="text.secondary" noWrap> border: '1px solid',
{caption} borderColor: alpha(theme.palette.text.primary, 0.08),
</Typography> }}
>
<Typography
variant="caption"
sx={{
fontWeight: 700,
fontSize: '0.6rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'text.secondary',
lineHeight: 1,
}}
noWrap
>
{flavor.category_name}
</Typography>
</Box>
)} )}
</Box> </Box>
</CardContent>
</GlassCard> <CardContent
</Link> sx={{
); flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: compact ? 0.5 : 0.75,
p: compact ? 1.5 : 2,
'&:last-child': { pb: compact ? 1.5 : 2 },
}}
>
<Typography
variant="subtitle1"
sx={{
fontWeight: 700,
fontSize: compact ? '0.9rem' : '1rem',
lineHeight: 1.25,
letterSpacing: '-0.01em',
}}
noWrap
>
{flavor.name}
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
mt: 'auto',
}}
>
{hasRating ? (
<RatingBadge score={flavor.average_rating || 0} size="small" />
) : (
<Typography
variant="caption"
color="text.secondary"
sx={{
fontWeight: 600,
fontSize: '0.7rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
—
</Typography>
)}
{caption && (
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ fontWeight: 600, fontSize: '0.7rem' }}
>
{caption}
</Typography>
)}
</Box>
</CardContent>
</GlassCard>
</Link>
);
};
+8
View File
@@ -135,6 +135,10 @@ const resources = {
save: 'Save', save: 'Save',
cancel: 'Cancel', cancel: 'Cancel',
}, },
status: {
limited: 'Limited',
outOfStock: 'Out of Stock',
},
flavorDetail: { flavorDetail: {
rateThisFlavor: 'Rate this flavor', rateThisFlavor: 'Rate this flavor',
scoreLabel: 'Your score, 1 to 10', scoreLabel: 'Your score, 1 to 10',
@@ -433,6 +437,10 @@ const resources = {
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
}, },
status: {
limited: 'Limitiert',
outOfStock: 'Ausverkauft',
},
flavorDetail: { flavorDetail: {
rateThisFlavor: 'Diese Sorte bewerten', rateThisFlavor: 'Diese Sorte bewerten',
scoreLabel: 'Deine Bewertung, 1 bis 10', scoreLabel: 'Deine Bewertung, 1 bis 10',