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

(merge) Merge remote-tracking branch 'origin/product-store_cart' into product-store

This commit is contained in:
Sylvain 2022-10-04 09:41:47 +02:00
commit 3a8082db97
13 changed files with 234 additions and 46 deletions

View File

@ -37,6 +37,18 @@ class API::CartController < API::ApiController
render 'api/orders/show'
end
def refresh_item
authorize @current_order, policy_class: CartPolicy
@order = Cart::RefreshItemService.new.call(@current_order, orderable)
render 'api/orders/show'
end
def validate
authorize @current_order, policy_class: CartPolicy
@order_errors = Cart::CheckCartService.new.call(@current_order)
render json: @order_errors
end
private
def orderable

View File

@ -1,6 +1,6 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Order } from '../models/order';
import { Order, OrderErrors } from '../models/order';
export default class CartAPI {
static async create (token?: string): Promise<Order> {
@ -27,4 +27,14 @@ export default class CartAPI {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, is_offered: isOffered });
return res?.data;
}
static async refreshItem (order: Order, orderableId: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/refresh_item', { order_token: order.token, orderable_id: orderableId });
return res?.data;
}
static async validate (order: Order): Promise<OrderErrors> {
const res: AxiosResponse<OrderErrors> = await apiClient.post('/api/cart/validate', { order_token: order.token });
return res?.data;
}
}

View File

