1628 lines
53 KiB
Markdown
1628 lines
53 KiB
Markdown
async placeOrder() {
|
||
this.placing = true;
|
||
try {
|
||
const order = await apiClient.post(
|
||
`/api/v1/public/vendors/${window.vendorId}/orders`,
|
||
{
|
||
shipping_address_id: this.selectedShippingAddress,
|
||
billing_address_id: this.selectedBillingAddress,
|
||
shipping_method: 'standard',
|
||
payment_method: this.paymentMethod,
|
||
customer_notes: ''
|
||
}
|
||
);
|
||
|
||
this.orderNumber = order.order_number;
|
||
this.currentStep = 4;
|
||
|
||
// Clear cart from localStorage
|
||
clearCart();
|
||
} catch (error) {
|
||
showNotification(error.message || 'Failed to place order', 'error');
|
||
} finally {
|
||
this.placing = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
#### Customer Order History (`templates/shop/account/orders.html`)
|
||
|
||
```html
|
||
{% extends "shop/base_shop.html" %}
|
||
|
||
{% block content %}
|
||
<div x-data="orderHistory()" x-init="loadOrders()">
|
||
<div class="account-page">
|
||
<h1>My Orders</h1>
|
||
|
||
<template x-if="orders.length === 0 && !loading">
|
||
<div class="empty-state">
|
||
<p>You haven't placed any orders yet.</p>
|
||
<a href="/shop/products" class="btn btn-primary">Start Shopping</a>
|
||
</div>
|
||
</template>
|
||
|
||
<template x-if="orders.length > 0">
|
||
<div class="orders-list">
|
||
<template x-for="order in orders" :key="order.id">
|
||
<div class="order-card">
|
||
<div class="order-header">
|
||
<div class="order-info">
|
||
<h3 x-text="order.order_number"></h3>
|
||
<span class="order-date" x-text="formatDate(order.created_at)"></span>
|
||
</div>
|
||
<span
|
||
class="badge"
|
||
:class="{
|
||
'badge-warning': order.status === 'pending' || order.status === 'processing',
|
||
'badge-info': order.status === 'confirmed' || order.status === 'shipped',
|
||
'badge-success': order.status === 'delivered',
|
||
'badge-danger': order.status === 'cancelled'
|
||
}"
|
||
x-text="order.status"
|
||
></span>
|
||
</div>
|
||
|
||
<div class="order-items">
|
||
<template x-for="item in order.items" :key="item.id">
|
||
<div class="order-item">
|
||
<img :src="item.product_image" :alt="item.product_title">
|
||
<div class="item-details">
|
||
<p x-text="item.product_title"></p>
|
||
<p class="text-muted">Qty: <span x-text="item.quantity"></span></p>
|
||
</div>
|
||
<p class="item-price">€<span x-text="item.total_price.toFixed(2)"></span></p>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="order-footer">
|
||
<p class="order-total">
|
||
Total: €<span x-text="order.total_amount.toFixed(2)"></span>
|
||
</p>
|
||
<a :href="`/shop/account/orders/${order.id}`" class="btn btn-sm">
|
||
View Details
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Pagination -->
|
||
<div x-show="hasMore" class="text-center mt-3">
|
||
<button @click="loadMore()" class="btn btn-secondary" :disabled="loading">
|
||
<span x-show="!loading">Load More</span>
|
||
<span x-show="loading" class="loading-spinner"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
window.vendorId = {{ vendor.id }};
|
||
</script>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script>
|
||
function orderHistory() {
|
||
return {
|
||
orders: [],
|
||
loading: false,
|
||
page: 0,
|
||
hasMore: true,
|
||
|
||
async loadOrders() {
|
||
this.loading = true;
|
||
try {
|
||
const newOrders = await apiClient.get(
|
||
`/api/v1/public/vendors/${window.vendorId}/orders/my-orders?skip=${this.page * 20}&limit=20`
|
||
);
|
||
|
||
this.orders = [...this.orders, ...newOrders];
|
||
this.hasMore = newOrders.length === 20;
|
||
} catch (error) {
|
||
showNotification('Failed to load orders', 'error');
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
async loadMore() {
|
||
this.page++;
|
||
await this.loadOrders();
|
||
},
|
||
|
||
formatDate(dateString) {
|
||
return new Date(dateString).toLocaleDateString();
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
#### Vendor Order Management (`templates/vendor/orders/list.html`)
|
||
|
||
```html
|
||
{% extends "vendor/base_vendor.html" %}
|
||
|
||
{% block title %}Order Management{% endblock %}
|
||
|
||
{% block content %}
|
||
<div x-data="orderManagement()" x-init="loadOrders()">
|
||
<!-- Page Header -->
|
||
<div class="page-header">
|
||
<h1>Orders</h1>
|
||
<div class="header-actions">
|
||
<button @click="exportOrders()" class="btn btn-secondary">
|
||
Export Orders
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filters -->
|
||
<div class="filters-bar">
|
||
<div class="filter-group">
|
||
<label>Status</label>
|
||
<select x-model="filters.status" @change="loadOrders()">
|
||
<option value="">All Statuses</option>
|
||
<option value="pending">Pending</option>
|
||
<option value="confirmed">Confirmed</option>
|
||
<option value="processing">Processing</option>
|
||
<option value="shipped">Shipped</option>
|
||
<option value="delivered">Delivered</option>
|
||
<option value="cancelled">Cancelled</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<label>Search</label>
|
||
<input
|
||
type="text"
|
||
x-model="filters.search"
|
||
@input.debounce.500ms="loadOrders()"
|
||
placeholder="Order #, customer name..."
|
||
class="form-control"
|
||
>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<label>Date From</label>
|
||
<input
|
||
type="date"
|
||
x-model="filters.date_from"
|
||
@change="loadOrders()"
|
||
class="form-control"
|
||
>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<label>Date To</label>
|
||
<input
|
||
type="date"
|
||
x-model="filters.date_to"
|
||
@change="loadOrders()"
|
||
class="form-control"
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Orders Table -->
|
||
<div class="card">
|
||
<template x-if="orders.length === 0 && !loading">
|
||
<p class="text-center text-muted">No orders found</p>
|
||
</template>
|
||
|
||
<template x-if="orders.length > 0">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Order #</th>
|
||
<th>Customer</th>
|
||
<th>Date</th>
|
||
<th>Items</th>
|
||
<th>Total</th>
|
||
<th>Status</th>
|
||
<th>Payment</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template x-for="order in orders" :key="order.id">
|
||
<tr>
|
||
<td>
|
||
<strong x-text="order.order_number"></strong>
|
||
</td>
|
||
<td>
|
||
<div x-text="order.customer.first_name + ' ' + order.customer.last_name"></div>
|
||
<small class="text-muted" x-text="order.customer.email"></small>
|
||
</td>
|
||
<td x-text="formatDate(order.created_at)"></td>
|
||
<td x-text="order.items.length"></td>
|
||
<td>€<span x-text="order.total_amount.toFixed(2)"></span></td>
|
||
<td>
|
||
<span
|
||
class="badge"
|
||
:class="{
|
||
'badge-warning': order.status === 'pending',
|
||
'badge-info': order.status === 'processing' || order.status === 'confirmed',
|
||
'badge-primary': order.status === 'shipped',
|
||
'badge-success': order.status === 'delivered',
|
||
'badge-danger': order.status === 'cancelled'
|
||
}"
|
||
x-text="order.status"
|
||
></span>
|
||
</td>
|
||
<td>
|
||
<span
|
||
class="badge"
|
||
:class="{
|
||
'badge-warning': order.payment_status === 'pending',
|
||
'badge-success': order.payment_status === 'paid',
|
||
'badge-danger': order.payment_status === 'failed'
|
||
}"
|
||
x-text="order.payment_status"
|
||
></span>
|
||
</td>
|
||
<td>
|
||
<button
|
||
@click="viewOrder(order.id)"
|
||
class="btn btn-sm"
|
||
>
|
||
View
|
||
</button>
|
||
<button
|
||
@click="showStatusModal(order)"
|
||
class="btn btn-sm btn-secondary"
|
||
>
|
||
Update Status
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Status Update Modal -->
|
||
<div x-show="statusModal.show" class="modal-overlay" @click.self="statusModal.show = false">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h3>Update Order Status</h3>
|
||
<button @click="statusModal.show = false" class="modal-close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form @submit.prevent="updateStatus()">
|
||
<div class="form-group">
|
||
<label>Order Number</label>
|
||
<input
|
||
type="text"
|
||
:value="statusModal.order?.order_number"
|
||
class="form-control"
|
||
readonly
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Current Status</label>
|
||
<input
|
||
type="text"
|
||
:value="statusModal.order?.status"
|
||
class="form-control"
|
||
readonly
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>New Status</label>
|
||
<select x-model="statusModal.newStatus" class="form-control" required>
|
||
<option value="confirmed">Confirmed</option>
|
||
<option value="processing">Processing</option>
|
||
<option value="shipped">Shipped</option>
|
||
<option value="delivered">Delivered</option>
|
||
<option value="cancelled">Cancelled</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" x-show="statusModal.newStatus === 'shipped'">
|
||
<label>Tracking Number</label>
|
||
<input
|
||
type="text"
|
||
x-model="statusModal.trackingNumber"
|
||
class="form-control"
|
||
placeholder="Enter tracking number"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Notes (Optional)</label>
|
||
<textarea
|
||
x-model="statusModal.notes"
|
||
class="form-control"
|
||
rows="3"
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="button" @click="statusModal.show = false" class="btn btn-secondary">
|
||
Cancel
|
||
</button>
|
||
<button type="submit" class="btn btn-primary" :disabled="statusModal.updating">
|
||
<span x-show="!statusModal.updating">Update Status</span>
|
||
<span x-show="statusModal.updating" class="loading-spinner"></span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script>
|
||
function orderManagement() {
|
||
return {
|
||
orders: [],
|
||
loading: false,
|
||
filters: {
|
||
status: '',
|
||
search: '',
|
||
date_from: '',
|
||
date_to: ''
|
||
},
|
||
statusModal: {
|
||
show: false,
|
||
order: null,
|
||
newStatus: '',
|
||
trackingNumber: '',
|
||
notes: '',
|
||
updating: false
|
||
},
|
||
|
||
async loadOrders() {
|
||
this.loading = true;
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (this.filters.status) params.append('status', this.filters.status);
|
||
if (this.filters.search) params.append('search', this.filters.search);
|
||
if (this.filters.date_from) params.append('date_from', this.filters.date_from);
|
||
if (this.filters.date_to) params.append('date_to', this.filters.date_to);
|
||
|
||
this.orders = await apiClient.get(`/api/v1/vendor/orders?${params}`);
|
||
} catch (error) {
|
||
showNotification('Failed to load orders', 'error');
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
viewOrder(orderId) {
|
||
window.location.href = `/vendor/orders/${orderId}`;
|
||
},
|
||
|
||
showStatusModal(order) {
|
||
this.statusModal.show = true;
|
||
this.statusModal.order = order;
|
||
this.statusModal.newStatus = order.status;
|
||
this.statusModal.trackingNumber = order.tracking_number || '';
|
||
this.statusModal.notes = '';
|
||
},
|
||
|
||
async updateStatus() {
|
||
this.statusModal.updating = true;
|
||
try {
|
||
await apiClient.put(
|
||
`/api/v1/vendor/orders/${this.statusModal.order.id}/status`,
|
||
{
|
||
status: this.statusModal.newStatus,
|
||
tracking_number: this.statusModal.trackingNumber,
|
||
notes: this.statusModal.notes
|
||
}
|
||
);
|
||
|
||
showNotification('Order status updated', 'success');
|
||
this.statusModal.show = false;
|
||
await this.loadOrders();
|
||
} catch (error) {
|
||
showNotification(error.message || 'Failed to update status', 'error');
|
||
} finally {
|
||
this.statusModal.updating = false;
|
||
}
|
||
},
|
||
|
||
async exportOrders() {
|
||
// Implement CSV export
|
||
showNotification('Export feature coming soon', 'info');
|
||
},
|
||
|
||
formatDate(dateString) {
|
||
return new Date(dateString).toLocaleDateString();
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
## ✅ Testing Checklist
|
||
|
||
### Backend Tests
|
||
|
||
#### Order Creation
|
||
- [ ] Order created successfully from cart
|
||
- [ ] Order number generated uniquely
|
||
- [ ] Order items created with product snapshots
|
||
- [ ] Inventory reserved correctly
|
||
- [ ] Customer stats updated (total_orders, total_spent)
|
||
- [ ] Cart cleared after order placement
|
||
- [ ] Order status history recorded
|
||
- [ ] Shipping/billing addresses validated
|
||
- [ ] Tax calculated correctly
|
||
- [ ] Shipping cost calculated correctly
|
||
|
||
#### Order Management
|
||
- [ ] Customer can view their order history
|
||
- [ ] Customer can view order details
|
||
- [ ] Vendor can view all orders
|
||
- [ ] Order filtering works (status, date, search)
|
||
- [ ] Order search works (order number, customer name)
|
||
- [ ] Vendor can update order status
|
||
- [ ] Status history tracked correctly
|
||
- [ ] Tracking number can be added
|
||
|
||
#### Payment Integration
|
||
- [ ] Payment method validation
|
||
- [ ] Payment status tracking
|
||
- [ ] Stripe integration ready (placeholder)
|
||
|
||
#### Notifications
|
||
- [ ] Order confirmation email sent
|
||
- [ ] Status update email sent
|
||
- [ ] Email contains correct order details
|
||
|
||
### Frontend Tests
|
||
|
||
#### Checkout Flow
|
||
- [ ] Multi-step checkout works
|
||
- [ ] Cart review displays correctly
|
||
- [ ] Address selection works
|
||
- [ ] New address creation works
|
||
- [ ] Payment method selection works
|
||
- [ ] Order summary calculates correctly
|
||
- [ ] Order placement succeeds
|
||
- [ ] Confirmation page displays
|
||
- [ ] Loading states work
|
||
- [ ] Error handling works
|
||
|
||
#### Customer Order History
|
||
- [ ] Order list displays correctly
|
||
- [ ] Order cards show correct information
|
||
- [ ] Status badges display correctly
|
||
- [ ] Order details link works
|
||
- [ ] Pagination works
|
||
- [ ] Empty state displays
|
||
|
||
#### Vendor Order Management
|
||
- [ ] Order table displays correctly
|
||
- [ ] Filters work (status, date, search)
|
||
- [ ] Order details modal works
|
||
- [ ] Status update modal works
|
||
- [ ] Status updates successfully
|
||
- [ ] Real-time status updates
|
||
- [ ] Export functionality (when implemented)
|
||
|
||
### Integration Tests
|
||
|
||
#### Complete Order Flow
|
||
- [ ] Customer adds products to cart
|
||
- [ ] Customer proceeds to checkout
|
||
- [ ] Customer completes checkout
|
||
- [ ] Order appears in customer history
|
||
- [ ] Order appears in vendor orders
|
||
- [ ] Vendor updates order status
|
||
- [ ] Customer sees status update
|
||
- [ ] Emails sent at each step
|
||
|
||
#### Vendor Isolation
|
||
- [ ] Orders scoped to correct vendor
|
||
- [ ] Customers can only see their orders
|
||
- [ ] Vendors can only manage their orders
|
||
- [ ] Cross-vendor access prevented
|
||
|
||
#### Data Integrity
|
||
- [ ] Inventory correctly reserved/updated
|
||
- [ ] Product snapshots preserved
|
||
- [ ] Customer stats accurate
|
||
- [ ] Order totals calculate correctly
|
||
- [ ] Status history maintained
|
||
|
||
## 📝 Additional Features (Optional)
|
||
|
||
### Payment Integration
|
||
- [ ] Stripe payment element integration
|
||
- [ ] Payment intent creation
|
||
- [ ] Payment confirmation handling
|
||
- [ ] Refund processing
|
||
|
||
### Email Notifications
|
||
- [ ] Order confirmation template
|
||
- [ ] Order shipped notification
|
||
- [ ] Order delivered notification
|
||
- [ ] Order cancelled notification
|
||
|
||
### Advanced Features
|
||
- [ ] Order notes/communication
|
||
- [ ] Partial refunds
|
||
- [ ] Order tracking page
|
||
- [ ] Invoice generation
|
||
- [ ] Bulk order status updates
|
||
- [ ] Order analytics dashboard
|
||
|
||
## 🚀 Deployment Checklist
|
||
|
||
### Environment Setup
|
||
- [ ] Email service configured (SendGrid, Mailgun, etc.)
|
||
- [ ] Stripe API keys configured (test and live)
|
||
- [ ] Order number prefix configured
|
||
- [ ] Tax rates configured by country
|
||
- [ ] Shipping rates configured
|
||
|
||
### Testing
|
||
- [ ] Complete checkout flow tested
|
||
- [ ] Payment processing tested (test mode)
|
||
- [ ] Email notifications tested
|
||
- [ ] Order management tested
|
||
- [ ] Mobile checkout tested
|
||
|
||
### Production Readiness
|
||
- [ ] Stripe live mode enabled
|
||
- [ ] Email templates reviewed
|
||
- [ ] Error logging enabled
|
||
- [ ] Order monitoring set up
|
||
- [ ] Backup strategy for orders
|
||
|
||
## 🎯 Acceptance Criteria
|
||
|
||
Slice 5 is complete when:
|
||
|
||
1. **Customer Checkout Works**
|
||
- [ ] Multi-step checkout functional
|
||
- [ ] Address management works
|
||
- [ ] Orders can be placed successfully
|
||
- [ ] Confirmation displayed
|
||
|
||
2. **Customer Order Management Works**
|
||
- [ ] Order history displays
|
||
- [ ] Order details accessible
|
||
- [ ] Order tracking works
|
||
|
||
3. **Vendor Order Management Works**
|
||
- [ ] All orders visible
|
||
- [ ] Filtering and search work
|
||
- [ ] Status updates work
|
||
- [ ] Order details accessible
|
||
|
||
4. **System Integration Complete**
|
||
- [ ] Inventory updates correctly
|
||
- [ ] Emails sent automatically
|
||
- [ ] Data integrity maintained
|
||
- [ ] Vendor isolation enforced
|
||
|
||
5. **Production Ready**
|
||
- [ ] All tests pass
|
||
- [ ] Payment integration ready
|
||
- [ ] Email system configured
|
||
- [ ] Error handling robust
|
||
|
||
## 🎉 Platform Complete!
|
||
|
||
After completing Slice 5, your multi-tenant ecommerce platform is **production-ready** with:
|
||
|
||
✅ **Multi-tenant foundation** - Complete vendor isolation
|
||
✅ **Admin management** - Vendor creation and management
|
||
✅ **Marketplace integration** - CSV product imports
|
||
✅ **Product catalog** - Vendor product management
|
||
✅ **Customer shopping** - Browse, search, cart
|
||
✅ **Order processing** - Complete checkout and fulfillment
|
||
|
||
### Next Steps for Production
|
||
|
||
1. **Security Audit**
|
||
- Review authentication
|
||
- Test vendor isolation
|
||
- Verify payment security
|
||
- Check data encryption
|
||
|
||
2. **Performance Optimization**
|
||
- Database indexing
|
||
- Query optimization
|
||
- Caching strategy
|
||
- CDN setup
|
||
|
||
3. **Monitoring & Analytics**
|
||
- Set up error tracking (Sentry)
|
||
- Configure analytics
|
||
- Set up uptime monitoring
|
||
- Create admin dashboards
|
||
|
||
4. **Documentation**
|
||
- User guides for vendors
|
||
- Admin documentation
|
||
- API documentation
|
||
- Deployment guide
|
||
|
||
5. **Launch Preparation**
|
||
- Beta testing with real vendors
|
||
- Load testing
|
||
- Backup procedures
|
||
- Support system
|
||
|
||
---
|
||
|
||
**Slice 5 Status**: 📋 Not Started
|
||
**Dependencies**: Slices 1, 2, 3, & 4 must be complete
|
||
**Estimated Duration**: 5 days
|
||
**Platform Status After Completion**: 🚀 Production Ready!# Slice 5: Order Processing
|
||
## Complete Checkout and Order Management
|
||
|
||
**Status**: 📋 NOT STARTED
|
||
**Timeline**: Week 5 (5 days)
|
||
**Prerequisites**: Slices 1, 2, 3, & 4 complete
|
||
|
||
## 🎯 Slice Objectives
|
||
|
||
Complete the ecommerce transaction workflow with checkout, order placement, and order management for both customers and vendors.
|
||
|
||
### User Stories
|
||
- As a Customer, I can proceed to checkout with my cart
|
||
- As a Customer, I can select shipping address
|
||
- As a Customer, I can place orders
|
||
- As a Customer, I can view my order history
|
||
- As a Customer, I can view order details and status
|
||
- As a Vendor Owner, I can view all customer orders
|
||
- As a Vendor Owner, I can manage orders (update status, view details)
|
||
- As a Vendor Owner, I can filter and search orders
|
||
- Order confirmation emails are sent automatically
|
||
|
||
### Success Criteria
|
||
- [ ] Customers can complete multi-step checkout process
|
||
- [ ] Address selection/creation works
|
||
- [ ] Orders are created with proper vendor isolation
|
||
- [ ] Inventory is reserved/updated on order placement
|
||
- [ ] Customers can view their order history
|
||
- [ ] Customers can track order status
|
||
- [ ] Vendors can view all their orders
|
||
- [ ] Vendors can update order status
|
||
- [ ] Order status changes trigger notifications
|
||
- [ ] Order confirmation emails sent
|
||
- [ ] Payment integration ready (placeholder for Stripe)
|
||
|
||
## 📋 Backend Implementation
|
||
|
||
### Database Models
|
||
|
||
#### Order Model (`models/database/order.py`)
|
||
|
||
```python
|
||
class Order(Base, TimestampMixin):
|
||
"""Customer orders"""
|
||
__tablename__ = "orders"
|
||
|
||
id = Column(Integer, primary_key=True, index=True)
|
||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
||
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
|
||
|
||
# Order identification
|
||
order_number = Column(String, unique=True, nullable=False, index=True)
|
||
|
||
# Order status
|
||
status = Column(
|
||
String,
|
||
nullable=False,
|
||
default="pending"
|
||
) # pending, confirmed, processing, shipped, delivered, cancelled
|
||
|
||
# Amounts
|
||
subtotal = Column(Numeric(10, 2), nullable=False)
|
||
shipping_cost = Column(Numeric(10, 2), default=0)
|
||
tax_amount = Column(Numeric(10, 2), default=0)
|
||
discount_amount = Column(Numeric(10, 2), default=0)
|
||
total_amount = Column(Numeric(10, 2), nullable=False)
|
||
currency = Column(String(3), default="EUR")
|
||
|
||
# Addresses (snapshot at time of order)
|
||
shipping_address_id = Column(Integer, ForeignKey("customer_addresses.id"))
|
||
billing_address_id = Column(Integer, ForeignKey("customer_addresses.id"))
|
||
|
||
# Shipping
|
||
shipping_method = Column(String)
|
||
tracking_number = Column(String)
|
||
shipped_at = Column(DateTime, nullable=True)
|
||
delivered_at = Column(DateTime, nullable=True)
|
||
|
||
# Payment
|
||
payment_status = Column(String, default="pending") # pending, paid, failed, refunded
|
||
payment_method = Column(String) # stripe, paypal, etc.
|
||
payment_reference = Column(String) # External payment ID
|
||
paid_at = Column(DateTime, nullable=True)
|
||
|
||
# Customer notes
|
||
customer_notes = Column(Text)
|
||
internal_notes = Column(Text) # Vendor-only notes
|
||
|
||
# Metadata
|
||
ip_address = Column(String)
|
||
user_agent = Column(String)
|
||
|
||
# Relationships
|
||
vendor = relationship("Vendor", back_populates="orders")
|
||
customer = relationship("Customer", back_populates="orders")
|
||
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||
shipping_address = relationship("CustomerAddress", foreign_keys=[shipping_address_id])
|
||
billing_address = relationship("CustomerAddress", foreign_keys=[billing_address_id])
|
||
status_history = relationship("OrderStatusHistory", back_populates="order")
|
||
|
||
# Indexes
|
||
__table_args__ = (
|
||
Index('ix_order_vendor_customer', 'vendor_id', 'customer_id'),
|
||
Index('ix_order_status', 'vendor_id', 'status'),
|
||
Index('ix_order_date', 'vendor_id', 'created_at'),
|
||
)
|
||
|
||
class OrderItem(Base, TimestampMixin):
|
||
"""Items in an order"""
|
||
__tablename__ = "order_items"
|
||
|
||
id = Column(Integer, primary_key=True, index=True)
|
||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||
|
||
# Product snapshot at time of order
|
||
product_sku = Column(String, nullable=False)
|
||
product_title = Column(String, nullable=False)
|
||
product_image = Column(String)
|
||
|
||
# Pricing
|
||
quantity = Column(Integer, nullable=False)
|
||
unit_price = Column(Numeric(10, 2), nullable=False)
|
||
total_price = Column(Numeric(10, 2), nullable=False)
|
||
|
||
# Tax
|
||
tax_rate = Column(Numeric(5, 2), default=0)
|
||
tax_amount = Column(Numeric(10, 2), default=0)
|
||
|
||
# Relationships
|
||
order = relationship("Order", back_populates="items")
|
||
product = relationship("Product", back_populates="order_items")
|
||
|
||
class OrderStatusHistory(Base, TimestampMixin):
|
||
"""Track order status changes"""
|
||
__tablename__ = "order_status_history"
|
||
|
||
id = Column(Integer, primary_key=True, index=True)
|
||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||
|
||
# Status change
|
||
from_status = Column(String)
|
||
to_status = Column(String, nullable=False)
|
||
|
||
# Who made the change
|
||
changed_by_type = Column(String) # 'customer', 'vendor', 'system'
|
||
changed_by_id = Column(Integer)
|
||
|
||
# Notes
|
||
notes = Column(Text)
|
||
|
||
# Relationships
|
||
order = relationship("Order", back_populates="status_history")
|
||
```
|
||
|
||
### Pydantic Schemas
|
||
|
||
#### Order Schemas (`models/schema/order.py`)
|
||
|
||
```python
|
||
from pydantic import BaseModel, Field
|
||
from typing import Optional, List
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
|
||
class OrderItemCreate(BaseModel):
|
||
"""Order item from cart"""
|
||
product_id: int
|
||
quantity: int
|
||
unit_price: Decimal
|
||
|
||
class OrderCreate(BaseModel):
|
||
"""Create new order"""
|
||
shipping_address_id: int
|
||
billing_address_id: int
|
||
shipping_method: Optional[str] = "standard"
|
||
payment_method: str = "stripe"
|
||
customer_notes: Optional[str] = None
|
||
|
||
class OrderItemResponse(BaseModel):
|
||
"""Order item details"""
|
||
id: int
|
||
product_id: int
|
||
product_sku: str
|
||
product_title: str
|
||
product_image: Optional[str]
|
||
quantity: int
|
||
unit_price: float
|
||
total_price: float
|
||
tax_amount: float
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
class OrderResponse(BaseModel):
|
||
"""Order details"""
|
||
id: int
|
||
vendor_id: int
|
||
customer_id: int
|
||
order_number: str
|
||
status: str
|
||
subtotal: float
|
||
shipping_cost: float
|
||
tax_amount: float
|
||
discount_amount: float
|
||
total_amount: float
|
||
currency: str
|
||
payment_status: str
|
||
payment_method: Optional[str]
|
||
tracking_number: Optional[str]
|
||
customer_notes: Optional[str]
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
items: List[OrderItemResponse]
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
class OrderStatusUpdate(BaseModel):
|
||
"""Update order status"""
|
||
status: str = Field(..., regex="^(pending|confirmed|processing|shipped|delivered|cancelled)$")
|
||
tracking_number: Optional[str] = None
|
||
notes: Optional[str] = None
|
||
|
||
class OrderListFilters(BaseModel):
|
||
"""Filters for order list"""
|
||
status: Optional[str] = None
|
||
customer_id: Optional[int] = None
|
||
date_from: Optional[datetime] = None
|
||
date_to: Optional[datetime] = None
|
||
search: Optional[str] = None # Order number or customer name
|
||
```
|
||
|
||
### Service Layer
|
||
|
||
#### Order Service (`app/services/order_service.py`)
|
||
|
||
```python
|
||
from typing import List, Optional
|
||
from sqlalchemy.orm import Session
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
|
||
class OrderService:
|
||
"""Handle order operations"""
|
||
|
||
async def create_order_from_cart(
|
||
self,
|
||
vendor_id: int,
|
||
customer_id: int,
|
||
cart_id: int,
|
||
order_data: OrderCreate,
|
||
db: Session
|
||
) -> Order:
|
||
"""Create order from shopping cart"""
|
||
|
||
# Get cart with items
|
||
cart = db.query(Cart).filter(
|
||
Cart.id == cart_id,
|
||
Cart.vendor_id == vendor_id
|
||
).first()
|
||
|
||
if not cart or not cart.items:
|
||
raise CartEmptyError("Cart is empty")
|
||
|
||
# Verify addresses belong to customer
|
||
shipping_addr = db.query(CustomerAddress).filter(
|
||
CustomerAddress.id == order_data.shipping_address_id,
|
||
CustomerAddress.customer_id == customer_id
|
||
).first()
|
||
|
||
billing_addr = db.query(CustomerAddress).filter(
|
||
CustomerAddress.id == order_data.billing_address_id,
|
||
CustomerAddress.customer_id == customer_id
|
||
).first()
|
||
|
||
if not shipping_addr or not billing_addr:
|
||
raise AddressNotFoundError()
|
||
|
||
# Calculate totals
|
||
subtotal = sum(item.line_total for item in cart.items)
|
||
shipping_cost = self._calculate_shipping(order_data.shipping_method)
|
||
tax_amount = self._calculate_tax(subtotal, shipping_addr.country)
|
||
total_amount = subtotal + shipping_cost + tax_amount
|
||
|
||
# Generate order number
|
||
order_number = self._generate_order_number(vendor_id, db)
|
||
|
||
# Create order
|
||
order = Order(
|
||
vendor_id=vendor_id,
|
||
customer_id=customer_id,
|
||
order_number=order_number,
|
||
status="pending",
|
||
subtotal=subtotal,
|
||
shipping_cost=shipping_cost,
|
||
tax_amount=tax_amount,
|
||
discount_amount=0,
|
||
total_amount=total_amount,
|
||
currency="EUR",
|
||
shipping_address_id=order_data.shipping_address_id,
|
||
billing_address_id=order_data.billing_address_id,
|
||
shipping_method=order_data.shipping_method,
|
||
payment_method=order_data.payment_method,
|
||
payment_status="pending",
|
||
customer_notes=order_data.customer_notes
|
||
)
|
||
|
||
db.add(order)
|
||
db.flush() # Get order ID
|
||
|
||
# Create order items from cart
|
||
for cart_item in cart.items:
|
||
# Verify product still available
|
||
product = db.query(Product).get(cart_item.product_id)
|
||
if not product or not product.is_active:
|
||
continue
|
||
|
||
# Check inventory
|
||
if product.track_inventory and product.stock_quantity < cart_item.quantity:
|
||
raise InsufficientInventoryError(f"Not enough stock for {product.title}")
|
||
|
||
# Create order item
|
||
order_item = OrderItem(
|
||
order_id=order.id,
|
||
product_id=cart_item.product_id,
|
||
product_sku=product.sku,
|
||
product_title=product.title,
|
||
product_image=product.featured_image,
|
||
quantity=cart_item.quantity,
|
||
unit_price=cart_item.unit_price,
|
||
total_price=cart_item.line_total,
|
||
tax_rate=self._get_tax_rate(shipping_addr.country),
|
||
tax_amount=cart_item.line_total * self._get_tax_rate(shipping_addr.country) / 100
|
||
)
|
||
db.add(order_item)
|
||
|
||
# Reserve inventory
|
||
if product.track_inventory:
|
||
await self._reserve_inventory(product, cart_item.quantity, order.id, db)
|
||
|
||
# Record initial status
|
||
status_history = OrderStatusHistory(
|
||
order_id=order.id,
|
||
from_status=None,
|
||
to_status="pending",
|
||
changed_by_type="customer",
|
||
changed_by_id=customer_id,
|
||
notes="Order created"
|
||
)
|
||
db.add(status_history)
|
||
|
||
# Clear cart
|
||
db.query(CartItem).filter(CartItem.cart_id == cart_id).delete()
|
||
|
||
# Update customer stats
|
||
customer = db.query(Customer).get(customer_id)
|
||
customer.total_orders += 1
|
||
customer.total_spent += float(total_amount)
|
||
|
||
db.commit()
|
||
db.refresh(order)
|
||
|
||
# Send order confirmation email
|
||
await self._send_order_confirmation(order, db)
|
||
|
||
return order
|
||
|
||
def get_customer_orders(
|
||
self,
|
||
vendor_id: int,
|
||
customer_id: int,
|
||
db: Session,
|
||
skip: int = 0,
|
||
limit: int = 20
|
||
) -> List[Order]:
|
||
"""Get customer's order history"""
|
||
|
||
return db.query(Order).filter(
|
||
Order.vendor_id == vendor_id,
|
||
Order.customer_id == customer_id
|
||
).order_by(
|
||
Order.created_at.desc()
|
||
).offset(skip).limit(limit).all()
|
||
|
||
def get_vendor_orders(
|
||
self,
|
||
vendor_id: int,
|
||
db: Session,
|
||
filters: OrderListFilters,
|
||
skip: int = 0,
|
||
limit: int = 50
|
||
) -> List[Order]:
|
||
"""Get vendor's orders with filters"""
|
||
|
||
query = db.query(Order).filter(Order.vendor_id == vendor_id)
|
||
|
||
if filters.status:
|
||
query = query.filter(Order.status == filters.status)
|
||
|
||
if filters.customer_id:
|
||
query = query.filter(Order.customer_id == filters.customer_id)
|
||
|
||
if filters.date_from:
|
||
query = query.filter(Order.created_at >= filters.date_from)
|
||
|
||
if filters.date_to:
|
||
query = query.filter(Order.created_at <= filters.date_to)
|
||
|
||
if filters.search:
|
||
query = query.filter(
|
||
or_(
|
||
Order.order_number.ilike(f"%{filters.search}%"),
|
||
Order.customer.has(
|
||
or_(
|
||
Customer.first_name.ilike(f"%{filters.search}%"),
|
||
Customer.last_name.ilike(f"%{filters.search}%"),
|
||
Customer.email.ilike(f"%{filters.search}%")
|
||
)
|
||
)
|
||
)
|
||
)
|
||
|
||
return query.order_by(
|
||
Order.created_at.desc()
|
||
).offset(skip).limit(limit).all()
|
||
|
||
async def update_order_status(
|
||
self,
|
||
vendor_id: int,
|
||
order_id: int,
|
||
status_update: OrderStatusUpdate,
|
||
changed_by_id: int,
|
||
db: Session
|
||
) -> Order:
|
||
"""Update order status"""
|
||
|
||
order = db.query(Order).filter(
|
||
Order.id == order_id,
|
||
Order.vendor_id == vendor_id
|
||
).first()
|
||
|
||
if not order:
|
||
raise OrderNotFoundError()
|
||
|
||
old_status = order.status
|
||
new_status = status_update.status
|
||
|
||
# Update order
|
||
order.status = new_status
|
||
|
||
if status_update.tracking_number:
|
||
order.tracking_number = status_update.tracking_number
|
||
|
||
if new_status == "shipped":
|
||
order.shipped_at = datetime.utcnow()
|
||
elif new_status == "delivered":
|
||
order.delivered_at = datetime.utcnow()
|
||
|
||
# Record status change
|
||
status_history = OrderStatusHistory(
|
||
order_id=order.id,
|
||
from_status=old_status,
|
||
to_status=new_status,
|
||
changed_by_type="vendor",
|
||
changed_by_id=changed_by_id,
|
||
notes=status_update.notes
|
||
)
|
||
db.add(status_history)
|
||
|
||
db.commit()
|
||
db.refresh(order)
|
||
|
||
# Send notification to customer
|
||
await self._send_status_update_email(order, old_status, new_status, db)
|
||
|
||
return order
|
||
|
||
def _generate_order_number(self, vendor_id: int, db: Session) -> str:
|
||
"""Generate unique order number"""
|
||
from models.database.vendor import Vendor
|
||
|
||
vendor = db.query(Vendor).get(vendor_id)
|
||
today = datetime.utcnow().strftime("%Y%m%d")
|
||
|
||
# Count orders today
|
||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0)
|
||
count = db.query(Order).filter(
|
||
Order.vendor_id == vendor_id,
|
||
Order.created_at >= today_start
|
||
).count()
|
||
|
||
return f"{vendor.vendor_code}-{today}-{count+1:05d}"
|
||
|
||
def _calculate_shipping(self, method: str) -> Decimal:
|
||
"""Calculate shipping cost"""
|
||
shipping_rates = {
|
||
"standard": Decimal("5.00"),
|
||
"express": Decimal("15.00"),
|
||
"free": Decimal("0.00")
|
||
}
|
||
return shipping_rates.get(method, Decimal("5.00"))
|
||
|
||
def _calculate_tax(self, subtotal: Decimal, country: str) -> Decimal:
|
||
"""Calculate tax amount"""
|
||
tax_rate = self._get_tax_rate(country)
|
||
return subtotal * tax_rate / 100
|
||
|
||
def _get_tax_rate(self, country: str) -> Decimal:
|
||
"""Get tax rate by country"""
|
||
tax_rates = {
|
||
"LU": Decimal("17.00"), # Luxembourg VAT
|
||
"FR": Decimal("20.00"), # France VAT
|
||
"DE": Decimal("19.00"), # Germany VAT
|
||
"BE": Decimal("21.00"), # Belgium VAT
|
||
}
|
||
return tax_rates.get(country, Decimal("0.00"))
|
||
|
||
async def _reserve_inventory(
|
||
self,
|
||
product: Product,
|
||
quantity: int,
|
||
order_id: int,
|
||
db: Session
|
||
):
|
||
"""Reserve inventory for order"""
|
||
|
||
inventory = db.query(Inventory).filter(
|
||
Inventory.product_id == product.id
|
||
).first()
|
||
|
||
if inventory:
|
||
inventory.available_quantity -= quantity
|
||
inventory.reserved_quantity += quantity
|
||
|
||
# Record movement
|
||
movement = InventoryMovement(
|
||
inventory_id=inventory.id,
|
||
movement_type="reserved",
|
||
quantity_change=-quantity,
|
||
reference_type="order",
|
||
reference_id=order_id,
|
||
notes=f"Reserved for order #{order_id}"
|
||
)
|
||
db.add(movement)
|
||
|
||
# Update product stock count
|
||
product.stock_quantity -= quantity
|
||
|
||
async def _send_order_confirmation(self, order: Order, db: Session):
|
||
"""Send order confirmation email to customer"""
|
||
# Implement email sending
|
||
# This will be part of notification service
|
||
pass
|
||
|
||
async def _send_status_update_email(
|
||
self,
|
||
order: Order,
|
||
old_status: str,
|
||
new_status: str,
|
||
db: Session
|
||
):
|
||
"""Send order status update email"""
|
||
# Implement email sending
|
||
pass
|
||
```
|
||
|
||
### API Endpoints
|
||
|
||
#### Customer Order Endpoints (`app/api/v1/public/vendors/orders.py`)
|
||
|
||
```python
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from sqlalchemy.orm import Session
|
||
from typing import List
|
||
|
||
router = APIRouter()
|
||
|
||
@router.post("", response_model=OrderResponse, status_code=201)
|
||
async def create_order(
|
||
vendor_id: int,
|
||
order_data: OrderCreate,
|
||
current_customer: Customer = Depends(get_current_customer),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""Place order from cart"""
|
||
|
||
# Get customer's cart
|
||
cart = db.query(Cart).filter(
|
||
Cart.vendor_id == vendor_id,
|
||
Cart.customer_id == current_customer.id
|
||
).first()
|
||
|
||
if not cart:
|
||
raise HTTPException(status_code=404, detail="Cart not found")
|
||
|
||
service = OrderService()
|
||
order = await service.create_order_from_cart(
|
||
vendor_id,
|
||
current_customer.id,
|
||
cart.id,
|
||
order_data,
|
||
db
|
||
)
|
||
|
||
return order
|
||
|
||
@router.get("/my-orders", response_model=List[OrderResponse])
|
||
async def get_my_orders(
|
||
vendor_id: int,
|
||
skip: int = 0,
|
||
limit: int = 20,
|
||
current_customer: Customer = Depends(get_current_customer),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""Get customer's order history"""
|
||
|
||
service = OrderService()
|
||
orders = service.get_customer_orders(
|
||
vendor_id,
|
||
current_customer.id,
|
||
db,
|
||
skip,
|
||
limit
|
||
)
|
||
|
||
return orders
|
||
|
||
@router.get("/my-orders/{order_id}", response_model=OrderResponse)
|
||
async def get_my_order(
|
||
vendor_id: int,
|
||
order_id: int,
|
||
current_customer: Customer = Depends(get_current_customer),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""Get specific order details"""
|
||
|
||
order = db.query(Order).filter(
|
||
Order.id == order_id,
|
||
Order.vendor_id == vendor_id,
|
||
Order.customer_id == current_customer.id
|
||
).first()
|
||
|
||
if not order:
|
||
raise HTTPException(status_code=404, detail="Order not found")
|
||
|
||
return order
|
||
```
|
||
|
||
#### Vendor Order Management Endpoints (`app/api/v1/vendor/orders.py`)
|
||
|
||
```python
|
||
@router.get("", response_model=List[OrderResponse])
|
||
async def get_vendor_orders(
|
||
status: Optional[str] = None,
|
||
customer_id: Optional[int] = None,
|
||
date_from: Optional[datetime] = None,
|
||
date_to: Optional[datetime] = None,
|
||
search: Optional[str] = None,
|
||
skip: int = 0,
|
||
limit: int = 50,
|
||
current_user: User = Depends(get_current_vendor_user),
|
||
vendor: Vendor = Depends(get_current_vendor),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""Get vendor's orders with filters"""
|
||
|
||
filters = OrderListFilters(
|
||
status=status,
|
||
customer_id=customer_id,
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
search=search
|
||
)
|
||
|
||
service = OrderService()
|
||
orders = service.get_vendor_orders(vendor.id, db, filters, skip, limit)
|
||
|
||
return orders
|
||
|
||
@router.get("/{order_id}", response_model=OrderResponse)
|
||
async def get_order_details(
|
||
order_id: int,
|
||
current_user: User = Depends(get_current_vendor_user),
|
||
vendor: Vendor = Depends(get_current_vendor),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""Get order details"""
|
||
|
||
order = db.query(Order).filter(
|
||
Order.id == order_id,
|
||
Order.vendor_id == vendor.id
|
||
).first()
|
||
|
||
if not order:
|
||
raise HTTPException(status_code=404, detail="Order not found")
|
||
|
||
return order
|
||
|
||
@router.put("/{order_id}/status", response_model=OrderResponse)
|
||
async def update_order_status(
|
||
order_id: int,
|
||
status_update: OrderStatusUpdate,
|
||
current_user: User = Depends(get_current_vendor_user),
|
||
vendor: Vendor = Depends(get_current_vendor),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""Update order status"""
|
||
|
||
service = OrderService()
|
||
order = await service.update_order_status(
|
||
vendor.id,
|
||
order_id,
|
||
status_update,
|
||
current_user.id,
|
||
db
|
||
)
|
||
|
||
return order
|
||
```
|
||
|
||
## 🎨 Frontend Implementation
|
||
|
||
### Templates
|
||
|
||
#### Checkout Page (`templates/shop/checkout.html`)
|
||
|
||
```html
|
||
{% extends "shop/base_shop.html" %}
|
||
|
||
{% block content %}
|
||
<div x-data="checkout()" x-init="init()">
|
||
<!-- Progress Steps -->
|
||
<div class="checkout-steps">
|
||
<div class="step" :class="{'active': currentStep >= 1, 'completed': currentStep > 1}">
|
||
<span class="step-number">1</span>
|
||
<span class="step-label">Cart Review</span>
|
||
</div>
|
||
<div class="step" :class="{'active': currentStep >= 2, 'completed': currentStep > 2}">
|
||
<span class="step-number">2</span>
|
||
<span class="step-label">Shipping</span>
|
||
</div>
|
||
<div class="step" :class="{'active': currentStep >= 3, 'completed': currentStep > 3}">
|
||
<span class="step-number">3</span>
|
||
<span class="step-label">Payment</span>
|
||
</div>
|
||
<div class="step" :class="{'active': currentStep === 4}">
|
||
<span class="step-number">4</span>
|
||
<span class="step-label">Confirmation</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 1: Cart Review -->
|
||
<div x-show="currentStep === 1" class="checkout-section">
|
||
<h2>Review Your Cart</h2>
|
||
<div class="cart-items">
|
||
<template x-for="item in cart.items" :key="item.id">
|
||
<div class="cart-item">
|
||
<img :src="item.product_image" :alt="item.product_title">
|
||
<div class="item-details">
|
||
<h3 x-text="item.product_title"></h3>
|
||
<p>Quantity: <span x-text="item.quantity"></span></p>
|
||
<p class="price">€<span x-text="item.line_total.toFixed(2)"></span></p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="cart-summary">
|
||
<p>Subtotal: €<span x-text="cart.subtotal.toFixed(2)"></span></p>
|
||
<button @click="nextStep()" class="btn btn-primary btn-lg">
|
||
Continue to Shipping
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 2: Shipping Address -->
|
||
<div x-show="currentStep === 2" class="checkout-section">
|
||
<h2>Shipping Address</h2>
|
||
|
||
<!-- Existing Addresses -->
|
||
<template x-if="addresses.length > 0">
|
||
<div class="address-selection">
|
||
<template x-for="address in addresses" :key="address.id">
|
||
<div
|
||
class="address-card"
|
||
:class="{'selected': selectedShippingAddress === address.id}"
|
||
@click="selectedShippingAddress = address.id"
|
||
>
|
||
<input
|
||
type="radio"
|
||
:value="address.id"
|
||
x-model="selectedShippingAddress"
|
||
>
|
||
<div class="address-info">
|
||
<strong x-text="`${address.first_name} ${address.last_name}`"></strong>
|
||
<p x-text="address.address_line1"></p>
|
||
<p x-text="`${address.postal_code} ${address.city}`"></p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<button @click="showAddressForm = true" class="btn btn-secondary">
|
||
+ Add New Address
|
||
</button>
|
||
|
||
<!-- New Address Form -->
|
||
<div x-show="showAddressForm" class="address-form">
|
||
<!-- Address form fields -->
|
||
</div>
|
||
|
||
<div class="checkout-actions">
|
||
<button @click="prevStep()" class="btn btn-secondary">Back</button>
|
||
<button
|
||
@click="nextStep()"
|
||
class="btn btn-primary"
|
||
:disabled="!selectedShippingAddress"
|
||
>
|
||
Continue to Payment
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 3: Payment -->
|
||
<div x-show="currentStep === 3" class="checkout-section">
|
||
<h2>Payment Method</h2>
|
||
|
||
<div class="payment-methods">
|
||
<div class="payment-option">
|
||
<input type="radio" value="stripe" x-model="paymentMethod">
|
||
<label>Credit Card (Stripe)</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stripe Payment Element -->
|
||
<div id="payment-element">
|
||
<!-- Stripe.js will inject payment form here -->
|
||
</div>
|
||
|
||
<div class="order-summary">
|
||
<h3>Order Summary</h3>
|
||
<p>Subtotal: €<span x-text="cart.subtotal.toFixed(2)"></span></p>
|
||
<p>Shipping: €<span x-text="shippingCost.toFixed(2)"></span></p>
|
||
<p>Tax: €<span x-text="taxAmount.toFixed(2)"></span></p>
|
||
<p class="total">Total: €<span x-text="totalAmount.toFixed(2)"></span></p>
|
||
</div>
|
||
|
||
<div class="checkout-actions">
|
||
<button @click="prevStep()" class="btn btn-secondary">Back</button>
|
||
<button
|
||
@click="placeOrder()"
|
||
class="btn btn-primary btn-lg"
|
||
:disabled="placing"
|
||
>
|
||
<span x-show="!placing">Place Order</span>
|
||
<span x-show="placing" class="loading-spinner"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 4: Confirmation -->
|
||
<div x-show="currentStep === 4" class="checkout-section">
|
||
<div class="order-confirmation">
|
||
<h2>✓ Order Confirmed!</h2>
|
||
<p>Thank you for your order!</p>
|
||
<p>Order Number: <strong x-text="orderNumber"></strong></p>
|
||
<p>We've sent a confirmation email to your address.</p>
|
||
|
||
<a href="/shop/account/orders" class="btn btn-primary">
|
||
View Order Details
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
window.vendorId = {{ vendor.id }};
|
||
window.customerId = {{ customer.id if customer else 'null' }};
|
||
</script>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script src="https://js.stripe.com/v3/"></script>
|
||
<script>
|
||
function checkout() {
|
||
return {
|
||
currentStep: 1,
|
||
cart: {},
|
||
addresses: [],
|
||
selectedShippingAddress: null,
|
||
selectedBillingAddress: null,
|
||
paymentMethod: 'stripe',
|
||
showAddressForm: false,
|
||
placing: false,
|
||
orderNumber: null,
|
||
|
||
// Calculated
|
||
shippingCost: 5.00,
|
||
taxRate: 0.17,
|
||
|
||
get taxAmount() {
|
||
return this.cart.subtotal * this.taxRate;
|
||
},
|
||
|
||
get totalAmount() {
|
||
return this.cart.subtotal + this.shippingCost + this.taxAmount;
|
||
},
|
||
|
||
async init() {
|
||
await this.loadCart();
|
||
await this.loadAddresses();
|
||
},
|
||
|
||
async loadCart() {
|
||
try {
|
||
const sessionId = getSessionId();
|
||
this.cart = await apiClient.get(
|
||
`/api/v1/public/vendors/${window.vendorId}/cart/${sessionId}`
|
||
);
|
||
} catch (error) {
|
||
showNotification('Failed to load cart', 'error');
|
||
}
|
||
},
|
||
|
||
async loadAddresses() {
|
||
try {
|
||
this.addresses = await apiClient.get(
|
||
`/api/v1/public/vendors/${window.vendorId}/customers/addresses`
|
||
);
|
||
|
||
// Select default address
|
||
const defaultAddr = this.addresses.find(a => a.is_default);
|
||
if (defaultAddr) {
|
||
this.selectedShippingAddress = defaultAddr.id;
|
||
this.selectedBillingAddress = defaultAddr.id;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load addresses', error);
|
||
}
|
||
},
|
||
|
||
nextStep() {
|
||
if (this.currentStep < 4) {
|
||
this.currentStep++;
|
||
}
|
||
},
|
||
|
||
prevStep() {
|
||
if (this.currentStep > 1) {
|
||
this.currentStep--;
|
||
}
|
||
},
|
||
|
||
async placeOrder |