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/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..290ab007e 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,9 @@ 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'; +import _ from 'lodash'; declare const Application: IApplication; @@ -34,15 +37,22 @@ interface StoreCartProps { const StoreCart: React.FC = ({ 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(null); + const [noMemberError, setNoMemberError] = useState(false); const [paymentModal, setPaymentModal] = useState(false); + const [settings, setSettings] = useState>(null); useEffect(() => { - const quantities = cart?.order_items_attributes.map(i => { - return { id: i.id, quantity: i.quantity }; - }); - setItemsQuantity(quantities); + SettingAPI.query(['store_withdrawal_instructions', 'fablab_name']) + .then(res => setSettings(res)) + .catch(onError); + }, []); + + useEffect(() => { + if (cart) { + checkCart(); + } }, [cart]); /** @@ -52,9 +62,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); - }).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,8 +83,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); @@ -77,6 +95,28 @@ const StoreCart: React.FC = ({ 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 => { + const errors = await CartAPI.validate(cart); + setCartErrors(errors); + return errors; + }; + /** * Checkout cart */ @@ -85,13 +125,40 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, userLogin(); } else { if (!cart.user) { + setNoMemberError(true); onError(t('app.public.store_cart.select_user')); } else { - setPaymentModal(true); + setNoMemberError(false); + 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 */ @@ -153,14 +220,49 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, } }; + /** + * Show item error + */ + const itemError = (item, error) => { + if (error.error === 'is_active' || error.error === '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')}

; + } + if (error.error === 'stock' && error.value > 0) { + 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 })}

