1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-18 07:52:23 +01:00

(feat) add reservations in cart/order

This commit is contained in:
Sylvain 2023-01-09 17:36:11 +01:00
parent 05a6f517cd
commit 473aedbdcb
27 changed files with 183 additions and 101 deletions

View File

@ -4,16 +4,21 @@ 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 type { ItemError, OrderItem } from '../../models/order';
import { useTranslation } from 'react-i18next';
import { ReactNode } from 'react';
import { Order } from '../../models/order';
import CartAPI from '../../api/cart';
interface AbstractItemProps {
item: OrderItem,
hasError: boolean,
errors: Array<ItemError>,
cart: Order,
setCart: (cart: Order) => void,
reloadCart: () => Promise<void>,
onError: (message: string) => void,
className?: string,
removeItemFromCart: (item: OrderItem) => void,
toggleItemOffer: (item: OrderItem, checked: boolean) => void,
offerItemLabel?: string,
privilegedOperator: boolean,
actions?: ReactNode
}
@ -21,7 +26,7 @@ interface AbstractItemProps {
/**
* 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 }) => {
export const AbstractItem: React.FC<AbstractItemProps> = ({ item, errors, cart, setCart, reloadCart, onError, className, offerItemLabel, privilegedOperator, actions, children }) => {
const { t } = useTranslation('public');
/**
@ -32,7 +37,13 @@ export const AbstractItem: React.FC<AbstractItemProps> = ({ item, hasError, clas
e.preventDefault();
e.stopPropagation();
removeItemFromCart(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);
}
};
};
@ -40,11 +51,21 @@ export const AbstractItem: React.FC<AbstractItemProps> = ({ item, hasError, clas
* 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 (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.abstract_item.errors.unauthorized_offering_product'));
} else {
onError(e);
}
});
};
};
return (
<article className={`item ${className || ''} ${hasError ? 'error' : ''}`}>
<article className={`item ${className || ''} ${errors.length > 0 ? 'error' : ''}`}>
<div className='picture'>
<img alt='' src={item.orderable_main_image_url || noImage} />
</div>
@ -62,7 +83,7 @@ export const AbstractItem: React.FC<AbstractItemProps> = ({ item, hasError, clas
{privilegedOperator &&
<div className='offer'>
<label>
<span>{t('app.public.abstract_item.offer_product')}</span>
<span>{offerItemLabel || t('app.public.abstract_item.offer_product')}</span>
<Switch
checked={item.is_offered || false}
onChange={handleToggleOffer(item)}

View File

@ -1,7 +1,7 @@
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 type { OrderProduct, OrderErrors, Order, ItemError } from '../../models/order';
import { useTranslation } from 'react-i18next';
import _ from 'lodash';
import CartAPI from '../../api/cart';
@ -14,22 +14,21 @@ interface CartOrderProductProps {
className?: string,
cart: Order,
setCart: (cart: Order) => void,
reloadCart: () => Promise<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 }) => {
export const CartOrderProduct: React.FC<CartOrderProductProps> = ({ item, cartErrors, className, cart, setCart, reloadCart, onError, privilegedOperator }) => {
const { t } = useTranslation('public');
/**
* Get the given item's errors
*/
const getItemErrors = (item: OrderProduct) => {
const getItemErrors = (item: OrderProduct): Array<ItemError> => {
if (!cartErrors) return [];
const errors = _.find(cartErrors.details, (e) => e.item_id === item.id);
return errors?.errors || [{ error: 'not_found' }];
@ -120,11 +119,13 @@ export const CartOrderProduct: React.FC<CartOrderProductProps> = ({ item, cartEr
return (
<AbstractItem className={`cart-order-product ${className || ''}`}
hasError={getItemErrors(item).length > 0}
errors={getItemErrors(item)}
setCart={setCart}
cart={cart}
onError={onError}
reloadCart={reloadCart}
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>

View File

@ -3,30 +3,30 @@ 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';
import { OrderCartItemReservation } from '../../models/order';
import FormatLib from '../../lib/format';
interface CartOrderReservationProps {
item: OrderCartItem,
item: OrderCartItemReservation,
cartErrors: OrderErrors,
className?: string,
cart: Order,
setCart: (cart: Order) => void,
reloadCart: () => Promise<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 }) => {
export const CartOrderReservation: React.FC<CartOrderReservationProps> = ({ item, cartErrors, className, cart, setCart, reloadCart, onError, privilegedOperator }) => {
const { t } = useTranslation('public');
/**
* Get the given item's errors
*/
const getItemErrors = (item: OrderCartItem) => {
const getItemErrors = (item: OrderCartItemReservation) => {
if (!cartErrors) return [];
const errors = _.find(cartErrors.details, (e) => e.item_id === item.id);
return errors?.errors || [{ error: 'not_found' }];
@ -34,13 +34,26 @@ export const CartOrderReservation: React.FC<CartOrderReservationProps> = ({ item
return (
<AbstractItem className={`cart-order-reservation ${className || ''}`}
hasError={getItemErrors(item).length > 0}
errors={getItemErrors(item)}
item={item}
removeItemFromCart={removeProductFromCart}
privilegedOperator={privilegedOperator}
toggleItemOffer={toggleProductOffer}>
cart={cart}
setCart={setCart}
onError={onError}
reloadCart={reloadCart}
actions={<div/>}
offerItemLabel={t('app.public.cart_order_reservation.offer_reservation')}
privilegedOperator={privilegedOperator}>
<div className="ref">
<p>Réservation {item.orderable_name}</p>
<p>{t('app.public.cart_order_reservation.reservation')} {item.orderable_name}</p>
<ul>{item.slots_reservations.map(sr => (
<li key={sr.id}>
{
t('app.public.cart_order_reservation.slot',
{ DATE: FormatLib.date(sr.slot.start_at), START: FormatLib.time(sr.slot.start_at), END: FormatLib.time(sr.slot.end_at) })
}
<span>{sr.offered ? t('app.public.cart_order_reservation.offered') : ''}</span>
</li>
))}</ul>
{getItemErrors(item)}
</div>
</AbstractItem>

View File

@ -11,7 +11,7 @@ import CartAPI from '../../api/cart';
import type { User } from '../../models/user';
import { PaymentModal } from '../payment/stripe/payment-modal';
import { PaymentMethod } from '../../models/payment';
import type { Order, OrderCartItem, OrderErrors, OrderItem, OrderProduct } from '../../models/order';
import type { Order, OrderCartItemReservation, OrderErrors, OrderProduct } from '../../models/order';
import { MemberSelect } from '../user/member-select';
import { CouponInput } from '../coupon/coupon-input';
import type { Coupon } from '../../models/coupon';
@ -53,20 +53,6 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
}
}, [cart]);
/**
* Remove the product from cart
*/
const removeProductFromCart = (item) => {
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);
}
};
/**
* Check the current cart's items (available, price, stock, quantity_min)
*/
@ -109,15 +95,6 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
return false;
};
/**
* get givean item's error
*/
const getItemErrors = (item) => {
if (!cartErrors) return [];
const errors = _.find(cartErrors.details, (e) => e.item_id === item.id);
return errors?.errors || [{ error: 'not_found' }];
};
/**
* Open/closes the payment modal
*/
@ -159,21 +136,6 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
return cart && cart.order_items_attributes.length === 0;
};
/**
* Toggle product offer
*/
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);
}
});
};
/**
* Apply coupon to current cart
*/
@ -196,22 +158,20 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
cartErrors={cartErrors}
cart={cart}
setCart={setCart}
reloadCart={reloadCart}
onError={onError}
removeProductFromCart={removeProductFromCart}
toggleProductOffer={toggleProductOffer}
privilegedOperator={isPrivileged()} />
);
}
return (
<CartOrderReservation item={item as OrderCartItem}
<CartOrderReservation item={item as OrderCartItemReservation}
key={item.id}
className="store-cart-list-item"
cartErrors={cartErrors}
cart={cart}
reloadCart={reloadCart}
setCart={setCart}
onError={onError}
removeProductFromCart={removeProductFromCart}
toggleProductOffer={toggleProductOffer}
privilegedOperator={isPrivileged()} />
);
})}

View File

@ -39,8 +39,9 @@ export interface CartItemPrepaidPack extends CartItem {
export interface CartItemFreeExtension extends CartItem {
free_extension: { end_at: Date }
}
export type CartItemReservationType = 'CartItem::MachineReservation' | 'CartItem::SpaceReservation' | 'CartItem::TrainingReservation';
export type CartItemType = 'CartItem::EventReservation' | 'CartItem::MachineReservation' | 'CartItem::PrepaidPack' | 'CartItem::SpaceReservation' | 'CartItem::Subscription' | 'CartItem::TrainingReservation';
export type CartItemType = 'CartItem::EventReservation' | 'CartItem::PrepaidPack' | 'CartItem::Subscription' | CartItemReservationType;
export interface CartItemResponse {
id: number,

View File

@ -4,7 +4,7 @@ import { CreateTokenResponse } from './payzen';
import { UserRole } from './user';
import { Coupon } from './coupon';
import { ApiFilter, PaginatedIndex } from './api';
import { CartItemType } from './cart_item';
import type { CartItemReservationType, CartItemType } from './cart_item';
export type OrderableType = 'Product' | CartItemType;
@ -13,6 +13,7 @@ export interface OrderItem {
orderable_type: OrderableType,
orderable_id: number,
orderable_name: string,
orderable_slug: string,
orderable_main_image_url?: string;
quantity: number,
amount: number,
@ -21,14 +22,22 @@ export interface OrderItem {
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 OrderCartItemReservation extends OrderItem {
orderable_type: CartItemReservationType
slots_reservations: Array<{
id: number,
offered: boolean,
slot: {
id: number,
start_at: TDateISO,
end_at: TDateISO
}
}>
}
export interface Order {
@ -78,13 +87,14 @@ export interface OrderIndexFilter extends ApiFilter {
period_to?: string
}
export interface ItemError {
error: string,
value?: string|number
}
export interface OrderErrors {
order_id: number,
details: Array<{
item_id: number,
errors: Array<{
error: string,
value: string|number
}>
errors: Array<ItemError>
}>
}

View File

@ -40,6 +40,7 @@
@import "modules/base/labelled-input";
@import "modules/calendar/calendar";
@import "modules/cart/cart-button";
@import "modules/cart/cart-order-reservation";
@import "modules/cart/store-cart";
@import "modules/dashboard/reservations/credits-panel";
@import "modules/dashboard/reservations/prepaid-packs-panel";

View File

@ -0,0 +1,9 @@
.cart-order-reservation {
.actions:before {
content: ' '
}
.ref > ul > li > span {
margin-left: 1em;
}
}

View File

@ -4,4 +4,18 @@
# This is a single spot to configure app-wide model behavior.
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
# Update attributes with validation context.
# In Rails you can provide a context while you save, for example: `.save(:step1)`, but no way to
# provide a context while you update. This method just adds the way to update with validation
# context.
#
# @param attributes [Hash] attributes to assign
# @param context [*] validation context
def update_with_context(attributes, context)
with_transaction_returning_status do
assign_attributes(attributes)
save(context: context)
end
end
end

View File

@ -2,6 +2,8 @@
# An event reservation added to the shopping cart
class CartItem::EventReservation < CartItem::Reservation
self.table_name = 'cart_item_event_reservations'
has_many :cart_item_event_reservation_tickets, class_name: 'CartItem::EventReservationTicket', dependent: :destroy,
inverse_of: :cart_item_event_reservation,
foreign_key: 'cart_item_event_reservation_id'

View File

@ -2,8 +2,6 @@
# A machine reservation added to the shopping cart
class CartItem::MachineReservation < CartItem::Reservation
self.table_name = 'cart_item_reservations'
has_many :cart_item_reservation_slots, class_name: 'CartItem::ReservationSlot', dependent: :destroy, inverse_of: :cart_item,
foreign_key: 'cart_item_id', foreign_type: 'cart_item_type'
accepts_nested_attributes_for :cart_item_reservation_slots

View File

@ -1,13 +1,11 @@
# frozen_string_literal: true
MINUTES_PER_HOUR = 60.0
SECONDS_PER_MINUTE = 60.0
GET_SLOT_PRICE_DEFAULT_OPTS = { has_credits: false, elements: nil, is_division: true, prepaid: { minutes: 0 }, custom_duration: nil }.freeze
# A generic reservation added to the shopping cart
class CartItem::Reservation < CartItem::BaseItem
self.abstract_class = true
MINUTES_PER_HOUR = 60.0
SECONDS_PER_MINUTE = 60.0
GET_SLOT_PRICE_DEFAULT_OPTS = { has_credits: false, elements: nil, is_division: true, prepaid: { minutes: 0 }, custom_duration: nil }.freeze
def reservable
nil

View File

@ -2,8 +2,6 @@
# A space reservation added to the shopping cart
class CartItem::SpaceReservation < CartItem::Reservation
self.table_name = 'cart_item_reservations'
has_many :cart_item_reservation_slots, class_name: 'CartItem::ReservationSlot', dependent: :destroy, inverse_of: :cart_item,
foreign_key: 'cart_item_id', foreign_type: 'cart_item_type'
accepts_nested_attributes_for :cart_item_reservation_slots

View File

@ -29,7 +29,7 @@ class Cart::AddItemService
def add_product(order, orderable, quantity)
raise Cart::InactiveProductError unless orderable.is_active
item = order.order_items.find_by(orderable: orderable)
item = order.order_items.find_by(orderable_type: orderable.class.name, orderable_id: orderable.id)
quantity = orderable.quantity_min > quantity.to_i && item.nil? ? orderable.quantity_min : quantity.to_i
if item.nil?
@ -43,6 +43,6 @@ class Cart::AddItemService
end
def add_cart_item(order, orderable)
order.order_items.new(quantity: 1, orderable: orderable, amount: orderable.price[:amount] || 0)
order.order_items.new(quantity: 1, orderable_type: orderable.class.name, orderable_id: orderable.id, amount: orderable.price[:amount] || 0)
end
end

View File

@ -3,11 +3,11 @@
# Provides methods to create new cart items, based on an existing Order
class Cart::CreateCartItemService
def initialize(order)
raise Cart::AnonymousError if order.statistic_profile.nil? || order.operator_profile.nil?
raise Cart::AnonymousError, I18n.t('cart_validation.select_user') if order.statistic_profile.nil? || order.operator_profile.nil?
@order = order
@customer = order.user
@operator = order.user.privileged? ? order.operator_profile.user : order.user
@operator = order.operator_profile.user
end
def create(item)

View File

@ -5,7 +5,7 @@ class Cart::RefreshItemService
def call(order, orderable)
raise Cart::InactiveProductError unless orderable.is_active
item = order.order_items.find_by(orderable: orderable)
item = order.order_items.find_by(orderable_type: orderable.class.name, orderable_id: orderable.id)
raise ActiveRecord::RecordNotFound if item.nil?

View File

@ -3,7 +3,7 @@
# Provides methods for remove order item to cart
class Cart::RemoveItemService
def call(order, orderable)
item = order.order_items.find_by(orderable: orderable)
item = order.order_items.find_by(orderable_type: orderable.class.name, orderable_id: orderable.id)
raise ActiveRecord::RecordNotFound if item.nil?

View File

@ -5,10 +5,13 @@ module Cart; end
# Provides methods to update the customer of the given cart
class Cart::SetCustomerService
# @param operator [User]
def initialize(operator)
@operator = operator
end
# @param order[Order]
# @param customer [User]
def call(order, customer)
return order unless @operator.privileged?
@ -19,6 +22,9 @@ class Cart::SetCustomerService
ActiveRecord::Base.transaction do
order.statistic_profile_id = customer.statistic_profile.id
order.operator_profile_id = @operator.invoicing_profile.id
order.order_items.each do |item|
update_item_user(item, customer)
end
unset_offer(order, customer)
Cart::UpdateTotalService.new.call(order)
order.save
@ -26,7 +32,11 @@ class Cart::SetCustomerService
order.reload
end
private
# If the operator is also the customer, he cannot offer items to himself, so we unset all the offers
# @param order[Order]
# @param customer [User]
def unset_offer(order, customer)
return unless @operator == customer
@ -35,4 +45,15 @@ class Cart::SetCustomerService
item.save
end
end
# @param item[OrderItem]
# @param customer [User]
def update_item_user(item, customer)
return unless item.orderable_type.match(/^CartItem::/)
item.orderable.update_with_context({
customer_profile_id: customer.invoicing_profile.id,
operator_profile_id: @operator.invoicing_profile.id
}, item.order.order_items)
end
end

View File

@ -6,7 +6,7 @@ module Cart; end
# Provides methods for set offer to item in cart
class Cart::SetOfferService
def call(order, orderable, is_offered)
item = order.order_items.find_by(orderable: orderable)
item = order.order_items.find_by(orderable_type: orderable.class.name, orderable_id: orderable.id)
raise ActiveRecord::RecordNotFound if item.nil?

View File

@ -9,7 +9,7 @@ class Cart::SetQuantityService
raise Cart::OutStockError if quantity.to_i > orderable.stock['external']
item = order.order_items.find_by(orderable: orderable)
item = order.order_items.find_by(orderable_type: orderable.class.name, orderable_id: orderable.id)
raise ActiveRecord::RecordNotFound if item.nil?

View File

@ -2,13 +2,24 @@
# Provides methods for update total of cart
class Cart::UpdateTotalService
# @param order[Order]
def call(order)
total = 0
order.order_items.each do |item|
update_item_price(item)
total += (item.amount * item.quantity) unless item.is_offered
end
order.total = total
order.save
order
end
private
# @param item[OrderItem]
def update_item_price(item)
return unless item.orderable_type.match(/^CartItem::/)
item.update(amount: item.orderable.price[:amount] || 0)
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
json.orderable_name item.orderable.reservable.name
json.orderable_slug item.orderable.reservable.slug
json.orderable_main_image_url item.orderable.reservable&.try("#{item.orderable.reservable_type.downcase}_image")&.attachment&.medium&.url
json.slots_reservations item.orderable.cart_item_reservation_slots do |sr|
json.extract! sr, :id, :offered
json.slot do
json.extract! sr.slot, :id, :start_at, :end_at
end
end

View File

@ -28,4 +28,7 @@ json.order_items_attributes order.order_items.order(created_at: :asc) do |item|
json.amount item.amount / 100.0
json.is_offered item.is_offered
json.partial! 'api/orders/product', item: item if item.orderable_type == 'Product'
if %w[CartItem::MachineReservation CartItem::SpaceReservation CartItem::TrainingReservation].include?(item.orderable_type)
json.partial! 'api/orders/cart_item_reservation', item: item
end
end

View File

@ -446,6 +446,8 @@ en:
abstract_item:
offer_product: "Offer the product"
total: "Total"
errors:
unauthorized_offering_product: "You can't offer anything to yourself"
cart_order_product:
reference_short: "ref:"
minimum_purchase: "Minimum purchase: "
@ -458,7 +460,11 @@ en:
stock_limit_QUANTITY: "Only {QUANTITY} {QUANTITY, plural, =1{unit} other{units}} left in stock, please adjust the quantity of items."
quantity_min_QUANTITY: "Minimum number of product was changed to {QUANTITY}, please adjust the quantity of items."
price_changed_PRICE: "The product price was modified to {PRICE}"
unauthorized_offering_product: "You can't offer anything to yourself"
cart_order_reservation:
reservation: "Reservation"
offer_reservation: "Offer the reservation"
slot: "{DATE}: {START} - {END}"
offered: "offered"
orders_dashboard:
heading: "My orders"
sort:

View File

@ -524,6 +524,8 @@ en:
space: "This space is disabled"
machine: "This machine is disabled"
reservable: "This machine is not reservable"
cart_validation:
select_user: "Please select a user before continuing"
settings:
locked_setting: "the setting is locked."
about_title: "\"About\" page title"

View File

@ -518,6 +518,8 @@ fr:
space: "Cet espace est désactivé"
machine: "Cette machine est désactivée"
reservable: "Cette machine n'est pas réservable"
cart_validation:
select_user: "Veuillez sélectionner un utilisateur avant de continuer"
settings:
locked_setting: "le paramètre est verrouillé."
about_title: "Le titre de la page \"À propos\""