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)
|
category = models.ForeignKey(Category, related_name='flavors', on_delete=models.CASCADE)
|
||||||
name = models.CharField(max_length=100, unique=True)
|
name = models.CharField(max_length=100, unique=True)
|
||||||
description = models.TextField(blank=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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ class CategorySerializer(serializers.ModelSerializer):
|
|||||||
class RatingSerializer(serializers.ModelSerializer):
|
class RatingSerializer(serializers.ModelSerializer):
|
||||||
user = serializers.StringRelatedField(read_only=True)
|
user = serializers.StringRelatedField(read_only=True)
|
||||||
flavor_name = serializers.CharField(source='flavor.name', 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:
|
class Meta:
|
||||||
model = Rating
|
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):
|
class FlavorSerializer(serializers.ModelSerializer):
|
||||||
category_name = serializers.CharField(source='category.name', read_only=True)
|
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||||
@@ -27,7 +28,7 @@ class FlavorSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Flavor
|
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):
|
def get_user_rating(self, obj):
|
||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
|
|||||||
+2
-2
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>Holy Flavors</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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';
|
} from '@mui/material';
|
||||||
import PaletteIcon from '@mui/icons-material/Palette';
|
import PaletteIcon from '@mui/icons-material/Palette';
|
||||||
import { getTheme, type CatppuccinTheme } from './theme';
|
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 Dashboard from './pages/Dashboard';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import api from './api';
|
import api from './api';
|
||||||
@@ -74,7 +75,7 @@ const App: React.FC = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<Button color="inherit" component={Link} to="/">Flavors</Button>
|
<Button color="inherit" component={Link} to="/">Categories</Button>
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<Button color="inherit" component={Link} to="/dashboard">Dashboard</Button>
|
<Button color="inherit" component={Link} to="/dashboard">Dashboard</Button>
|
||||||
@@ -103,7 +104,8 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
<Container sx={{ mt: 4 }}>
|
<Container sx={{ mt: 4 }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<FlavorList />} />
|
<Route path="/" element={<CategoryList />} />
|
||||||
|
<Route path="/category/:slug" element={<CategoryFlavors />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
</Routes>
|
</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 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 api from '../api';
|
||||||
|
import { useTitle } from '../hooks/useTitle';
|
||||||
|
|
||||||
interface DashboardData {
|
interface DashboardData {
|
||||||
rated_count: number;
|
rated_count: number;
|
||||||
@@ -10,6 +11,7 @@ interface DashboardData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
|
useTitle('Dashboard');
|
||||||
const [data, setData] = useState<DashboardData | null>(null);
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -39,17 +41,26 @@ const Dashboard: React.FC = () => {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{data.my_ratings.map((rating: any) => (
|
{data.my_ratings.map((rating: any) => (
|
||||||
<Card key={rating.id}>
|
<Card key={rating.id}>
|
||||||
<CardContent>
|
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||||
<Typography variant="h6">{rating.flavor_name}</Typography>
|
{rating.flavor_image && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', my: 1 }}>
|
<Box
|
||||||
<MuiRating value={rating.score} readOnly max={10} size="small" />
|
component="img"
|
||||||
<Typography variant="body2" sx={{ ml: 1 }}>{rating.score}/10</Typography>
|
src={rating.flavor_image}
|
||||||
</Box>
|
sx={{ width: 60, height: 60, objectFit: 'contain', bgcolor: 'rgba(255,255,255,0.05)', p: 0.5, borderRadius: 1 }}
|
||||||
{rating.comment && (
|
/>
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
"{rating.comment}"
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -60,10 +71,31 @@ const Dashboard: React.FC = () => {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{data.missing_flavors.map((flavor: any) => (
|
{data.missing_flavors.map((flavor: any) => (
|
||||||
<Card key={flavor.id}>
|
<Card key={flavor.id}>
|
||||||
<CardContent>
|
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||||
<Typography variant="h6">{flavor.name}</Typography>
|
{flavor.image_url && (
|
||||||
<Typography variant="caption" color="text.secondary">{flavor.category_name}</Typography>
|
<Box
|
||||||
<Typography variant="body2">{flavor.description}</Typography>
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ interface Flavor {
|
|||||||
average_rating: number;
|
average_rating: number;
|
||||||
user_rating: number | null;
|
user_rating: number | null;
|
||||||
ratings: Rating[];
|
ratings: Rating[];
|
||||||
|
image_url: string | null;
|
||||||
|
is_available: boolean;
|
||||||
|
shop_url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
@@ -92,12 +95,52 @@ const FlavorList: React.FC = () => {
|
|||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||||
{flavors.filter(f => f.category_name === category.name).map(flavor => (
|
{flavors.filter(f => f.category_name === category.name).map(flavor => (
|
||||||
<Box sx={{ flex: '1 1 30%', minWidth: 280 }} key={flavor.id}>
|
<Box sx={{ flex: '1 1 30%', minWidth: 280 }} key={flavor.id}>
|
||||||
<Card>
|
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}>
|
||||||
<CardContent>
|
{!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="h6">{flavor.name}</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
{flavor.description}
|
{flavor.description}
|
||||||
</Typography>
|
</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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||||
<Typography variant="body2" sx={{ mr: 1 }}>Avg:</Typography>
|
<Typography variant="body2" sx={{ mr: 1 }}>Avg:</Typography>
|
||||||
<MuiRating value={flavor.average_rating || 0} readOnly precision={0.5} max={10} size="small" />
|
<MuiRating value={flavor.average_rating || 0} readOnly precision={0.5} max={10} size="small" />
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Typography, TextField, Button, Box, Paper, Tab, Tabs } from '@mui/material';
|
import { Typography, TextField, Button, Box, Paper, Tab, Tabs } from '@mui/material';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import { useTitle } from '../hooks/useTitle';
|
||||||
|
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Login: React.FC<LoginProps> = () => {
|
const Login: React.FC<LoginProps> = () => {
|
||||||
|
useTitle('Login');
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = 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