From ac671ea26d12834907d0ada56fc41a61cddb76a0 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Tue, 27 Sep 2022 19:44:39 +0200 Subject: [PATCH 01/15] (feat) check cart item amount/available/quantity_min/stock before checkout --- app/controllers/api/cart_controller.rb | 12 +++ app/frontend/src/javascript/api/cart.ts | 12 ++- .../javascript/components/cart/store-cart.tsx | 88 ++++++++++++++++++- app/frontend/src/javascript/models/order.ts | 11 +++ .../stylesheets/modules/cart/store-cart.scss | 5 +- app/policies/cart_policy.rb | 2 +- app/services/cart/check_cart_service.rb | 18 ++++ app/services/cart/refresh_item_service.rb | 21 +++++ app/services/orders/order_service.rb | 3 +- config/routes.rb | 2 + db/schema.rb | 20 ++--- 11 files changed, 177 insertions(+), 17 deletions(-) create mode 100644 app/services/cart/check_cart_service.rb create mode 100644 app/services/cart/refresh_item_service.rb diff --git a/app/controllers/api/cart_controller.rb b/app/controllers/api/cart_controller.rb index 82cc48615..f338d99b7 100644 --- a/app/controllers/api/cart_controller.rb +++ b/app/controllers/api/cart_controller.rb @@ -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 diff --git a/app/frontend/src/javascript/api/cart.ts b/app/frontend/src/javascript/api/cart.ts index f0efea8db..a863de8ee 100644 --- a/app/frontend/src/javascript/api/cart.ts +++ b/app/frontend/src/javascript/api/cart.ts @@ -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 { @@ -27,4 +27,14 @@ export default class CartAPI { const res: AxiosResponse = 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 { + const res: AxiosResponse = await apiClient.put('/api/cart/refresh_item', { order_token: order.token, orderable_id: orderableId }); + return res?.data; + } + + static async validate (order: Order): Promise { + const res: AxiosResponse = await apiClient.post('/api/cart/validate', { order_token: order.token }); + return res?.data; + } } diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index 7b7c055be..728173441 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -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'; @@ -18,6 +18,7 @@ import noImage from '../../../../images/no_image.png'; import Switch from 'react-switch'; import OrderLib from '../../lib/order'; import { CaretDown, CaretUp } from 'phosphor-react'; +import _ from 'lodash'; declare const Application: IApplication; @@ -35,6 +36,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, const { t } = useTranslation('public'); const { cart, setCart } = useCart(currentUser); + const [cartErrors, setCartErrors] = useState(null); const [itemsQuantity, setItemsQuantity] = useState<{ id: number; quantity: number; }[]>(); const [paymentModal, setPaymentModal] = useState(false); @@ -54,6 +56,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, e.stopPropagation(); CartAPI.removeItem(cart, item.orderable_id).then(data => { setCart(data); + checkCart(); }).catch(onError); }; }; @@ -65,6 +68,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, CartAPI.setQuantity(cart, item.orderable_id, e.target.value) .then(data => { setCart(data); + checkCart(); }) .catch(() => onError(t('app.public.store_cart.stock_limit'))); }; @@ -73,10 +77,34 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, CartAPI.setQuantity(cart, item.orderable_id, direction === 'up' ? item.quantity + 1 : item.quantity - 1) .then(data => { setCart(data); + checkCart(); }) .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); + checkCart(); + }).catch(onError); + }; + }; + + /** + * Check the current cart's items (available, price, stock, quantity_min) + */ + const checkCart = async (): Promise => { + const errors = await CartAPI.validate(cart); + setCartErrors(errors); + return errors; + }; + /** * Checkout cart */ @@ -84,10 +112,35 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, if (!currentUser) { userLogin(); } 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 */ @@ -149,12 +202,36 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, } }; + /** + * Show item error + */ + const itemError = (item, error) => { + if (error.error === 'is_active' || error.error === 'not_found') { + return
This product is not available, please remove it in cart
; + } + if (error.error === 'stock' && error.value === 0) { + return
This product is out of stock. Please remove this item to before checkout the cart.
; + } + if (error.error === 'stock' && error.value > 0) { + return
This product has only {error.value} unit in stock, please change the quantity of item.
; + } + if (error.error === 'quantity_min') { + return
Minimum number of product was changed to {error.value}, please change the quantity of item
; + } + if (error.error === 'amount') { + return
+ The price of product was modified to {FormatLib.price(error.value)} / {t('app.public.store_cart.unit')} + Update +
; + } + }; + return (
{cart && cartIsEmpty() &&

{t('app.public.store_cart.cart_is_empty')}

} {cart && cart.order_items_attributes.map(item => ( -
+
0 ? 'error' : ''}`}>
@@ -164,6 +241,11 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, {item.quantity_min > 1 && {t('app.public.store_cart.minimum_purchase')}{item.quantity_min} } +
+ {getItemErrors(item).map(e => { + return itemError(item, e); + })} +
diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts index 421bfa509..0ac99a3b4 100644 --- a/app/frontend/src/javascript/models/order.ts +++ b/app/frontend/src/javascript/models/order.ts @@ -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 + }> + }> +} diff --git a/app/frontend/src/stylesheets/modules/cart/store-cart.scss b/app/frontend/src/stylesheets/modules/cart/store-cart.scss index 5d28b627e..6ce5176f3 100644 --- a/app/frontend/src/stylesheets/modules/cart/store-cart.scss +++ b/app/frontend/src/stylesheets/modules/cart/store-cart.scss @@ -41,7 +41,7 @@ margin: 0; @include text-base(600); } - .min { + .min,.error { margin-top: 0.8rem; color: var(--alert); text-transform: none; @@ -133,6 +133,9 @@ text-transform: uppercase; } } + &.error { + border-color: var(--alert); + } } } .group { diff --git a/app/policies/cart_policy.rb b/app/policies/cart_policy.rb index 4772323f4..66ca3865d 100644 --- a/app/policies/cart_policy.rb +++ b/app/policies/cart_policy.rb @@ -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 diff --git a/app/services/cart/check_cart_service.rb b/app/services/cart/check_cart_service.rb new file mode 100644 index 000000000..6936f1878 --- /dev/null +++ b/app/services/cart/check_cart_service.rb @@ -0,0 +1,18 @@ +# 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 + errors.push({ error: 'stock', value: item.orderable.stock['external'] }) if item.quantity > item.orderable.stock['external'] + 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 diff --git a/app/services/cart/refresh_item_service.rb b/app/services/cart/refresh_item_service.rb new file mode 100644 index 000000000..7828f0be7 --- /dev/null +++ b/app/services/cart/refresh_item_service.rb @@ -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 diff --git a/app/services/orders/order_service.rb b/app/services/orders/order_service.rb index 2d724c424..6a698042b 100644 --- a/app/services/orders/order_service.rb +++ b/app/services/orders/order_service.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index d8942981b..626af4912 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 62fa5125d..aaa98c964 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2022_09_20_131912) 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_09_20_131912) 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_09_20_131912) 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_09_20_131912) 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_09_20_131912) 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_09_20_131912) 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 @@ -823,8 +823,8 @@ ActiveRecord::Schema.define(version: 2022_09_20_131912) 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" @@ -833,8 +833,8 @@ ActiveRecord::Schema.define(version: 2022_09_20_131912) 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" @@ -1114,8 +1114,8 @@ ActiveRecord::Schema.define(version: 2022_09_20_131912) 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 From c7559e38578d459c743bf0381ce62f2067ba60e1 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 28 Sep 2022 11:32:07 +0200 Subject: [PATCH 02/15] (bug) product stock status --- .../src/javascript/components/store/store-product-item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/src/javascript/components/store/store-product-item.tsx b/app/frontend/src/javascript/components/store/store-product-item.tsx index 9c8934607..c934ef5a4 100644 --- a/app/frontend/src/javascript/components/store/store-product-item.tsx +++ b/app/frontend/src/javascript/components/store/store-product-item.tsx @@ -66,7 +66,7 @@ export const StoreProductItem: React.FC = ({ product, car * Return product's stock status */ const productStockStatus = (product: Product) => { - if (product.stock.external === 0) { + if (product.stock.external < (product.quantity_min || 1)) { return {t('app.public.store_product_item.out_of_stock')}; } if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) { From 274d661a58077bd22cd26bbdd162d9bd2001d975 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 28 Sep 2022 12:14:58 +0200 Subject: [PATCH 03/15] (feat) always check cart items when user in cart page --- .../javascript/components/cart/store-cart.tsx | 34 +++++++++++-------- .../components/store/store-product-item.tsx | 2 +- app/services/cart/check_cart_service.rb | 5 ++- app/services/orders/order_service.rb | 2 +- config/locales/app.public.en.yml | 7 ++++ 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index 728173441..0e6bb5e32 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -35,7 +35,7 @@ interface StoreCartProps { const StoreCart: React.FC = ({ onSuccess, onError, currentUser, userLogin }) => { const { t } = useTranslation('public'); - const { cart, setCart } = useCart(currentUser); + const { cart, setCart, reloadCart } = useCart(currentUser); const [cartErrors, setCartErrors] = useState(null); const [itemsQuantity, setItemsQuantity] = useState<{ id: number; quantity: number; }[]>(); const [paymentModal, setPaymentModal] = useState(false); @@ -45,6 +45,9 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, return { id: i.id, quantity: i.quantity }; }); setItemsQuantity(quantities); + if (cart) { + checkCart(); + } }, [cart]); /** @@ -54,10 +57,14 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, return (e: React.BaseSyntheticEvent) => { e.preventDefault(); e.stopPropagation(); - CartAPI.removeItem(cart, item.orderable_id).then(data => { - setCart(data); - checkCart(); - }).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); + } }; }; @@ -68,7 +75,6 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, CartAPI.setQuantity(cart, item.orderable_id, e.target.value) .then(data => { setCart(data); - checkCart(); }) .catch(() => onError(t('app.public.store_cart.stock_limit'))); }; @@ -77,7 +83,6 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, CartAPI.setQuantity(cart, item.orderable_id, direction === 'up' ? item.quantity + 1 : item.quantity - 1) .then(data => { setCart(data); - checkCart(); }) .catch(() => onError(t('app.public.store_cart.stock_limit'))); }; @@ -91,7 +96,6 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, e.stopPropagation(); CartAPI.refreshItem(cart, item.orderable_id).then(data => { setCart(data); - checkCart(); }).catch(onError); }; }; @@ -138,7 +142,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, const getItemErrors = (item) => { if (!cartErrors) return []; const errors = _.find(cartErrors.details, (e) => e.item_id === item.id); - return errors.errors || [{ error: 'not_found' }]; + return errors?.errors || [{ error: 'not_found' }]; }; /** @@ -207,21 +211,21 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, */ const itemError = (item, error) => { if (error.error === 'is_active' || error.error === 'not_found') { - return
This product is not available, please remove it in cart
; + return
{t('app.public.store_cart.errors.product_not_found')}
; } if (error.error === 'stock' && error.value === 0) { - return
This product is out of stock. Please remove this item to before checkout the cart.
; + return
{t('app.public.store_cart.errors.out_of_stock')}
; } if (error.error === 'stock' && error.value > 0) { - return
This product has only {error.value} unit in stock, please change the quantity of item.
; + return
{t('app.public.store_cart.errors.stock_limit_QUANTITY', { QUANTITY: error.value })}
; } if (error.error === 'quantity_min') { - return
Minimum number of product was changed to {error.value}, please change the quantity of item
; + return
{t('app.public.store_cart.errors.quantity_min_QUANTITY', { QUANTITY: error.value })}
; } if (error.error === 'amount') { return
- The price of product was modified to {FormatLib.price(error.value)} / {t('app.public.store_cart.unit')} - Update + {t('app.public.store_cart.errors.price_changed_PRICE', { PRICE: `${FormatLib.price(error.value)} / ${t('app.public.store_cart.unit')}` })} + {t('app.public.store_cart.update_item')}
; } }; diff --git a/app/frontend/src/javascript/components/store/store-product-item.tsx b/app/frontend/src/javascript/components/store/store-product-item.tsx index c934ef5a4..3ff9cf132 100644 --- a/app/frontend/src/javascript/components/store/store-product-item.tsx +++ b/app/frontend/src/javascript/components/store/store-product-item.tsx @@ -66,7 +66,7 @@ export const StoreProductItem: React.FC = ({ product, car * Return product's stock status */ const productStockStatus = (product: Product) => { - if (product.stock.external < (product.quantity_min || 1)) { + if (product.stock.external === 0 || product.stock.external < (product.quantity_min || 1)) { return {t('app.public.store_product_item.out_of_stock')}; } if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) { diff --git a/app/services/cart/check_cart_service.rb b/app/services/cart/check_cart_service.rb index 6936f1878..e576c5b79 100644 --- a/app/services/cart/check_cart_service.rb +++ b/app/services/cart/check_cart_service.rb @@ -7,7 +7,10 @@ class Cart::CheckCartService order.order_items.each do |item| errors = [] errors.push({ error: 'is_active', value: false }) unless item.orderable.is_active - errors.push({ error: 'stock', value: item.orderable.stock['external'] }) if item.quantity > item.orderable.stock['external'] + 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 diff --git a/app/services/orders/order_service.rb b/app/services/orders/order_service.rb index 6a698042b..3d2a94a7f 100644 --- a/app/services/orders/order_service.rb +++ b/app/services/orders/order_service.rb @@ -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 diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index e90223626..899f9bd82 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -434,6 +434,13 @@ en: checkout_total: "Cart total" checkout_error: "An unexpected error occurred. Please contact the administrator." checkout_success: "Purchase confirmed. Thanks!" + update_item: "Update" + errors: + product_not_found: "This product is not available, please remove it in cart." + out_of_stock: "This product is out of stock. Please remove this item to before checkout the cart." + stock_limit_QUANTITY: "This product has only {QUANTITY} {QUANTITY, plural, =1{unit} other{units}} in stock, please change the quantity of item." + quantity_min_QUANTITY: "Minimum number of product was changed to {QUANTITY}, please change the quantity of item." + price_changed_PRICE: "The price of product was modified to {PRICE}" orders_dashboard: heading: "My orders" sort: From 91b7fb9e6af9064ffb976074aed4864e751dae01 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 28 Sep 2022 12:21:46 +0200 Subject: [PATCH 04/15] (quality) refactoring change quantity in cart --- .../javascript/components/cart/store-cart.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index 0e6bb5e32..66d39bc34 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -37,14 +37,9 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, const { cart, setCart, reloadCart } = useCart(currentUser); const [cartErrors, setCartErrors] = useState(null); - const [itemsQuantity, setItemsQuantity] = useState<{ id: number; quantity: number; }[]>(); const [paymentModal, setPaymentModal] = useState(false); useEffect(() => { - const quantities = cart?.order_items_attributes.map(i => { - return { id: i.id, quantity: i.quantity }; - }); - setItemsQuantity(quantities); if (cart) { checkCart(); } @@ -78,8 +73,11 @@ const StoreCart: React.FC = ({ 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); @@ -261,10 +259,10 @@ const StoreCart: React.FC = ({ 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} + value={item.quantity} /> - - + +
{t('app.public.store_cart.total')} From 05e729fa268f583c526cbdf5b00b344440725c60 Mon Sep 17 00:00:00 2001 From: vincent Date: Wed, 28 Sep 2022 17:48:55 +0200 Subject: [PATCH 05/15] (inte) cart item error message + responsive --- .../javascript/components/cart/store-cart.tsx | 20 ++++---- .../stylesheets/modules/cart/store-cart.scss | 46 +++++++++++++------ config/locales/app.public.en.yml | 10 ++-- 3 files changed, 46 insertions(+), 30 deletions(-) diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index 66d39bc34..d92d07646 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -209,21 +209,21 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, */ const itemError = (item, error) => { if (error.error === 'is_active' || error.error === 'not_found') { - return
{t('app.public.store_cart.errors.product_not_found')}
; + return

{t('app.public.store_cart.errors.product_not_found')}

; } if (error.error === 'stock' && error.value === 0) { - return
{t('app.public.store_cart.errors.out_of_stock')}
; + return

{t('app.public.store_cart.errors.out_of_stock')}

; } if (error.error === 'stock' && error.value > 0) { - return
{t('app.public.store_cart.errors.stock_limit_QUANTITY', { QUANTITY: error.value })}
; + return

{t('app.public.store_cart.errors.stock_limit_QUANTITY', { QUANTITY: error.value })}

; } if (error.error === 'quantity_min') { - return
{t('app.public.store_cart.errors.quantity_min_QUANTITY', { QUANTITY: error.value })}
; + return

{t('app.public.store_cart.errors.quantity_min_QUANTITY', { QUANTITY: error.value })}

; } if (error.error === 'amount') { return
- {t('app.public.store_cart.errors.price_changed_PRICE', { PRICE: `${FormatLib.price(error.value)} / ${t('app.public.store_cart.unit')}` })} - {t('app.public.store_cart.update_item')} +

{t('app.public.store_cart.errors.price_changed_PRICE', { PRICE: `${FormatLib.price(error.value)} / ${t('app.public.store_cart.unit')}` })}

+ {t('app.public.store_cart.update_item')}
; } }; @@ -243,11 +243,9 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, {item.quantity_min > 1 && {t('app.public.store_cart.minimum_purchase')}{item.quantity_min} } -
- {getItemErrors(item).map(e => { - return itemError(item, e); - })} -
+ {getItemErrors(item).map(e => { + return itemError(item, e); + })}
diff --git a/app/frontend/src/stylesheets/modules/cart/store-cart.scss b/app/frontend/src/stylesheets/modules/cart/store-cart.scss index 6ce5176f3..d4c96bd55 100644 --- a/app/frontend/src/stylesheets/modules/cart/store-cart.scss +++ b/app/frontend/src/stylesheets/modules/cart/store-cart.scss @@ -41,19 +41,28 @@ margin: 0; @include text-base(600); } - .min,.error { + .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; @@ -140,7 +148,7 @@ } .group { display: grid; - grid-template-columns: repeat(2, 1fr); + grid-template-columns: 1fr; gap: 2.4rem; } &-info, @@ -220,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; } } } diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index 899f9bd82..c7286a576 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -436,11 +436,11 @@ en: checkout_success: "Purchase confirmed. Thanks!" update_item: "Update" errors: - product_not_found: "This product is not available, please remove it in cart." - out_of_stock: "This product is out of stock. Please remove this item to before checkout the cart." - stock_limit_QUANTITY: "This product has only {QUANTITY} {QUANTITY, plural, =1{unit} other{units}} in stock, please change the quantity of item." - quantity_min_QUANTITY: "Minimum number of product was changed to {QUANTITY}, please change the quantity of item." - price_changed_PRICE: "The price of product was modified to {PRICE}" + 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: From a260f8855583390c787a16b51818c3cbbf1400ef Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 28 Sep 2022 17:45:48 +0200 Subject: [PATCH 06/15] (feat) store withdrawal instructions --- app/frontend/src/javascript/api/setting.ts | 11 ++++-- .../base/text-editor/fab-text-editor.tsx | 6 ++++ .../components/base/text-editor/menu-bar.tsx | 10 ++---- .../javascript/components/cart/store-cart.tsx | 30 +++++++++++++--- .../components/store/store-settings.tsx | 36 ++++++++++++------- app/frontend/src/javascript/lib/product.ts | 3 +- app/frontend/src/javascript/lib/setting.ts | 25 +++++++++++++ app/frontend/src/javascript/models/setting.ts | 9 ++++- app/models/setting.rb | 3 +- app/policies/setting_policy.rb | 2 +- config/locales/app.admin.en.yml | 1 + config/locales/app.public.en.yml | 1 + config/locales/en.yml | 1 + 13 files changed, 106 insertions(+), 32 deletions(-) create mode 100644 app/frontend/src/javascript/lib/setting.ts diff --git a/app/frontend/src/javascript/api/setting.ts b/app/frontend/src/javascript/api/setting.ts index 76cc112e1..c0f8832e7 100644 --- a/app/frontend/src/javascript/api/setting.ts +++ b/app/frontend/src/javascript/api/setting.ts @@ -1,6 +1,13 @@ import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; -import { Setting, SettingBulkResult, SettingError, SettingName, SettingValue } from '../models/setting'; +import { + Setting, + SettingBulkArray, + SettingBulkResult, + SettingError, + SettingName, + SettingValue +} from '../models/setting'; export default class SettingAPI { static async get (name: SettingName): Promise { @@ -60,7 +67,7 @@ export default class SettingAPI { return map; } - private static toObjectArray (data: Map): Array> { + private static toObjectArray (data: Map): SettingBulkArray { const array = []; data.forEach((value, key) => { array.push({ diff --git a/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx b/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx index c08d5eef7..0e712265d 100644 --- a/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx +++ b/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx @@ -83,6 +83,12 @@ export const FabTextEditor: React.ForwardRefRenderFunction { + if (editor?.getHTML() !== content) { + editor?.commands.setContent(content); + } + }, [content]); + // bind the editor to the ref, once it is ready if (!editor) return null; editorRef.current = editor; diff --git a/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx b/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx index 97d2b647e..d3d74b28b 100644 --- a/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx +++ b/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx @@ -79,12 +79,6 @@ export const MenuBar: React.FC = ({ editor, heading, bulletList, b } }; - // prevent form submition propagation to parent forms - const handleSubmit = (event) => { - event.preventDefault(); - event.stopPropagation(); - }; - // Update the selected link const setLink = useCallback((closeLinkMenu?: boolean) => { if (url.href === '') { @@ -241,7 +235,7 @@ export const MenuBar: React.FC = ({ editor, heading, bulletList, b }
-
+
{ submenu === 'link' && (<>
{t('app.shared.text_editor.menu_bar.add_link')}
@@ -290,7 +284,7 @@ export const MenuBar: React.FC = ({ editor, heading, bulletList, b
) } -
+
); }; diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index b08ed2566..eb4e55df4 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -18,6 +18,8 @@ import noImage from '../../../../images/no_image.png'; import Switch from 'react-switch'; import OrderLib from '../../lib/order'; import { CaretDown, CaretUp } from 'phosphor-react'; +import SettingAPI from '../../api/setting'; +import { SettingName } from '../../models/setting'; declare const Application: IApplication; @@ -35,8 +37,15 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, const { t } = useTranslation('public'); const { cart, setCart } = useCart(currentUser); - const [itemsQuantity, setItemsQuantity] = useState<{ id: number; quantity: number; }[]>(); + const [itemsQuantity, setItemsQuantity] = useState<{ id: number; quantity: number; }[]>([]); const [paymentModal, setPaymentModal] = useState(false); + const [settings, setSettings] = useState>(null); + + useEffect(() => { + SettingAPI.query(['store_withdrawal_instructions', 'fablab_name']) + .then(res => setSettings(res)) + .catch(onError); + }, []); useEffect(() => { const quantities = cart?.order_items_attributes.map(i => { @@ -153,6 +162,17 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, } }; + /** + * Text instructions for the customer + */ + const withdrawalInstructions = (): string => { + const instructions = settings?.get('store_withdrawal_instructions'); + if (instructions) { + return instructions; + } + return t('app.public.store_cart.please_contact_FABLAB', { FABLAB: settings?.get('fablab_name') }); + }; + return (
@@ -160,7 +180,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, {cart && cart.order_items_attributes.map(item => (
- +
{t('app.public.store_cart.reference_short')} {item.orderable_ref || ''} @@ -179,7 +199,7 @@ const StoreCart: React.FC = ({ 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} + value={itemsQuantity?.find(i => i.id === item.id)?.quantity || 1} /> @@ -197,7 +217,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser,
}

{category.name}

- [count] + {category.products_count}
{!isDragging && diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index 990cc7d0b..3d9127264 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -255,7 +255,16 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter {categoriesTree.map(c =>

filterCategory(c.parent)}> - {c.parent.name}(count) + {c.parent.name} + + {/* here we add the parent count with the sum of all children counts */} + { + c.parent.products_count + + c.children + .map(ch => ch.products_count) + .reduce((sum, val) => sum + val, 0) + } +

{c.children.length > 0 &&
@@ -263,7 +272,7 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter

filterCategory(ch)}> - {ch.name}(count) + {ch.name}{ch.products_count}

)}
diff --git a/app/frontend/src/javascript/models/product-category.ts b/app/frontend/src/javascript/models/product-category.ts index 59acf07fa..7fcd5bbc6 100644 --- a/app/frontend/src/javascript/models/product-category.ts +++ b/app/frontend/src/javascript/models/product-category.ts @@ -4,4 +4,5 @@ export interface ProductCategory { slug: string, parent_id?: number, position: number, + products_count: number } diff --git a/app/models/product_category.rb b/app/models/product_category.rb index afe4595ae..e98f4245b 100644 --- a/app/models/product_category.rb +++ b/app/models/product_category.rb @@ -10,9 +10,9 @@ class ProductCategory < ApplicationRecord validates :slug, uniqueness: true belongs_to :parent, class_name: 'ProductCategory' - has_many :children, class_name: 'ProductCategory', foreign_key: :parent_id + has_many :children, class_name: 'ProductCategory', foreign_key: :parent_id, inverse_of: :parent, dependent: :nullify - has_many :products + has_many :products, dependent: :nullify acts_as_list scope: :parent, top_of_list: 0 end diff --git a/app/services/product_category_service.rb b/app/services/product_category_service.rb index fbcc72cf8..679525d53 100644 --- a/app/services/product_category_service.rb +++ b/app/services/product_category_service.rb @@ -3,7 +3,9 @@ # Provides methods for ProductCategory class ProductCategoryService def self.list - ProductCategory.all.order(parent_id: :asc, position: :asc) + ProductCategory.left_outer_joins(:products) + .select('product_categories.*, count(products.*) as products_count') + .group('product_categories.id') end def self.destroy(product_category) diff --git a/app/views/api/product_categories/_product_category.json.jbuilder b/app/views/api/product_categories/_product_category.json.jbuilder index e691d8276..6cdef711b 100644 --- a/app/views/api/product_categories/_product_category.json.jbuilder +++ b/app/views/api/product_categories/_product_category.json.jbuilder @@ -1,3 +1,3 @@ # frozen_string_literal: true -json.extract! product_category, :id, :name, :slug, :parent_id, :position +json.extract! product_category, :id, :name, :slug, :parent_id, :position, :products_count diff --git a/db/migrate/20221003133019_add_index_on_product_category_slug.rb b/db/migrate/20221003133019_add_index_on_product_category_slug.rb new file mode 100644 index 000000000..3b7c90dc6 --- /dev/null +++ b/db/migrate/20221003133019_add_index_on_product_category_slug.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# ProductCategory's slugs should validate uniqness in database +class AddIndexOnProductCategorySlug < ActiveRecord::Migration[5.2] + def change + add_index :product_categories, :slug, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 62fa5125d..2db9aa336 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_09_20_131912) do +ActiveRecord::Schema.define(version: 2022_10_03_133019) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -642,6 +642,7 @@ ActiveRecord::Schema.define(version: 2022_09_20_131912) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["parent_id"], name: "index_product_categories_on_parent_id" + t.index ["slug"], name: "index_product_categories_on_slug", unique: true end create_table "product_stock_movements", force: :cascade do |t| From db86769d4237308a9b6a13783c465dbcdb9e738a Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 3 Oct 2022 16:32:32 +0200 Subject: [PATCH 08/15] (bug) products filter: is_available previously, we misunderstand the behavior or this filter and we used the filter is_active instead --- .../src/javascript/components/store/store.tsx | 23 +++++++++++++++---- app/frontend/src/javascript/lib/product.ts | 6 ++--- app/frontend/src/javascript/models/product.ts | 2 ++ app/frontend/src/javascript/router.js | 5 ++-- app/services/product_service.rb | 7 ++++++ 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index 3d9127264..2a9c316d6 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -32,6 +32,19 @@ import { CaretDoubleDown } from 'phosphor-react'; declare const Application: IApplication; +const storeInitialFilters = { + ...initialFilters, + is_active: true +}; + +const storeInitialResources = { + ...initialResources, + filters: { + data: storeInitialFilters, + ready: false + } +}; + interface StoreProps { onError: (message: string) => void, onSuccess: (message: string) => void, @@ -54,7 +67,7 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter const [products, setProducts] = useState>([]); // this includes the resources fetch from the API (machines, categories) and from the URL (filters) - const [resources, setResources] = useImmer(initialResources); + const [resources, setResources] = useImmer(storeInitialResources); const [machinesModule, setMachinesModule] = useState(false); const [categoriesTree, setCategoriesTree] = useState([]); const [filtersPanel, setFiltersPanel] = useState(false); @@ -83,7 +96,7 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter return { ...draft, filters: { - data: ProductLib.readFiltersFromUrl(uiRouter.globals.params, resources.machines.data, resources.categories.data), + data: ProductLib.readFiltersFromUrl(uiRouter.globals.params, resources.machines.data, resources.categories.data, storeInitialFilters), ready: true } }; @@ -142,7 +155,7 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter filters: { ...draft.filters, data: { - ...initialFilters, + ...storeInitialFilters, categories: draft.filters.data.categories } } @@ -172,7 +185,7 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter * Filter: toggle non-available products visibility */ const toggleVisible = (checked: boolean) => { - ProductLib.updateFilter(setResources, 'is_active', checked); + ProductLib.updateFilter(setResources, 'is_available', checked); }; /** @@ -304,7 +317,7 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter selectOptions={buildOptions()} onSelectOptionsChange={handleSorting} switchLabel={t('app.public.store.products.in_stock_only')} - switchChecked={resources.filters.data.is_active} + switchChecked={resources.filters.data.is_available} selectValue={resources.filters.data.sort} onSwitch={toggleVisible} /> diff --git a/app/frontend/src/javascript/lib/product.ts b/app/frontend/src/javascript/lib/product.ts index 8c1b02411..db612110b 100644 --- a/app/frontend/src/javascript/lib/product.ts +++ b/app/frontend/src/javascript/lib/product.ts @@ -143,12 +143,12 @@ export default class ProductLib { /** * Parse the provided URL and return a ready-to-use filter object */ - static readFiltersFromUrl = (params: StateParams, machines: Array, categories: Array): ProductIndexFilter => { - const res: ProductIndexFilter = { ...initialFilters }; + static readFiltersFromUrl = (params: StateParams, machines: Array, categories: Array, defaultFilters = initialFilters): ProductIndexFilter => { + const res: ProductIndexFilter = { ...defaultFilters }; for (const key in params) { if (['#', 'categoryTypeUrl'].includes(key) || !Object.prototype.hasOwnProperty.call(params, key)) continue; - const value = ParsingLib.parse(params[key]) || initialFilters[key]; + const value = ParsingLib.parse(params[key]) || defaultFilters[key]; switch (key) { case 'category': { const parents = categories?.filter(c => (value as Array)?.includes(c.slug)); diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index dd2e57196..2019ef516 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -7,6 +7,7 @@ export type ProductSortOption = 'name-asc' | 'name-desc' | 'amount-asc' | 'amoun export interface ProductIndexFilter { is_active?: boolean, + is_available?: boolean, page?: number, categories?: ProductCategory[], machines?: Machine[], @@ -40,6 +41,7 @@ export const initialFilters: ProductIndexFilter = { keywords: [], machines: [], is_active: false, + is_available: false, stock_type: 'internal', stock_from: 0, stock_to: 0, diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index bbb628b35..cab02c6cc 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -626,7 +626,7 @@ angular.module('application.router', ['ui.router']) // store .state('app.public.store', { - url: '/store/:categoryTypeUrl/:category?{machines:string}{keywords:string}{is_active:string}{page:string}{sort:string}', + url: '/store/:categoryTypeUrl/:category?{machines:string}{keywords:string}{is_active:string}{is_available:string}{page:string}{sort:string}', abstract: !Fablab.storeModule, views: { 'main@': { @@ -639,7 +639,8 @@ angular.module('application.router', ['ui.router']) category: { dynamic: true, type: 'path', raw: true, value: null, squash: true }, machines: { array: true, dynamic: true, type: 'query', raw: true }, keywords: { dynamic: true, type: 'query' }, - is_active: { dynamic: true, type: 'query', value: 'false', squash: true }, + is_active: { dynamic: true, type: 'query', value: 'true', squash: true }, + is_available: { dynamic: true, type: 'query', value: 'false', squash: true }, page: { dynamic: true, type: 'query', value: '1', squash: true }, sort: { dynamic: true, type: 'query' } } diff --git a/app/services/product_service.rb b/app/services/product_service.rb index dad3c741e..8e5072d3d 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -8,6 +8,7 @@ class ProductService def list(filters, operator) products = Product.includes(:product_images) products = filter_by_active(products, filters) + products = filter_by_available(products, filters, operator) products = filter_by_categories(products, filters) products = filter_by_machines(products, filters) products = filter_by_keyword_or_reference(products, filters) @@ -89,6 +90,12 @@ class ProductService products.where(is_active: state) end + def filter_by_available(products, filters, operator) + return products if filters[:is_available].blank? || filters[:is_available] == 'false' + + filter_by_stock(products, { stock_type: 'external', stock_from: '1' }, operator) + end + def filter_by_categories(products, filters) return products if filters[:categories].blank? From ae0d87c89393a5ccf7d91bb877c81a6e4fc1ce68 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 3 Oct 2022 16:35:54 +0200 Subject: [PATCH 09/15] (bug) ability to filter by negative stock --- app/services/product_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/product_service.rb b/app/services/product_service.rb index 8e5072d3d..0b702d189 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -121,7 +121,7 @@ class ProductService if filters[:stock_from].to_i.positive? products = products.where('(stock ->> ?)::int >= ?', filters[:stock_type], filters[:stock_from]) end - products = products.where('(stock ->> ?)::int <= ?', filters[:stock_type], filters[:stock_to]) if filters[:stock_to].to_i.positive? + products = products.where('(stock ->> ?)::int <= ?', filters[:stock_type], filters[:stock_to]) if filters[:stock_to].to_i != 0 products end From ac861e3bcb2e8ba68fe9a81c212f0defd17f3ef8 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 3 Oct 2022 16:37:46 +0200 Subject: [PATCH 10/15] (bug) negative stock is marked as available --- app/frontend/src/javascript/lib/product.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/src/javascript/lib/product.ts b/app/frontend/src/javascript/lib/product.ts index db612110b..4f285f138 100644 --- a/app/frontend/src/javascript/lib/product.ts +++ b/app/frontend/src/javascript/lib/product.ts @@ -41,7 +41,7 @@ export default class ProductLib { }; static stockStatusTrKey = (product: Product): string => { - if (product.stock.external === 0) { + if (product.stock.external <= 0) { return 'app.public.stock_status.out_of_stock'; } if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) { From 7798df1f87ac416c77669c631b7360db01a6ba84 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 3 Oct 2022 16:39:58 +0200 Subject: [PATCH 11/15] (bug) stock form fields not marked as required --- .../src/javascript/components/store/product-stock-modal.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/frontend/src/javascript/components/store/product-stock-modal.tsx b/app/frontend/src/javascript/components/store/product-stock-modal.tsx index 67b07e782..4b8809ab0 100644 --- a/app/frontend/src/javascript/components/store/product-stock-modal.tsx +++ b/app/frontend/src/javascript/components/store/product-stock-modal.tsx @@ -97,6 +97,7 @@ export const ProductStockModal: React.FC = ({ onError, o = ({ onError, o {t('app.admin.store.product_stock_modal.update_stock')} From 31044a56ac38b94e5ac70f2ffd4bf992d4f676f4 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 3 Oct 2022 17:26:43 +0200 Subject: [PATCH 12/15] (quality) simplified regex --- .../components/store/categories/manage-product-category.tsx | 3 ++- .../components/store/categories/product-category-form.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx index be46c5dfc..a8d0758f1 100644 --- a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx +++ b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx @@ -77,7 +77,8 @@ export const ManageProductCategory: React.FC = ({ pr + onSuccess={handleSuccess} + onError={onError} />
); diff --git a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx index 2729d966e..8ed9ddce5 100644 --- a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx @@ -62,7 +62,7 @@ export const ProductCategoryForm: React.FC = ({ action }, [watch]); // Check slug pattern // Only lowercase alphanumeric groups of characters separated by an hyphen - const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/g; + const slugPattern = /^[a-z\d]+(?:-[a-z\d]+)*$/g; // Form submit const onSubmit: SubmitHandler = (category: ProductCategory) => { From 4cfb1a1253af420c1a0ef4b92b4cfa83d118c1a5 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 4 Oct 2022 10:28:37 +0200 Subject: [PATCH 13/15] (bug) create a category result in error --- .../api/product_categories/_product_category.json.jbuilder | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/api/product_categories/_product_category.json.jbuilder b/app/views/api/product_categories/_product_category.json.jbuilder index 6cdef711b..641a4d027 100644 --- a/app/views/api/product_categories/_product_category.json.jbuilder +++ b/app/views/api/product_categories/_product_category.json.jbuilder @@ -1,3 +1,4 @@ # frozen_string_literal: true -json.extract! product_category, :id, :name, :slug, :parent_id, :position, :products_count +json.extract! product_category, :id, :name, :slug, :parent_id, :position +json.products_count product_category.try(:products_count) From f3a2136e7dc1ad39fef2850ad3afdc60b606687f Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 4 Oct 2022 10:32:16 +0200 Subject: [PATCH 14/15] (bug) category slug with special characters --- .../components/store/categories/product-category-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx index 8ed9ddce5..8831bb054 100644 --- a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx @@ -54,7 +54,7 @@ export const ProductCategoryForm: React.FC = ({ action useEffect(() => { const subscription = watch((value, { name }) => { if (name === 'name') { - const _slug = slugify(value.name, { lower: true }); + const _slug = slugify(value.name, { lower: true, strict: true }); setValue('slug', _slug); } }); From a10e75844a88168493738e59875e21ae11d29fbd Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 4 Oct 2022 10:51:32 +0200 Subject: [PATCH 15/15] (feat) visual indicator if no user selected --- .../javascript/components/cart/store-cart.tsx | 5 +++- .../components/user/member-select.tsx | 12 ++++++--- app/frontend/src/stylesheets/application.scss | 1 + .../modules/user/member-select.scss | 26 +++++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 app/frontend/src/stylesheets/modules/user/member-select.scss diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index 240e129ef..290ab007e 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -39,6 +39,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, const { cart, setCart, reloadCart } = useCart(currentUser); const [cartErrors, setCartErrors] = useState(null); + const [noMemberError, setNoMemberError] = useState(false); const [paymentModal, setPaymentModal] = useState(false); const [settings, setSettings] = useState>(null); @@ -124,8 +125,10 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, userLogin(); } else { if (!cart.user) { + setNoMemberError(true); onError(t('app.public.store_cart.select_user')); } else { + setNoMemberError(false); checkCart().then(errors => { if (!hasCartErrors(errors)) { setPaymentModal(true); @@ -328,7 +331,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser,