mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
changed flavor cards
This commit is contained in:
1 parent
6f5f3eaf37
commit
b521c82042
4 files changed
+271
-113
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in new issue
Block a user