1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-26 20:54:21 +01:00

(wip) cart items components

This commit is contained in:
Sylvain 2023-01-05 09:16:46 +01:00
parent c24673fefa
commit 05a6f517cd
8 changed files with 352 additions and 165 deletions

View File

@ -0,0 +1,79 @@
import * as React from 'react';
import noImage from '../../../../images/no_image.png';
import FormatLib from '../../lib/format';
import OrderLib from '../../lib/order';
import { FabButton } from '../base/fab-button';
import Switch from 'react-switch';
import type { OrderItem } from '../../models/order';
import { useTranslation } from 'react-i18next';
import { ReactNode } from 'react';
interface AbstractItemProps {
item: OrderItem,
hasError: boolean,
className?: string,
removeItemFromCart: (item: OrderItem) => void,
toggleItemOffer: (item: OrderItem, checked: boolean) => void,
privilegedOperator: boolean,
actions?: ReactNode
}
/**
* This component shares the common code for items in the cart (product, cart-item, etc)
*/
export const AbstractItem: React.FC<AbstractItemProps> = ({ item, hasError, className, removeItemFromCart, toggleItemOffer, privilegedOperator, actions, children }) => {
const { t } = useTranslation('public');
/**
* Return the callback triggered when then user remove the given item from the cart
*/
const handleRemoveItem = (item: OrderItem) => {
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
removeItemFromCart(item);
};
};
/**
* Return the callback triggered when the privileged user enable/disable the offered attribute for the given item
*/
const handleToggleOffer = (item: OrderItem) => {
return (checked: boolean) => toggleItemOffer(item, checked);
};
return (
<article className={`item ${className || ''} ${hasError ? 'error' : ''}`}>
<div className='picture'>
<img alt='' src={item.orderable_main_image_url || noImage} />
</div>
{children}
<div className="actions">
{actions}
<div className='total'>
<span>{t('app.public.abstract_item.total')}</span>
<p>{FormatLib.price(OrderLib.itemAmount(item))}</p>
</div>
<FabButton className="main-action-btn" onClick={handleRemoveItem(item)}>
<i className="fa fa-trash" />
</FabButton>
</div>
{privilegedOperator &&
<div className='offer'>
<label>
<span>{t('app.public.abstract_item.offer_product')}</span>
<Switch
checked={item.is_offered || false}
onChange={handleToggleOffer(item)}
width={40}
height={19}
uncheckedIcon={false}
checkedIcon={false}
handleDiameter={15} />
</label>
</div>
}
</article>
);
};

View File

