chore: initial import
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.DS_Store
|
||||||
20
AGENTS.md
Normal file
@@ -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_<module>.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.
|
||||||
17
DREAM_FEATURES.md
Normal file
@@ -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.
|
||||||
26
README.md
Normal file
@@ -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.
|
||||||
29
ROADMAP.md
Normal file
@@ -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.
|
||||||
21
TODO.md
Normal file
@@ -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.
|
||||||
12
WISHLIST.md
Normal file
@@ -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.
|
||||||
7
api/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.env
|
||||||
|
venv
|
||||||
|
.django
|
||||||
21
api/Dockerfile
Normal file
@@ -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"]
|
||||||
30
api/README.md
Normal file
@@ -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.
|
||||||
1
api/apps/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Package marker for Django apps.
|
||||||
2
api/apps/commerce/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Default app configuration
|
||||||
|
default_app_config = "apps.commerce.apps.CommerceConfig"
|
||||||
87
api/apps/commerce/admin.py
Normal file
@@ -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",
|
||||||
|
)
|
||||||
7
api/apps/commerce/apps.py
Normal file
@@ -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"
|
||||||
267
api/apps/commerce/migrations/0001_initial.py
Normal file
@@ -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"]},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
api/apps/commerce/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Package marker for migrations.
|
||||||
198
api/apps/commerce/models.py
Normal file
@@ -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}"
|
||||||
253
api/apps/commerce/serializers.py
Normal file
@@ -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",
|
||||||
|
)
|
||||||
34
api/apps/commerce/urls.py
Normal file
@@ -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)),
|
||||||
|
]
|
||||||
82
api/apps/commerce/views.py
Normal file
@@ -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]
|
||||||
BIN
api/celerybeat-schedule
Normal file
3
api/config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .celery_app import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ("celery_app",)
|
||||||
7
api/config/asgi.py
Normal file
@@ -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()
|
||||||
9
api/config/celery_app.py
Normal file
@@ -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()
|
||||||
123
api/config/settings.py
Normal file
@@ -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"
|
||||||
14
api/config/urls.py
Normal file
@@ -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)
|
||||||
7
api/config/wsgi.py
Normal file
@@ -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()
|
||||||
21
api/manage.py
Normal file
@@ -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()
|
||||||
8
api/requirements.txt
Normal file
@@ -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
|
||||||
113
docker-compose.yml
Normal file
@@ -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:
|
||||||
3
frontend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
npm-debug.log
|
||||||
5
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
npm-debug.log
|
||||||
|
.DS_Store
|
||||||
14
frontend/Dockerfile
Normal file
@@ -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"]
|
||||||
20
frontend/README.md
Normal file
@@ -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.
|
||||||
39
frontend/components/AdminLayout.js
Normal file
@@ -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 (
|
||||||
|
<div className="admin-shell">
|
||||||
|
<div className="admin-container">
|
||||||
|
<header style={{ marginBottom: '2.5rem' }}>
|
||||||
|
<div className="admin-badge">Full Moon Bakehouse Operations</div>
|
||||||
|
<h1 className="admin-section-title">{title}</h1>
|
||||||
|
<nav className="admin-nav" style={{ marginTop: '1.2rem' }}>
|
||||||
|
{navLinks.map((link) => {
|
||||||
|
const isActive = router.pathname.startsWith(link.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={isActive ? 'active' : 'inactive'}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<section className="admin-card">{children}</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/components/FaqSection.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export function FaqSection({ items }) {
|
||||||
|
if (!items?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="faq-grid">
|
||||||
|
{items.map((item) => (
|
||||||
|
<article key={item.question} className="faq-item">
|
||||||
|
<h4>{item.question}</h4>
|
||||||
|
<p>{item.answer}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/components/ProductCard.js
Normal file
@@ -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 (
|
||||||
|
<article className="product-card">
|
||||||
|
<div className="product-card__image">
|
||||||
|
<img src={imageUrl} alt={product?.name || 'Bakehouse item'} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>{product?.name || 'Menu item'}</h3>
|
||||||
|
{product?.short_description ? <p>{product.short_description}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="product-card__meta">
|
||||||
|
<span>${price}</span>
|
||||||
|
<span>{product?.inventory_quantity ?? '—'} available</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="product-card__tags">
|
||||||
|
<span>Max {product?.max_per_order ?? '—'} / order</span>
|
||||||
|
{product?.allergens ? <span>{product.allergens}</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="product-card__actions">
|
||||||
|
{canPurchase ? (
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => addItem(product)}>
|
||||||
|
Add to cart
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="sample-tag">Sample listing</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasDetail ? (
|
||||||
|
<Link href={`/products/${product.slug}`}>View details</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/components/SiteLayout.js
Normal file
@@ -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 (
|
||||||
|
<div className="site-shell">
|
||||||
|
<header className="site-header">
|
||||||
|
<div className="site-header__inner">
|
||||||
|
<Link href="/" className="logo">
|
||||||
|
<img src="/logo.png" alt="Full Moon Bakehouse" />
|
||||||
|
<span>Full Moon Bakehouse</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="nav-links">
|
||||||
|
{NAV_LINKS.map((link) => {
|
||||||
|
const isActive = router.pathname === link.href || router.pathname.startsWith(`${link.href}/`);
|
||||||
|
const isCart = link.href === '/cart';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={link.href} href={link.href} className={isActive ? 'active' : ''}>
|
||||||
|
{link.label}
|
||||||
|
{isCart && itemCount > 0 ? <span className="badge">{itemCount}</span> : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
|
<Link href="/admin" className="ghost-btn">
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="site-main">{children}</main>
|
||||||
|
|
||||||
|
<footer className="site-footer">
|
||||||
|
<div className="site-footer__inner">
|
||||||
|
<div>
|
||||||
|
<strong>Full Moon Bakehouse</strong>
|
||||||
|
<p>Eau Claire & Altoona, Wisconsin</p>
|
||||||
|
</div>
|
||||||
|
<div className="footer-links">
|
||||||
|
<Link href="mailto:bonna@fullmoonbakehouse.com">bonna@fullmoonbakehouse.com</Link>
|
||||||
|
<Link href="https://www.instagram.com" target="_blank" rel="noreferrer">
|
||||||
|
Instagram
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<span className="copyright">© {new Date().getFullYear()} Full Moon Bakehouse</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
frontend/context/CartContext.js
Normal file
@@ -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 <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCart() {
|
||||||
|
const context = useContext(CartContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCart must be used within a CartProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
41
frontend/hooks/useAdminApi.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
48
frontend/lib/api.js
Normal file
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
9
frontend/next.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
appDir: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
17
frontend/package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/pages/_app.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import '../styles/globals.css';
|
||||||
|
|
||||||
|
import { CartProvider } from '../context/CartContext';
|
||||||
|
|
||||||
|
export default function App({ Component, pageProps }) {
|
||||||
|
return (
|
||||||
|
<CartProvider>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</CartProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/pages/admin/index.js
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Full Moon Bakehouse · Admin</title>
|
||||||
|
</Head>
|
||||||
|
<AdminLayout title="Operations dashboard">
|
||||||
|
<div className="admin-section">
|
||||||
|
<p className="admin-muted" style={{ maxWidth: '540px' }}>
|
||||||
|
Built for a one-woman bakehouse: fast edits, real-time inventory, and workflows that
|
||||||
|
translate beautifully to an iPad on the counter.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="admin-products-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<article key={card.title} className="admin-product-card">
|
||||||
|
<header>
|
||||||
|
<h3>{card.title}</h3>
|
||||||
|
</header>
|
||||||
|
<p>{card.body}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="empty-state" style={{ textAlign: 'left' }}>
|
||||||
|
Authentication and role-aware dashboards land alongside the order board—this page is a
|
||||||
|
stub until those pieces go live.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
457
frontend/pages/admin/menu/index.js
Normal file
@@ -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 <AdminTokenPrompt onSubmit={setToken} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Menu · Admin · Full Moon Bakehouse</title>
|
||||||
|
</Head>
|
||||||
|
<AdminLayout title="Menu manager">
|
||||||
|
<div className="admin-section">
|
||||||
|
<section>
|
||||||
|
<header>
|
||||||
|
<div className="admin-badge">Create offering</div>
|
||||||
|
<h2 className="admin-section-title" style={{ marginTop: '1rem' }}>
|
||||||
|
{isEditing ? 'Update product' : 'Publish new product'}
|
||||||
|
</h2>
|
||||||
|
<p className="form-subtext">
|
||||||
|
Add breads, bagels, sweets, and weekly specials. Quantities decrement automatically
|
||||||
|
with each order so you never oversell.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="form-grid two-column">
|
||||||
|
<label className="form-field">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={formState.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((state) => ({ ...state, name: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Category
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={formState.category_id || ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((state) => ({
|
||||||
|
...state,
|
||||||
|
category_id: event.target.value || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Uncategorized</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
Short description
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
className="textarea"
|
||||||
|
value={formState.short_description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((state) => ({
|
||||||
|
...state,
|
||||||
|
short_description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
Description
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
className="textarea"
|
||||||
|
value={formState.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((state) => ({
|
||||||
|
...state,
|
||||||
|
description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Price ($)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
className="input"
|
||||||
|
value={formState.price}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((state) => ({ ...state, price: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Inventory on hand
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
className="input"
|
||||||
|
value={formState.inventory_quantity}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((state) => ({
|
||||||
|
...state,
|
||||||
|
inventory_quantity: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Max per order
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
className="input"
|
||||||
|
value={formState.max_per_order}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((state) => ({
|
||||||
|
...state,
|
||||||
|
max_per_order: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Allergens (comma separated)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={formState.allergens}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((state) => ({
|
||||||
|
...state,
|
||||||
|
allergens: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
Ingredients
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
className="textarea"
|
||||||
|
value={formState.ingredients}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((state) => ({
|
||||||
|
...state,
|
||||||
|
ingredients: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="form-grid" style={{ gridColumn: '1 / -1', gap: '0.75rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={formState.is_active}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((state) => ({
|
||||||
|
...state,
|
||||||
|
is_active: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Visible on storefront
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={formState.is_featured}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((state) => ({
|
||||||
|
...state,
|
||||||
|
is_featured: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Featured on homepage
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions" style={{ gridColumn: '1 / -1', marginTop: '0.5rem' }}>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||||
|
{isEditing ? 'Save changes' : 'Publish product'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={resetForm}>
|
||||||
|
Reset form
|
||||||
|
</button>
|
||||||
|
{loading ? <span className="admin-muted">Saving...</span> : null}
|
||||||
|
{error ? <span style={{ color: '#b91c1c' }}>{error}</span> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="section-header" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontFamily: 'Fraunces, serif', fontSize: '2rem', margin: 0 }}>
|
||||||
|
Current offerings
|
||||||
|
</h2>
|
||||||
|
<p className="admin-muted" style={{ marginTop: '0.35rem' }}>
|
||||||
|
{products.length} product{products.length === 1 ? '' : 's'} · {featuredProducts.length}{' '}
|
||||||
|
featured
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-products-grid">
|
||||||
|
{products.map((product) => (
|
||||||
|
<article key={product.id} className="admin-product-card">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h3>{product.name}</h3>
|
||||||
|
<span className="admin-muted">
|
||||||
|
{(product.category && product.category.name) || 'Uncategorized'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<strong style={{ fontSize: '1.1rem' }}>${Number(product.price).toFixed(2)}</strong>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{product.short_description ? <p>{product.short_description}</p> : null}
|
||||||
|
|
||||||
|
<div className="admin-product-meta">
|
||||||
|
<span>
|
||||||
|
<strong>{product.inventory_quantity}</strong> units available · max{' '}
|
||||||
|
{product.max_per_order} per order
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Visibility: {product.is_active ? 'Storefront' : 'Hidden'} · Featured:{' '}
|
||||||
|
{product.is_featured ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-product-actions">
|
||||||
|
<button type="button" className="btn-small primary" onClick={() => handleEdit(product)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-small danger" onClick={() => handleDelete(product.id)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!products.length ? (
|
||||||
|
<div className="empty-state" style={{ marginTop: '1.5rem' }}>
|
||||||
|
No products yet. Use the form above to add your first offering.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminTokenPrompt({ onSubmit }) {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Authenticate · Admin · Full Moon Bakehouse</title>
|
||||||
|
</Head>
|
||||||
|
<AdminLayout title="Authenticate">
|
||||||
|
<div style={{ display: 'grid', gap: '1.4rem' }}>
|
||||||
|
<p className="admin-muted">
|
||||||
|
Paste your API token to unlock the admin tools. Generate one via Django Admin → Tokens.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
style={{ display: 'grid', gap: '1rem', maxWidth: '420px' }}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(LOCAL_TOKEN_KEY, value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
placeholder="Paste token here"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ width: 'fit-content' }}>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialProductState = {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
category_id: null,
|
||||||
|
short_description: '',
|
||||||
|
description: '',
|
||||||
|
price: '',
|
||||||
|
inventory_quantity: 0,
|
||||||
|
max_per_order: 6,
|
||||||
|
allergens: '',
|
||||||
|
ingredients: '',
|
||||||
|
is_active: true,
|
||||||
|
is_featured: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapProductToForm(product) {
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
category_id: product.category?.id || null,
|
||||||
|
short_description: product.short_description || '',
|
||||||
|
description: product.description || '',
|
||||||
|
price: product.price,
|
||||||
|
inventory_quantity: product.inventory_quantity,
|
||||||
|
max_per_order: product.max_per_order,
|
||||||
|
allergens: product.allergens || '',
|
||||||
|
ingredients: product.ingredients || '',
|
||||||
|
is_active: product.is_active,
|
||||||
|
is_featured: product.is_featured,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(state) {
|
||||||
|
return {
|
||||||
|
name: state.name,
|
||||||
|
short_description: state.short_description,
|
||||||
|
description: state.description,
|
||||||
|
price: state.price,
|
||||||
|
inventory_quantity: state.inventory_quantity,
|
||||||
|
max_per_order: state.max_per_order,
|
||||||
|
allergens: state.allergens,
|
||||||
|
ingredients: state.ingredients,
|
||||||
|
is_active: state.is_active,
|
||||||
|
is_featured: state.is_featured,
|
||||||
|
category_id: state.category_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
336
frontend/pages/cart.js
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
import SiteLayout from '../components/SiteLayout';
|
||||||
|
import { submitOrder } from '../lib/api';
|
||||||
|
import { useCart } from '../context/CartContext';
|
||||||
|
|
||||||
|
const INITIAL_CUSTOMER = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
fulfillment_method: 'pickup',
|
||||||
|
scheduled_date: '',
|
||||||
|
scheduled_time_slot: '',
|
||||||
|
delivery_address_line1: '',
|
||||||
|
delivery_city: '',
|
||||||
|
delivery_state: 'WI',
|
||||||
|
delivery_postal_code: '',
|
||||||
|
override_code: '',
|
||||||
|
notes: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CartPage() {
|
||||||
|
const { items, updateQuantity, removeItem, clearCart, subtotal } = useCart();
|
||||||
|
const [customer, setCustomer] = useState(INITIAL_CUSTOMER);
|
||||||
|
const [notice, setNotice] = useState(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [confirmation, setConfirmation] = useState(null);
|
||||||
|
const timeoutRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||||
|
|
||||||
|
const showNotice = (message, variant = 'info') => {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
setNotice({ message, variant });
|
||||||
|
timeoutRef.current = setTimeout(() => setNotice(null), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuantity = (id, delta) => {
|
||||||
|
const entry = items.find((item) => item.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
const limit = Number.isFinite(entry.limit) ? entry.limit : Infinity;
|
||||||
|
const next = Math.max(0, Math.min(entry.quantity + delta, limit));
|
||||||
|
updateQuantity(id, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deliveryFee = customer.fulfillment_method === 'delivery' ? 5 : 0;
|
||||||
|
const total = subtotal + deliveryFee;
|
||||||
|
const hasCartItems = items.length > 0;
|
||||||
|
|
||||||
|
const updateCustomerField = (field, value) => {
|
||||||
|
setCustomer((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!hasCartItems) {
|
||||||
|
showNotice('Add at least one item to your cart before checking out.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customer.name.trim() || !customer.email.trim()) {
|
||||||
|
showNotice('Name and email are required.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customer.fulfillment_method === 'delivery') {
|
||||||
|
if (!customer.delivery_address_line1.trim() || !customer.delivery_city.trim() || !customer.delivery_postal_code.trim()) {
|
||||||
|
showNotice('Delivery address, city, and postal code are required for delivery orders.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.some((item) => !item.id)) {
|
||||||
|
showNotice('Cart contains placeholder items. Add products from the live menu once available.', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
customer_name: customer.name.trim(),
|
||||||
|
customer_email: customer.email.trim(),
|
||||||
|
customer_phone: customer.phone.trim(),
|
||||||
|
fulfillment_method: customer.fulfillment_method,
|
||||||
|
scheduled_date: customer.scheduled_date || null,
|
||||||
|
scheduled_time_slot: customer.scheduled_time_slot,
|
||||||
|
delivery_address_line1:
|
||||||
|
customer.fulfillment_method === 'delivery' ? customer.delivery_address_line1.trim() : '',
|
||||||
|
delivery_address_line2: '',
|
||||||
|
delivery_city: customer.fulfillment_method === 'delivery' ? customer.delivery_city.trim() : '',
|
||||||
|
delivery_state: customer.fulfillment_method === 'delivery' ? customer.delivery_state.trim() : '',
|
||||||
|
delivery_postal_code:
|
||||||
|
customer.fulfillment_method === 'delivery' ? customer.delivery_postal_code.trim() : '',
|
||||||
|
delivery_override_code: customer.override_code.trim() || null,
|
||||||
|
notes: customer.notes,
|
||||||
|
items: items.map((item) => ({ product: item.id, quantity: item.quantity })),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const order = await submitOrder(payload);
|
||||||
|
setConfirmation(order.id);
|
||||||
|
clearCart();
|
||||||
|
setCustomer(INITIAL_CUSTOMER);
|
||||||
|
showNotice('Order received! Check your email for confirmation.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showNotice('We were unable to place your order. Please try again.', 'error');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteLayout>
|
||||||
|
<Head>
|
||||||
|
<title>Cart · Full Moon Bakehouse</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<div className="wrapper cart-page">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Your cart</h2>
|
||||||
|
<p>Add or adjust items, then submit your preorder request.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notice ? <div className={`flash ${notice.variant}`}>{notice.message}</div> : null}
|
||||||
|
|
||||||
|
<div className="cart-layout">
|
||||||
|
<div className="cart-card">
|
||||||
|
<div className="cart-header">
|
||||||
|
<h3>Items</h3>
|
||||||
|
{hasCartItems ? (
|
||||||
|
<button className="btn-small danger" type="button" onClick={clearCart}>
|
||||||
|
Clear cart
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasCartItems ? (
|
||||||
|
<div className="cart-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="cart-item">
|
||||||
|
<div className="cart-item__row">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<span>${(item.price * item.quantity).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="cart-item__controls">
|
||||||
|
<button className="stepper" type="button" onClick={() => handleQuantity(item.id, -1)}>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<span>{item.quantity}</span>
|
||||||
|
<button className="stepper" type="button" onClick={() => handleQuantity(item.id, 1)}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button className="btn-small danger" type="button" onClick={() => removeItem(item.id)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{Number.isFinite(item.limit) ? (
|
||||||
|
<small className="cart-note">Max {item.limit} per order.</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="cart-empty">Your cart is empty. Browse the menu to get started.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="cart-summary">
|
||||||
|
<div className="cart-item__row">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>${subtotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="cart-item__row" style={{ fontWeight: 500 }}>
|
||||||
|
<span>Delivery fee</span>
|
||||||
|
<span>{customer.fulfillment_method === 'delivery' ? '$5.00' : '$0.00'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="cart-item__row" style={{ fontSize: '1.1rem' }}>
|
||||||
|
<span>Total</span>
|
||||||
|
<span>${total.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="cart-card checkout-form" onSubmit={handleSubmit}>
|
||||||
|
<h3>Checkout</h3>
|
||||||
|
{confirmation ? (
|
||||||
|
<div className="notice success">Order #{confirmation} received!</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="form-grid two-column">
|
||||||
|
<label className="form-field">
|
||||||
|
Name *
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={customer.name}
|
||||||
|
onChange={(event) => updateCustomerField('name', event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Email *
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="input"
|
||||||
|
value={customer.email}
|
||||||
|
onChange={(event) => updateCustomerField('email', event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Phone
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={customer.phone}
|
||||||
|
onChange={(event) => updateCustomerField('phone', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Preferred pickup date
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input"
|
||||||
|
value={customer.scheduled_date}
|
||||||
|
onChange={(event) => updateCustomerField('scheduled_date', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Time window
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="e.g., Thu 5:30-5:45pm"
|
||||||
|
value={customer.scheduled_time_slot}
|
||||||
|
onChange={(event) => updateCustomerField('scheduled_time_slot', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="radio-group">
|
||||||
|
<span>Fulfillment method</span>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="fulfillment_method"
|
||||||
|
value="pickup"
|
||||||
|
checked={customer.fulfillment_method === 'pickup'}
|
||||||
|
onChange={(event) => updateCustomerField('fulfillment_method', event.target.value)}
|
||||||
|
/>{' '}
|
||||||
|
Pickup (free)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="fulfillment_method"
|
||||||
|
value="delivery"
|
||||||
|
checked={customer.fulfillment_method === 'delivery'}
|
||||||
|
onChange={(event) => updateCustomerField('fulfillment_method', event.target.value)}
|
||||||
|
/>{' '}
|
||||||
|
Delivery in Eau Claire or Altoona ($5)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customer.fulfillment_method === 'delivery' ? (
|
||||||
|
<div className="form-grid two-column">
|
||||||
|
<label className="form-field">
|
||||||
|
Address line 1 *
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={customer.delivery_address_line1}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateCustomerField('delivery_address_line1', event.target.value)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
City *
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={customer.delivery_city}
|
||||||
|
onChange={(event) => updateCustomerField('delivery_city', event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
State
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={customer.delivery_state}
|
||||||
|
onChange={(event) => updateCustomerField('delivery_state', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Postal code *
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={customer.delivery_postal_code}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateCustomerField('delivery_postal_code', event.target.value)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Secret friend code
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Optional override code"
|
||||||
|
value={customer.override_code}
|
||||||
|
onChange={(event) => updateCustomerField('override_code', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Notes
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
className="textarea"
|
||||||
|
placeholder="Allergies, delivery notes, or special requests."
|
||||||
|
value={customer.notes}
|
||||||
|
onChange={(event) => updateCustomerField('notes', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={submitting || !hasCartItems}>
|
||||||
|
{submitting ? 'Submitting…' : 'Place order'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SiteLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
frontend/pages/index.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import ProductCard from '../components/ProductCard';
|
||||||
|
import SiteLayout from '../components/SiteLayout';
|
||||||
|
import { buildMediaUrl, fetchProducts } from '../lib/api';
|
||||||
|
|
||||||
|
const SAMPLE_PRODUCTS = [
|
||||||
|
{
|
||||||
|
id: null,
|
||||||
|
slug: 'sprouted-heritage-loaf',
|
||||||
|
name: 'Sprouted Heritage Loaf',
|
||||||
|
short_description: 'Organic, freshly milled grains with a caramelized crust and pillowy crumb.',
|
||||||
|
price: 12,
|
||||||
|
inventory_quantity: 8,
|
||||||
|
max_per_order: 2,
|
||||||
|
allergens: 'Contains: Wheat, Dairy',
|
||||||
|
imageUrl:
|
||||||
|
'https://images.unsplash.com/photo-1608198093002-ad4e005484ec?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
isSample: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: null,
|
||||||
|
slug: 'malted-maple-bagels',
|
||||||
|
name: 'Malted Maple Bagels',
|
||||||
|
short_description: 'Slow-fermented bagels with malted barley glaze and maple butter finish.',
|
||||||
|
price: 18,
|
||||||
|
inventory_quantity: 12,
|
||||||
|
max_per_order: 3,
|
||||||
|
allergens: 'Contains: Wheat',
|
||||||
|
imageUrl:
|
||||||
|
'https://images.unsplash.com/photo-1589367920476-0be109c12e82?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
isSample: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: null,
|
||||||
|
slug: 'moonbeam-cookie-flight',
|
||||||
|
name: 'Moonbeam Cookie Flight',
|
||||||
|
short_description: 'Rotating selection of award-winning cookies in seasonal flavors.',
|
||||||
|
price: 16,
|
||||||
|
inventory_quantity: 15,
|
||||||
|
max_per_order: 4,
|
||||||
|
allergens: 'Contains: Wheat, Eggs, Dairy',
|
||||||
|
imageUrl:
|
||||||
|
'https://images.unsplash.com/photo-1499636136210-6f4ee915583e?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
isSample: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FAQS = [
|
||||||
|
{
|
||||||
|
question: 'Do you offer gluten free or vegan baked goods?',
|
||||||
|
answer:
|
||||||
|
'At this time, we do not offer any gluten free baked goods. Some items are naturally vegan friendly. If you have any dietary or allergy concerns, please reach out before placing an order.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Where do you deliver to?',
|
||||||
|
answer:
|
||||||
|
'We currently deliver to Eau Claire and Altoona, Wisconsin. Need an out-of-area drop-off? Use the secret friend code and I’ll reach out personally to coordinate.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'What whole grains do you use in your fresh milled flour?',
|
||||||
|
answer:
|
||||||
|
'Our exact proportions are proprietary, but we regularly mill the following grains:\n- Hard Red Wheat Berries (varying varietals)\n- Soft Red Wheat Berries (varying varietals)\n- Buckwheat\n\nAnd we are always experimenting with other grains and blends. NOTE: If you have a dietary or allergy concern, please reach out before placing an order.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'What are sprouted grains, and why should I eat them?',
|
||||||
|
answer:
|
||||||
|
'Sprouted grains are whole grains that are allowed to just begin sprouting before they are dehydrated and milled. When a grain sprouts, natural enzymes break down starches and proteins, unlocking flavor and nutrition. Sprouted grain flours are often easier to digest, lower on the glycemic index, and richer in vitamins, minerals, and antioxidants than unsprouted flour.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "What's a CSB?",
|
||||||
|
answer:
|
||||||
|
'A Community Supported Bakery share works just like a CSA—pre-purchase 4–8 weeks of weekly boxes and receive breads, bagels, and sweets on a set schedule.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeProducts(products = []) {
|
||||||
|
if (!products.length) {
|
||||||
|
return SAMPLE_PRODUCTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return products.map((product) => ({
|
||||||
|
...product,
|
||||||
|
imageUrl: buildMediaUrl(product.hero_image) || SAMPLE_PRODUCTS[0].imageUrl,
|
||||||
|
isSample: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home({ products }) {
|
||||||
|
const displayProducts = normalizeProducts(products);
|
||||||
|
const featuredProducts = displayProducts.filter((product) => product.is_featured).slice(0, 3);
|
||||||
|
const highlight = featuredProducts.length ? featuredProducts : displayProducts.slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteLayout>
|
||||||
|
<Head>
|
||||||
|
<title>Full Moon Bakehouse · Eau Claire Microbakery</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Sprouted grain breads, laminated pastries, and microbatch sweets crafted by Bonna Moon in Eau Claire, WI."
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<div className="wrapper">
|
||||||
|
<section className="hero">
|
||||||
|
<div>
|
||||||
|
<h1>Sprouted grain breads & sweets baked under the Full Moon.</h1>
|
||||||
|
<p>
|
||||||
|
From award-winning cookies to laminated croissants, every bake starts with organic
|
||||||
|
midwest grains sprouted and milled in-house. Reserve your favorites for pickup or
|
||||||
|
delivery in Eau Claire and Altoona.
|
||||||
|
</p>
|
||||||
|
<div className="hero-cta">
|
||||||
|
<Link className="btn btn-primary" href="/products">
|
||||||
|
Browse products
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline" href="/cart">
|
||||||
|
View cart & checkout
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className="product-detail__gallery">
|
||||||
|
<h3>How it works</h3>
|
||||||
|
<p>
|
||||||
|
Preorders open Saturday morning and close Monday afternoon. Choose a pickup window on
|
||||||
|
Thursday evening or add $5 contactless delivery within Eau Claire and Altoona on
|
||||||
|
Thursday or Friday afternoon.
|
||||||
|
</p>
|
||||||
|
<div className="product-detail__meta">
|
||||||
|
<span>• Pickup: Thursday evening (15 minute windows)</span>
|
||||||
|
<span>• Delivery: Thu or Fri · Eau Claire & Altoona ($5)</span>
|
||||||
|
<span>• Preorder window: Sat morning → Mon afternoon</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<div className="wrapper">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Fresh from the bakehouse</h2>
|
||||||
|
<p>
|
||||||
|
Small-batch menus rotate weekly. These highlights rotate in and out depending on the
|
||||||
|
season and the grains that inspire us.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link className="btn btn-outline" href="/products">
|
||||||
|
See all offerings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="product-grid">
|
||||||
|
{highlight.map((product) => (
|
||||||
|
<ProductCard key={product.slug || product.name} product={product} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<div className="wrapper">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>How to order</h2>
|
||||||
|
<p>Pick the flow that fits your schedule right now or join the CSB launch list.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="product-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))' }}>
|
||||||
|
<article className="product-card">
|
||||||
|
<h3>Option 1 · Weekly preorders</h3>
|
||||||
|
<p>
|
||||||
|
Pre-orders open Saturday morning and close Monday afternoon each week. Pickups happen
|
||||||
|
Thursday evening in 15 minute windows (address provided after purchase). Delivery is
|
||||||
|
available within Eau Claire & Altoona on Thursday or Friday afternoon for $5.
|
||||||
|
</p>
|
||||||
|
<div className="product-card__actions">
|
||||||
|
<Link className="btn btn-primary" href="/products">
|
||||||
|
Shop this week's menu
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="product-card">
|
||||||
|
<h3>Option 2 · CSB boxes</h3>
|
||||||
|
<p>
|
||||||
|
Community Supported Bakery (CSB) shares are coming soon. Each weekly box includes a
|
||||||
|
bread (loaf, pita, focaccia), a pack of bagels or English muffins, and a sweet treat.
|
||||||
|
Pricing operates on a sliding scale—pay a little more if you can to feed more
|
||||||
|
families.
|
||||||
|
</p>
|
||||||
|
<div className="product-card__actions">
|
||||||
|
<a
|
||||||
|
className="btn btn-outline"
|
||||||
|
href="https://www.fullmoonbakehouse.com/#newsletter"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Join the email list
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section" id="about">
|
||||||
|
<div className="wrapper">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Meet Bonna Moon</h2>
|
||||||
|
<p>Scratch baker, ceramicist, and late-night laminator.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article className="product-card" style={{ gridTemplateColumns: '1fr', gap: '1rem' }}>
|
||||||
|
<p>
|
||||||
|
I'm Bonna Moon—part-time baker, part-time artist working in ceramics, wood, and metal.
|
||||||
|
Full Moon Bakehouse is the microbakery I run from my Eau Claire kitchen, built on a
|
||||||
|
decade of dough experiments, award-winning cookies, and a deep love for sprouted
|
||||||
|
grains.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We sprout and mill our grains in-house, source ingredients with care, and craft every
|
||||||
|
bake to be something I'd be proud to feed to my own family. Join the email list for
|
||||||
|
preorder reminders, seasonal menus, and CSB launches.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section" id="faq">
|
||||||
|
<div className="wrapper">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>FAQ</h2>
|
||||||
|
<p>Your top questions about ingredients, deliveries, and the CSB shares.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="product-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
|
||||||
|
{FAQS.map((item) => (
|
||||||
|
<article key={item.question} className="product-card">
|
||||||
|
<h3>{item.question}</h3>
|
||||||
|
<p style={{ whiteSpace: 'pre-line' }}>{item.answer}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</SiteLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps() {
|
||||||
|
try {
|
||||||
|
const products = await fetchProducts();
|
||||||
|
return { props: { products } };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Home page data fallback', error);
|
||||||
|
return { props: { products: [] } };
|
||||||
|
}
|
||||||
|
}
|
||||||
95
frontend/pages/products/[slug].js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
import SiteLayout from '../../components/SiteLayout';
|
||||||
|
import { buildMediaUrl, fetchProduct, fetchProducts } from '../../lib/api';
|
||||||
|
import ProductCard from '../../components/ProductCard';
|
||||||
|
import { useCart } from '../../context/CartContext';
|
||||||
|
|
||||||
|
export default function ProductPage({ product, suggestions }) {
|
||||||
|
const { addItem } = useCart();
|
||||||
|
const imageUrl = buildMediaUrl(product.hero_image) || product.imageUrl;
|
||||||
|
const price = Number(product.price || 0).toFixed(2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteLayout>
|
||||||
|
<Head>
|
||||||
|
<title>{product.name} · Full Moon Bakehouse</title>
|
||||||
|
<meta name="description" content={product.short_description || product.description || ''} />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<div className="wrapper">
|
||||||
|
<section className="product-detail">
|
||||||
|
<div className="product-detail__gallery">
|
||||||
|
<img src={imageUrl} alt={product.name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="product-detail__info">
|
||||||
|
<button className="ghost-btn" onClick={() => history.back()} style={{ width: 'fit-content' }}>
|
||||||
|
← Back to products
|
||||||
|
</button>
|
||||||
|
<h1>{product.name}</h1>
|
||||||
|
<div className="product-detail__price">${price}</div>
|
||||||
|
{product.short_description ? (
|
||||||
|
<p className="product-detail__description">{product.short_description}</p>
|
||||||
|
) : null}
|
||||||
|
{product.description ? (
|
||||||
|
<p className="product-detail__description">{product.description}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="product-detail__meta">
|
||||||
|
<span>Inventory: {product.inventory_quantity ?? '—'} available</span>
|
||||||
|
<span>Max {product.max_per_order ?? '—'} per order</span>
|
||||||
|
{product.allergens ? <span>Allergens: {product.allergens}</span> : null}
|
||||||
|
{product.ingredients ? <span>Ingredients: {product.ingredients}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="product-card__actions">
|
||||||
|
<button className="btn btn-primary" type="button" onClick={() => addItem(product)}>
|
||||||
|
Add to cart
|
||||||
|
</button>
|
||||||
|
{Number.isFinite(product.max_per_order) ? (
|
||||||
|
<span className="cart-note">Max {product.max_per_order} per order</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{suggestions.length ? (
|
||||||
|
<section className="section" style={{ paddingTop: 0 }}>
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>More from this week's bake</h2>
|
||||||
|
<p>Round out your order with another loaf, bagel pack, or sweet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="product-grid">
|
||||||
|
{suggestions.map((item) => (
|
||||||
|
<ProductCard key={item.id || item.slug} product={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SiteLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps({ params }) {
|
||||||
|
const { slug } = params;
|
||||||
|
try {
|
||||||
|
const product = await fetchProduct(slug);
|
||||||
|
const products = await fetchProducts();
|
||||||
|
const suggestions = products
|
||||||
|
.filter((item) => item.slug !== slug)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((item) => ({ ...item, imageUrl: buildMediaUrl(item.hero_image), isSample: false }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
product: { ...product, imageUrl: buildMediaUrl(product.hero_image), isSample: false },
|
||||||
|
suggestions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Product detail fallback', error);
|
||||||
|
return { notFound: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
94
frontend/pages/products/index.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
import ProductCard from '../../components/ProductCard';
|
||||||
|
import SiteLayout from '../../components/SiteLayout';
|
||||||
|
import { buildMediaUrl, fetchCategories, fetchProducts } from '../../lib/api';
|
||||||
|
|
||||||
|
const SAMPLE_PRODUCTS = [];
|
||||||
|
|
||||||
|
function normalizeProducts(products = []) {
|
||||||
|
return products.map((product) => ({
|
||||||
|
...product,
|
||||||
|
imageUrl: buildMediaUrl(product.hero_image),
|
||||||
|
isSample: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductsPage({ products, categories }) {
|
||||||
|
const items = normalizeProducts(products);
|
||||||
|
|
||||||
|
const groups = categories.reduce((acc, category) => {
|
||||||
|
acc[category.id] = { category, items: [] };
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const uncategorized = [];
|
||||||
|
items.forEach((product) => {
|
||||||
|
const categoryId = product.category?.id;
|
||||||
|
if (categoryId && groups[categoryId]) {
|
||||||
|
groups[categoryId].items.push(product);
|
||||||
|
} else {
|
||||||
|
uncategorized.push(product);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderedGroups = Object.values(groups).filter((group) => group.items.length);
|
||||||
|
if (uncategorized.length) {
|
||||||
|
orderedGroups.push({
|
||||||
|
category: { name: 'Weekly specials' },
|
||||||
|
items: uncategorized,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasProducts = orderedGroups.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteLayout>
|
||||||
|
<Head>
|
||||||
|
<title>Products · Full Moon Bakehouse</title>
|
||||||
|
<meta name="description" content="Browse the current Full Moon Bakehouse offerings." />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<div className="wrapper">
|
||||||
|
<section className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Weekly menu</h2>
|
||||||
|
<p>
|
||||||
|
Availability updates in real time. Add items to your cart and checkout when you're
|
||||||
|
ready.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasProducts ? (
|
||||||
|
orderedGroups.map((group) => (
|
||||||
|
<div key={group.category.id || group.category.name} className="section" style={{ paddingTop: 0 }}>
|
||||||
|
<h3 className="section-title" style={{ fontFamily: 'Fraunces, serif', fontSize: '1.6rem' }}>
|
||||||
|
{group.category.name}
|
||||||
|
</h3>
|
||||||
|
<div className="product-grid">
|
||||||
|
{group.items.map((product) => (
|
||||||
|
<ProductCard key={product.id || product.slug} product={product} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flash info">Menu items will appear here once the API is populated.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</SiteLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps() {
|
||||||
|
try {
|
||||||
|
const [products, categories] = await Promise.all([fetchProducts(), fetchCategories()]);
|
||||||
|
return { props: { products, categories } };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Products page data fallback', error);
|
||||||
|
return { props: { products: SAMPLE_PRODUCTS, categories: [] } };
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/logo.png
Executable file
|
After Width: | Height: | Size: 43 KiB |
654
frontend/styles/globals.css
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Fraunces:wght@400;600;700&family=Work+Sans:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ink: #2d2a26;
|
||||||
|
--ink-soft: #4b4942;
|
||||||
|
--ink-muted: #77736a;
|
||||||
|
--cream: #f8f6f0;
|
||||||
|
--paper: #ffffff;
|
||||||
|
--accent: #8daabd;
|
||||||
|
--accent-dark: #5f7f96;
|
||||||
|
--butter: #d9b583;
|
||||||
|
--border: rgba(95, 113, 124, 0.16);
|
||||||
|
--shadow-soft: 0 24px 48px rgba(67, 60, 48, 0.14);
|
||||||
|
--shadow-card: 0 18px 36px rgba(62, 50, 26, 0.12);
|
||||||
|
font-family: 'Work Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background-color: var(--cream);
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--cream);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
background: rgba(248, 246, 240, 0.88);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.15rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a.active::after,
|
||||||
|
.nav-links a:hover::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -0.35rem;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
background: var(--accent-dark);
|
||||||
|
color: #fdfcfb;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.55rem 1.1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
transition: background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 3rem;
|
||||||
|
padding: 6rem 0 4rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: clamp(2.8rem, 4vw, 3.8rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-cta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.85rem 1.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 160ms ease, box-shadow 180ms ease, background 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||||||
|
color: #fdfcfb;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 24px 48px rgba(95, 127, 150, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 4rem 0 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 2.2rem;
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header p {
|
||||||
|
max-width: 34rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(141, 170, 189, 0.18);
|
||||||
|
color: var(--accent-dark);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 26px;
|
||||||
|
padding: 1.6rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__image {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 18px;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__meta,
|
||||||
|
.product-card__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__meta {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__tags span {
|
||||||
|
background: rgba(217, 181, 131, 0.25);
|
||||||
|
padding: 0.25rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__actions .btn {
|
||||||
|
padding: 0.6rem 1.3rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__actions a {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-tag {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice,
|
||||||
|
.flash {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.info,
|
||||||
|
.flash.info {
|
||||||
|
background: rgba(141, 170, 189, 0.18);
|
||||||
|
color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.success,
|
||||||
|
.flash.success {
|
||||||
|
background: rgba(217, 181, 131, 0.25);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.error,
|
||||||
|
.flash.error {
|
||||||
|
background: rgba(214, 75, 71, 0.1);
|
||||||
|
color: #8f1d1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: 3rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
padding: 4rem 0 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__gallery {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__info {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__info h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 2.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__price {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__description {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-page {
|
||||||
|
padding: 4rem 0 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-card {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 26px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 1.8rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__controls .stepper {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(233, 229, 220, 0.8);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__controls .btn-small {
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
background: rgba(214, 75, 71, 0.12);
|
||||||
|
color: #a42a27;
|
||||||
|
border: 1px solid rgba(214, 75, 71, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-note {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small.danger {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: rgba(214, 75, 71, 0.12);
|
||||||
|
color: #a42a27;
|
||||||
|
border: 1px solid rgba(214, 75, 71, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-summary small {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-form .form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-form .two-column {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.textarea,
|
||||||
|
.select {
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: rgba(248, 246, 240, 0.88);
|
||||||
|
padding: 3rem 0 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__inner {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(180deg, #f9f6ef 0%, #eef2f4 100%);
|
||||||
|
padding: 0 1.5rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1.5rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav a.active {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||||||
|
color: #fdfcfb;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 26px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: rgba(141, 170, 189, 0.18);
|
||||||
|
color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.site-header__inner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 4.5rem 0 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-cta {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail {
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
infra/docker/.env.development
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Shared secrets (development only)
|
||||||
|
POSTGRES_DB=fullmoon_dev
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
DJANGO_SECRET_KEY=dev-secret-key-change-me
|
||||||
|
DJANGO_DEBUG=1
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1,api-gpt
|
||||||
|
STRIPE_API_KEY=sk_test_placeholder
|
||||||
|
PUSHBULLET_API_KEY=pb_test_placeholder
|
||||||
|
MINIO_ROOT_USER=miniokey
|
||||||
|
MINIO_ROOT_PASSWORD=miniopassword
|
||||||
11
infra/docker/.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Shared secrets (development only)
|
||||||
|
POSTGRES_DB=fullmoon_dev
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
DJANGO_SECRET_KEY=dev-secret-key-change-me
|
||||||
|
DJANGO_DEBUG=1
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1,api-gpt
|
||||||
|
STRIPE_API_KEY=sk_test_placeholder
|
||||||
|
PUSHBULLET_API_KEY=pb_test_placeholder
|
||||||
|
MINIO_ROOT_USER=miniokey
|
||||||
|
MINIO_ROOT_PASSWORD=miniopassword
|
||||||
30
infra/docker/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
## Docker Stack (local development)
|
||||||
|
|
||||||
|
The compose stack defined in `docker-compose.yml` spins up the following services:
|
||||||
|
|
||||||
|
- `frontend-gpt`: Next.js dev server published on port 3001.
|
||||||
|
- `api-gpt`: Django API on port 8000.
|
||||||
|
- `worker-gpt` / `scheduler-gpt`: Celery worker and beat for background jobs.
|
||||||
|
- `db-gpt`: Postgres 15 with persisted data volume (host port 5433).
|
||||||
|
- `cache-gpt`: Redis 7 for caching and Celery broker.
|
||||||
|
- `storage-gpt`: MinIO object storage (S3-compatible) with console on port 9001.
|
||||||
|
|
||||||
|
### Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp infra/docker/.env.example infra/docker/.env.development # adjust secrets
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The containers mount the local `frontend/` and `api/` folders, so code changes reload automatically.
|
||||||
|
|
||||||
|
### Helper commands
|
||||||
|
|
||||||
|
- `docker compose exec api-gpt python manage.py migrate` — run database migrations.
|
||||||
|
- `docker compose exec api-gpt python manage.py createsuperuser` — create an admin account.
|
||||||
|
- `docker compose exec worker-gpt celery -A config.celery_app status` — confirm Celery connectivity.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- MinIO credentials come from `.env.development`; open http://localhost:9001 to manage buckets.
|
||||||
|
- Pushbullet and Stripe keys are placeholders. Update them before testing notifications or payments.
|
||||||
BIN
resources/Full Moon Bakehouse Logo/1.png
Executable file
|
After Width: | Height: | Size: 43 KiB |
BIN
resources/Full Moon Bakehouse Logo/10.png
Executable file
|
After Width: | Height: | Size: 50 KiB |
BIN
resources/Full Moon Bakehouse Logo/2.png
Executable file
|
After Width: | Height: | Size: 61 KiB |
BIN
resources/Full Moon Bakehouse Logo/3.png
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
resources/Full Moon Bakehouse Logo/5.png
Executable file
|
After Width: | Height: | Size: 60 KiB |
BIN
resources/Full Moon Bakehouse Logo/6.png
Executable file
|
After Width: | Height: | Size: 60 KiB |
BIN
resources/Full Moon Bakehouse Logo/7.png
Executable file
|
After Width: | Height: | Size: 33 KiB |
BIN
resources/Full Moon Bakehouse Logo/8.png
Executable file
|
After Width: | Height: | Size: 38 KiB |
BIN
resources/Full Moon Bakehouse Logo/9.png
Executable file
|
After Width: | Height: | Size: 49 KiB |
BIN
resources/Logs/Sprouting Dehydrating Milling Whole Grain Log.numbers
Executable file
BIN
resources/Recipes/Cookie Dough Base Master Recipe.pages
Executable file
BIN
resources/Recipes/Croissants Master Recipe.pages
Executable file
BIN
resources/Recipes/English Muffins.pages
Executable file
BIN
resources/Recipes/Frangipane .pages
Executable file
BIN
resources/Recipes/Marshmallow fluff.pages
Executable file
BIN
resources/Recipes/Master Bagel Recipe.pages
Executable file
BIN
resources/Recipes/Soft Ball Caramel.pages
Executable file
BIN
resources/Recipes/Sweet Rolls Master Recipe.pages
Executable file
|
After Width: | Height: | Size: 8.0 MiB |
|
After Width: | Height: | Size: 3.3 MiB |