diff --git a/.github/temp/ci.yml b/.github/workflows/ci.yml similarity index 100% rename from .github/temp/ci.yml rename to .github/workflows/ci.yml diff --git a/README.md b/README.md index d981f5e..ae8556b 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,48 @@ # Wagtail Shop Kit -This is a starter kit for a Wagtail e-commerce project. It includes a Docker setup for local development, a basic project structure, and some useful tools and libraries. - -You can use this project as a starting point for your own Wagtail shop projects and build upon it as needed. +An e-commerce starter kit built with Wagtail CMS and Django. This project provides a foundation for building online shops with a powerful content management system. ## Features -- Docker Development Environment -- Postgresql, Mysql or Sqlite3 Database -- Frontend Node SASS and Javascript compilation -- Pico CSS for almost classless styling -- esbuild javascript bundler -- Wagtail CMS v7.2 -- Django v5.2 +- **Wagtail CMS v7.2** - Powerful, flexible content management +- **Django v5.2** - Robust Python web framework +- **Docker Development Environment** - Containerized setup for consistency +- **Multiple Database Options** - PostgreSQL, MySQL, or SQLite3 +- **Modern Frontend Stack** - SASS, esbuild, and Pico CSS +- **E-commerce Ready** - Built with shop functionality in mind ![Wagtail Shop Kit](./docs/welcome-screen.jpg) -## Requirements - -Required: - -- [Python >= 3.10](https://www.python.org/downloads/) (developemnt and deployment) -- [Docker](https://www.docker.com/) (for local development) -- [Docker Compose](https://docs.docker.com/compose/) (for local development) -- [Node.js](https://nodejs.org/en/) (for frontend build tools) (for frontend build tools in development) - -Optional: -- [Git](https://git-scm.com/) (optional, for version control) -- [Make](https://www.gnu.org/software/make/) (optional, for running commands) -- [NVM](https://github.com/nvm-sh/nvm) (optional, for managing Node versions) -- [pre-commit](https://pre-commit.com/) (optional, for running code checks) -- [UV](https://github.com/astral-sh/uv) (optional, for managing Python dependencies) - -## Getting started - -1. Clone this repository to a location on your computer -2. Change into the project directory -3. Copy `.env.example` to `.env` and choose your database (default is SQLite). Set `DATABASE=sqlite|postgres|mysql`. -4. Run `make build` to build the Docker containers -5. Run `make up` to start the Docker containers -6. Run `make migrate` to apply database migrations -7. Run `make superuser` to create a superuser -8. Run `make run` to start the Django development server - -Note: `.env` is required. `make build` and `make up` validate environment variables via `make check-env` and will fail if required values are missing. See `.env.example` for all keys. - -### Quick start - -There is a make command to run most of the steps above in one go: +## Quick Start ```bash -make quickstart -``` - -You'll need to run `make superuser` separately. - -If you haven't changed `.env` the app will default to: - -- Use SQLite as the database -- A mail utility will be available at [http://localhost:8025](http://localhost:8025) -- A database management utility will be available at [http://localhost:8080](http://localhost:8080) - -## Working with the fronend +# Copy environment file and configure +cp .env.example .env -The project uses [Pico CSS](https://picocss.com/) for styling. It's a minmal setup that you can build on. - -When you first run the project you may notice that no styling is applied. This is because the first time you run the project with `make up` the compiled frontend files might not be available. Just run the frontend build script and refresh the page. [Read on](./docs/frontend-development.md) - -## Working with the backend - -The project uses Docker for local development. The Wagtail project is in the `app` directory. The project is set up to use a SQLite database by default. You can change this to use Mysql or Postgres. [Read on](./docs/backend-development.md) - -## Management Commands - -The project includes custom Django management commands to help with development and testing. These commands can be used to generate sample content, manage data, and perform administrative tasks. - -For detailed documentation on all available commands, see [Management Commands Documentation](./docs/management-commands.md). - -### Quick Examples - -```bash -# Create sample images and documents for testing -docker exec -it wagtail-shop-kit-app-1 python manage.py create_sample_media +# Build and start the project +make quickstart -# Reset all sample content -docker exec -it wagtail-shop-kit-app-1 python manage.py create_sample_media --reset +# Create admin user +make superuser ``` -## View the site - -The site will be available at [http://localhost:8000](http://localhost:8000). - -The Wagtail admin interface will be available at [http://localhost:8000/admin](http://localhost:8000/admin). - -## Deployment - -Currently there is no deployment setup included in this project. You could try this [Wagtail deployment guide](https://docs.wagtail.org/en/stable/deployment/index.html) for some ideas. +The site will be available at [http://localhost:8000](http://localhost:8000) -## Deployment Examples +## Documentation -1. How to deploy a Wagtail site to [PythonAnywhere](https://www.nickmoreton.co.uk/articles/deploy-wagtail-cms-to-pythonanywhere/), this does need you to have a paid account with PythonAnywhere. -2. This [example](https://github.com/wagtail-examples/wsk-deploy-python-anywhere) is a fork of this starter kit which has documentation on how to deploy to PythonAnywhere, using a free account. +- **[Installation Guide](./docs/installation.md)** - Detailed setup instructions +- **[Backend Development](./docs/backend-development.md)** - Database configuration and backend development +- **[Frontend Development](./docs/frontend-development.md)** - CSS/JS build process and styling +- **[Management Commands](./docs/management-commands.md)** - Helpful commands for development -## Contributing +## Technology Stack -If you have any suggestions or improvements, please open an issue or a pull request. +- Python 3.10+ +- Django 5.2 +- Wagtail 7.2 +- Docker & Docker Compose +- Node.js (SASS & esbuild) +- Pico CSS ## License diff --git a/app/home/models.py b/app/home/models.py index a8a0cc2..d74e694 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -1,9 +1,16 @@ from wagtail import __version__ as WAGTAIL_VERSION from wagtail.models import Page +from app.shop.models import ShopCategoryPage + class HomePage(Page): + def get_featured_categories(self): + """Return featured shop categories""" + return ShopCategoryPage.objects.live().filter(featured=True).order_by("title") + def get_context(self, request): context = super().get_context(request) context["wagtail_version"] = WAGTAIL_VERSION + context["featured_categories"] = self.get_featured_categories() return context diff --git a/app/home/templates/home/home_page.html b/app/home/templates/home/home_page.html index 0b1dc65..7564461 100644 --- a/app/home/templates/home/home_page.html +++ b/app/home/templates/home/home_page.html @@ -3,136 +3,6 @@ {% block body_class %}template-homepage{% endblock %} -{% block extra_css %} - -{% comment %} -Delete the line below if you're just getting started and want to remove the welcome screen! -{% endcomment %} - -{% endblock extra_css %} - {% block content %} {% comment %} diff --git a/app/home/templates/home/welcome_page.html b/app/home/templates/home/welcome_page.html index 12fa3a7..ede7b24 100644 --- a/app/home/templates/home/welcome_page.html +++ b/app/home/templates/home/welcome_page.html @@ -1,175 +1,121 @@ -{% load i18n wagtailcore_tags %} - -
-
- - - - - {% trans "Wagtail core release notes" %} - - - {% trans "Starter Kit Style Guide" %} - -
-
+{% load i18n wagtailcore_tags wagtailimages_tags %}
-
-
- -
-
+ +
+
-

{% trans "Welcome to your new Wagtail site!" %}

-

{% trans " by Wagtail Shop Kit" %}

+

Welcome to Wagtail Shop

+

Discover amazing products for your needs

-
    -
  • - - archive-content - - - - - - - - Docker Development Environment -
  • -
  • - - database - - - - + {% comment %} below can be dynamic later {% endcomment %} + Shop Now +
+
+ +
+
+ + + + + +
+

Shop by Category

+ {% if featured_categories %} + -
-
-
{% trans 'Join the Wagtail community on Slack, or get started with one of the links below.' %}
-
+
+ View All Categories +
+ {% else %} +

+ No categories available yet. Run python manage.py create_sample_categories to create sample categories. +