@ -0,0 +1,141 @@
import * as React from 'react';
import FormatLib from '../../lib/format';
import { CaretDown, CaretUp } from 'phosphor-react';
import type { OrderProduct, OrderErrors, Order } from '../../models/order';
import { useTranslation } from 'react-i18next';
import _ from 'lodash';
import CartAPI from '../../api/cart';
import { AbstractItem } from './abstract-item';
import { ReactNode } from 'react';
interface CartOrderProductProps {
item: OrderProduct,
cartErrors: OrderErrors,
className?: string,
cart: Order,
setCart: (cart: Order) => void,
onError: (message: string) => void,
removeProductFromCart: (item: OrderProduct) => void,
toggleProductOffer: (item: OrderProduct, checked: boolean) => void,
privilegedOperator: boolean,
}
/**
* This component shows a product in the cart
*/
export const CartOrderProduct: React.FC<CartOrderProductProps> = ({ item, cartErrors, className, cart, setCart, onError, removeProductFromCart, toggleProductOffer, privilegedOperator }) => {
const { t } = useTranslation('public');
/**
* Get the given item's errors
*/
const getItemErrors = (item: OrderProduct) => {
if (!cartErrors) return [];
const errors = _.find(cartErrors.details, (e) => e.item_id === item.id);
return errors?.errors || [{ error: 'not_found' }];
};
/**
* Show an human-readable styled error for the given item's error
*/
const itemError = (item: OrderProduct, error) => {
if (error.error === 'is_active' || error.error === 'not_found') {
return <div className='error'><p>{t('app.public.cart_order_product.errors.product_not_found')}</p></div>;
}
if (error.error === 'stock' && error.value === 0) {
return <div className='error'><p>{t('app.public.cart_order_product.errors.out_of_stock')}</p></div>;
}
if (error.error === 'stock' && error.value > 0) {
return <div className='error'><p>{t('app.public.cart_order_product.errors.stock_limit_QUANTITY', { QUANTITY: error.value })}</p></div>;
}
if (error.error === 'quantity_min') {
return <div className='error'><p>{t('app.public.cart_order_product.errors.quantity_min_QUANTITY', { QUANTITY: error.value })}</p></div>;
}
if (error.error === 'amount') {
return <div className='error'>
<p>{t('app.public.cart_order_product.errors.price_changed_PRICE', { PRICE: `${FormatLib.price(error.value)} / ${t('app.public.cart_order_product.unit')}` })}</p>
<span className='refresh-btn' onClick={refreshItem(item)}>{t('app.public.cart_order_product.update_item')}</span>
</div>;
}
};
/**
* Refresh product amount
*/
const refreshItem = (item: OrderProduct) => {
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
CartAPI.refreshItem(cart, item.orderable_id, item.orderable_type).then(data => {
setCart(data);
}).catch(onError);
};
};
/**
* Change product quantity
*/
const changeProductQuantity = (e: React.BaseSyntheticEvent, item: OrderProduct) => {
CartAPI.setQuantity(cart, item.orderable_id, item.orderable_type, e.target.value)
.then(data => {
setCart(data);
})
.catch(() => onError(t('app.public.cart_order_product.stock_limit')));
};
/**
* Increment/decrement product quantity
*/
const increaseOrDecreaseProductQuantity = (item: OrderProduct, direction: 'up' | 'down') => {
CartAPI.setQuantity(cart, item.orderable_id, item.orderable_type, direction === 'up' ? item.quantity + 1 : item.quantity - 1)
.then(data => {
setCart(data);
})
.catch(() => onError(t('app.public.cart_order_product.stock_limit')));
};
/**
* Return the components in the "actions" section of the item
*/
const buildActions = (): ReactNode => {
return (
<>
<div className='price'>
<p>{FormatLib.price(item.amount)}</p>
<span>/ {t('app.public.cart_order_product.unit')}</span>
</div>
<div className='quantity'>
<input type='number'
onChange={e => changeProductQuantity(e, item)}
min={item.quantity_min}
max={item.orderable_external_stock}
value={item.quantity}
/>
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'up')}><CaretUp size={12} weight="fill" /></button>
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'down')}><CaretDown size={12} weight="fill" /></button>
</div>
</>
);
};
return (
<AbstractItem className={`cart-order-product ${className || ''}`}
hasError={getItemErrors(item).length > 0}
item={item}
removeItemFromCart={removeProductFromCart}
privilegedOperator={privilegedOperator}
toggleItemOffer={toggleProductOffer}
actions={buildActions()}>
<div className="ref">
<span>{t('app.public.cart_order_product.reference_short')} {item.orderable_ref || ''}</span>
<p><a className="text-black" href={`/#!/store/p/${item.orderable_slug}`}>{item.orderable_name}</a></p>
{item.quantity_min > 1 &&
<span className='min'>{t('app.public.cart_order_product.minimum_purchase')}{item.quantity_min}</span>
}
{getItemErrors(item).map(e => {
return itemError(item, e);
})}
</div>
</AbstractItem>
);
};

View File