@ -10,7 +10,7 @@ import CartAPI from '../../api/cart';
import { User } from '../../models/user';
import { PaymentModal } from '../payment/stripe/payment-modal';
import { PaymentMethod } from '../../models/payment';
import { Order } from '../../models/order';
import { Order, OrderErrors } from '../../models/order';
import { MemberSelect } from '../user/member-select';
import { CouponInput } from '../coupon/coupon-input';
import { Coupon } from '../../models/coupon';
@ -20,6 +20,7 @@ import OrderLib from '../../lib/order';
import { CaretDown, CaretUp } from 'phosphor-react';
import SettingAPI from '../../api/setting';
import { SettingName } from '../../models/setting';
import _ from 'lodash';
declare const Application: IApplication;
@ -36,8 +37,8 @@ interface StoreCartProps {
const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser, userLogin }) => {
const { t } = useTranslation('public');
const { cart, setCart } = useCart(currentUser);
const [itemsQuantity, setItemsQuantity] = useState<{ id: number; quantity: number; }[]>([]);
const { cart, setCart, reloadCart } = useCart(currentUser);
const [cartErrors, setCartErrors] = useState<OrderErrors>(null);
const [paymentModal, setPaymentModal] = useState<boolean>(false);
const [settings, setSettings] = useState<Map<SettingName, string>>(null);
@ -48,10 +49,9 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
}, []);
useEffect(() => {
const quantities = cart?.order_items_attributes.map(i => {
return { id: i.id, quantity: i.quantity };
});
setItemsQuantity(quantities);
if (cart) {
checkCart();
}
}, [cart]);
/**
@ -61,9 +61,14 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
CartAPI.removeItem(cart, item.orderable_id).then(data => {
setCart(data);
}).catch(onError);
const errors = getItemErrors(item);
if (errors.length === 1 && errors[0].error === 'not_found') {
reloadCart().catch(onError);
} else {
CartAPI.removeItem(cart, item.orderable_id).then(data => {
setCart(data);
}).catch(onError);
}
};
};
@ -77,8 +82,11 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
})
.catch(() => onError(t('app.public.store_cart.stock_limit')));
};
/** Increment/decrement product quantity */
const handleInputNumber = (item, direction: 'up' | 'down') => {
/**
* Increment/decrement product quantity
*/
const increaseOrDecreaseProductQuantity = (item, direction: 'up' | 'down') => {
CartAPI.setQuantity(cart, item.orderable_id, direction === 'up' ? item.quantity + 1 : item.quantity - 1)
.then(data => {
setCart(data);
@ -86,6 +94,28 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
.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).then(data => {
setCart(data);
}).catch(onError);
};
};
/**
* Check the current cart's items (available, price, stock, quantity_min)
*/
const checkCart = async (): Promise<OrderErrors> => {
const errors = await CartAPI.validate(cart);
setCartErrors(errors);
return errors;
};
/**
* Checkout cart
*/
@ -96,11 +126,36 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
if (!cart.user) {
onError(t('app.public.store_cart.select_user'));
} else {
setPaymentModal(true);
checkCart().then(errors => {
if (!hasCartErrors(errors)) {
setPaymentModal(true);
}
});
}
}
};
/**
* Check if the carrent cart has any error
*/
const hasCartErrors = (errors: OrderErrors) => {
if (!errors) return false;
for (const item of cart.order_items_attributes) {
const error = _.find(errors.details, (e) => e.item_id === item.id);
if (!error || error?.errors?.length > 0) return true;
}
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
*/
@ -162,6 +217,30 @@ 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>;
}
};
/**
* Text instructions for the customer
*/
@ -178,7 +257,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
<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'>
<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>
@ -188,6 +267,9 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
{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'>
@ -199,10 +281,10 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
onChange={e => changeProductQuantity(e, item)}
min={item.quantity_min}
max={item.orderable_external_stock}
value={itemsQuantity?.find(i => i.id === item.id)?.quantity || 1}
value={item.quantity}
/>
<button onClick={() => handleInputNumber(item, 'up')}><CaretUp size={12} weight="fill" /></button>
<button onClick={() => handleInputNumber(item, 'down')}><CaretDown size={12} weight="fill" /></button>
<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>

View File

@ -41,7 +41,7 @@ export default class ProductLib {
};
static stockStatusTrKey = (product: Product): string => {
if (product.stock.external <= 0) {
if (product.stock.external <= (product.quantity_min || 0)) {
return 'app.public.stock_status.out_of_stock';
}
if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) {

View File

@ -63,3 +63,14 @@ export interface OrderIndexFilter extends ApiFilter {
period_from?: string,
period_to?: string
}
export interface OrderErrors {
order_id: number,
details: Array<{
item_id: number,
errors: Array<{
error: string,
value: string|number
}>
}>
}

View File

@ -41,19 +41,28 @@
margin: 0;
@include text-base(600);
}
.min {
.min,.error p {
margin-top: 0.8rem;
@include text-sm;
color: var(--alert);
text-transform: none;
}
.error .refresh-btn {
@extend .fab-button, .is-black;
height: auto;
margin-top: 0.4rem;
padding: 0.4rem 0.8rem;
@include text-sm;
}
}
.actions {
grid-area: 2 / 1 / 3 / 3;
align-self: stretch;
padding: 0.8rem;
display: grid;
grid-auto-flow: column;
justify-content: space-between;
grid-template-columns: min-content min-content;
justify-content: space-evenly;
justify-items: flex-end;
align-items: center;
gap: 2.4rem;
background-color: var(--gray-soft-light);
@ -87,8 +96,7 @@
border-radius: var(--border-radius-sm);
input[type="number"] {
grid-area: 1 / 1 / 3 / 2;
width: 4ch;
min-width: fit-content;
min-width: 4ch;
background-color: transparent;
border: none;
text-align: right;
@ -133,11 +141,14 @@
text-transform: uppercase;
}
}
&.error {
border-color: var(--alert);
}
}
}
.group {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: 1fr;
gap: 2.4rem;
}
&-info,
@ -217,25 +228,35 @@
}
}
@media (min-width: 640px) {
.actions {
grid-auto-flow: column;
grid-template-columns: 1fr min-content 1fr min-content;
justify-content: stretch;
justify-items: flex-end;
align-items: center;
}
}
@media (min-width: 1024px) {
&-list-item {
.ref { grid-area: 1 / 2 / 2 / 3; }
.actions { grid-area: 2 / 1 / 3 / 4; }
.offer { grid-area: 1 / 3 / 2 / 4; }
.actions { grid-area: 2 / 1 / 3 / 3; }
}
.group { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1200px) {
&-list-item {
grid-auto-flow: column;
grid-auto-flow: row;
grid-template-columns: min-content 1fr 1fr;
justify-content: space-between;
align-items: center;
.picture { grid-area: auto; }
.ref { grid-area: auto; }
.actions { grid-area: auto; }
.picture { grid-area: 1 / 1 / 2 / 2; }
.ref { grid-area: 1 / 2 / 2 / 3; }
.actions { grid-area: 1 / 3 / 2 / 4; }
.offer {
grid-area: auto;
align-self: flex-start;
grid-area: 2 / 1 / 3 / 4;
justify-self: flex-end;
}
}
}

View File

@ -6,7 +6,7 @@ class CartPolicy < ApplicationPolicy
true
end
%w[add_item remove_item set_quantity].each do |action|
%w[add_item remove_item set_quantity refresh_item validate].each do |action|
define_method "#{action}?" do
return user.privileged? || (record.statistic_profile_id == user.statistic_profile.id) if user

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
# Provides methods for check cart's items (available, price, stock, quantity_min)
class Cart::CheckCartService
def call(order)
res = { order_id: order.id, details: [] }
order.order_items.each do |item|
errors = []
errors.push({ error: 'is_active', value: false }) unless item.orderable.is_active
if item.quantity > item.orderable.stock['external'] || item.orderable.stock['external'] < item.orderable.quantity_min
value = item.orderable.stock['external'] < item.orderable.quantity_min ? 0 : item.orderable.stock['external']
errors.push({ error: 'stock', value: value })
end
orderable_amount = item.orderable.amount || 0
errors.push({ error: 'amount', value: orderable_amount / 100.0 }) if item.amount != orderable_amount
errors.push({ error: 'quantity_min', value: item.orderable.quantity_min }) if item.quantity < item.orderable.quantity_min
res[:details].push({ item_id: item.id, errors: errors })
end
res
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
# Provides methods for refresh amount of order item
class Cart::RefreshItemService
def call(order, orderable)
raise Cart::InactiveProductError unless orderable.is_active
item = order.order_items.find_by(orderable: orderable)
raise ActiveRecord::RecordNotFound if item.nil?
order.total -= (item.amount * item.quantity.to_i) unless item.is_offered
item.amount = orderable.amount || 0
order.total += (item.amount * item.quantity.to_i) unless item.is_offered
ActiveRecord::Base.transaction do
item.save
order.save
end
order.reload
end
end

View File

@ -44,7 +44,7 @@ class Orders::OrderService
def in_stock?(order, stock_type = 'external')
order.order_items.each do |item|
return false if item.orderable.stock[stock_type] < item.quantity
return false if item.orderable.stock[stock_type] < item.quantity || item.orderable.stock[stock_type] < item.orderable.quantity_min
end
true
end
@ -58,7 +58,8 @@ class Orders::OrderService
def item_amount_not_equal?(order)
order.order_items.each do |item|
return false if item.amount != item.orderable.amount
orderable_amount = item.orderable.amount || 0
return false if item.amount != orderable_amount
end
true
end

View File

@ -443,6 +443,13 @@ en:
checkout_success: "Purchase confirmed. Thanks!"
select_user: "Please select a user before continuing."
please_contact_FABLAB: "Please contact {FABLAB, select, undefined{us} other{{FABLAB}}} for withdrawal instructions."
update_item: "Update"
errors:
product_not_found: "This product is no longer available, please remove it from your cart."
out_of_stock: "This product is out of stock, please remove it from your cart."
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}"
orders_dashboard:
heading: "My orders"
sort:

