Add shopper accounts, reviews, tracking, and seed_demo (#9)

Closes #9. Shop-gated buyer accounts, purchase reviews, Stripe customer ids, shipment tracking, slim public contact form, and a template-neutral seed_demo command.
This commit is contained in:
2026-09-07 08:35:55 -05:00
parent 9cdce7a897
commit 5b11cc18c7
66 changed files with 3051 additions and 285 deletions
@@ -0,0 +1,48 @@
{% extends "accounts/account_base.html" %}
{% block title %}Order {{ order.number }} · {{ SITE_NAME }}{% endblock %}
{% block account_content %}
<h3>Order {{ order.number }}</h3>
<p>{{ order.get_status_display }} · ${{ order.amount }} {{ order.currency|upper }}</p>
{% if order.customer_name %}<p>{{ order.customer_name }}</p>{% endif %}
{% if order.shipping_address.line1 %}
<p>
{{ order.shipping_address.line1 }}{% if order.shipping_address.line2 %}, {{ order.shipping_address.line2 }}{% endif %}<br>
{{ order.shipping_address.city }} {{ order.shipping_address.state }} {{ order.shipping_address.zip }}
</p>
{% endif %}
<ul>
{% for item in order.items.all %}
<li>{{ item.quantity }}× {{ item.name }} — ${{ item.line_total }}</li>
{% endfor %}
</ul>
{% if "shipping" in enabled_features %}
<h4>Shipments</h4>
{% for shipment in order.shipments.all %}
<div>
<p>
{{ shipment.carrier }} {{ shipment.service }}
· {{ shipment.get_status_display }}
{% if shipment.tracking_status %} · {{ shipment.get_tracking_status_display }}{% endif %}
</p>
{% if shipment.tracking_number %}
<p>
Tracking {{ shipment.tracking_number }}
{% if shipment.tracking_url %}
· <a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">Track package</a>
{% endif %}
</p>
{% endif %}
{% if shipment.tracking_events %}
<ul>
{% for event in shipment.tracking_events %}
<li>{{ event.datetime }} — {{ event.message|default:event.status }}{% if event.location %} ({{ event.location }}){% endif %}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% empty %}
<p>Not shipped yet. Tracking appears here once a label is bought or a tracking number is added.</p>
{% endfor %}
{% endif %}
<p><a href="{% url 'account:orders' %}">← All orders</a></p>
{% endblock %}
@@ -0,0 +1,18 @@
{% extends "accounts/account_base.html" %}
{% block title %}Orders · {{ SITE_NAME }}{% endblock %}
{% block account_content %}
<h3>Order history</h3>
{% if orders %}
<ul>
{% for order in orders %}
<li>
<a href="{% url 'account:order_detail' order.pk %}">{{ order.number }}</a>
· {{ order.get_status_display }}
· {{ order.amount }}
</li>
{% endfor %}
</ul>
{% else %}
<p>No orders yet. <a href="{% url 'shop:list' %}">Browse the shop</a>.</p>
{% endif %}
{% endblock %}
+21 -7
View File
@@ -1,21 +1,35 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Checkout · {{ SITE_NAME }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1>Checkout</h1>
<p>Total: {{ total }}</p>
{% if user.is_authenticated %}
<p>Signed in as {{ user.email }}. Cards stay on Stripe; we never store card numbers. <a href="{% url 'account:profile' %}">Edit profile</a></p>
{% else %}
<p>Have an account? <a href="{% url 'account:login' %}?next={{ request.path }}">Sign in</a> to prefill shipping and save cards on Stripe. Or <a href="{% url 'account:register' %}">create one</a>.</p>
{% endif %}
<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>
<div data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<p><label>Email <input class="form-input" name="email" type="email" required value="{{ checkout_initial.email }}" {% if user.is_authenticated %}readonly{% endif %}></label></p>
<p><label>Name <input class="form-input" name="customer_name" value="{{ checkout_initial.customer_name }}"></label></p>
<p><label>Address <input class="form-input" name="address_line1" data-ac="line1" autocomplete="off" value="{{ checkout_initial.address.line1|default:'' }}"></label></p>
<p><label>Address 2 <input class="form-input" name="address_line2" data-ac="line2" autocomplete="address-line2" value="{{ checkout_initial.address.line2|default:'' }}"></label></p>
<p><label>City <input class="form-input" name="address_city" data-ac="city" autocomplete="address-level2" value="{{ checkout_initial.address.city|default:'' }}"></label></p>
<p><label>State <input class="form-input" name="address_state" data-ac="state" autocomplete="address-level1" value="{{ checkout_initial.address.state|default:'' }}"></label></p>
<p><label>ZIP <input class="form-input" name="address_zip" data-ac="zip" autocomplete="postal-code" value="{{ checkout_initial.address.zip|default:'' }}"></label></p>
</div>
<button class="button button-primary" type="submit">Pay with Stripe</button>
</form>
</div>
</section>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% endblock %}
+41
View File
@@ -15,6 +15,47 @@
<label>Qty <input name="quantity" type="number" min="1" value="1"></label>
<button class="button button-primary" type="submit">Add to cart</button>
</form>
<h2>Reviews</h2>
{% if review_count %}
<p>{{ review_avg|floatformat:1 }} / 5 · {{ review_count }} review{{ review_count|pluralize }}</p>
{% else %}
<p>No reviews yet.</p>
{% endif %}
{% if can_review %}
<form method="post" action="{% url 'shop:review' product.slug %}">
{% csrf_token %}
<p>
<label for="review-rating">Rating</label>
<select id="review-rating" name="rating" required>
<option value="">Choose 15</option>
<option value="5">5 — Excellent</option>
<option value="4">4 — Good</option>
<option value="3">3 — Okay</option>
<option value="2">2 — Fair</option>
<option value="1">1 — Poor</option>
</select>
</p>
<p><label>Title (optional) <input name="title" maxlength="120"></label></p>
<p><label>Review (optional) <textarea name="body" rows="4"></textarea></label></p>
<button class="button button-primary" type="submit">Submit review</button>
</form>
{% elif already_reviewed %}
<p>You already reviewed this product.</p>
{% elif user.is_authenticated %}
<p>Buy this product to leave a review.</p>
{% else %}
<p><a href="{% url 'account:login' %}?next={{ request.path }}">Sign in</a> after a purchase to leave a review.</p>
{% endif %}
<ul>
{% for review in reviews %}
<li>
<strong>{{ review.rating }}/5</strong>
{% if review.title %} · {{ review.title }}{% endif %}
<span> — {{ review.user.first_name|default:review.user.email }}</span>
{% if review.body %}<p>{{ review.body|linebreaks }}</p>{% endif %}
</li>
{% endfor %}
</ul>
</div>
</section>
{% endblock %}
@@ -17,5 +17,26 @@
{% endfor %}
</tbody>
</table>
{% if "shipping" in enabled_features and order.shipments.all %}
<h3>Shipments</h3>
<table class="table">
<thead><tr><th>Carrier</th><th>Tracking</th><th>Status</th></tr></thead>
<tbody>
{% for shipment in order.shipments.all %}
<tr>
<td>{{ shipment.carrier }} {{ shipment.service }}</td>
<td>
{% if shipment.tracking_url %}
<a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">{{ shipment.tracking_number }}</a>
{% else %}
{{ shipment.tracking_number|default:"—" }}
{% endif %}
</td>
<td>{{ shipment.get_tracking_status_display|default:shipment.get_status_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p><a href="{% url 'shop_portal:order_list' %}">← All orders</a></p>
{% endblock %}
+7
View File
@@ -5,5 +5,12 @@
<h1>Thank you</h1>
<p>Order {{ order.number }} is {{ order.get_status_display|lower }}.</p>
<p>A confirmation will go to {{ order.email }}.</p>
{% if user.is_authenticated %}
<p><a class="button button-primary" href="{% url 'account:order_detail' order.pk %}">View order</a></p>
{% else %}
<p>Create an account to track shipping and review products you bought. Card details stay with Stripe.</p>
<p><a class="button button-primary" href="{% url 'account:register' %}?email={{ order.email|urlencode }}">Create account</a></p>
{% endif %}
<p><a href="{% url 'shop:list' %}">Continue shopping</a></p>
</div></section>
{% endblock %}