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 @@
+
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;