From 2befe3eb7d53ab15536d3c6f9b8dc9e6768ab6ac Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 15 Feb 2026 20:54:12 +0100 Subject: [PATCH] added searchbar and restructured start page --- .../api/management/commands/sync_flavors.py | 109 +++++++++++ ...r_external_id_flavor_image_url_and_more.py | 28 +++ .../api/migrations/0003_flavor_shop_url.py | 18 ++ backend/api/models.py | 4 + backend/api/serializers.py | 5 +- frontend/index.html | 4 +- frontend/public/favicon.svg | 4 + frontend/src/App.tsx | 8 +- frontend/src/hooks/useTitle.ts | 7 + frontend/src/pages/CategoryList.tsx | 95 ++++++++++ frontend/src/pages/Dashboard.tsx | 62 +++++-- frontend/src/pages/FlavorList.tsx | 47 ++++- frontend/src/pages/Login.tsx | 2 + .../src/pages/categories/CategoryFlavors.tsx | 173 ++++++++++++++++++ 14 files changed, 542 insertions(+), 24 deletions(-) create mode 100644 backend/api/management/commands/sync_flavors.py create mode 100644 backend/api/migrations/0002_flavor_external_id_flavor_image_url_and_more.py create mode 100644 backend/api/migrations/0003_flavor_shop_url.py create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/hooks/useTitle.ts create mode 100644 frontend/src/pages/CategoryList.tsx create mode 100644 frontend/src/pages/categories/CategoryFlavors.tsx diff --git a/backend/api/management/commands/sync_flavors.py b/backend/api/management/commands/sync_flavors.py new file mode 100644 index 0000000..57a1132 --- /dev/null +++ b/backend/api/management/commands/sync_flavors.py @@ -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}")) diff --git a/backend/api/migrations/0002_flavor_external_id_flavor_image_url_and_more.py b/backend/api/migrations/0002_flavor_external_id_flavor_image_url_and_more.py new file mode 100644 index 0000000..706240a --- /dev/null +++ b/backend/api/migrations/0002_flavor_external_id_flavor_image_url_and_more.py @@ -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), + ), + ] diff --git a/backend/api/migrations/0003_flavor_shop_url.py b/backend/api/migrations/0003_flavor_shop_url.py new file mode 100644 index 0000000..b513268 --- /dev/null +++ b/backend/api/migrations/0003_flavor_shop_url.py @@ -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), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 8a0a09b..422d445 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -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): diff --git a/backend/api/serializers.py b/backend/api/serializers.py index eea12d5..81ab79b 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -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') diff --git a/frontend/index.html b/frontend/index.html index 072a57e..b1a173f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - frontend + Holy Flavors
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..eb29fc4 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + H + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 203232d..5263f44 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => { - + {user ? ( <> @@ -103,7 +104,8 @@ const App: React.FC = () => { - } /> + } /> + } /> } /> } /> diff --git a/frontend/src/hooks/useTitle.ts b/frontend/src/hooks/useTitle.ts new file mode 100644 index 0000000..a10e49d --- /dev/null +++ b/frontend/src/hooks/useTitle.ts @@ -0,0 +1,7 @@ +import { useEffect } from 'react'; + +export const useTitle = (title: string) => { + useEffect(() => { + document.title = `${title} | Holy Flavors`; + }, [title]); +}; diff --git a/frontend/src/pages/CategoryList.tsx b/frontend/src/pages/CategoryList.tsx new file mode 100644 index 0000000..1c00a9d --- /dev/null +++ b/frontend/src/pages/CategoryList.tsx @@ -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([]); + 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 Loading Categories...; + + return ( + + Flavor Categories + + setSearch(e.target.value)} + sx={{ mb: 4 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {filteredCategories.map(category => ( + + + + + {category.name} + + + + + ))} + + + ); +}; + +export default CategoryList; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f8518c2..9da2552 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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(null); const [loading, setLoading] = useState(true); @@ -39,17 +41,26 @@ const Dashboard: React.FC = () => { {data.my_ratings.map((rating: any) => ( - - {rating.flavor_name} - - - {rating.score}/10 - - {rating.comment && ( - - "{rating.comment}" - + + {rating.flavor_image && ( + )} + + {rating.flavor_name} + + + {rating.score}/10 + + {rating.comment && ( + + "{rating.comment}" + + )} + ))} @@ -60,10 +71,31 @@ const Dashboard: React.FC = () => { {data.missing_flavors.map((flavor: any) => ( - - {flavor.name} - {flavor.category_name} - {flavor.description} + + {flavor.image_url && ( + + )} + + {flavor.name} + {flavor.category_name} + {flavor.description.substring(0, 100)}... + {flavor.shop_url && ( + + )} + ))} diff --git a/frontend/src/pages/FlavorList.tsx b/frontend/src/pages/FlavorList.tsx index 6ee67e1..640dbbc 100644 --- a/frontend/src/pages/FlavorList.tsx +++ b/frontend/src/pages/FlavorList.tsx @@ -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 = () => { {flavors.filter(f => f.category_name === category.name).map(flavor => ( - - + + {!flavor.is_available && ( + + Out of Stock + + )} + {flavor.image_url && ( + + )} + {flavor.name} {flavor.description} + {flavor.shop_url && ( + + )} Avg: diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 536719f..684b5c4 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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 = () => { + useTitle('Login'); const [tab, setTab] = useState(0); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); diff --git a/frontend/src/pages/categories/CategoryFlavors.tsx b/frontend/src/pages/categories/CategoryFlavors.tsx new file mode 100644 index 0000000..54381e8 --- /dev/null +++ b/frontend/src/pages/categories/CategoryFlavors.tsx @@ -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([]); + 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 Loading Flavors...; + + return ( + + {categoryName || 'Flavors'} + + setSearch(e.target.value)} + sx={{ mb: 4 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {filteredFlavors.map(flavor => ( + + + {!flavor.is_available && ( + + Out of Stock + + )} + {flavor.image_url && ( + + )} + + {flavor.name} + {flavor.description} + {flavor.shop_url && ( + + )} + + Avg: + + ({(flavor.average_rating || 0).toFixed(1)}) + + {flavor.ratings && flavor.ratings.length > 0 && ( + + }> + Comments ({flavor.ratings.filter(r => r.comment).length}) + + + {flavor.ratings.filter(r => r.comment).map(r => ( + + {r.user} ({r.score}/10): + {r.comment} + + ))} + + + )} + {flavor.user_rating ? ( + Your Rating: {flavor.user_rating}/10 + ) : ( + + }> + Rate this flavor + + + + setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {comment: ''}), score: val || 0}})} /> + setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {score: 0}), comment: e.target.value}})} /> + + + + + )} + + + + ))} + + + ); +}; + +export default CategoryFlavors;