; + } + 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')} +
; + } + }; + + /** + * 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 (
{cart && cartIsEmpty() &&

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

} {cart && cart.order_items_attributes.map(item => ( -
+
0 ? 'error' : ''}`}>
- +
{t('app.public.store_cart.reference_short')} {item.orderable_ref || ''} @@ -168,6 +270,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); + })}
@@ -179,10 +284,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')} @@ -197,7 +302,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser,
}

{category.name}

- [count] + {category.products_count}
{!isDragging && 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..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); } }); @@ -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) => { 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')} diff --git a/app/frontend/src/javascript/components/store/store-settings.tsx b/app/frontend/src/javascript/components/store/store-settings.tsx index c09617fec..3f9784f08 100644 --- a/app/frontend/src/javascript/components/store/store-settings.tsx +++ b/app/frontend/src/javascript/components/store/store-settings.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; import { IApplication } from '../../models/application'; @@ -8,6 +8,9 @@ import { useForm, SubmitHandler } from 'react-hook-form'; import { FabAlert } from '../base/fab-alert'; import { FormRichText } from '../form/form-rich-text'; import { FabButton } from '../base/fab-button'; +import SettingAPI from '../../api/setting'; +import SettingLib from '../../lib/setting'; +import { SettingName, SettingValue, storeSettings } from '../../models/setting'; declare const Application: IApplication; @@ -15,25 +18,32 @@ interface StoreSettingsProps { onError: (message: string) => void, onSuccess: (message: string) => void } -interface Settings { - withdrawal: string -} /** - * Shows store settings + * Store settings display and edition */ -// TODO: delete next eslint disable -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export const StoreSettings: React.FC = (onError, onSuccess) => { +export const StoreSettings: React.FC = ({ onError, onSuccess }) => { const { t } = useTranslation('admin'); + const { control, handleSubmit, reset } = useForm>(); - const { control, handleSubmit } = useForm(); + useEffect(() => { + SettingAPI.query(storeSettings) + .then(settings => { + const data = SettingLib.mapToBulkObject(settings); + reset(data); + }) + .catch(onError); + }, []); /** - * Callback triggered when the form is submitted: process with the product creation or update. + * Callback triggered when the form is submitted: save the settings */ - const onSubmit: SubmitHandler = (data) => { - console.log(data); + const onSubmit: SubmitHandler> = (data) => { + SettingAPI.bulkUpdate(SettingLib.bulkObjectToMap(data)).then(() => { + onSuccess(t('app.admin.store_settings.update_success')); + }, reason => { + onError(reason); + }); }; return ( @@ -51,7 +61,7 @@ export const StoreSettings: React.FC = (onError, onSuccess) bulletList link limit={400} - id="withdrawal" /> + id="store_withdrawal_instructions" /> {t('app.admin.store_settings.save')}
diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index 1aab1da17..db572209f 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -31,6 +31,19 @@ import SettingAPI from '../../api/setting'; 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, @@ -53,7 +66,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([]); // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -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); }; /** @@ -241,7 +254,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 &&
@@ -249,7 +271,7 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter

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

)}
@@ -281,7 +303,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/components/user/member-select.tsx b/app/frontend/src/javascript/components/user/member-select.tsx index 04e7c116f..c28be025b 100644 --- a/app/frontend/src/javascript/components/user/member-select.tsx +++ b/app/frontend/src/javascript/components/user/member-select.tsx @@ -8,7 +8,8 @@ interface MemberSelectProps { defaultUser?: User, value?: User, onSelected?: (user: { id: number, name: string }) => void, - noHeader?: boolean + noHeader?: boolean, + hasError?: boolean } /** @@ -20,7 +21,7 @@ type selectOption = { value: number, label: string }; /** * This component renders the member select for manager. */ -export const MemberSelect: React.FC = ({ defaultUser, value, onSelected, noHeader }) => { +export const MemberSelect: React.FC = ({ defaultUser, value, onSelected, noHeader, hasError }) => { const { t } = useTranslation('public'); const [option, setOption] = useState(); @@ -67,13 +68,14 @@ export const MemberSelect: React.FC = ({ defaultUser, value, }; return ( -
+
{!noHeader &&

{t('app.public.member_select.select_a_member')}

} = ({ defaultUser, value,
); }; + +MemberSelect.defaultProps = { + hasError: false +}; diff --git a/app/frontend/src/javascript/lib/product.ts b/app/frontend/src/javascript/lib/product.ts index 74a27b957..569dac20e 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 <= (product.quantity_min || 0)) { return 'app.public.stock_status.out_of_stock'; } if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) { @@ -143,18 +143,19 @@ 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': + case 'category': { const parents = categories?.filter(c => (value as Array)?.includes(c.slug)); // we may also add to the selection children categories res.categories = [...parents, ...categories?.filter(c => parents.map(c => c.id).includes(c.parent_id))]; break; + } case 'categories': res.categories = [...categories?.filter(c => (value as Array)?.includes(c.slug))]; break; diff --git a/app/frontend/src/javascript/lib/setting.ts b/app/frontend/src/javascript/lib/setting.ts new file mode 100644 index 000000000..f988c6f41 --- /dev/null +++ b/app/frontend/src/javascript/lib/setting.ts @@ -0,0 +1,25 @@ +import { SettingName, SettingValue } from '../models/setting'; + +export default class SettingLib { + /** + * Convert the provided data to a map, as expected by BulkUpdate + */ + static bulkObjectToMap = (data: Record): Map => { + const res = new Map(); + for (const key in data) { + res.set(key as SettingName, data[key]); + } + return res; + }; + + /** + * Convert the provided map to a simple javascript object + */ + static mapToBulkObject = (data: Map): Record => { + const res = {} as Record; + data.forEach((value, key) => { + res[key] = value; + }); + return res; + }; +} 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/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/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/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index c661b2948..d53e6d8dd 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -213,6 +213,10 @@ export const displaySettings = [ 'email_from' ] as const; +export const storeSettings = [ + 'store_withdrawal_instructions' +] as const; + export const allSettings = [ ...homePageSettings, ...privacyPolicySettings, @@ -237,7 +241,8 @@ export const allSettings = [ ...adminSettings, ...pricingSettings, ...poymentSettings, - ...displaySettings + ...displaySettings, + ...storeSettings ] as const; export type SettingName = typeof allSettings[number]; @@ -264,3 +269,5 @@ export interface SettingBulkResult { error?: string, localized?: string, } + +export type SettingBulkArray = Array<{ name: SettingName, value: SettingValue }>; 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/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 1dce80690..ee795eccc 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -120,6 +120,7 @@ @import "modules/user/avatar"; @import "modules/user/avatar-input"; @import "modules/user/gender-input"; +@import "modules/user/member-select"; @import "modules/user/user-profile-form"; @import "modules/user/user-validation"; diff --git a/app/frontend/src/stylesheets/modules/cart/store-cart.scss b/app/frontend/src/stylesheets/modules/cart/store-cart.scss index 5d28b627e..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 { + .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; } } } diff --git a/app/frontend/src/stylesheets/modules/user/member-select.scss b/app/frontend/src/stylesheets/modules/user/member-select.scss new file mode 100644 index 000000000..fd25409e7 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/user/member-select.scss @@ -0,0 +1,26 @@ +.member-select { + &.error { + .select-input > div { + border-color: var(--alert); + transform: perspective(1px) translateZ(0); + box-shadow: 0 0 1px rgba(0, 0, 0, 0); + animation-name: buzz-out; + animation-duration: 0.75s; + animation-timing-function: linear; + animation-iteration-count: 1; + } + } +} + +@keyframes buzz-out { + 10% { transform: translateX(3px) rotate(2deg); } + 20% { transform: translateX(-3px) rotate(-2deg); } + 30% { transform: translateX(3px) rotate(2deg); } + 40% { transform: translateX(-3px) rotate(-2deg); } + 50% { transform: translateX(2px) rotate(1deg); } + 60% { transform: translateX(-2px) rotate(-1deg); } + 70% { transform: translateX(2px) rotate(1deg); } + 80% { transform: translateX(-2px) rotate(-1deg); } + 90% { transform: translateX(1px) rotate(0); } + 100% { transform: translateX(-1px) rotate(0); } +} 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/models/setting.rb b/app/models/setting.rb index 7c2f0e4ed..b7c0d5730 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -153,7 +153,8 @@ class Setting < ApplicationRecord user_validation_required user_validation_required_list show_username_in_admin_list - store_module] } + store_module + store_withdrawal_instructions] } # WARNING: when adding a new key, you may also want to add it in: # - config/locales/en.yml#settings # - app/frontend/src/javascript/models/setting.ts#SettingName 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/policies/setting_policy.rb b/app/policies/setting_policy.rb index 77d2de26b..d469d9a64 100644 --- a/app/policies/setting_policy.rb +++ b/app/policies/setting_policy.rb @@ -42,7 +42,7 @@ class SettingPolicy < ApplicationPolicy payment_gateway payzen_endpoint payzen_public_key public_agenda_module renew_pack_threshold statistics_module pack_only_for_subscription overlapping_categories public_registrations facebook twitter viadeo linkedin instagram youtube vimeo dailymotion github echosciences pinterest lastfm flickr machines_module user_change_group - user_validation_required user_validation_required_list store_module] + user_validation_required user_validation_required_list store_module store_withdrawal_instructions] end ## diff --git a/app/services/cart/check_cart_service.rb b/app/services/cart/check_cart_service.rb new file mode 100644 index 000000000..e576c5b79 --- /dev/null +++ b/app/services/cart/check_cart_service.rb @@ -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 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 06409c2dc..d4bb6c886 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 @@ -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/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/services/product_service.rb b/app/services/product_service.rb index dad3c741e..0b702d189 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? @@ -114,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 diff --git a/app/views/api/product_categories/_product_category.json.jbuilder b/app/views/api/product_categories/_product_category.json.jbuilder index e691d8276..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 +json.products_count product_category.try(:products_count) diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 9048ca72a..fa8b42b04 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -2095,3 +2095,4 @@ en: withdrawal_instructions: 'Product withdrawal instructions' withdrawal_info: "This text is displayed on the checkout page to inform the client about the products withdrawal method" save: "Save" + update_success: "The settings were successfully updated" diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index 6bafc4fcb..4b9ac0465 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -442,6 +442,14 @@ en: checkout_error: "An unexpected error occurred. Please contact the administrator." checkout_success: "Purchase confirmed. Thanks!" select_user: "Please select a user before continuing." + 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: diff --git a/config/locales/en.yml b/config/locales/en.yml index 37a79a5fd..09f8addbe 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -609,3 +609,4 @@ en: user_change_group: "Allow users to change their group" show_username_in_admin_list: "Show the username in the admin's members list" store_module: "Store module" + store_withdrawal_instructions: "Withdrawal instructions" 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/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..9e6a688de 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" @@ -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 @@ -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| @@ -823,8 +824,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 +834,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 +1115,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