View File

@ -162,6 +162,8 @@ Rails.application.routes.draw do
put 'remove_item', on: :collection
put 'set_quantity', on: :collection
put 'set_offer', on: :collection
put 'refresh_item', on: :collection
post 'validate', on: :collection
end
resources :checkout, only: %i[] do
post 'payment', on: :collection

View File

@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2022_10_03_133019) do
enable_extension "unaccent"
create_table "abuses", id: :serial, force: :cascade do |t|
t.integer "signaled_id"
t.string "signaled_type"
t.integer "signaled_id"
t.string "first_name"
t.string "last_name"
t.string "email"
@ -49,8 +49,8 @@ ActiveRecord::Schema.define(version: 2022_10_03_133019) do
t.string "locality"
t.string "country"
t.string "postal_code"
t.integer "placeable_id"
t.string "placeable_type"
t.integer "placeable_id"
t.datetime "created_at"
t.datetime "updated_at"
end
@ -64,8 +64,8 @@ ActiveRecord::Schema.define(version: 2022_10_03_133019) do
end
create_table "assets", id: :serial, force: :cascade do |t|
t.integer "viewable_id"
t.string "viewable_type"
t.integer "viewable_id"
t.string "attachment"
t.string "type"
t.datetime "created_at"
@ -147,8 +147,8 @@ ActiveRecord::Schema.define(version: 2022_10_03_133019) do
end
create_table "credits", id: :serial, force: :cascade do |t|
t.integer "creditable_id"
t.string "creditable_type"
t.integer "creditable_id"
t.integer "plan_id"
t.integer "hours"
t.datetime "created_at"
@ -375,15 +375,15 @@ ActiveRecord::Schema.define(version: 2022_10_03_133019) do
create_table "notifications", id: :serial, force: :cascade do |t|
t.integer "receiver_id"
t.integer "attached_object_id"
t.string "attached_object_type"
t.integer "attached_object_id"
t.integer "notification_type_id"
t.boolean "is_read", default: false
t.datetime "created_at"
t.datetime "updated_at"
t.string "receiver_type"
t.boolean "is_send", default: false
t.jsonb "meta_data", default: {}
t.jsonb "meta_data", default: "{}"
t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id"
t.index ["receiver_id"], name: "index_notifications_on_receiver_id"
end
@ -623,8 +623,8 @@ ActiveRecord::Schema.define(version: 2022_10_03_133019) do
create_table "prices", id: :serial, force: :cascade do |t|
t.integer "group_id"
t.integer "plan_id"
t.integer "priceable_id"
t.string "priceable_type"
t.integer "priceable_id"
t.integer "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -824,8 +824,8 @@ ActiveRecord::Schema.define(version: 2022_10_03_133019) do
t.text "message"
t.datetime "created_at"
t.datetime "updated_at"
t.integer "reservable_id"
t.string "reservable_type"
t.integer "reservable_id"
t.integer "nb_reserve_places"
t.integer "statistic_profile_id"
t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id"
@ -834,8 +834,8 @@ ActiveRecord::Schema.define(version: 2022_10_03_133019) do
create_table "roles", id: :serial, force: :cascade do |t|
t.string "name"
t.integer "resource_id"
t.string "resource_type"
t.integer "resource_id"
t.datetime "created_at"
t.datetime "updated_at"
t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id"
@ -1115,8 +1115,8 @@ ActiveRecord::Schema.define(version: 2022_10_03_133019) do
t.boolean "is_allow_newsletter"
t.inet "current_sign_in_ip"
t.inet "last_sign_in_ip"
t.string "mapped_from_sso"
t.datetime "validated_at"
t.string "mapped_from_sso"
t.index ["auth_token"], name: "index_users_on_auth_token"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true