From 67fb60e6ca2a8fb055e193993849b5bf850175aa Mon Sep 17 00:00:00 2001 From: tonym Date: Sun, 9 Nov 2025 00:26:00 -0600 Subject: [PATCH] chore: initial import --- .gitignore | 1 + AGENTS.md | 20 + DREAM_FEATURES.md | 17 + README.md | 26 + ROADMAP.md | 29 + TODO.md | 21 + WISHLIST.md | 12 + api/.dockerignore | 7 + api/Dockerfile | 21 + api/README.md | 30 + api/apps/__init__.py | 1 + api/apps/commerce/__init__.py | 2 + api/apps/commerce/admin.py | 87 +++ api/apps/commerce/apps.py | 7 + api/apps/commerce/migrations/0001_initial.py | 267 +++++++ api/apps/commerce/migrations/__init__.py | 1 + api/apps/commerce/models.py | 198 ++++++ api/apps/commerce/serializers.py | 253 +++++++ api/apps/commerce/urls.py | 34 + api/apps/commerce/views.py | 82 +++ api/celerybeat-schedule | Bin 0 -> 16384 bytes api/config/__init__.py | 3 + api/config/asgi.py | 7 + api/config/celery_app.py | 9 + api/config/settings.py | 123 ++++ api/config/urls.py | 14 + api/config/wsgi.py | 7 + api/manage.py | 21 + api/requirements.txt | 8 + docker-compose.yml | 113 +++ frontend/.dockerignore | 3 + frontend/.gitignore | 5 + frontend/Dockerfile | 14 + frontend/README.md | 20 + frontend/components/AdminLayout.js | 39 ++ frontend/components/FaqSection.js | 16 + frontend/components/ProductCard.js | 52 ++ frontend/components/SiteLayout.js | 66 ++ frontend/context/CartContext.js | 139 ++++ frontend/hooks/useAdminApi.js | 41 ++ frontend/lib/api.js | 48 ++ frontend/next.config.js | 9 + frontend/package-lock.json | 17 + frontend/package.json | 15 + frontend/pages/_app.js | 11 + frontend/pages/admin/index.js | 56 ++ frontend/pages/admin/menu/index.js | 457 ++++++++++++ frontend/pages/cart.js | 336 +++++++++ frontend/pages/index.js | 265 +++++++ frontend/pages/products/[slug].js | 95 +++ frontend/pages/products/index.js | 94 +++ frontend/public/logo.png | Bin 0 -> 44400 bytes frontend/styles/globals.css | 654 ++++++++++++++++++ infra/docker/.env.development | 11 + infra/docker/.env.example | 11 + infra/docker/README.md | 30 + resources/Full Moon Bakehouse Logo/1.png | Bin 0 -> 44400 bytes resources/Full Moon Bakehouse Logo/10.png | Bin 0 -> 51038 bytes resources/Full Moon Bakehouse Logo/2.png | Bin 0 -> 62673 bytes resources/Full Moon Bakehouse Logo/3.png | Bin 0 -> 84884 bytes resources/Full Moon Bakehouse Logo/5.png | Bin 0 -> 61361 bytes resources/Full Moon Bakehouse Logo/6.png | Bin 0 -> 61644 bytes resources/Full Moon Bakehouse Logo/7.png | Bin 0 -> 34213 bytes resources/Full Moon Bakehouse Logo/8.png | Bin 0 -> 39285 bytes resources/Full Moon Bakehouse Logo/9.png | Bin 0 -> 50049 bytes ...ehydrating Milling Whole Grain Log.numbers | Bin 0 -> 176785 bytes .../Cookie Dough Base Master Recipe.pages | Bin 0 -> 350173 bytes .../Recipes/Croissants Master Recipe.pages | Bin 0 -> 277665 bytes resources/Recipes/English Muffins.pages | Bin 0 -> 245296 bytes resources/Recipes/Frangipane .pages | Bin 0 -> 214712 bytes resources/Recipes/Marshmallow fluff.pages | Bin 0 -> 276173 bytes resources/Recipes/Master Bagel Recipe.pages | Bin 0 -> 232591 bytes resources/Recipes/Soft Ball Caramel.pages | Bin 0 -> 269812 bytes .../Recipes/Sweet Rolls Master Recipe.pages | Bin 0 -> 403807 bytes ...-fullmoonbakehouse-2025-11-04-23_08_55.png | Bin 0 -> 8378812 bytes ...bakehouse-About-us-2025-11-04-23_09_18.png | Bin 0 -> 3500765 bytes 76 files changed, 3925 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 DREAM_FEATURES.md create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 TODO.md create mode 100644 WISHLIST.md create mode 100644 api/.dockerignore create mode 100644 api/Dockerfile create mode 100644 api/README.md create mode 100644 api/apps/__init__.py create mode 100644 api/apps/commerce/__init__.py create mode 100644 api/apps/commerce/admin.py create mode 100644 api/apps/commerce/apps.py create mode 100644 api/apps/commerce/migrations/0001_initial.py create mode 100644 api/apps/commerce/migrations/__init__.py create mode 100644 api/apps/commerce/models.py create mode 100644 api/apps/commerce/serializers.py create mode 100644 api/apps/commerce/urls.py create mode 100644 api/apps/commerce/views.py create mode 100644 api/celerybeat-schedule create mode 100644 api/config/__init__.py create mode 100644 api/config/asgi.py create mode 100644 api/config/celery_app.py create mode 100644 api/config/settings.py create mode 100644 api/config/urls.py create mode 100644 api/config/wsgi.py create mode 100644 api/manage.py create mode 100644 api/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/components/AdminLayout.js create mode 100644 frontend/components/FaqSection.js create mode 100644 frontend/components/ProductCard.js create mode 100644 frontend/components/SiteLayout.js create mode 100644 frontend/context/CartContext.js create mode 100644 frontend/hooks/useAdminApi.js create mode 100644 frontend/lib/api.js create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/pages/_app.js create mode 100644 frontend/pages/admin/index.js create mode 100644 frontend/pages/admin/menu/index.js create mode 100644 frontend/pages/cart.js create mode 100644 frontend/pages/index.js create mode 100644 frontend/pages/products/[slug].js create mode 100644 frontend/pages/products/index.js create mode 100755 frontend/public/logo.png create mode 100644 frontend/styles/globals.css create mode 100644 infra/docker/.env.development create mode 100644 infra/docker/.env.example create mode 100644 infra/docker/README.md create mode 100755 resources/Full Moon Bakehouse Logo/1.png create mode 100755 resources/Full Moon Bakehouse Logo/10.png create mode 100755 resources/Full Moon Bakehouse Logo/2.png create mode 100755 resources/Full Moon Bakehouse Logo/3.png create mode 100755 resources/Full Moon Bakehouse Logo/5.png create mode 100755 resources/Full Moon Bakehouse Logo/6.png create mode 100755 resources/Full Moon Bakehouse Logo/7.png create mode 100755 resources/Full Moon Bakehouse Logo/8.png create mode 100755 resources/Full Moon Bakehouse Logo/9.png create mode 100755 resources/Logs/Sprouting Dehydrating Milling Whole Grain Log.numbers create mode 100755 resources/Recipes/Cookie Dough Base Master Recipe.pages create mode 100755 resources/Recipes/Croissants Master Recipe.pages create mode 100755 resources/Recipes/English Muffins.pages create mode 100755 resources/Recipes/Frangipane .pages create mode 100755 resources/Recipes/Marshmallow fluff.pages create mode 100755 resources/Recipes/Master Bagel Recipe.pages create mode 100755 resources/Recipes/Soft Ball Caramel.pages create mode 100755 resources/Recipes/Sweet Rolls Master Recipe.pages create mode 100644 resources/screencapture-fullmoonbakehouse-2025-11-04-23_08_55.png create mode 100644 resources/screencapture-fullmoonbakehouse-About-us-2025-11-04-23_09_18.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1b36d98 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# Repository Guidelines + +## Project Structure & Module Organization +FullMooncGPT hosts the orchestration code for conversational agents. Place runtime modules in `src/fullmooncgpt/`, grouped by feature folders such as `agents/`, `pipelines/`, and `integrations/`. Shared helpers belong in `src/fullmooncgpt/common/` so they can be reused without circular dependencies. Prompt templates, YAML configs, and sample payloads live in `assets/`, with a short `README.md` per subsection describing expected inputs and outputs. Tests sit in `tests/`, mirroring the module layout, and notebooks or exploratory scripts go under `research/` so they never ship with the package. + +## Build, Test, and Development Commands +1. `python -m pip install -r requirements.txt` installs the runtime stack; add dev-only tooling to `requirements-dev.txt`. +2. `python -m pip install -e .[dev]` enables editable development once `pyproject.toml` is in place. +3. `python -m fullmooncgpt.cli --help` exercises the primary entry point; add new subcommands under `src/fullmooncgpt/cli/`. +4. `python -m pytest` runs the entire suite; pass `-k` to focus on a subset during iteration. +5. `ruff format && ruff check` enforce formatting and linting; run before every commit to match CI. + +## Coding Style & Naming Conventions +Target Python 3.11+, annotate public functions, and rely on dataclasses or pydantic models for structured payloads. Follow snake_case for modules and functions, PascalCase for classes, and keep module names short but descriptive. Stick to 4-space indentation and keep functions focused on a single responsibility. Store configuration in `.env` files loaded through `pydantic-settings` rather than hard-coding credentials. + +## Testing Guidelines +Use pytest and hypothesis for property-based coverage. Name files `test_.py` and isolate test data under `tests/fixtures/`. Aim for ≥85% statement coverage; capture notable gaps in the PR description. Mark slow or external-integration tests with `@pytest.mark.slow` so CI can opt in. + +## Commit & Pull Request Guidelines +Adopt Conventional Commits (for example, `feat: planner adds multi-hop support`) to keep changelog generation simple. Keep PRs scoped; include a checklist covering tests run, documentation updates, and backward compatibility notes. Link related issues with `Closes #ID` and attach logs or screenshots for UI-facing adjustments. Request at least one maintainer review and avoid force pushes after review without coordination. diff --git a/DREAM_FEATURES.md b/DREAM_FEATURES.md new file mode 100644 index 0000000..ab470b6 --- /dev/null +++ b/DREAM_FEATURES.md @@ -0,0 +1,17 @@ +# Dream Features + +- **Seasonal Storytelling**: cinematic landing sections that adapt with lunar phases, featuring Bonna’s artwork alongside seasonal bakes and playlists. +- **Interactive Menu Planner**: drag-and-drop production calendar that auto-generates bake timelines, ingredient prep tasks, and pick-ticket batches. +- **Flavor Voting**: customers vote on upcoming flavors; winning picks auto-populate draft products for the next preorder cycle. +- **Neighborhood Heatmaps**: visualize delivery density on a map to spotlight underserved areas or hyperlocal pop-ups. +- **Smart Substitutions**: AI-driven suggestions for swapping similar items when inventory is tight (e.g., “sold out of Moonbeam Cookies—try Starlight Blondies”). +- **Kitchen Display Integrations**: real-time order board for an iPad or smart display that updates as the team marks bakes complete. +- **Community Collaboration**: partner producers can list limited-time add-ons (e.g., local honey, coffee) with revenue split tracking. +- **Ingredient Provenance Stories**: dynamic product pages that showcase farm sourcing, grain varietals, milling logs, and fermentation notes. +- **CSB Adventure Mode**: customers choose flavors by answering playful prompts (e.g., “pick your moon mood”), generating personalized weekly boxes. +- **Augmented Reality Unboxing**: QR codes on packaging unlock AR scenes describing tasting notes, reheating tips, and Bonna’s studio insights. +- **Voice Assistant Skills**: integrate with smart speakers (“Hey assistant, what’s on the Full Moon preorder menu this week?”). +- **Sourdough Diagnostics**: log fermentation data, bake temperatures, and humidity—trigger alerts if variables deviate from target ranges. +- **Chef’s Journal**: public-facing blog that pulls selectively from Bonna’s recipe notes, fermentation experiments, and art projects. +- **Sustainability Dashboard**: track food waste avoidance, composting stats, reusable packaging returns, and highlight eco-forward choices to customers. +- **Moonlit Rewards**: loyalty program with tiers tied to lunar cycles—unlock early access drops, surprise pastry drops, or studio tour invites. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3eb8c87 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# FullMoon Bakehouse Platform (gpt) + +This repository houses the custom full stack platform for Full Moon Bakehouse. The stack combines a Next.js frontend and a Django + Celery backend, all orchestrated with Docker for easy local testing. + +## Getting Started + +1. Duplicate the environment template: + ```bash + cp infra/docker/.env.example infra/docker/.env.development + ``` +2. Adjust any secrets in `.env.development` (Stripe, Pushbullet, Django secret key). +3. Launch the stack: + ```bash + docker compose up --build + ``` +4. Access the services at: + - Frontend: http://localhost:3001 + - API: http://localhost:8000 + - MinIO Console: http://localhost:9001 + +## Next Steps + +- Configure S3 storage, Stripe keys, and Pushbullet notifications once credentials are available. +- Add Makefile tasks to wrap common Docker commands (build, migrate, test). + +The `infra/docker/README.md` file contains service-level details and helper commands. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..96a3f98 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,29 @@ +# Roadmap + +## Phase 0 – Foundation (Done) +- Docker-compose stack for frontend (Next.js), backend (Django + DRF), Celery, Postgres, Redis, MinIO. +- Commerce models, migrations, and API endpoints for products, categories, orders, delivery zones, override codes. +- Brand-aligned frontend shell with shared layout, hero, FAQ, and admin menu manager. + +## Phase 1 – Ordering Experience (Next) +- **Backend**: finalize order serializer/view to compute totals, enforce inventory/max-per-order, handle override codes, delivery fees, and tap Stripe/Twilio stubs. +- **Frontend**: integrate checkout POST with success/error states, toasts, persisted customer info, refined quantity steppers. +- **Data**: seed initial menu categories, offerings, delivery zones via fixtures or admin UI. + +## Phase 2 – Operations & CMS +- Admin portal: inventory adjustments, delivery zone editor, override code generator UI. +- Media management: image uploads (S3/MinIO), featured flags, product scheduling. +- Notifications: Pushbullet/email confirmations for new orders, low stock alerts. +- Analytics dashboard (orders per week, top products, ingredient usage) with nightly Celery aggregation. + +## Phase 3 – CSB & Growth +- CSB subscription models (plans, seasonal windows, recurring billing). +- Customer accounts & order history portal. +- Marketing hooks (newsletter signup integration, referral codes, gift cards). +- Deployment automation (DigitalOcean App Platform or droplet scripts), monitoring, backups. + +## Phase 4 – Intelligence & Delight +- Ingredient forecasting based on order history & CSB commitments. +- Custom pick-ticket iPad mode with offline caching. +- Loyalty program or CSA-style credit bank. +- Integration with bookkeeping/inventory (e.g., QuickBooks, Airtable) if desired. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..2fdace7 --- /dev/null +++ b/TODO.md @@ -0,0 +1,21 @@ +# TODO + +## Completed +- Dockerized full stack with Next.js frontend, Django + DRF backend, Celery, Postgres, Redis, MinIO. +- Scaffolded commerce models (products, categories, orders, delivery zones, override codes) with REST endpoints. +- Implemented custom admin menu manager with token-based access. +- Migrated storefront to brand-matched design with shared layout/navigation. +- Added global cart context with persistent storage and checkout flow. +- Created product listing, product detail, and cart pages. +- Imported current logo and palette from resources folder. + +## In Progress / Next +- Update backend order serializer to compute totals, enforce limits, record override code usage, and return structured errors. +- Seed database with real products, categories, delivery zones (fixtures / admin entries). +- Wire admin UI for inventory adjustments, delivery zones, override code management. +- Implement image upload and featured flag support in admin + frontend. +- Draft customer email confirmations and order notifications (Pushbullet/SMS). +- Add automated tests (frontend integration + backend unit tests). +- Polish quantity controls and toast notifications on the frontend. +- Capture design assets to a style guide (colors, typography, component specs). +- Prepare deployment scripts for DigitalOcean App Platform or Docker droplet. diff --git a/WISHLIST.md b/WISHLIST.md new file mode 100644 index 0000000..d536b19 --- /dev/null +++ b/WISHLIST.md @@ -0,0 +1,12 @@ +# Wishlist + +- **Style Guide**: document colors, typography, component spacing so future contributors keep the brand cohesive. +- **Product Scheduling**: allow date-based availability toggles to pre-post weekly menus. +- **Ingredient Library**: track base ingredients (flour batches, butter, etc.) with depletion per recipe. +- **Gift Cards**: simple digital gift card purchase and balance tracking. +- **Customer Portal**: login for order history, saved delivery addresses, CSB subscription management. +- **Marketing Automations**: integrate ConvertKit/Mailchimp for automatic preorder reminders and CSB launch drips. +- **Accessibility Polish**: keyboard shortcuts in admin, improved focus states, semantic detail pages. +- **Performance**: image optimization pipeline (next/image or Cloudinary) once real photos arrive. +- **Testing Harness**: Jest/Testing Library for frontend components and pytest coverage for critical order flows. +- **Deployment Enhancements**: staging environment with seeded data, preview URLs for marketing review. diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..bdf0593 --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,7 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.env +venv +.django diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..61a484a --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..bc42c4d --- /dev/null +++ b/api/README.md @@ -0,0 +1,30 @@ +# API + +Django project configured for the Full Moon Bakehouse platform. The scaffold is minimal but wired to the Docker environment variables. + +## Development + +Inside Docker (recommended): + +```bash +docker compose up api-gpt +``` + +Run management commands via: + +```bash +docker compose exec api-gpt python manage.py migrate +docker compose exec api-gpt python manage.py createsuperuser +``` + +For local development outside Docker, create a virtualenv and install requirements: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python manage.py migrate +python manage.py runserver +``` + +Environment variables default to SQLite if `DATABASE_URL` is not provided. diff --git a/api/apps/__init__.py b/api/apps/__init__.py new file mode 100644 index 0000000..94c58f1 --- /dev/null +++ b/api/apps/__init__.py @@ -0,0 +1 @@ +# Package marker for Django apps. diff --git a/api/apps/commerce/__init__.py b/api/apps/commerce/__init__.py new file mode 100644 index 0000000..3edf493 --- /dev/null +++ b/api/apps/commerce/__init__.py @@ -0,0 +1,2 @@ +# Default app configuration +default_app_config = "apps.commerce.apps.CommerceConfig" diff --git a/api/apps/commerce/admin.py b/api/apps/commerce/admin.py new file mode 100644 index 0000000..3f39a0d --- /dev/null +++ b/api/apps/commerce/admin.py @@ -0,0 +1,87 @@ +from django.contrib import admin + +from . import models + + +@admin.register(models.ProductCategory) +class ProductCategoryAdmin(admin.ModelAdmin): + list_display = ("name", "slug", "display_order", "is_active") + list_filter = ("is_active",) + search_fields = ("name", "slug") + ordering = ("display_order", "name") + prepopulated_fields = {"slug": ("name",)} + + +@admin.register(models.Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ( + "name", + "category", + "price", + "inventory_quantity", + "max_per_order", + "is_active", + "is_featured", + ) + list_filter = ("category", "is_active", "is_featured") + search_fields = ("name", "slug") + prepopulated_fields = {"slug": ("name",)} + autocomplete_fields = ("category",) + + +@admin.register(models.DeliveryZone) +class DeliveryZoneAdmin(admin.ModelAdmin): + list_display = ("label", "postal_code", "is_active") + list_filter = ("is_active",) + search_fields = ("label", "postal_code") + + +@admin.register(models.DeliveryOverrideCode) +class DeliveryOverrideCodeAdmin(admin.ModelAdmin): + list_display = ("code", "label", "max_uses", "usage_count", "is_active") + list_filter = ("is_active",) + search_fields = ("code", "label") + + +class OrderItemInline(admin.TabularInline): + model = models.OrderItem + extra = 0 + readonly_fields = ( + "product", + "product_name", + "product_slug", + "unit_price", + "quantity", + "line_subtotal", + ) + + +@admin.register(models.Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ( + "id", + "customer_name", + "fulfillment_method", + "status", + "scheduled_date", + "total_amount", + "created_at", + ) + list_filter = ( + "fulfillment_method", + "status", + "scheduled_date", + "delivery_postal_code", + ) + search_fields = ("id", "customer_name", "customer_email") + date_hierarchy = "scheduled_date" + inlines = [OrderItemInline] + readonly_fields = ( + "subtotal_amount", + "tax_amount", + "delivery_fee_amount", + "discount_amount", + "total_amount", + "created_at", + "updated_at", + ) diff --git a/api/apps/commerce/apps.py b/api/apps/commerce/apps.py new file mode 100644 index 0000000..84066d4 --- /dev/null +++ b/api/apps/commerce/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CommerceConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.commerce" + verbose_name = "Commerce" diff --git a/api/apps/commerce/migrations/0001_initial.py b/api/apps/commerce/migrations/0001_initial.py new file mode 100644 index 0000000..f7154f9 --- /dev/null +++ b/api/apps/commerce/migrations/0001_initial.py @@ -0,0 +1,267 @@ +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="DeliveryOverrideCode", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("code", models.CharField(max_length=32, unique=True)), + ("label", models.CharField(max_length=120)), + ("message_to_customer", models.CharField(blank=True, max_length=240)), + ( + "max_uses", + models.PositiveIntegerField(default=0, help_text="0 = unlimited"), + ), + ("usage_count", models.PositiveIntegerField(default=0)), + ("is_active", models.BooleanField(default=True)), + ], + options={"ordering": ["code"]}, + ), + migrations.CreateModel( + name="DeliveryZone", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("label", models.CharField(max_length=120)), + ("postal_code", models.CharField(db_index=True, max_length=10)), + ("is_active", models.BooleanField(default=True)), + ], + options={"ordering": ["postal_code"], "unique_together": {("postal_code", "label")}}, + ), + migrations.CreateModel( + name="Order", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("customer_name", models.CharField(max_length=160)), + ("customer_email", models.EmailField(max_length=254)), + ("customer_phone", models.CharField(blank=True, max_length=32)), + ( + "fulfillment_method", + models.CharField( + choices=[("pickup", "Pickup"), ("delivery", "Delivery")], + db_index=True, + max_length=16, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("pending", "Pending"), + ("confirmed", "Confirmed"), + ("in_production", "In Production"), + ("ready", "Ready"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + db_index=True, + default="pending", + max_length=16, + ), + ), + ("scheduled_date", models.DateField(blank=True, null=True)), + ("scheduled_time_slot", models.CharField(blank=True, max_length=64)), + ("delivery_address_line1", models.CharField(blank=True, max_length=160)), + ("delivery_address_line2", models.CharField(blank=True, max_length=160)), + ("delivery_city", models.CharField(blank=True, max_length=80)), + ("delivery_state", models.CharField(blank=True, max_length=32)), + ("delivery_postal_code", models.CharField(blank=True, max_length=16)), + ( + "subtotal_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=8, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ( + "tax_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=8, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ( + "delivery_fee_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=8, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ( + "discount_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=8, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ( + "total_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=8, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ("notes", models.TextField(blank=True)), + ( + "delivery_override_code", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="orders", + to="commerce.deliveryoverridecode", + ), + ), + ], + options={"ordering": ["-scheduled_date", "-created_at"]}, + ), + migrations.CreateModel( + name="ProductCategory", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=120, unique=True)), + ("slug", models.SlugField(max_length=140, unique=True)), + ("description", models.TextField(blank=True)), + ("display_order", models.PositiveIntegerField(default=0)), + ("is_active", models.BooleanField(default=True)), + ], + options={ + "ordering": ["display_order", "name"], + "verbose_name_plural": "Product categories", + }, + ), + migrations.CreateModel( + name="Product", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=160)), + ("slug", models.SlugField(max_length=180, unique=True)), + ("short_description", models.CharField(blank=True, max_length=280)), + ("description", models.TextField(blank=True)), + ("ingredients", models.TextField(blank=True)), + ( + "allergens", + models.CharField( + blank=True, + help_text="Comma-separated allergen labels.", + max_length=280, + ), + ), + ("price", models.DecimalField(decimal_places=2, max_digits=8)), + ("inventory_quantity", models.PositiveIntegerField(default=0)), + ("max_per_order", models.PositiveIntegerField(default=6)), + ("is_active", models.BooleanField(default=True)), + ("is_featured", models.BooleanField(default=False)), + ("hero_image", models.ImageField(blank=True, upload_to="products/hero/")), + ( + "category", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="products", + to="commerce.productcategory", + ), + ), + ], + options={"ordering": ["name"]}, + ), + migrations.CreateModel( + name="OrderItem", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("product_name", models.CharField(max_length=160)), + ("product_slug", models.SlugField(max_length=180)), + ("unit_price", models.DecimalField(decimal_places=2, max_digits=8)), + ( + "quantity", + models.PositiveIntegerField( + default=1, validators=[django.core.validators.MinValueValidator(1)] + ), + ), + ( + "line_subtotal", + models.DecimalField(decimal_places=2, default=0, max_digits=8), + ), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="commerce.order", + ), + ), + ( + "product", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="order_items", + to="commerce.product", + ), + ), + ], + options={"ordering": ["product_name"]}, + ), + ] diff --git a/api/apps/commerce/migrations/__init__.py b/api/apps/commerce/migrations/__init__.py new file mode 100644 index 0000000..80d4422 --- /dev/null +++ b/api/apps/commerce/migrations/__init__.py @@ -0,0 +1 @@ +# Package marker for migrations. diff --git a/api/apps/commerce/models.py b/api/apps/commerce/models.py new file mode 100644 index 0000000..ce50f6b --- /dev/null +++ b/api/apps/commerce/models.py @@ -0,0 +1,198 @@ +import uuid + +from django.core.validators import MinValueValidator +from django.db import models + + +class TimeStampedModel(models.Model): + """Abstract base with created/updated timestamps.""" + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class ProductCategory(TimeStampedModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=120, unique=True) + slug = models.SlugField(max_length=140, unique=True) + description = models.TextField(blank=True) + display_order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["display_order", "name"] + verbose_name_plural = "Product categories" + + def __str__(self) -> str: + return self.name + + +class Product(TimeStampedModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + category = models.ForeignKey( + ProductCategory, + related_name="products", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + name = models.CharField(max_length=160) + slug = models.SlugField(max_length=180, unique=True) + short_description = models.CharField(max_length=280, blank=True) + description = models.TextField(blank=True) + ingredients = models.TextField(blank=True) + allergens = models.CharField( + max_length=280, blank=True, help_text="Comma-separated allergen labels." + ) + price = models.DecimalField(max_digits=8, decimal_places=2) + inventory_quantity = models.PositiveIntegerField(default=0) + max_per_order = models.PositiveIntegerField(default=6) + is_active = models.BooleanField(default=True) + is_featured = models.BooleanField(default=False) + hero_image = models.ImageField(upload_to="products/hero/", blank=True) + + class Meta: + ordering = ["name"] + + def __str__(self) -> str: + return self.name + + @property + def is_available(self) -> bool: + return self.is_active and self.inventory_quantity > 0 + + +class DeliveryZone(TimeStampedModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + label = models.CharField(max_length=120) + postal_code = models.CharField(max_length=10, db_index=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["postal_code"] + unique_together = ("postal_code", "label") + + def __str__(self) -> str: + return f"{self.label} ({self.postal_code})" + + +class DeliveryOverrideCode(TimeStampedModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + code = models.CharField(max_length=32, unique=True) + label = models.CharField(max_length=120) + message_to_customer = models.CharField(max_length=240, blank=True) + max_uses = models.PositiveIntegerField(default=0, help_text="0 = unlimited") + usage_count = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["code"] + + def __str__(self) -> str: + return self.code + + def can_use(self) -> bool: + if not self.is_active: + return False + if self.max_uses and self.usage_count >= self.max_uses: + return False + return True + + +class Order(TimeStampedModel): + class FulfillmentMethod(models.TextChoices): + PICKUP = "pickup", "Pickup" + DELIVERY = "delivery", "Delivery" + + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + PENDING = "pending", "Pending" + CONFIRMED = "confirmed", "Confirmed" + IN_PRODUCTION = "in_production", "In Production" + READY = "ready", "Ready" + COMPLETED = "completed", "Completed" + CANCELLED = "cancelled", "Cancelled" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + customer_name = models.CharField(max_length=160) + customer_email = models.EmailField() + customer_phone = models.CharField(max_length=32, blank=True) + fulfillment_method = models.CharField( + max_length=16, choices=FulfillmentMethod.choices, db_index=True + ) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True + ) + scheduled_date = models.DateField(null=True, blank=True) + scheduled_time_slot = models.CharField(max_length=64, blank=True) + delivery_address_line1 = models.CharField(max_length=160, blank=True) + delivery_address_line2 = models.CharField(max_length=160, blank=True) + delivery_city = models.CharField(max_length=80, blank=True) + delivery_state = models.CharField(max_length=32, blank=True) + delivery_postal_code = models.CharField(max_length=16, blank=True) + delivery_override_code = models.ForeignKey( + DeliveryOverrideCode, + related_name="orders", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + subtotal_amount = models.DecimalField( + max_digits=8, + decimal_places=2, + default=0, + validators=[MinValueValidator(0)], + ) + tax_amount = models.DecimalField( + max_digits=8, + decimal_places=2, + default=0, + validators=[MinValueValidator(0)], + ) + delivery_fee_amount = models.DecimalField( + max_digits=8, + decimal_places=2, + default=0, + validators=[MinValueValidator(0)], + ) + discount_amount = models.DecimalField( + max_digits=8, + decimal_places=2, + default=0, + validators=[MinValueValidator(0)], + ) + total_amount = models.DecimalField( + max_digits=8, + decimal_places=2, + default=0, + validators=[MinValueValidator(0)], + ) + notes = models.TextField(blank=True) + + class Meta: + ordering = ["-scheduled_date", "-created_at"] + + def __str__(self) -> str: + return f"Order {self.id}" + + +class OrderItem(TimeStampedModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE) + product = models.ForeignKey( + Product, related_name="order_items", on_delete=models.SET_NULL, null=True + ) + product_name = models.CharField(max_length=160) + product_slug = models.SlugField(max_length=180) + unit_price = models.DecimalField(max_digits=8, decimal_places=2) + quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)]) + line_subtotal = models.DecimalField(max_digits=8, decimal_places=2, default=0) + + class Meta: + ordering = ["product_name"] + + def __str__(self) -> str: + return f"{self.product_name} x{self.quantity}" diff --git a/api/apps/commerce/serializers.py b/api/apps/commerce/serializers.py new file mode 100644 index 0000000..629fce0 --- /dev/null +++ b/api/apps/commerce/serializers.py @@ -0,0 +1,253 @@ +from decimal import Decimal + +from django.db import transaction +from rest_framework import serializers + +from . import models + + +class ProductCategorySerializer(serializers.ModelSerializer): + class Meta: + model = models.ProductCategory + fields = ( + "id", + "name", + "slug", + "description", + "display_order", + "is_active", + ) + + +class ProductSerializer(serializers.ModelSerializer): + category = ProductCategorySerializer(read_only=True) + category_id = serializers.PrimaryKeyRelatedField( + source="category", + queryset=models.ProductCategory.objects.all(), + required=False, + allow_null=True, + write_only=True, + ) + + class Meta: + model = models.Product + fields = ( + "id", + "name", + "slug", + "short_description", + "description", + "ingredients", + "allergens", + "price", + "inventory_quantity", + "max_per_order", + "is_active", + "is_featured", + "hero_image", + "category", + "category_id", + ) + + +class DeliveryZoneSerializer(serializers.ModelSerializer): + is_active = serializers.BooleanField(required=False) + + class Meta: + model = models.DeliveryZone + fields = ("id", "label", "postal_code", "is_active") + + +class DeliveryOverrideCodeSerializer(serializers.ModelSerializer): + can_use = serializers.SerializerMethodField() + + class Meta: + model = models.DeliveryOverrideCode + fields = ( + "id", + "code", + "label", + "message_to_customer", + "max_uses", + "usage_count", + "is_active", + "can_use", + ) + read_only_fields = ("usage_count", "can_use") + + def get_can_use(self, obj: models.DeliveryOverrideCode) -> bool: + return obj.can_use() + + +class OrderItemSerializer(serializers.ModelSerializer): + line_subtotal = serializers.DecimalField( + max_digits=8, decimal_places=2, read_only=True + ) + + class Meta: + model = models.OrderItem + fields = ( + "id", + "product", + "product_name", + "product_slug", + "unit_price", + "quantity", + "line_subtotal", + ) + read_only_fields = ("product_name", "product_slug", "unit_price", "line_subtotal") + + +class OrderSerializer(serializers.ModelSerializer): + items = OrderItemSerializer(many=True) + subtotal_amount = serializers.DecimalField( + max_digits=8, decimal_places=2, read_only=True + ) + tax_amount = serializers.DecimalField(max_digits=8, decimal_places=2, read_only=True) + delivery_fee_amount = serializers.DecimalField( + max_digits=8, decimal_places=2, read_only=True + ) + discount_amount = serializers.DecimalField( + max_digits=8, decimal_places=2, read_only=True + ) + total_amount = serializers.DecimalField(max_digits=8, decimal_places=2, read_only=True) + + class Meta: + model = models.Order + fields = ( + "id", + "customer_name", + "customer_email", + "customer_phone", + "fulfillment_method", + "status", + "scheduled_date", + "scheduled_time_slot", + "delivery_address_line1", + "delivery_address_line2", + "delivery_city", + "delivery_state", + "delivery_postal_code", + "delivery_override_code", + "subtotal_amount", + "tax_amount", + "delivery_fee_amount", + "discount_amount", + "total_amount", + "notes", + "items", + "created_at", + "updated_at", + ) + read_only_fields = ("status", "created_at", "updated_at") + + def create(self, validated_data): + items_data = validated_data.pop("items", []) + delivery_fee_amount = Decimal(validated_data.pop("delivery_fee_amount", Decimal("0"))) + discount_amount = Decimal(validated_data.pop("discount_amount", Decimal("0"))) + tax_amount = Decimal(validated_data.pop("tax_amount", Decimal("0"))) + + if not items_data: + raise serializers.ValidationError("Order requires at least one item.") + + with transaction.atomic(): + subtotal = Decimal("0") + order_items = [] + + for item_data in items_data: + product = item_data.get("product") + if product is None: + raise serializers.ValidationError("Each order item must reference a product.") + + quantity = item_data.get("quantity", 1) + if quantity > product.inventory_quantity: + raise serializers.ValidationError( + f"Insufficient inventory for {product.name}. Requested {quantity}, " + f"available {product.inventory_quantity}." + ) + + line_subtotal = product.price * quantity + subtotal += line_subtotal + + order_items.append( + { + "product": product, + "product_name": product.name, + "product_slug": product.slug, + "unit_price": product.price, + "quantity": quantity, + "line_subtotal": line_subtotal, + } + ) + + product.inventory_quantity -= quantity + product.save(update_fields=["inventory_quantity"]) + + total_amount = subtotal + tax_amount + delivery_fee_amount - discount_amount + + order = models.Order.objects.create( + subtotal_amount=subtotal, + tax_amount=tax_amount, + delivery_fee_amount=delivery_fee_amount, + discount_amount=discount_amount, + total_amount=total_amount, + **validated_data, + ) + + models.OrderItem.objects.bulk_create( + [models.OrderItem(order=order, **item) for item in order_items] + ) + + return order + + +class OrderAdminSerializer(serializers.ModelSerializer): + items = OrderItemSerializer(many=True, read_only=True) + subtotal_amount = serializers.DecimalField( + max_digits=8, decimal_places=2, read_only=True + ) + tax_amount = serializers.DecimalField(max_digits=8, decimal_places=2, read_only=True) + delivery_fee_amount = serializers.DecimalField( + max_digits=8, decimal_places=2, read_only=True + ) + discount_amount = serializers.DecimalField( + max_digits=8, decimal_places=2, read_only=True + ) + total_amount = serializers.DecimalField(max_digits=8, decimal_places=2, read_only=True) + + class Meta: + model = models.Order + fields = ( + "id", + "customer_name", + "customer_email", + "customer_phone", + "fulfillment_method", + "status", + "scheduled_date", + "scheduled_time_slot", + "delivery_address_line1", + "delivery_address_line2", + "delivery_city", + "delivery_state", + "delivery_postal_code", + "delivery_override_code", + "subtotal_amount", + "tax_amount", + "delivery_fee_amount", + "discount_amount", + "total_amount", + "notes", + "items", + "created_at", + "updated_at", + ) + read_only_fields = ( + "subtotal_amount", + "tax_amount", + "delivery_fee_amount", + "discount_amount", + "total_amount", + "created_at", + "updated_at", + ) diff --git a/api/apps/commerce/urls.py b/api/apps/commerce/urls.py new file mode 100644 index 0000000..7dfd7b2 --- /dev/null +++ b/api/apps/commerce/urls.py @@ -0,0 +1,34 @@ +from django.urls import include, path +from rest_framework import routers + +from . import views + +public_router = routers.DefaultRouter() +public_router.register( + "categories", views.ProductCategoryPublicViewSet, basename="category" +) +public_router.register("products", views.ProductPublicViewSet, basename="product") +public_router.register( + "delivery-zones", views.DeliveryZonePublicViewSet, basename="delivery-zone" +) +public_router.register("orders", views.OrderPublicViewSet, basename="order") + +admin_router = routers.DefaultRouter() +admin_router.register( + "categories", views.ProductCategoryAdminViewSet, basename="admin-category" +) +admin_router.register("products", views.ProductAdminViewSet, basename="admin-product") +admin_router.register( + "delivery-zones", views.DeliveryZoneAdminViewSet, basename="admin-delivery-zone" +) +admin_router.register( + "delivery-overrides", + views.DeliveryOverrideCodeAdminViewSet, + basename="admin-delivery-override", +) +admin_router.register("orders", views.OrderAdminViewSet, basename="admin-order") + +urlpatterns = [ + path("", include(public_router.urls)), + path("admin/", include(admin_router.urls)), +] diff --git a/api/apps/commerce/views.py b/api/apps/commerce/views.py new file mode 100644 index 0000000..193d123 --- /dev/null +++ b/api/apps/commerce/views.py @@ -0,0 +1,82 @@ +from rest_framework import mixins, permissions, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from . import models, serializers + + +class ProductPublicViewSet(viewsets.ReadOnlyModelViewSet): + """Public storefront products.""" + + queryset = models.Product.objects.filter(is_active=True).select_related("category") + serializer_class = serializers.ProductSerializer + permission_classes = [permissions.AllowAny] + lookup_field = "slug" + + +class ProductAdminViewSet(viewsets.ModelViewSet): + queryset = models.Product.objects.select_related("category").all() + serializer_class = serializers.ProductSerializer + permission_classes = [permissions.IsAuthenticated] + + +class ProductCategoryPublicViewSet(viewsets.ReadOnlyModelViewSet): + queryset = models.ProductCategory.objects.filter(is_active=True) + serializer_class = serializers.ProductCategorySerializer + permission_classes = [permissions.AllowAny] + lookup_field = "slug" + + +class ProductCategoryAdminViewSet(viewsets.ModelViewSet): + queryset = models.ProductCategory.objects.all() + serializer_class = serializers.ProductCategorySerializer + permission_classes = [permissions.IsAuthenticated] + + +class DeliveryZonePublicViewSet(viewsets.ReadOnlyModelViewSet): + queryset = models.DeliveryZone.objects.filter(is_active=True) + serializer_class = serializers.DeliveryZoneSerializer + permission_classes = [permissions.AllowAny] + + +class DeliveryZoneAdminViewSet(viewsets.ModelViewSet): + queryset = models.DeliveryZone.objects.all() + serializer_class = serializers.DeliveryZoneSerializer + permission_classes = [permissions.IsAuthenticated] + + +class DeliveryOverrideCodeAdminViewSet(viewsets.ModelViewSet): + queryset = models.DeliveryOverrideCode.objects.all() + serializer_class = serializers.DeliveryOverrideCodeSerializer + permission_classes = [permissions.IsAuthenticated] + lookup_field = "code" + + @action(methods=["post"], detail=True, permission_classes=[permissions.IsAuthenticated]) + def activate(self, request, code=None): + override_code = self.get_object() + override_code.is_active = True + override_code.save(update_fields=["is_active"]) + return Response(self.get_serializer(override_code).data) + + @action(methods=["post"], detail=True, permission_classes=[permissions.IsAuthenticated]) + def deactivate(self, request, code=None): + override_code = self.get_object() + override_code.is_active = False + override_code.save(update_fields=["is_active"]) + return Response(self.get_serializer(override_code).data) + + +class OrderPublicViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): + queryset = models.Order.objects.all() + serializer_class = serializers.OrderSerializer + permission_classes = [permissions.AllowAny] + + +class OrderAdminViewSet(viewsets.ModelViewSet): + queryset = ( + models.Order.objects.select_related("delivery_override_code") + .prefetch_related("items") + .all() + ) + serializer_class = serializers.OrderAdminSerializer + permission_classes = [permissions.IsAuthenticated] diff --git a/api/celerybeat-schedule b/api/celerybeat-schedule new file mode 100644 index 0000000000000000000000000000000000000000..19a7556f52c3313562e8a084f032368b705db889 GIT binary patch literal 16384 zcmeI(KTE?v7{~EbQba0Lh=U-MZVtjJNO#52RS+B+Vm&mVDWuV%AY@eN7#CkaXYma< zJNW{fd;`7$L7(d#n8B%F{lm9#_k^Qd!>7;v$daeq{ZK6`H6BuIo%eNQ>z19AdUJ&t z0R#|0009ILKmY**5I_I{1pcBx%ilEBLEwOc6`&931Nwk-Kn{=tTW?)z+*#6&!G=o)7wAI{onc__7*B7iPidUtNB7Ne2GY z@y8iwCx8PERRMRs(z$P*=iO+xlFvL(emaq#&H{(Y9OCQO(4={${P~)bZE7;`oXqp# zZf`jsD7EH2XJwn_FZ8tkd1CrSa9L{m{G0WYUK&M3a5H`jK<;>0YwNb&EOkDR)zERM zVPpHuWJxz(-#L-_xL0Z$&nArzPt8#gTxtJB3IYfqfB*srAbV literal 0 HcmV?d00001 diff --git a/api/config/__init__.py b/api/config/__init__.py new file mode 100644 index 0000000..6d300d0 --- /dev/null +++ b/api/config/__init__.py @@ -0,0 +1,3 @@ +from .celery_app import app as celery_app + +__all__ = ("celery_app",) diff --git a/api/config/asgi.py b/api/config/asgi.py new file mode 100644 index 0000000..856079b --- /dev/null +++ b/api/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/api/config/celery_app.py b/api/config/celery_app.py new file mode 100644 index 0000000..f645b6b --- /dev/null +++ b/api/config/celery_app.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +app = Celery("fullmoon") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/api/config/settings.py b/api/config/settings.py new file mode 100644 index 0000000..4f7056e --- /dev/null +++ b/api/config/settings.py @@ -0,0 +1,123 @@ +import os +from pathlib import Path + +import dj_database_url +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent.parent + +# Load environment variables from a sibling .env file if present (useful outside Docker) +load_dotenv(BASE_DIR.parent / ".env") + +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "insecure-dev-key") +DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1" +ALLOWED_HOSTS = [ + host.strip() + for host in os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") + if host.strip() +] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework.authtoken", + "apps.commerce", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" + +database_url = os.environ.get("DATABASE_URL") + +if database_url: + DATABASES = { + "default": dj_database_url.parse(database_url, conn_max_age=600), + } +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB", "postgres"), + "USER": os.environ.get("POSTGRES_USER", "postgres"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "postgres"), + "HOST": os.environ.get("POSTGRES_HOST", "db-gpt"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), + } + } + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = os.environ.get("DJANGO_TIME_ZONE", "America/Chicago") +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +MEDIA_URL = "media/" +MEDIA_ROOT = BASE_DIR / "media" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.TokenAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticatedOrReadOnly", + ], +} + +CELERY_BROKER_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") +CELERY_RESULT_BACKEND = CELERY_BROKER_URL +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" diff --git a/api/config/urls.py b/api/config/urls.py new file mode 100644 index 0000000..0bd1955 --- /dev/null +++ b/api/config/urls.py @@ -0,0 +1,14 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path +from rest_framework.authtoken.views import obtain_auth_token + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/commerce/", include("apps.commerce.urls")), + path("api/auth/token/", obtain_auth_token, name="api-token"), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/api/config/wsgi.py b/api/config/wsgi.py new file mode 100644 index 0000000..8509335 --- /dev/null +++ b/api/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/api/manage.py b/api/manage.py new file mode 100644 index 0000000..ff37503 --- /dev/null +++ b/api/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +import os +import sys + + +def main() -> None: + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..a82ed1f --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,8 @@ +django>=5.0,<6.0 +djangorestframework>=3.14,<4.0 +celery>=5.3,<6.0 +psycopg2-binary>=2.9 +python-dotenv>=1.0 +dj-database-url>=2.1 +redis>=5.0 +Pillow>=10.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..49b8a30 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,113 @@ +version: "3.9" + +services: + frontend-gpt: + build: + context: ./frontend + dockerfile: Dockerfile + command: npm run dev -- -H 0.0.0.0 + env_file: + - infra/docker/.env.development + environment: + - NEXT_PUBLIC_API_URL=http://api-gpt:8000 + volumes: + - ./frontend:/app + - node_modules-gpt:/app/node_modules + ports: + - "3001:3000" + depends_on: + - api-gpt + + api-gpt: + build: + context: ./api + dockerfile: Dockerfile + command: python manage.py runserver 0.0.0.0:8000 + env_file: + - infra/docker/.env.development + environment: + - DJANGO_SETTINGS_MODULE=config.settings + - REDIS_URL=redis://cache-gpt:6379/0 + - MINIO_ENDPOINT=http://storage-gpt:9000 + volumes: + - ./api:/app + - media-gpt:/app/media + ports: + - "8000:8000" + depends_on: + - db-gpt + - cache-gpt + - storage-gpt + + worker-gpt: + build: + context: ./api + dockerfile: Dockerfile + command: celery -A config.celery_app worker -l info + env_file: + - infra/docker/.env.development + environment: + - DJANGO_SETTINGS_MODULE=config.settings + - REDIS_URL=redis://cache-gpt:6379/0 + - MINIO_ENDPOINT=http://storage-gpt:9000 + volumes: + - ./api:/app + depends_on: + - api-gpt + - db-gpt + - cache-gpt + - storage-gpt + + scheduler-gpt: + build: + context: ./api + dockerfile: Dockerfile + command: celery -A config.celery_app beat -l info + env_file: + - infra/docker/.env.development + environment: + - DJANGO_SETTINGS_MODULE=config.settings + - REDIS_URL=redis://cache-gpt:6379/0 + - MINIO_ENDPOINT=http://storage-gpt:9000 + volumes: + - ./api:/app + depends_on: + - api-gpt + - db-gpt + - cache-gpt + - storage-gpt + + db-gpt: + image: postgres:15-alpine + env_file: + - infra/docker/.env.development + volumes: + - pgdata-gpt:/var/lib/postgresql/data + ports: + - "5433:5432" + + cache-gpt: + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redisdata-gpt:/data + ports: + - "6379:6379" + + storage-gpt: + image: minio/minio:RELEASE.2024-01-11T07-46-16Z + command: server /data --console-address ":9001" + env_file: + - infra/docker/.env.development + volumes: + - minio-gpt:/data + ports: + - "9000:9000" + - "9001:9001" + +volumes: + node_modules-gpt: + pgdata-gpt: + redisdata-gpt: + minio-gpt: + media-gpt: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..32b3ece --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.next +npm-debug.log diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..ca19613 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,5 @@ +node_modules +.next +out +npm-debug.log +.DS_Store diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1bd2d08 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,14 @@ +# syntax=docker/dockerfile:1 + +FROM node:20-bullseye + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN if [ -f package.json ]; then npm install; fi + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "run", "dev", "--", "-H", "0.0.0.0"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d40ba62 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,20 @@ +# Frontend + +This directory contains the Next.js frontend. The current scaffold renders a placeholder page so the Docker stack can start without additional setup. + +## Development + +Inside Docker (recommended): + +```bash +docker compose up frontend-gpt +``` + +Local node tooling: + +```bash +npm install +npm run dev +``` + +The development server is available at http://localhost:3000. diff --git a/frontend/components/AdminLayout.js b/frontend/components/AdminLayout.js new file mode 100644 index 0000000..97acf3e --- /dev/null +++ b/frontend/components/AdminLayout.js @@ -0,0 +1,39 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; + +const navLinks = [ + { href: '/admin', label: 'Dashboard' }, + { href: '/admin/menu', label: 'Menu' }, + { href: '/admin/orders', label: 'Orders' }, + { href: '/admin/inventory', label: 'Inventory' }, + { href: '/admin/delivery', label: 'Delivery' }, +]; + +export function AdminLayout({ children, title = 'Admin' }) { + const router = useRouter(); + return ( +
+
+
+
Full Moon Bakehouse Operations
+

