mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
added searchbar and restructured start page
This commit is contained in:
1 parent
3437f69f3b
commit
2befe3eb7d
14 files changed
+542
-24
No files matched your search
@@ -0,0 +1,109 @@
|
||||
import json
|
||||
import urllib.request
|
||||
import re
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.text import slugify
|
||||
from api.models import Category, Flavor
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Syncs flavors from Holy Energy Shopify API'
|
||||
|
||||
def clean_html(self, raw_html):
|
||||
if not raw_html:
|
||||
return ""
|
||||
cleanr = re.compile('<.*?>')
|
||||
cleantext = re.sub(cleanr, '', raw_html)
|
||||
return cleantext.strip()
|
||||
|
||||
def handle(self, *args, **options):
|
||||
url = "https://weareholy.com/products.json?limit=250"
|
||||
self.stdout.write(f"Fetching from {url}...")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(url) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}"))
|
||||
return
|
||||
|
||||
products = data.get('products', [])
|
||||
self.stdout.write(f"Found {len(products)} products.")
|
||||
|
||||
# Ensure categories exist
|
||||
cat_map = {
|
||||
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0],
|
||||
'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],
|
||||
}
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for p in products:
|
||||
title = p.get('title')
|
||||
tags = p.get('tags', [])
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(',')]
|
||||
|
||||
# Determine category
|
||||
category = None
|
||||
tags_lower = [t.lower() for t in tags]
|
||||
|
||||
if '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']
|
||||
elif 'holy iced tea' in tags_lower or 'iced tea' in tags_lower:
|
||||
category = cat_map['iced-tea']
|
||||
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:
|
||||
continue
|
||||
|
||||
# Check availability
|
||||
variants = p.get('variants', [])
|
||||
is_available = any(v.get('available') for v in variants)
|
||||
|
||||
# Image
|
||||
images = p.get('images', [])
|
||||
image_url = images[0].get('src') if images else None
|
||||
|
||||
# Description
|
||||
description = self.clean_html(p.get('body_html', ''))
|
||||
|
||||
# Shop URL
|
||||
handle = p.get('handle')
|
||||
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
|
||||
|
||||
# Create or update
|
||||
# Try finding by external_id first, then by name
|
||||
flavor = Flavor.objects.filter(external_id=p['id']).first()
|
||||
if not flavor:
|
||||
flavor = Flavor.objects.filter(name=title).first()
|
||||
|
||||
if flavor:
|
||||
flavor.external_id = p['id']
|
||||
flavor.name = title
|
||||
flavor.category = category
|
||||
flavor.description = description
|
||||
flavor.image_url = image_url
|
||||
flavor.shop_url = shop_url
|
||||
flavor.is_available = is_available
|
||||
flavor.save()
|
||||
updated_count += 1
|
||||
else:
|
||||
Flavor.objects.create(
|
||||
external_id=p['id'],
|
||||
name=title,
|
||||
category=category,
|
||||
description=description,
|
||||
image_url=image_url,
|
||||
shop_url=shop_url,
|
||||
is_available=is_available
|
||||
)
|
||||
created_count += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Finished! Created: {created_count}, Updated: {updated_count}"))
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-15 19:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='flavor',
|
||||
name='external_id',
|
||||
field=models.BigIntegerField(blank=True, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='flavor',
|
||||
name='image_url',
|
||||
field=models.URLField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='flavor',
|
||||
name='is_available',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-15 19:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0002_flavor_external_id_flavor_image_url_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='flavor',
|
||||
name='shop_url',
|
||||
field=models.URLField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
]
|
||||
@@ -28,6 +28,10 @@ class Flavor(models.Model):
|
||||
category = models.ForeignKey(Category, related_name='flavors', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
image_url = models.URLField(max_length=500, blank=True, null=True)
|
||||
shop_url = models.URLField(max_length=500, blank=True, null=True)
|
||||
is_available = models.BooleanField(default=True)
|
||||
external_id = models.BigIntegerField(unique=True, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -14,10 +14,11 @@ class CategorySerializer(serializers.ModelSerializer):
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
model = Rating
|
||||
fields = ['id', 'user', 'flavor', 'flavor_name', 'score', 'comment', 'created_at']
|
||||
fields = ['id', 'user', 'flavor', 'flavor_name', 'flavor_image', 'score', 'comment', 'created_at']
|
||||
|
||||
class FlavorSerializer(serializers.ModelSerializer):
|
||||
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||
@@ -27,7 +28,7 @@ class FlavorSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Flavor
|
||||
fields = ['id', 'name', 'category', 'category_name', 'description', 'average_rating', 'user_rating', 'ratings']
|
||||
fields = ['id', 'name', 'category', 'category_name', 'description', 'average_rating', 'user_rating', 'ratings', 'image_url', 'is_available', 'shop_url']
|
||||
|
||||
def get_user_rating(self, obj):
|
||||
request = self.context.get('request')
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>Holy Flavors</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#9d7cd8"/>
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="60" fill="white" font-weight="bold">H</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@@ -15,7 +15,8 @@ import {
|
||||
} from '@mui/material';
|
||||
import PaletteIcon from '@mui/icons-material/Palette';
|
||||
import { getTheme, type CatppuccinTheme } from './theme';
|
||||
import FlavorList from './pages/FlavorList';
|
||||
import CategoryList from './pages/CategoryList';
|
||||
import CategoryFlavors from './pages/categories/CategoryFlavors';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Login from './pages/Login';
|
||||
import api from './api';
|
||||
@@ -74,7 +75,7 @@ const App: React.FC = () => {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Button color="inherit" component={Link} to="/">Flavors</Button>
|
||||
<Button color="inherit" component={Link} to="/">Categories</Button>
|
||||
{user ? (
|
||||
<>
|
||||
<Button color="inherit" component={Link} to="/dashboard">Dashboard</Button>
|
||||
@@ -103,7 +104,8 @@ const App: React.FC = () => {
|
||||
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<FlavorList />} />
|
||||
<Route path="/" element={<CategoryList />} />
|
||||
<Route path="/category/:slug" element={<CategoryFlavors />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useTitle = (title: string) => {
|
||||
useEffect(() => {
|
||||
document.title = `${title} | Holy Flavors`;
|
||||
}, [title]);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
TextField,
|
||||
InputAdornment
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const CategoryList: React.FC = () => {
|
||||
useTitle('Categories');
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await api.get('categories/');
|
||||
setCategories(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
const filteredCategories = categories.filter(c =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) return <Typography>Loading Categories...</Typography>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Flavor Categories</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Search categories..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ mb: 4 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{filteredCategories.map(category => (
|
||||
<Box key={category.id} sx={{ flex: '1 1 45%', minWidth: 280 }}>
|
||||
<Link to={`/category/${category.slug}`} style={{ textDecoration: 'none' }}>
|
||||
<Card sx={{
|
||||
height: 150,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)',
|
||||
cursor: 'pointer',
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}>
|
||||
<CardContent>
|
||||
<Typography variant="h4" color="primary">{category.name}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryList;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Typography, Card, CardContent, Box, Rating as MuiRating } from '@mui/material';
|
||||
import { Typography, Card, CardContent, Box, Rating as MuiRating, Button } from '@mui/material';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
|
||||
interface DashboardData {
|
||||
rated_count: number;
|
||||
@@ -10,6 +11,7 @@ interface DashboardData {
|
||||
}
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
useTitle('Dashboard');
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -39,17 +41,26 @@ const Dashboard: React.FC = () => {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{data.my_ratings.map((rating: any) => (
|
||||
<Card key={rating.id}>
|
||||
<CardContent>
|
||||
<Typography variant="h6">{rating.flavor_name}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', my: 1 }}>
|
||||
<MuiRating value={rating.score} readOnly max={10} size="small" />
|
||||
<Typography variant="body2" sx={{ ml: 1 }}>{rating.score}/10</Typography>
|
||||
</Box>
|
||||
{rating.comment && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
"{rating.comment}"
|
||||
</Typography>
|
||||
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
{rating.flavor_image && (
|
||||
<Box
|
||||
component="img"
|
||||
src={rating.flavor_image}
|
||||
sx={{ width: 60, height: 60, objectFit: 'contain', bgcolor: 'rgba(255,255,255,0.05)', p: 0.5, borderRadius: 1 }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: '1rem' }}>{rating.flavor_name}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', my: 0.5 }}>
|
||||
<MuiRating value={rating.score} readOnly max={10} size="small" />
|
||||
<Typography variant="body2" sx={{ ml: 1, fontSize: '0.8rem' }}>{rating.score}/10</Typography>
|
||||
</Box>
|
||||
{rating.comment && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.85rem' }}>
|
||||
"{rating.comment}"
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -60,10 +71,31 @@ const Dashboard: React.FC = () => {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{data.missing_flavors.map((flavor: any) => (
|
||||
<Card key={flavor.id}>
|
||||
<CardContent>
|
||||
<Typography variant="h6">{flavor.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{flavor.category_name}</Typography>
|
||||
<Typography variant="body2">{flavor.description}</Typography>
|
||||
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{ width: 60, height: 60, objectFit: 'contain', bgcolor: 'rgba(255,255,255,0.05)', p: 0.5, borderRadius: 1 }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: '1rem' }}>{flavor.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{flavor.category_name}</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.85rem', mb: 1 }}>{flavor.description.substring(0, 100)}...</Typography>
|
||||
{flavor.shop_url && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
component="a"
|
||||
href={flavor.shop_url}
|
||||
target="_blank"
|
||||
sx={{ fontSize: '0.65rem', py: 0 }}
|
||||
>
|
||||
Buy Now
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -30,6 +30,9 @@ interface Flavor {
|
||||
average_rating: number;
|
||||
user_rating: number | null;
|
||||
ratings: Rating[];
|
||||
image_url: string | null;
|
||||
is_available: boolean;
|
||||
shop_url: string | null;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
@@ -92,12 +95,52 @@ const FlavorList: React.FC = () => {
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{flavors.filter(f => f.category_name === category.name).map(flavor => (
|
||||
<Box sx={{ flex: '1 1 30%', minWidth: 280 }} key={flavor.id}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<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>
|
||||
)}
|
||||
<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" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Typography, TextField, Button, Box, Paper, Tab, Tabs } from '@mui/material';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
|
||||
interface LoginProps {
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = () => {
|
||||
useTitle('Login');
|
||||
const [tab, setTab] = useState(0);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
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';
|
||||
|
||||
interface Rating {
|
||||
id: number;
|
||||
user: string;
|
||||
score: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Flavor {
|
||||
id: number;
|
||||
name: string;
|
||||
category_name: string;
|
||||
description: string;
|
||||
average_rating: number;
|
||||
user_rating: number | null;
|
||||
ratings: Rating[];
|
||||
image_url: string | null;
|
||||
is_available: boolean;
|
||||
shop_url: string | null;
|
||||
}
|
||||
|
||||
const CategoryFlavors: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [flavors, setFlavors] = useState<Flavor[]>([]);
|
||||
const [categoryName, setCategoryName] = useState('');
|
||||
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 () => {
|
||||
try {
|
||||
const res = await api.get(`flavors/?category__slug=${slug}`);
|
||||
setFlavors(res.data);
|
||||
if (res.data.length > 0) {
|
||||
setCategoryName(res.data[0].category_name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
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())
|
||||
);
|
||||
|
||||
if (loading) return <Typography>Loading Flavors...</Typography>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>{categoryName || 'Flavors'}</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder={`Search ${categoryName} flavors...`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ mb: 4 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{filteredFlavors.map(flavor => (
|
||||
<Box sx={{ flex: '1 1 30%', 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>
|
||||
)}
|
||||
<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.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>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryFlavors;
|
||||
Reference in new issue
Block a user