diff --git a/packages/evershop/src/components/frontStore/checkout/cart/items/Items.jsx b/packages/evershop/src/components/frontStore/checkout/cart/items/Items.jsx index 5e6450b20..3e1c5b7ce 100644 --- a/packages/evershop/src/components/frontStore/checkout/cart/items/Items.jsx +++ b/packages/evershop/src/components/frontStore/checkout/cart/items/Items.jsx @@ -7,10 +7,10 @@ import ProductNoThumbnail from '@components/common/ProductNoThumbnail'; import { ItemOptions } from './ItemOptions'; import { ItemVariantOptions } from './ItemVariantOptions'; import './Items.scss'; +import Quantity from './Quantity'; function Items({ items, setting: { priceIncludingTax } }) { const AppContextDispatch = useAppDispatch(); - const removeItem = async (item) => { const response = await fetch(item.removeApi, { method: 'DELETE', @@ -124,13 +124,12 @@ function Items({ items, setting: { priceIncludingTax } }) { </span> </div> )} - <div className="md:hidden mt-2"> - <span>{_('Qty')}</span> - <span>{item.qty}</span> + <div className="md:hidden mt-2 flex justify-end"> + <Quantity qty={item.qty} api={item.updateQtyApi} /> </div> </td> <td className="hidden md:table-cell"> - <span>{item.qty}</span> + <Quantity qty={item.qty} api={item.updateQtyApi} /> </td> <td className="hidden md:table-cell"> <span> @@ -180,7 +179,8 @@ Items.propTypes = { value: PropTypes.number, text: PropTypes.string }), - removeApi: PropTypes.string + removeApi: PropTypes.string, + updateQtyApi: PropTypes.string }) ).isRequired, setting: PropTypes.shape({ diff --git a/packages/evershop/src/components/frontStore/checkout/cart/items/Items.scss b/packages/evershop/src/components/frontStore/checkout/cart/items/Items.scss index bd84a8c59..c807a4a8a 100644 --- a/packages/evershop/src/components/frontStore/checkout/cart/items/Items.scss +++ b/packages/evershop/src/components/frontStore/checkout/cart/items/Items.scss @@ -47,4 +47,51 @@ } } } + .qty-box { + max-width: 130px; + button { + .spinner { + animation: rotator 1.4s linear infinite; + .path { + stroke-dasharray: 280; + stroke-dashoffset: 0; + transform-origin: center; + stroke: var(--primary); + animation: dash 1.4s ease-in-out infinite; + } + } + svg { + width: 1.1rem; + height: 1.1rem; + } + } + input { + text-align: center; + padding-left: 10px; + padding-right: 10px; + } + } } + +@keyframes rotator { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(270deg); + } +} + +@keyframes dash { + 0% { + stroke-dashoffset: 280; + } + 50% { + stroke-dashoffset: 70; + transform: rotate(135deg); + } + 100% { + stroke-dashoffset: 280; + transform: rotate(450deg); + } +} \ No newline at end of file diff --git a/packages/evershop/src/components/frontStore/checkout/cart/items/Quantity.jsx b/packages/evershop/src/components/frontStore/checkout/cart/items/Quantity.jsx new file mode 100644 index 000000000..369bad748 --- /dev/null +++ b/packages/evershop/src/components/frontStore/checkout/cart/items/Quantity.jsx @@ -0,0 +1,154 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useAppDispatch } from '@components/common/context/app'; +import { toast } from 'react-toastify'; + +export default function Quantity({ qty, api }) { + const AppContextDispatch = useAppDispatch(); + const [quantity, setQuantity] = React.useState(qty); + const previousQuantity = React.useRef(qty); + const [debounceTimer, setDebounceTimer] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + + const updateQuantity = (newQuantity) => { + setQuantity(newQuantity); + if (debounceTimer) { + clearTimeout(debounceTimer); + } + const timer = setTimeout(() => { + callUpdateAPI(newQuantity); + }, 500); + setDebounceTimer(timer); + }; + + const callUpdateAPI = async (qty) => { + setIsLoading(true); + try { + const response = await fetch(api, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + qty: Math.abs(previousQuantity.current - qty), + action: qty > quantity ? 'increase' : 'decrease' + }), + credentials: 'same-origin' + }); + const json = await response.json(); + if (!json.error) { + const url = new URL(window.location.href); + url.searchParams.set('ajax', true); + await AppContextDispatch.fetchPageData(url); + previousQuantity.current = qty; + } else { + setQuantity(previousQuantity.current); + toast.error(json.error.message); + } + } catch (error) { + setQuantity(previousQuantity.current); + toast.error(error.message); + } finally { + setIsLoading(false); + } + }; + + return ( + <div className="qty-box grid grid-cols-3 border border-[#ccc]"> + <button + className="flex justify-center items-center" + onClick={() => updateQuantity(Math.max(quantity - 1, 0))} + disabled={isLoading} + type="button" + > + {isLoading && ( + <svg + ariaHidden="true" + focusable="false" + role="presentation" + className="spinner" + viewBox="0 0 66 66" + xmlns="http://www.w3.org/2000/svg" + > + <circle + className="path" + fill="none" + strokeWidth="6" + cx="33" + cy="33" + r="30" + /> + </svg> + )} + {!isLoading && ( + <svg + xmlns="http://www.w3.org/2000/svg" + ariaHidden="true" + focusable="false" + role="presentation" + className="icon icon-minus" + fill="none" + viewBox="0 0 10 2" + > + <path + fillRule="evenodd" + clipRule="evenodd" + d="M.5 1C.5.7.7.5 1 .5h8a.5.5 0 110 1H1A.5.5 0 01.5 1z" + fill="currentColor" + /> + </svg> + )} + </button> + <input type="text" value={quantity} /> + <button + className="flex justify-center items-center" + onClick={() => updateQuantity(quantity + 1)} + disabled={isLoading} + type="button" + > + {isLoading && ( + <svg + ariaHidden="true" + focusable="false" + role="presentation" + className="spinner" + viewBox="0 0 66 66" + xmlns="http://www.w3.org/2000/svg" + > + <circle + className="path" + fill="none" + strokeWidth="6" + cx="33" + cy="33" + r="30" + /> + </svg> + )} + {!isLoading && ( + <svg + xmlns="http://www.w3.org/2000/svg" + ariaHidden="true" + focusable="false" + role="presentation" + className="icon icon-plus" + fill="none" + viewBox="0 0 10 10" + > + <path + fillRule="evenodd" + clipRule="evenodd" + d="M1 4.51a.5.5 0 000 1h3.5l.01 3.5a.5.5 0 001-.01V5.5l3.5-.01a.5.5 0 00-.01-1H5.5L5.49.99a.5.5 0 00-1 .01v3.5l-3.5.01H1z" + fill="currentColor" + /> + </svg> + )} + </button> + </div> + ); +} + +Quantity.propTypes = { + qty: PropTypes.number.isRequired, + api: PropTypes.string.isRequired +}; diff --git a/packages/evershop/src/modules/checkout/api/updateCartItemQty/[bodyParser]updateQty.js b/packages/evershop/src/modules/checkout/api/updateCartItemQty/[bodyParser]updateQty.js new file mode 100644 index 000000000..3edddcd12 --- /dev/null +++ b/packages/evershop/src/modules/checkout/api/updateCartItemQty/[bodyParser]updateQty.js @@ -0,0 +1,49 @@ +const { + INVALID_PAYLOAD, + INTERNAL_SERVER_ERROR, + OK +} = require('@evershop/evershop/src/lib/util/httpStatus'); +const { + translate +} = require('@evershop/evershop/src/lib/locale/translate/translate'); +const { error } = require('@evershop/evershop/src/lib/log/logger'); +const { getCartByUUID } = require('../../services/getCartByUUID'); +const { saveCart } = require('../../services/saveCart'); + +module.exports = async (request, response, delegate, next) => { + try { + const { cart_id, item_id } = request.params; + const cart = await getCartByUUID(cart_id); + if (!cart) { + response.status(INVALID_PAYLOAD); + response.json({ + error: { + message: translate('Invalid cart'), + status: INVALID_PAYLOAD + } + }); + return; + } + const { action, qty } = request.body; + const item = await cart.updateItemQty(item_id, qty, action); + await saveCart(cart); + response.status(OK); + response.$body = { + data: { + item: item.export(), + count: cart.getItems().length, + cartId: cart.getData('uuid') + } + }; + next(); + } catch (err) { + error(err); + response.status(INTERNAL_SERVER_ERROR); + response.json({ + error: { + status: INTERNAL_SERVER_ERROR, + message: err.message + } + }); + } +}; diff --git a/packages/evershop/src/modules/checkout/api/updateCartItemQty/[context]bodyParser[auth].js b/packages/evershop/src/modules/checkout/api/updateCartItemQty/[context]bodyParser[auth].js new file mode 100644 index 000000000..1f3c47e69 --- /dev/null +++ b/packages/evershop/src/modules/checkout/api/updateCartItemQty/[context]bodyParser[auth].js @@ -0,0 +1,5 @@ +const bodyParser = require('body-parser'); + +module.exports = (request, response, delegate, next) => { + bodyParser.json({ inflate: false })(request, response, next); +}; diff --git a/packages/evershop/src/modules/checkout/api/updateCartItemQty/payloadSchema.json b/packages/evershop/src/modules/checkout/api/updateCartItemQty/payloadSchema.json new file mode 100644 index 000000000..3f04b4a2c --- /dev/null +++ b/packages/evershop/src/modules/checkout/api/updateCartItemQty/payloadSchema.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["increase", "decrease"] + }, + "qty": { + "type": ["string", "integer"], + "pattern": "^[1-9][0-9]*$" + } + }, + "required": ["action", "qty"], + "additionalProperties": true, + "errorMessage": { + "properties": { + "action": "Action is required. It must be either 'increase' or 'decrease'", + "qty": "Qty is invalid" + } + } +} diff --git a/packages/evershop/src/modules/checkout/api/updateCartItemQty/route.json b/packages/evershop/src/modules/checkout/api/updateCartItemQty/route.json new file mode 100644 index 000000000..eb1505cb2 --- /dev/null +++ b/packages/evershop/src/modules/checkout/api/updateCartItemQty/route.json @@ -0,0 +1,5 @@ +{ + "methods": ["PATCH"], + "path": "/cart/:cart_id/items/:item_id", + "access": "public" +} diff --git a/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/[context]bodyParser[auth].js b/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/[context]bodyParser[auth].js new file mode 100644 index 000000000..1f3c47e69 --- /dev/null +++ b/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/[context]bodyParser[auth].js @@ -0,0 +1,5 @@ +const bodyParser = require('body-parser'); + +module.exports = (request, response, delegate, next) => { + bodyParser.json({ inflate: false })(request, response, next); +}; diff --git a/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/[detectCurrentCart]updateQty.js b/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/[detectCurrentCart]updateQty.js new file mode 100644 index 000000000..8a93abb66 --- /dev/null +++ b/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/[detectCurrentCart]updateQty.js @@ -0,0 +1,51 @@ +const { + INVALID_PAYLOAD, + INTERNAL_SERVER_ERROR, + OK +} = require('@evershop/evershop/src/lib/util/httpStatus'); +const { + translate +} = require('@evershop/evershop/src/lib/locale/translate/translate'); +const { error } = require('@evershop/evershop/src/lib/log/logger'); +const { getCartByUUID } = require('../../services/getCartByUUID'); +const { saveCart } = require('../../services/saveCart'); +const { getContextValue } = require('../../../graphql/services/contextHelper'); + +module.exports = async (request, response, delegate, next) => { + try { + const { item_id } = request.params; + const cartId = getContextValue(request, 'cartId'); + const cart = await getCartByUUID(cartId); + if (!cart) { + response.status(INVALID_PAYLOAD); + response.json({ + error: { + message: translate('Invalid cart'), + status: INVALID_PAYLOAD + } + }); + return; + } + const { action, qty } = request.body; + const item = await cart.updateItemQty(item_id, qty, action); + await saveCart(cart); + response.status(OK); + response.$body = { + data: { + item: item.export(), + count: cart.getItems().length, + cartId: cart.getData('uuid') + } + }; + next(); + } catch (err) { + error(err); + response.status(INTERNAL_SERVER_ERROR); + response.json({ + error: { + status: INTERNAL_SERVER_ERROR, + message: err.message + } + }); + } +}; diff --git a/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/[getCurrentCustomer]detectCurrentCart.js b/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/[getCurrentCustomer]detectCurrentCart.js new file mode 100644 index 000000000..01002505b --- /dev/null +++ b/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/[getCurrentCustomer]detectCurrentCart.js @@ -0,0 +1,32 @@ +const { pool } = require('@evershop/evershop/src/lib/postgres/connection'); +const { select } = require('@evershop/postgres-query-builder'); +const { UNAUTHORIZED } = require('@evershop/evershop/src/lib/util/httpStatus'); +const { setContextValue } = require('../../../graphql/services/contextHelper'); + +module.exports = async (request, response, delegate, next) => { + /** + * We firstly get the sessionID from the request. + * This API needs the client to send the session cookie in the request. + * Base on the sessionID, we can get the cart + */ + const { sessionID } = request.locals; + if (!sessionID) { + response.status(UNAUTHORIZED); + response.json({ + error: { + status: UNAUTHORIZED, + message: 'Unauthorized' + } + }); + } else { + const cart = await select() + .from('cart') + .where('sid', '=', sessionID) + .and('status', '=', 1) + .load(pool); + if (cart) { + setContextValue(request, 'cartId', cart.uuid); + } + next(); + } +}; diff --git a/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/payloadSchema.json b/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/payloadSchema.json new file mode 100644 index 000000000..3f04b4a2c --- /dev/null +++ b/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/payloadSchema.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["increase", "decrease"] + }, + "qty": { + "type": ["string", "integer"], + "pattern": "^[1-9][0-9]*$" + } + }, + "required": ["action", "qty"], + "additionalProperties": true, + "errorMessage": { + "properties": { + "action": "Action is required. It must be either 'increase' or 'decrease'", + "qty": "Qty is invalid" + } + } +} diff --git a/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/route.json b/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/route.json new file mode 100644 index 000000000..4cd73ac0e --- /dev/null +++ b/packages/evershop/src/modules/checkout/api/updateMineCartItemQty/route.json @@ -0,0 +1,5 @@ +{ + "methods": ["PATCH"], + "path": "/cart/mine/items/:item_id", + "access": "public" +} diff --git a/packages/evershop/src/modules/checkout/graphql/types/Cart/Cart.graphql b/packages/evershop/src/modules/checkout/graphql/types/Cart/Cart.graphql index 09d6ff684..7ee962123 100644 --- a/packages/evershop/src/modules/checkout/graphql/types/Cart/Cart.graphql +++ b/packages/evershop/src/modules/checkout/graphql/types/Cart/Cart.graphql @@ -97,6 +97,7 @@ type CartItem implements ShoppingCartItem { uuid: String! cartId: ID! removeApi: String! + updateQtyApi: String! productId: ID! productSku: String! productName: String diff --git a/packages/evershop/src/modules/checkout/graphql/types/Cart/Cart.resolvers.js b/packages/evershop/src/modules/checkout/graphql/types/Cart/Cart.resolvers.js index 5d769da4d..7873b845f 100644 --- a/packages/evershop/src/modules/checkout/graphql/types/Cart/Cart.resolvers.js +++ b/packages/evershop/src/modules/checkout/graphql/types/Cart/Cart.resolvers.js @@ -19,7 +19,14 @@ module.exports = { const items = cart.items || []; return items.map((item) => ({ ...camelCase(item), - removeApi: buildUrl('removeMineCartItem', { item_id: item.uuid }) + removeApi: buildUrl('removeCartItem', { + item_id: item.uuid, + cart_id: cart.uuid + }), + updateQtyApi: buildUrl('updateCartItemQty', { + cart_id: cart.uuid, + item_id: item.uuid + }) })); }, shippingAddress: async ({ shippingAddressId }, _, { pool }) => { @@ -46,13 +53,11 @@ module.exports = { addAddressApi: (cart) => buildUrl('addCartAddress', { cart_id: cart.uuid }) }, CartItem: { - total: ({ lineTotalInclTax }) => + total: ({ lineTotalInclTax }) => // This field is deprecated, use lineTotalInclTax instead - lineTotalInclTax - , - subTotal: ({ lineTotal }) => + lineTotalInclTax, + subTotal: ({ lineTotal }) => // This field is deprecated, use lineTotal instead - lineTotal - + lineTotal } }; diff --git a/packages/evershop/src/modules/checkout/migration/Version-1.0.6.js b/packages/evershop/src/modules/checkout/migration/Version-1.0.6.js index 27c0df39d..8d9c7f935 100644 --- a/packages/evershop/src/modules/checkout/migration/Version-1.0.6.js +++ b/packages/evershop/src/modules/checkout/migration/Version-1.0.6.js @@ -108,7 +108,7 @@ module.exports = exports = async (connection) => { 'ALTER TABLE "order" ADD COLUMN IF NOT EXISTS "shipping_tax_amount" decimal(12,4)' ); - // Calculate the value for the new columns 'tax_amount_before_discount', 'line_total_with_discount', 'line_total_with_discount_incl_tax', 'sub_total_before_discount', 'sub_total_before_discount_incl_tax' + // Calculate the value for the new columns 'tax_amount_before_discount', 'line_total_with_discount', 'line_total_with_discount_incl_tax', 'sub_total' await execute( connection, @@ -178,6 +178,6 @@ module.exports = exports = async (connection) => { await execute( connection, - `UPDATE "order" SET sub_total_before_discount_incl_tax = sub_total_with_discount + tax_amount` + `UPDATE "order" SET sub_total_with_discount_incl_tax = sub_total_with_discount + tax_amount` ); }; diff --git a/packages/evershop/src/modules/checkout/pages/frontStore/cart/ShoppingCart.jsx b/packages/evershop/src/modules/checkout/pages/frontStore/cart/ShoppingCart.jsx index 14564343e..485f14208 100644 --- a/packages/evershop/src/modules/checkout/pages/frontStore/cart/ShoppingCart.jsx +++ b/packages/evershop/src/modules/checkout/pages/frontStore/cart/ShoppingCart.jsx @@ -133,6 +133,7 @@ export const query = ` text } removeApi + updateQtyApi errors } } diff --git a/packages/evershop/src/modules/checkout/services/cart/Cart.js b/packages/evershop/src/modules/checkout/services/cart/Cart.js index 4c568b65c..7e59c2679 100644 --- a/packages/evershop/src/modules/checkout/services/cart/Cart.js +++ b/packages/evershop/src/modules/checkout/services/cart/Cart.js @@ -113,6 +113,29 @@ class Cart extends DataObject { } } + async updateItemQty(uuid, qty, action) { + if (['increase', 'decrease'].indexOf(action) === -1) { + throw new Error('Invalid action'); + } + const item = this.getItem(uuid); + if (!item) { + throw new Error('Item not found'); + } + if (action === 'increase') { + await item.setData('qty', item.getData('qty') + parseInt(qty, 10)); + } else { + const currentQty = item.getData('qty'); + const newQty = Math.max(currentQty - parseInt(qty, 10), 0); + if (newQty === 0) { + await this.removeItem(uuid); + } else { + await item.setData('qty', newQty); + } + } + await this.build(); + return item; + } + async createItem(productId, qty) { // Make sure the qty is a number, not NaN and greater than 0 if (typeof qty !== 'number' || Number.isNaN(qty) || qty <= 0) {