Template
Add shop, POS sync, event ticketing, and shipping catalog apps (#6)
## Summary - Closes #5 - Optional `shop`, `pos_sync`, `events`, and `shipping` apps gated by `FEATURE_*` flags - Deps match the catalog: shop/events need email + Stripe; POS/shipping need shop - Portal inventory, POS webhooks, capacity tickets, EasyPost/Pirate Ship shipping ## Test plan - [ ] `manage.py test` (152 passed locally) - [ ] Shop cart + paid order decrements stocked inventory - [ ] POS inbound webhook decrements SKU; paid order queues outbound reserve - [ ] Event capacity blocks overbook; paid order emails ticket codes - [ ] Shipping stub label + Pirate Ship CSV of unshipped paid orders - [ ] `validate-env.sh` rejects shop without email/payments, POS/shipping without shop Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Checkout cancelled{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg"><div class="container">
|
||||
<h1>Checkout cancelled</h1>
|
||||
<p>Order {{ order.number }} was not paid. You can return to the <a href="{% url 'shop:cart' %}">cart</a>.</p>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Cart · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<h1>Cart</h1>
|
||||
{% for line in lines %}
|
||||
<p>
|
||||
<a href="{{ line.product.get_absolute_url }}">{{ line.product.name }}</a>
|
||||
· {{ line.quantity }} × {{ line.unit_price }} = {{ line.line_total }}
|
||||
</p>
|
||||
<form method="post" action="{% url 'shop:cart_update' line.product.slug %}" style="margin:0 0 16px">
|
||||
{% csrf_token %}
|
||||
<input name="quantity" type="number" min="0" value="{{ line.quantity }}">
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
{% empty %}
|
||||
<p>Cart is empty.</p>
|
||||
{% endfor %}
|
||||
{% if lines %}
|
||||
<p><strong>Total:</strong> {{ total }}</p>
|
||||
<p><a class="button button-primary" href="{% url 'shop:checkout' %}">Checkout</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Checkout · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<h1>Checkout</h1>
|
||||
<p>Total: {{ total }}</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<p><label>Email <input name="email" type="email" required></label></p>
|
||||
<p><label>Name <input name="customer_name"></label></p>
|
||||
<p><label>Address <input name="address_line1"></label></p>
|
||||
<p><label>Address 2 <input name="address_line2"></label></p>
|
||||
<p><label>City <input name="address_city"></label></p>
|
||||
<p><label>State <input name="address_state"></label></p>
|
||||
<p><label>ZIP <input name="address_zip"></label></p>
|
||||
<button class="button button-primary" type="submit">Pay with Stripe</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ product.name }} · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<p><a href="{% url 'shop:list' %}">← Shop</a></p>
|
||||
<h1>{{ product.name }}</h1>
|
||||
<p class="muted">{{ product.price }} {{ product.currency|upper }} · {{ product.sku }}</p>
|
||||
<div>{{ product.description|linebreaks }}</div>
|
||||
{% if available is not None %}
|
||||
<p>{{ available }} in stock</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'shop:cart_add' product.slug %}">
|
||||
{% csrf_token %}
|
||||
<label>Qty <input name="quantity" type="number" min="1" value="1"></label>
|
||||
<button class="button button-primary" type="submit">Add to cart</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Shop · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<h1 class="text-uppercase">Shop</h1>
|
||||
<p><a href="{% url 'shop:cart' %}">View cart</a></p>
|
||||
{% for product in products %}
|
||||
<article style="margin:0 0 32px">
|
||||
<h2><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></h2>
|
||||
<p class="muted">{{ product.price }} {{ product.currency|upper }} · {{ product.sku }}</p>
|
||||
<p>{{ product.description|truncatewords:40 }}</p>
|
||||
</article>
|
||||
{% empty %}
|
||||
<p>No products yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{{ order.number }} · Portal{% endblock %}
|
||||
{% block topbar_title %}{{ order.number }}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<p><strong>{{ order.email }}</strong> · {{ order.amount }} {{ order.currency|upper }} · {{ order.get_status_display }}</p>
|
||||
{% if order.customer_name %}<p>{{ order.customer_name }}</p>{% endif %}
|
||||
<table class="table">
|
||||
<thead><tr><th>Item</th><th>SKU</th><th>Qty</th><th>Price</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ item.sku }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>{{ item.unit_price }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p><a href="{% url 'shop_portal:order_list' %}">← All orders</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Orders · Portal{% endblock %}
|
||||
{% block topbar_title %}Orders{% endblock %}
|
||||
{% block portal_content %}
|
||||
<table class="table">
|
||||
<thead><tr><th>Number</th><th>Email</th><th>Amount</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td><a href="{% url 'shop_portal:order_detail' order.pk %}">{{ order.number }}</a></td>
|
||||
<td>{{ order.email }}</td>
|
||||
<td>{{ order.amount }} {{ order.currency|upper }}</td>
|
||||
<td>{{ order.get_status_display }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No orders yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{% if product %}Edit{% else %}New{% endif %} product · Portal{% endblock %}
|
||||
{% block topbar_title %}{% if product %}Edit product{% else %}New product{% endif %}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<form method="post" class="form-grid">
|
||||
{% csrf_token %}
|
||||
<div class="field"><label>Name</label><input name="name" required value="{{ product.name|default:'' }}"></div>
|
||||
<div class="field"><label>SKU</label><input name="sku" value="{{ product.sku|default:'' }}" placeholder="auto from name"></div>
|
||||
<div class="field"><label>Slug</label><input name="slug" value="{{ product.slug|default:'' }}" placeholder="auto from name"></div>
|
||||
<div class="field"><label>Price</label><input name="price" type="number" step="0.01" min="0" required value="{{ product.price|default:'' }}"></div>
|
||||
<div class="field">
|
||||
<label>Fulfillment</label>
|
||||
<select name="fulfillment">
|
||||
<option value="stocked" {% if product.fulfillment == 'stocked' or not product %}selected{% endif %}>On-hand stock</option>
|
||||
<option value="made_to_order" {% if product.fulfillment == 'made_to_order' %}selected{% endif %}>Made to order</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Stock qty</label><input name="stock_qty" type="number" value="{{ product.stock_qty|default:0 }}"></div>
|
||||
<div class="field"><label>Print minutes</label><input name="print_minutes" type="number" min="0" value="{{ product.print_minutes|default:0 }}"></div>
|
||||
<div class="field"><label>Filament grams</label><input name="filament_grams" type="number" min="0" value="{{ product.filament_grams|default:0 }}"></div>
|
||||
<div class="field"><label>Description</label><textarea name="description" style="min-height:120px">{{ product.description|default:'' }}</textarea></div>
|
||||
<label><input type="checkbox" name="is_published" {% if product.is_published %}checked{% endif %}> Published</label>
|
||||
<label><input type="checkbox" name="track_inventory" {% if product.track_inventory or not product %}checked{% endif %}> Track inventory</label>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</form>
|
||||
{% if product %}
|
||||
<form method="post" action="{% url 'shop_portal:product_stock' product.pk %}" class="form-grid" style="margin-top:24px">
|
||||
{% csrf_token %}
|
||||
<div class="field"><label>Adjust stock (+/−)</label><input name="delta" type="number" required value="1"></div>
|
||||
<button class="btn btn-ghost" type="submit">Apply adjustment</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Products · Portal{% endblock %}
|
||||
{% block topbar_title %}Products{% endblock %}
|
||||
{% block portal_content %}
|
||||
<p><a class="btn btn-primary" href="{% url 'shop_portal:product_new' %}">New product</a></p>
|
||||
<table class="table">
|
||||
<thead><tr><th>Name</th><th>SKU</th><th>Price</th><th>Stock</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for product in products %}
|
||||
<tr>
|
||||
<td><a href="{% url 'shop_portal:product_edit' product.pk %}">{{ product.name }}</a></td>
|
||||
<td>{{ product.sku }}</td>
|
||||
<td>{{ product.price }} {{ product.currency|upper }}</td>
|
||||
<td>{{ product.stock_qty }}</td>
|
||||
<td>{% if product.is_published %}Published{% else %}Draft{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No products yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,147 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Sales · Portal{% endblock %}
|
||||
{% block topbar_title %}Sales{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">Orders ({{ days }} days)</div>
|
||||
<div class="value">{{ order_count }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Revenue ({{ days }} days)</div>
|
||||
<div class="value">{{ revenue }} {{ currency|upper }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Units sold</div>
|
||||
<div class="value">{{ units_sold }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Average order</div>
|
||||
<div class="value">{{ aov }} {{ currency|upper }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">
|
||||
<h2>Orders per day</h2>
|
||||
<span class="muted">Last {{ days }} days</span>
|
||||
</div>
|
||||
<div class="panel-b">
|
||||
<div class="chart-placeholder chart-daily" role="img"
|
||||
aria-label="Paid orders per day for the last {{ days }} days">
|
||||
{% for bar in daily_sales %}
|
||||
<div class="chart-bar-col">
|
||||
<div class="bar{% if not bar.count %} is-zero{% endif %}"
|
||||
style="height:{{ bar.pct }}%"
|
||||
title="{{ bar.label }}: {{ bar.count }} sale{{ bar.count|pluralize }} · {{ bar.revenue }} {{ currency|upper }}"></div>
|
||||
<div class="chart-bar-meta">
|
||||
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %} {% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if not has_sales %}
|
||||
<p class="empty-state" style="padding-top:12px">No paid orders in this window.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">
|
||||
<h2>Revenue per day</h2>
|
||||
<span class="muted">Last {{ days }} days</span>
|
||||
</div>
|
||||
<div class="panel-b">
|
||||
<div class="chart-placeholder chart-daily chart-revenue" role="img"
|
||||
aria-label="Revenue per day for the last {{ days }} days">
|
||||
{% for bar in daily_revenue %}
|
||||
<div class="chart-bar-col">
|
||||
<div class="bar{% if not bar.revenue %} is-zero{% endif %}"
|
||||
style="height:{{ bar.pct }}%"
|
||||
title="{{ bar.label }}: {{ bar.revenue }} {{ currency|upper }}"></div>
|
||||
<div class="chart-bar-meta">
|
||||
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %} {% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-h">
|
||||
<h2>Top products</h2>
|
||||
<span class="muted">By units sold</span>
|
||||
</div>
|
||||
<div class="panel-b">
|
||||
{% if top_products %}
|
||||
<div class="hbar-chart" role="img" aria-label="Top products by units sold">
|
||||
{% for row in top_products %}
|
||||
<div class="hbar-row" title="{{ row.name }} · {{ row.units }} sold · {{ row.revenue }} {{ currency|upper }}">
|
||||
<div class="hbar-label">{{ row.name }}</div>
|
||||
<div class="hbar-track">
|
||||
<div class="hbar-fill" style="width:{{ row.bar_pct }}%"></div>
|
||||
</div>
|
||||
<div class="hbar-value">{{ row.units }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state" style="margin:0;padding:0">No product sales yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h">
|
||||
<h2>Product mix</h2>
|
||||
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:product_list' %}">Products</a>
|
||||
</div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Product</th><th>SKU</th><th>Units</th><th>Revenue</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in top_products %}
|
||||
<tr>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.sku }}</td>
|
||||
<td>{{ row.units }}</td>
|
||||
<td>{{ row.revenue }} {{ currency|upper }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No paid line items in the last {{ days }} days.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">
|
||||
<h2>Recent sales</h2>
|
||||
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:order_list' %}">All orders</a>
|
||||
</div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Number</th><th>Customer</th><th>Amount</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in recent_orders %}
|
||||
<tr>
|
||||
<td><a href="{% url 'shop_portal:order_detail' order.pk %}">{{ order.number }}</a></td>
|
||||
<td>{{ order.customer_name|default:order.email }}</td>
|
||||
<td>{{ order.amount }} {{ order.currency|upper }}</td>
|
||||
<td>{{ order.get_status_display }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No paid orders yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Order {{ order.number }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg"><div class="container">
|
||||
<h1>Thank you</h1>
|
||||
<p>Order {{ order.number }} is {{ order.get_status_display|lower }}.</p>
|
||||
<p>A confirmation will go to {{ order.email }}.</p>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user