{title}

+ +
+
{children}
+
+
+ ); +} diff --git a/frontend/components/FaqSection.js b/frontend/components/FaqSection.js new file mode 100644 index 0000000..4a40973 --- /dev/null +++ b/frontend/components/FaqSection.js @@ -0,0 +1,16 @@ +export function FaqSection({ items }) { + if (!items?.length) { + return null; + } + + return ( +
+ {items.map((item) => ( +
+

{item.question}

+

{item.answer}

+
+ ))} +
+ ); +} diff --git a/frontend/components/ProductCard.js b/frontend/components/ProductCard.js new file mode 100644 index 0000000..4a30ac5 --- /dev/null +++ b/frontend/components/ProductCard.js @@ -0,0 +1,52 @@ +import Link from 'next/link'; + +import { useCart } from '../context/CartContext'; + +const FALLBACK_IMAGE = + 'https://images.unsplash.com/photo-1608198093002-ad4e005484ec?auto=format&fit=crop&w=1200&q=80'; + +export default function ProductCard({ product }) { + const { addItem } = useCart(); + + const imageUrl = product?.imageUrl || product?.hero_image || FALLBACK_IMAGE; + const price = Number(product?.price || 0).toFixed(2); + const canPurchase = Boolean(product?.id) && !product?.isSample; + const hasDetail = Boolean(product?.slug) && !product?.isSample; + + return ( +
+
+ {product?.name +
+ +
+

{product?.name || 'Menu item'}

+ {product?.short_description ?

{product.short_description}

: null} +
+ +
+ ${price} + {product?.inventory_quantity ?? '—'} available +
+ +
+ Max {product?.max_per_order ?? '—'} / order + {product?.allergens ? {product.allergens} : null} +
+ +
+ {canPurchase ? ( + + ) : ( + Sample listing + )} + + {hasDetail ? ( + View details + ) : null} +
+
+ ); +} diff --git a/frontend/components/SiteLayout.js b/frontend/components/SiteLayout.js new file mode 100644 index 0000000..2f986b9 --- /dev/null +++ b/frontend/components/SiteLayout.js @@ -0,0 +1,66 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; + +import { useCart } from '../context/CartContext'; + +const NAV_LINKS = [ + { href: '/', label: 'Home' }, + { href: '/products', label: 'Products' }, + { href: '/cart', label: 'Cart' }, +]; + +export default function SiteLayout({ children }) { + const router = useRouter(); + const { itemCount } = useCart(); + + return ( +
+
+
+ + Full Moon Bakehouse + Full Moon Bakehouse + + + + +
+ + Admin + +
+
+
+ +
{children}
+ +
+
+
+ Full Moon Bakehouse +

Eau Claire & Altoona, Wisconsin

+
+
+ bonna@fullmoonbakehouse.com + + Instagram + +
+ © {new Date().getFullYear()} Full Moon Bakehouse +
+
+
+ ); +} diff --git a/frontend/context/CartContext.js b/frontend/context/CartContext.js new file mode 100644 index 0000000..9807de4 --- /dev/null +++ b/frontend/context/CartContext.js @@ -0,0 +1,139 @@ +import { createContext, useContext, useEffect, useMemo, useReducer } from 'react'; + +const CartContext = createContext(undefined); + +const initialState = { + items: [], +}; + +function cartReducer(state, action) { + switch (action.type) { + case 'HYDRATE': + return { ...state, items: action.payload }; + case 'ADD_ITEM': { + const { item } = action.payload; + const existing = state.items.find((entry) => entry.id === item.id); + if (!existing) { + return { ...state, items: [...state.items, item] }; + } + const updated = state.items.map((entry) => + entry.id === item.id ? { ...entry, quantity: item.quantity, limit: item.limit } : entry + ); + return { ...state, items: updated }; + } + case 'UPDATE_QUANTITY': { + const { id, quantity } = action.payload; + const updated = state.items + .map((entry) => (entry.id === id ? { ...entry, quantity } : entry)) + .filter((entry) => entry.quantity > 0); + return { ...state, items: updated }; + } + case 'REMOVE_ITEM': { + const { id } = action.payload; + return { ...state, items: state.items.filter((entry) => entry.id !== id) }; + } + case 'CLEAR': + return { ...state, items: [] }; + default: + return state; + } +} + +const STORAGE_KEY = 'fullmoon.cart'; + +export function CartProvider({ children }) { + const [state, dispatch] = useReducer(cartReducer, initialState); + + useEffect(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + dispatch({ type: 'HYDRATE', payload: parsed }); + } + } catch (error) { + console.warn('Cart hydration failed', error); + } + }, []); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.items)); + }, [state.items]); + + const itemCount = useMemo( + () => state.items.reduce((sum, entry) => sum + entry.quantity, 0), + [state.items] + ); + + const subtotal = useMemo( + () => state.items.reduce((sum, entry) => sum + entry.price * entry.quantity, 0), + [state.items] + ); + + const addItem = (product) => { + if (!product?.id) return; + const price = Number(product.price || 0); + const maxPerOrder = Number(product.max_per_order || 0) > 0 ? Number(product.max_per_order) : Infinity; + const inventoryLimit = + typeof product.inventory_quantity === 'number' && product.inventory_quantity >= 0 + ? product.inventory_quantity + : Infinity; + const limit = Math.min(maxPerOrder, inventoryLimit); + if (limit <= 0) return; + + const numericLimit = Number.isFinite(limit) ? limit : Infinity; + const existing = state.items.find((entry) => entry.id === product.id); + const nextQuantity = existing ? Math.min(existing.quantity + 1, numericLimit) : 1; + + dispatch({ + type: 'ADD_ITEM', + payload: { + item: { + id: product.id, + slug: product.slug, + name: product.name, + price, + quantity: nextQuantity, + imageUrl: product.imageUrl, + limit: Number.isFinite(limit) ? limit : null, + }, + }, + }); + }; + + const updateQuantity = (id, quantity) => { + if (quantity < 0) return; + const entry = state.items.find((item) => item.id === id); + if (!entry) return; + const limit = Number.isFinite(entry.limit) ? entry.limit : Infinity; + const clamped = Math.min(quantity, limit); + dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity: clamped } }); + }; + + const removeItem = (id) => dispatch({ type: 'REMOVE_ITEM', payload: { id } }); + const clearCart = () => dispatch({ type: 'CLEAR' }); + + const value = useMemo( + () => ({ + items: state.items, + addItem, + updateQuantity, + removeItem, + clearCart, + itemCount, + subtotal, + }), + [state.items, itemCount, subtotal] + ); + + return {children}; +} + +export function useCart() { + const context = useContext(CartContext); + if (!context) { + throw new Error('useCart must be used within a CartProvider'); + } + return context; +} diff --git a/frontend/hooks/useAdminApi.js b/frontend/hooks/useAdminApi.js new file mode 100644 index 0000000..9c7e24d --- /dev/null +++ b/frontend/hooks/useAdminApi.js @@ -0,0 +1,41 @@ +import { useCallback, useState } from 'react'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, '') || 'http://localhost:8000'; + +export function useAdminApi(token) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const authorizedFetch = useCallback( + async (path, options = {}) => { + setLoading(true); + setError(null); + try { + const response = await fetch(`${API_BASE}${path}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Token ${token}`, + ...(options.headers || {}), + }, + ...options, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `Request failed ${response.status}`); + } + + const isJson = response.headers.get('content-type')?.includes('application/json'); + return isJson ? response.json() : null; + } catch (err) { + setError(err.message); + throw err; + } finally { + setLoading(false); + } + }, + [token] + ); + + return { loading, error, request: authorizedFetch }; +} diff --git a/frontend/lib/api.js b/frontend/lib/api.js new file mode 100644 index 0000000..d70b636 --- /dev/null +++ b/frontend/lib/api.js @@ -0,0 +1,48 @@ +const API_BASE = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, '') || 'http://localhost:8000'; + +async function request(path, init = {}) { + const response = await fetch(`${API_BASE}${path}`, { + headers: { + 'Content-Type': 'application/json', + ...(init.headers || {}), + }, + ...init, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Request failed: ${response.status} ${text}`); + } + + return response.json(); +} + +export function buildMediaUrl(path) { + if (!path) return null; + if (path.startsWith('http')) return path; + return `${API_BASE}${path}`; +} + +export async function fetchProducts() { + return request('/api/commerce/products/'); +} + +export async function fetchProduct(slug) { + if (!slug) throw new Error('Product slug is required'); + return request(`/api/commerce/products/${slug}/`); +} + +export async function fetchCategories() { + return request('/api/commerce/categories/'); +} + +export async function fetchDeliveryZones() { + return request('/api/commerce/delivery-zones/'); +} + +export async function submitOrder(payload) { + return request('/api/commerce/orders/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..4675f02 --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + experimental: { + appDir: false, + }, +}; + +module.exports = nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b436dc1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,17 @@ +{ + "name": "fullmooncgpt-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fullmooncgpt-frontend", + "version": "0.1.0", + "dependencies": { + "next": "14.2.0", + "react": "18.2.0", + "react-dom": "18.2.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e3ce864 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,15 @@ +{ + "name": "fullmooncgpt-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "14.2.0", + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/frontend/pages/_app.js b/frontend/pages/_app.js new file mode 100644 index 0000000..264a952 --- /dev/null +++ b/frontend/pages/_app.js @@ -0,0 +1,11 @@ +import '../styles/globals.css'; + +import { CartProvider } from '../context/CartContext'; + +export default function App({ Component, pageProps }) { + return ( + + + + ); +} diff --git a/frontend/pages/admin/index.js b/frontend/pages/admin/index.js new file mode 100644 index 0000000..e1f419a --- /dev/null +++ b/frontend/pages/admin/index.js @@ -0,0 +1,56 @@ +import Head from 'next/head'; + +import { AdminLayout } from '../../components/AdminLayout'; + +const cards = [ + { + title: 'Menu builder', + body: 'Create offerings, upload photos, and set inventory caps without leaving the page.', + }, + { + title: 'Orders & pick tickets', + body: 'Review today’s bake list, tap through stages, and open iPad-friendly pick tickets.', + }, + { + title: 'Inventory & ingredients', + body: 'Track flour, butter, and fillings with automatic depletion and restock alerts.', + }, + { + title: 'Analytics', + body: 'See weekly order trends, best sellers, and forecast upcoming bake volumes.', + }, +]; + +export default function AdminHome() { + return ( + <> + + Full Moon Bakehouse · Admin + + +
+

+ Built for a one-woman bakehouse: fast edits, real-time inventory, and workflows that + translate beautifully to an iPad on the counter. +

+ +
+ {cards.map((card) => ( +
+
+

{card.title}

+
+

{card.body}

+
+ ))} +
+ +

+ Authentication and role-aware dashboards land alongside the order board—this page is a + stub until those pieces go live. +

+
+
+ + ); +} diff --git a/frontend/pages/admin/menu/index.js b/frontend/pages/admin/menu/index.js new file mode 100644 index 0000000..a9cf17c --- /dev/null +++ b/frontend/pages/admin/menu/index.js @@ -0,0 +1,457 @@ +import { useEffect, useMemo, useState } from 'react'; +import Head from 'next/head'; + +import { AdminLayout } from '../../../components/AdminLayout'; +import { useAdminApi } from '../../../hooks/useAdminApi'; + +const LOCAL_TOKEN_KEY = 'fullmoon_admin_token'; + +export default function AdminMenu() { + const [token, setToken] = useState(''); + const [products, setProducts] = useState([]); + const [categories, setCategories] = useState([]); + const [isEditing, setIsEditing] = useState(false); + const [formState, setFormState] = useState(initialProductState); + + useEffect(() => { + if (typeof window !== 'undefined') { + const stored = window.localStorage.getItem(LOCAL_TOKEN_KEY); + if (stored) { + setToken(stored); + } + } + }, []); + + const { request, loading, error } = useAdminApi(token); + + useEffect(() => { + if (!token) return; + + async function loadData() { + try { + const [productsData, categoriesData] = await Promise.all([ + request('/api/commerce/admin/products/'), + request('/api/commerce/admin/categories/'), + ]); + setProducts(productsData); + setCategories(categoriesData); + } catch (err) { + console.error(err); + } + } + + loadData(); + }, [token, request]); + + const featuredProducts = useMemo( + () => products.filter((product) => product.is_featured), + [products] + ); + + const handleSubmit = async (event) => { + event.preventDefault(); + try { + const payload = buildPayload(formState); + const url = isEditing + ? `/api/commerce/admin/products/${formState.id}/` + : '/api/commerce/admin/products/'; + const method = isEditing ? 'PUT' : 'POST'; + const saved = await request(url, { + method, + body: JSON.stringify(payload), + }); + + setProducts((prev) => { + if (isEditing) { + return prev.map((product) => (product.id === saved.id ? saved : product)); + } + return [saved, ...prev]; + }); + + resetForm(); + } catch (err) { + console.error(err); + } + }; + + const handleEdit = (product) => { + setIsEditing(true); + setFormState(mapProductToForm(product)); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const handleDelete = async (productId) => { + if (!window.confirm('Delete this product?')) return; + try { + await request(`/api/commerce/admin/products/${productId}/`, { + method: 'DELETE', + }); + setProducts((prev) => prev.filter((product) => product.id !== productId)); + } catch (err) { + console.error(err); + } + }; + + const resetForm = () => { + setIsEditing(false); + setFormState(initialProductState); + }; + + if (!token) { + return ; + } + + return ( + <> + + Menu · Admin · Full Moon Bakehouse + + +
+
+
+
Create offering
+

+ {isEditing ? 'Update product' : 'Publish new product'} +

+

+ Add breads, bagels, sweets, and weekly specials. Quantities decrement automatically + with each order so you never oversell. +

+
+ +
+ + + + +