mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-03-21 12:29:03 +01:00
(merge) branch 'origin/product-store'
This commit is contained in:
commit
50e4b2ba0c
@ -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
|
||||
|
@ -1,6 +1,6 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Order } from '../models/order';
|
||||
import { Order, OrderErrors } from '../models/order';
|
||||
|
||||
export default class CartAPI {
|
||||
static async create (token?: string): Promise<Order> {
|
||||
@ -27,4 +27,14 @@ export default class CartAPI {
|
||||
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, is_offered: isOffered });
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async refreshItem (order: Order, orderableId: number): Promise<Order> {
|
||||
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/refresh_item', { order_token: order.token, orderable_id: orderableId });
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async validate (order: Order): Promise<OrderErrors> {
|
||||
const res: AxiosResponse<OrderErrors> = await apiClient.post('/api/cart/validate', { order_token: order.token });
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
@ -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<Setting> {
|
||||
@ -60,7 +67,7 @@ export default class SettingAPI {
|
||||
return map;
|
||||
}
|
||||
|
||||
private static toObjectArray (data: Map<SettingName, SettingValue>): Array<Record<string, SettingValue>> {
|
||||
private static toObjectArray (data: Map<SettingName, SettingValue>): SettingBulkArray {
|
||||
const array = [];
|
||||
data.forEach((value, key) => {
|
||||
array.push({
|
||||
|
@ -83,6 +83,12 @@ export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, Fab
|
||||
editor?.setEditable(!disabled);
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
|
@ -79,12 +79,6 @@ export const MenuBar: React.FC<MenuBarProps> = ({ 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<MenuBarProps> = ({ editor, heading, bulletList, b
|
||||
}
|
||||
</div>
|
||||
|
||||
<form ref={ref} className={`fab-text-editor-subMenu ${submenu ? 'is-active' : ''}`} onSubmit={handleSubmit}>
|
||||
<div ref={ref} className={`fab-text-editor-subMenu ${submenu ? 'is-active' : ''}`}>
|
||||
{ submenu === 'link' &&
|
||||
(<>
|
||||
<h6>{t('app.shared.text_editor.menu_bar.add_link')}</h6>
|
||||
@ -290,7 +284,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, heading, bulletList, b
|
||||
</div>
|
||||
</>)
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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<StoreCartProps> = ({ onSuccess, onError, currentUser, userLogin }) => {
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
const { cart, setCart } = useCart(currentUser);
|
||||
const [itemsQuantity, setItemsQuantity] = useState<{ id: number; quantity: number; }[]>();
|
||||
const { cart, setCart, reloadCart } = useCart(currentUser);
|
||||
const [cartErrors, setCartErrors] = useState<OrderErrors>(null);
|
||||
const [noMemberError, setNoMemberError] = useState<boolean>(false);
|
||||
const [paymentModal, setPaymentModal] = useState<boolean>(false);
|
||||
const [settings, setSettings] = useState<Map<SettingName, string>>(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<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
||||
return (e: React.BaseSyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
CartAPI.removeItem(cart, item.orderable_id).then(data => {
|
||||
setCart(data);
|
||||
}).catch(onError);
|
||||
const errors = getItemErrors(item);
|
||||
if (errors.length === 1 && errors[0].error === 'not_found') {
|
||||
reloadCart().catch(onError);
|
||||
} else {
|
||||
CartAPI.removeItem(cart, item.orderable_id).then(data => {
|
||||
setCart(data);
|
||||
}).catch(onError);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@ -68,8 +83,11 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
||||
})
|
||||
.catch(() => onError(t('app.public.store_cart.stock_limit')));
|
||||
};
|
||||
/** Increment/decrement product quantity */
|
||||
const handleInputNumber = (item, direction: 'up' | 'down') => {
|
||||
|
||||
/**
|
||||
* Increment/decrement product quantity
|
||||
*/
|
||||
const increaseOrDecreaseProductQuantity = (item, direction: 'up' | 'down') => {
|
||||
CartAPI.setQuantity(cart, item.orderable_id, direction === 'up' ? item.quantity + 1 : item.quantity - 1)
|
||||
.then(data => {
|
||||
setCart(data);
|
||||
@ -77,6 +95,28 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
||||
.catch(() => onError(t('app.public.store_cart.stock_limit')));
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh product amount
|
||||
*/
|
||||
const refreshItem = (item) => {
|
||||
return (e: React.BaseSyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
CartAPI.refreshItem(cart, item.orderable_id).then(data => {
|
||||
setCart(data);
|
||||
}).catch(onError);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check the current cart's items (available, price, stock, quantity_min)
|
||||
*/
|
||||
const checkCart = async (): Promise<OrderErrors> => {
|
||||
const errors = await CartAPI.validate(cart);
|
||||
setCartErrors(errors);
|
||||
return errors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checkout cart
|
||||
*/
|
||||
@ -85,13 +125,40 @@ const StoreCart: React.FC<StoreCartProps> = ({ 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<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Show item error
|
||||
*/
|
||||
const itemError = (item, error) => {
|
||||
if (error.error === 'is_active' || error.error === 'not_found') {
|
||||
return <div className='error'><p>{t('app.public.store_cart.errors.product_not_found')}</p></div>;
|
||||
}
|
||||
if (error.error === 'stock' && error.value === 0) {
|
||||
return <div className='error'><p>{t('app.public.store_cart.errors.out_of_stock')}</p></div>;
|
||||
}
|
||||
if (error.error === 'stock' && error.value > 0) {
|
||||
return <div className='error'><p>{t('app.public.store_cart.errors.stock_limit_QUANTITY', { QUANTITY: error.value })}</p></div>;
|
||||
}
|
||||
if (error.error === 'quantity_min') {
|
||||
return <div className='error'><p>{t('app.public.store_cart.errors.quantity_min_QUANTITY', { QUANTITY: error.value })}</p></div>;
|
||||
}
|
||||
if (error.error === 'amount') {
|
||||
return <div className='error'>
|
||||
<p>{t('app.public.store_cart.errors.price_changed_PRICE', { PRICE: `${FormatLib.price(error.value)} / ${t('app.public.store_cart.unit')}` })}</p>
|
||||
<span className='refresh-btn' onClick={refreshItem(item)}>{t('app.public.store_cart.update_item')}</span>
|
||||
</div>;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Text instructions for the customer
|
||||
*/
|
||||
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 (
|
||||
<div className='store-cart'>
|
||||
<div className="store-cart-list">
|
||||
{cart && cartIsEmpty() && <p>{t('app.public.store_cart.cart_is_empty')}</p>}
|
||||
{cart && cart.order_items_attributes.map(item => (
|
||||
<article key={item.id} className='store-cart-list-item'>
|
||||
<article key={item.id} className={`store-cart-list-item ${getItemErrors(item).length > 0 ? 'error' : ''}`}>
|
||||
<div className='picture'>
|
||||
<img alt=''src={item.orderable_main_image_url || noImage} />
|
||||
<img alt='' src={item.orderable_main_image_url || noImage} />
|
||||
</div>
|
||||
<div className="ref">
|
||||
<span>{t('app.public.store_cart.reference_short')} {item.orderable_ref || ''}</span>
|
||||
@ -168,6 +270,9 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
||||
{item.quantity_min > 1 &&
|
||||
<span className='min'>{t('app.public.store_cart.minimum_purchase')}{item.quantity_min}</span>
|
||||
}
|
||||
{getItemErrors(item).map(e => {
|
||||
return itemError(item, e);
|
||||
})}
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className='price'>
|
||||
@ -179,10 +284,10 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
||||
onChange={e => changeProductQuantity(e, item)}
|
||||
min={item.quantity_min}
|
||||
max={item.orderable_external_stock}
|
||||
value={itemsQuantity?.find(i => i.id === item.id).quantity}
|
||||
value={item.quantity}
|
||||
/>
|
||||
<button onClick={() => handleInputNumber(item, 'up')}><CaretUp size={12} weight="fill" /></button>
|
||||
<button onClick={() => handleInputNumber(item, 'down')}><CaretDown size={12} weight="fill" /></button>
|
||||
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'up')}><CaretUp size={12} weight="fill" /></button>
|
||||
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'down')}><CaretDown size={12} weight="fill" /></button>
|
||||
</div>
|
||||
<div className='total'>
|
||||
<span>{t('app.public.store_cart.total')}</span>
|
||||
@ -197,7 +302,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
||||
<label>
|
||||
<span>{t('app.public.store_cart.offer_product')}</span>
|
||||
<Switch
|
||||
checked={item.is_offered}
|
||||
checked={item.is_offered || false}
|
||||
onChange={toggleProductOffer(item)}
|
||||
width={40}
|
||||
height={19}
|
||||
@ -214,7 +319,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
||||
<div className="group">
|
||||
<div className='store-cart-info'>
|
||||
<h3>{t('app.public.store_cart.pickup')}</h3>
|
||||
<p>[TODO: texte venant des paramètres de la boutique…]</p>
|
||||
<p dangerouslySetInnerHTML={{ __html: withdrawalInstructions() }} />
|
||||
</div>
|
||||
|
||||
{cart && !cartIsEmpty() &&
|
||||
@ -226,7 +331,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
||||
|
||||
<aside>
|
||||
{cart && !cartIsEmpty() && isPrivileged() &&
|
||||
<div> <MemberSelect onSelected={handleChangeMember} defaultUser={cart.user as User} /></div>
|
||||
<div> <MemberSelect onSelected={handleChangeMember} defaultUser={cart.user as User} hasError={noMemberError} /></div>
|
||||
}
|
||||
|
||||
{cart && !cartIsEmpty() && <>
|
||||
|
@ -77,7 +77,8 @@ export const ManageProductCategory: React.FC<ManageProductCategoryProps> = ({ pr
|
||||
<ProductCategoryForm action={action}
|
||||
productCategories={productCategories}
|
||||
productCategory={productCategory}
|
||||
onSuccess={handleSuccess} onError={onError} />
|
||||
onSuccess={handleSuccess}
|
||||
onError={onError} />
|
||||
</FabModal>
|
||||
</div>
|
||||
);
|
||||
|
@ -51,7 +51,7 @@ export const ProductCategoriesItem: React.FC<ProductCategoriesItemProps> = ({ pr
|
||||
</button>
|
||||
</div>}
|
||||
<p className='itemInfo-name'>{category.name}</p>
|
||||
<span className='itemInfo-count'>[count]</span>
|
||||
<span className='itemInfo-count'>{category.products_count}</span>
|
||||
</div>
|
||||
<div className='actions'>
|
||||
{!isDragging &&
|
||||
|
@ -54,7 +54,7 @@ export const ProductCategoryForm: React.FC<ProductCategoryFormProps> = ({ 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<ProductCategoryFormProps> = ({ 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<ProductCategory> = (category: ProductCategory) => {
|
||||
|
@ -97,6 +97,7 @@ export const ProductStockModal: React.FC<ProductStockModalProps> = ({ onError, o
|
||||
<FormSelect options={buildStocksOptions()}
|
||||
control={control}
|
||||
id="stock_type"
|
||||
rules={{ required: true }}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.stocks')} />
|
||||
<FormInput id="quantity"
|
||||
@ -109,6 +110,7 @@ export const ProductStockModal: React.FC<ProductStockModalProps> = ({ onError, o
|
||||
<FormSelect options={buildEventsOptions()}
|
||||
control={control}
|
||||
id="reason"
|
||||
rules={{ required: true }}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.reason_type')} />
|
||||
<FabButton type='submit'>{t('app.admin.store.product_stock_modal.update_stock')} </FabButton>
|
||||
|
@ -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<StoreSettingsProps> = (onError, onSuccess) => {
|
||||
export const StoreSettings: React.FC<StoreSettingsProps> = ({ onError, onSuccess }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
const { control, handleSubmit, reset } = useForm<Record<SettingName, SettingValue>>();
|
||||
|
||||
const { control, handleSubmit } = useForm<Settings>();
|
||||
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<Settings> = (data) => {
|
||||
console.log(data);
|
||||
const onSubmit: SubmitHandler<Record<SettingName, SettingValue>> = (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<StoreSettingsProps> = (onError, onSuccess)
|
||||
bulletList
|
||||
link
|
||||
limit={400}
|
||||
id="withdrawal" />
|
||||
id="store_withdrawal_instructions" />
|
||||
<FabButton type='submit' className='save-btn'>{t('app.admin.store_settings.save')}</FabButton>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -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<StoreProps> = ({ onError, onSuccess, currentUser, uiRouter
|
||||
|
||||
const [products, setProducts] = useState<Array<Product>>([]);
|
||||
// this includes the resources fetch from the API (machines, categories) and from the URL (filters)
|
||||
const [resources, setResources] = useImmer<ProductResourcesFetching>(initialResources);
|
||||
const [resources, setResources] = useImmer<ProductResourcesFetching>(storeInitialResources);
|
||||
const [machinesModule, setMachinesModule] = useState<boolean>(false);
|
||||
const [categoriesTree, setCategoriesTree] = useState<CategoryTree[]>([]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@ -83,7 +96,7 @@ const Store: React.FC<StoreProps> = ({ 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<StoreProps> = ({ onError, onSuccess, currentUser, uiRouter
|
||||
filters: {
|
||||
...draft.filters,
|
||||
data: {
|
||||
...initialFilters,
|
||||
...storeInitialFilters,
|
||||
categories: draft.filters.data.categories
|
||||
}
|
||||
}
|
||||
@ -172,7 +185,7 @@ const Store: React.FC<StoreProps> = ({ 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<StoreProps> = ({ onError, onSuccess, currentUser, uiRouter
|
||||
{categoriesTree.map(c =>
|
||||
<div key={c.parent.id} className={`parent ${selectedCategory?.id === c.parent.id || selectedCategory?.parent_id === c.parent.id ? 'is-active' : ''}`}>
|
||||
<p onClick={() => filterCategory(c.parent)}>
|
||||
{c.parent.name}<span>(count)</span>
|
||||
{c.parent.name}
|
||||
<span>
|
||||
{/* 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)
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
{c.children.length > 0 &&
|
||||
<div className='children'>
|
||||
@ -249,7 +271,7 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser, uiRouter
|
||||
<p key={ch.id}
|
||||
className={selectedCategory?.id === ch.id ? 'is-active' : ''}
|
||||
onClick={() => filterCategory(ch)}>
|
||||
{ch.name}<span>(count)</span>
|
||||
{ch.name}<span>{ch.products_count}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -281,7 +303,7 @@ const Store: React.FC<StoreProps> = ({ 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}
|
||||
/>
|
||||
|
@ -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<MemberSelectProps> = ({ defaultUser, value, onSelected, noHeader }) => {
|
||||
export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, value, onSelected, noHeader, hasError }) => {
|
||||
const { t } = useTranslation('public');
|
||||
const [option, setOption] = useState<selectOption>();
|
||||
|
||||
@ -67,13 +68,14 @@ export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, value,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="member-select">
|
||||
<div className={`member-select ${hasError ? 'error' : ''}`}>
|
||||
{!noHeader &&
|
||||
<div className="member-select-header">
|
||||
<h3 className="member-select-title">{t('app.public.member_select.select_a_member')}</h3>
|
||||
</div>
|
||||
}
|
||||
<AsyncSelect placeholder={t('app.public.member_select.start_typing')}
|
||||
className="select-input"
|
||||
cacheOptions
|
||||
loadOptions={loadMembers}
|
||||
defaultOptions
|
||||
@ -83,3 +85,7 @@ export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, value,
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MemberSelect.defaultProps = {
|
||||
hasError: false
|
||||
};
|
||||
|
@ -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<Machine>, categories: Array<ProductCategory>): ProductIndexFilter => {
|
||||
const res: ProductIndexFilter = { ...initialFilters };
|
||||
static readFiltersFromUrl = (params: StateParams, machines: Array<Machine>, categories: Array<ProductCategory>, 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<string>)?.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<string>)?.includes(c.slug))];
|
||||
break;
|
||||
|
25
app/frontend/src/javascript/lib/setting.ts
Normal file
25
app/frontend/src/javascript/lib/setting.ts
Normal file
@ -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<SettingName, SettingValue>): Map<SettingName, SettingValue> => {
|
||||
const res = new Map<SettingName, SettingValue>();
|
||||
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<SettingName, SettingValue>): Record<SettingName, SettingValue> => {
|
||||
const res = {} as Record<SettingName, SettingValue>;
|
||||
data.forEach((value, key) => {
|
||||
res[key] = value;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
}
|
@ -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
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
@ -4,4 +4,5 @@ export interface ProductCategory {
|
||||
slug: string,
|
||||
parent_id?: number,
|
||||
position: number,
|
||||
products_count: number
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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 }>;
|
||||
|
@ -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' }
|
||||
}
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
26
app/frontend/src/stylesheets/modules/user/member-select.scss
Normal file
26
app/frontend/src/stylesheets/modules/user/member-select.scss
Normal file
@ -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); }
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
##
|
||||
|
21
app/services/cart/check_cart_service.rb
Normal file
21
app/services/cart/check_cart_service.rb
Normal file
@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Provides methods for check cart's items (available, price, stock, quantity_min)
|
||||
class Cart::CheckCartService
|
||||
def call(order)
|
||||
res = { order_id: order.id, details: [] }
|
||||
order.order_items.each do |item|
|
||||
errors = []
|
||||
errors.push({ error: 'is_active', value: false }) unless item.orderable.is_active
|
||||
if item.quantity > item.orderable.stock['external'] || item.orderable.stock['external'] < item.orderable.quantity_min
|
||||
value = item.orderable.stock['external'] < item.orderable.quantity_min ? 0 : item.orderable.stock['external']
|
||||
errors.push({ error: 'stock', value: value })
|
||||
end
|
||||
orderable_amount = item.orderable.amount || 0
|
||||
errors.push({ error: 'amount', value: orderable_amount / 100.0 }) if item.amount != orderable_amount
|
||||
errors.push({ error: 'quantity_min', value: item.orderable.quantity_min }) if item.quantity < item.orderable.quantity_min
|
||||
res[:details].push({ item_id: item.id, errors: errors })
|
||||
end
|
||||
res
|
||||
end
|
||||
end
|
21
app/services/cart/refresh_item_service.rb
Normal file
21
app/services/cart/refresh_item_service.rb
Normal file
@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Provides methods for refresh amount of order item
|
||||
class Cart::RefreshItemService
|
||||
def call(order, orderable)
|
||||
raise Cart::InactiveProductError unless orderable.is_active
|
||||
|
||||
item = order.order_items.find_by(orderable: orderable)
|
||||
|
||||
raise ActiveRecord::RecordNotFound if item.nil?
|
||||
|
||||
order.total -= (item.amount * item.quantity.to_i) unless item.is_offered
|
||||
item.amount = orderable.amount || 0
|
||||
order.total += (item.amount * item.quantity.to_i) unless item.is_offered
|
||||
ActiveRecord::Base.transaction do
|
||||
item.save
|
||||
order.save
|
||||
end
|
||||
order.reload
|
||||
end
|
||||
end
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -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:
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
23
db/schema.rb
23
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
|
||||
|
Loading…
x
Reference in New Issue
Block a user