+ {% endif %} +
- - diff --git a/app/home/tests.py b/app/home/tests.py index 5b9f502..cf8ed8a 100644 --- a/app/home/tests.py +++ b/app/home/tests.py @@ -30,7 +30,7 @@ def test_home_frontend_returns_200(self): """Test that the home page frontend returns 200 OK.""" response = self.client.get("/") self.assertEqual(response.status_code, 200) - self.assertContains(response, "Welcome to your new Wagtail site!") + self.assertContains(response, "Welcome to Wagtail Shop") self.assertTemplateUsed(response, "home/home_page.html") def test_home_admin_edit_returns_200(self): diff --git a/app/settings/base.py b/app/settings/base.py index d0cf7b9..dc75e65 100644 --- a/app/settings/base.py +++ b/app/settings/base.py @@ -27,6 +27,7 @@ INSTALLED_APPS = [ "app.home", "app.search", + "app.shop", "wagtail.contrib.forms", "wagtail.contrib.redirects", "wagtail.contrib.table_block", diff --git a/app/shop/__init__.py b/app/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/admin.py b/app/shop/admin.py new file mode 100644 index 0000000..846f6b4 --- /dev/null +++ b/app/shop/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/app/shop/apps.py b/app/shop/apps.py new file mode 100644 index 0000000..f0eefef --- /dev/null +++ b/app/shop/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ShopConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "app.shop" diff --git a/app/shop/management/__init__.py b/app/shop/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/management/commands/__init__.py b/app/shop/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/management/commands/create_sample_categories.py b/app/shop/management/commands/create_sample_categories.py new file mode 100644 index 0000000..e70148e --- /dev/null +++ b/app/shop/management/commands/create_sample_categories.py @@ -0,0 +1,271 @@ +import random + +from django.core.management.base import BaseCommand +from wagtail.images.models import Image +from wagtail.models import Page + +from app.shop.models import ShopCategoryPage, ShopIndexPage + + +class Command(BaseCommand): + help = "Creates sample category pages for the shop under a ShopIndexPage" + + def add_arguments(self, parser): + parser.add_argument( + "--reset", + action="store_true", + help="Delete all existing ShopCategoryPage instances before creating new ones", + ) + parser.add_argument( + "--no-icons", + action="store_true", + help="Skip assigning icons to categories", + ) + parser.add_argument( + "--home-slug", + type=str, + default="home", + help="Slug of the home page (default: home)", + ) + parser.add_argument( + "--shop-title", + type=str, + default="Shop", + help="Title for the shop index page (default: Shop)", + ) + parser.add_argument( + "--shop-slug", + type=str, + default="shop", + help="Slug for the shop index page (default: shop)", + ) + + def handle(self, *args, **options): + # Find home page + try: + home_page = Page.objects.get(slug=options["home_slug"]) + except Page.DoesNotExist: + self.stdout.write( + self.style.ERROR( + f"Home page with slug '{options['home_slug']}' not found" + ) + ) + return + + # Find or create ShopIndexPage + shop_index = ShopIndexPage.objects.filter(slug=options["shop_slug"]).first() + + if shop_index: + self.stdout.write( + self.style.SUCCESS( + f"Found existing ShopIndexPage: '{shop_index.title}' at {shop_index.get_url()}" + ) + ) + else: + self.stdout.write("Creating ShopIndexPage...") + shop_index = ShopIndexPage( + title=options["shop_title"], + slug=options["shop_slug"], + intro="

Browse our collection of quality products across various categories.

", + show_in_menus=True, + ) + home_page.add_child(instance=shop_index) + rev = shop_index.save_revision() + rev.publish() + self.stdout.write( + self.style.SUCCESS( + f"✓ Created ShopIndexPage: '{shop_index.title}' at {shop_index.get_url()}" + ) + ) + + # Reset if requested + if options["reset"]: + self.stdout.write("Deleting existing category pages...") + all_categories = shop_index.get_children().type(ShopCategoryPage) + deleted_count = all_categories.count() + + # Delete all at once instead of iterating + for category in all_categories: + category.delete() + + # Refresh shop index from database to update tree structure + shop_index.refresh_from_db() + + self.stdout.write( + self.style.SUCCESS(f"Deleted {deleted_count} category pages") + ) + + # Query available images (unless --no-icons is specified) + available_images = [] + if not options["no_icons"]: + available_images = list(Image.objects.all().order_by("?")) # Random order + if not available_images: + self.stdout.write( + self.style.WARNING( + "No images found in database. Run 'python manage.py create_sample_media' first." + ) + ) + self.stdout.write( + self.style.WARNING("Creating categories without icons...") + ) + else: + self.stdout.write( + self.style.SUCCESS( + f"Found {len(available_images)} images available for category icons" + ) + ) + + # Category data matching the home page + categories = [ + { + "title": "Electronics", + "slug": "electronics", + "description": """ +

Discover the latest gadgets and tech products. From smartphones and laptops to + smart home devices and wearables, find everything you need to stay connected and + productive in the digital age.

+

Our electronics collection features cutting-edge technology from leading brands, + ensuring quality and innovation in every product.

+ """, + "featured": True, + }, + { + "title": "Fashion", + "slug": "fashion", + "description": """ +

Explore our curated collection of clothing and accessories for every style and + occasion. From casual everyday wear to elegant formal attire, express your unique + personality through fashion.

+

We offer a diverse range of sizes, styles, and trends to suit everyone's taste.

+ """, + "featured": True, + }, + { + "title": "Home & Living", + "slug": "home-living", + "description": """ +

Transform your living space with our selection of furniture and decor. Create a + comfortable, stylish home that reflects your personality and meets your lifestyle needs.

+

From modern minimalist designs to classic traditional pieces, we have everything + to make your house a home.

+ """, + "featured": True, + }, + { + "title": "Beauty", + "slug": "beauty", + "description": """ +

Discover premium skincare and cosmetics to enhance your natural beauty. Our + carefully selected products help you look and feel your best every day.

+

From daily essentials to special occasion makeup, we offer quality beauty products + for all skin types and preferences.

+ """, + "featured": False, + }, + { + "title": "Sports", + "slug": "sports", + "description": """ +

Get active with our range of fitness and outdoor equipment. Whether you're a + seasoned athlete or just starting your fitness journey, we have the gear you need + to reach your goals.

+

Explore workout equipment, outdoor gear, and athletic wear designed for + performance and comfort.

+ """, + "featured": False, + }, + { + "title": "Books", + "slug": "books", + "description": """ +

Feed your mind with our diverse collection of books. From bestselling novels and + educational resources to inspiring biographies and practical guides, discover your + next great read.

+

We offer books across all genres and subjects, perfect for readers of all ages + and interests.

+ """, + "featured": False, + }, + ] + + created_count = 0 + + # Shuffle images for random assignment + if available_images: + random.shuffle(available_images) + + for idx, category_data in enumerate(categories): + # Check if category already exists under shop index + existing = ( + shop_index.get_children() + .type(ShopCategoryPage) + .filter(slug=category_data["slug"]) + .first() + ) + if existing: + self.stdout.write( + self.style.WARNING( + f"Category '{category_data['title']}' already exists, skipping..." + ) + ) + continue + + # Create category page + category_page = ShopCategoryPage( + title=category_data["title"], + slug=category_data["slug"], + description=category_data["description"], + featured=category_data["featured"], + show_in_menus=True, # Make categories appear in menus + ) + + # Assign icon from shuffled image list + if available_images: + # Use modulo to cycle through images if we have fewer images than categories + icon_index = idx % len(available_images) + category_page.icon = available_images[icon_index] + self.stdout.write( + f" Assigning icon: {category_page.icon.title} (ID: {category_page.icon.id})" + ) + + # Add as child of shop index page + shop_index.add_child(instance=category_page) + rev = category_page.save_revision() + rev.publish() + + created_count += 1 + icon_status = ( + f" 🖼️ (icon: {category_page.icon.title[:30]}...)" + if category_page.icon + else " (no icon)" + ) + featured_status = " ⭐ [FEATURED]" if category_page.featured else "" + self.stdout.write( + self.style.SUCCESS( + f" ✓ Created: {category_page.title}{icon_status}{featured_status}" + ) + ) + + # Summary + self.stdout.write("\n" + "=" * 60) + if created_count > 0: + self.stdout.write( + self.style.SUCCESS( + f"Successfully created {created_count} category pages under '{shop_index.title}'" + ) + ) + else: + self.stdout.write(self.style.WARNING("No new category pages were created")) + + # Display URLs + if created_count > 0: + self.stdout.write("\nCategory URLs:") + for category in ( + shop_index.get_children() + .type(ShopCategoryPage) + .live() + .order_by("title") + ): + self.stdout.write(f" → {category.get_url()} - {category.title}") + + self.stdout.write("=" * 60) diff --git a/app/shop/management/commands/create_sample_products.py b/app/shop/management/commands/create_sample_products.py new file mode 100644 index 0000000..ddf5004 --- /dev/null +++ b/app/shop/management/commands/create_sample_products.py @@ -0,0 +1,354 @@ +import random +from decimal import Decimal + +from django.core.management.base import BaseCommand +from faker import Faker +from wagtail.images.models import Image +from wagtail.models import Page + +from app.shop.models import ProductImage, ProductPage, ShopCategoryPage + +fake = Faker() + + +class Command(BaseCommand): + help = "Creates sample product pages for testing under existing category pages" + + def add_arguments(self, parser): + parser.add_argument( + "--count", + type=int, + default=20, + help="Number of products to create per category (default: 20)", + ) + parser.add_argument( + "--reset", + action="store_true", + help="Delete all existing ProductPage instances before creating new ones", + ) + parser.add_argument( + "--with-images", + action="store_true", + help="Generate sample product images and gallery", + ) + parser.add_argument( + "--featured", + type=int, + default=3, + help="Number of products per category to mark as featured (default: 3)", + ) + + def handle(self, *args, **options): + # Fix tree structure first (in case it's corrupted) + self.stdout.write("Fixing page tree structure...") + Page.fix_tree() + + # Get all category pages + categories = ShopCategoryPage.objects.live() + + if not categories.exists(): + self.stdout.write( + self.style.ERROR( + "No category pages found. Run 'python manage.py create_sample_categories' first." + ) + ) + return + + self.stdout.write( + self.style.SUCCESS(f"Found {categories.count()} category pages") + ) + + # Reset if requested + if options["reset"]: + self.stdout.write("Deleting existing product pages...") + all_products = ProductPage.objects.all() + deleted_count = all_products.count() + all_products.delete() + + # Refresh all categories from database to update tree structure + for category in categories: + category.refresh_from_db() + + self.stdout.write( + self.style.SUCCESS(f"Deleted {deleted_count} product pages") + ) + + # Get available images for products + available_images = [] + if options["with_images"]: + available_images = list(Image.objects.all().order_by("?")) # Random order + if not available_images: + self.stdout.write( + self.style.WARNING( + "No images found in database. Run 'python manage.py create_sample_media' first." + ) + ) + self.stdout.write( + self.style.WARNING("Creating products without images...") + ) + else: + self.stdout.write( + self.style.SUCCESS( + f"Found {len(available_images)} images available for products" + ) + ) + + # Product name templates by category + product_templates = { + "electronics": [ + "Wireless Bluetooth Headphones", + "Smart Watch Series {n}", + "Portable Bluetooth Speaker", + "USB-C Fast Charger", + "Wireless Mouse", + "Mechanical Keyboard", + "HD Webcam", + "Laptop Stand", + "Phone Case", + "Screen Protector", + "Power Bank 20000mAh", + "Wireless Earbuds", + "HDMI Cable", + "Gaming Controller", + "LED Desk Lamp", + ], + "fashion": [ + "Cotton T-Shirt", + "Denim Jeans", + "Leather Jacket", + "Running Shoes", + "Canvas Sneakers", + "Wool Sweater", + "Summer Dress", + "Casual Shorts", + "Baseball Cap", + "Leather Belt", + "Cotton Socks (3-Pack)", + "Winter Coat", + "Scarf", + "Gloves", + "Sunglasses", + ], + "home-living": [ + "Ceramic Coffee Mug", + "Throw Pillow", + "Floor Lamp", + "Wall Clock", + "Photo Frame", + "Desk Organizer", + "Storage Box", + "Area Rug", + "Curtain Panel", + "Table Lamp", + "Decorative Vase", + "Wall Art Print", + "Couch Cover", + "Bedding Set", + "Kitchen Mat", + ], + "beauty": [ + "Facial Cleanser", + "Moisturizing Cream", + "Lip Balm", + "Face Mask Set", + "Makeup Brush Set", + "Eyeshadow Palette", + "Liquid Foundation", + "Hair Serum", + "Nail Polish Set", + "Body Lotion", + "Sunscreen SPF 50", + "Anti-Aging Serum", + "Exfoliating Scrub", + "Makeup Remover", + "Hair Straightener", + ], + "sports": [ + "Yoga Mat", + "Resistance Bands Set", + "Dumbbell Set", + "Water Bottle", + "Running Shorts", + "Sports Bra", + "Fitness Tracker", + "Jump Rope", + "Gym Bag", + "Foam Roller", + "Athletic Socks", + "Compression Sleeves", + "Exercise Ball", + "Knee Support", + "Sweat Towel", + ], + "books": [ + "The Art of Programming", + "Mystery Novel Collection", + "Cookbook: Healthy Recipes", + "Self-Help Guide", + "Science Fiction Novel", + "Biography: Inspiring Lives", + "Children's Story Book", + "Travel Guide", + "Business Strategy Book", + "Poetry Collection", + "History Encyclopedia", + "Graphic Novel", + "Language Learning Book", + "Philosophy Reader", + "DIY Home Improvement", + ], + } + + total_created = 0 + total_images_created = 0 + + for category in categories: + self.stdout.write(f"\nProcessing category: {category.title}") + self.stdout.write("-" * 60) + + # Get product templates for this category + category_slug = category.slug + templates = product_templates.get( + category_slug, ["Product {n}"] * options["count"] + ) + + # Ensure we have enough product names + if len(templates) < options["count"]: + # Cycle through templates if we need more + templates = templates * ((options["count"] // len(templates)) + 1) + + # Shuffle templates for variety + random.shuffle(templates) + + for i in range(options["count"]): + # Generate product title + product_title = templates[i].format(n=i + 1) + + # Check if product already exists + existing = ( + category.get_children() + .type(ProductPage) + .filter(title=product_title) + ) + if existing.exists(): + self.stdout.write( + self.style.WARNING( + f" Product '{product_title}' already exists, skipping..." + ) + ) + continue + + # Generate realistic price (10-500 range with .99 endings) + price_base = random.randint(10, 500) + price = Decimal(f"{price_base}.99") + + # Generate rich text description + description = self._generate_description(product_title, category.title) + + # Random stock status (80% in stock) + in_stock = random.random() < 0.8 + + # Determine if featured (first N products per category) + featured = i < options["featured"] + + # Generate SKU with category prefix and random suffix + from datetime import datetime + + category_prefix = category.slug[:4].upper() + timestamp = datetime.now().strftime("%m%d%H%M%S%f")[ + :14 + ] # Include microseconds + sku = f"{category_prefix}-{timestamp}" + + # Create product page + product = ProductPage( + title=product_title, + price=price, + sku=sku, # Explicitly set SKU + description=description, + in_stock=in_stock, + featured=featured, + show_in_menus=False, + ) + + # Assign main image if available + if available_images: + product.main_image = random.choice(available_images) + + # Add as child of category + category.add_child(instance=product) + rev = product.save_revision() + rev.publish() + + # Add gallery images (2-4 images per product) + if available_images and options["with_images"]: + num_gallery_images = random.randint(2, 4) + # Get random images, ensuring no duplicates + gallery_images = random.sample( + available_images, min(num_gallery_images, len(available_images)) + ) + + for img in gallery_images: + ProductImage.objects.create( + product=product, + image=img, + caption=f"{product_title} - {fake.sentence(nb_words=4)}", + sort_order=gallery_images.index(img), + ) + total_images_created += 1 + + total_created += 1 + + # Status indicators + status_indicators = [] + if product.main_image: + status_indicators.append("🖼️ ") + if product.featured: + status_indicators.append("⭐") + if not product.in_stock: + status_indicators.append("📦") + + status = " ".join(status_indicators) + + self.stdout.write( + self.style.SUCCESS( + f" ✓ {product.title} - ${product.price} {status}" + ) + ) + + # Summary + self.stdout.write("\n" + "=" * 60) + self.stdout.write( + self.style.SUCCESS( + f"Successfully created {total_created} product pages across {categories.count()} categories" + ) + ) + if options["with_images"] and total_images_created > 0: + self.stdout.write( + self.style.SUCCESS( + f"Added {total_images_created} gallery images to products" + ) + ) + + self.stdout.write("\nLegend:") + self.stdout.write(" 🖼️ = Has main image") + self.stdout.write(" ⭐ = Featured product") + self.stdout.write(" 📦 = Out of stock") + self.stdout.write("=" * 60) + + def _generate_description(self, product_title, category_title): + """Generate realistic product description using Faker""" + intro = fake.paragraph(nb_sentences=2) + features = "\n".join( + [f"
  • {fake.sentence()}
  • " for _ in range(random.randint(3, 5))] + ) + closing = fake.paragraph(nb_sentences=1) + + return f""" +

    {intro}

    +

    Key Features:

    + +

    {closing}

    + """ diff --git a/app/shop/migrations/0001_initial.py b/app/shop/migrations/0001_initial.py new file mode 100644 index 0000000..c6543cd --- /dev/null +++ b/app/shop/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.9 on 2026-01-02 17:18 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'), + ('wagtailimages', '0027_image_description'), + ] + + operations = [ + migrations.CreateModel( + name='ShopCategoryPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')), + ('description', wagtail.fields.RichTextField(blank=True)), + ('featured', models.BooleanField(default=False)), + ('icon', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + ] diff --git a/app/shop/migrations/0002_shopindexpage.py b/app/shop/migrations/0002_shopindexpage.py new file mode 100644 index 0000000..e810a3a --- /dev/null +++ b/app/shop/migrations/0002_shopindexpage.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.9 on 2026-01-02 22:01 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0001_initial'), + ('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ShopIndexPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')), + ('intro', wagtail.fields.RichTextField(blank=True, help_text='Optional introduction text for the shop')), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + ] diff --git a/app/shop/migrations/0003_productpage_productimage.py b/app/shop/migrations/0003_productpage_productimage.py new file mode 100644 index 0000000..8ecd4bd --- /dev/null +++ b/app/shop/migrations/0003_productpage_productimage.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.9 on 2026-01-02 22:42 + +import django.db.models.deletion +import modelcluster.fields +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0002_shopindexpage'), + ('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'), + ('wagtailimages', '0027_image_description'), + ] + + operations = [ + migrations.CreateModel( + name='ProductPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')), + ('price', models.DecimalField(decimal_places=2, help_text='Product price in dollars', max_digits=10)), + ('sku', models.CharField(blank=True, help_text='Stock Keeping Unit - leave blank to auto-generate', max_length=100, unique=True)), + ('description', wagtail.fields.RichTextField(help_text='Product description and details')), + ('in_stock', models.BooleanField(default=True, help_text='Whether the product is currently available')), + ('featured', models.BooleanField(default=False, help_text='Show this product in featured sections')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('main_image', models.ForeignKey(blank=True, help_text='Main product image', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + migrations.CreateModel( + name='ProductImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), + ('caption', models.CharField(blank=True, max_length=250)), + ('image', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='wagtailimages.image')), + ('product', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='gallery_images', to='shop.productpage')), + ], + options={ + 'ordering': ['sort_order'], + }, + ), + ] diff --git a/app/shop/migrations/__init__.py b/app/shop/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/models.py b/app/shop/models.py new file mode 100644 index 0000000..f75507b --- /dev/null +++ b/app/shop/models.py @@ -0,0 +1,202 @@ +from django.core.paginator import Paginator +from django.db import models +from modelcluster.fields import ParentalKey +from wagtail.admin.panels import FieldPanel, InlinePanel +from wagtail.fields import RichTextField +from wagtail.models import Orderable, Page + +""" +**Create ShopCategoryPage model** + - File: `app/shop/models.py` + - Extends: `wagtail.models.Page` + - Fields: + - `description` (RichTextField, blank=True) + - `icon` (ForeignKey to wagtailimages.Image, optional) + - `featured` (BooleanField, default=False) + - Content panels: + - Basic: title (inherited from Page), description, icon, featured + - Settings panels: + - Promote tab: slug (auto-populating), SEO fields + - Parent page types: `HomePage` or self (for subcategories) + - Subpage types: `ProductPage`, `CategoryPage` (allow subcategories) + - Methods: + - `get_products()`: Returns child ProductPage objects + - `get_context()`: Add products and pagination to template + """ + + +class ShopIndexPage(Page): + """ + Main shop page that displays all categories. + Should be created once as a child of HomePage. + """ + + intro = RichTextField( + blank=True, help_text="Optional introduction text for the shop" + ) + + content_panels = Page.content_panels + [ + FieldPanel("intro"), + ] + + parent_page_types = ["home.HomePage"] + subpage_types = ["shop.ShopCategoryPage"] + max_count = 1 # Only one shop index page allowed + + def get_context(self, request): + context = super().get_context(request) + # Get all category pages that are children of this shop index + categories = ShopCategoryPage.objects.child_of(self).live().order_by("title") + context["categories"] = categories + return context + + +class ShopCategoryPage(Page): + description = RichTextField(blank=True) + icon = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + ) + featured = models.BooleanField(default=False) + + content_panels = Page.content_panels + [ + FieldPanel("description"), + FieldPanel("icon"), + FieldPanel("featured"), + ] + + parent_page_types = ["shop.ShopIndexPage"] + subpage_types = ["shop.ProductPage"] + + def get_products(self): + """Returns child ProductPage objects""" + return ProductPage.objects.child_of(self).live().order_by("-first_published_at") + + def get_context(self, request): + context = super().get_context(request) + products = self.get_products() + paginator = Paginator(products, 12) # Show 12 products per page + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + context["products"] = page_obj + return context + + +class ProductImage(Orderable): + """ + Inline image gallery for products. + Allows multiple images per product with captions and ordering. + """ + + product = ParentalKey( + "ProductPage", on_delete=models.CASCADE, related_name="gallery_images" + ) + image = models.ForeignKey( + "wagtailimages.Image", + on_delete=models.CASCADE, + related_name="+", + ) + caption = models.CharField(max_length=250, blank=True) + + panels = [ + FieldPanel("image"), + FieldPanel("caption"), + ] + + class Meta: + ordering = ["sort_order"] + + def __str__(self): + return f"Image for {self.product.title}" + + +class ProductPage(Page): + """ + Individual product detail page. + Products are children of CategoryPage in the page tree. + """ + + # Basic product info + price = models.DecimalField( + max_digits=10, + decimal_places=2, + help_text="Product price in dollars", + ) + sku = models.CharField( + max_length=100, + unique=True, + blank=True, + help_text="Stock Keeping Unit - leave blank to auto-generate", + ) + + # Product content + description = RichTextField(help_text="Product description and details") + main_image = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + help_text="Main product image", + ) + + # Product status + in_stock = models.BooleanField( + default=True, help_text="Whether the product is currently available" + ) + featured = models.BooleanField( + default=False, help_text="Show this product in featured sections" + ) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + + content_panels = Page.content_panels + [ + FieldPanel("sku"), + FieldPanel("price"), + FieldPanel("main_image"), + FieldPanel("description"), + InlinePanel("gallery_images", label="Gallery Images"), + FieldPanel("in_stock"), + FieldPanel("featured"), + ] + + parent_page_types = ["shop.ShopCategoryPage"] + subpage_types = [] # Products are leaf nodes + + search_fields = ( + Page.search_fields + + [ + # Inherit Page.search_fields (title, etc.) + ] + ) + + def get_category(self): + """Returns the parent CategoryPage""" + return self.get_parent().specific + + def save(self, *args, **kwargs): + """Auto-generate SKU if not provided""" + if not self.sku: + # Generate SKU based on category and timestamp + category = self.get_parent() + if category: + category_prefix = category.slug[:4].upper() + # Use ID if available, otherwise use timestamp + random suffix + if self.id: + self.sku = f"{category_prefix}-{self.id:04d}" + else: + # For new products, use a timestamp-based SKU with random suffix + import random + from datetime import datetime + + timestamp = datetime.now().strftime("%m%d%H%M%S") + random_suffix = random.randint(1000, 9999) + self.sku = f"{category_prefix}-{timestamp}-{random_suffix}" + super().save(*args, **kwargs) + + def __str__(self): + return self.title diff --git a/app/shop/templates/shop/product_page.html b/app/shop/templates/shop/product_page.html new file mode 100644 index 0000000..36450a0 --- /dev/null +++ b/app/shop/templates/shop/product_page.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% load static wagtailcore_tags wagtailimages_tags %} + +{% block body_class %}template-productpage{% endblock %} + +{% block content %} +
    + + + + +
    + +
    +
    + {% if page.main_image %} + {% image page.main_image fill-800x800 as main_img %} + {{ page.title }} + {% else %} + + + No image + + {% endif %} +
    + + + {% if page.gallery_images.all %} + + {% endif %} +
    + + +
    +

    {{ page.title }}

    + + +
    +

    ${{ page.price }}

    + + {% if page.in_stock %} + + + + + In Stock + + {% else %} + + + + + + Out of Stock + + {% endif %} +
    + + + {% if page.sku %} +

    SKU: {{ page.sku }}

    + {% endif %} + + +
    + {{ page.description|richtext }} +
    + + +
    + +

    Shopping cart functionality will be available in a future update.

    +
    +
    +
    + + + +
    +{% endblock content %} diff --git a/app/shop/templates/shop/shop_category_page.html b/app/shop/templates/shop/shop_category_page.html new file mode 100644 index 0000000..63aeff6 --- /dev/null +++ b/app/shop/templates/shop/shop_category_page.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} +{% load static wagtailcore_tags wagtailimages_tags %} + +{% block body_class %}template-shopcategorypage{% endblock %} + +{% block content %} +
    + + + + +
    + {% if page.icon %} +
    + {% image page.icon width-100 as icon_img %} + {{ page.title }} icon +
    + {% endif %} + +

    {{ page.title }}

    + + {% if page.description %} +
    + {{ page.description|richtext }} +
    + {% endif %} +
    + + + {% if subcategories %} +
    +

    Browse Subcategories

    + +
    + {% endif %} + + + {% if products %} +
    +

    {% if subcategories %}All Products{% else %}Products{% endif %}

    + +
    + + + {% if products.paginator.num_pages > 1 %} + + {% endif %} + + {% elif not subcategories %} + +
    +

    No products yet

    +

    This category doesn't have any products at the moment.

    +
    + {% endif %} +
    +{% endblock content %} diff --git a/app/shop/templates/shop/shop_index_page.html b/app/shop/templates/shop/shop_index_page.html new file mode 100644 index 0000000..4925629 --- /dev/null +++ b/app/shop/templates/shop/shop_index_page.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% load static wagtailcore_tags wagtailimages_tags %} + +{% block body_class %}template-shopindexpage{% endblock %} + +{% block content %} +
    + +
    +

    {{ page.title }}

    + {% if page.intro %} +
    + {{ page.intro|richtext }} +
    + {% endif %} +
    + + + {% if categories %} +
    + +
    + {% else %} + +
    +

    No categories yet

    +

    Categories will appear here once they are created.

    +

    + Run python manage.py create_sample_categories to create sample categories. +

    +
    + {% endif %} +
    +{% endblock content %} diff --git a/app/shop/tests.py b/app/shop/tests.py new file mode 100644 index 0000000..f1bb0d8 --- /dev/null +++ b/app/shop/tests.py @@ -0,0 +1,338 @@ +from decimal import Decimal + +from django.test import TestCase +from wagtail.images.tests.utils import get_test_image_file +from wagtail.test.utils import WagtailPageTestCase + +from app.home.models import HomePage +from app.shop.models import ProductImage, ProductPage, ShopCategoryPage, ShopIndexPage + + +class ShopIndexPageTest(WagtailPageTestCase): + """Tests for ShopIndexPage model""" + + def setUp(self): + self.home_page = HomePage.objects.first() + + def test_can_create_shop_index_page(self): + """Test that ShopIndexPage can be created under HomePage""" + self.assertCanCreateAt(HomePage, ShopIndexPage) + + def test_shop_index_page_only_allows_category_children(self): + """Test that ShopIndexPage only allows ShopCategoryPage as children""" + self.assertAllowedSubpageTypes(ShopIndexPage, {ShopCategoryPage}) + + def test_shop_index_page_max_count(self): + """Test that only one ShopIndexPage can be created""" + # Create first shop index + shop_index = ShopIndexPage(title="Shop", slug="shop", intro="

    Test

    ") + self.home_page.add_child(instance=shop_index) + + # Check max_count is enforced + self.assertEqual(ShopIndexPage.max_count, 1) + + +class ShopCategoryPageTest(WagtailPageTestCase): + """Tests for ShopCategoryPage model""" + + def setUp(self): + self.home_page = HomePage.objects.first() + self.shop_index = ShopIndexPage( + title="Shop", slug="shop", intro="

    Browse our products

    " + ) + self.home_page.add_child(instance=self.shop_index) + + def test_can_create_category_page(self): + """Test that ShopCategoryPage can be created under ShopIndexPage""" + self.assertCanCreateAt(ShopIndexPage, ShopCategoryPage) + + def test_category_page_allows_product_children(self): + """Test that ShopCategoryPage allows ProductPage as children""" + self.assertAllowedSubpageTypes(ShopCategoryPage, {ProductPage}) + + def test_category_page_creation(self): + """Test creating a category page with all fields""" + category = ShopCategoryPage( + title="Electronics", + slug="electronics", + description="

    Electronic products

    ", + featured=True, + ) + self.shop_index.add_child(instance=category) + + # Verify the category was created + self.assertEqual(category.title, "Electronics") + self.assertEqual(category.slug, "electronics") + self.assertTrue(category.featured) + + def test_get_products_method(self): + """Test that get_products() returns child ProductPage objects""" + # Create category + category = ShopCategoryPage(title="Electronics", slug="electronics") + self.shop_index.add_child(instance=category) + + # Create some products + for i in range(3): + product = ProductPage( + title=f"Product {i}", + slug=f"product-{i}", + price=Decimal("99.99"), + description="

    Test product

    ", + sku=f"TEST-{i}", + ) + category.add_child(instance=product) + + # Test get_products method + products = category.get_products() + self.assertEqual(products.count(), 3) + + +class ProductPageTest(WagtailPageTestCase): + """Tests for ProductPage model""" + + def setUp(self): + self.home_page = HomePage.objects.first() + + # Create shop structure + self.shop_index = ShopIndexPage(title="Shop", slug="shop") + self.home_page.add_child(instance=self.shop_index) + + self.category = ShopCategoryPage( + title="Electronics", + slug="electronics", + description="

    Electronic products

    ", + ) + self.shop_index.add_child(instance=self.category) + + def test_can_create_product_page(self): + """Test that ProductPage can be created under ShopCategoryPage""" + self.assertCanCreateAt(ShopCategoryPage, ProductPage) + + def test_product_page_parent_types(self): + """Test that ProductPage can only be created under ShopCategoryPage""" + self.assertAllowedParentPageTypes(ProductPage, {ShopCategoryPage}) + + def test_product_page_has_no_subpages(self): + """Test that ProductPage is a leaf node (no subpages allowed)""" + self.assertAllowedSubpageTypes(ProductPage, {}) + + def test_product_page_creation_with_all_fields(self): + """Test creating a product page with all required and optional fields""" + product = ProductPage( + title="Wireless Headphones", + slug="wireless-headphones", + price=Decimal("129.99"), + sku="ELEC-001", + description="

    High quality wireless headphones

    ", + in_stock=True, + featured=True, + ) + self.category.add_child(instance=product) + + # Verify all fields + self.assertEqual(product.title, "Wireless Headphones") + self.assertEqual(product.price, Decimal("129.99")) + self.assertEqual(product.sku, "ELEC-001") + self.assertTrue(product.in_stock) + self.assertTrue(product.featured) + self.assertIsNotNone(product.created_at) + + def test_product_sku_auto_generation(self): + """Test that SKU is auto-generated when not provided""" + product = ProductPage( + title="Test Product", + slug="test-product", + price=Decimal("49.99"), + description="

    Test

    ", + ) + self.category.add_child(instance=product) + + # SKU should be auto-generated + self.assertIsNotNone(product.sku) + self.assertTrue(product.sku.startswith("ELEC-")) + + def test_product_sku_uniqueness(self): + """Test that SKU field enforces uniqueness""" + # Create first product + product1 = ProductPage( + title="Product 1", + slug="product-1", + price=Decimal("99.99"), + sku="TEST-123", + description="

    Test

    ", + ) + self.category.add_child(instance=product1) + + # Try to create another product with same SKU + product2 = ProductPage( + title="Product 2", + slug="product-2", + price=Decimal("99.99"), + sku="TEST-123", # Same SKU + description="

    Test

    ", + ) + + # This should raise a validation error + from django.core.exceptions import ValidationError + + with self.assertRaises(ValidationError): + self.category.add_child(instance=product2) + + def test_get_category_method(self): + """Test that get_category() returns the parent category""" + product = ProductPage( + title="Test Product", + slug="test-product", + price=Decimal("99.99"), + sku="TEST-001", + description="

    Test

    ", + ) + self.category.add_child(instance=product) + + # Get category + parent_category = product.get_category() + self.assertEqual(parent_category.id, self.category.id) + self.assertIsInstance(parent_category, ShopCategoryPage) + + def test_product_page_str_method(self): + """Test the string representation of ProductPage""" + product = ProductPage( + title="Test Product", + slug="test-product", + price=Decimal("99.99"), + sku="TEST-001", + description="

    Test

    ", + ) + self.category.add_child(instance=product) + + self.assertEqual(str(product), "Test Product") + + +class ProductImageTest(TestCase): + """Tests for ProductImage inline model""" + + def setUp(self): + from wagtail.images.models import Image + + self.home_page = HomePage.objects.first() + + # Create shop structure + self.shop_index = ShopIndexPage(title="Shop", slug="shop") + self.home_page.add_child(instance=self.shop_index) + + self.category = ShopCategoryPage(title="Electronics", slug="electronics") + self.shop_index.add_child(instance=self.category) + + self.product = ProductPage( + title="Test Product", + slug="test-product", + price=Decimal("99.99"), + sku="TEST-001", + description="

    Test

    ", + ) + self.category.add_child(instance=self.product) + + # Create test image + self.image = Image.objects.create( + title="Test image", file=get_test_image_file() + ) + + def test_product_image_creation(self): + """Test creating a ProductImage""" + product_image = ProductImage.objects.create( + product=self.product, image=self.image, caption="Test caption" + ) + + self.assertEqual(product_image.product, self.product) + self.assertEqual(product_image.image, self.image) + self.assertEqual(product_image.caption, "Test caption") + + def test_product_image_gallery_relationship(self): + """Test that product images are accessible via gallery_images""" + # Create multiple gallery images + for i in range(3): + ProductImage.objects.create( + product=self.product, + image=self.image, + caption=f"Gallery image {i}", + sort_order=i, + ) + + # Check that all images are accessible + gallery_images = self.product.gallery_images.all() + self.assertEqual(gallery_images.count(), 3) + + def test_product_image_ordering(self): + """Test that product images are ordered by sort_order""" + # Create images with specific sort orders + ProductImage.objects.create( + product=self.product, image=self.image, caption="Third", sort_order=3 + ) + ProductImage.objects.create( + product=self.product, image=self.image, caption="First", sort_order=1 + ) + ProductImage.objects.create( + product=self.product, image=self.image, caption="Second", sort_order=2 + ) + + # Get ordered images + ordered_images = self.product.gallery_images.all() + self.assertEqual(ordered_images[0].caption, "First") + self.assertEqual(ordered_images[1].caption, "Second") + self.assertEqual(ordered_images[2].caption, "Third") + + def test_product_image_str_method(self): + """Test the string representation of ProductImage""" + product_image = ProductImage.objects.create( + product=self.product, image=self.image, caption="Test" + ) + + self.assertEqual(str(product_image), "Image for Test Product") + + +class ProductPageTemplateTest(TestCase): + """Tests for ProductPage template rendering""" + + def setUp(self): + self.home_page = HomePage.objects.first() + + # Create shop structure + self.shop_index = ShopIndexPage(title="Shop", slug="shop") + self.home_page.add_child(instance=self.shop_index) + + self.category = ShopCategoryPage(title="Electronics", slug="electronics") + self.shop_index.add_child(instance=self.category) + + self.product = ProductPage( + title="Test Product", + slug="test-product", + price=Decimal("99.99"), + sku="TEST-001", + description="

    This is a test product

    ", + in_stock=True, + ) + self.category.add_child(instance=self.product) + + # Publish the pages + revision = self.product.save_revision() + revision.publish() + + def test_product_page_renders(self): + """Test that product page returns 200 status""" + response = self.client.get(self.product.url) + self.assertEqual(response.status_code, 200) + + def test_product_page_uses_correct_template(self): + """Test that product page uses the correct template""" + response = self.client.get(self.product.url) + self.assertTemplateUsed(response, "shop/product_page.html") + + def test_product_page_context(self): + """Test that product page has correct context""" + response = self.client.get(self.product.url) + + # Check that page object is in context + self.assertEqual(response.context["page"], self.product) + self.assertIn("Test Product", response.content.decode()) + self.assertIn("$99.99", response.content.decode()) + self.assertIn("In Stock", response.content.decode()) diff --git a/app/shop/views.py b/app/shop/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/app/shop/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/app/templates/base.html b/app/templates/base.html index 92e1099..6673616 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -34,8 +34,12 @@ {% wagtailuserbar %} + {% include "includes/base_header.html" %} + {% block content %}{% endblock %} + {% include "includes/base_footer.html" %} + {# Global javascript #} diff --git a/app/templates/includes/base_footer.html b/app/templates/includes/base_footer.html new file mode 100644 index 0000000..5fe6898 --- /dev/null +++ b/app/templates/includes/base_footer.html @@ -0,0 +1,36 @@ + diff --git a/app/templates/includes/base_header.html b/app/templates/includes/base_header.html new file mode 100644 index 0000000..78cba28 --- /dev/null +++ b/app/templates/includes/base_header.html @@ -0,0 +1,15 @@ +
    +
    + + +
    +
    diff --git a/docs/README.md b/docs/README.md index 4715b42..23a81a1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,5 +4,7 @@ This is the docs folder. It contains all the documentation for the project. ## Table of contents -- [Backend Documentation](./backend-development.md) -- [Frontend Documentation](./frontend-development.md) +- [Installation Guide](./installation.md) +- [Backend Development](./backend-development.md) +- [Frontend Development](./frontend-development.md) +- [Management Commands](./management-commands.md) diff --git a/docs/development-progress.md b/docs/development-progress.md new file mode 100644 index 0000000..8e61838 --- /dev/null +++ b/docs/development-progress.md @@ -0,0 +1,316 @@ +# E-commerce Development Progress Tracker + +> **Quick reference checklist for tracking development progress** +> +> For detailed specifications, see [E-commerce Development Plan](./e-commerce-development-plan.md) + +**Last Updated**: Not started + +**Current Phase**: Setup + +--- + +## Setup & Prerequisites + +- [x] Docker environment running (`make up`) +- [x] Database selected (SQLite/PostgreSQL/MySQL) +- [x] Superuser created (`make superuser`) +- [x] Frontend assets compiled (`npm run build`) + +--- + +## Phase 1: Category Pages + +**Goal**: Create category pages as foundation of page tree + +**Architecture**: Categories as Wagtail Pages (not snippets) - Products will be children + +### Core Tasks +- [x] Create shop app (`python manage.py startapp shop`) +- [x] Add shop to INSTALLED_APPS in `app/settings/base.py` +- [x] Create CategoryPage model (extends wagtail.models.Page) + - Fields: description (RichTextField), icon, featured + - Parent types: HomePage, self (subcategories) + - Subpage types: ProductPage, CategoryPage + - Methods: get_products(), get_context() +- [x] Create category page template (`app/shop/templates/shop/category_page.html`) + - Category header, description, icon + + - Product grid showing child products + +- [x] Create a ShopIndexPage (extends wagtail.models.Page) + - Parent types: HomePage ONLY + - Subpage types: CategoryPage ONLY + - Template: `app/shop/templates/shop/shop_index_page.html` + - Displays all CategoryPages as cards with icon, name, description +- [x] Add styling in extra_css block +- [x] Run migrations +- [x] Create sample categories command + - Command: `python manage.py create_sample_categories` + - Creates ShopIndexPage under HomePage (if not exists) + - Creates 6 CategoryPage instances under ShopIndexPage + - Test with: `--reset`, `--no-icons`, `--shop-slug=shop`, `--shop-title="Shop"` + +- [x] Verify in Wagtail admin: Category pages in page tree under HomePage + +**Completion Criteria**: +- [x] All tasks checked above +- [x] Category pages visible in Wagtail page tree +- [x] Categories browsable at shop/electronics/, shop/fashion/, etc. +- [x] Management command creates all 6 categories + + +--- + +## Phase 2: Product Detail Pages + +**Goal**: Individual product pages as children of categories + +**Architecture**: Products are children of CategoryPages in page tree + +### Core Tasks +- [ ] Create ProductPage model (extends wagtail.models.Page) + - Fields: price, sku, description, main_image, in_stock, featured, created_at + - Parent types: CategoryPage ONLY (enforced) + - Subpage types: None (leaf node) + - Search fields configured + - Methods: get_category() returns parent +- [ ] Create ProductImage inline model (gallery) + - ParentalKey to ProductPage + - Fields: image, caption, display_order +- [ ] Create product detail template (`app/shop/templates/shop/product_page.html`) + - Product image, title, breadcrumb (Home > Category > Product) + - Price display, description + - Stock status, "Add to Cart" placeholder + - Image gallery (thumbnail strip) +- [ ] Add styling in extra_css block +- [ ] Run migrations +- [ ] Create sample products command + - Command: `python manage.py create_sample_products --count=20 --with-images` + - Creates products as children of existing CategoryPages + - Distributes products evenly across categories + - Auto-generates SKUs +- [ ] Write tests for products and images + - Test parent type restriction + - Test get_category() method + - Test SKU auto-generation +- [ ] Verify: Products browsable in page tree under categories + +**Completion Criteria**: +- [ ] All tasks checked above +- [ ] Product pages render correctly at /electronics/wireless-headphones/ +- [ ] Products properly nested under categories in page tree +- [ ] Images display properly +- [ ] Management command creates realistic products +- [ ] Tests passing + +--- + +## Phase 3: Category Browse Pages + +**Goal**: Browse products by category + +### Core Tasks +- [ ] Create CategoryPage model (extends wagtail.models.Page) + - Fields: category (FK to ProductCategory), show_featured_only, products_per_page + - Methods: get_products(), get_context() with pagination +- [ ] Create category page template (`app/shop/templates/shop/category_page.html`) + - Category header (name, description, icon) + - Product grid (reuse product card styling) + - Pagination controls +- [ ] Update navigation + - Link category cards on home page to CategoryPages + - Add category menu to header +- [ ] Run migrations +- [ ] Create category pages command + - Command: `python manage.py create_category_pages` + - Test with: `--reset` +- [ ] Write tests for category pages and filtering +- [ ] Verify: Category pages show filtered products + +**Completion Criteria**: +- [ ] All tasks checked above +- [ ] Category pages display correctly +- [ ] Product filtering works +- [ ] Pagination functional +- [ ] Management command creates all pages +- [ ] Tests passing + +--- + +## Phase 4: Product Listing/Index Page + +**Goal**: Main shop page with filtering and search + +### Core Tasks +- [ ] Create ProductIndexPage model (extends wagtail.models.Page) + - Fields: intro, products_per_page, show_filters + - Methods: get_products(), get_context() with filtering +- [ ] Add filtering functionality + - GET parameters: category, min_price, max_price, in_stock, featured, sort + - Pagination support +- [ ] Create product index template (`app/shop/templates/shop/product_index_page.html`) + - Filter sidebar (category, price range, stock, sort) + - Product grid + - Result count, pagination +- [ ] Integrate with search + - Update `app/search/views.py` to show ProductPages + - Add search box to header +- [ ] Run migrations +- [ ] Create product index page (via admin or command) + - Command: `python manage.py setup_product_index` (optional) + - Slug: `/shop/` or `/products/` +- [ ] Update header navigation to link to shop page +- [ ] Write tests for filtering, sorting, pagination, search +- [ ] Verify: All products browsable with filters working + +**Completion Criteria**: +- [ ] All tasks checked above +- [ ] Filtering works correctly +- [ ] Search returns product results +- [ ] Sorting functional +- [ ] Tests passing + +--- + +## Phase 5: Product Enhancements + +**Goal**: Variants, specs, reviews, related products + +### Core Tasks +- [ ] Create ProductVariant model (inline) + - Fields: name, variant_type, price_modifier, sku_suffix, in_stock + - Add to ProductPage as InlinePanel +- [ ] Add product specifications + - Choose approach: StreamField, TableBlock, or JSONField + - Update template to display specs +- [ ] Add related products field + - ParentalManyToManyField to ProductPage + - Display in template (3-4 related products) +- [ ] Create ProductReview snippet + - Fields: author_name, rating, comment, created_at, is_approved + - Add review submission form + - Display approved reviews on product page +- [ ] Add breadcrumbs + - Template tag or include + - Show: Home > Category > Product +- [ ] Optimize images + - Create renditions: 200x200, 400x400, 800x800 + - Update templates to use renditions +- [ ] Create enhancement command + - Command: `python manage.py enhance_sample_products` + - Options: `--variants-only`, `--reviews-only`, `--related-only`, `--reset` +- [ ] Run migrations +- [ ] Write tests for variants, specs, reviews, related products +- [ ] Verify: All enhancements working + +**Completion Criteria**: +- [ ] All tasks checked above +- [ ] Variants selectable +- [ ] Reviews display and submission works +- [ ] Related products show +- [ ] Breadcrumbs appear +- [ ] Management command enhances products +- [ ] Tests passing + +--- + +## Phase 6: Supporting Content Pages + +**Goal**: Info pages and contact form + +### Core Tasks +- [ ] Create StandardPage model (StreamField for body) +- [ ] Create ContactFormPage model (wagtail.contrib.forms) + - Fields: name, email, subject, message + - Email notification setup +- [ ] Create templates for content pages +- [ ] Create specific pages: + - [ ] About Us + - [ ] FAQ + - [ ] Shipping & Returns + - [ ] Terms & Conditions + - [ ] Privacy Policy + - [ ] Contact page with form +- [ ] Update footer links to actual pages +- [ ] Create content pages command + - Command: `python manage.py create_content_pages` + - Options: `--reset`, `--pages=about,faq` +- [ ] Write tests for content pages and contact form +- [ ] Verify: All info pages accessible, contact form works + +**Completion Criteria**: +- [ ] All tasks checked above +- [ ] All info pages created +- [ ] Contact form submits successfully +- [ ] Footer links functional +- [ ] Management command creates pages +- [ ] Tests passing + +--- + +## Quick Commands Reference + +### Database Reset +```bash +make down && make up && make migrate +make superuser +``` + +### Run All Phase Commands +```bash +# Phase 1 +python manage.py create_sample_categories + +# Phase 2 +python manage.py create_sample_products --count=50 --with-images + +# Phase 3 +python manage.py create_category_pages + +# Phase 4 +python manage.py setup_product_index + +# Phase 5 +python manage.py enhance_sample_products + +# Phase 6 +python manage.py create_content_pages +``` + +### Testing +```bash +make test # Run all tests +make sh # Enter container shell +python manage.py test app.shop.tests # Test shop app only +``` + +### Frontend +```bash +npm run build # Build production assets +npm start # Watch mode for development +``` + +--- + +## Notes & Decisions + +_Use this section to track important decisions, blockers, or notes as you work_ + +- **[Date]**: Decision/Note here +- **[Date]**: Issue encountered and resolution + +--- + +## Next Steps + +When resuming work: +1. Check which phase is current (see "Current Phase" at top) +2. Review unchecked tasks in that phase +3. Run `make test` to verify current state +4. Continue with next unchecked task + +For detailed implementation specs, always refer to [E-commerce Development Plan](./e-commerce-development-plan.md) diff --git a/docs/e-commerce-development-plan.md b/docs/e-commerce-development-plan.md new file mode 100644 index 0000000..039e3ef --- /dev/null +++ b/docs/e-commerce-development-plan.md @@ -0,0 +1,802 @@ +# Wagtail Shop Kit - E-commerce Development Plan + +## Overview + +Incremental development plan for building out e-commerce functionality in Wagtail Shop Kit. This plan focuses on establishing the content foundation (categories, products, pages) before adding transactional features (cart, checkout, users). + +## Development Philosophy + +- **Start with foundation**: Categories → Products → Listings +- **Content before transactions**: Build browsable catalog before cart/checkout +- **Wagtail-native patterns**: Use Pages for URLs, Snippets for reusable content +- **Test as you go**: Write tests for each phase +- **Keep it simple**: Start with MVP features, iterate later +- **Reproducible content**: Create management commands and/or fixtures for each phase to enable UI testing and quick site resets + +--- + +## Data Management & Fixtures Strategy + +**Goal**: Every phase should produce reproducible sample data for testing and development. + +### Approach + +For each phase of development: + +1. **Management Commands** (Preferred for complex data): + - Create command in `app/shop/management/commands/` + - Follow pattern from existing `create_sample_media.py` + - Generate realistic sample data with randomization + - Support options: `--count`, `--reset`, `--clear` + - Make idempotent (safe to run multiple times) + - Log progress clearly + +2. **Fixtures** (For simple, static data): + - Create JSON fixtures in `app/shop/fixtures/` + - Use for consistent reference data (categories, sample users) + - Load with `python manage.py loaddata ` + - Keep minimal and focused + +### Benefits + +- **UI Testing**: Quickly populate site with realistic content for visual testing +- **Demo Environments**: Spin up demo sites with full content in seconds +- **Onboarding**: New developers can see working examples immediately +- **CI/CD**: Automated testing with predictable data +- **Site Resets**: Recover from mistakes or experiments quickly + +### Naming Convention + +Management commands should follow this pattern: +- Phase 1: `setup_phase1_categories.py` or `create_sample_categories.py` +- Phase 2: `setup_phase2_products.py` or `create_sample_products.py` +- Phase 3: `setup_phase3_category_pages.py` +- Phase 4: `setup_phase4_product_index.py` +- Or create cumulative: `setup_shop_demo.py --phase=1` to `--phase=6` + +### Quick Setup Workflow + +After implementing each phase: +```bash +# Reset database +make down && make up && make migrate + +# Create superuser +make superuser + +# Run phase setup commands +python manage.py setup_phase1_categories +python manage.py setup_phase2_products --count=50 +python manage.py setup_phase3_category_pages +python manage.py setup_phase4_product_index + +# Or use all-in-one command +python manage.py setup_shop_demo --all-phases +``` + +--- + +## Phase 1: Category Pages + +**Goal**: Create category pages as the foundation of the page tree hierarchy + +### Architecture Decision + +Categories are implemented as **Wagtail Pages** (not snippets) to leverage Wagtail's page tree for hierarchical navigation. Products will be children of category pages, creating a natural URL structure like `/electronics/wireless-headphones/`. + +**Trade-offs:** +- ✅ Natural URL hierarchy and breadcrumbs +- ✅ Built-in Wagtail admin page management +- ✅ Easy to add category-specific content and templates +- ⚠️ Products can only belong to one category (parent page relationship) +- ⚠️ Moving products between categories requires page tree operations + +### Tasks + +1. **Create shop app** + ```bash + python manage.py startapp shop + ``` + - Add to `INSTALLED_APPS` in `app/settings/base.py` + - Create `app/shop/templates/shop/` directory structure + - Create initial `__init__.py`, `models.py`, `admin.py`, `tests.py` + +2. **Create CategoryPage model** + - File: `app/shop/models.py` + - Extends: `wagtail.models.Page` + - Fields: + - `description` (RichTextField, blank=True) + - `icon` (ForeignKey to wagtailimages.Image, optional) + - `featured` (BooleanField, default=False) + - Content panels: + - Basic: title (inherited from Page), description, icon, featured + - Settings panels: + - Promote tab: slug (auto-populating), SEO fields + - Parent page types: `HomePage` or self (for subcategories) + - Subpage types: `ProductPage`, `CategoryPage` (allow subcategories) + - Methods: + - `get_products()`: Returns child ProductPage objects + - `get_context()`: Add products and pagination to template + +3. **Create category page template** + - File: `app/shop/templates/shop/category_page.html` + - Extends: `base.html` + - Sections: + - Category header (title, description, icon) + - Child categories (if any) + - Product grid showing child products + - Pagination + - Responsive design using Pico CSS + +4. **Run migrations** + ```bash + python manage.py makemigrations shop + python manage.py migrate + ``` + +5. **Create sample categories command** + - **Management command**: `create_sample_categories.py` + - Creates 6 category pages as children of HomePage: + - Electronics, Fashion, Home & Living, Beauty, Sports, Books + - Options: + - `--reset`: Delete existing category pages + - `--with-icons`: Create icon images + - `--parent-slug=home`: Specify parent page (default: HomePage) + - **Purpose**: Reproducible category page structure for demos + +6. **Write tests** + - File: `app/shop/tests.py` + - Test CategoryPage creation and page tree hierarchy + - Test get_products() method + - Test category page rendering + - Test admin page operations (create, edit, move) + +**Deliverable**: CategoryPage model working in Wagtail page tree, ready to contain products + +--- + +## Phase 2: Product Detail Pages + +**Goal**: Create individual product pages as children of categories + +### Tasks + +1. **Create ProductPage model** + - File: `app/shop/models.py` + - Extends: `wagtail.models.Page` + - Fields: + - `price` (DecimalField, max_digits=10, decimal_places=2) + - `sku` (CharField, unique, optional, help_text for auto-generation) + - `description` (RichTextField) + - `main_image` (ForeignKey to wagtailimages.Image) + - `in_stock` (BooleanField, default=True) + - `featured` (BooleanField, default=False) + - `created_at` (DateTimeField, auto_now_add=True) + - Content panels: + - Basic info: title, sku, price + - Content: description (rich text), main_image + - Settings: in_stock, featured + - Parent page types: `CategoryPage` (products must be under a category) + - Subpage types: None (leaf node) + - Search fields: title, description, sku + - Methods: + - `get_category()`: Returns parent CategoryPage + - `save()`: Auto-generate SKU if not provided + +2. **Create product detail template** + - File: `app/shop/templates/shop/product_page.html` + - Extends: `base.html` + - Sections: + - Product image (large, responsive) + - Product title and category breadcrumb + - Price display (prominent, styled) + - Description (rich text) + - Stock status indicator + - "Add to Cart" placeholder button (non-functional for now) + - Related products section (future) + - Styling in `{% block extra_css %}` using Pico CSS + +3. **Enhance ProductPage with image gallery** + - Create `ProductImage` model (inline): + - ForeignKey to ProductPage (ParentalKey for Wagtail) + - ForeignKey to wagtailimages.Image + - `caption` (CharField, optional) + - `display_order` (IntegerField) + - Use `InlinePanel` in ProductPage + - Add gallery to template (thumbnail strip + lightbox) + +4. **Run migrations** + ```bash + python manage.py makemigrations shop + python manage.py migrate + ``` + +5. **Create sample products command** + - **Management command**: `create_sample_products.py` + - **Required for UI testing**: Generate realistic browsable products + - Options: + - `--count=20`: Number of products per category (default: 20) + - `--with-images`: Generate sample product images + - `--reset`: Clear existing product pages first + - `--featured=5`: Mark N products per category as featured + - Generate realistic product data using Faker: + - Product names (e.g., "Wireless Bluetooth Speaker", "Cotton T-Shirt") + - Rich text descriptions (2-3 paragraphs) + - Prices: $10-$500 range with .99 endings + - SKUs: Auto-generated (e.g., "ELEC-001", "FASH-042") + - Random in_stock status (80% in stock) + - Create products as children of CategoryPage instances + - Distribute evenly across all existing categories + - Generate 2-4 images per product using `create_sample_media` pattern + - **Purpose**: Reproducible product catalog for testing all product features + +6. **Write tests** + - Test ProductPage creation with all fields + - Test image gallery inline + - Test get_category() method returns parent + - Test parent page type restriction (must be CategoryPage) + - Test template rendering (200 status) + - Test search indexing + - Test SKU auto-generation + +**Deliverable**: Individual product pages with images, descriptions, browsable via Wagtail page tree under categories + +--- + +## Phase 3: Product Listing/Shop Index Page + +**Goal**: Create a main shop page showing all products with filtering + +### Architecture Note + +Since products are organized under CategoryPages in the tree, we need a separate page type that can list and filter products from across all categories. This provides a "browse all products" view independent of the category hierarchy. + +### Tasks + +1. **Create ProductIndexPage model** + - File: `app/shop/models.py` + - Extends: `wagtail.models.Page` + - Fields: + - `intro` (RichTextField, blank=True) + - `products_per_page` (IntegerField, default=12) + - `show_filters` (BooleanField, default=True) + - Content panels: + - Basic: title, intro, products_per_page, show_filters + - Parent page types: `HomePage` + - Subpage types: None (leaf node) + - Max count: 1 (only one shop index) + - Methods: + - `get_products()`: Returns all ProductPage objects (live, ordered) + - `get_categories()`: Returns all CategoryPage objects for filter sidebar + - `get_context()`: Add products, categories, filters, pagination to template + +2. **Add filtering functionality to ProductIndexPage** + - Handle GET parameters in `get_context()`: + - `category`: Filter by CategoryPage slug + - `min_price`, `max_price`: Price range + - `in_stock`: Show only available + - `featured`: Show featured products + - `sort`: Order by (price_asc, price_desc, newest, name) + - Add pagination support (Django Paginator) + +3. **Create product index template** + - File: `app/shop/templates/shop/product_index_page.html` + - Extends: `base.html` + - Sections: + - Page intro/description + - Filter sidebar/panel: + - Category filter (links to CategoryPages) + - Price range sliders + - In stock toggle + - Featured toggle + - Sort dropdown + - Product grid (same styling as category page) + - Result count ("Showing X of Y products") + - Pagination controls + - Responsive: Filters collapse to dropdown on mobile + +4. **Integrate with search** + - Update `app/search/views.py`: + - Filter results to show ProductPages + - Add product-specific result template + - Add search box to header navigation + - Link from ProductIndexPage + +5. **Update navigation** + - Update `app/home/templates/home/welcome_page.html`: + - Link "Shop Now" CTA to ProductIndexPage + - Link category cards to CategoryPage URLs + - Add "Shop" link to header navigation pointing to ProductIndexPage + +6. **Run migrations** + ```bash + python manage.py makemigrations shop + python manage.py migrate + ``` + +7. **Create product index page** (via admin or command) + - **Optional management command**: `setup_product_index.py` + - Create ProductIndexPage under HomePage programmatically + - Slug: `/shop/` or `/products/` + - Set intro text, products_per_page, show_filters + - **Alternative**: Document manual creation via Wagtail admin + - **Purpose**: Quick setup of main shop entry point + +8. **Write tests** + - Test ProductIndexPage creation + - Test get_products() returns all products + - Test get_categories() returns all categories + - Test filtering by category, price, stock + - Test sorting options + - Test pagination + - Test search integration + - Test template rendering + +**Deliverable**: Main shop page with all products, filtering, sorting, and search + +--- + +## Phase 4: Product Enhancements + +**Goal**: Create a page showing all products with filtering and search + +### Tasks + +1. **Create ProductIndexPage model** + - File: `app/shop/models.py` + - Extends: `wagtail.models.Page` + - Fields: + - `intro` (RichTextField, optional) + - `products_per_page` (IntegerField, default=12) + - `show_filters` (BooleanField, default=True) + - Methods: + - `get_products()`: Returns all ProductPages + - `get_context()`: Add products, categories, filters to context + - Max count: 1 (only one shop index page) + - Parent page types: HomePage + - Subpage types: ProductPage (can create products under it) + +2. **Add filtering functionality** + - Update `get_context()` to handle GET parameters: + - `category`: Filter by category ID/slug + - `min_price`, `max_price`: Price range + - `in_stock`: Show only available + - `featured`: Show featured products + - `sort`: Order by (price_asc, price_desc, newest, name) + - Add pagination + +3. **Create product index template** + - File: `app/shop/templates/shop/product_index_page.html` + - Extends: `base.html` + - Sections: + - Page intro/description + - Filter sidebar/panel: + - Category checkboxes + - Price range sliders + - In stock toggle + - Featured toggle + - Sort dropdown + - Product grid (same styling as category page) + - Pagination + - Result count ("Showing X of Y products") + - Responsive: Filters collapse to dropdown on mobile + +4. **Integrate with search** + - Update `app/search/views.py`: + - Filter results to show ProductPages + - Add product-specific result template + - Add search box to header navigation + - Consider: Autocomplete/suggestions (future enhancement) + +5. **Run migrations** + ```bash + python manage.py makemigrations shop + python manage.py migrate + ``` + +6. **Create product index page command** + - **Management command**: `setup_product_index.py` (optional) + - Create ProductIndexPage under HomePage programmatically + - Slug: `/shop/` or `/products/` + - Set intro text, products_per_page, show_filters + - **Alternative**: Document manual creation via Wagtail admin + - **Purpose**: Reproducible main shop page for testing filtering/search + +7. **Write tests** + - Test ProductIndexPage creation + - Test filtering by category, price, stock + - Test sorting + - Test pagination + - Test search integration + - Test template rendering + +**Deliverable**: Main shop page with all products, filtering, sorting, and search + +--- + +## Phase 5: Product Enhancements + +**Goal**: Add features to make products more appealing and useful + +### Tasks + +1. **Add product variants** + - Create `ProductVariant` model: + - ParentalKey to ProductPage + - `name` (CharField: "Small", "Red", etc.) + - `variant_type` (CharField: "Size", "Color") + - `price_modifier` (DecimalField, can be positive/negative) + - `sku_suffix` (CharField) + - `in_stock` (BooleanField) + - Add InlinePanel to ProductPage + - Update template to show variant selector + - JavaScript for variant selection (update price display) + +2. **Add product specifications** + - Use StreamField or TableBlock for specifications: + - Option A: RichTextField with table block + - Option B: JSONField with structured data + - Option C: StructBlock in StreamField + - Display as formatted table in template + - Examples: Dimensions, Weight, Materials, Care Instructions + +3. **Add related products** + - Add `related_products` field to ProductPage: + - ParentalManyToManyField to ProductPage + - Display in product detail template + - Show 3-4 related product cards + - Auto-suggest: Same category products (fallback if none manually selected) + +4. **Add product reviews (simple version)** + - Create `ProductReview` snippet: + - ForeignKey to ProductPage + - `author_name` (CharField) + - `rating` (IntegerField, 1-5) + - `comment` (TextField) + - `created_at` (DateTimeField) + - `is_approved` (BooleanField, default=False) + - Display approved reviews on product page + - Show average rating + - Add simple review submission form (no auth required yet) + +5. **Add breadcrumbs** + - Create breadcrumb template tag or include + - Show: Home > Category > Product + - Add to all shop templates + +6. **Optimize images** + - Create image renditions for common sizes: + - Thumbnail: 200x200 + - Product card: 400x400 + - Product detail: 800x800 + - Update templates to use renditions + - Consider: WebP format for better performance + +7. **Create sample variants, reviews, and related products** + - **Management command**: `enhance_sample_products.py` + - Add variants to existing products: + - Sizes for clothing (S, M, L, XL) + - Colors for applicable products + - Price modifiers (+$5 for Large, etc.) + - Generate sample reviews using Faker: + - 3-5 reviews per product + - Ratings 3-5 stars (weighted toward higher) + - Realistic review text + - Mix of approved/unapproved + - Link related products: + - Same category products + - 3-4 related per product + - Options: + - `--variants-only` + - `--reviews-only` + - `--related-only` + - `--reset` + - **Purpose**: Reproducible enhanced product data for full feature testing + +8. **Run migrations** + ```bash + python manage.py makemigrations shop + python manage.py migrate + ``` + +9. **Write tests** + - Test variants creation and display + - Test specifications rendering + - Test related products logic + - Test review submission and approval + - Test breadcrumbs generation + +**Deliverable**: Enhanced product pages with variants, specs, reviews, and related products + +--- + +## Phase 6: Supporting Content Pages + +**Goal**: Create standard e-commerce pages for information and support + +### Tasks + +1. **Create StandardPage model** (if not exists) + - File: `app/shop/models.py` or new `app/content/models.py` + - Extends: `wagtail.models.Page` + - Fields: + - `body` (StreamField with rich text, images, headings) + - Parent: HomePage + - Use for: About, Contact, FAQ, Shipping Info, Returns Policy + +2. **Create specific pages** + - About Us page: Company story, mission + - Contact page: Form with email submission + - FAQ page: Accordion-style questions + - Shipping & Returns: Policy details + - Terms & Conditions: Legal text + - Privacy Policy: GDPR compliance + +3. **Update footer links** + - File: `app/home/templates/home/welcome_page.html` + - Link footer sections to actual pages: + - Customer Support → FAQ, Contact + - Shop → ProductIndexPage, Categories + - Account → Login (placeholder for Phase 7+) + +4. **Create contact form** + - Use `wagtail.contrib.forms`: + - Create ContactFormPage model + - Fields: name, email, subject, message + - Email notification on submission + - Template with styled form + - Success message after submission + +5. **Create sample content pages command** + - **Management command**: `create_content_pages.py` + - Create standard pages: + - About Us (with rich text content) + - FAQ (with questions and answers) + - Shipping & Returns (policy text) + - Terms & Conditions + - Privacy Policy + - Create ContactFormPage under HomePage + - Options: + - `--reset`: Delete existing content pages + - `--pages=about,faq`: Create specific pages only + - **Purpose**: Reproducible informational pages for complete site testing + +6. **Write tests** + - Test StandardPage creation + - Test contact form submission + - Test email sending (use Django test email backend) + +**Deliverable**: Complete informational pages linked from footer, contact form functional + +--- + +## Phase 7+: Future Enhancements (Not in Current Scope) + +These will be planned separately after core shop functionality is complete: + +### User Management & Authentication +- User registration and login +- User profiles +- Order history +- Wishlist +- Saved addresses + +### Shopping Cart +- Add to cart functionality +- Cart page with item list +- Update quantities +- Remove items +- Cart persistence (session/database) + +### Checkout Process +- Shipping address form +- Billing address form +- Shipping method selection +- Payment integration (Stripe, PayPal) +- Order confirmation page +- Order confirmation emails + +### Order Management +- Order model +- Order items model +- Order status tracking +- Admin order management +- Customer order history + +### Advanced Features +- Inventory management +- Product stock alerts +- Discount codes/coupons +- Gift cards +- Product recommendations (ML) +- Analytics integration + +--- + +## Technical Guidelines + +### Wagtail Best Practices + +1. **Use Snippets for reusable content**: Categories, variants, shipping methods +2. **Use Pages for browsable content**: Products, categories, listings +3. **Use StreamFields for flexible content**: Product descriptions, page bodies +4. **Add search_fields to all Page models**: Improve search functionality +5. **Use ParentalKey for inline relationships**: Images, variants +6. **Test admin access**: Ensure all models editable via Wagtail admin + +### Documentation References + +When implementing Wagtail features, refer to the official documentation: + +**Wagtail CMS Documentation**: https://docs.wagtail.org/en/stable/ + +Particularly relevant sections for e-commerce development: +- [Page Models & Fields](https://docs.wagtail.org/en/stable/topics/pages.html) - Creating ProductPage, CategoryPage, etc. +- [Snippets](https://docs.wagtail.org/en/stable/topics/snippets.html) - ProductCategory, ProductVariant snippets +- [Images & Renditions](https://docs.wagtail.org/en/stable/topics/images.html) - Product image handling +- [Search](https://docs.wagtail.org/en/stable/topics/search/index.html) - Product search functionality +- [Forms](https://docs.wagtail.org/en/stable/reference/contrib/forms.html) - Contact form implementation +- [Testing](https://docs.wagtail.org/en/stable/advanced_topics/testing.html) - Writing tests for page types + +**Django Documentation**: https://docs.djangoproject.com/en/5.2/ + +### Database Considerations + +- Start with PostgreSQL or MySQL (not SQLite) for production readiness +- Add indexes to frequently queried fields: category, price, sku +- Use select_related/prefetch_related for efficient queries +- Consider: Database views for complex product filtering + +### Frontend Guidelines + +- Reuse home page product card styling for consistency +- Keep extra_css in templates for page-specific styles +- Use Pico CSS variables: `--pico-primary`, `--pico-muted-color` +- Ensure responsive design at 768px, 1024px, 1280px breakpoints +- Optimize images: Use Wagtail renditions, serve WebP where supported + +### Testing Strategy + +- Test model creation and relationships +- Test page rendering (200 status codes) +- Test Wagtail admin CRUD operations +- Test filtering and search functionality +- Test template context variables +- Run tests after each phase: `make test` + +### Migration Management + +- Create migrations after each model change +- Test migrations on fresh database +- Document any data migrations needed +- Keep migrations small and focused + +### Management Commands & Fixtures + +**Management Commands** (for complex, randomized data): +- Follow pattern in `create_sample_media.py` +- **Must create for each phase** to generate sample content +- Add `--help` text for all options +- Support options: + - `--count=N`: Number of items to create + - `--reset`: Clear existing data first + - `--clear`: Delete all data without creating new + - `--phase=N`: For cumulative setup commands +- Support idempotent operations (safe to run multiple times) +- Log progress and success messages with clear output +- Use Faker library for realistic data (names, descriptions, prices) + +**Fixtures** (for static reference data): +- Create JSON fixtures in `app/shop/fixtures/` +- Use `python manage.py dumpdata` to create from existing data +- Load with `python manage.py loaddata ` +- Good for: Categories, sample users, consistent test data +- Version control fixtures for reproducibility + +**Example Command Structure**: +```python +# app/shop/management/commands/create_sample_products.py +from django.core.management.base import BaseCommand +from faker import Faker +from app.shop.models import ProductPage, ProductCategory +from app.home.models import HomePage + +class Command(BaseCommand): + help = "Create sample products for testing" + + def add_arguments(self, parser): + parser.add_argument('--count', type=int, default=20) + parser.add_argument('--reset', action='store_true') + parser.add_argument('--clear', action='store_true') + + def handle(self, *args, **options): + # Implementation + pass +``` + +**Why This Matters**: +- Enables quick UI testing after each development phase +- Allows easy site resets during development +- Provides realistic demo content for stakeholders +- Speeds up onboarding for new developers +- Makes CI/CD testing more reliable + +--- + +## Success Criteria by Phase + +### Phase 1 ✓ +- CategoryPage model created and working +- Category pages browsable in Wagtail page tree +- Category template displays products and subcategories +- Sample categories created via management command +- Tests passing + +### Phase 2 ✓ +- Individual products browsable via Wagtail tree +- Product detail pages render with images +- Sample products created +- Management command creates realistic product catalog +- Tests passing + +### Phase 3 ✓ +- Category pages show filtered products +- Category links work from home page +- Pagination functional +- Management command creates all category pages +- Tests passing + +### Phase 4 ✓ +- Product index page shows all products +- Filtering by category, price, stock works +- Search returns product results +- Sort options functional +- Product index page setup is reproducible +- Tests passing + +### Phase 5 ✓ +- Product variants selectable +- Specifications display in tables +- Related products show +- Reviews submission works +- Breadcrumbs appear +- Management command enhances products with variants/reviews +- Tests passing + +### Phase 6 ✓ +- All info pages created and linked +- Contact form submits successfully +- Footer links functional +- Management command creates all content pages +- Tests passing + +--- + +## Implementation Order Summary + +``` +1. CategoryPage (page) ← Foundation - categories as pages in tree +2. ProductPage (page) ← Core content - products as children of categories +3. ProductIndexPage (page) ← Browse all products with filtering +4. ProductVariant, ProductImage (inlines) ← Product enhancements +5. ProductReview (snippet) ← Social proof +6. StandardPage, ContactFormPage (pages) ← Supporting content +``` + +**Page Tree Structure:** +``` +HomePage +├── CategoryPage (Electronics) +│ ├── ProductPage (Wireless Headphones) +│ ├── ProductPage (Bluetooth Speaker) +│ └── ... +├── CategoryPage (Fashion) +│ ├── ProductPage (Cotton T-Shirt) +│ └── ... +├── ProductIndexPage (Shop) +└── StandardPage (About, Contact, etc.) +``` + +**Later**: Users → Cart → Checkout → Orders + +This order ensures each phase builds on the previous, with clear dependencies and testable milestones. The page-based architecture provides natural URL hierarchy and leverages Wagtail's built-in page management. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..846dbfd --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,67 @@ +# Installation Guide + +This guide will help you set up the Wagtail Shop Kit development environment on your local machine. + +## Requirements + +### Required + +- [Python >= 3.10](https://www.python.org/downloads/) (development and deployment) +- [Docker](https://www.docker.com/) (for local development) +- [Docker Compose](https://docs.docker.com/compose/) (for local development) +- [Node.js](https://nodejs.org/en/) (for frontend build tools in development) + +### Optional + +- [Git](https://git-scm.com/) (optional, for version control) +- [Make](https://www.gnu.org/software/make/) (optional, for running commands) +- [NVM](https://github.com/nvm-sh/nvm) (optional, for managing Node versions) +- [pre-commit](https://pre-commit.com/) (optional, for running code checks) +- [UV](https://github.com/astral-sh/uv) (optional, for managing Python dependencies) + +## Getting Started + +Follow these steps to set up the project: + +1. Clone this repository to a location on your computer +2. Change into the project directory +3. Copy `.env.example` to `.env` and choose your database (default is SQLite). Set `DATABASE=sqlite|postgres|mysql`. +4. Run `make build` to build the Docker containers +5. Run `make up` to start the Docker containers +6. Run `make migrate` to apply database migrations +7. Run `make superuser` to create a superuser +8. Run `make run` to start the Django development server + +**Note:** `.env` is required. `make build` and `make up` validate environment variables via `make check-env` and will fail if required values are missing. See `.env.example` for all keys. + +## Quick Start + +There is a make command to run most of the steps above in one go: + +```bash +make quickstart +``` + +You'll need to run `make superuser` separately to create your admin user. + +## Default Configuration + +If you haven't changed `.env` the app will default to: + +- Use SQLite as the database +- A mail utility will be available at [http://localhost:8025](http://localhost:8025) +- A database management utility will be available at [http://localhost:8080](http://localhost:8080) + +## Accessing the Site + +Once the development server is running: + +- **Main site**: [http://localhost:8000](http://localhost:8000) +- **Wagtail admin**: [http://localhost:8000/admin](http://localhost:8000/admin) +- **Style guide**: [http://localhost:8000/style-guide](http://localhost:8000/style-guide) (only available in debug mode) + +## Next Steps + +- See [Backend Development](./backend-development.md) for information about database configuration and backend development +- See [Frontend Development](./frontend-development.md) for information about building CSS and JavaScript assets +- See [Management Commands](./management-commands.md) for helpful commands to generate sample content and manage data diff --git a/docs/management-commands.md b/docs/management-commands.md index 507394b..5f8da98 100644 --- a/docs/management-commands.md +++ b/docs/management-commands.md @@ -5,6 +5,7 @@ This document provides detailed information about the custom Django management c ## Table of Contents - [create_sample_media](#create_sample_media) +- [create_sample_categories](#create_sample_categories) - [Future Commands](#future-commands) --- @@ -127,6 +128,170 @@ docker exec -it wagtail-shop-kit-app-1 python manage.py create_sample_media --re --- +## create_sample_categories + +**Location**: `app/shop/management/commands/create_sample_categories.py` + +**Purpose**: Creates a shop index page and sample category pages for the e-commerce section of your Wagtail site. + +### Description + +The `create_sample_categories` command sets up the foundational structure for your online shop by: + +1. **Creating or finding a ShopIndexPage**: Automatically creates a main "Shop" page under the home page if it doesn't already exist +2. **Creating Category Pages**: Generates 6 sample category pages (Electronics, Fashion, Home & Living, Beauty, Sports, Books) as children of the shop index page +3. **Assigning Icons**: Optionally assigns random images from your media library as category icons +4. **Publishing Content**: All pages are automatically published and ready to view + +This command is perfect for: +- Setting up a new e-commerce site structure +- Demonstrating shop browsing functionality +- Creating realistic category data for development +- Testing the shop page hierarchy + +### Usage + +```bash +# Basic usage (creates shop index and 6 categories with icons) +python manage.py create_sample_categories + +# Reset and recreate all categories +python manage.py create_sample_categories --reset + +# Create categories without icons +python manage.py create_sample_categories --no-icons + +# Custom shop page configuration +python manage.py create_sample_categories --shop-title "Store" --shop-slug "store" + +# Use a different home page +python manage.py create_sample_categories --home-slug "homepage" +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `--reset` | Flag | False | Delete all existing category pages before creating new ones | +| `--no-icons` | Flag | False | Skip assigning icons to categories | +| `--home-slug` | String | "home" | Slug of the home page where shop index will be created | +| `--shop-title` | String | "Shop" | Title for the shop index page | +| `--shop-slug` | String | "shop" | Slug for the shop index page | + +### Generated Content Details + +#### ShopIndexPage +- **Location**: Created as a child of the home page +- **Default URL**: `/shop/` +- **Properties**: + - Title: "Shop" (customizable) + - Intro text: "Browse our collection of quality products across various categories." + - Visible in menus: Yes + - Max count: 1 (only one shop index page allowed) + +#### Category Pages +The command creates 6 category pages with the following structure: + +| Category | Slug | Featured | Description Theme | +|----------|------|----------|-------------------| +| Electronics | electronics | Yes | Gadgets, tech products, smartphones, laptops | +| Fashion | fashion | Yes | Clothing, accessories, styles, trends | +| Home & Living | home-living | Yes | Furniture, decor, interior design | +| Beauty | beauty | No | Skincare, cosmetics, beauty products | +| Sports | sports | No | Fitness equipment, outdoor gear | +| Books | books | No | Novels, educational resources, guides | + +Each category includes: +- **Rich text description**: HTML formatted text with multiple paragraphs +- **Icon**: Random image from your media library (if available and not using `--no-icons`) +- **Featured flag**: First 3 categories are marked as featured +- **Menu visibility**: All categories are visible in navigation menus + +### Page Hierarchy + +The command creates the following page structure: + +``` +Home +└── Shop (ShopIndexPage) + ├── Beauty (ShopCategoryPage) + ├── Books (ShopCategoryPage) + ├── Electronics (ShopCategoryPage) ⭐ Featured + ├── Fashion (ShopCategoryPage) ⭐ Featured + ├── Home & Living (ShopCategoryPage) ⭐ Featured + └── Sports (ShopCategoryPage) +``` + +### Examples + +#### Creating categories in Docker +```bash +docker exec -it wagtail-shop-kit-app-1 python manage.py create_sample_categories +``` + +#### Creating categories with make command +```bash +make sh +python manage.py create_sample_categories +``` + +#### Resetting and recreating with different configuration +```bash +docker exec -it wagtail-shop-kit-app-1 python manage.py create_sample_categories \ + --reset \ + --shop-title "Store" \ + --shop-slug "store" +``` + +#### Creating without icons (faster, no media dependencies) +```bash +docker exec -it wagtail-shop-kit-app-1 python manage.py create_sample_categories --no-icons +``` + +### Prerequisites + +- A home page must exist in your Wagtail site (default slug: "home") +- For icons: Run `python manage.py create_sample_media` first to generate sample images + +### Output Example + +``` +Found existing ShopIndexPage: 'Shop' at /shop/ +Found 75 images available for category icons + Assigning icon: Sharp City Skyline #469 (ID: 29) + ✓ Created: Electronics 🖼️ (icon: Sharp City Skyline #469...) ⭐ [FEATURED] + Assigning icon: Handcrafted Garden Abstract Art #415 (ID: 44) + ✓ Created: Fashion 🖼️ (icon: Handcrafted Garden Abstract Ar...) ⭐ [FEATURED] + ... + +============================================================ +Successfully created 6 category pages under 'Shop' + +Category URLs: + → /shop/beauty/ - Beauty + → /shop/books/ - Books + → /shop/electronics/ - Electronics + → /shop/fashion/ - Fashion + → /shop/home-living/ - Home & Living + → /shop/sports/ - Sports +============================================================ +``` + +### Technical Implementation + +#### Dependencies +- **Wagtail Page Models**: Uses `ShopIndexPage` and `ShopCategoryPage` from `app.shop.models` +- **Wagtail Images**: Optionally uses `wagtail.images.models.Image` for category icons +- **Treebeard**: Leverages Wagtail's page tree structure for parent-child relationships + +#### Key Features +- **Idempotent**: Safe to run multiple times; checks for existing pages before creating +- **Tree Cache Management**: Properly refreshes page tree after deletions to prevent cache issues +- **Random Icon Assignment**: Shuffles available images to assign unique icons to each category +- **Revision Management**: Creates and publishes page revisions automatically + +--- + ## Future Commands This section will be expanded as additional management commands are added to the project. diff --git a/pyproject.toml b/pyproject.toml index a26a123..485d606 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ requires-python = ">=3.10" dependencies = [ "django~=5.2", "django-browser-reload~=1.21", + "faker~=33.1", "wagtail~=7.2", ] diff --git a/static_src/scss/app.scss b/static_src/scss/app.scss index 63ebb17..86da46b 100644 --- a/static_src/scss/app.scss +++ b/static_src/scss/app.scss @@ -10,4 +10,5 @@ // Your custom styles goes here -@use 'components/example'; +@use 'components/home'; +@use 'components/shop'; diff --git a/static_src/scss/components/_home.scss b/static_src/scss/components/_home.scss new file mode 100644 index 0000000..3133a25 --- /dev/null +++ b/static_src/scss/components/_home.scss @@ -0,0 +1,295 @@ +// Home Page Styles + +/* Base Styling */ +.template-homepage { + background-color: var(--pico-color-sand-50); + min-height: 100vh; +} + +/* Header Navigation */ +header .welcome-grid { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 2rem; +} + +.shop-branding h1 { + margin: 0; + font-size: 1.75rem; + color: var(--pico-primary); +} + +.shop-branding h1 a { + color: var(--pico-primary); + text-decoration: none; + transition: opacity 0.2s ease; +} + +.shop-branding h1 a:hover { + opacity: 0.8; +} + +header nav ul { + display: flex; + gap: 1.5rem; + list-style: none; + margin: 0; + padding: 0; + align-items: center; +} + +header nav li { + margin: 0; +} + +header nav a:not([role="button"]) { + text-decoration: none; + color: var(--pico-color); + font-weight: 500; +} + +header nav a:not([role="button"]):hover { + color: var(--pico-primary); +} + +/* Hero Banner */ +.hero-banner { + text-align: center; + padding: 3rem 0; + margin: 2rem 0; +} + +.hero-banner h1 { + font-size: 2.5rem; + margin-bottom: 1rem; + color: var(--pico-primary); +} + +.hero-banner hgroup p { + font-size: 1.25rem; + color: var(--pico-muted-color); + margin-bottom: 2rem; +} + +.hero-visual svg { + width: 150px; + height: 150px; + margin: 2rem auto; +} + +.hero-cta { + font-size: 1.1rem; + padding: 0.75rem 2rem; +} + +/* Featured Products Section */ +.featured-products, +.product-categories { + margin: 4rem 0; +} + +.featured-products h2, +.product-categories h2 { + text-align: center; + font-size: 2rem; + margin-bottom: 2rem; + color: var(--pico-color); +} + +.product-grid { + display: grid; + grid-template-columns: 1fr; + gap: 2rem; + margin-top: 2rem; +} + +.product-card { + background: var(--pico-background-color); + border-radius: var(--pico-border-radius); + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.product-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); +} + +.product-image { + width: 100%; + background: var(--pico-color-sand-50); + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.product-image svg { + width: 100%; + height: 100%; +} + +.product-details { + padding: 1.5rem; +} + +.product-details h3 { + margin: 0 0 0.5rem 0; + font-size: 1.25rem; +} + +.product-description { + color: var(--pico-muted-color); + font-size: 0.9rem; + margin: 0.5rem 0; +} + +.product-price { + font-size: 1.5rem; + font-weight: bold; + color: var(--pico-primary); + margin: 1rem 0; +} + +.product-details a[role="button"] { + width: 100%; + margin: 0; +} + +/* Product Categories Section */ +.category-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + margin-top: 2rem; +} + +.category-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 2rem; + background: var(--pico-background-color); + border-radius: var(--pico-border-radius); + text-decoration: none; + color: var(--pico-color); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.category-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + color: var(--pico-primary); +} + +.category-card svg, +.category-card img { + color: var(--pico-primary); + margin-bottom: 1rem; +} + +.category-card img { + width: 48px; + height: 48px; + object-fit: contain; + border-radius: var(--pico-border-radius); +} + +.category-card h3 { + margin: 0.5rem 0; + font-size: 1.25rem; +} + +.category-card p { + margin: 0.25rem 0 0 0; + color: var(--pico-muted-color); + font-size: 0.9rem; +} + +/* Footer */ +footer.welcome-grid article { + text-align: center; +} + +footer.welcome-grid article h2 { + font-size: 1rem; + text-decoration: underline; +} + +footer.welcome-grid article a:hover h2, +footer.welcome-grid article a:hover p { + color: var(--pico-primary); +} + +footer a[role="card"] svg { + width: 4rem; + fill: var(--pico-primary); +} + +/* Responsive Design */ +@media screen and (min-width: 768px) { + .shop-branding h1 { + font-size: 2rem; + } + + .hero-banner { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + align-items: center; + text-align: left; + } + + .hero-visual svg { + width: 200px; + height: 200px; + } + + .product-grid { + grid-template-columns: repeat(2, 1fr); + } + + .category-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media screen and (min-width: 1024px) { + .product-grid { + grid-template-columns: repeat(4, 1fr); + } + + .category-grid { + grid-template-columns: repeat(3, 1fr); + } + + footer.welcome-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + } + + footer.welcome-grid svg { + width: 2rem; + } +} + +@media screen and (min-width: 1280px) { + footer.welcome-grid article a { + display: grid; + grid-template-columns: 1fr 3fr; + align-items: center; + } + + footer a[role="card"] svg { + width: 3rem; + } + + footer hgroup { + text-align: left; + margin: 0; + } +} diff --git a/static_src/scss/components/_shop.scss b/static_src/scss/components/_shop.scss new file mode 100644 index 0000000..3e6c068 --- /dev/null +++ b/static_src/scss/components/_shop.scss @@ -0,0 +1,596 @@ +// Shop Category Page Styles + +/* Base Styling */ +.template-shopcategorypage { + background-color: var(--pico-color-sand-50); + min-height: 100vh; +} + +/* Category Header */ +.category-header { + text-align: center; + padding: 3rem 0 2rem; + margin-bottom: 2rem; +} + +.category-header .category-icon { + margin: 0 auto 1.5rem; + width: 100px; + height: 100px; + display: flex; + align-items: center; + justify-content: center; +} + +.category-header .category-icon img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.category-header h1 { + font-size: 2.5rem; + margin-bottom: 1rem; + color: var(--pico-primary); +} + +.category-header .category-description { + font-size: 1.1rem; + color: var(--pico-muted-color); + max-width: 800px; + margin: 0 auto; +} + +.category-header .category-description p:last-child { + margin-bottom: 0; +} + +/* Breadcrumb Navigation */ +nav[aria-label="breadcrumb"] { + margin-bottom: 2rem; +} + +nav[aria-label="breadcrumb"] ul { + justify-content: flex-start; +} + +/* Subcategories Section */ +.subcategories-section { + margin: 3rem 0; +} + +.subcategories-section h2 { + font-size: 1.5rem; + margin-bottom: 1.5rem; + color: var(--pico-color); +} + +.subcategory-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; +} + +.subcategory-card { + display: flex; + align-items: center; + padding: 1.5rem; + background: var(--pico-background-color); + border-radius: var(--pico-border-radius); + text-decoration: none; + color: var(--pico-color); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.subcategory-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + color: var(--pico-primary); +} + +.subcategory-card .subcategory-icon { + width: 60px; + height: 60px; + margin-right: 1.5rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.subcategory-card .subcategory-icon img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.subcategory-card .subcategory-info h3 { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; +} + +.subcategory-card .subcategory-info p { + margin: 0; + color: var(--pico-muted-color); + font-size: 0.9rem; +} + +/* Products Section */ +.products-section { + margin: 3rem 0; +} + +.products-section h2 { + font-size: 1.5rem; + margin-bottom: 1.5rem; + color: var(--pico-color); +} + +.template-shopcategorypage .product-grid { + display: grid; + grid-template-columns: 1fr; + gap: 2rem; +} + +.template-shopcategorypage .product-card { + background: var(--pico-background-color); + border-radius: var(--pico-border-radius); + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + text-decoration: none; + color: var(--pico-color); + display: block; +} + +.template-shopcategorypage .product-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); +} + +.template-shopcategorypage .product-image { + width: 100%; + background: var(--pico-color-sand-50); + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.template-shopcategorypage .product-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.template-shopcategorypage .product-details { + padding: 1.5rem; +} + +.template-shopcategorypage .product-details h3 { + margin: 0 0 0.5rem 0; + font-size: 1.25rem; + color: var(--pico-color); +} + +.template-shopcategorypage .product-description { + color: var(--pico-muted-color); + font-size: 0.9rem; + margin: 0.5rem 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.template-shopcategorypage .product-price { + font-size: 1.5rem; + font-weight: bold; + color: var(--pico-primary); + margin: 1rem 0 0 0; +} + +.product-stock-status { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: var(--pico-border-radius); + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.product-stock-status.in-stock { + background-color: var(--pico-ins-color); + color: var(--pico-background-color); +} + +.product-stock-status.out-of-stock { + background-color: var(--pico-del-color); + color: var(--pico-background-color); +} + +/* Pagination */ +.pagination { + margin: 3rem 0; + text-align: center; +} + +.pagination nav { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.pagination nav a, +.pagination nav span { + padding: 0.5rem 1rem; + text-decoration: none; + color: var(--pico-primary); + border: 1px solid var(--pico-primary); + border-radius: var(--pico-border-radius); + transition: background-color 0.2s ease; +} + +.pagination nav a:hover { + background-color: var(--pico-primary); + color: var(--pico-background-color); +} + +.pagination nav .current { + background-color: var(--pico-primary); + color: var(--pico-background-color); +} + +.pagination nav .disabled { + opacity: 0.5; + pointer-events: none; +} + +/* Responsive Design */ +@media screen and (min-width: 768px) { + .category-header h1 { + font-size: 3rem; + } + + .subcategory-grid { + grid-template-columns: repeat(2, 1fr); + } + + .template-shopcategorypage .product-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media screen and (min-width: 1024px) { + .subcategory-grid { + grid-template-columns: repeat(3, 1fr); + } + + .template-shopcategorypage .product-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media screen and (min-width: 1280px) { + .template-shopcategorypage .product-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +// Shop Index Page Styles +.template-shopindexpage { + background-color: var(--pico-color-sand-50); + min-height: 100vh; +} + +/* Shop Header */ +.shop-header { + text-align: center; + margin-bottom: 3rem; + padding: 2rem 0; +} + +.shop-header h1 { + margin-bottom: 1rem; +} + +.shop-intro { + max-width: 600px; + margin: 0 auto; + color: var(--pico-muted-color); +} + +/* Category Grid */ +.category-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; + margin-bottom: 3rem; +} + +.category-card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 2rem 1.5rem; + background: var(--pico-card-background-color); + border: 1px solid var(--pico-card-border-color); + border-radius: var(--pico-border-radius); + text-decoration: none; + color: var(--pico-color); + transition: all 0.2s ease; +} + +.category-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + border-color: var(--pico-primary); +} + +.category-card-icon { + margin-bottom: 1rem; + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; +} + +.category-card-icon img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.category-card-icon svg { + color: var(--pico-muted-color); +} + +.category-card-content h3 { + margin-bottom: 0.5rem; + font-size: 1.25rem; + color: var(--pico-color); +} + +.category-card-content p { + margin: 0; + font-size: 0.9rem; + color: var(--pico-muted-color); + line-height: 1.5; +} + +.category-badge { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: var(--pico-primary); + color: white; + padding: 0.25rem 0.75rem; + border-radius: var(--pico-border-radius); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +/* Empty State - Shared styles */ +.empty-state { + text-align: center; + padding: 4rem 2rem; + color: var(--pico-muted-color); +} + +.empty-state h3 { + color: var(--pico-color); + margin-bottom: 0.5rem; +} + +.empty-state code { + background: var(--pico-code-background-color); + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-size: 0.85em; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .category-grid { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1rem; + } + + .shop-header { + padding: 1rem 0; + margin-bottom: 2rem; + } +} + +// Product Page Styles +.template-productpage { + background-color: var(--pico-color-sand-50); + min-height: 100vh; +} + +/* Breadcrumb styling for product page */ +.template-productpage nav[aria-label="breadcrumb"] ul { + list-style: none; + padding: 0; + margin: 1rem 0 2rem 0; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + font-size: 0.9rem; +} + +.template-productpage nav[aria-label="breadcrumb"] li:not(:last-child)::after { + content: "›"; + margin-left: 0.5rem; + color: var(--pico-muted-color); +} + +.template-productpage nav[aria-label="breadcrumb"] a { + color: var(--pico-primary); + text-decoration: none; +} + +.template-productpage nav[aria-label="breadcrumb"] a:hover { + text-decoration: underline; +} + +/* Product Detail Layout */ +.product-detail { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3rem; + margin: 2rem 0; +} + +/* Product Images */ +.product-images { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.main-image { + width: 100%; + border-radius: var(--pico-border-radius); + overflow: hidden; + background: var(--pico-background-color); + border: 1px solid var(--pico-muted-border-color); +} + +.main-image img { + width: 100%; + height: auto; + display: block; +} + +.image-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 0.5rem; +} + +.gallery-thumbnail { + border-radius: var(--pico-border-radius); + overflow: hidden; + border: 2px solid var(--pico-muted-border-color); + cursor: pointer; + transition: border-color 0.2s; +} + +.gallery-thumbnail:hover { + border-color: var(--pico-primary); +} + +.gallery-thumbnail img { + width: 100%; + height: auto; + display: block; +} + +/* Product Information */ +.product-info h1 { + margin-top: 0; + font-size: 2rem; + color: var(--pico-color); +} + +.product-meta { + display: flex; + align-items: center; + gap: 1.5rem; + margin: 1.5rem 0; +} + +.product-price { + font-size: 2rem; + font-weight: bold; + color: var(--pico-primary); + margin: 0; +} + +.product-stock-status { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 1rem; + border-radius: var(--pico-border-radius); + font-size: 0.9rem; + font-weight: 600; +} + +.product-stock-status.in-stock { + background-color: rgba(46, 125, 50, 0.1); + color: #2e7d32; +} + +.product-stock-status.out-of-stock { + background-color: rgba(211, 47, 47, 0.1); + color: #d32f2f; +} + +.product-sku { + color: var(--pico-muted-color); + font-size: 0.9rem; + margin: 0.5rem 0; +} + +.product-sku span { + font-family: monospace; + color: var(--pico-color); +} + +.product-description { + margin: 2rem 0; + line-height: 1.8; + color: var(--pico-color); +} + +/* Add to Cart Button */ +.product-actions { + margin-top: 2rem; +} + +.add-to-cart-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 1rem 2rem; + font-size: 1.1rem; + font-weight: 600; + cursor: not-allowed; + opacity: 0.6; +} + +.cart-notice { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--pico-muted-color); + font-style: italic; +} + +/* Responsive Design for Product Page */ +@media (max-width: 768px) { + .product-detail { + grid-template-columns: 1fr; + gap: 2rem; + } + + .product-info h1 { + font-size: 1.5rem; + } + + .product-price { + font-size: 1.5rem; + } + + .product-meta { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } +} diff --git a/static_src/scss/components/example.scss b/static_src/scss/components/example.scss deleted file mode 100644 index eb0c546..0000000 --- a/static_src/scss/components/example.scss +++ /dev/null @@ -1 +0,0 @@ -// example imported/used in app.scss diff --git a/uv.lock b/uv.lock index 5317263..31bedaa 100644 --- a/uv.lock +++ b/uv.lock @@ -416,6 +416,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] +[[package]] +name = "faker" +version = "33.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/50/48ab6ba3f07ee7d0eac367695aeb8bc9eb9c3debc0445a67cd07e2d62b44/faker-33.3.1.tar.gz", hash = "sha256:49dde3b06a5602177bc2ad013149b6f60a290b7154539180d37b6f876ae79b20", size = 1854895, upload-time = "2025-01-10T16:49:37.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/01/6acc8b4dba4154cd93b444382a9ad3c099557aac577bdc7d66373e0a0c68/Faker-33.3.1-py3-none-any.whl", hash = "sha256:ac4cf2f967ce02c898efa50651c43180bd658a7707cfd676fcc5410ad1482c03", size = 1894842, upload-time = "2025-01-10T16:49:34.261Z" }, +] + [[package]] name = "filelock" version = "3.20.1" @@ -677,6 +690,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "pyupgrade" version = "3.21.2" @@ -794,6 +819,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "soupsieve" version = "2.8.1" @@ -908,6 +942,7 @@ source = { virtual = "." } dependencies = [ { name = "django" }, { name = "django-browser-reload" }, + { name = "faker" }, { name = "wagtail" }, ] @@ -924,6 +959,7 @@ dev = [ requires-dist = [ { name = "django", specifier = "~=5.2" }, { name = "django-browser-reload", specifier = "~=1.21" }, + { name = "faker", specifier = "~=33.1" }, { name = "wagtail", specifier = "~=7.2" }, ]