@ -0,0 +1,48 @@
import * as React from 'react';
import type { OrderErrors, Order } from '../../models/order';
import { useTranslation } from 'react-i18next';
import _ from 'lodash';
import { AbstractItem } from './abstract-item';
import { OrderCartItem } from '../../models/order';
interface CartOrderReservationProps {
item: OrderCartItem,
cartErrors: OrderErrors,
className?: string,
cart: Order,
setCart: (cart: Order) => void,
onError: (message: string) => void,
removeProductFromCart: (item: OrderCartItem) => void,
toggleProductOffer: (item: OrderCartItem, checked: boolean) => void,
privilegedOperator: boolean,
}
/**
* This component shows a product in the cart
*/
export const CartOrderReservation: React.FC<CartOrderReservationProps> = ({ item, cartErrors, className, cart, setCart, onError, removeProductFromCart, toggleProductOffer, privilegedOperator }) => {
const { t } = useTranslation('public');
/**
* Get the given item's errors
*/
const getItemErrors = (item: OrderCartItem) => {
if (!cartErrors) return [];
const errors = _.find(cartErrors.details, (e) => e.item_id === item.id);
return errors?.errors || [{ error: 'not_found' }];
};
return (
<AbstractItem className={`cart-order-reservation ${className || ''}`}
hasError={getItemErrors(item).length > 0}
item={item}
removeItemFromCart={removeProductFromCart}
privilegedOperator={privilegedOperator}
toggleItemOffer={toggleProductOffer}>
<div className="ref">
<p>Réservation {item.orderable_name}</p>
{getItemErrors(item)}
</div>
</AbstractItem>
);
};

View File

