nick's shopping cart
postgres shopping cart table, has both nick and andy's items
nick checks out
shopping cart table, nicks items are cleared
charges are reflected on stripe website
he click to view his orders
all his orders are collapsed and sorted by date
he can expand to view order detail
keep expanding sub collapsed categories
collapse the category that has been viewed and open the other
postgres order history table, that contains stripe charge id
#django/models
class ShoppingItem(models.Model):
shopper = models.ForeignKey(User, on_delete=models.CASCADE, default=1)
album = models.ForeignKey(Album, on_delete=models.CASCADE, blank=True, default=1)
quantity = models.PositiveIntegerField(blank=True, default=0)
def __str__(self):
return 'shopper ' + str(self.shopper) + ' - album ' + str(self.album) + ' - quantity '+str(self.quantity)
class OrderHistory(models.Model):
order = models.CharField(max_length=50)
shopper = models.ForeignKey(User, on_delete=models.CASCADE)
total = models.DecimalField(decimal_places=2,max_digits=7, default=0)
date = models.DateTimeField(auto_now_add=True)
def __str__(self):
return str(self.order)+ ' ' + str(self.shopper) + ' bought ' + str(self.total) + ' on ' + str(self.date)
---------------------------------------
#django/api/serializers
class ShoppingItemsSerializer(serializers.ModelSerializer):
class Meta:
model = ShoppingItem
fields = ('id', 'shopper', 'album', 'quantity')
class OrderHistorySerializer(serializers.ModelSerializer):
class Meta:
model = OrderHistory
fields = ('id', 'order', 'shopper', 'total', 'date')
-----------------------------------
#django/api/url
path('cart_checkout/', apiview.CartCheckout.as_view(), name='CartCheckout'),
path('order_history/', apiview.OrderHistoryList.as_view(), name='OrderHistory'),
path('shoppingItem/<int:pk>/', apiview.ShoppingItemDetail.as_view(), name='ShoppingItemDetail'),
path('orderDetail/<int:pk>/', apiview.OrderHistoryDetail.as_view(), name='OrderDetail'),
path('shoppingItems/', apiview.ShoppingItemsList.as_view(), name='ShoppingItems'),
---------------------------------------------------
#django/api/apiview
class ShoppingItemsList(APIView):
def get(self, request, format=None):
#for privacy, only return shoppers items
shoppingItems = ShoppingItem.objects.filter(shopper=request.user)
serializer = ShoppingItemsSerializer(shoppingItems, many=True)
return Response(serializer.data)
def post(self, request, format=None):
#customer has item in cart
try:
revisitedItem = ShoppingItem.objects.get(shopper=request.user, album=request.data.get('album'))
revisitedItem.quantity= int(request.data.get('quantity')) + revisitedItem.quantity
serializer = ShoppingItemsSerializer(data=model_to_dict( revisitedItem ))
if serializer.is_valid():
# update database
revisitedItem.save()
return Response(serializer.data, status=status.HTTP_202_ACCEPTED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
#customer select new item
except exceptions.ObjectDoesNotExist:
serializer = ShoppingItemsSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ShoppingItemDetail(APIView):
def get_object(self, pk):
try:
return ShoppingItem.objects.get(pk=pk)
except ShoppingItem.DoesNotExist:
raise Http404
def get(self, request, pk, format=None):
shoppingItem = self.get_object(pk)
serializer = ShoppingItemsSerializer(shoppingItem)
return Response(serializer.data)
def put(self, request, pk, format=None):
shoppintItem = self.get_object(pk)
#only owner can edit
if shoppintItem.shopper != request.user:
return Response({"detail": "You do not have permission to perform this action."},
status= status.HTTP_403_FORBIDDEN)
serializer = ShoppingItemsSerializer(shoppintItem, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, pk, format=None):
shoppingItem = self.get_object(pk)
# only owner can delete
if shoppingItem.shopper != request.user:
return Response({"detail": "You do not have permission to perform this action."},
status=status.HTTP_403_FORBIDDEN)
shoppingItem.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class CartCheckout(APIView):
def post(self, request, format=None):
stripe.api_key = settings.STRIPE_SECRET_KEY
token = request.data.get('token')
cart = request.data.get('cart')
try:
customer=stripe.Customer.create(email=token['email'],source=token['id'])
charge=stripe.Charge.create(
amount=cart['price']*100,
currency='cad',
receipt_email= token['email'],
customer=customer.id,
metadata=cart['items'],
)
return Response(charge, status=status.HTTP_202_ACCEPTED)
except Exception as e:
return Response(e, status=status.HTTP_400_BAD_REQUEST)
class OrderHistoryList(APIView):
def get(self, request, format=None):
orders = OrderHistory.objects.filter(shopper=request.user)
serializer = OrderHistorySerializer(orders, many=True)
return Response(serializer.data)
def post(self, request, format=None):
serializer = OrderHistorySerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class OrderHistoryDetail(APIView):
def get_object(self, pk):
try:
return OrderHistory.objects.get(pk=pk)
except OrderHistory.DoesNotExist:
raise Http404
def get(self, request, pk, format=None):
stripe.api_key = settings.STRIPE_SECRET_KEY
order = self.get_object(pk)
try:
#order is a model boject, request is a python dictionary
charge=stripe.Charge.retrieve(order.order)
return Response(charge, status=status.HTTP_200_OK)
except Exception as e:
return Response(e, status=status.HTTP_400_BAD_REQUEST)
-------------------------------------
import React, { Component } from 'react';
import '../App.css';
import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';
import { getShoppingItems } from '../redux/actions/getShoppingIItems';
import { getAlbums } from '../redux/actions/getAlbums';
import { getUsers } from '../redux/actions/getUser';
import { putShoppingItem } from '../redux/actions/putShoppingItem';
import { deleteShoppingItem } from '../redux/actions/deleteShoppingItem'
import { checkoutCart } from '../redux/actions/checkoutCart'
import { postOrderHistory } from '../redux/actions/postOrderHistory'
import { Input, message, Tag } from 'antd';
import { Button } from 'reactstrap';
import { MdExposureNeg1, MdExposurePlus1 } from "react-icons/md";
import Stripecheckout from 'react-stripe-checkout';
class ShoppingCart extends Component {
constructor(props) {
super(props);
this.state = {
qty: [],
qty_input: [],
qty_loaded: false,
};
}
componentDidMount() {
//start fetching database once logged in
if (this.props.loggedin) {
if (!this.props.gotAlbums) {
this.props.dispatch(getAlbums(this.props.token));
}
if (!this.props.gotUsers) {
this.props.dispatch(getUsers(this.props.token));
}
if (!this.props.gotShoppingItems) {
this.props.dispatch(getShoppingItems(this.props.token));
}
//wait for 5sec, check every 0.1s to see if shoppingItems are fetched
let i = 0;
const waitShoppingItem = setInterval(async () => {
if (this.props.gotShoppingItems) {
await this.setState({ qty: [], qty_input: [], qty_loaded: false })
this.props.shoppingItems.map(async (item, index) => {
await this.setState(prevState => {
return {
qty: prevState.qty.concat({ album_id: item.album, qty: item.quantity }),
qty_input: prevState.qty_input.concat({ album_id: item.album, qty: null })
}
})
if (this.state.qty.length === this.props.shoppingItems.length) { this.setState({ qty_loaded: true }) }
})
clearInterval(waitShoppingItem);
}
if (i == 50) {
message.error('fetching shopping items timed out.')
clearInterval(waitShoppingItem);
}
i++;
}, 100)
}
}
add = async (album_id) => {
await this.setState(prevState => { return { qty_input: prevState.qty_input.filter(item => item.album_id !== album_id).concat({ album_id: album_id, qty: null }) } });
this.setState(prevState => { return { qty: prevState.qty.filter(item => item.album_id !== album_id).concat({ album_id: album_id, qty: prevState.qty.find(item => item.album_id === album_id).qty + 1 }) } })
}
subtract = async (album_id) => {
await this.setState(prevState => { return { qty_input: prevState.qty_input.filter(item => item.album_id !== album_id).concat({ album_id: album_id, qty: null }) } });
if (this.state.qty.find(item => item.album_id === album_id).qty > 1) {
this.setState(prevState => { return { qty: prevState.qty.filter(item => item.album_id !== album_id).concat({ album_id: album_id, qty: prevState.qty.find(item => item.album_id === album_id).qty - 1 }) } })
}
}
inputChange = (e, album_id) => {
if (e.target.value > 0) {
const newQty = parseInt(e.target.value)
this.setState(prevState => { return { qty: prevState.qty.filter(item => item.album_id !== album_id).concat({ album_id: album_id, qty: newQty }) } })
this.setState(prevState => { return { qty_input: prevState.qty_input.filter(item => item.album_id !== album_id).concat({ album_id: album_id, qty: newQty }) } })
} else {
message.error('entered invalid value')
}
}
deleteItem = (id) => {
this.props.dispatch(deleteShoppingItem(this.props.token, id));
//wait for 5sec, check every sec to see if delete successful
let i = 0;
const waitDelete = setInterval(() => {
if (this.props.shoppingItemDeleted) {
clearInterval(waitDelete);
}
if (i == 50) {
message.error('connection timed out.')
clearInterval(waitDelete);
}
i++;
}, 100)
}
updateCart = () => {
this.state.qty.map((item, index) => {
//only update items with qty changed
const oldItem = this.props.shoppingItems.find(i => i.album === item.album_id)
if (item.qty !== oldItem.quantity) {
const formData = new FormData();
formData.set('shopper', this.props.users.find(user => user.username === this.props.username).id);
formData.set('album', item.album_id);
formData.set('quantity', item.qty);
this.props.dispatch(putShoppingItem(this.props.token, oldItem.id, formData));
//wait for 5sec, check every sec to see if updateShoppingItem successful
let i = 0;
const waitUpdate = setInterval(() => {
const newItem = this.props.shoppingItems.find(i => i.album === item.album_id)
if (newItem.quantity === item.qty) {
message.success('Updated quantity of ' + this.props.albums.filter(album => album.id === item.album_id)[0].album_title + ' to ' + item.qty)
clearInterval(waitUpdate);
}
if (i == 50) {
message.error('connection timed out.')
clearInterval(waitUpdate);
}
i++;
}, 100)
}
})
}
checkout = (token) => {
let total = 0
if (this.state.qty_loaded) {
let i = 0, items = {}
this.props.shoppingItems.map((item, index) => {
const album = this.props.albums.find(album => album.id === item.album)
total = total + album.price * item.quantity
i++
items['item' + i.toString()] = album.album_title + ' $' + album.price + ' x ' + item.quantity
})
const cartName = this.props.username + "'s Cart"
const cart = { name: cartName, price: total, items: items }
this.props.dispatch(checkoutCart(this.props.token, { token, cart }))
}
else {
message.error('please wait for cart info been retrieved')
}
//wait for 5sec, check every sec to see if checkout successful
let i = 0;
const waitCheckout = setInterval(() => {
if (this.props.checkedout) {
message.success('payment received')
//save order to history
const formData = new FormData();
formData.set('shopper', this.props.users.find(user => user.username === this.props.username).id);
formData.set('order', this.props.orderid);
formData.set('total', total);
this.props.dispatch(postOrderHistory(this.props.token, formData))
//wait for 5sec, check every sec to see if postOrderHistory successful
let i = 0;
const waitAdd = setInterval(() => {
if (this.props.orderPosted) {
message.success('order posted')
//empty cart
this.emptyCart()
//display receipt
window.open(this.props.receipt, '_blank')
clearInterval(waitAdd);
}
if (i == 50) {
message.error('connection timed out.')
clearInterval(waitAdd);
}
i++;
}, 100)
clearInterval(waitCheckout);
}
if (i == 50) {
message.error('connection timed out.')
clearInterval(waitCheckout);
}
i++;
}, 100)
}
emptyCart = () => {
this.props.shoppingItems.map((item, index) => {
this.deleteItem(item.id)
})
}
render() {
if (!this.props.loggedin) {
return <Redirect to='/login' />
}
let total = 0
return (
<div style={{ padding: '10px', marginTop: '10px' }}>
<legend>Shopping Cart</legend>
<hr />
<div style={{ color: 'red' }}>{this.props.errorShoppingItem} {this.props.errorAlbum}</div>
{
this.state.qty_loaded ?
this.props.shoppingItems
.sort((a, b) => { return this.props.albums.find(album => album.id === a.album).album_title.toUpperCase().localeCompare(this.props.albums.find(album => album.id === b.album).album_title.toUpperCase()) })
.map((item, index) => {
const album = this.props.albums.find(album => album.id === item.album)
total = total + album.price * item.quantity
return <div key={index}>
<div style={{ width: '90%', display: 'flex', justifyContent: 'space-between' }}>
<div style={{ width: '90%', display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontStyle: 'italic' }}>{album.album_title} <Tag color='geekblue'>${album.price}</Tag></span>
<Input
type='number'
onChange={(e) => { this.inputChange(e, item.album) }}
style={{ width: '150px' }}
addonBefore={<MdExposureNeg1 onClick={() => this.subtract(item.album)} style={{ cursor: 'pointer', fontSize: '20px' }} />}
addonAfter={<MdExposurePlus1 onClick={() => this.add(item.album)} style={{ cursor: 'pointer', fontSize: '20px' }} />}
value={this.state.qty_input.find(i => i.album_id === item.album).qty || this.state.qty.find(i => i.album_id === item.album).qty}
/>
</div>
<b style={{ textAlign: 'right' }} onClick={() => this.deleteItem(item.id)} style={{ cursor: 'pointer' }}>X</b>
</div>
<hr />
</div>
})
: null
}
<div style={{ width: '90%', display: 'flex', justifyContent: 'space-between' }}>
<span>Total: <Tag color='geekblue'>${total}</Tag></span>
<div>
<Button color='success' size='sm' onClick={() => this.updateCart()} style={{ marginRight: '15px' }}>Update</Button>
<Stripecheckout
stripeKey='pk_test_i8PD1nMcLZvtkCghkFKU1r0d00P9v7L9wg'
token={this.checkout}
billingAddress
shippingAddress
amount={total * 100}
name={this.props.username + "'s Cart"}
currency='CAD'
/>
</div>
</div>
</div>
);
}
}
export default connect(
(store) => {
return {
loggedin: store.login.fetched,
gotShoppingItems: store.shoppingItems.fetched,
shoppingItems: store.shoppingItems.shoppingItems,
errorShoppingItem: store.shoppingItems.error,
shoppingItemUpdated: store.shoppingItems.updated,
shoppingItemDeleted: store.shoppingItems.deleted,
albums: store.albums.albums,
gotAlbums: store.albums.fetched,
errorAlbum: store.albums.error,
users: store.users.users,
gotUsers: store.users.fetched,
errorUser: store.users.error,
username: store.login.username,
token: store.login.token,
loggedin: store.login.fetched,
checkedout: store.shoppingItems.checkedOut,
receipt: store.shoppingItems.receipt,
orderid: store.shoppingItems.orderid,
orderPosted: store.orderHistory.added,
};
}
)(ShoppingCart);
---------------------------------------
//react/pages/orderHistory
import React, { Component } from 'react';
import '../App.css';
import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';
import { getOrderHistory } from '../redux/actions/getOrderHistory';
import { message, Collapse } from 'antd';
import OrderCollapse from '../partials/orderCollapse';
import { deflateSync } from 'zlib';
class OrderHistory extends Component {
constructor(props) {
super(props);
this.state = {
};
}
componentDidMount() {
//start fetching database once logged in
if (this.props.loggedin) {
if (!this.props.gotOrders) {
this.props.dispatch(getOrderHistory(this.props.token));
}
//wait for 5sec, check every 0.1s to see if orders are fetched
let i = 0;
const waitOrders = setInterval(() => {
if (this.props.gotOrders) {
clearInterval(waitOrders);
}
if (i == 50) {
message.error('fetching order history timed out.')
clearInterval(waitOrders);
}
i++;
}, 100)
}
}
render() {
if (!this.props.loggedin) {
return <Redirect to='/login' />
}
return (
<div style={{ padding: '10px', marginTop: '10px' }}>
<legend>Order History</legend>
<hr />
{this.props.gotOrders ?
this.props.orders.sort((a, b) => { return new Date(b.date) - new Date(a.date) })
.map((order, index) => {
return <OrderCollapse header={order.date.toString()} extra={order.total.toString()}
orderid={order.id} key={index} />
})
: null}
</div>
);
}
}
export default connect(
(store) => {
return {
loggedin: store.login.fetched,
token: store.login.token,
gotOrders: store.orderHistory.fetched,
orders: store.orderHistory.orders,
};
}
)(OrderHistory);
-------------------------------------
//react/partials/orderCollapse
import React, { Component } from 'react';
import '../App.css';
import { connect } from 'react-redux';
import { getOrderDetail } from '../redux/actions/getOrderDetail';
import { message, Collapse } from 'antd';
class OrderCollapse extends Component {
constructor(props) {
super(props);
this.state = {
open: false,
content: null,
};
}
jsonToArray = (jsonText) => {
return JSON.stringify(jsonText).replace('{', '').replace('}', '').replace(/"/g, '').split(',')
}
orderDetail = async (orderid) => {
await this.setState(prevState => { return { open: !prevState.open } })
if (this.state.open) {
this.props.dispatch(getOrderDetail(this.props.token, orderid));
//wait for 5sec, check every 0.1s to see if order detail is fetched
let i = 0;
const waitOrderDetail = setInterval(() => {
if (this.props.gotOrderDetail) {
const sales = this.jsonToArray(this.props.orderDetail.metadata)
const address = this.jsonToArray(this.props.orderDetail.billing_details.address)
const cardFull = this.props.orderDetail.payment_method_details.card
delete cardFull.checks
const card = this.jsonToArray(cardFull)
const content = <div>
{sales.map((sale, index) => { return <div key={'s' + index.toString()}>{sale.replace(':', ': ')}</div> })}
<Collapse bordered={false} style={{ backgroundColor: 'rgba(0, 0, 0, 0)' }}>
<Collapse.Panel header={'billing address'} >
{address.map((addr, index) => { return <div key={'a' + index.toString()}>{addr.replace(':', ': ')}</div> })}
</Collapse.Panel>
</Collapse>
<Collapse bordered={false} style={{ backgroundColor: 'rgba(0, 0, 0, 0)' }}>
<Collapse.Panel header={'payment card'} >
{card.map((c, index) => { return <div key={'c' + index.toString()}>{c.replace(':', ': ')}</div> })}
</Collapse.Panel>
</Collapse>
</div>
this.setState({ content: content })
clearInterval(waitOrderDetail);
}
if (i == 50) {
message.error('fetching order detail timed out.')
clearInterval(waitOrderDetail);
}
i++;
}, 100)
}
}
render() {
const { Panel } = Collapse;
return (
<Collapse bordered={false} onChange={() => this.orderDetail(this.props.orderid)}
style={{ backgroundColor: 'rgba(0, 0, 0, 0.1)' }}>
<Panel header={this.props.header} extra={'$' + this.props.extra}>
{this.state.content ? this.state.content : 'fetching details'}
</Panel>
</Collapse>
);
}
}
export default connect(
(store) => {
return {
token: store.login.token,
gotOrderDetail: store.orderHistory.gotDetail,
orderDetail: store.orderHistory.detail,
};
}
)(OrderCollapse);
--------------------------------------
//redux/actions/getOrderDetail
import axios from 'axios';
export function getOrderDetail(token, id) {
return {
type: "get_orderDetail",
payload: axios({
method: 'get',
url: 'http://127.0.0.1:8000/api/orderDetail/' + id.toString() + '/',
headers: {
Authorization: 'Token ' + token,
},
})
}
}
---------------------------------
//redux/actions/getOrderHistory
import axios from 'axios';
export function getOrderHistory(token) {
return {
type: "fetch_orders",
payload: axios({
method: 'get',
url: 'http://127.0.0.1:8000/api/order_history/',
headers: {
Authorization: 'Token ' + token
},
})
}
}
---------------------------------
//redux/actions/postOrderHistory
import axios from 'axios';
export function postOrderHistory(token, data) {
return {
type: "add_order",
payload: axios({
method: 'post',
url: 'http://127.0.0.1:8000/api/order_history/',
headers: {
Authorization: 'Token ' + token,
},
data: data,
})
}
}
----------------------------------
//redux/reducers/orderHistoryReducer
export default function reducer(
state = {
orders: [],
fetching: false,
fetched: false,
adding: false,
added: false,
deleting: false,
deleted: false,
updating: false,
updated: false,
gettingDetail: false,
gotDetail: false,
detail: null,
error: ''
},
action
) {
switch (action.type) {
case "fetch_orders_PENDING": {
return { ...state, fetching: true, fetched: false, error: '' }
}
case "fetch_orders_FULFILLED": {
return {
...state,
fetching: false,
fetched: true,
orders: action.payload.data,
error: ''
}
}
case "fetch_orders_REJECTED": {
return {
...state,
fetching: false,
error: JSON.stringify(action.payload.response.data),
}
}
case "get_orderDetail_PENDING": {
return { ...state, gettingDetail: true, gotDetail: false, error: '' }
}
case "get_orderDetail_FULFILLED": {
return {
...state,
gettingDetail: false,
gotDetail: true,
detail: action.payload.data,
error: ''
}
}
case "get_orderDetail_REJECTED": {
return {
...state,
gettingDetail: false,
error: JSON.stringify(action.payload.response.data),
}
}
case "add_order_PENDING": {
return { ...state, adding: true, added: false, error: '' }
}
case "add_order_FULFILLED": {
return {
...state,
adding: false,
added: true,
orders: [...state.orders].concat(action.payload.data),
error: ''
}
}
case "add_order_REJECTED": {
return {
...state,
adding: false,
error: JSON.stringify(action.payload.response.data),
}
}
case "delete_order_PENDING": {
return { ...state, deleting: true, deleted: false, error: '' }
}
case "delete_order_FULFILLED": {
const deleteId = action.payload.config.data;
return {
...state,
deleting: false,
deleted: true,
orders: [...state.orders].filter(order => order.id != deleteId),
error: ''
}
}
case "delete_order_REJECTED": {
return {
...state,
deleting: false,
error: JSON.stringify(action.payload.response.data),
}
}
case "reset": {
return {
...state,
orders: [],
fetching: false,
fetched: false,
adding: false,
added: false,
deleting: false,
deleted: false,
updating: false,
updated: false,
gettingDetail: false,
gotDetail: false,
detail: null,
error: ''
}
}
case "update_order_PENDING": {
return { ...state, updating: true, updated: false, error: '' }
}
case "update_order_FULFILLED": {
const data = action.payload.data;
return {
...state,
updating: false,
updated: true,
orders: [...state.orders].filter(order => order.id != data.id).concat(data),
error: ''
}
}
case "update_order_REJECTED": {
return {
...state,
updating: false,
error: JSON.stringify(action.payload.response.data),
}
}
default:
break;
}
return state;
}
reference:
https://stripe.com/docs/api/charges/retrieve
https://www.w3schools.com/jsref/jsref_replace.asp
https://stackoverflow.com/questions/208105/how-do-i-remove-a-property-from-a-javascript-object
https://stackoverflow.com/questions/10123953/how-to-sort-an-array-by-a-date-property
No comments:
Post a Comment