mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
updated user features and added palette
This commit is contained in:
1 parent
03adde8928
commit
6b26b77e0e
11 files changed
+515
-140
No files matched your search
@@ -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()
|
||||
|
||||
# Check for Packs/Bundles/Samples first
|
||||
is_pack = False
|
||||
pack_keywords = ['bundle', 'pack', 'set', 'box', 'probe', 'sample', 'taster', 'shaker', 'starter']
|
||||
|
||||
if 'holy energy' in tags_lower or 'energy' in tags_lower:
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -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 }}>
|
||||
← 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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user