@ -3,24 +3,23 @@ import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import type { IApplication } from '../../models/application';
import { FabButton } from '../base/fab-button';
import useCart from '../../hooks/use-cart';
import FormatLib from '../../lib/format';
import CartAPI from '../../api/cart';
import { User } from '../../models/user';
import type { User } from '../../models/user';
import { PaymentModal } from '../payment/stripe/payment-modal';
import { PaymentMethod } from '../../models/payment';
import { Order, OrderErrors } from '../../models/order';
import type { Order, OrderCartItem, OrderErrors, OrderItem, OrderProduct } from '../../models/order';
import { MemberSelect } from '../user/member-select';
import { CouponInput } from '../coupon/coupon-input';
import { Coupon } from '../../models/coupon';
import noImage from '../../../../images/no_image.png';
import Switch from 'react-switch';
import type { Coupon } from '../../models/coupon';
import OrderLib from '../../lib/order';
import { CaretDown, CaretUp } from 'phosphor-react';
import _ from 'lodash';
import OrderAPI from '../../api/order';
import { CartOrderProduct } from './cart-order-product';
import { CartOrderReservation } from './cart-order-reservation';
declare const Application: IApplication;
@ -58,53 +57,14 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
* Remove the product from cart
*/
const removeProductFromCart = (item) => {
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
const errors = getItemErrors(item);
if (errors.length === 1 && errors[0].error === 'not_found') {
reloadCart().catch(onError);
} else {
CartAPI.removeItem(cart, item.orderable_id, item.orderable_type).then(data => {
setCart(data);
}).catch(onError);
}
};
};
/**
* Change product quantity
*/
const changeProductQuantity = (e: React.BaseSyntheticEvent, item) => {
CartAPI.setQuantity(cart, item.orderable_id, item.orderable_type, e.target.value)
.then(data => {
setCart(data);
})
.catch(() => onError(t('app.public.store_cart.stock_limit')));
};
/**
* Increment/decrement product quantity
*/
const increaseOrDecreaseProductQuantity = (item, direction: 'up' | 'down') => {
CartAPI.setQuantity(cart, item.orderable_id, item.orderable_type, direction === 'up' ? item.quantity + 1 : item.quantity - 1)
.then(data => {
setCart(data);
})
.catch(() => onError(t('app.public.store_cart.stock_limit')));
};
/**
* Refresh product amount
*/
const refreshItem = (item) => {
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
CartAPI.refreshItem(cart, item.orderable_id, item.orderable_type).then(data => {
const errors = getItemErrors(item);
if (errors.length === 1 && errors[0].error === 'not_found') {
reloadCart().catch(onError);
} else {
CartAPI.removeItem(cart, item.orderable_id, item.orderable_type).then(data => {
setCart(data);
}).catch(onError);
};
}
};
/**
@ -202,18 +162,16 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
/**
* Toggle product offer
*/
const toggleProductOffer = (item) => {
return (checked: boolean) => {
CartAPI.setOffer(cart, item.orderable_id, item.orderable_type, checked).then(data => {
setCart(data);
}).catch(e => {
if (e.match(/code 403/)) {
onError(t('app.public.store_cart.errors.unauthorized_offering_product'));
} else {
onError(e);
}
});
};
const toggleProductOffer = (item: OrderItem, checked: boolean) => {
CartAPI.setOffer(cart, item.orderable_id, item.orderable_type, checked).then(data => {
setCart(data);
}).catch(e => {
if (e.match(/code 403/)) {
onError(t('app.public.store_cart.errors.unauthorized_offering_product'));
} else {
onError(e);
}
});
};
/**
@ -225,89 +183,38 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
}
};
/**
* Show item error
*/
const itemError = (item, error) => {
if (error.error === 'is_active' || error.error === 'not_found') {
return <div className='error'><p>{t('app.public.store_cart.errors.product_not_found')}</p></div>;
}
if (error.error === 'stock' && error.value === 0) {
return <div className='error'><p>{t('app.public.store_cart.errors.out_of_stock')}</p></div>;
}
if (error.error === 'stock' && error.value > 0) {
return <div className='error'><p>{t('app.public.store_cart.errors.stock_limit_QUANTITY', { QUANTITY: error.value })}</p></div>;
}
if (error.error === 'quantity_min') {
return <div className='error'><p>{t('app.public.store_cart.errors.quantity_min_QUANTITY', { QUANTITY: error.value })}</p></div>;
}
if (error.error === 'amount') {
return <div className='error'>
<p>{t('app.public.store_cart.errors.price_changed_PRICE', { PRICE: `${FormatLib.price(error.value)} / ${t('app.public.store_cart.unit')}` })}</p>
<span className='refresh-btn' onClick={refreshItem(item)}>{t('app.public.store_cart.update_item')}</span>
</div>;
}
};
return (
<div className='store-cart'>
<div className="store-cart-list">
{cart && cartIsEmpty() && <p>{t('app.public.store_cart.cart_is_empty')}</p>}
{cart && cart.order_items_attributes.map(item => (
<article key={item.id} className={`store-cart-list-item ${getItemErrors(item).length > 0 ? 'error' : ''}`}>
<div className='picture'>
<img alt='' src={item.orderable_main_image_url || noImage} />
</div>
<div className="ref">
<span>{t('app.public.store_cart.reference_short')} {item.orderable_ref || ''}</span>
<p><a className="text-black" href={`/#!/store/p/${item.orderable_slug}`}>{item.orderable_name}</a></p>
{item.quantity_min > 1 &&
<span className='min'>{t('app.public.store_cart.minimum_purchase')}{item.quantity_min}</span>
}
{getItemErrors(item).map(e => {
return itemError(item, e);
})}
</div>
<div className="actions">
<div className='price'>
<p>{FormatLib.price(item.amount)}</p>
<span>/ {t('app.public.store_cart.unit')}</span>
</div>
<div className='quantity'>
<input type='number'
onChange={e => changeProductQuantity(e, item)}
min={item.quantity_min}
max={item.orderable_external_stock}
value={item.quantity}
/>
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'up')}><CaretUp size={12} weight="fill" /></button>
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'down')}><CaretDown size={12} weight="fill" /></button>
</div>
<div className='total'>
<span>{t('app.public.store_cart.total')}</span>
<p>{FormatLib.price(OrderLib.itemAmount(item))}</p>
</div>
<FabButton className="main-action-btn" onClick={removeProductFromCart(item)}>
<i className="fa fa-trash" />
</FabButton>
</div>
{isPrivileged() &&
<div className='offer'>
<label>
<span>{t('app.public.store_cart.offer_product')}</span>
<Switch
checked={item.is_offered || false}
onChange={toggleProductOffer(item)}
width={40}
height={19}
uncheckedIcon={false}
checkedIcon={false}
handleDiameter={15} />
</label>
</div>
}
</article>
))}
{cart && cart.order_items_attributes.map(item => {
if (item.orderable_type === 'Product') {
return (
<CartOrderProduct item={item as OrderProduct}
key={item.id}
className="store-cart-list-item"
cartErrors={cartErrors}
cart={cart}
setCart={setCart}
onError={onError}
removeProductFromCart={removeProductFromCart}
toggleProductOffer={toggleProductOffer}
privilegedOperator={isPrivileged()} />
);
}
return (
<CartOrderReservation item={item as OrderCartItem}
key={item.id}
className="store-cart-list-item"
cartErrors={cartErrors}
cart={cart}
setCart={setCart}
onError={onError}
removeProductFromCart={removeProductFromCart}
toggleProductOffer={toggleProductOffer}
privilegedOperator={isPrivileged()} />
);
})}
</div>
<div className="group">

