updated user features and added palette

This commit is contained in:
nvrl committed 2026-02-16 00:14:28 +01:00
1 parent 03adde8928
commit 6b26b77e0e
11 files changed
+514 -139

No files matched your search

+1
View File
@@ -28,6 +28,7 @@ CSRF_TRUSTED_ORIGINS=http://localhost:5173
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_HOST_USER=your-email@example.com
# Do not wrap the password in quotes or backticks
EMAIL_HOST_PASSWORD=your-app-password
# --- Optional (defaults are usually fine) ---
@@ -35,6 +35,7 @@ class Command(BaseCommand):
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0],
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0],
}
created_count = 0
@@ -43,14 +44,30 @@ class Command(BaseCommand):
for p in products:
title = p.get('title')
tags = p.get('tags', [])
product_type = p.get('product_type', '').lower()
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(',')]
# Determine category
category = None
tags_lower = [t.lower() for t in tags]
title_lower = title.lower()
if 'holy energy' in tags_lower or 'energy' in tags_lower:
# Check for Packs/Bundles/Samples first
is_pack = False
pack_keywords = ['bundle', 'pack', 'set', 'box', 'probe', 'sample', 'taster', 'shaker', 'starter']
# Check title keywords
if any(k in title_lower for k in pack_keywords):
is_pack = True
# Check product type keywords
if any(k in product_type for k in pack_keywords):
is_pack = True
if is_pack:
category = cat_map['packs']
elif 'holy energy' in tags_lower or 'energy' in tags_lower:
category = cat_map['energy']
elif 'holy hydration' in tags_lower or 'hydration' in tags_lower:
category = cat_map['hydration']
@@ -59,8 +76,12 @@ class Command(BaseCommand):
elif 'milkshake' in tags_lower:
category = cat_map['milkshake']
# Skip if not a flavor product (e.g. merch, shakers)
if not category or 'merch' in tags_lower or 'shaker' in tags_lower:
# Skip only pure merch (clothing etc) if it's not a pack
if not category:
if 'merch' in tags_lower:
continue
# If we still don't have a category but it's a product, maybe put it in Packs/Other as fallback?
# For now, let's skip to avoid clutter unless we are sure.
continue
# Check availability
@@ -0,0 +1,18 @@
# Generated by Django 6.0.2 on 2026-02-15 22:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0004_user_email_confirmation_code_user_pending_email'),
]
operations = [
migrations.AlterField(
model_name='user',
name='theme',
field=models.CharField(choices=[('latte', 'Latte'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha'), ('pink', 'Pastel Pink')], default='mocha', max_length=20),
),
]
+28
View File
@@ -0,0 +1,28 @@
# Generated by Django 6.0.2 on 2026-02-15 22:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0005_alter_user_theme'),
]
operations = [
migrations.CreateModel(
name='Reply',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('rating', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='api.rating')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at'],
},
),
]
+13
View File
@@ -8,6 +8,7 @@ class User(AbstractUser):
('frappe', 'Frappé'),
('macchiato', 'Macchiato'),
('mocha', 'Mocha'),
('pink', 'Pastel Pink'),
]
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='mocha')
pending_email = models.EmailField(max_length=254, blank=True, null=True)
@@ -59,3 +60,15 @@ class Rating(models.Model):
def __str__(self):
return f"{self.user.username} - {self.flavor.name}: {self.score}"
class Reply(models.Model):
user = models.ForeignKey(User, related_name='replies', on_delete=models.CASCADE)
rating = models.ForeignKey(Rating, related_name='replies', on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f"Reply by {self.user.username} on {self.rating.id}"
+11 -2
View File
@@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import User, Flavor, Category, Rating
from .models import User, Flavor, Category, Rating, Reply
class UserSerializer(serializers.ModelSerializer):
class Meta:
@@ -11,14 +11,23 @@ class CategorySerializer(serializers.ModelSerializer):
model = Category
fields = ['id', 'name', 'slug']
class ReplySerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
class Meta:
model = Reply
fields = ['id', 'user', 'rating', 'text', 'created_at']
read_only_fields = ['rating']
class RatingSerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
flavor_name = serializers.CharField(source='flavor.name', read_only=True)
flavor_image = serializers.CharField(source='flavor.image_url', read_only=True)
replies = ReplySerializer(many=True, read_only=True)
class Meta:
model = Rating
fields = ['id', 'user', 'flavor', 'flavor_name', 'flavor_image', 'score', 'comment', 'created_at']
fields = ['id', 'user', 'flavor', 'flavor_name', 'flavor_image', 'score', 'comment', 'created_at', 'replies']
class FlavorSerializer(serializers.ModelSerializer):
category_name = serializers.CharField(source='category.name', read_only=True)
+67 -45
View File
@@ -4,11 +4,10 @@ from rest_framework.response import Response
from rest_framework.decorators import action
from django.db.models import Avg
from django_filters.rest_framework import DjangoFilterBackend
from .models import User, Flavor, Category, Rating
from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer
from .models import User, Flavor, Category, Rating, Reply
from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer
class FlavorViewSet(viewsets.ModelViewSet):
# ... (rest of FlavorViewSet)
queryset = Flavor.objects.annotate(average_rating=Avg('ratings__score')).order_by('-average_rating')
serializer_class = FlavorSerializer
filter_backends = [DjangoFilterBackend]
@@ -28,12 +27,35 @@ class RatingViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def perform_create(self, serializer):
# Ensure user can only have one rating per flavor
flavor = serializer.validated_data['flavor']
if Rating.objects.filter(user=self.request.user, flavor=flavor).exists():
raise serializers.ValidationError('You have already rated this flavor.')
serializer.save(user=self.request.user)
def get_queryset(self):
if self.action == 'list':
return Rating.objects.select_related('user', 'flavor')
return Rating.objects.filter(user=self.request.user)
if self.action in ['list', 'retrieve']:
return Rating.objects.select_related('user', 'flavor').prefetch_related('replies', 'replies__user')
# Allow users to see only their own ratings when editing/deleting?
# No, for detail views they need to find it.
# The permission class IsOwnerOrReadOnly (custom) would be better,
# but standard ViewSet logic handles "get_object" which we can protect.
return Rating.objects.select_related('user', 'flavor').prefetch_related('replies', 'replies__user')
def check_object_permissions(self, request, obj):
super().check_object_permissions(request, obj)
if request.method in ['PUT', 'PATCH', 'DELETE'] and obj.user != request.user:
self.permission_denied(request, message='You cannot edit/delete this rating.')
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def reply(self, request, pk=None):
rating = self.get_object()
text = request.data.get('text')
if not text:
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
reply = Reply.objects.create(user=request.user, rating=rating, text=text)
return Response(ReplySerializer(reply).data, status=status.HTTP_201_CREATED)
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
@@ -105,45 +127,6 @@ class UserViewSet(viewsets.ModelViewSet):
else:
return Response({'error': 'Invalid verification code'}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def request_password_reset(self, request):
email = request.data.get('email')
try:
user = User.objects.get(email=email, is_active=True)
except User.DoesNotExist:
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user.email_confirmation_code = code
user.save()
send_mail(
'Password Reset Request',
f'Hi {user.username},\n\nYour password reset code is: {code}',
'noreply@holyflavors.com',
[email],
fail_silently=False,
)
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def complete_password_reset(self, request):
email = request.data.get('email')
code = request.data.get('code')
new_password = request.data.get('password')
try:
user = User.objects.get(email=email, email_confirmation_code=code)
except User.DoesNotExist:
return Response({'error': 'Invalid email or code'}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.email_confirmation_code = None
user.save()
return Response({'status': 'Password reset successful!'})
@action(detail=False, methods=['get'])
def me(self, request):
serializer = self.get_serializer(request.user)
@@ -217,6 +200,45 @@ class UserViewSet(viewsets.ModelViewSet):
user.save()
return Response({'status': 'Email confirmed', 'email': user.email})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def request_password_reset(self, request):
email = request.data.get('email')
try:
user = User.objects.get(email=email, is_active=True)
except User.DoesNotExist:
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user.email_confirmation_code = code
user.save()
send_mail(
'Password Reset Request',
f'Hi {user.username},\n\nYour password reset code is: {code}',
'noreply@holyflavors.com',
[email],
fail_silently=False,
)
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def complete_password_reset(self, request):
email = request.data.get('email')
code = request.data.get('code')
new_password = request.data.get('password')
try:
user = User.objects.get(email=email, email_confirmation_code=code)
except User.DoesNotExist:
return Response({'error': 'Invalid email or code'}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.email_confirmation_code = None
user.save()
return Response({'status': 'Password reset successful!'})
@action(detail=False, methods=['get'])
def dashboard(self, request):
user = request.user
+8 -4
View File
@@ -25,6 +25,7 @@ import MenuIcon from '@mui/icons-material/Menu';
import { getTheme, type CatppuccinTheme } from './theme';
import CategoryList from './pages/CategoryList';
import CategoryFlavors from './pages/categories/CategoryFlavors';
import FlavorDetail from './pages/flavors/FlavorDetail';
import Dashboard from './pages/Dashboard';
import Login from './pages/Login';
import Settings from './pages/Settings';
@@ -154,10 +155,12 @@ const App: React.FC = () => {
onClose={() => setAnchorEl(null)}
>
<MenuItem onClick={() => handleThemeChange('latte')}>Latte</MenuItem>
<MenuItem onClick={() => handleThemeChange('frappe')}>Frappé</MenuItem>
<MenuItem onClick={() => handleThemeChange('macchiato')}>Macchiato</MenuItem>
<MenuItem onClick={() => handleThemeChange('mocha')}>Mocha</MenuItem>
</Menu>
<MenuItem onClick={() => handleThemeChange('frappe')}>Frappé</MenuItem>
<MenuItem onClick={() => handleThemeChange('macchiato')}>Macchiato</MenuItem>
<MenuItem onClick={() => handleThemeChange('mocha')}>Mocha</MenuItem>
<MenuItem onClick={() => handleThemeChange('pink')}>Pastel Pink</MenuItem>
</Menu>
</Box>
</Toolbar>
</AppBar>
@@ -181,6 +184,7 @@ const App: React.FC = () => {
<Routes>
<Route path="/" element={<CategoryList />} />
<Route path="/category/:slug" element={<CategoryFlavors />} />
<Route path="/flavor/:id" element={<FlavorDetail />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/login" element={<Login />} />
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import {
Typography,
Box,
@@ -7,13 +7,8 @@ import {
CardContent,
Rating as MuiRating,
TextField,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
InputAdornment
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import SearchIcon from '@mui/icons-material/Search';
import api from '../../api';
import { useTitle } from '../../hooks/useTitle';
@@ -46,7 +41,6 @@ const CategoryFlavors: React.FC = () => {
useTitle(categoryName || 'Flavors');
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [ratingInput, setRatingInput] = useState<{[key: number]: {score: number, comment: string}}>({});
useEffect(() => {
const fetchFlavors = async () => {
@@ -65,19 +59,6 @@ const CategoryFlavors: React.FC = () => {
fetchFlavors();
}, [slug]);
const handleRate = async (flavorId: number) => {
const input = ratingInput[flavorId];
if (!input) return;
try {
await api.post('ratings/', { flavor: flavorId, score: input.score, comment: input.comment });
const res = await api.get(`flavors/?category__slug=${slug}`);
setFlavors(res.data);
alert('Rating submitted!');
} catch (err) {
alert("Failed to submit rating. Make sure you are logged in.");
}
};
const filteredFlavors = flavors.filter(f =>
f.name.toLowerCase().includes(search.toLowerCase()) ||
f.description.toLowerCase().includes(search.toLowerCase())
@@ -108,60 +89,27 @@ const CategoryFlavors: React.FC = () => {
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{filteredFlavors.map(flavor => (
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 30%', lg: '1 1 23%', xl: '1 1 18%' }, minWidth: 280 }} key={flavor.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}>
{!flavor.is_available && (
<Box sx={{ position: 'absolute', top: 10, right: 10, bgcolor: 'error.main', color: 'white', px: 1, borderRadius: 1, fontSize: '0.75rem', zIndex: 1 }}>
Out of Stock
</Box>
)}
{flavor.image_url && (
<Box component="img" src={flavor.image_url} sx={{ width: '100%', height: 200, objectFit: 'contain', p: 2, bgcolor: 'rgba(255,255,255,0.05)' }} />
)}
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h6">{flavor.name}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{flavor.description}</Typography>
{flavor.shop_url && (
<Button variant="outlined" size="small" component="a" href={flavor.shop_url} target="_blank" sx={{ mb: 2, fontSize: '0.7rem' }}>
View in Shop
</Button>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative', transition: 'transform 0.2s', '&:hover': { transform: 'scale(1.02)' } }}>
<Box component={Link} to={`/flavor/${flavor.id}`} sx={{ textDecoration: 'none', color: 'inherit', flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
{!flavor.is_available && (
<Box sx={{ position: 'absolute', top: 10, right: 10, bgcolor: 'error.main', color: 'white', px: 1, borderRadius: 1, fontSize: '0.75rem', zIndex: 1 }}>
Out of Stock
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="body2" sx={{ mr: 1 }}>Avg:</Typography>
<MuiRating value={flavor.average_rating || 0} readOnly precision={0.5} max={10} size="small" />
<Typography variant="body2" sx={{ ml: 1 }}>({(flavor.average_rating || 0).toFixed(1)})</Typography>
</Box>
{flavor.ratings && flavor.ratings.length > 0 && (
<Accordion variant="outlined" sx={{ mt: 1, mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="caption">Comments ({flavor.ratings.filter(r => r.comment).length})</Typography>
</AccordionSummary>
<AccordionDetails sx={{ maxHeight: 200, overflow: 'auto', p: 1 }}>
{flavor.ratings.filter(r => r.comment).map(r => (
<Box key={r.id} sx={{ mb: 1, borderBottom: '1px solid rgba(0,0,0,0.1)', pb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>{r.user} ({r.score}/10):</Typography>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>{r.comment}</Typography>
</Box>
))}
</AccordionDetails>
</Accordion>
{flavor.image_url && (
<Box component="img" src={flavor.image_url} sx={{ width: '100%', height: 200, objectFit: 'contain', p: 2, bgcolor: 'rgba(255,255,255,0.05)' }} />
)}
{flavor.user_rating ? (
<Typography variant="body2" color="secondary">Your Rating: {flavor.user_rating}/10</Typography>
) : (
<Accordion variant="outlined" sx={{ mt: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="body2">Rate this flavor</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<MuiRating max={10} value={ratingInput[flavor.id]?.score || 0} onChange={(_, val) => setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {comment: ''}), score: val || 0}})} />
<TextField label="Comment (Optional)" variant="outlined" size="small" multiline value={ratingInput[flavor.id]?.comment || ''} onChange={(e) => setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {score: 0}), comment: e.target.value}})} />
<Button variant="contained" size="small" onClick={() => handleRate(flavor.id)}>Submit</Button>
</Box>
</AccordionDetails>
</Accordion>
)}
</CardContent>
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h6">{flavor.name}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{flavor.description.substring(0, 100)}...</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<MuiRating value={flavor.average_rating || 0} readOnly precision={0.5} max={10} size="small" />
<Typography variant="body2" sx={{ ml: 1 }}>({(flavor.average_rating || 0).toFixed(1)})</Typography>
</Box>
<Typography variant="caption" color="text.secondary">{flavor.ratings.length} reviews</Typography>
</CardContent>
</Box>
</Card>
</Box>
))}
+297
View File
@@ -0,0 +1,297 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
Typography,
Box,
Card,
CardContent,
Rating as MuiRating,
Button,
TextField,
Avatar
} from '@mui/material';
import api from '../../api';
import { useTitle } from '../../hooks/useTitle';
interface Reply {
id: number;
user: string;
text: string;
created_at: string;
}
interface Rating {
id: number;
user: string;
score: number;
comment: string;
created_at: string;
replies: Reply[];
}
interface Flavor {
id: number;
name: string;
category_name: string;
category_slug: string;
description: string;
average_rating: number;
user_rating: number | null;
ratings: Rating[];
image_url: string | null;
is_available: boolean;
shop_url: string | null;
}
const FlavorDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [flavor, setFlavor] = useState<Flavor | null>(null);
const [loading, setLoading] = useState(true);
const [replyInputs, setReplyInputs] = useState<{[key: number]: string}>({});
const [currentUser, setCurrentUser] = useState<string | null>(null);
const [editMode, setEditMode] = useState<number | null>(null);
const [editScore, setEditScore] = useState(0);
const [editComment, setEditComment] = useState('');
useEffect(() => {
const getUser = async () => {
try {
const res = await api.get('users/me/');
setCurrentUser(res.data.username);
} catch (e) { /* ignore */ }
};
getUser();
}, []);
const fetchFlavor = async () => {
try {
const res = await api.get(`flavors/${id}/`);
setFlavor(res.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFlavor();
}, [id]);
useTitle(flavor?.name || 'Flavor Detail');
const handleReplySubmit = async (ratingId: number) => {
const text = replyInputs[ratingId];
if (!text) return;
try {
await api.post(`ratings/${ratingId}/reply/`, { text });
setReplyInputs({ ...replyInputs, [ratingId]: '' });
fetchFlavor(); // Refresh to show new reply
} catch (err) {
alert('Failed to submit reply');
}
};
const handleDeleteRating = async (ratingId: number) => {
if (!confirm('Are you sure you want to delete your review?')) return;
try {
await api.delete(`ratings/${ratingId}/`);
fetchFlavor();
} catch (err) {
alert('Failed to delete review');
}
};
const startEdit = (rating: Rating) => {
setEditMode(rating.id);
setEditScore(rating.score);
setEditComment(rating.comment || '');
};
const handleUpdateRating = async (ratingId: number) => {
try {
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
setEditMode(null);
fetchFlavor();
} catch (err) {
alert('Failed to update review');
}
};
if (loading) return <Typography>Loading...</Typography>;
if (!flavor) return <Typography>Flavor not found</Typography>;
return (
<Box>
<Button component={Link} to={`/category/${flavor.category_slug}`} sx={{ mb: 2 }}>
&larr; Back to {flavor.category_name}
</Button>
<Card sx={{ display: 'flex', flexDirection: { xs: 'column', md: 'row' }, mb: 4, overflow: 'visible' }}>
{flavor.image_url && (
<Box
sx={{
width: { xs: '100%', md: 400 },
height: 400,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(255,255,255,0.05)',
p: 4
}}
>
<Box
component="img"
src={flavor.image_url}
sx={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
filter: 'drop-shadow(0px 10px 20px rgba(0,0,0,0.3))'
}}
/>
</Box>
)}
<CardContent sx={{ flex: 1, p: 4 }}>
{!flavor.is_available && (
<Box sx={{
display: 'inline-block',
bgcolor: 'error.main',
color: 'white',
px: 1,
py: 0.5,
borderRadius: 1,
mb: 2,
fontSize: '0.875rem',
fontWeight: 'bold'
}}>
Out of Stock
</Box>
)}
<Typography variant="h3" gutterBottom sx={{ fontWeight: 'bold' }}>{flavor.name}</Typography>
<Typography variant="h6" color="text.secondary" gutterBottom>{flavor.category_name}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<MuiRating value={flavor.average_rating || 0} readOnly precision={0.5} size="large" />
<Typography variant="h5" sx={{ ml: 2 }}>{(flavor.average_rating || 0).toFixed(1)} / 10</Typography>
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>({flavor.ratings.length} ratings)</Typography>
</Box>
<Typography variant="body1" sx={{ mb: 4, lineHeight: 1.8 }}>
{flavor.description}
</Typography>
{flavor.shop_url && (
<Button
variant="contained"
size="large"
component="a"
href={flavor.shop_url}
target="_blank"
sx={{ px: 4, py: 1.5 }}
>
Buy Now
</Button>
)}
</CardContent>
</Card>
<Typography variant="h4" gutterBottom sx={{ mt: 6, mb: 3 }}>Community Reviews</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{flavor.ratings.length === 0 ? (
<Typography color="text.secondary">No reviews yet. Be the first to rate this flavor!</Typography>
) : (
flavor.ratings.map((rating: Rating) => (
<Card key={rating.id} variant="outlined">
<CardContent>
{editMode === rating.id ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h6">Edit Review</Typography>
<MuiRating max={10} value={editScore} onChange={(_, val) => setEditScore(val || 0)} />
<TextField
multiline
fullWidth
label="Comment"
value={editComment}
onChange={(e) => setEditComment(e.target.value)}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" onClick={() => handleUpdateRating(rating.id)}>Save</Button>
<Button onClick={() => setEditMode(null)}>Cancel</Button>
</Box>
</Box>
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar sx={{ width: 32, height: 32, mr: 1.5, fontSize: '0.875rem' }}>
{rating.user.charAt(0).toUpperCase()}
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{rating.user}</Typography>
<Box sx={{ flexGrow: 1 }} />
<MuiRating value={rating.score} readOnly size="small" />
<Typography variant="body2" sx={{ ml: 1, fontWeight: 'bold' }}>{rating.score}/10</Typography>
</Box>
{rating.comment && (
<Typography variant="body1" sx={{ mb: 2 }}>{rating.comment}</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">
{new Date(rating.created_at).toLocaleDateString()}
</Typography>
{currentUser === rating.user && (
<Box>
<Button size="small" onClick={() => startEdit(rating)}>Edit</Button>
<Button size="small" color="error" onClick={() => handleDeleteRating(rating.id)}>Delete</Button>
</Box>
)}
</Box>
</>
)}
{/* Replies Section */}
{(rating.replies.length > 0 || true) && ( // Always show reply section essentially
<Box sx={{ mt: 2, ml: 4, pl: 2, borderLeft: '2px solid rgba(0,0,0,0.1)' }}>
{rating.replies.map((reply: Reply) => (
<Box key={reply.id} sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', fontSize: '0.85rem' }}>
{reply.user}
</Typography>
<Typography variant="body2">{reply.text}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(reply.created_at).toLocaleDateString()}
</Typography>
</Box>
))}
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
<TextField
size="small"
placeholder="Write a reply..."
fullWidth
value={replyInputs[rating.id] || ''}
onChange={(e) => setReplyInputs({ ...replyInputs, [rating.id]: e.target.value })}
/>
<Button
variant="text"
disabled={!replyInputs[rating.id]}
onClick={() => handleReplySubmit(rating.id)}
>
Reply
</Button>
</Box>
</Box>
)}
</CardContent>
</Card>
))
)}
</Box>
</Box>
);
};
export default FlavorDetail;
+27 -13
View File
@@ -1,27 +1,41 @@
import { createTheme, type ThemeOptions } from '@mui/material/styles';
import { flavors } from '@catppuccin/palette';
export type CatppuccinTheme = 'latte' | 'frappe' | 'macchiato' | 'mocha';
export type CatppuccinTheme = 'latte' | 'frappe' | 'macchiato' | 'mocha' | 'pink';
export const getTheme = (mode: CatppuccinTheme) => {
const flavor = flavors[mode].colors;
let palette;
if (mode === 'pink') {
palette = {
base: { hex: '#fff0f5' }, // Lavender Blush
surface0: { hex: '#ffe4e1' }, // Misty Rose
crust: { hex: '#ffc0cb' }, // Pink
text: { hex: '#5c4b51' }, // Darker text for contrast
subtext0: { hex: '#8b6b7a' },
mauve: { hex: '#ff69b4' }, // Hot Pink (Primary)
pink: { hex: '#db7093' }, // Pale Violet Red (Secondary)
};
} else {
palette = flavors[mode].colors;
}
const themeOptions: ThemeOptions = {
palette: {
mode: mode === 'latte' ? 'light' : 'dark',
mode: (mode === 'latte' || mode === 'pink') ? 'light' : 'dark',
primary: {
main: flavor.mauve.hex,
main: palette.mauve.hex,
},
secondary: {
main: flavor.pink.hex,
main: palette.pink.hex,
},
background: {
default: flavor.base.hex,
paper: flavor.surface0.hex,
default: palette.base.hex,
paper: palette.surface0.hex,
},
text: {
primary: flavor.text.hex,
secondary: flavor.subtext0.hex,
primary: palette.text.hex,
secondary: palette.subtext0.hex,
},
},
typography: {
@@ -31,16 +45,16 @@ export const getTheme = (mode: CatppuccinTheme) => {
MuiCard: {
styleOverrides: {
root: {
backgroundColor: flavor.surface0.hex,
color: flavor.text.hex,
backgroundColor: palette.surface0.hex,
color: palette.text.hex,
}
}
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: flavor.crust.hex,
color: flavor.text.hex,
backgroundColor: palette.crust.hex,
color: palette.text.hex,
}
}
}