View File

@ -1,11 +1,11 @@
import { computePriceWithCoupon } from './coupon';
import { Order } from '../models/order';
import { Order, OrderItem } from '../models/order';
export default class OrderLib {
/**
* Get the order item total
*/
static itemAmount = (item): number => {
static itemAmount = (item: OrderItem): number => {
return item.quantity * Math.round(item.amount * 100) / 100;
};

View File

@ -8,6 +8,29 @@ import { CartItemType } from './cart_item';
export type OrderableType = 'Product' | CartItemType;
export interface OrderItem {
id: number,
orderable_type: OrderableType,
orderable_id: number,
orderable_name: string,
orderable_main_image_url?: string;
quantity: number,
amount: number,
is_offered: boolean
}
export interface OrderProduct extends OrderItem {
orderable_type: 'Product',
orderable_slug: string,
orderable_ref?: string,
orderable_external_stock: number,
quantity_min: number
}
export interface OrderCartItem extends OrderItem {
orderable_type: CartItemType
}
export interface Order {
id: number,
token: string,
@ -29,20 +52,7 @@ export interface Order {
payment_date?: TDateISO,
wallet_amount?: number,
paid_total?: number,
order_items_attributes: Array<{
id: number,
orderable_type: OrderableType,
orderable_id: number,
orderable_name: string,
orderable_slug: string,
orderable_ref?: string,
orderable_main_image_url?: string,
orderable_external_stock: number,
quantity: number,
quantity_min: number,
amount: number,
is_offered: boolean
}>,
order_items_attributes: Array<OrderItem>,
}
export interface OrderPayment {

View File

@ -434,12 +434,6 @@ en:
checkout: "Checkout"
cart_is_empty: "Your cart is empty"
pickup: "Pickup your products"
reference_short: "ref:"
minimum_purchase: "Minimum purchase: "
stock_limit: "You have reached the current stock limit"
unit: "Unit"
total: "Total"
offer_product: "Offer the product"
checkout_header: "Total amount for your cart"
checkout_products_COUNT: "Your cart contains {COUNT} {COUNT, plural, =1{product} other{products}}"
checkout_products_total: "Products total"
@ -449,6 +443,14 @@ en:
checkout_error: "An unexpected error occurred. Please contact the administrator."
checkout_success: "Purchase confirmed. Thanks!"
select_user: "Please select a user before continuing."
abstract_item:
offer_product: "Offer the product"
total: "Total"
cart_order_product:
reference_short: "ref:"
minimum_purchase: "Minimum purchase: "
stock_limit: "You have reached the current stock limit"
unit: "Unit"
update_item: "Update"
errors:
product_not_found: "This product is no longer available, please remove it from your cart."