mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-01-30 19:52:20 +01:00
Merge branch 'product_store-inte' into product_store-store
This commit is contained in:
commit
79f01d4f78
BIN
app/frontend/images/no_avatar.png
Executable file → Normal file
BIN
app/frontend/images/no_avatar.png
Executable file → Normal file
Binary file not shown.
Before Width: | Height: | Size: 619 B After Width: | Height: | Size: 792 B |
BIN
app/frontend/images/no_image.png
Normal file
BIN
app/frontend/images/no_image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 686 B |
@ -12,11 +12,14 @@ import { MenuBar } from './menu-bar';
|
||||
import { WarningOctagon } from 'phosphor-react';
|
||||
|
||||
interface FabTextEditorProps {
|
||||
paragraphTools?: boolean,
|
||||
content?: string,
|
||||
limit?: number,
|
||||
heading?: boolean,
|
||||
bulletList?: boolean,
|
||||
blockquote?: boolean,
|
||||
link?: boolean,
|
||||
video?: boolean,
|
||||
image?: boolean,
|
||||
content?: string,
|
||||
limit?: number,
|
||||
onChange?: (content: string) => void,
|
||||
placeholder?: string,
|
||||
error?: string,
|
||||
@ -30,7 +33,7 @@ export interface FabTextEditorRef {
|
||||
/**
|
||||
* This component is a WYSIWYG text editor
|
||||
*/
|
||||
export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ paragraphTools, content, limit = 400, video, image, onChange, placeholder, error, disabled = false }, ref: RefObject<FabTextEditorRef>) => {
|
||||
export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ heading, bulletList, blockquote, content, limit = 400, video, image, link, onChange, placeholder, error, disabled = false }, ref: RefObject<FabTextEditorRef>) => {
|
||||
const { t } = useTranslation('shared');
|
||||
const placeholderText = placeholder || t('app.shared.text_editor.fab_text_editor.text_placeholder');
|
||||
// TODO: Add ctrl+click on link to visit
|
||||
@ -86,7 +89,7 @@ export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, Fab
|
||||
|
||||
return (
|
||||
<div className={`fab-text-editor ${disabled && 'is-disabled'}`}>
|
||||
<MenuBar editor={editor} paragraphTools={paragraphTools} video={video} image={image} disabled={disabled} />
|
||||
<MenuBar editor={editor} heading={heading} bulletList={bulletList} blockquote={blockquote} video={video} image={image} link={link} disabled={disabled} />
|
||||
<EditorContent editor={editor} />
|
||||
<div className="fab-text-editor-character-count">
|
||||
{editor?.storage.characterCount.characters()} / {limit}
|
||||
|
@ -6,7 +6,10 @@ import { TextAa, TextBolder, TextItalic, TextUnderline, LinkSimpleHorizontal, Li
|
||||
|
||||
interface MenuBarProps {
|
||||
editor?: Editor,
|
||||
paragraphTools?: boolean,
|
||||
heading?: boolean,
|
||||
bulletList?: boolean,
|
||||
blockquote?: boolean,
|
||||
link?: boolean,
|
||||
video?: boolean,
|
||||
image?: boolean,
|
||||
disabled?: boolean,
|
||||
@ -15,7 +18,7 @@ interface MenuBarProps {
|
||||
/**
|
||||
* This component is the menu bar for the WYSIWYG text editor
|
||||
*/
|
||||
export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video, image, disabled = false }) => {
|
||||
export const MenuBar: React.FC<MenuBarProps> = ({ editor, heading, bulletList, blockquote, link, video, image, disabled = false }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [submenu, setSubmenu] = useState('');
|
||||
@ -142,8 +145,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
return (
|
||||
<>
|
||||
<div className={`fab-text-editor-menu ${disabled ? 'fab-text-editor-menu--disabled' : ''}`}>
|
||||
{ paragraphTools &&
|
||||
(<>
|
||||
{heading &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
@ -152,6 +154,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<TextAa size={24} />
|
||||
</button>
|
||||
}
|
||||
{bulletList &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
@ -160,6 +164,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<ListBullets size={24} />
|
||||
</button>
|
||||
}
|
||||
{blockquote &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
@ -168,9 +174,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<Quotes size={24} />
|
||||
</button>
|
||||
<span className='menu-divider'></span>
|
||||
</>)
|
||||
}
|
||||
{ (heading || bulletList || blockquote) && <span className='menu-divider'></span> }
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
@ -195,14 +200,16 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<TextUnderline size={24} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => toggleSubmenu('link')}
|
||||
disabled={disabled}
|
||||
className={`ignore-onclickoutside ${editor.isActive('link') ? 'is-active' : ''}`}
|
||||
>
|
||||
<LinkSimpleHorizontal size={24} />
|
||||
</button>
|
||||
{link &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => toggleSubmenu('link')}
|
||||
disabled={disabled}
|
||||
className={`ignore-onclickoutside ${editor.isActive('link') ? 'is-active' : ''}`}
|
||||
>
|
||||
<LinkSimpleHorizontal size={24} />
|
||||
</button>
|
||||
}
|
||||
{ (video || image) && <span className='menu-divider'></span> }
|
||||
{ video &&
|
||||
(<>
|
||||
|
@ -25,16 +25,13 @@ const CartButton: React.FC = () => {
|
||||
window.location.href = '/#!/cart';
|
||||
};
|
||||
|
||||
if (cart) {
|
||||
return (
|
||||
<div className="cart-button" onClick={showCart}>
|
||||
<i className="fas fa-cart-arrow-down" />
|
||||
<span>{cart.order_items_attributes.length}</span>
|
||||
<div>{t('app.public.cart_button.my_cart')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return (
|
||||
<div className="cart-button" onClick={showCart}>
|
||||
<i className="fas fa-cart-arrow-down" />
|
||||
<span>{cart?.order_items_attributes?.length}</span>
|
||||
<p>{t('app.public.cart_button.my_cart')}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CartButtonWrapper: React.FC = () => {
|
||||
|
@ -15,10 +15,13 @@ import { MemberSelect } from '../user/member-select';
|
||||
import { CouponInput } from '../coupon/coupon-input';
|
||||
import { Coupon } from '../../models/coupon';
|
||||
import { computePriceWithCoupon } from '../../lib/coupon';
|
||||
import noImage from '../../../../images/no_image.png';
|
||||
import Switch from 'react-switch';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface StoreCartProps {
|
||||
onSuccess: (message: string) => void,
|
||||
onError: (message: string) => void,
|
||||
userLogin: () => void,
|
||||
currentUser?: User
|
||||
@ -27,10 +30,11 @@ interface StoreCartProps {
|
||||
/**
|
||||
* This component shows user's cart
|
||||
*/
|
||||
const StoreCart: React.FC<StoreCartProps> = ({ onError, currentUser, userLogin }) => {
|
||||
const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser, userLogin }) => {
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
const { cart, setCart } = useCart(currentUser);
|
||||
console.log('cart: ', cart);
|
||||
const [paymentModal, setPaymentModal] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
@ -76,14 +80,15 @@ const StoreCart: React.FC<StoreCartProps> = ({ onError, currentUser, userLogin }
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/closes the payment modal
|
||||
* Handle payment
|
||||
*/
|
||||
const handlePaymentSuccess = (data: Order): void => {
|
||||
if (data.payment_state === 'paid') {
|
||||
setPaymentModal(false);
|
||||
window.location.href = '/#!/store';
|
||||
onSuccess(t('app.public.store_cart.checkout_success'));
|
||||
} else {
|
||||
onError('Erreur inconnue after payment, please conntact admin');
|
||||
onError(t('app.public.store_cart.checkout_error'));
|
||||
}
|
||||
};
|
||||
|
||||
@ -108,6 +113,13 @@ const StoreCart: React.FC<StoreCartProps> = ({ onError, currentUser, userLogin }
|
||||
return cart && cart.order_items_attributes.length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle product offer
|
||||
*/
|
||||
const onSwitch = (product, checked: boolean) => {
|
||||
console.log('Offer ', product.orderable_name, ': ', checked);
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply coupon to current cart
|
||||
*/
|
||||
@ -117,35 +129,105 @@ const StoreCart: React.FC<StoreCartProps> = ({ onError, currentUser, userLogin }
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the offered item total
|
||||
*/
|
||||
const offeredAmount = (): number => {
|
||||
return cart.order_items_attributes
|
||||
.filter(i => i.is_offered)
|
||||
.map(i => i.amount)
|
||||
.reduce((acc, curr) => acc + curr, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="store-cart">
|
||||
{cart && cartIsEmpty() && <p>{t('app.public.store_cart.cart_is_empty')}</p>}
|
||||
{cart && cart.order_items_attributes.map(item => (
|
||||
<div key={item.id}>
|
||||
<div>{item.orderable_name}</div>
|
||||
<div>{FormatLib.price(item.amount)}</div>
|
||||
<div>{item.quantity}</div>
|
||||
<select value={item.quantity} onChange={changeProductQuantity(item)}>
|
||||
{Array.from({ length: 100 }, (_, i) => i + 1).map(v => (
|
||||
<option key={v} value={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<div>{FormatLib.price(item.quantity * item.amount)}</div>
|
||||
<FabButton className="delete-btn" onClick={removeProductFromCart(item)}>
|
||||
<i className="fa fa-trash" />
|
||||
</FabButton>
|
||||
<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'>
|
||||
<div className='picture'>
|
||||
<img alt=''src={noImage} />
|
||||
</div>
|
||||
<div className="ref">
|
||||
<span>{t('app.public.store_cart.reference_short')} </span>
|
||||
<p>{item.orderable_name}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className='price'>
|
||||
<p>{FormatLib.price(item.amount)}</p>
|
||||
<span>/ {t('app.public.store_cart.unit')}</span>
|
||||
</div>
|
||||
<select value={item.quantity} onChange={changeProductQuantity(item)}>
|
||||
{Array.from({ length: 100 }, (_, i) => i + 1).map(v => (
|
||||
<option key={v} value={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className='total'>
|
||||
<span>{t('app.public.store_cart.total')}</span>
|
||||
<p>{FormatLib.price(item.quantity * item.amount)}</p>
|
||||
</div>
|
||||
<FabButton className="main-action-btn" onClick={removeProductFromCart(item)}>
|
||||
<i className="fa fa-trash" />
|
||||
</FabButton>
|
||||
</div>
|
||||
{isPrivileged() &&
|
||||
<div className='offer'>
|
||||
<label>
|
||||
<span>Offer the product</span>
|
||||
<Switch
|
||||
checked={item.is_offered}
|
||||
onChange={(checked) => onSwitch(item, checked)}
|
||||
width={40}
|
||||
height={19}
|
||||
uncheckedIcon={false}
|
||||
checkedIcon={false}
|
||||
handleDiameter={15} />
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
{cart && !cartIsEmpty() && cart.user && <CouponInput user={cart.user as User} amount={cart.total} onChange={applyCoupon} />}
|
||||
{cart && !cartIsEmpty() && <p>Total produits: {FormatLib.price(cart.total)}</p>}
|
||||
{cart && !cartIsEmpty() && cart.coupon && computePriceWithCoupon(cart.total, cart.coupon) !== cart.total && <p>Coupon réduction: {FormatLib.price(-(cart.total - computePriceWithCoupon(cart.total, cart.coupon)))}</p>}
|
||||
{cart && !cartIsEmpty() && <p>Total panier: {FormatLib.price(computePriceWithCoupon(cart.total, cart.coupon))}</p>}
|
||||
{cart && !cartIsEmpty() && isPrivileged() && <MemberSelect onSelected={handleChangeMember} />}
|
||||
{cart && !cartIsEmpty() &&
|
||||
<FabButton className="checkout-btn" onClick={checkout}>
|
||||
{t('app.public.store_cart.checkout')}
|
||||
</FabButton>
|
||||
}
|
||||
|
||||
{cart && !cartIsEmpty() && cart.user &&
|
||||
<div className='store-cart-coupon'>
|
||||
<CouponInput user={cart.user as User} amount={cart.total} onChange={applyCoupon} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
{cart && !cartIsEmpty() && isPrivileged() &&
|
||||
<div> <MemberSelect onSelected={handleChangeMember} /></div>
|
||||
}
|
||||
|
||||
{cart && !cartIsEmpty() && <>
|
||||
<div className="checkout">
|
||||
<h3>{t('app.public.store_cart.checkout_header')}</h3>
|
||||
<span>{t('app.public.store_cart.checkout_products_COUNT', { COUNT: cart?.order_items_attributes.length })}</span>
|
||||
<div className="list">
|
||||
<p>{t('app.public.store_cart.checkout_products_total')} <span>{FormatLib.price(cart.total)}</span></p>
|
||||
{offeredAmount() > 0 &&
|
||||
<p className='gift'>{t('app.public.store_cart.checkout_gift_total')} <span>-{FormatLib.price(offeredAmount())}</span></p>
|
||||
}
|
||||
{cart.coupon && computePriceWithCoupon(cart.total, cart.coupon) !== cart.total &&
|
||||
<p>{t('app.public.store_cart.checkout_coupon')} <span>{FormatLib.price(-(cart.total - computePriceWithCoupon(cart.total, cart.coupon)))}</span></p>
|
||||
}
|
||||
</div>
|
||||
<p className='total'>{t('app.public.store_cart.checkout_total')} <span>{FormatLib.price(computePriceWithCoupon(cart.total, cart.coupon))}</span></p>
|
||||
</div>
|
||||
<FabButton className='checkout-btn' onClick={checkout} disabled={!cart.user || cart.order_items_attributes.length === 0}>
|
||||
{t('app.public.store_cart.checkout')}
|
||||
</FabButton>
|
||||
</>}
|
||||
</aside>
|
||||
|
||||
{cart && !cartIsEmpty() && cart.user && <div>
|
||||
<PaymentModal isOpen={paymentModal}
|
||||
toggleModal={togglePaymentModal}
|
||||
@ -169,4 +251,4 @@ const StoreCartWrapper: React.FC<StoreCartProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('storeCart', react2angular(StoreCartWrapper, ['onError', 'currentUser', 'userLogin']));
|
||||
Application.Components.component('storeCart', react2angular(StoreCartWrapper, ['onSuccess', 'onError', 'currentUser', 'userLogin']));
|
||||
|
@ -7,6 +7,8 @@ import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FormComponent } from '../../models/form-component';
|
||||
import { AbstractFormItemProps } from './abstract-form-item';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FilePdf, Trash } from 'phosphor-react';
|
||||
|
||||
export interface FileType {
|
||||
id?: number,
|
||||
@ -81,49 +83,41 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
|
||||
`${className || ''}`
|
||||
].join(' ');
|
||||
|
||||
/**
|
||||
* Returns placeholder text
|
||||
*/
|
||||
const placeholder = (): string => hasFile() ? t('app.shared.form_file_upload.edit') : t('app.shared.form_file_upload.browse');
|
||||
|
||||
return (
|
||||
<div className={`form-file-upload fileinput ${classNames}`}>
|
||||
<div className="filename-container">
|
||||
{hasFile() && (
|
||||
<div>
|
||||
<i className="fa fa-file fileinput-exists" />
|
||||
<span className="fileinput-filename">
|
||||
{file.attachment_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`form-file-upload ${classNames}`}>
|
||||
{hasFile() && (
|
||||
<span>{file.attachment_name}</span>
|
||||
)}
|
||||
<div className="actions">
|
||||
{file?.id && file?.attachment_url && (
|
||||
<a href={file.attachment_url}
|
||||
target="_blank"
|
||||
className="file-download"
|
||||
className="fab-button"
|
||||
rel="noreferrer">
|
||||
<i className="fa fa-download"/>
|
||||
<FilePdf size={24} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<span className="fileinput-button">
|
||||
{!hasFile() && (
|
||||
<span className="fileinput-new">{t('app.shared.form_file_upload.browse')}</span>
|
||||
)}
|
||||
{hasFile() && (
|
||||
<span className="fileinput-exists">{t('app.shared.form_file_upload.edit')}</span>
|
||||
)}
|
||||
<FormInput type="file"
|
||||
accept={accept}
|
||||
register={register}
|
||||
formState={formState}
|
||||
rules={rules}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
warning={warning}
|
||||
id={`${id}[attachment_files]`}
|
||||
onChange={onFileSelected}/>
|
||||
</span>
|
||||
{hasFile() && (
|
||||
<a className="fileinput-exists fileinput-delete" onClick={onRemoveFile}>
|
||||
<i className="fa fa-trash-o"></i>
|
||||
</a>
|
||||
)}
|
||||
className="image-file-input"
|
||||
accept={accept}
|
||||
register={register}
|
||||
formState={formState}
|
||||
rules={rules}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
warning={warning}
|
||||
id={`${id}[attachment_files]`}
|
||||
onChange={onFileSelected}
|
||||
placeholder={placeholder()}/>
|
||||
{hasFile() &&
|
||||
<FabButton onClick={onRemoveFile} icon={<Trash size={20} weight="fill" />} className="is-main" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -8,7 +8,8 @@ import { FormInput } from '../form/form-input';
|
||||
import { FormComponent } from '../../models/form-component';
|
||||
import { AbstractFormItemProps } from './abstract-form-item';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import noAvatar from '../../../../images/no_avatar.png';
|
||||
import noImage from '../../../../images/no_image.png';
|
||||
import { Trash } from 'phosphor-react';
|
||||
|
||||
export interface ImageType {
|
||||
id?: number,
|
||||
@ -21,7 +22,7 @@ interface FormImageUploadProps<TFieldValues> extends FormComponent<TFieldValues>
|
||||
setValue: UseFormSetValue<TFieldValues>,
|
||||
defaultImage?: ImageType,
|
||||
accept?: string,
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
size?: 'small' | 'medium' | 'large',
|
||||
mainOption?: boolean,
|
||||
onFileChange?: (value: ImageType) => void,
|
||||
onFileRemove?: () => void,
|
||||
@ -97,6 +98,11 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns placeholder text
|
||||
*/
|
||||
const placeholder = (): string => hasImage() ? t('app.shared.form_image_upload.edit') : t('app.shared.form_image_upload.browse');
|
||||
|
||||
/**
|
||||
* Callback triggered when the user set the image is main
|
||||
*/
|
||||
@ -116,32 +122,29 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
|
||||
return (
|
||||
<div className={`form-image-upload form-image-upload--${size} ${classNames}`}>
|
||||
<div className={`image image--${size}`}>
|
||||
<img src={image || noAvatar} />
|
||||
<img src={image || noImage} />
|
||||
</div>
|
||||
<div className="buttons">
|
||||
<FabButton className="select-button">
|
||||
{!hasImage() && <span>{t('app.shared.form_image_upload.browse')}</span>}
|
||||
{hasImage() && <span>{t('app.shared.form_image_upload.edit')}</span>}
|
||||
<FormInput className="image-file-input"
|
||||
type="file"
|
||||
accept={accept}
|
||||
register={register}
|
||||
formState={formState}
|
||||
rules={rules}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
warning={warning}
|
||||
id={`${id}[attachment_files]`}
|
||||
onChange={onFileSelected}/>
|
||||
</FabButton>
|
||||
{hasImage() && <FabButton onClick={onRemoveFile} icon={<i className="fa fa-trash-o"/>} className="delete-image" />}
|
||||
<div className="actions">
|
||||
{mainOption &&
|
||||
<label className='fab-button'>
|
||||
{t('app.shared.form_image_upload.main_image')}
|
||||
<input type="radio" checked={!!file?.is_main} onChange={setMainImage} />
|
||||
</label>
|
||||
}
|
||||
<FormInput className="image-file-input"
|
||||
type="file"
|
||||
accept={accept}
|
||||
register={register}
|
||||
formState={formState}
|
||||
rules={rules}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
warning={warning}
|
||||
id={`${id}[attachment_files]`}
|
||||
onChange={onFileSelected}
|
||||
placeholder={placeholder()}/>
|
||||
{hasImage() && <FabButton onClick={onRemoveFile} icon={<Trash size={20} weight="fill" />} className="is-main" />}
|
||||
</div>
|
||||
{mainOption &&
|
||||
<div>
|
||||
<input type="radio" checked={!!file?.is_main} onChange={setMainImage} />
|
||||
<label>{t('app.shared.form_image_upload.main_image')}</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -67,6 +67,7 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
||||
disabled={typeof disabled === 'function' ? disabled(id) : disabled}
|
||||
placeholder={placeholder}
|
||||
accept={accept} />
|
||||
{(type === 'file' && placeholder) && <span className='fab-button is-black file-placeholder'>{placeholder}</span>}
|
||||
{addOn && <span onClick={addOnAction} className={`addon ${addOnClassName || ''} ${addOnAction ? 'is-btn' : ''}`}>{addOn}</span>}
|
||||
</AbstractFormItem>
|
||||
);
|
||||
|
@ -10,15 +10,18 @@ import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
|
||||
interface FormRichTextProps<TFieldValues, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||
valueDefault?: string,
|
||||
limit?: number,
|
||||
paragraphTools?: boolean,
|
||||
heading?: boolean,
|
||||
bulletList?: boolean,
|
||||
blockquote?: boolean,
|
||||
link?: boolean,
|
||||
video?: boolean,
|
||||
image?: boolean,
|
||||
image?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a rich-text editor to use with react-hook-form.
|
||||
*/
|
||||
export const FormRichText = <TFieldValues extends FieldValues, TContext extends object>({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, paragraphTools, video, image }: FormRichTextProps<TFieldValues, TContext>) => {
|
||||
export const FormRichText = <TFieldValues extends FieldValues, TContext extends object>({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, heading, bulletList, blockquote, video, image, link }: FormRichTextProps<TFieldValues, TContext>) => {
|
||||
const textEditorRef = React.useRef<FabTextEditorRef>();
|
||||
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
|
||||
|
||||
@ -54,9 +57,12 @@ export const FormRichText = <TFieldValues extends FieldValues, TContext extends
|
||||
<FabTextEditor onChange={onChange}
|
||||
content={value}
|
||||
limit={limit}
|
||||
paragraphTools={paragraphTools}
|
||||
heading={heading}
|
||||
bulletList={bulletList}
|
||||
blockquote={blockquote}
|
||||
video={video}
|
||||
image={image}
|
||||
link={link}
|
||||
disabled={isDisabled}
|
||||
ref={textEditorRef} />
|
||||
} />
|
||||
|
@ -41,8 +41,11 @@ export const FormSwitch = <TFieldValues, TContext extends object>({ id, label, t
|
||||
onChangeCb(val);
|
||||
}}
|
||||
checked={value as boolean || false}
|
||||
height={19}
|
||||
width={40}
|
||||
height={19}
|
||||
uncheckedIcon={false}
|
||||
checkedIcon={false}
|
||||
handleDiameter={15}
|
||||
ref={ref}
|
||||
disabled={typeof disabled === 'function' ? disabled(id) : disabled} />
|
||||
} />
|
||||
|
@ -7,6 +7,8 @@ import MachineAPI from '../../api/machine';
|
||||
import { MachineCard } from './machine-card';
|
||||
import { MachinesFilters } from './machines-filters';
|
||||
import { User } from '../../models/user';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
@ -25,6 +27,7 @@ interface MachinesListProps {
|
||||
* This component shows a list of all machines and allows filtering on that list.
|
||||
*/
|
||||
export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user, canProposePacks }) => {
|
||||
const { t } = useTranslation('public');
|
||||
// shown machines
|
||||
const [machines, setMachines] = useState<Array<Machine>>(null);
|
||||
// we keep the full list of machines, for filtering
|
||||
@ -56,10 +59,30 @@ export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess,
|
||||
setMachines(allMachines.filter(m => !!m.disabled === !status));
|
||||
};
|
||||
|
||||
/**
|
||||
* Go to store
|
||||
*/
|
||||
const linkToStore = (): void => {
|
||||
window.location.href = '/#!/store';
|
||||
};
|
||||
|
||||
// TODO: Conditionally display the store ad
|
||||
return (
|
||||
<div className="machines-list">
|
||||
<MachinesFilters onStatusSelected={handleFilterByStatus} />
|
||||
<div className="all-machines">
|
||||
{false &&
|
||||
<div className='store-ad' onClick={() => linkToStore}>
|
||||
<div className='content'>
|
||||
<h3>{t('app.public.machines_list.store_ad.title')}</h3>
|
||||
<p>{t('app.public.machines_list.store_ad.buy')}</p>
|
||||
<p className='sell'>{t('app.public.machines_list.store_ad.sell')}</p>
|
||||
</div>
|
||||
<FabButton icon={<i className="fa fa-cart-plus fa-lg" />} className="cta" onClick={linkToStore}>
|
||||
{t('app.public.machines_list.store_ad.link')}
|
||||
</FabButton>
|
||||
</div>
|
||||
}
|
||||
{machines && machines.map(machine => {
|
||||
return <MachineCard key={machine.id}
|
||||
user={user}
|
||||
|
@ -0,0 +1,30 @@
|
||||
/* eslint-disable fabmanager/scoped-translation */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CaretDown } from 'phosphor-react';
|
||||
|
||||
interface AccordionItemProps {
|
||||
isOpen: boolean,
|
||||
onChange: (id: number, isOpen: boolean) => void,
|
||||
id: number,
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an accordion item
|
||||
*/
|
||||
export const AccordionItem: React.FC<AccordionItemProps> = ({ isOpen, onChange, id, label, children }) => {
|
||||
const [state, setState] = useState(isOpen);
|
||||
useEffect(() => {
|
||||
onChange(id, state);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<div id={id.toString()} className={`accordion-item ${state ? '' : 'collapsed'}`}>
|
||||
<header onClick={() => setState(!state)}>
|
||||
{label}
|
||||
<CaretDown size={16} weight="bold" />
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,3 +1,4 @@
|
||||
import { PencilSimple, Trash } from 'phosphor-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ProductCategory } from '../../../models/product-category';
|
||||
@ -53,12 +54,12 @@ export const ManageProductCategory: React.FC<ManageProductCategoryProps> = ({ pr
|
||||
);
|
||||
case 'update':
|
||||
return (<FabButton type='button'
|
||||
icon={<i className="fas fa-pen" />}
|
||||
icon={<PencilSimple size={20} weight="fill" />}
|
||||
className="edit-btn"
|
||||
onClick={toggleModal} />);
|
||||
case 'delete':
|
||||
return (<FabButton type='button'
|
||||
icon={<i className="fa fa-trash" />}
|
||||
icon={<Trash size={20} weight="fill" />}
|
||||
className="delete-btn"
|
||||
onClick={toggleModal} />);
|
||||
}
|
||||
|
@ -5,12 +5,12 @@ import { ProductCategory } from '../../../models/product-category';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { ManageProductCategory } from './manage-product-category';
|
||||
import { CaretDown, DotsSixVertical } from 'phosphor-react';
|
||||
import { ArrowElbowDownRight, ArrowElbowLeftUp, CaretDown, DotsSixVertical } from 'phosphor-react';
|
||||
|
||||
interface ProductCategoriesItemProps {
|
||||
productCategories: Array<ProductCategory>,
|
||||
category: ProductCategory,
|
||||
offset: boolean,
|
||||
offset: 'up' | 'down' | null,
|
||||
collapsed?: boolean,
|
||||
handleCollapse?: (id: number) => void,
|
||||
status: 'child' | 'single' | 'parent',
|
||||
@ -39,13 +39,16 @@ export const ProductCategoriesItem: React.FC<ProductCategoriesItemProps> = ({ pr
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}
|
||||
className={`product-categories-item ${(status === 'child' && collapsed) ? 'is-collapsed' : ''}`}>
|
||||
{(status === 'child' || offset) &&
|
||||
<div className='offset'></div>
|
||||
{((isDragging && offset) || status === 'child') &&
|
||||
<div className='offset'>
|
||||
{(offset === 'down') && <ArrowElbowDownRight size={32} weight="light" />}
|
||||
{(offset === 'up') && <ArrowElbowLeftUp size={32} weight="light" />}
|
||||
</div>
|
||||
}
|
||||
<div className="wrap">
|
||||
<div className='wrap'>
|
||||
<div className='itemInfo'>
|
||||
{status === 'parent' && <div className='collapse-handle'>
|
||||
<button className={collapsed ? '' : 'rotate'} onClick={() => handleCollapse(category.id)}>
|
||||
<button className={collapsed || isDragging ? '' : 'rotate'} onClick={() => handleCollapse(category.id)}>
|
||||
<CaretDown size={16} weight="bold" />
|
||||
</button>
|
||||
</div>}
|
||||
@ -53,19 +56,21 @@ export const ProductCategoriesItem: React.FC<ProductCategoriesItemProps> = ({ pr
|
||||
<span className='itemInfo-count'>[count]</span>
|
||||
</div>
|
||||
<div className='actions'>
|
||||
<div className='manage'>
|
||||
<ManageProductCategory action='update'
|
||||
productCategories={productCategories}
|
||||
productCategory={category}
|
||||
onSuccess={onSuccess} onError={onError} />
|
||||
<ManageProductCategory action='delete'
|
||||
productCategories={productCategories}
|
||||
productCategory={category}
|
||||
onSuccess={onSuccess} onError={onError} />
|
||||
</div>
|
||||
{!isDragging &&
|
||||
<div className='manage'>
|
||||
<ManageProductCategory action='update'
|
||||
productCategories={productCategories}
|
||||
productCategory={category}
|
||||
onSuccess={onSuccess} onError={onError} />
|
||||
<ManageProductCategory action='delete'
|
||||
productCategories={productCategories}
|
||||
productCategory={category}
|
||||
onSuccess={onSuccess} onError={onError} />
|
||||
</div>
|
||||
}
|
||||
<div className='drag-handle'>
|
||||
<button {...attributes} {...listeners}>
|
||||
<DotsSixVertical size={16} />
|
||||
<DotsSixVertical size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -21,17 +21,15 @@ interface ProductCategoriesTreeProps {
|
||||
export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ productCategories, onDnd, onSuccess, onError }) => {
|
||||
const [categoriesList, setCategoriesList] = useImmer<ProductCategory[]>(productCategories);
|
||||
const [activeData, setActiveData] = useImmer<ActiveData>(initActiveData);
|
||||
// TODO: type extractedChildren: {[parentId]: ProductCategory[]} ???
|
||||
const [extractedChildren, setExtractedChildren] = useImmer({});
|
||||
const [collapsed, setCollapsed] = useImmer<number[]>([]);
|
||||
const [offset, setOffset] = useState<boolean>(false);
|
||||
|
||||
// Initialize state from props
|
||||
useEffect(() => {
|
||||
setCategoriesList(productCategories);
|
||||
}, [productCategories]);
|
||||
|
||||
// Dnd Kit config
|
||||
// @dnd-kit config
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
@ -63,11 +61,31 @@ export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ pr
|
||||
* On drag move
|
||||
*/
|
||||
const handleDragMove = ({ delta, active, over }: DragMoveEvent) => {
|
||||
if ((getStatus(active.id) === 'single' || getStatus(active.id) === 'child') && getStatus(over.id) === 'single') {
|
||||
if (delta.x > 32) {
|
||||
setOffset(true);
|
||||
const activeStatus = getStatus(active.id);
|
||||
if (activeStatus === 'single') {
|
||||
if (Math.ceil(delta.x) > 32 && getStatus(over.id) !== 'child') {
|
||||
setActiveData(draft => {
|
||||
return { ...draft, offset: 'down' };
|
||||
});
|
||||
} else {
|
||||
setOffset(false);
|
||||
setActiveData(draft => {
|
||||
return { ...draft, offset: null };
|
||||
});
|
||||
}
|
||||
}
|
||||
if (activeStatus === 'child') {
|
||||
if (Math.ceil(delta.x) > 32 && getStatus(over.id) !== 'child') {
|
||||
setActiveData(draft => {
|
||||
return { ...draft, offset: 'down' };
|
||||
});
|
||||
} else if (Math.ceil(delta.x) < -32 && getStatus(over.id) === 'child') {
|
||||
setActiveData(draft => {
|
||||
return { ...draft, offset: 'up' };
|
||||
});
|
||||
} else {
|
||||
setActiveData(draft => {
|
||||
return { ...draft, offset: null };
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -83,12 +101,13 @@ export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ pr
|
||||
|
||||
// [A] Single |> [B] Single
|
||||
if (getStatus(active.id) === 'single' && getStatus(over.id) === 'single') {
|
||||
console.log('[A] Single |> [B] Single');
|
||||
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
|
||||
newOrder = newIdsOrder.map(sortedId => {
|
||||
let category = getCategory(sortedId);
|
||||
if (offset && sortedId === active.id && activeData.index < newIndex) {
|
||||
if (activeData.offset === 'down' && sortedId === active.id && activeData.index < newIndex && active.id !== over.id) {
|
||||
category = { ...category, parent_id: Number(over.id) };
|
||||
} else if (activeData.offset === 'down' && sortedId === active.id && (activeData.index > newIndex || active.id === over.id)) {
|
||||
category = { ...category, parent_id: getPreviousAdopter(over.id) };
|
||||
}
|
||||
return category;
|
||||
});
|
||||
@ -96,13 +115,14 @@ export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ pr
|
||||
|
||||
// [A] Child |> [B] Single
|
||||
if ((getStatus(active.id) === 'child') && getStatus(over.id) === 'single') {
|
||||
console.log('[A] Child |> [B] Single');
|
||||
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
|
||||
newOrder = newIdsOrder.map(sortedId => {
|
||||
let category = getCategory(sortedId);
|
||||
if (offset && sortedId === active.id && activeData.index < newIndex) {
|
||||
if (activeData.offset === 'down' && sortedId === active.id && activeData.index < newIndex) {
|
||||
category = { ...category, parent_id: Number(over.id) };
|
||||
} else if (sortedId === active.id && activeData.index < newIndex) {
|
||||
} else if (activeData.offset === 'down' && sortedId === active.id && activeData.index > newIndex) {
|
||||
category = { ...category, parent_id: getPreviousAdopter(over.id) };
|
||||
} else if (sortedId === active.id) {
|
||||
category = { ...category, parent_id: null };
|
||||
}
|
||||
return category;
|
||||
@ -113,8 +133,8 @@ export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ pr
|
||||
if (getStatus(active.id) === 'single' || getStatus(active.id) === 'child') {
|
||||
// [B] Parent
|
||||
if (getStatus(over.id) === 'parent') {
|
||||
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
|
||||
if (activeData.index < newIndex) {
|
||||
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
|
||||
newOrder = newIdsOrder.map(sortedId => {
|
||||
let category = getCategory(sortedId);
|
||||
if (sortedId === active.id) {
|
||||
@ -122,12 +142,13 @@ export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ pr
|
||||
}
|
||||
return category;
|
||||
});
|
||||
} else {
|
||||
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
|
||||
} else if (activeData.index > newIndex) {
|
||||
newOrder = newIdsOrder.map(sortedId => {
|
||||
let category = getCategory(sortedId);
|
||||
if (sortedId === active.id) {
|
||||
if (sortedId === active.id && !activeData.offset) {
|
||||
category = { ...category, parent_id: null };
|
||||
} else if (sortedId === active.id && activeData.offset === 'down') {
|
||||
category = { ...category, parent_id: getPreviousAdopter(over.id) };
|
||||
}
|
||||
return category;
|
||||
});
|
||||
@ -183,9 +204,8 @@ export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ pr
|
||||
// insert children back
|
||||
newOrder = showChildren(active.id, newOrder, newIndex);
|
||||
}
|
||||
|
||||
setActiveData(initActiveData);
|
||||
onDnd(newOrder);
|
||||
setOffset(false);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -216,6 +236,16 @@ export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ pr
|
||||
return extractedChildren[id];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get previous category that can have children
|
||||
*/
|
||||
const getPreviousAdopter = (overId) => {
|
||||
const reversedList = [...categoriesList].reverse();
|
||||
const dropIndex = reversedList.findIndex(c => c.id === overId);
|
||||
const adopter = reversedList.find((c, index) => index > dropIndex && !c.parent_id)?.id;
|
||||
return adopter || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get category's status by its id
|
||||
* child | single | parent
|
||||
@ -285,7 +315,7 @@ export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ pr
|
||||
category={category}
|
||||
onSuccess={onSuccess}
|
||||
onError={onError}
|
||||
offset={category.id === activeData.category?.id && activeData?.offset}
|
||||
offset={category.id === activeData.category?.id ? activeData?.offset : null}
|
||||
collapsed={collapsed.includes(category.id) || collapsed.includes(category.parent_id)}
|
||||
handleCollapse={handleCollapse}
|
||||
status={getStatus(category.id)}
|
||||
@ -302,12 +332,12 @@ interface ActiveData {
|
||||
category: ProductCategory,
|
||||
status: 'child' | 'single' | 'parent',
|
||||
children: ProductCategory[],
|
||||
offset: boolean
|
||||
offset: 'up' | 'down' | null
|
||||
}
|
||||
const initActiveData: ActiveData = {
|
||||
index: null,
|
||||
category: null,
|
||||
status: null,
|
||||
children: [],
|
||||
offset: false
|
||||
offset: null
|
||||
};
|
||||
|
@ -54,7 +54,7 @@ const ProductCategories: React.FC<ProductCategoriesProps> = ({ onSuccess, onErro
|
||||
*/
|
||||
const refreshCategories = () => {
|
||||
ProductCategoryAPI.index().then(data => {
|
||||
// Translate ProductCategory.position to array index
|
||||
// Map product categories by position
|
||||
const sortedCategories = data
|
||||
.filter(c => !c.parent_id)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
|
40
app/frontend/src/javascript/components/store/order-item.tsx
Normal file
40
app/frontend/src/javascript/components/store/order-item.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Order } from '../../models/order';
|
||||
import FormatLib from '../../lib/format';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
|
||||
interface OrderItemProps {
|
||||
order?: Order
|
||||
statusColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* List item for an order
|
||||
*/
|
||||
export const OrderItem: React.FC<OrderItemProps> = ({ order, statusColor }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
/**
|
||||
* Go to order page
|
||||
*/
|
||||
const showOrder = (token: string) => {
|
||||
window.location.href = `/#!/admin/store/o/${token}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='order-item'>
|
||||
<p className="ref">order.token</p>
|
||||
<span className={`order-status ${statusColor}`}>order.state</span>
|
||||
<div className='client'>
|
||||
<span>{t('app.admin.store.order_item.client')}</span>
|
||||
<p>order.user.name</p>
|
||||
</div>
|
||||
<p className="date">order.created_at</p>
|
||||
<div className='price'>
|
||||
<span>{t('app.admin.store.order_item.total')}</span>
|
||||
<p>{FormatLib.price(order?.total)}</p>
|
||||
</div>
|
||||
<FabButton onClick={() => showOrder('orderToken')} icon={<i className="fas fa-eye" />} className="is-black" />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { StoreListHeader } from './store-list-header';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface OrdersDashboardProps {
|
||||
onError: (message: string) => void
|
||||
}
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* This component shows a list of all orders from the store for the current user
|
||||
*/
|
||||
// TODO: delete next eslint disable
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const OrdersDashboard: React.FC<OrdersDashboardProps> = ({ onError }) => {
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.public.store.orders_dashboard.sort.newest') },
|
||||
{ value: 1, label: t('app.public.store.orders_dashboard.sort.oldest') }
|
||||
];
|
||||
};
|
||||
/**
|
||||
* Display option: sorting
|
||||
*/
|
||||
const handleSorting = (option: selectOption) => {
|
||||
console.log('Sort option:', option);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="orders-dashboard">
|
||||
<header>
|
||||
<h2>{t('app.public.store.orders_dashboard.heading')}</h2>
|
||||
</header>
|
||||
|
||||
<div className="store-list">
|
||||
<StoreListHeader
|
||||
productsCount={0}
|
||||
selectOptions={buildOptions()}
|
||||
onSelectOptionsChange={handleSorting}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const OrdersDashboardWrapper: React.FC<OrdersDashboardProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
<OrdersDashboard {...props} />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('ordersDashboard', react2angular(OrdersDashboardWrapper, ['onError']));
|
242
app/frontend/src/javascript/components/store/orders.tsx
Normal file
242
app/frontend/src/javascript/components/store/orders.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useImmer } from 'use-immer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { StoreListHeader } from './store-list-header';
|
||||
import { AccordionItem } from './accordion-item';
|
||||
import { OrderItem } from './order-item';
|
||||
import { MemberSelect } from '../user/member-select';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface OrdersProps {
|
||||
onSuccess: (message: string) => void,
|
||||
onError: (message: string) => void,
|
||||
}
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* Option format, expected by checklist
|
||||
*/
|
||||
type checklistOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* Admin list of orders
|
||||
*/
|
||||
// TODO: delete next eslint disable
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const Orders: React.FC<OrdersProps> = ({ onSuccess, onError }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [filters, setFilters] = useImmer<Filters>(initFilters);
|
||||
const [clearFilters, setClearFilters] = useState<boolean>(false);
|
||||
const [accordion, setAccordion] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters();
|
||||
setClearFilters(false);
|
||||
}, [clearFilters]);
|
||||
|
||||
/**
|
||||
* Create a new order
|
||||
*/
|
||||
const newOrder = () => {
|
||||
console.log('Create new order');
|
||||
};
|
||||
|
||||
const statusOptions: checklistOption[] = [
|
||||
{ value: 0, label: t('app.admin.store.orders.status.error') },
|
||||
{ value: 1, label: t('app.admin.store.orders.status.canceled') },
|
||||
{ value: 2, label: t('app.admin.store.orders.status.pending') },
|
||||
{ value: 3, label: t('app.admin.store.orders.status.under_preparation') },
|
||||
{ value: 4, label: t('app.admin.store.orders.status.paid') },
|
||||
{ value: 5, label: t('app.admin.store.orders.status.ready') },
|
||||
{ value: 6, label: t('app.admin.store.orders.status.collected') },
|
||||
{ value: 7, label: t('app.admin.store.orders.status.refunded') }
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply filters
|
||||
*/
|
||||
const applyFilters = () => {
|
||||
console.log('Apply filters:', filters);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear filters
|
||||
*/
|
||||
const clearAllFilters = () => {
|
||||
setFilters(initFilters);
|
||||
setClearFilters(true);
|
||||
console.log('Clear all filters');
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.admin.store.orders.sort.newest') },
|
||||
{ value: 1, label: t('app.admin.store.orders.sort.oldest') }
|
||||
];
|
||||
};
|
||||
/**
|
||||
* Display option: sorting
|
||||
*/
|
||||
const handleSorting = (option: selectOption) => {
|
||||
console.log('Sort option:', option);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter: by status
|
||||
*/
|
||||
const handleSelectStatus = (s: checklistOption, checked) => {
|
||||
const list = [...filters.status];
|
||||
checked
|
||||
? list.push(s)
|
||||
: list.splice(list.indexOf(s), 1);
|
||||
setFilters(draft => {
|
||||
return { ...draft, status: list };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter: by member
|
||||
*/
|
||||
const handleSelectMember = (userId: number) => {
|
||||
setFilters(draft => {
|
||||
return { ...draft, memberId: userId };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/close accordion items
|
||||
*/
|
||||
const handleAccordion = (id, state) => {
|
||||
setAccordion({ ...accordion, [id]: state });
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a className according to the status
|
||||
*/
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'canceled':
|
||||
return 'canceled';
|
||||
case 'pending' || 'under_preparation':
|
||||
return 'pending';
|
||||
default:
|
||||
return 'normal';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='orders'>
|
||||
<header>
|
||||
<h2>{t('app.admin.store.orders.heading')}</h2>
|
||||
{false &&
|
||||
<div className='grpBtn'>
|
||||
<FabButton className="main-action-btn" onClick={newOrder}>{t('app.admin.store.orders.create_order')}</FabButton>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div className="store-filters">
|
||||
<header>
|
||||
<h3>{t('app.admin.store.orders.filter')}</h3>
|
||||
<div className='grpBtn'>
|
||||
<FabButton onClick={clearAllFilters} className="is-black">{t('app.admin.store.orders.filter_clear')}</FabButton>
|
||||
</div>
|
||||
</header>
|
||||
<div className="accordion">
|
||||
<AccordionItem id={0}
|
||||
isOpen={accordion[0]}
|
||||
onChange={handleAccordion}
|
||||
label={t('app.admin.store.orders.filter_ref')}
|
||||
>
|
||||
<div className='content'>
|
||||
<div className="group">
|
||||
<input type="text" />
|
||||
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
<AccordionItem id={1}
|
||||
isOpen={accordion[1]}
|
||||
onChange={handleAccordion}
|
||||
label={t('app.admin.store.orders.filter_status')}
|
||||
>
|
||||
<div className='content'>
|
||||
<div className="group u-scrollbar">
|
||||
{statusOptions.map(s => (
|
||||
<label key={s.value}>
|
||||
<input type="checkbox" checked={filters.status.some(o => o.label === s.label)} onChange={(event) => handleSelectStatus(s, event.target.checked)} />
|
||||
<p>{s.label}</p>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
<AccordionItem id={2}
|
||||
isOpen={accordion[2]}
|
||||
onChange={handleAccordion}
|
||||
label={t('app.admin.store.orders.filter_client')}
|
||||
>
|
||||
<div className='content'>
|
||||
<div className="group">
|
||||
<MemberSelect noHeader onSelected={handleSelectMember} />
|
||||
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="store-list">
|
||||
<StoreListHeader
|
||||
productsCount={0}
|
||||
selectOptions={buildOptions()}
|
||||
onSelectOptionsChange={handleSorting}
|
||||
/>
|
||||
<div className="orders-list">
|
||||
<OrderItem statusColor={statusColor('error')} />
|
||||
<OrderItem statusColor={statusColor('canceled')} />
|
||||
<OrderItem statusColor={statusColor('pending')} />
|
||||
<OrderItem statusColor={statusColor('refunded')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OrdersWrapper: React.FC<OrdersProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
<Orders {...props} />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('orders', react2angular(OrdersWrapper, ['onSuccess', 'onError']));
|
||||
|
||||
interface Filters {
|
||||
reference: string,
|
||||
status: checklistOption[],
|
||||
memberId: number
|
||||
}
|
||||
|
||||
const initFilters: Filters = {
|
||||
reference: '',
|
||||
status: [],
|
||||
memberId: null
|
||||
};
|
@ -17,6 +17,8 @@ import { FabAlert } from '../base/fab-alert';
|
||||
import ProductCategoryAPI from '../../api/product-category';
|
||||
import MachineAPI from '../../api/machine';
|
||||
import ProductAPI from '../../api/product';
|
||||
import { Plus } from 'phosphor-react';
|
||||
import { ProductStockForm } from './product-stock-form';
|
||||
|
||||
interface ProductFormProps {
|
||||
product: Product,
|
||||
@ -47,10 +49,22 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
|
||||
const [isActivePrice, setIsActivePrice] = useState<boolean>(product.id && _.isFinite(product.amount) && product.amount > 0);
|
||||
const [productCategories, setProductCategories] = useState<selectOption[]>([]);
|
||||
const [machines, setMachines] = useState<checklistOption[]>([]);
|
||||
const [stockTab, setStockTab] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
ProductCategoryAPI.index().then(data => {
|
||||
setProductCategories(buildSelectOptions(data));
|
||||
// Map product categories by position
|
||||
const sortedCategories = data
|
||||
.filter(c => !c.parent_id)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
const childrenCategories = data
|
||||
.filter(c => typeof c.parent_id === 'number')
|
||||
.sort((a, b) => b.position - a.position);
|
||||
childrenCategories.forEach(c => {
|
||||
const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id);
|
||||
sortedCategories.splice(parentIndex + 1, 0, c);
|
||||
});
|
||||
setProductCategories(buildSelectOptions(sortedCategories));
|
||||
}).catch(onError);
|
||||
MachineAPI.index({ disabled: false }).then(data => {
|
||||
setMachines(buildChecklistOptions(data));
|
||||
@ -223,144 +237,182 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
|
||||
</div>
|
||||
</header>
|
||||
<form className="product-form" onSubmit={onSubmit}>
|
||||
<div className="layout">
|
||||
<FormInput id="name"
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
formState={formState}
|
||||
onChange={handleNameChange}
|
||||
label={t('app.admin.store.product_form.name')}
|
||||
className='span-7' />
|
||||
<FormInput id="sku"
|
||||
register={register}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.sku')}
|
||||
className='span-3' />
|
||||
<div className='tabs'>
|
||||
<p className={!stockTab ? 'is-active' : ''} onClick={() => setStockTab(false)}>{t('app.admin.store.product_form.product_parameters')}</p>
|
||||
<p className={stockTab ? 'is-active' : ''} onClick={() => setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}</p>
|
||||
</div>
|
||||
<div className="layout">
|
||||
<FormInput id="slug"
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.slug')}
|
||||
className='span-7' />
|
||||
<FormSwitch control={control}
|
||||
id="is_active"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.is_show_in_store')}
|
||||
className='span-3' />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="price-data">
|
||||
<div className="layout">
|
||||
<h4 className='span-7'>{t('app.admin.store.product_form.price_and_rule_of_selling_product')}</h4>
|
||||
<FormSwitch control={control}
|
||||
id="is_active_price"
|
||||
label={t('app.admin.store.product_form.is_active_price')}
|
||||
tooltip={t('app.admin.store.product_form.is_active_price')}
|
||||
defaultValue={isActivePrice}
|
||||
onChange={toggleIsActivePrice}
|
||||
className='span-3' />
|
||||
</div>
|
||||
{isActivePrice && <div className="price-fields">
|
||||
<div className="flex">
|
||||
<FormInput id="amount"
|
||||
type="number"
|
||||
register={register}
|
||||
rules={{ required: true, min: 0.01 }}
|
||||
step={0.01}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.price')} />
|
||||
<FormInput id="quantity_min"
|
||||
type="number"
|
||||
rules={{ required: true }}
|
||||
register={register}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.quantity_min')} />
|
||||
{stockTab
|
||||
? <ProductStockForm product={product} register={register} control={control} id="stock" onError={onError} onSuccess={onSuccess} />
|
||||
: <section>
|
||||
<div className="subgrid">
|
||||
<FormInput id="name"
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
formState={formState}
|
||||
onChange={handleNameChange}
|
||||
label={t('app.admin.store.product_form.name')}
|
||||
className="span-7" />
|
||||
<FormInput id="sku"
|
||||
register={register}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.sku')}
|
||||
className="span-3" />
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h4>{t('app.admin.store.product_form.product_images')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.product_images_info" />
|
||||
</FabAlert>
|
||||
<div className="product-images">
|
||||
{output.product_images_attributes.map((image, i) => (
|
||||
<FormImageUpload key={i}
|
||||
defaultImage={image}
|
||||
id={`product_images_attributes[${i}]`}
|
||||
accept="image/*"
|
||||
size="large"
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
className={image._destroy ? 'hidden' : ''}
|
||||
mainOption={true}
|
||||
onFileRemove={handleRemoveProductImage(i)}
|
||||
onFileIsMain={handleSetMainImage(i)}
|
||||
/>
|
||||
))}
|
||||
<div className="subgrid">
|
||||
<FormInput id="slug"
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.slug')}
|
||||
className='span-7' />
|
||||
<FormSwitch control={control}
|
||||
id="is_active"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.is_show_in_store')}
|
||||
tooltip={t('app.admin.store.product_form.active_price_info')}
|
||||
className='span-3' />
|
||||
</div>
|
||||
<FabButton onClick={addProductImage}>{t('app.admin.store.product_form.add_product_image')}</FabButton>
|
||||
</div>
|
||||
<h4>{t('app.admin.store.product_form.assigning_category')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.assigning_category_info" />
|
||||
</FabAlert>
|
||||
<FormSelect options={productCategories}
|
||||
control={control}
|
||||
id="product_category_id"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.linking_product_to_category')} />
|
||||
|
||||
<hr />
|
||||
<hr />
|
||||
|
||||
<h4>{t('app.admin.store.product_form.assigning_machines')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.assigning_machines_info" />
|
||||
</FabAlert>
|
||||
<FormChecklist options={machines}
|
||||
control={control}
|
||||
id="machine_ids"
|
||||
formState={formState} />
|
||||
<div className="price-data">
|
||||
<div className="header-switch">
|
||||
<h4>{t('app.admin.store.product_form.price_and_rule_of_selling_product')}</h4>
|
||||
<FormSwitch control={control}
|
||||
id="is_active_price"
|
||||
label={t('app.admin.store.product_form.is_active_price')}
|
||||
defaultValue={isActivePrice}
|
||||
onChange={toggleIsActivePrice} />
|
||||
</div>
|
||||
{isActivePrice && <div className="price-data-content">
|
||||
<FormInput id="amount"
|
||||
type="number"
|
||||
register={register}
|
||||
rules={{ required: true, min: 0.01 }}
|
||||
step={0.01}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.price')} />
|
||||
<FormInput id="quantity_min"
|
||||
type="number"
|
||||
rules={{ required: true }}
|
||||
register={register}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.quantity_min')} />
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<hr />
|
||||
|
||||
<h4>{t('app.admin.store.product_form.product_description')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.product_description_info" />
|
||||
</FabAlert>
|
||||
<FormRichText control={control}
|
||||
paragraphTools={true}
|
||||
limit={1000}
|
||||
id="description" />
|
||||
<div>
|
||||
<h4>{t('app.admin.store.product_form.product_files')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.product_files_info" />
|
||||
</FabAlert>
|
||||
{output.product_files_attributes.map((file, i) => (
|
||||
<FormFileUpload key={i}
|
||||
defaultFile={file}
|
||||
id={`product_files_attributes[${i}]`}
|
||||
accept="application/pdf"
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
className={file._destroy ? 'hidden' : ''}
|
||||
onFileRemove={handleRemoveProductFile(i)}/>
|
||||
))}
|
||||
<FabButton onClick={addProductFile}>{t('app.admin.store.product_form.add_product_file')}</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="main-actions">
|
||||
<FabButton type="submit" className="main-action-btn">{t('app.admin.store.product_form.save')}</FabButton>
|
||||
</div>
|
||||
<div>
|
||||
<h4>{t('app.admin.store.product_form.product_images')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.product_images_info" />
|
||||
</FabAlert>
|
||||
<div className="product-images">
|
||||
<div className="list">
|
||||
{output.product_images_attributes.map((image, i) => (
|
||||
<FormImageUpload key={i}
|
||||
defaultImage={image}
|
||||
id={`product_images_attributes[${i}]`}
|
||||
accept="image/*"
|
||||
size="small"
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
className={image._destroy ? 'hidden' : ''}
|
||||
mainOption={true}
|
||||
onFileRemove={handleRemoveProductImage(i)}
|
||||
onFileIsMain={handleSetMainImage(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FabButton
|
||||
onClick={addProductImage}
|
||||
className='is-info'
|
||||
icon={<Plus size={24} />}>
|
||||
{t('app.admin.store.product_form.add_product_image')}
|
||||
</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h4>{t('app.admin.store.product_form.assigning_category')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.assigning_category_info" />
|
||||
</FabAlert>
|
||||
<FormSelect options={productCategories}
|
||||
control={control}
|
||||
id="product_category_id"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.linking_product_to_category')} />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h4>{t('app.admin.store.product_form.assigning_machines')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.assigning_machines_info" />
|
||||
</FabAlert>
|
||||
<FormChecklist options={machines}
|
||||
control={control}
|
||||
id="machine_ids"
|
||||
formState={formState} />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h4>{t('app.admin.store.product_form.product_description')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.product_description_info" />
|
||||
</FabAlert>
|
||||
<FormRichText control={control}
|
||||
heading
|
||||
bulletList
|
||||
blockquote
|
||||
link
|
||||
limit={6000}
|
||||
id="description" />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h4>{t('app.admin.store.product_form.product_files')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.product_files_info" />
|
||||
</FabAlert>
|
||||
<div className="product-documents">
|
||||
<div className="list">
|
||||
{output.product_files_attributes.map((file, i) => (
|
||||
<FormFileUpload key={i}
|
||||
defaultFile={file}
|
||||
id={`product_files_attributes[${i}]`}
|
||||
accept="application/pdf"
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
className={file._destroy ? 'hidden' : ''}
|
||||
onFileRemove={handleRemoveProductFile(i)}/>
|
||||
))}
|
||||
</div>
|
||||
<FabButton
|
||||
onClick={addProductFile}
|
||||
className='is-info'
|
||||
icon={<Plus size={24} />}>
|
||||
{t('app.admin.store.product_form.add_product_file')}
|
||||
</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-actions">
|
||||
<FabButton type="submit" className="main-action-btn">{t('app.admin.store.product_form.save')}</FabButton>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
100
app/frontend/src/javascript/components/store/product-item.tsx
Normal file
100
app/frontend/src/javascript/components/store/product-item.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FormatLib from '../../lib/format';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { Product } from '../../models/product';
|
||||
import { PencilSimple, Trash } from 'phosphor-react';
|
||||
import noImage from '../../../../images/no_image.png';
|
||||
|
||||
interface ProductItemProps {
|
||||
product: Product,
|
||||
onEdit: (product: Product) => void,
|
||||
onDelete: (productId: number) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows a product item in the admin view
|
||||
*/
|
||||
export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDelete }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
/**
|
||||
* Get the main image
|
||||
*/
|
||||
const thumbnail = () => {
|
||||
const image = product.product_images_attributes
|
||||
.find(att => att.is_main);
|
||||
return image;
|
||||
};
|
||||
/**
|
||||
* Init the process of editing the given product
|
||||
*/
|
||||
const editProduct = (product: Product): () => void => {
|
||||
return (): void => {
|
||||
onEdit(product);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Init the process of delete the given product
|
||||
*/
|
||||
const deleteProduct = (productId: number): () => void => {
|
||||
return (): void => {
|
||||
onDelete(productId);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns CSS class from stock status
|
||||
*/
|
||||
const statusColor = (product: Product) => {
|
||||
if (product.stock.external === 0 && product.stock.internal === 0) {
|
||||
return 'out-of-stock';
|
||||
}
|
||||
if (product.low_stock_threshold && (product.stock.external < product.low_stock_threshold || product.stock.internal < product.low_stock_threshold)) {
|
||||
return 'low';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`product-item ${statusColor(product)}`}>
|
||||
<div className='itemInfo'>
|
||||
{/* TODO: image size version ? */}
|
||||
<img src={thumbnail()?.attachment_url || noImage} alt='' className='itemInfo-thumbnail' />
|
||||
<p className="itemInfo-name">{product.name}</p>
|
||||
</div>
|
||||
<div className='details'>
|
||||
<span className={`visibility ${product.is_active ? 'is-active' : ''}`}>
|
||||
{product.is_active
|
||||
? t('app.admin.store.product_item.visible')
|
||||
: t('app.admin.store.product_item.hidden')
|
||||
}
|
||||
</span>
|
||||
<div className={`stock ${product.stock.internal < product.low_stock_threshold ? 'low' : ''}`}>
|
||||
<span>{t('app.admin.store.product_item.stock.internal')}</span>
|
||||
<p>{product.stock.internal}</p>
|
||||
</div>
|
||||
<div className={`stock ${product.stock.external < product.low_stock_threshold ? 'low' : ''}`}>
|
||||
<span>{t('app.admin.store.product_item.stock.external')}</span>
|
||||
<p>{product.stock.external}</p>
|
||||
</div>
|
||||
{product.amount &&
|
||||
<div className='price'>
|
||||
<p>{FormatLib.price(product.amount)}</p>
|
||||
<span>/ {t('app.admin.store.product_item.unit')}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className='actions'>
|
||||
<div className='manage'>
|
||||
<FabButton className='edit-btn' onClick={editProduct(product)}>
|
||||
<PencilSimple size={20} weight="fill" />
|
||||
</FabButton>
|
||||
<FabButton className='delete-btn' onClick={deleteProduct(product.id)}>
|
||||
<Trash size={20} weight="fill" />
|
||||
</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,208 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Product } from '../../models/product';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { Control, FormState } from 'react-hook-form/dist/types/form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import { FormSwitch } from '../form/form-switch';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import Select from 'react-select';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { PencilSimple } from 'phosphor-react';
|
||||
import { FabModal, ModalSize } from '../base/fab-modal';
|
||||
import { ProductStockModal } from './product-stock-modal';
|
||||
|
||||
interface ProductStockFormProps<TFieldValues, TContext extends object> {
|
||||
product: Product,
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
formState: FormState<TFieldValues>,
|
||||
onSuccess: (product: Product) => void,
|
||||
onError: (message: string) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Form tab to manage a product's stock
|
||||
*/
|
||||
export const ProductStockForm = <TFieldValues, TContext extends object> ({ product, register, control, formState, onError, onSuccess }: ProductStockFormProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [activeThreshold, setActiveThreshold] = useState<boolean>(false);
|
||||
// is the modal open?
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
// Styles the React-select component
|
||||
const customStyles = {
|
||||
control: base => ({
|
||||
...base,
|
||||
width: '20ch',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent'
|
||||
}),
|
||||
indicatorSeparator: () => ({
|
||||
display: 'none'
|
||||
})
|
||||
};
|
||||
|
||||
type selectOption = { value: number, label: string };
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildEventsOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_form.events.inward_stock') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_form.events.returned') },
|
||||
{ value: 2, label: t('app.admin.store.product_stock_form.events.canceled') },
|
||||
{ value: 3, label: t('app.admin.store.product_stock_form.events.sold') },
|
||||
{ value: 4, label: t('app.admin.store.product_stock_form.events.missing') },
|
||||
{ value: 5, label: t('app.admin.store.product_stock_form.events.damaged') }
|
||||
];
|
||||
};
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildStocksOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_form.internal') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_form.external') },
|
||||
{ value: 2, label: t('app.admin.store.product_stock_form.all') }
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* On events option change
|
||||
*/
|
||||
const eventsOptionsChange = (evt: selectOption) => {
|
||||
console.log('Event option:', evt);
|
||||
};
|
||||
/**
|
||||
* On stocks option change
|
||||
*/
|
||||
const stocksOptionsChange = (evt: selectOption) => {
|
||||
console.log('Stocks option:', evt);
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle stock threshold
|
||||
*/
|
||||
const toggleStockThreshold = (checked: boolean) => {
|
||||
setActiveThreshold(checked);
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens/closes the product category modal
|
||||
*/
|
||||
const toggleModal = (): void => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle stock threshold alert
|
||||
*/
|
||||
const toggleStockThresholdAlert = (checked: boolean) => {
|
||||
console.log('Low stock notification:', checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className='product-stock-form'>
|
||||
<h4>Stock à jour <span>00/00/0000 - 00H30</span></h4>
|
||||
<div></div>
|
||||
<div className="stock-item">
|
||||
<p className='title'>Product name</p>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.internal')}</span>
|
||||
<p>00</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.external')}</span>
|
||||
<p>000</p>
|
||||
</div>
|
||||
<FabButton onClick={toggleModal} icon={<PencilSimple size={20} weight="fill" />} className="is-black">Modifier</FabButton>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div className="threshold-data">
|
||||
<div className="header-switch">
|
||||
<h4>{t('app.admin.store.product_stock_form.low_stock_threshold')}</h4>
|
||||
<FormSwitch control={control}
|
||||
id="is_active_threshold"
|
||||
label={t('app.admin.store.product_stock_form.stock_threshold_toggle')}
|
||||
defaultValue={activeThreshold}
|
||||
onChange={toggleStockThreshold} />
|
||||
</div>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_stock_form.stock_threshold_information" />
|
||||
</FabAlert>
|
||||
{activeThreshold && <>
|
||||
<span className='stock-label'>{t('app.admin.store.product_stock_form.low_stock')}</span>
|
||||
<div className="threshold-data-content">
|
||||
<FormInput id="threshold"
|
||||
type="number"
|
||||
register={register}
|
||||
rules={{ required: true, min: 1 }}
|
||||
step={1}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_form.threshold_level')} />
|
||||
<FormSwitch control={control}
|
||||
id="threshold_alert"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_form.threshold_alert')}
|
||||
defaultValue={activeThreshold}
|
||||
onChange={toggleStockThresholdAlert} />
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div className="store-list">
|
||||
<h4>{t('app.admin.store.product_stock_form.events_history')}</h4>
|
||||
<div className="store-list-header">
|
||||
<div className='sort-events'>
|
||||
<p>{t('app.admin.store.product_stock_form.event_type')}</p>
|
||||
<Select
|
||||
options={buildEventsOptions()}
|
||||
onChange={evt => eventsOptionsChange(evt)}
|
||||
styles={customStyles}
|
||||
/>
|
||||
</div>
|
||||
<div className='sort-stocks'>
|
||||
<p>{t('app.admin.store.product_stock_form.stocks')}</p>
|
||||
<Select
|
||||
options={buildStocksOptions()}
|
||||
onChange={evt => stocksOptionsChange(evt)}
|
||||
styles={customStyles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stock-history">
|
||||
<div className="stock-item">
|
||||
<p className='title'>Product name</p>
|
||||
<p>00/00/0000</p>
|
||||
<div className="group">
|
||||
<span>[stock type]</span>
|
||||
<p>00</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.event_type')}</span>
|
||||
<p>[event type]</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.stock_level')}</span>
|
||||
<p>000</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FabModal title={t('app.admin.store.product_stock_form.modal_title')}
|
||||
className="fab-modal-lg"
|
||||
width={ModalSize.large}
|
||||
isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
closeButton>
|
||||
<ProductStockModal product={product} register={register} control={control} id="stock-modal" onError={onError} onSuccess={onSuccess} />
|
||||
</FabModal>
|
||||
</section>
|
||||
);
|
||||
};
|
@ -0,0 +1,98 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Product } from '../../models/product';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { Control, FormState } from 'react-hook-form/dist/types/form';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
interface ProductStockModalProps<TFieldValues, TContext extends object> {
|
||||
product: Product,
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
formState: FormState<TFieldValues>,
|
||||
onSuccess: (product: Product) => void,
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Form to manage a product's stock movement and quantity
|
||||
*/
|
||||
// TODO: delete next eslint disable
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const ProductStockModal = <TFieldValues, TContext extends object> ({ product, register, control, formState, onError, onSuccess }: ProductStockModalProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [movement, setMovement] = useState<'in' | 'out'>('in');
|
||||
|
||||
/**
|
||||
* Toggle between adding or removing product from stock
|
||||
*/
|
||||
const toggleMovementType = (evt: React.MouseEvent<HTMLButtonElement, MouseEvent>, type: 'in' | 'out') => {
|
||||
evt.preventDefault();
|
||||
setMovement(type);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildEventsOptions = (): Array<selectOption> => {
|
||||
let options = [];
|
||||
movement === 'in'
|
||||
? options = [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_modal.events.inward_stock') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_modal.events.returned') },
|
||||
{ value: 2, label: t('app.admin.store.product_stock_modal.events.canceled') }
|
||||
]
|
||||
: options = [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_modal.events.sold') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_modal.events.missing') },
|
||||
{ value: 2, label: t('app.admin.store.product_stock_modal.events.damaged') }
|
||||
];
|
||||
return options;
|
||||
};
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildStocksOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_modal.internal') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_modal.external') }
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<form className='product-stock-modal'>
|
||||
<p className='subtitle'>{t('app.admin.store.product_stock_modal.new_event')}</p>
|
||||
<div className="movement">
|
||||
<button onClick={(evt) => toggleMovementType(evt, 'in')} className={movement === 'in' ? 'is-active' : ''}>
|
||||
{t('app.admin.store.product_stock_modal.addition')}
|
||||
</button>
|
||||
<button onClick={(evt) => toggleMovementType(evt, 'out')} className={movement === 'out' ? 'is-active' : ''}>
|
||||
{t('app.admin.store.product_stock_modal.withdrawal')}
|
||||
</button>
|
||||
</div>
|
||||
<FormSelect options={buildStocksOptions()}
|
||||
control={control}
|
||||
id="updated_stock_type"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.stocks')} />
|
||||
<FormInput id="updated_stock_quantity"
|
||||
type="number"
|
||||
register={register}
|
||||
rules={{ required: true, min: 1 }}
|
||||
step={1}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.quantity')} />
|
||||
<FormSelect options={buildEventsOptions()}
|
||||
control={control}
|
||||
id="updated_stock_event"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.event_type')} />
|
||||
<FabButton type='submit'>{t('app.admin.store.product_stock_modal.update_stock')} </FabButton>
|
||||
</form>
|
||||
);
|
||||
};
|
@ -1,56 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { Product } from '../../models/product';
|
||||
|
||||
interface ProductsListProps {
|
||||
products: Array<Product>,
|
||||
onEdit: (product: Product) => void,
|
||||
onDelete: (productId: number) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows a list of all Products
|
||||
*/
|
||||
export const ProductsList: React.FC<ProductsListProps> = ({ products, onEdit, onDelete }) => {
|
||||
/**
|
||||
* Init the process of editing the given product
|
||||
*/
|
||||
const editProduct = (product: Product): () => void => {
|
||||
return (): void => {
|
||||
onEdit(product);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Init the process of delete the given product
|
||||
*/
|
||||
const deleteProduct = (productId: number): () => void => {
|
||||
return (): void => {
|
||||
onDelete(productId);
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{products.map((product) => (
|
||||
<div className='products-list-item' key={product.id}>
|
||||
<div className='itemInfo'>
|
||||
<img src='https://via.placeholder.com/300' alt='' className='itemInfo-thumbnail' />
|
||||
<p className="itemInfo-name">{product.name}</p>
|
||||
</div>
|
||||
<div className=''></div>
|
||||
<div className='actions'>
|
||||
<div className='manage'>
|
||||
<FabButton className='edit-btn' onClick={editProduct(product)}>
|
||||
<i className='fas fa-pen' />
|
||||
</FabButton>
|
||||
<FabButton className='delete-btn' onClick={deleteProduct(product.id)}>
|
||||
<i className='fa fa-trash' />
|
||||
</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,12 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useImmer } from 'use-immer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { ProductsList } from './products-list';
|
||||
import { Product } from '../../models/product';
|
||||
import { ProductCategory } from '../../models/product-category';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { ProductItem } from './product-item';
|
||||
import ProductAPI from '../../api/product';
|
||||
import ProductCategoryAPI from '../../api/product-category';
|
||||
import MachineAPI from '../../api/machine';
|
||||
import { AccordionItem } from './accordion-item';
|
||||
import { X } from 'phosphor-react';
|
||||
import { StoreListHeader } from './store-list-header';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
@ -14,21 +21,62 @@ interface ProductsProps {
|
||||
onSuccess: (message: string) => void,
|
||||
onError: (message: string) => void,
|
||||
}
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* This component shows all Products and filter
|
||||
* This component shows the admin view of the store
|
||||
*/
|
||||
const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [products, setProducts] = useState<Array<Product>>([]);
|
||||
const [filteredProductsList, setFilteredProductList] = useImmer<Array<Product>>([]);
|
||||
const [features, setFeatures] = useImmer<Filters>(initFilters);
|
||||
const [filterVisible, setFilterVisible] = useState<boolean>(false);
|
||||
const [filters, setFilters] = useImmer<Filters>(initFilters);
|
||||
const [clearFilters, setClearFilters] = useState<boolean>(false);
|
||||
const [productCategories, setProductCategories] = useState<ProductCategory[]>([]);
|
||||
const [machines, setMachines] = useState<checklistOption[]>([]);
|
||||
const [update, setUpdate] = useState(false);
|
||||
const [accordion, setAccordion] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
ProductAPI.index().then(data => {
|
||||
setProducts(data);
|
||||
setFilteredProductList(data);
|
||||
});
|
||||
|
||||
ProductCategoryAPI.index().then(data => {
|
||||
// Map product categories by position
|
||||
const sortedCategories = data
|
||||
.filter(c => !c.parent_id)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
const childrenCategories = data
|
||||
.filter(c => typeof c.parent_id === 'number')
|
||||
.sort((a, b) => b.position - a.position);
|
||||
childrenCategories.forEach(c => {
|
||||
const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id);
|
||||
sortedCategories.splice(parentIndex + 1, 0, c);
|
||||
});
|
||||
setProductCategories(sortedCategories);
|
||||
}).catch(onError);
|
||||
|
||||
MachineAPI.index({ disabled: false }).then(data => {
|
||||
setMachines(buildChecklistOptions(data));
|
||||
}).catch(onError);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters();
|
||||
setClearFilters(false);
|
||||
setUpdate(false);
|
||||
}, [filterVisible, clearFilters, update === true]);
|
||||
|
||||
/**
|
||||
* Goto edit product page
|
||||
*/
|
||||
@ -57,6 +105,122 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
||||
window.location.href = '/#!/admin/store/products/new';
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter: toggle non-available products visibility
|
||||
*/
|
||||
const toggleVisible = (checked: boolean) => {
|
||||
setFilterVisible(!filterVisible);
|
||||
console.log('Display on the shelf product only:', checked);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter: by categories
|
||||
*/
|
||||
const handleSelectCategory = (c: ProductCategory, checked: boolean, instantUpdate?: boolean) => {
|
||||
let list = [...filters.categories];
|
||||
const children = productCategories
|
||||
.filter(el => el.parent_id === c.id);
|
||||
const siblings = productCategories
|
||||
.filter(el => el.parent_id === c.parent_id && el.parent_id !== null);
|
||||
|
||||
if (checked) {
|
||||
list.push(c);
|
||||
if (children.length) {
|
||||
const unique = Array.from(new Set([...list, ...children]));
|
||||
list = [...unique];
|
||||
}
|
||||
if (siblings.length && siblings.every(el => list.includes(el))) {
|
||||
list.push(productCategories.find(p => p.id === siblings[0].parent_id));
|
||||
}
|
||||
} else {
|
||||
list.splice(list.indexOf(c), 1);
|
||||
const parent = productCategories.find(p => p.id === c.parent_id);
|
||||
if (c.parent_id && list.includes(parent)) {
|
||||
list.splice(list.indexOf(parent), 1);
|
||||
}
|
||||
if (children.length) {
|
||||
children.forEach(child => {
|
||||
list.splice(list.indexOf(child), 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
setFilters(draft => {
|
||||
return { ...draft, categories: list };
|
||||
});
|
||||
if (instantUpdate) {
|
||||
setUpdate(true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter: by machines
|
||||
*/
|
||||
const handleSelectMachine = (m: checklistOption, checked, instantUpdate?) => {
|
||||
const list = [...filters.machines];
|
||||
checked
|
||||
? list.push(m)
|
||||
: list.splice(list.indexOf(m), 1);
|
||||
setFilters(draft => {
|
||||
return { ...draft, machines: list };
|
||||
});
|
||||
if (instantUpdate) {
|
||||
setUpdate(true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Display option: sorting
|
||||
*/
|
||||
const handleSorting = (option: selectOption) => {
|
||||
console.log('Sort option:', option);
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply filters
|
||||
*/
|
||||
const applyFilters = () => {
|
||||
let tags = initFilters;
|
||||
|
||||
if (filters.categories.length) {
|
||||
tags = { ...tags, categories: [...filters.categories] };
|
||||
}
|
||||
|
||||
if (filters.machines.length) {
|
||||
tags = { ...tags, machines: [...filters.machines] };
|
||||
}
|
||||
|
||||
setFeatures(tags);
|
||||
console.log('Apply filters:', filters);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear filters
|
||||
*/
|
||||
const clearAllFilters = () => {
|
||||
setFilters(initFilters);
|
||||
setClearFilters(true);
|
||||
console.log('Clear all filters');
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.admin.store.products.sort.name_az') },
|
||||
{ value: 1, label: t('app.admin.store.products.sort.name_za') },
|
||||
{ value: 2, label: t('app.admin.store.products.sort.price_low') },
|
||||
{ value: 3, label: t('app.admin.store.products.sort.price_high') }
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/close accordion items
|
||||
*/
|
||||
const handleAccordion = (id, state) => {
|
||||
setAccordion({ ...accordion, [id]: state });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='products'>
|
||||
<header>
|
||||
@ -65,44 +229,83 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
||||
<FabButton className="main-action-btn" onClick={newProduct}>{t('app.admin.store.products.create_a_product')}</FabButton>
|
||||
</div>
|
||||
</header>
|
||||
<div className='layout'>
|
||||
<div className='products-filters span-3'>
|
||||
<header>
|
||||
<h3>Filtrer</h3>
|
||||
<div className='grpBtn'>
|
||||
<FabButton className="is-black">Clear</FabButton>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<div className='products-list span-7'>
|
||||
<div className='status'>
|
||||
<div className='count'>
|
||||
<p>Result count: <span>{products.length}</span></p>
|
||||
</div>
|
||||
<div className="">
|
||||
<div className='sort'>
|
||||
<p>Display options:</p>
|
||||
<div className='store-filters'>
|
||||
<header>
|
||||
<h3>{t('app.admin.store.products.filter')}</h3>
|
||||
<div className='grpBtn'>
|
||||
<FabButton onClick={clearAllFilters} className="is-black">{t('app.admin.store.products.filter_clear')}</FabButton>
|
||||
</div>
|
||||
</header>
|
||||
<div className='accordion'>
|
||||
<AccordionItem id={0}
|
||||
isOpen={accordion[0]}
|
||||
onChange={handleAccordion}
|
||||
label={t('app.admin.store.products.filter_categories')}
|
||||
>
|
||||
<div className='content'>
|
||||
<div className="group u-scrollbar">
|
||||
{productCategories.map(pc => (
|
||||
<label key={pc.id} className={pc.parent_id ? 'offset' : ''}>
|
||||
<input type="checkbox" checked={filters.categories.includes(pc)} onChange={(event) => handleSelectCategory(pc, event.target.checked)} />
|
||||
<p>{pc.name}</p>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className='visibility'>
|
||||
<FabButton onClick={() => setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem id={1}
|
||||
isOpen={accordion[1]}
|
||||
onChange={handleAccordion}
|
||||
label={t('app.admin.store.products.filter_machines')}
|
||||
>
|
||||
<div className='content'>
|
||||
<div className="group u-scrollbar">
|
||||
{machines.map(m => (
|
||||
<label key={m.value}>
|
||||
<input type="checkbox" checked={filters.machines.includes(m)} onChange={(event) => handleSelectMachine(m, event.target.checked)} />
|
||||
<p>{m.label}</p>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<FabButton onClick={() => setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className='features'>
|
||||
<div className='features-item'>
|
||||
<p>feature name</p>
|
||||
<button><i className="fa fa-times" /></button>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
<div className='store-list'>
|
||||
<StoreListHeader
|
||||
productsCount={filteredProductsList.length}
|
||||
selectOptions={buildOptions()}
|
||||
onSelectOptionsChange={handleSorting}
|
||||
switchChecked={filterVisible}
|
||||
onSwitch={toggleVisible}
|
||||
/>
|
||||
<div className='features'>
|
||||
{features.categories.map(c => (
|
||||
<div key={c.id} className='features-item'>
|
||||
<p>{c.name}</p>
|
||||
<button onClick={() => handleSelectCategory(c, false, true)}><X size={16} weight="light" /></button>
|
||||
</div>
|
||||
<div className='features-item'>
|
||||
<p>long feature name</p>
|
||||
<button><i className="fa fa-times" /></button>
|
||||
))}
|
||||
{features.machines.map(m => (
|
||||
<div key={m.value} className='features-item'>
|
||||
<p>{m.label}</p>
|
||||
<button onClick={() => handleSelectMachine(m, false, true)}><X size={16} weight="light" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<ProductsList
|
||||
products={products}
|
||||
onEdit={editProduct}
|
||||
onDelete={deleteProduct}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="products-list">
|
||||
{filteredProductsList.map((product) => (
|
||||
<ProductItem
|
||||
key={product.id}
|
||||
product={product}
|
||||
onEdit={editProduct}
|
||||
onDelete={deleteProduct}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -118,3 +321,46 @@ const ProductsWrapper: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
||||
};
|
||||
|
||||
Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError']));
|
||||
|
||||
/**
|
||||
* Option format, expected by checklist
|
||||
*/
|
||||
type checklistOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* Convert the provided array of items to the checklist format
|
||||
*/
|
||||
const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array<checklistOption> => {
|
||||
return items.map(t => {
|
||||
return { value: t.id, label: t.name };
|
||||
});
|
||||
};
|
||||
|
||||
interface Stock {
|
||||
from: number,
|
||||
to: number
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
instant: boolean,
|
||||
categories: ProductCategory[],
|
||||
machines: checklistOption[],
|
||||
keywords: string[],
|
||||
internalStock: Stock,
|
||||
externalStock: Stock
|
||||
}
|
||||
|
||||
const initFilters: Filters = {
|
||||
instant: false,
|
||||
categories: [],
|
||||
machines: [],
|
||||
keywords: [],
|
||||
internalStock: {
|
||||
from: 0,
|
||||
to: null
|
||||
},
|
||||
externalStock: {
|
||||
from: 0,
|
||||
to: null
|
||||
}
|
||||
};
|
||||
|
127
app/frontend/src/javascript/components/store/show-order.tsx
Normal file
127
app/frontend/src/javascript/components/store/show-order.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import noImage from '../../../../images/no_image.png';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface ShowOrderProps {
|
||||
orderRef: string,
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows an order details
|
||||
*/
|
||||
// TODO: delete next eslint disable
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const ShowOrder: React.FC<ShowOrderProps> = ({ orderRef, onError, onSuccess }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
/**
|
||||
* Returns a className according to the status
|
||||
*/
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'canceled':
|
||||
return 'canceled';
|
||||
case 'pending' || 'under_preparation':
|
||||
return 'pending';
|
||||
default:
|
||||
return 'normal';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='show-order'>
|
||||
<header>
|
||||
<h2>[order.ref]</h2>
|
||||
<div className="grpBtn">
|
||||
<a href={''}
|
||||
target='_blank'
|
||||
className='fab-button is-black'
|
||||
rel='noreferrer'>
|
||||
{t('app.admin.store.show_order.see_invoice')}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="client-info">
|
||||
<label>{t('app.admin.store.show_order.client')}</label>
|
||||
<div className="content">
|
||||
<div className='group'>
|
||||
<span>{t('app.admin.store.show_order.client')}</span>
|
||||
<p>order.user.name</p>
|
||||
</div>
|
||||
<div className='group'>
|
||||
<span>{t('app.admin.store.show_order.created_at')}</span>
|
||||
<p>order.created_at</p>
|
||||
</div>
|
||||
<div className='group'>
|
||||
<span>{t('app.admin.store.show_order.last_update')}</span>
|
||||
<p>order.???</p>
|
||||
</div>
|
||||
<span className={`order-status ${statusColor('error')}`}>order.state</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cart">
|
||||
<label>{t('app.admin.store.show_order.cart')}</label>
|
||||
<div>
|
||||
{/* loop sur les articles du panier */}
|
||||
<article className='store-cart-list-item'>
|
||||
<div className='picture'>
|
||||
<img alt=''src={noImage} />
|
||||
</div>
|
||||
<div className="ref">
|
||||
<span>{t('app.admin.store.show_order.reference_short')} orderable_id?</span>
|
||||
<p>o.orderable_name</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className='price'>
|
||||
<p>o.amount</p>
|
||||
<span>/ {t('app.admin.store.show_order.unit')}</span>
|
||||
</div>
|
||||
|
||||
<span className="count">o.quantity</span>
|
||||
|
||||
<div className='total'>
|
||||
<span>{t('app.admin.store.show_order.item_total')}</span>
|
||||
<p>o.quantity * o.amount</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group">
|
||||
<div className="payment-info">
|
||||
<label>{t('app.admin.store.show_order.payment_informations')}</label>
|
||||
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Ipsum rerum commodi quaerat possimus! Odit, harum.</p>
|
||||
</div>
|
||||
<div className="amount">
|
||||
<label>{t('app.admin.store.show_order.amount')}</label>
|
||||
<p>{t('app.admin.store.show_order.products_total')}<span>order.amount</span></p>
|
||||
<p className='gift'>{t('app.admin.store.show_order.gift_total')}<span>-order.amount</span></p>
|
||||
<p>{t('app.admin.store.show_order.coupon')}<span>order.amount</span></p>
|
||||
<p className='total'>{t('app.admin.store.show_order.total')} <span>order.total</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ShowOrderWrapper: React.FC<ShowOrderProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
<ShowOrder {...props} />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('showOrder', react2angular(ShowOrderWrapper, ['orderRef', 'onError', 'onSuccess']));
|
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Select from 'react-select';
|
||||
import Switch from 'react-switch';
|
||||
|
||||
interface StoreListHeaderProps {
|
||||
productsCount: number,
|
||||
selectOptions: selectOption[],
|
||||
onSelectOptionsChange: (option: selectOption) => void,
|
||||
switchLabel?: string,
|
||||
switchChecked?: boolean,
|
||||
onSwitch?: (boolean) => void
|
||||
}
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* Renders an accordion item
|
||||
*/
|
||||
export const StoreListHeader: React.FC<StoreListHeaderProps> = ({ productsCount, selectOptions, onSelectOptionsChange, switchLabel, switchChecked, onSwitch }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
// Styles the React-select component
|
||||
const customStyles = {
|
||||
control: base => ({
|
||||
...base,
|
||||
width: '20ch',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent'
|
||||
}),
|
||||
indicatorSeparator: () => ({
|
||||
display: 'none'
|
||||
})
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='store-list-header'>
|
||||
<div className='count'>
|
||||
<p>{t('app.admin.store.store_list_header.result_count')}<span>{productsCount}</span></p>
|
||||
</div>
|
||||
<div className="display">
|
||||
<div className='sort'>
|
||||
<p>{t('app.admin.store.store_list_header.display_options')}</p>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
onChange={evt => onSelectOptionsChange(evt)}
|
||||
styles={customStyles}
|
||||
/>
|
||||
</div>
|
||||
{onSwitch &&
|
||||
<div className='visibility'>
|
||||
<label>
|
||||
<span>{switchLabel || t('app.admin.store.store_list_header.visible_only')}</span>
|
||||
<Switch
|
||||
checked={switchChecked}
|
||||
onChange={(checked) => onSwitch(checked)}
|
||||
width={40}
|
||||
height={19}
|
||||
uncheckedIcon={false}
|
||||
checkedIcon={false}
|
||||
handleDiameter={15} />
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -6,6 +6,7 @@ import { Product } from '../../models/product';
|
||||
import { Order } from '../../models/order';
|
||||
import FormatLib from '../../lib/format';
|
||||
import CartAPI from '../../api/cart';
|
||||
import noImage from '../../../../images/no_image.png';
|
||||
|
||||
interface StoreProductItemProps {
|
||||
product: Product,
|
||||
@ -20,27 +21,14 @@ export const StoreProductItem: React.FC<StoreProductItemProps> = ({ product, car
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
/**
|
||||
* Return main image of Product, if the product has not any image, show default image
|
||||
* Return main image of Product, if the product has no image, show default image
|
||||
*/
|
||||
const productImageUrl = (product: Product) => {
|
||||
const productImage = _.find(product.product_images_attributes, { is_main: true });
|
||||
if (productImage) {
|
||||
return productImage.attachment_url;
|
||||
}
|
||||
return 'https://via.placeholder.com/300';
|
||||
};
|
||||
|
||||
/**
|
||||
* Return product's stock status
|
||||
*/
|
||||
const productStockStatus = (product: Product) => {
|
||||
if (product.stock.external === 0) {
|
||||
return <span>{t('app.public.store_product_item.out_of_stock')}</span>;
|
||||
}
|
||||
if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) {
|
||||
return <span>{t('app.public.store_product_item.limited_stock')}</span>;
|
||||
}
|
||||
return <span>{t('app.public.store_product_item.available')}</span>;
|
||||
return noImage;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -59,23 +47,51 @@ export const StoreProductItem: React.FC<StoreProductItemProps> = ({ product, car
|
||||
window.location.href = `/#!/store/p/${product.slug}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns CSS class from stock status
|
||||
*/
|
||||
const statusColor = (product: Product) => {
|
||||
if (product.stock.external === 0 && product.stock.internal === 0) {
|
||||
return 'out-of-stock';
|
||||
}
|
||||
if (product.low_stock_alert) {
|
||||
return 'low';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return product's stock status
|
||||
*/
|
||||
const productStockStatus = (product: Product) => {
|
||||
if (product.stock.external === 0) {
|
||||
return <span>{t('app.public.store_product_item.out_of_stock')}</span>;
|
||||
}
|
||||
if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) {
|
||||
return <span>{t('app.public.store_product_item.limited_stock')}</span>;
|
||||
}
|
||||
return <span>{t('app.public.store_product_item.available')}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="store-product-item" onClick={() => showProduct(product)}>
|
||||
<div className='itemInfo-image'>
|
||||
<img src={productImageUrl(product)} alt='' className='itemInfo-thumbnail' />
|
||||
<div className={`store-product-item ${statusColor(product)}`} onClick={() => showProduct(product)}>
|
||||
<div className="picture">
|
||||
<img src={productImageUrl(product)} alt='' />
|
||||
</div>
|
||||
<p className="itemInfo-name">{product.name}</p>
|
||||
<div className=''>
|
||||
<span>
|
||||
<div>{FormatLib.price(product.amount)}</div>
|
||||
{productStockStatus(product)}
|
||||
</span>
|
||||
{product.stock.external > 0 &&
|
||||
<FabButton className="edit-btn" onClick={addProductToCart}>
|
||||
<i className="fas fa-cart-arrow-down" /> {t('app.public.store_product_item.add')}
|
||||
</FabButton>
|
||||
}
|
||||
<p className="name">{product.name}</p>
|
||||
{product.amount &&
|
||||
<div className='price'>
|
||||
<p>{FormatLib.price(product.amount)}</p>
|
||||
<span>/ {t('app.public.store_product_item.unit')}</span>
|
||||
</div>
|
||||
}
|
||||
<div className="stock-label">
|
||||
{productStockStatus(product)}
|
||||
</div>
|
||||
{product.stock.external > 0 &&
|
||||
<FabButton icon={<i className="fas fa-cart-arrow-down" />} className="main-action-btn" onClick={addProductToCart}>
|
||||
{t('app.public.store_product_item.add')}
|
||||
</FabButton>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,11 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
/* eslint-disable fabmanager/scoped-translation */
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FormatLib from '../../lib/format';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import _ from 'lodash';
|
||||
import { Product } from '../../models/product';
|
||||
import ProductAPI from '../../api/product';
|
||||
import noImage from '../../../../images/no_image.png';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FilePdf, Minus, Plus } from 'phosphor-react';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
@ -21,32 +26,161 @@ export const StoreProduct: React.FC<StoreProductProps> = ({ productSlug, onError
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
const [product, setProduct] = useState<Product>();
|
||||
const [showImage, setShowImage] = useState<number>(null);
|
||||
const [toCartCount, setToCartCount] = useState<number>(0);
|
||||
const [displayToggle, setDisplayToggle] = useState<boolean>(false);
|
||||
const [collapseDescription, setCollapseDescription] = useState<boolean>(true);
|
||||
const descContainer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
ProductAPI.get(productSlug).then(data => {
|
||||
setProduct(data);
|
||||
const productImage = _.find(data.product_images_attributes, { is_main: true });
|
||||
setShowImage(productImage.id);
|
||||
setToCartCount(data.quantity_min ? data.quantity_min : 1);
|
||||
setDisplayToggle(descContainer.current.offsetHeight < descContainer.current.scrollHeight);
|
||||
}).catch(() => {
|
||||
onError(t('app.public.store_product.unexpected_error_occurred'));
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Return main image of Product, if the product has not any image, show default image
|
||||
* Return main image of Product, if the product has no image, show default image
|
||||
*/
|
||||
const productImageUrl = (product: Product) => {
|
||||
const productImage = _.find(product.product_images_attributes, { is_main: true });
|
||||
const productImageUrl = (id: number) => {
|
||||
const productImage = _.find(product.product_images_attributes, { id });
|
||||
if (productImage) {
|
||||
return productImage.attachment_url;
|
||||
}
|
||||
return 'https://via.placeholder.com/300';
|
||||
return noImage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns CSS class from stock status
|
||||
*/
|
||||
const statusColor = (product: Product) => {
|
||||
if (product.stock.external === 0 && product.stock.internal === 0) {
|
||||
return 'out-of-stock';
|
||||
}
|
||||
if (product.low_stock_alert) {
|
||||
return 'low';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return product's stock status
|
||||
*/
|
||||
const productStockStatus = (product: Product) => {
|
||||
if (product.stock.external === 0) {
|
||||
return <span>{t('app.public.store_product_item.out_of_stock')}</span>;
|
||||
}
|
||||
if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) {
|
||||
return <span>{t('app.public.store_product_item.limited_stock')}</span>;
|
||||
}
|
||||
return <span>{t('app.public.store_product_item.available')}</span>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update product count
|
||||
*/
|
||||
const setCount = (type: 'add' | 'remove') => {
|
||||
switch (type) {
|
||||
case 'add':
|
||||
setToCartCount(toCartCount + 1);
|
||||
break;
|
||||
case 'remove':
|
||||
if (toCartCount > product.quantity_min) {
|
||||
setToCartCount(toCartCount - 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Update product count
|
||||
*/
|
||||
const typeCount = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
evt.preventDefault();
|
||||
setToCartCount(Number(evt.target.value));
|
||||
};
|
||||
|
||||
/**
|
||||
* Add product to cart
|
||||
*/
|
||||
const addToCart = () => {
|
||||
console.log('Add', toCartCount, 'to cart');
|
||||
};
|
||||
|
||||
if (product) {
|
||||
return (
|
||||
<div className="store-product">
|
||||
<img src={productImageUrl(product)} alt='' className='itemInfo-thumbnail' />
|
||||
<p className="itemInfo-name">{product.name}</p>
|
||||
<div dangerouslySetInnerHTML={{ __html: product.description }} />
|
||||
<div className={`store-product ${statusColor(product)}`}>
|
||||
<span className='ref'>ref: {product.sku}</span>
|
||||
<h2 className='name'>{product.name}</h2>
|
||||
<div className='gallery'>
|
||||
<div className='main'>
|
||||
<div className='picture'>
|
||||
<img src={productImageUrl(showImage)} alt='' />
|
||||
</div>
|
||||
</div>
|
||||
{product.product_images_attributes.length > 1 &&
|
||||
<div className='thumbnails'>
|
||||
{product.product_images_attributes.map(i => (
|
||||
<div key={i.id} className={`picture ${i.id === showImage ? 'is-active' : ''}`}>
|
||||
<img alt='' onClick={() => setShowImage(i.id)} src={i.attachment_url} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className='description'>
|
||||
<div ref={descContainer} dangerouslySetInnerHTML={{ __html: product.description }} className='description-text' style={{ maxHeight: collapseDescription ? '35rem' : '1000rem' }} />
|
||||
{displayToggle &&
|
||||
<button onClick={() => setCollapseDescription(!collapseDescription)} className='description-toggle'>
|
||||
{collapseDescription
|
||||
? <span>{t('app.public.store_product.show_more')}</span>
|
||||
: <span>{t('app.public.store_product.show_less')}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
{product.product_files_attributes.length > 0 &&
|
||||
<div className='description-document'>
|
||||
<p>{t('app.public.store_product.documentation')}</p>
|
||||
<div className='list'>
|
||||
{product.product_files_attributes.map(f =>
|
||||
<a key={f.id} href={f.attachment_url}
|
||||
target='_blank'
|
||||
className='fab-button'
|
||||
rel='noreferrer'>
|
||||
<FilePdf size={24} />
|
||||
<span>{f.attachment_name}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<div className="stock-label">
|
||||
{productStockStatus(product)}
|
||||
</div>
|
||||
<div className='price'>
|
||||
<p>{FormatLib.price(product.amount)} <sup>TTC</sup></p>
|
||||
<span>/ {t('app.public.store_product_item.unit')}</span>
|
||||
</div>
|
||||
{product.stock.external > 0 &&
|
||||
<div className='to-cart'>
|
||||
<FabButton onClick={() => setCount('remove')} icon={<Minus size={16} />} className="minus" />
|
||||
<input type="number"
|
||||
value={toCartCount}
|
||||
onChange={evt => typeCount(evt)} />
|
||||
<FabButton onClick={() => setCount('add')} icon={<Plus size={16} />} className="plus" />
|
||||
<FabButton onClick={() => addToCart()} icon={<i className="fas fa-cart-arrow-down" />}
|
||||
className="main-action-btn">
|
||||
{t('app.public.store_product_item.add_to_cart')}
|
||||
</FabButton>
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
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';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface StoreSettingsProps {
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void
|
||||
}
|
||||
interface Settings {
|
||||
withdrawal: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows store settings
|
||||
*/
|
||||
// TODO: delete next eslint disable
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const StoreSettings: React.FC<StoreSettingsProps> = (onError, onSuccess) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const { control, handleSubmit } = useForm<Settings>();
|
||||
|
||||
/**
|
||||
* Callback triggered when the form is submitted: process with the product creation or update.
|
||||
*/
|
||||
const onSubmit: SubmitHandler<Settings> = (data) => {
|
||||
console.log(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='store-settings'>
|
||||
<header>
|
||||
<h2>{t('app.admin.store_settings.title')}</h2>
|
||||
</header>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<p>{t('app.admin.store_settings.withdrawal_instructions')}</p>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store_settings.withdrawal_info" />
|
||||
</FabAlert>
|
||||
<FormRichText control={control}
|
||||
heading
|
||||
bulletList
|
||||
link
|
||||
limit={400}
|
||||
id="withdrawal" />
|
||||
<FabButton type='submit' className='save-btn'>{t('app.admin.store_settings.save')}</FabButton>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StoreSettingsWrapper: React.FC<StoreSettingsProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
<StoreSettings {...props} />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('storeSettings', react2angular(StoreSettingsWrapper, ['onError', 'onSuccess']));
|
@ -5,28 +5,46 @@ import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { Product } from '../../models/product';
|
||||
import { ProductCategory } from '../../models/product-category';
|
||||
import ProductAPI from '../../api/product';
|
||||
import ProductCategoryAPI from '../../api/product-category';
|
||||
import MachineAPI from '../../api/machine';
|
||||
import { StoreProductItem } from './store-product-item';
|
||||
import useCart from '../../hooks/use-cart';
|
||||
import { emitCustomEvent } from 'react-custom-events';
|
||||
import { User } from '../../models/user';
|
||||
import { Order } from '../../models/order';
|
||||
import { AccordionItem } from './accordion-item';
|
||||
import { StoreListHeader } from './store-list-header';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface StoreProps {
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void,
|
||||
currentUser: User,
|
||||
}
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* This component shows public store
|
||||
*/
|
||||
const Store: React.FC<StoreProps> = ({ onError, currentUser }) => {
|
||||
const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser }) => {
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
const { cart, setCart } = useCart(currentUser);
|
||||
|
||||
const [products, setProducts] = useState<Array<Product>>([]);
|
||||
const [productCategories, setProductCategories] = useState<ProductCategory[]>([]);
|
||||
const [categoriesTree, setCategoriesTree] = useState<ParentCategory[]>([]);
|
||||
const [activeCategory, setActiveCategory] = useState<ActiveCategory>();
|
||||
const [filterVisible, setFilterVisible] = useState<boolean>(false);
|
||||
const [machines, setMachines] = useState<checklistOption[]>([]);
|
||||
const [accordion, setAccordion] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
ProductAPI.index({ is_active: true }).then(data => {
|
||||
@ -34,59 +52,209 @@ const Store: React.FC<StoreProps> = ({ onError, currentUser }) => {
|
||||
}).catch(() => {
|
||||
onError(t('app.public.store.unexpected_error_occurred'));
|
||||
});
|
||||
|
||||
ProductCategoryAPI.index().then(data => {
|
||||
setProductCategories(data);
|
||||
formatCategories(data);
|
||||
}).catch(() => {
|
||||
onError(t('app.public.store.unexpected_error_occurred'));
|
||||
});
|
||||
|
||||
MachineAPI.index({ disabled: false }).then(data => {
|
||||
setMachines(buildChecklistOptions(data));
|
||||
}).catch(() => {
|
||||
onError(t('app.public.store.unexpected_error_occurred'));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
emitCustomEvent('CartUpdate', cart);
|
||||
}, [cart]);
|
||||
|
||||
/**
|
||||
* Create categories tree (parent/children)
|
||||
*/
|
||||
const formatCategories = (list: ProductCategory[]) => {
|
||||
const tree = [];
|
||||
const parents = list.filter(c => !c.parent_id);
|
||||
const getChildren = (id) => {
|
||||
return list.filter(c => c.parent_id === id);
|
||||
};
|
||||
parents.forEach(p => {
|
||||
tree.push({ parent: p, children: getChildren(p.id) });
|
||||
});
|
||||
setCategoriesTree(tree);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter by category
|
||||
*/
|
||||
const filterCategory = (id: number, parent?: number) => {
|
||||
setActiveCategory({ id, parent });
|
||||
console.log('Filter by category:', productCategories.find(c => c.id === id).name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply filters
|
||||
*/
|
||||
const applyFilters = () => {
|
||||
console.log('Filter products');
|
||||
};
|
||||
/**
|
||||
* Clear filters
|
||||
*/
|
||||
const clearAllFilters = () => {
|
||||
console.log('Clear filters');
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/close accordion items
|
||||
*/
|
||||
const handleAccordion = (id, state) => {
|
||||
setAccordion({ ...accordion, [id]: state });
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.public.store.products.sort.name_az') },
|
||||
{ value: 1, label: t('app.public.store.products.sort.name_za') },
|
||||
{ value: 2, label: t('app.public.store.products.sort.price_low') },
|
||||
{ value: 3, label: t('app.public.store.products.sort.price_high') }
|
||||
];
|
||||
};
|
||||
/**
|
||||
* Display option: sorting
|
||||
*/
|
||||
const handleSorting = (option: selectOption) => {
|
||||
console.log('Sort option:', option);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter: toggle non-available products visibility
|
||||
*/
|
||||
const toggleVisible = (checked: boolean) => {
|
||||
setFilterVisible(!filterVisible);
|
||||
console.log('Display in stock only:', checked);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add product to the cart
|
||||
*/
|
||||
const addToCart = (cart: Order) => {
|
||||
setCart(cart);
|
||||
onSuccess(t('app.public.store.add_to_cart_success'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="store">
|
||||
<div className='layout'>
|
||||
<div className='store-filters span-3'>
|
||||
<ul className="breadcrumbs">
|
||||
<li>
|
||||
<span onClick={() => setActiveCategory(null)}>{t('app.public.store.products.all_products')}</span>
|
||||
</li>
|
||||
{activeCategory?.parent &&
|
||||
<li>
|
||||
<span onClick={() => filterCategory(activeCategory?.parent)}>
|
||||
{productCategories.find(c => c.id === activeCategory.parent).name}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
{activeCategory?.id &&
|
||||
<li>
|
||||
<span onClick={() => filterCategory(activeCategory?.id, activeCategory?.parent)}>
|
||||
{productCategories.find(c => c.id === activeCategory.id).name}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<aside className='store-filters'>
|
||||
<div className="categories">
|
||||
<header>
|
||||
<h3>Filtrer</h3>
|
||||
<h3>{t('app.public.store.products.filter_categories')}</h3>
|
||||
</header>
|
||||
<div className="group u-scrollbar">
|
||||
{categoriesTree.map(c =>
|
||||
<div key={c.parent.id} className={`parent ${activeCategory?.id === c.parent.id || activeCategory?.parent === c.parent.id ? 'is-active' : ''}`}>
|
||||
<p onClick={() => filterCategory(c.parent.id)}>
|
||||
{c.parent.name}<span>(count)</span>
|
||||
</p>
|
||||
{c.children.length > 0 &&
|
||||
<div className='children'>
|
||||
{c.children.map(ch =>
|
||||
<p key={ch.id}
|
||||
className={activeCategory?.id === ch.id ? 'is-active' : ''}
|
||||
onClick={() => filterCategory(ch.id, c.parent.id)}>
|
||||
{ch.name}<span>(count)</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='filters'>
|
||||
<header>
|
||||
<h3>{t('app.public.store.products.filter')}</h3>
|
||||
<div className='grpBtn'>
|
||||
<FabButton className="is-black">Clear</FabButton>
|
||||
<FabButton onClick={clearAllFilters} className="is-black">{t('app.public.store.products.filter_clear')}</FabButton>
|
||||
</div>
|
||||
</header>
|
||||
<div className="accordion">
|
||||
<AccordionItem id={1}
|
||||
isOpen={accordion[1]}
|
||||
onChange={handleAccordion}
|
||||
label={t('app.public.store.products.filter_machines')}
|
||||
>
|
||||
<div className='content'>
|
||||
<div className="group u-scrollbar">
|
||||
{machines.map(m => (
|
||||
<label key={m.value}>
|
||||
<input type="checkbox" />
|
||||
<p>{m.label}</p>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<FabButton onClick={applyFilters} className="is-info">{t('app.public.store.products.filter_apply')}</FabButton>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
<div className='store-products-list span-7'>
|
||||
<div className='status'>
|
||||
<div className='count'>
|
||||
<p>Result count: <span>{products.length}</span></p>
|
||||
</div>
|
||||
<div className="">
|
||||
<div className='sort'>
|
||||
<p>Display options:</p>
|
||||
</div>
|
||||
<div className='visibility'>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='features'>
|
||||
<div className='features-item'>
|
||||
<p>feature name</p>
|
||||
<button><i className="fa fa-times" /></button>
|
||||
</div>
|
||||
<div className='features-item'>
|
||||
<p>long feature name</p>
|
||||
<button><i className="fa fa-times" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="products">
|
||||
{products.map((product) => (
|
||||
<StoreProductItem key={product.id} product={product} cart={cart} onSuccessAddProductToCart={setCart} />
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className='store-list'>
|
||||
<StoreListHeader
|
||||
productsCount={products.length}
|
||||
selectOptions={buildOptions()}
|
||||
onSelectOptionsChange={handleSorting}
|
||||
switchLabel={t('app.public.store.products.in_stock_only')}
|
||||
switchChecked={filterVisible}
|
||||
onSwitch={toggleVisible}
|
||||
/>
|
||||
<div className="products-grid">
|
||||
{products.map((product) => (
|
||||
<StoreProductItem key={product.id} product={product} cart={cart} onSuccessAddProductToCart={addToCart} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Option format, expected by checklist
|
||||
*/
|
||||
type checklistOption = { value: number, label: string };
|
||||
/**
|
||||
* Convert the provided array of items to the checklist format
|
||||
*/
|
||||
const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array<checklistOption> => {
|
||||
return items.map(t => {
|
||||
return { value: t.id, label: t.name };
|
||||
});
|
||||
};
|
||||
|
||||
const StoreWrapper: React.FC<StoreProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
@ -95,4 +263,13 @@ const StoreWrapper: React.FC<StoreProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('store', react2angular(StoreWrapper, ['onError', 'currentUser']));
|
||||
Application.Components.component('store', react2angular(StoreWrapper, ['onError', 'onSuccess', 'currentUser']));
|
||||
|
||||
interface ActiveCategory {
|
||||
id: number,
|
||||
parent: number
|
||||
}
|
||||
interface ParentCategory {
|
||||
parent: ProductCategory,
|
||||
children: ProductCategory[]
|
||||
}
|
||||
|
@ -6,7 +6,8 @@ import { User } from '../../models/user';
|
||||
|
||||
interface MemberSelectProps {
|
||||
defaultUser?: User,
|
||||
onSelected?: (userId: number) => void
|
||||
onSelected?: (userId: number) => void,
|
||||
noHeader?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@ -18,7 +19,7 @@ type selectOption = { value: number, label: string };
|
||||
/**
|
||||
* This component renders the member select for manager.
|
||||
*/
|
||||
export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, onSelected }) => {
|
||||
export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, onSelected, noHeader }) => {
|
||||
const { t } = useTranslation('public');
|
||||
const [value, setValue] = useState<selectOption>();
|
||||
|
||||
@ -51,9 +52,11 @@ export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, onSelec
|
||||
|
||||
return (
|
||||
<div className="member-select">
|
||||
<div className="member-select-header">
|
||||
<h3 className="member-select-title">{t('app.public.member_select.select_a_member')}</h3>
|
||||
</div>
|
||||
{!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')}
|
||||
cacheOptions
|
||||
loadOptions={loadMembers}
|
||||
|
49
app/frontend/src/javascript/controllers/orders.js
Normal file
49
app/frontend/src/javascript/controllers/orders.js
Normal file
@ -0,0 +1,49 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-undef,
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
Application.Controllers.controller('ShowOrdersController', ['$scope', 'CSRF', 'growl', '$state', '$transition$',
|
||||
function ($scope, CSRF, growl, $state, $transition$) {
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/* PUBLIC SCOPE */
|
||||
$scope.orderToken = $transition$.params().token;
|
||||
|
||||
/**
|
||||
* Callback triggered in case of error
|
||||
*/
|
||||
$scope.onError = (message) => {
|
||||
growl.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered in case of success
|
||||
*/
|
||||
$scope.onSuccess = (message) => {
|
||||
growl.success(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Click Callback triggered in case of back products list
|
||||
*/
|
||||
$scope.backProductsList = () => {
|
||||
$state.go('app.admin.store.orders');
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/**
|
||||
* Kind of constructor: these actions will be realized first when the controller is loaded
|
||||
*/
|
||||
const initialize = function () {
|
||||
// set the authenticity tokens in the forms
|
||||
CSRF.setMetaTags();
|
||||
};
|
||||
|
||||
// init the controller (call at the end !)
|
||||
return initialize();
|
||||
}
|
||||
|
||||
]);
|
@ -227,6 +227,15 @@ angular.module('application.router', ['ui.router'])
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('app.logged.dashboard.orders', {
|
||||
url: '/orders',
|
||||
views: {
|
||||
'main@': {
|
||||
templateUrl: '/dashboard/orders.html',
|
||||
controller: 'DashboardController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('app.logged.dashboard.wallet', {
|
||||
url: '/wallet',
|
||||
abstract: !Fablab.walletModule,
|
||||
@ -904,6 +913,17 @@ angular.module('application.router', ['ui.router'])
|
||||
}
|
||||
})
|
||||
|
||||
// show order
|
||||
.state('app.admin.order_show', {
|
||||
url: '/admin/store/o/:token',
|
||||
views: {
|
||||
'main@': {
|
||||
templateUrl: '/admin/orders/show.html',
|
||||
controller: 'ShowOrdersController'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// invoices
|
||||
.state('app.admin.invoices', {
|
||||
url: '/admin/invoices',
|
||||
|
@ -30,6 +30,8 @@
|
||||
@import "modules/base/fab-text-editor";
|
||||
@import "modules/base/labelled-input";
|
||||
@import "modules/calendar/calendar";
|
||||
@import "modules/cart/cart-button";
|
||||
@import "modules/cart/store-cart";
|
||||
@import "modules/dashboard/reservations/credits-panel";
|
||||
@import "modules/dashboard/reservations/reservations-dashboard";
|
||||
@import "modules/dashboard/reservations/reservations-panel";
|
||||
@ -42,6 +44,7 @@
|
||||
@import "modules/form/form-file-upload";
|
||||
@import "modules/form/form-image-upload";
|
||||
@import "modules/group/change-group";
|
||||
@import "modules/layout/header-page";
|
||||
@import "modules/machines/machine-card";
|
||||
@import "modules/machines/machines-filters";
|
||||
@import "modules/machines/machines-list";
|
||||
@ -89,9 +92,19 @@
|
||||
@import "modules/settings/user-validation-setting";
|
||||
@import "modules/socials/fab-socials";
|
||||
@import "modules/store/_utilities";
|
||||
@import "modules/store/manage-product-category";
|
||||
@import "modules/store/orders-dashboard";
|
||||
@import "modules/store/orders";
|
||||
@import "modules/store/product-categories";
|
||||
@import "modules/store/product-form";
|
||||
@import "modules/store/product-stock-form";
|
||||
@import "modules/store/product-stock-modal";
|
||||
@import "modules/store/products-grid";
|
||||
@import "modules/store/products-list";
|
||||
@import "modules/store/products";
|
||||
@import "modules/store/store-filters";
|
||||
@import "modules/store/store-list-header";
|
||||
@import "modules/store/store-list";
|
||||
@import "modules/store/store-settings";
|
||||
@import "modules/store/store";
|
||||
@import "modules/subscriptions/free-extend-modal";
|
||||
@import "modules/subscriptions/renew-modal";
|
||||
@ -105,7 +118,6 @@
|
||||
@import "modules/user/gender-input";
|
||||
@import "modules/user/user-profile-form";
|
||||
@import "modules/user/user-validation";
|
||||
@import "modules/store/product-form";
|
||||
|
||||
@import "modules/abuses";
|
||||
@import "modules/cookies";
|
||||
|
@ -1,23 +1,23 @@
|
||||
.fab-button {
|
||||
color: black;
|
||||
background-color: #fbfbfb;
|
||||
display: inline-block;
|
||||
height: 38px;
|
||||
margin-bottom: 0;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
background-image: none;
|
||||
border: 1px solid #c9c9c9;
|
||||
padding: 6px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #c9c9c9;
|
||||
border-radius: 4px;
|
||||
background-color: #fbfbfb;
|
||||
background-image: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
text-align: center;
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
height: 38px;
|
||||
color: black;
|
||||
white-space: nowrap;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
@ -45,32 +45,31 @@
|
||||
|
||||
&--icon {
|
||||
margin-right: 0.5em;
|
||||
display: flex;
|
||||
}
|
||||
&--icon-only {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// color variants
|
||||
&.is-info {
|
||||
border-color: var(--information);
|
||||
background-color: var(--information);
|
||||
color: var(--gray-soft-lightest);
|
||||
@mixin colorVariant($color, $textColor) {
|
||||
border-color: $color;
|
||||
background-color: $color;
|
||||
color: $textColor;
|
||||
&:hover {
|
||||
border-color: var(--information);
|
||||
background-color: var(--information);
|
||||
color: var(--gray-soft-lightest);
|
||||
border-color: $color;
|
||||
background-color: $color;
|
||||
color: $textColor;
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
&.is-info {
|
||||
@include colorVariant(var(--information), var(--gray-soft-lightest));
|
||||
}
|
||||
&.is-black {
|
||||
border-color: var(--gray-hard-darkest);
|
||||
background-color: var(--gray-hard-darkest);
|
||||
color: var(--gray-soft-lightest);
|
||||
&:hover {
|
||||
border-color: var(--gray-hard-darkest);
|
||||
background-color: var(--gray-hard-darkest);
|
||||
color: var(--gray-soft-lightest);
|
||||
opacity: 0.75;
|
||||
}
|
||||
@include colorVariant(var(--gray-hard-darkest), var(--gray-soft-lightest));
|
||||
}
|
||||
&.is-main {
|
||||
@include colorVariant(var(--main), var(--gray-soft-lightest));
|
||||
}
|
||||
}
|
||||
|
41
app/frontend/src/stylesheets/modules/cart/cart-button.scss
Normal file
41
app/frontend/src/stylesheets/modules/cart/cart-button.scss
Normal file
@ -0,0 +1,41 @@
|
||||
.cart-button {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.8rem 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
background-color: var(--secondary);
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--secondary-text-color);
|
||||
border-radius: 10rem;
|
||||
color: var(--secondary);
|
||||
@include text-sm(600);
|
||||
}
|
||||
i {
|
||||
margin-bottom: 0.8rem;
|
||||
font-size: 2.6rem;
|
||||
columns: var(--secondary-text-color);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
@include text-sm;
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
}
|
186
app/frontend/src/stylesheets/modules/cart/store-cart.scss
Normal file
186
app/frontend/src/stylesheets/modules/cart/store-cart.scss
Normal file
@ -0,0 +1,186 @@
|
||||
.store-cart {
|
||||
width: 100%;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
grid-template-rows: minmax(0, min-content);
|
||||
gap: 3.2rem;
|
||||
align-items: flex-start;
|
||||
|
||||
&-list {
|
||||
grid-area: 1 / 1 / 2 / 10;
|
||||
display: grid;
|
||||
gap: 1.6rem;
|
||||
|
||||
&-item {
|
||||
padding: 0.8rem;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: min-content 1fr;
|
||||
gap: 1.6rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--gray-soft-lightest);
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
.picture {
|
||||
width: 10rem !important;
|
||||
@include imageRatio(76%);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
.ref {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
span {
|
||||
@include text-sm;
|
||||
color: var(--gray-hard-lightest);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
p {
|
||||
max-width: 60ch;
|
||||
margin: 0;
|
||||
@include text-base(600);
|
||||
}
|
||||
}
|
||||
.actions {
|
||||
align-self: stretch;
|
||||
padding: 0.8rem;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2.4rem;
|
||||
background-color: var(--gray-soft-light);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
.offer {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.8rem 1.6rem;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
@include text-base;
|
||||
cursor: pointer;
|
||||
span { margin-right: 0.8rem; }
|
||||
}
|
||||
}
|
||||
.price,
|
||||
.total {
|
||||
min-width: 10rem;
|
||||
p {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
@include title-base;
|
||||
}
|
||||
span { @include text-sm; }
|
||||
}
|
||||
.count {
|
||||
padding: 0.8rem 1.6rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--gray-soft);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
.total {
|
||||
span {
|
||||
@include text-sm;
|
||||
color: var(--main);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.group {
|
||||
grid-area: 2 / 1 / 3 / 10;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 2.4rem;
|
||||
}
|
||||
&-info,
|
||||
&-coupon {
|
||||
padding: 2.4rem;
|
||||
background-color: var(--gray-soft-light);
|
||||
border-radius: var(--border-radius);
|
||||
h3, label {
|
||||
margin: 0 0 1.6rem;
|
||||
@include text-base(500);
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
}
|
||||
.fab-input .input-wrapper {
|
||||
width: 100%;
|
||||
.fab-input--input {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
&-info {
|
||||
p { @include text-sm; }
|
||||
}
|
||||
|
||||
aside {
|
||||
grid-area: 1 / 10 / 3 / 13;
|
||||
& > div {
|
||||
margin-bottom: 3.2rem;
|
||||
padding: 1.6rem;
|
||||
background-color: var(--gray-soft-lightest);
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
h3,
|
||||
.member-select-title {
|
||||
margin: 0 0 2.4rem;
|
||||
padding-bottom: 1.2rem;
|
||||
border-bottom: 1px solid var(--gray-hard);
|
||||
@include title-base;
|
||||
color: var(--gray-hard-dark) !important;
|
||||
}
|
||||
}
|
||||
.checkout {
|
||||
.list {
|
||||
margin: 0.8rem 0 2.4rem;
|
||||
padding: 2.4rem 0;
|
||||
border-top: 1px solid var(--main);
|
||||
border-bottom: 1px solid var(--main);
|
||||
p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
span { @include title-base; }
|
||||
}
|
||||
.gift { color: var(--information); }
|
||||
}
|
||||
.total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
@include text-base(600);
|
||||
span { @include title-lg; }
|
||||
}
|
||||
|
||||
&-btn {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1.6rem 0.8rem;
|
||||
background-color: var(--information);
|
||||
border: none;
|
||||
color: var(--gray-soft-lightest);
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
&:hover {
|
||||
color: var(--gray-soft-lightest);
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -32,7 +32,7 @@
|
||||
background-color: var(--information-lightest);
|
||||
color: var(--information);
|
||||
border: 1px solid var(--information);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
line-height: 1.2em;
|
||||
@ -52,7 +52,7 @@
|
||||
&.is-required &-header p::after {
|
||||
content: "*";
|
||||
margin-left: 0.5ch;
|
||||
color: var(--error);
|
||||
color: var(--alert);
|
||||
}
|
||||
|
||||
&-field {
|
||||
@ -152,19 +152,19 @@
|
||||
}
|
||||
}
|
||||
&.is-incorrect &-field {
|
||||
border-color: var(--error);
|
||||
border-color: var(--alert);
|
||||
.icon {
|
||||
color: var(--error);
|
||||
border-color: var(--error);
|
||||
background-color: var(--error-lightest);
|
||||
color: var(--alert);
|
||||
border-color: var(--alert);
|
||||
background-color: var(--alert-lightest);
|
||||
}
|
||||
}
|
||||
&.is-warned &-field {
|
||||
border-color: var(--warning);
|
||||
border-color: var(--notification);
|
||||
.icon {
|
||||
color: var(--warning);
|
||||
border-color: var(--warning);
|
||||
background-color: var(--warning-lightest);
|
||||
color: var(--notification);
|
||||
border-color: var(--notification);
|
||||
background-color: var(--notification-lightest);
|
||||
}
|
||||
}
|
||||
&.is-disabled &-field input,
|
||||
@ -174,10 +174,21 @@
|
||||
|
||||
&-error {
|
||||
margin-top: 0.4rem;
|
||||
color: var(--error);
|
||||
color: var(--alert);
|
||||
}
|
||||
&-warning {
|
||||
margin-top: 0.4rem;
|
||||
color: var(--warning);
|
||||
color: var(--notification);
|
||||
}
|
||||
|
||||
input[type='file'] {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.file-placeholder {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
@ -1,106 +1,25 @@
|
||||
.fileinput {
|
||||
display: table;
|
||||
border-collapse: separate;
|
||||
position: relative;
|
||||
margin-bottom: 9px;
|
||||
.form-file-upload {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.6rem;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft-lightest);
|
||||
|
||||
.filename-container {
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
float: left;
|
||||
margin-bottom: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
background-color: #fff;
|
||||
background-image: none;
|
||||
border: 1px solid #c4c4c4;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%);
|
||||
height: 38px;
|
||||
padding: 6px 12px;
|
||||
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
|
||||
color: #555;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
|
||||
.fileinput-filename {
|
||||
vertical-align: bottom;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
margin-left: 10px;
|
||||
& > *:not(:first-child) {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
a {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.file-download {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
|
||||
i {
|
||||
color: black;
|
||||
}
|
||||
.image-file-input {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.fileinput-button {
|
||||
z-index: 1;
|
||||
border: 1px solid #c4c4c4;
|
||||
border-left: 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
background-color: #eee;
|
||||
color: #555;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
width: 1%;
|
||||
display: table-cell;
|
||||
background-image: none;
|
||||
touch-action: manipulation;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
.form-input {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
input[type=file] {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
direction: ltr;
|
||||
filter: alpha(opacity=0);
|
||||
font-size: 23px;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.fileinput-delete {
|
||||
padding: 6px 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
color: #555555;
|
||||
text-align: center;
|
||||
background-color: #eeeeee;
|
||||
border: 1px solid #c4c4c4;
|
||||
border-radius: 4px;
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
display: table-cell;
|
||||
}
|
||||
}
|
||||
|
@ -1,57 +1,54 @@
|
||||
@mixin base {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.6rem;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft-lightest);
|
||||
}
|
||||
.form-image-upload {
|
||||
&--small,
|
||||
&--medium,
|
||||
&--large {
|
||||
@include base;
|
||||
}
|
||||
|
||||
.image {
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--gray-soft);
|
||||
padding: 4px;
|
||||
display: inline-block;
|
||||
|
||||
&--small img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
&--medium img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
&--large img {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
|
||||
.select-button {
|
||||
position: relative;
|
||||
.image-file-input {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
&--small {
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
}
|
||||
.delete-image {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
&--medium {
|
||||
width: 20rem;
|
||||
height: 20rem;
|
||||
}
|
||||
&--large {
|
||||
width: 40rem;
|
||||
height: 40rem;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&--large {
|
||||
margin: 80px 40px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& > *:not(:first-child) {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
margin: 80px 40px;
|
||||
}
|
||||
input[type="radio"] { margin-left: 0.5rem; }
|
||||
|
||||
&--small {
|
||||
text-align: center;
|
||||
.image-file-input {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
37
app/frontend/src/stylesheets/modules/layout/header-page.scss
Normal file
37
app/frontend/src/stylesheets/modules/layout/header-page.scss
Normal file
@ -0,0 +1,37 @@
|
||||
.header-page {
|
||||
width: 100%;
|
||||
min-height: 9rem;
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
background-color: var(--gray-soft-lightest);
|
||||
border-bottom: 1px solid var(--gray-soft-dark);
|
||||
|
||||
.back {
|
||||
width: 9rem;
|
||||
border-right: 1px solid var(--gray-soft-dark);
|
||||
a {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
padding: 3.2rem;
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
min-width: 9rem;
|
||||
border-left: 1px solid var(--gray-soft-dark);
|
||||
}
|
||||
}
|
@ -1,11 +1,9 @@
|
||||
.machine-card {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
margin: 0 15px 30px;
|
||||
width: 30%;
|
||||
min-width: 263px;
|
||||
border-radius: var(--border-radius);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.loading::before {
|
||||
content: '';
|
||||
@ -37,18 +35,6 @@
|
||||
100% { transform: rotate(360deg);}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1219px) {
|
||||
width: 45%;
|
||||
min-width: 195px;
|
||||
margin: 0 auto 30px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 674px) {
|
||||
width: 95%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto 30px;
|
||||
}
|
||||
|
||||
.machine-picture {
|
||||
height: 250px;
|
||||
background-size: cover;
|
||||
|
@ -1,6 +1,44 @@
|
||||
.machines-list {
|
||||
.machines-list {
|
||||
.all-machines {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 3.2rem;
|
||||
|
||||
.store-ad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--main);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
color: var(--main-text-color);
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 3.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
h3 {
|
||||
margin: 0 0 2.4rem;
|
||||
@include title-lg;
|
||||
color: var(--main-text-color) !important;
|
||||
}
|
||||
p { margin: 0; }
|
||||
.sell {
|
||||
margin-top: auto;
|
||||
@include text-lg(500);
|
||||
}
|
||||
}
|
||||
.cta {
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
height: 5.4rem;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-color: var(--gray-hard-darkest);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,4 +11,96 @@
|
||||
color: currentColor;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin grid-col($col-count) {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat($col-count, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin: 2.4rem 0;
|
||||
padding: 0.4rem 0.8rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: var(--gray-soft-darkest);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--gray-soft-lightest);
|
||||
i { margin-right: 0.8rem; }
|
||||
|
||||
&:hover {
|
||||
color: var(--gray-soft-lightest);
|
||||
background-color: var(--gray-hard-lightest);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.main-action-btn {
|
||||
background-color: var(--main);
|
||||
color: var(--gray-soft-lightest);
|
||||
border: none;
|
||||
&:hover {
|
||||
background-color: var(--main);
|
||||
color: var(--gray-soft-lightest);
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin header {
|
||||
padding: 2.4rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.grpBtn {
|
||||
display: flex;
|
||||
& > *:not(:first-child) { margin-left: 2.4rem; }
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
@include title-lg;
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
@include text-lg(600);
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.stock-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@include text-sm;
|
||||
color: var(--status-color);
|
||||
&::before {
|
||||
content: "";
|
||||
margin-right: 0.8rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: var(--status-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom scrollbar
|
||||
.u-scrollbar {
|
||||
&::-webkit-scrollbar-track
|
||||
{
|
||||
border-radius: 6px;
|
||||
background-color: #d9d9d9;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar
|
||||
{
|
||||
width: 12px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb
|
||||
{
|
||||
border-radius: 6px;
|
||||
background-color: #2d2d2d;
|
||||
border: 2px solid #d9d9d9
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
<!-- Drop options -->
|
||||
|
||||
## [A] Single |> [B] Single
|
||||
[A] = index de [B]
|
||||
offset && [A] child de [B]
|
||||
|
||||
<!--## [A] Single || Child |> [B] Parent
|
||||
[A] = index de [B]
|
||||
[A] child de [B]-->
|
||||
|
||||
<!--## [A] Single || Child |> [B] Child
|
||||
[A] = index de [B]
|
||||
[A] même parent que [B]-->
|
||||
|
||||
## [A] Child |> [B] Single
|
||||
[A] = index de [B]
|
||||
offset
|
||||
? [A] child de [B]
|
||||
: [A] Single
|
||||
|
||||
<!--## [A] Parent |> [B] Single
|
||||
[A] = index de [B]-->
|
||||
|
||||
<!--## [A] Parent |> [B] Parent
|
||||
down
|
||||
? [A] = index du dernier child de [B]
|
||||
: [A] = index de [B]-->
|
||||
|
||||
<!--## [A] Parent |> [B] Child
|
||||
down
|
||||
? [A] = index du dernier child de [B]
|
||||
: [A] = index du parent de [B]-->
|
||||
|
||||
## [A] Single |> [A]
|
||||
offset && [A] child du précédant parent
|
@ -1,3 +0,0 @@
|
||||
.manage-product-category {
|
||||
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
.orders-dashboard {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 6rem;
|
||||
@include grid-col(12);
|
||||
gap: 3.2rem;
|
||||
align-items: flex-start;
|
||||
|
||||
header {
|
||||
@include header();
|
||||
padding-bottom: 0;
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
}
|
158
app/frontend/src/stylesheets/modules/store/orders.scss
Normal file
158
app/frontend/src/stylesheets/modules/store/orders.scss
Normal file
@ -0,0 +1,158 @@
|
||||
.orders,
|
||||
.show-order {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 6rem;
|
||||
@include grid-col(12);
|
||||
gap: 3.2rem;
|
||||
align-items: flex-start;
|
||||
|
||||
header {
|
||||
@include header();
|
||||
padding-bottom: 0;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
&-list {
|
||||
& > *:not(:first-child) {
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: 1fr 15rem 15rem 10ch 12rem;
|
||||
gap: 2.4rem;
|
||||
justify-items: flex-start;
|
||||
align-items: center;
|
||||
padding: 1.6rem;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft-lightest);
|
||||
|
||||
p { margin: 0; }
|
||||
.ref { @include text-base(600); }
|
||||
.client {
|
||||
span {
|
||||
@include text-xs;
|
||||
color: var(--gray-hard-light);
|
||||
}
|
||||
p { @include text-sm; }
|
||||
}
|
||||
.date { @include text-sm; }
|
||||
.price {
|
||||
justify-self: flex-end;
|
||||
span {
|
||||
@include text-xs;
|
||||
color: var(--gray-hard-light);
|
||||
}
|
||||
p { @include text-base(600); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.show-order {
|
||||
&-nav {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
@include grid-col(12);
|
||||
gap: 3.2rem;
|
||||
justify-items: flex-start;
|
||||
& > * {
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
}
|
||||
header { grid-column: 2 / -2; }
|
||||
.client-info,
|
||||
.cart {
|
||||
grid-column: 2 / -2;
|
||||
label {
|
||||
margin-bottom: 1.6rem;
|
||||
@include title-base;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& > *:not(:last-child) {
|
||||
margin-right: 2.4rem;
|
||||
padding-right: 2.4rem;
|
||||
border-right: 1px solid var(--gray-hard-dark);
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.18;
|
||||
}
|
||||
.group {
|
||||
span {
|
||||
@include text-xs;
|
||||
color: var(--gray-hard-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .group {
|
||||
grid-column: 2 / -2;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 2.4rem;
|
||||
align-items: flex-start;
|
||||
|
||||
.payment-info,
|
||||
.amount {
|
||||
padding: 2.4rem;
|
||||
border: 1px solid var(--gray-soft);
|
||||
border-radius: var(--border-radius);
|
||||
label {
|
||||
margin: 0 0 2.4rem;
|
||||
padding: 0 0 0.8rem;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--gray-hard);
|
||||
@include title-base;
|
||||
}
|
||||
}
|
||||
.amount {
|
||||
p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
span { @include title-base; }
|
||||
}
|
||||
.gift { color: var(--information); }
|
||||
.total {
|
||||
padding: 1.6rem 0 0;
|
||||
align-items: flex-start;
|
||||
border-top: 1px solid var(--main);
|
||||
@include text-base(600);
|
||||
span { @include title-lg; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.order-status {
|
||||
--status-color: var(--success);
|
||||
&.error { --status-color: var(--alert); }
|
||||
&.canceled { --status-color: var(--alert-light); }
|
||||
&.pending { --status-color: var(--information); }
|
||||
&.normal { --status-color: var(--success); }
|
||||
padding: 0.4rem 0.8rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--gray-soft-light);
|
||||
border-radius: var(--border-radius);
|
||||
@include text-sm(500);
|
||||
line-height: 1.714;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
margin-right: 0.8rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: var(--status-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
@ -1,40 +1,30 @@
|
||||
.product-categories {
|
||||
max-width: 1300px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 6rem;
|
||||
@include grid-col(12);
|
||||
gap: 0 3.2rem;
|
||||
|
||||
header {
|
||||
padding: 2.4rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.grpBtn {
|
||||
display: flex;
|
||||
& > *:not(:first-child) { margin-left: 2.4rem; }
|
||||
.create-button {
|
||||
background-color: var(--gray-hard-darkest);
|
||||
border-color: var(--gray-hard-darkest);
|
||||
color: var(--gray-soft-lightest);
|
||||
&:hover {
|
||||
background-color: var(--gray-hard-light);
|
||||
border-color: var(--gray-hard-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
@include title-lg;
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
}
|
||||
}
|
||||
@include header();
|
||||
grid-column: 2 / -2;
|
||||
|
||||
.main-action-btn {
|
||||
background-color: var(--main);
|
||||
color: var(--gray-soft-lightest);
|
||||
border: none;
|
||||
&:hover { opacity: 0.75; }
|
||||
.create-button {
|
||||
background-color: var(--gray-hard-darkest);
|
||||
border-color: var(--gray-hard-darkest);
|
||||
color: var(--gray-soft-lightest);
|
||||
&:hover {
|
||||
background-color: var(--gray-hard-light);
|
||||
border-color: var(--gray-hard-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
.fab-alert {
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
|
||||
&-tree {
|
||||
grid-column: 2 / -2;
|
||||
& > *:not(:first-child) {
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
@ -64,6 +54,7 @@
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft-lightest);
|
||||
&.is-child { margin-left: 4.8rem; }
|
||||
.itemInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -96,7 +87,7 @@
|
||||
&:hover { opacity: 0.75; }
|
||||
}
|
||||
.edit-btn { background: var(--gray-hard-darkest); }
|
||||
.delete-btn { background: var(--error); }
|
||||
.delete-btn { background: var(--main); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,82 @@
|
||||
.product-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.product-form {
|
||||
grid-column: 2 / -2;
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
p {
|
||||
flex: 1;
|
||||
margin-bottom: 4rem;
|
||||
padding: 0.8rem;
|
||||
text-align: center;
|
||||
color: var(--main);
|
||||
border-bottom: 1px solid var(--gray-soft-dark);
|
||||
&.is-active {
|
||||
color: var(--gray-hard-dark);
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-bottom: none;
|
||||
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
|
||||
}
|
||||
&:hover { cursor: pointer; }
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 2.4rem;
|
||||
@include title-base;
|
||||
}
|
||||
hr {
|
||||
margin: 4.8rem 0;
|
||||
}
|
||||
|
||||
.subgrid {
|
||||
@include grid-col(10);
|
||||
gap: 3.2rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.span-3 { grid-column: span 3; }
|
||||
.span-7 { grid-column: span 7; }
|
||||
|
||||
& > div {
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0 3.2rem;
|
||||
& > * {
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-switch {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 3.2rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
label { flex: 0 1 fit-content; }
|
||||
}
|
||||
.price-data-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 0 3.2rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.product-images,
|
||||
.product-documents {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.list {
|
||||
margin-bottom: 2.4rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
|
||||
gap: 2.4rem;
|
||||
}
|
||||
button { margin-left: auto; }
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
.product-stock-form {
|
||||
h4 span { @include text-sm; }
|
||||
|
||||
.store-list {
|
||||
h4 { margin: 0; }
|
||||
}
|
||||
.store-list-header {
|
||||
& > *:not(:first-child) {
|
||||
&::before {
|
||||
content: "";
|
||||
margin: 0 2rem;
|
||||
width: 1px;
|
||||
height: 2rem;
|
||||
background-color: var(--gray-hard-darkest);
|
||||
}
|
||||
}
|
||||
.sort-events {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.sort-stocks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.threshold-data-content {
|
||||
margin-top: 1.6rem;
|
||||
padding: 1.6rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 3.2rem;
|
||||
border: 1px solid var(--gray-soft);
|
||||
border-radius: var(--border-radius);
|
||||
label { flex: 0 1 fit-content; }
|
||||
|
||||
}
|
||||
.stock-label {
|
||||
--status-color: var(--alert-light);
|
||||
}
|
||||
|
||||
.stock-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 4.8rem;
|
||||
justify-items: flex-start;
|
||||
align-items: center;
|
||||
padding: 1.6rem;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft-lightest);
|
||||
|
||||
& > * { flex: 1 1 45%; }
|
||||
button { flex: 0;}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
@include text-base;
|
||||
}
|
||||
.title {
|
||||
@include text-base(600);
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.group {
|
||||
span {
|
||||
@include text-xs;
|
||||
color: var(--gray-hard-light);
|
||||
}
|
||||
p { @include text-base(600); }
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
.product-stock-modal {
|
||||
.movement {
|
||||
margin-bottom: 3.2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 1.6rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--gray-soft-lightest);
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
color: var(--gray-soft-darkest);
|
||||
@include text-base;
|
||||
&.is-active {
|
||||
border: 1px solid var(--gray-soft-darkest);
|
||||
background-color: var(--gray-hard-darkest);
|
||||
color: var(--gray-soft-lightest);
|
||||
}
|
||||
}
|
||||
button:first-of-type {
|
||||
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||
}
|
||||
button:last-of-type {
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
.products-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 3.2rem;
|
||||
|
||||
.store-product-item {
|
||||
--status-color: var(--success);
|
||||
&.low { --status-color: var(--alert-light); }
|
||||
&.out-of-stock {
|
||||
--status-color: var(--alert);
|
||||
background-color: var(--gray-soft-light);
|
||||
border: none;
|
||||
}
|
||||
|
||||
padding: 1.6rem 2.4rem;
|
||||
display: grid;
|
||||
grid-template-areas: "image image"
|
||||
"name name"
|
||||
"price btn"
|
||||
"stock btn";
|
||||
grid-template-columns: auto min-content;
|
||||
grid-template-rows: min-content auto min-content min-content;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
|
||||
.picture {
|
||||
grid-area: image;
|
||||
@include imageRatio(50%);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
.name {
|
||||
margin: 2.4rem 0 1.6rem;
|
||||
grid-area: name;
|
||||
align-self: flex-start;
|
||||
@include text-base(600);
|
||||
}
|
||||
.price {
|
||||
grid-area: price;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
p {
|
||||
margin: 0;
|
||||
@include title-base;
|
||||
}
|
||||
span {
|
||||
margin-left: 0.8rem;
|
||||
@include text-sm;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
.stock-label { grid-area: stock; }
|
||||
button {
|
||||
grid-area: btn;
|
||||
align-self: flex-end;
|
||||
margin-left: 1rem;
|
||||
i { margin-right: 0.8rem; }
|
||||
}
|
||||
}
|
||||
}
|
108
app/frontend/src/stylesheets/modules/store/products-list.scss
Normal file
108
app/frontend/src/stylesheets/modules/store/products-list.scss
Normal file
@ -0,0 +1,108 @@
|
||||
.products-list {
|
||||
& > *:not(:first-child) {
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
.product-item {
|
||||
--status-color: var(--gray-hard-darkest);
|
||||
&.low { --status-color: var(--alert-light); }
|
||||
&.out-of-stock {
|
||||
--status-color: var(--alert);
|
||||
.stock { color: var(--alert) !important; }
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.6rem 0.8rem;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft-lightest);
|
||||
&.out-of-stock { border-color: var(--status-color); }
|
||||
|
||||
.itemInfo {
|
||||
min-width: 20ch;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&-thumbnail {
|
||||
width: 4.8rem;
|
||||
height: 4.8rem;
|
||||
margin-right: 1.6rem;
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft);
|
||||
}
|
||||
&-name {
|
||||
margin: 0;
|
||||
@include text-base;
|
||||
font-weight: 600;
|
||||
color: var(--gray-hard-darkest);
|
||||
}
|
||||
}
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: 120px repeat(2, minmax(min-content, 120px)) 120px;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
gap: 1.6rem;
|
||||
margin-left: auto;
|
||||
margin-right: 4rem;
|
||||
p {
|
||||
margin: 0;
|
||||
@include text-base(600);
|
||||
}
|
||||
|
||||
.visibility {
|
||||
justify-self: center;
|
||||
padding: 0.4rem 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--gray-soft-light);
|
||||
border-radius: var(--border-radius);
|
||||
&::before {
|
||||
flex-shrink: 0;
|
||||
margin-right: 1rem;
|
||||
content: "";
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: var(--gray-hard);
|
||||
border-radius: 50%;
|
||||
}
|
||||
&.is-active::before {
|
||||
background-color: var(--success);
|
||||
}
|
||||
}
|
||||
.stock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--gray-hard-darkest);
|
||||
span { @include text-xs; }
|
||||
&.low { color: var(--alert-light); }
|
||||
}
|
||||
.price {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
.manage {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
border-radius: var(--border-radius-sm);
|
||||
button {
|
||||
@include btn;
|
||||
border-radius: 0;
|
||||
color: var(--gray-soft-lightest);
|
||||
&:hover { opacity: 0.75; }
|
||||
}
|
||||
.edit-btn {background: var(--gray-hard-darkest) }
|
||||
.delete-btn {background: var(--main) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,214 +1,33 @@
|
||||
.products,
|
||||
.new-product,
|
||||
.edit-product {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 6rem;
|
||||
|
||||
.back-btn {
|
||||
margin: 2.4rem 0;
|
||||
padding: 0.4rem 0.8rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: var(--gray-soft-darkest);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--gray-soft-lightest);
|
||||
i { margin-right: 0.8rem; }
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-hard-lightest);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@include grid-col(12);
|
||||
gap: 3.2rem;
|
||||
align-items: flex-start;
|
||||
|
||||
header {
|
||||
padding: 2.4rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.grpBtn {
|
||||
display: flex;
|
||||
& > *:not(:first-child) { margin-left: 2.4rem; }
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
@include title-lg;
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
@include text-lg(600);
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0 3.2rem;
|
||||
.span-7 { flex: 1 1 70%; }
|
||||
.span-3 { flex: 1 1 30%; }
|
||||
}
|
||||
|
||||
.main-action-btn {
|
||||
background-color: var(--main);
|
||||
color: var(--gray-soft-lightest);
|
||||
border: none;
|
||||
&:hover { opacity: 0.75; }
|
||||
}
|
||||
|
||||
.main-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
& > *:not(:first-child) {
|
||||
margin-left: 1.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.products {
|
||||
max-width: 1600px;
|
||||
|
||||
.layout {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&-filters {
|
||||
padding-top: 1.6rem;
|
||||
border-top: 1px solid var(--gray-soft-dark);
|
||||
}
|
||||
|
||||
&-list {
|
||||
.status {
|
||||
padding: 1.6rem 2.4rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: var(--gray-soft);
|
||||
border-radius: var(--border-radius);
|
||||
p { margin: 0; }
|
||||
.count {
|
||||
p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@include text-sm;
|
||||
span {
|
||||
margin-left: 1.6rem;
|
||||
@include text-lg(600);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.features {
|
||||
margin: 2.4rem 0 1.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.6rem 2.4rem;
|
||||
&-item {
|
||||
padding-left: 1.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--information-light);
|
||||
border-radius: 100px;
|
||||
color: var(--information-dark);
|
||||
p { margin: 0; }
|
||||
button {
|
||||
width: 3.2rem;
|
||||
height: 3.2rem;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft-lightest);
|
||||
&:not(:first-child) {
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.itemInfo {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
&-thumbnail {
|
||||
width: 4.8rem;
|
||||
height: 4.8rem;
|
||||
margin-right: 1.6rem;
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft);
|
||||
}
|
||||
&-name {
|
||||
margin: 0;
|
||||
@include text-base;
|
||||
font-weight: 600;
|
||||
color: var(--gray-hard-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
.manage {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
border-radius: var(--border-radius-sm);
|
||||
button {
|
||||
@include btn;
|
||||
border-radius: 0;
|
||||
color: var(--gray-soft-lightest);
|
||||
&:hover { opacity: 0.75; }
|
||||
}
|
||||
.edit-btn {background: var(--gray-hard-darkest) }
|
||||
.delete-btn {background: var(--error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@include header();
|
||||
padding-bottom: 0;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.new-product,
|
||||
.edit-product {
|
||||
max-width: 1300px;
|
||||
padding-right: 1.6rem;
|
||||
padding-left: 1.6rem;
|
||||
|
||||
.product-form {
|
||||
.flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0 3.2rem;
|
||||
& > * {
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.layout {
|
||||
@media (max-width: 1023px) {
|
||||
.span-3,
|
||||
.span-7 {
|
||||
flex-basis: 50%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.price-data {
|
||||
.layout {
|
||||
align-items: center;
|
||||
}
|
||||
&-nav {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
@include grid-col(12);
|
||||
gap: 3.2rem;
|
||||
justify-items: flex-start;
|
||||
& > * {
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
}
|
||||
|
||||
header { grid-column: 2 / -2; }
|
||||
}
|
||||
|
146
app/frontend/src/stylesheets/modules/store/store-filters.scss
Normal file
146
app/frontend/src/stylesheets/modules/store/store-filters.scss
Normal file
@ -0,0 +1,146 @@
|
||||
.store-filters {
|
||||
grid-column: 1 / 4;
|
||||
|
||||
.categories {
|
||||
margin-bottom: 3.2rem;
|
||||
.list {
|
||||
max-height: 30vh;
|
||||
overflow: auto;
|
||||
}
|
||||
p {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
cursor: pointer;
|
||||
span {
|
||||
margin-left: 0.8rem;
|
||||
@include text-xs;
|
||||
color: var(--information);
|
||||
}
|
||||
}
|
||||
.parent {
|
||||
& > p {
|
||||
margin-bottom: 2.4rem;
|
||||
@include text-base(500);
|
||||
color: var(--gray-hard);
|
||||
}
|
||||
&.is-active > p {
|
||||
@include text-base(600);
|
||||
color: var(--information);
|
||||
.children {
|
||||
max-height: 1000px;
|
||||
}
|
||||
}
|
||||
&.is-active .children {
|
||||
max-height: 1000px;
|
||||
margin: -0.8rem 0 1.6rem;
|
||||
transition: max-height 500ms ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.children {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
p {
|
||||
margin-bottom: 1.6rem;
|
||||
@include text-base(400);
|
||||
color: var(--gray-hard-light);
|
||||
&.is-active {
|
||||
background-color: var(--gray-soft-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
padding-top: 1.6rem;
|
||||
border-top: 1px solid var(--gray-soft-dark);
|
||||
}
|
||||
|
||||
header {
|
||||
@include header();
|
||||
padding: 0 0 2.4rem 0;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
&-item:not(:last-of-type) {
|
||||
margin-bottom: 1.6rem;
|
||||
border-bottom: 1px solid var(--gray-soft-darkest);
|
||||
}
|
||||
&-item {
|
||||
position: relative;
|
||||
padding-bottom: 1.6rem;
|
||||
&.collapsed {
|
||||
header svg { transform: rotateZ(180deg); }
|
||||
.content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
* { opacity: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
@include text-base(600);
|
||||
cursor: pointer;
|
||||
svg { transition: transform 250ms ease-in-out; }
|
||||
}
|
||||
.content {
|
||||
max-height: 24rem;
|
||||
padding-top: 1.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
transition: max-height 500ms ease-in-out;
|
||||
* { transition: opacity 250ms ease-in-out 300ms; }
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 1;
|
||||
&.u-scrollbar { overflow: hidden auto; }
|
||||
|
||||
label {
|
||||
margin: 0 0.8rem 0 0;
|
||||
padding: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&:hover {
|
||||
background-color: var(--gray-soft-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type=checkbox] { margin: 0 0.8rem 0 0; }
|
||||
p {
|
||||
margin: 0;
|
||||
@include text-base;
|
||||
}
|
||||
&.offset { margin-left: 1.6rem; }
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
min-height: 4rem;
|
||||
padding: 0 0.8rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius-sm);
|
||||
@include text-base;
|
||||
}
|
||||
|
||||
button {
|
||||
opacity: 100;
|
||||
margin-top: 0.8rem;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
.store-list-header {
|
||||
padding: 0.8rem 2.4rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: var(--gray-soft);
|
||||
border-radius: var(--border-radius);
|
||||
p { margin: 0; }
|
||||
|
||||
.count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
p {
|
||||
@include text-sm;
|
||||
span {
|
||||
margin-left: 1.6rem;
|
||||
@include text-lg(600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& > *:not(:first-child) {
|
||||
&::before {
|
||||
content: "";
|
||||
margin: 0 2rem;
|
||||
width: 1px;
|
||||
height: 2rem;
|
||||
background-color: var(--gray-hard-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
.sort {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
p { margin-right: 0.8rem; }
|
||||
}
|
||||
|
||||
.visibility {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
label {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
span {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
32
app/frontend/src/stylesheets/modules/store/store-list.scss
Normal file
32
app/frontend/src/stylesheets/modules/store/store-list.scss
Normal file
@ -0,0 +1,32 @@
|
||||
.store-list {
|
||||
grid-column: 4 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.4rem 0;
|
||||
|
||||
.features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.6rem 2.4rem;
|
||||
&-item {
|
||||
padding-left: 1.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--information-light);
|
||||
border-radius: 100px;
|
||||
color: var(--information-dark);
|
||||
overflow: hidden;
|
||||
p { margin: 0; }
|
||||
button {
|
||||
width: 3.2rem;
|
||||
height: 3.2rem;
|
||||
margin-left: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
.store-settings {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 6rem;
|
||||
@include grid-col(12);
|
||||
gap: 3.2rem;
|
||||
align-items: flex-start;
|
||||
header {
|
||||
@include header();
|
||||
padding-bottom: 0;
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
form {
|
||||
grid-column: 2 / 7;
|
||||
p { @include title-base; }
|
||||
.save-btn {
|
||||
background-color: var(--main);
|
||||
color: var(--gray-soft-lightest);
|
||||
border: none;
|
||||
&:hover {
|
||||
background-color: var(--main);
|
||||
color: var(--gray-soft-lightest);
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,169 +1,185 @@
|
||||
.store {
|
||||
.store,
|
||||
.store-product {
|
||||
max-width: 1600px;
|
||||
@include grid-col(12);
|
||||
gap: 2.4rem 3.2rem;
|
||||
align-items: flex-start;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin: 2.4rem 0;
|
||||
padding: 0.4rem 0.8rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: var(--gray-soft-darkest);
|
||||
.store {
|
||||
.breadcrumbs {
|
||||
grid-column: 1 / -1;
|
||||
padding: 0.8rem 1.6rem;
|
||||
display: flex;
|
||||
list-style: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--gray-soft-lightest);
|
||||
i { margin-right: 0.8rem; }
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-hard-lightest);
|
||||
background-color: var(--gray-soft-light);
|
||||
li:not(:last-of-type)::after {
|
||||
margin: 0 2.4rem;
|
||||
content: "\f054";
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-size: 1.2rem;
|
||||
font-weight: 900;
|
||||
color: var(--gray-hard-darkest);
|
||||
}
|
||||
li:last-of-type:not(:first-of-type) span {
|
||||
color: var(--information);
|
||||
}
|
||||
span {
|
||||
color: var(--gray-hard-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 2.4rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.grpBtn {
|
||||
display: flex;
|
||||
& > *:not(:first-child) { margin-left: 2.4rem; }
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
@include title-lg;
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
@include text-lg(600);
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0 3.2rem;
|
||||
.span-7 { flex: 1 1 70%; }
|
||||
.span-3 { flex: 1 1 30%; }
|
||||
}
|
||||
|
||||
.main-action-btn {
|
||||
background-color: var(--main);
|
||||
color: var(--gray-soft-lightest);
|
||||
border: none;
|
||||
&:hover { opacity: 0.75; }
|
||||
}
|
||||
|
||||
.main-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
& > *:not(:first-child) {
|
||||
margin-left: 1.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.store {
|
||||
max-width: 1600px;
|
||||
.store-product {
|
||||
--status-color: var(--success);
|
||||
&.low { --status-color: var(--alert-light); }
|
||||
&.out-of-stock { --status-color: var(--alert); }
|
||||
|
||||
.layout {
|
||||
align-items: flex-start;
|
||||
padding-top: 4rem;
|
||||
gap: 0 3.2rem;
|
||||
align-items: flex-start;
|
||||
|
||||
.ref {
|
||||
grid-area: 1 / 1 / 2 / 9;
|
||||
@include text-sm;
|
||||
color: var(--gray-hard-lightest);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&-filters {
|
||||
.name {
|
||||
grid-area: 2 / 1 / 3 / 9;
|
||||
margin: 0.8rem 0 3.2rem;
|
||||
@include title-lg;
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
}
|
||||
|
||||
&-products-list {
|
||||
.products {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.gallery {
|
||||
grid-area: 3 / 1 / 4 / 4;
|
||||
.picture{
|
||||
@include imageRatio;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--gray-soft-darkest);
|
||||
img {
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 1.6rem 2.4rem;
|
||||
.thumbnails {
|
||||
margin-top: 1.6rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.8rem;
|
||||
.is-active {
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 2px var(--gray-hard-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
.description {
|
||||
grid-area: 3 / 4 / 4 / 9;
|
||||
&-text {
|
||||
padding-bottom: 4rem;
|
||||
overflow: hidden;
|
||||
@include editor;
|
||||
transition: max-height 0.5s ease-in-out;
|
||||
h3 {
|
||||
@include text-base(600);
|
||||
}
|
||||
p {
|
||||
@include text-sm;
|
||||
color: var(--gray-hard-lightest);
|
||||
}
|
||||
}
|
||||
&-toggle {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 6rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: var(--gray-soft);
|
||||
border-radius: var(--border-radius);
|
||||
p { margin: 0; }
|
||||
.count {
|
||||
p {
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
background: linear-gradient(0deg, white 0%, transparent 100%);
|
||||
border: none;
|
||||
transform: translateY(-4rem);
|
||||
&::before {
|
||||
position: absolute;
|
||||
bottom: 1.2rem;
|
||||
left: 0;
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--information);
|
||||
z-index: -1;
|
||||
}
|
||||
span {
|
||||
padding: 0 1.6rem;
|
||||
color: var(--information);
|
||||
background-color: var(--gray-soft-lightest);
|
||||
}
|
||||
}
|
||||
&-document {
|
||||
padding: 2.4rem;
|
||||
background-color: var(--gray-soft-light);
|
||||
p { @include text-sm(500); }
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem 1.6rem;
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@include text-sm;
|
||||
span {
|
||||
margin-left: 1.6rem;
|
||||
@include text-lg(600);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.features {
|
||||
margin: 2.4rem 0 1.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.6rem 2.4rem;
|
||||
&-item {
|
||||
padding-left: 1.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--information-light);
|
||||
border-radius: 100px;
|
||||
color: var(--information-dark);
|
||||
p { margin: 0; }
|
||||
button {
|
||||
width: 3.2rem;
|
||||
height: 3.2rem;
|
||||
background: none;
|
||||
border: none;
|
||||
svg { margin-right: 0.8rem; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
aside {
|
||||
grid-area: 1 / -4 / 4 / -1;
|
||||
position: sticky;
|
||||
top: 4rem;
|
||||
padding: 4rem;
|
||||
background-color: var(--gray-soft-light);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
&-product-item {
|
||||
padding: 1rem 1.8rem;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft-lightest);
|
||||
|
||||
margin-right: 1.6rem;
|
||||
|
||||
.itemInfo-image {
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
width: 19.8rem;
|
||||
height: 14.8rem;
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft);
|
||||
.price {
|
||||
p {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
@include title-xl;
|
||||
sup {
|
||||
margin: 0.8rem 0 0 0.8rem;
|
||||
@include title-sm;
|
||||
}
|
||||
}
|
||||
span {
|
||||
@include text-sm;
|
||||
}
|
||||
}
|
||||
.itemInfo-name {
|
||||
margin: 1rem 0;
|
||||
@include text-base;
|
||||
font-weight: 600;
|
||||
color: var(--gray-hard-darkest);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.manage {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
border-radius: var(--border-radius-sm);
|
||||
button {
|
||||
@include btn;
|
||||
border-radius: 0;
|
||||
color: var(--gray-soft-lightest);
|
||||
&:hover { opacity: 0.75; }
|
||||
}
|
||||
.edit-btn {background: var(--gray-hard-darkest) }
|
||||
.delete-btn {background: var(--error) }
|
||||
.to-cart {
|
||||
margin-top: 1.6rem;
|
||||
padding-top: 3.2rem;
|
||||
display: grid;
|
||||
grid-template-areas: "minus input plus"
|
||||
"btn btn btn";
|
||||
grid-template-columns: repeat(3, minmax(0, min-content));
|
||||
gap: 1.6rem;
|
||||
border-top: 1px solid var(--gray-soft-dark);
|
||||
.minus {
|
||||
grid-area: minus;
|
||||
color: var(--gray-hard-darkest);
|
||||
}
|
||||
.plus {
|
||||
grid-area: plus;
|
||||
color: var(--gray-hard-darkest);
|
||||
}
|
||||
input {
|
||||
grid-area: input;
|
||||
text-align: center;
|
||||
}
|
||||
.main-action-btn {
|
||||
grid-area: btn;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,7 @@
|
||||
|
||||
.file-item {
|
||||
&.has-error {
|
||||
color: var(--error);
|
||||
color: var(--alert);
|
||||
}
|
||||
|
||||
label {
|
||||
|
@ -16,7 +16,7 @@
|
||||
}
|
||||
|
||||
.missing-file {
|
||||
color: var(--error);
|
||||
color: var(--alert);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,7 @@
|
||||
}
|
||||
}
|
||||
.delete-avatar {
|
||||
background-color: var(--error);
|
||||
background-color: var(--alert);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
@ -28,11 +28,11 @@
|
||||
--success-dark: #229051;
|
||||
--success-darkest: #155239;
|
||||
|
||||
--error-lightest: #FDF1F1;
|
||||
--error-light: #EA8585;
|
||||
--error: #DA3030;
|
||||
--error-dark: #9F1D1D;
|
||||
--error-darkest: #611818;
|
||||
--alert-lightest: #FDF1F1;
|
||||
--alert-light: #EA8585;
|
||||
--alert: #DA3030;
|
||||
--alert-dark: #9F1D1D;
|
||||
--alert-darkest: #611818;
|
||||
|
||||
--information-lightest: #EFF6FF;
|
||||
--information-light: #93C5FD;
|
||||
@ -40,11 +40,11 @@
|
||||
--information-dark: #1E3A8A;
|
||||
--information-darkest: #122354;
|
||||
|
||||
--warning-lightest: #FFFCF4;
|
||||
--warning-light: #FAE29F;
|
||||
--warning: #D6AE47;
|
||||
--warning-dark: #8C6D1F;
|
||||
--warning-darkest: #5C4813;
|
||||
--notification-lightest: #FFFCF4;
|
||||
--notification-light: #FAE29F;
|
||||
--notification: #D6AE47;
|
||||
--notification-dark: #8C6D1F;
|
||||
--notification-darkest: #5C4813;
|
||||
|
||||
--main-text-color: black;
|
||||
--secondary-text-color: black;
|
||||
|
@ -2,4 +2,19 @@
|
||||
--border-radius: 8px;
|
||||
--border-radius-sm: 4px;
|
||||
--shadow: 0 0 10px rgba(39, 32, 32, 0.25);
|
||||
}
|
||||
|
||||
@mixin imageRatio($ratio: 100%) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: $ratio;
|
||||
overflow: hidden;
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
@ -56,6 +56,12 @@
|
||||
font-size: 1.4rem;
|
||||
line-height: normal;
|
||||
}
|
||||
@mixin text-xs($weight: normal) {
|
||||
font-family: var(--font-text);
|
||||
font-weight: $weight;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
// Text Editor
|
||||
@mixin editor {
|
||||
@ -65,7 +71,7 @@
|
||||
h3 {
|
||||
@include text-lg(600);
|
||||
margin: 0 0 1rem;
|
||||
color: var(--gray-hard-darkest);
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
}
|
||||
ul {
|
||||
padding-inline-start: 2.2rem;
|
||||
|
19
app/frontend/templates/admin/orders/show.html
Normal file
19
app/frontend/templates/admin/orders/show.html
Normal file
@ -0,0 +1,19 @@
|
||||
<div class="header-page">
|
||||
<div class="back">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</div>
|
||||
|
||||
<div class="center">
|
||||
<h1 translate>{{ 'app.admin.store.manage_the_store' }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="show-order-nav">
|
||||
<a class="back-btn" ng-click="backProductsList()" tabindex="0">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
<span translate>{{ 'app.admin.store.back_products_list' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<show-order order-token="orderToken" on-error="onError" on-success="onSuccess" />
|
||||
</section>
|
@ -1,18 +1,12 @@
|
||||
<section class="heading b-b">
|
||||
<div class="row no-gutter">
|
||||
<div class="col-xs-2 col-sm-2 col-md-1">
|
||||
<section class="heading-btn">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-xs-10 col-sm-10 col-md-8 b-l">
|
||||
<section class="heading-title">
|
||||
<h1 translate>{{ 'app.admin.store.manage_the_store' }}</h1>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="header-page">
|
||||
<div class="back">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="center">
|
||||
<h1 translate>{{ 'app.admin.store.manage_the_store' }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="m-lg admin-store-manage">
|
||||
<div class="row">
|
||||
|
@ -1 +1 @@
|
||||
<h2>Orders page</h2>
|
||||
<orders on-success="onSuccess" on-error="onError"/>
|
||||
|
@ -1,35 +1,19 @@
|
||||
<section class="heading b-b">
|
||||
<div class="row no-gutter">
|
||||
<div class="col-xs-2 col-sm-2 col-md-1">
|
||||
<section class="heading-btn">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-xs-10 col-sm-10 col-md-8 b-l">
|
||||
<section class="heading-title">
|
||||
<h1 translate>{{ 'app.admin.store.manage_the_store' }}</h1>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="header-page">
|
||||
<div class="back">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="edit-product m-lg admin-store-manage">
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-12">
|
||||
<a class="back-btn" ng-click="backProductsList()">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
<span translate>{{ 'app.admin.store.back_products_list' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="center">
|
||||
<h1 translate>{{ 'app.admin.store.manage_the_store' }}</h1>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
<div>
|
||||
<edit-product product-id="productId" on-success="onSuccess" on-error="onError"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="admin-store-manage">
|
||||
<div class="edit-product-nav">
|
||||
<a class="back-btn" ng-click="backProductsList()">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
<span translate>{{ 'app.admin.store.back_products_list' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<edit-product product-id="productId" on-success="onSuccess" on-error="onError"/>
|
||||
</section>
|
@ -1,35 +1,19 @@
|
||||
<section class="heading b-b">
|
||||
<div class="row no-gutter">
|
||||
<div class="col-xs-2 col-sm-2 col-md-1">
|
||||
<section class="heading-btn">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-xs-10 col-sm-10 col-md-8 b-l">
|
||||
<section class="heading-title">
|
||||
<h1 translate>{{ 'app.admin.store.manage_the_store' }}</h1>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="header-page">
|
||||
<div class="back">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="new-product m-lg admin-store-manage">
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-12">
|
||||
<a class="back-btn" ng-click="backProductsList()" tabindex="0">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
<span translate>{{ 'app.admin.store.back_products_list' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="center">
|
||||
<h1 translate>{{ 'app.admin.store.manage_the_store' }}</h1>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
<div>
|
||||
<new-product on-success="onSuccess" on-error="onError"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="new-product-nav">
|
||||
<a class="back-btn" ng-click="backProductsList()" tabindex="0">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
<span translate>{{ 'app.admin.store.back_products_list' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<new-product on-success="onSuccess" on-error="onError"/>
|
||||
</section>
|
@ -1 +1 @@
|
||||
<h2>Settings page</h2>
|
||||
<store-settings on-success="onSuccess" on-error="onError"/>
|
||||
|
@ -1,18 +1,12 @@
|
||||
<section class="heading b-b">
|
||||
<div class="row no-gutter">
|
||||
<div class="col-xs-2 col-sm-2 col-md-1">
|
||||
<section class="heading-btn">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-xs-10 col-sm-10 col-md-8 b-l b-r-md">
|
||||
<section class="heading-title">
|
||||
<h1 translate>{{ 'app.public.cart.my_cart' }}</h1>
|
||||
</section>
|
||||
</div>
|
||||
<div class="header-page">
|
||||
<div class="back">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="center">
|
||||
<h1 translate>{{ 'app.public.cart.my_cart' }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="m-lg">
|
||||
<store-cart current-user="currentUser" user-login="userLogin" on-error="onError" on-success="onSuccess" />
|
||||
|
@ -19,6 +19,7 @@
|
||||
<li ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.events" translate>{{ 'app.public.common.my_events' }}</a></li>
|
||||
<li ui-sref-active="active" ng-show="$root.modules.invoicing"><a class="text-black" ui-sref="app.logged.dashboard.invoices" translate>{{ 'app.public.common.my_invoices' }}</a></li>
|
||||
<li ui-sref-active="active" ng-show="$root.modules.invoicing"><a class="text-black" ui-sref="app.logged.dashboard.payment_schedules" translate>{{ 'app.public.common.my_payment_schedules' }}</a></li>
|
||||
<li ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.orders" translate>{{ 'app.public.common.my_orders' }}</a></li>
|
||||
<li ng-show="$root.modules.wallet" ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.wallet" translate>{{ 'app.public.common.my_wallet' }}</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
9
app/frontend/templates/dashboard/orders.html
Normal file
9
app/frontend/templates/dashboard/orders.html
Normal file
@ -0,0 +1,9 @@
|
||||
<div>
|
||||
<section class="heading">
|
||||
<div class="row no-gutter">
|
||||
<ng-include src="'/dashboard/nav.html'"></ng-include>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<orders-dashboard on-error="onError" />
|
||||
</div>
|
@ -1,23 +1,16 @@
|
||||
<section class="heading b-b">
|
||||
<div class="row no-gutter">
|
||||
<div class="col-xs-2 col-sm-2 col-md-1">
|
||||
<section class="heading-btn">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-xs-10 col-sm-10 col-md-8 b-l b-r-md">
|
||||
<section class="heading-title">
|
||||
<h1 translate>{{ 'app.public.store.fablab_store' }}</h1>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 col-sm-12 col-md-3 b-t hide-b-md">
|
||||
<section class="heading-actions wrapper">
|
||||
</section>
|
||||
</div>
|
||||
<div class="header-page">
|
||||
<div class="back">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="center">
|
||||
<h1 translate>{{ 'app.public.store.fablab_store' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<cart-button />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="m-lg">
|
||||
<store-product product-slug="productSlug" on-error="onError" on-success="onSuccess" />
|
||||
|
@ -48,6 +48,7 @@
|
||||
<li><a ui-sref="app.logged.dashboard.events" translate>{{ 'app.public.common.my_events' }}</a></li>
|
||||
<li><a ui-sref="app.logged.dashboard.invoices" ng-show="$root.modules.invoicing" translate>{{ 'app.public.common.my_invoices' }}</a></li>
|
||||
<li><a ui-sref="app.logged.dashboard.payment_schedules" ng-show="$root.modules.invoicing" translate>{{ 'app.public.common.my_payment_schedules' }}</a></li>
|
||||
<li><a ui-sref="app.logged.dashboard.orders" translate>{{ 'app.public.common.my_orders' }}</a></li>
|
||||
<li ng-show="$root.modules.wallet"><a ui-sref="app.logged.dashboard.wallet" translate>{{ 'app.public.common.my_wallet' }}</a></li>
|
||||
<li class="divider" ng-if="isAuthorized(['admin', 'manager'])"></li>
|
||||
<li><a class="text-black pointer" ng-click="help($event)" ng-if="isAuthorized(['admin', 'manager'])"><i class="fa fa-question-circle"></i> <span translate>{{ 'app.public.common.help' }}</span> </a></li>
|
||||
|
@ -1,24 +1,16 @@
|
||||
<section class="heading b-b">
|
||||
<div class="row no-gutter">
|
||||
<div class="col-xs-2 col-sm-2 col-md-1">
|
||||
<section class="heading-btn">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-xs-10 col-sm-10 col-md-8 b-l b-r-md">
|
||||
<section class="heading-title">
|
||||
<h1 translate>{{ 'app.public.store.fablab_store' }}</h1>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 col-sm-12 col-md-3 b-t hide-b-md">
|
||||
<section class="heading-actions wrapper">
|
||||
<cart-button />
|
||||
</section>
|
||||
</div>
|
||||
<div class="header-page">
|
||||
<div class="back">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="center">
|
||||
<h1 translate>{{ 'app.public.store.fablab_store' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<cart-button />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="m-lg">
|
||||
<store current-user="currentUser" on-error="onError" on-success="onSuccess" />
|
||||
|
@ -100,7 +100,7 @@ en:
|
||||
delete_this_and_next: "This slot and the following"
|
||||
delete_all: "All slots"
|
||||
event_in_the_past: "Create a slot in the past"
|
||||
confirm_create_event_in_the_past: "You are about to create a slot in the past. Are you sure you want to do this? Members will not be able to book this slot."
|
||||
confirm_create_event_in_the_past: "You are about to create a slot in the past. Are you sure you want to do this? Members on the store? will not be able to book this slot."
|
||||
edit_event: "Edit the event"
|
||||
view_reservations: "View reservations"
|
||||
legend: "Legend"
|
||||
@ -1933,17 +1933,45 @@ en:
|
||||
create_a_product: "Create a product"
|
||||
successfully_deleted: "The product has been successfully deleted"
|
||||
unable_to_delete: "Unable to delete the product: "
|
||||
filter: "Filter"
|
||||
filter_clear: "Clear all"
|
||||
filter_apply: "Apply"
|
||||
filter_categories: "By categories"
|
||||
filter_machines: "By machines"
|
||||
filter_keywords_reference: "By keywords or reference"
|
||||
filter_stock: "By stock status"
|
||||
filter_stock_from: "From"
|
||||
filter_stock_to: "to"
|
||||
sort:
|
||||
name_az: "A-Z"
|
||||
name_za: "Z-A"
|
||||
price_low: "Price: low to high"
|
||||
price_high: "Price: high to low"
|
||||
store_list_header:
|
||||
result_count: "Result count:"
|
||||
display_options: "Display options:"
|
||||
visible_only: "Visible products only"
|
||||
product_item:
|
||||
visible: "visible"
|
||||
hidden: "hidden"
|
||||
stock:
|
||||
internal: "Private stock"
|
||||
external: "Public stock"
|
||||
unit: "unit"
|
||||
new_product:
|
||||
add_a_new_product: "Add a new product"
|
||||
successfully_created: "The new product has been created."
|
||||
edit_product:
|
||||
successfully_updated: "The product has been updated."
|
||||
product_form:
|
||||
product_parameters: "Product parameters"
|
||||
stock_management: "Stock management"
|
||||
name: "Name of product"
|
||||
sku: "Reference product (SKU)"
|
||||
slug: "Name of URL"
|
||||
is_show_in_store: "Available in the store"
|
||||
is_active_price: "Activate the price"
|
||||
active_price_info: "Is this product visible by the members on the store?"
|
||||
price_and_rule_of_selling_product: "Price and rule for selling the product"
|
||||
price: "Price of product"
|
||||
quantity_min: "Minimum number of items for the shopping cart"
|
||||
@ -1961,3 +1989,86 @@ en:
|
||||
product_images_info: "<strong>Advice</strong></br>We advise you to use a square format, jpg or png, for jpgs, please use white for the background colour. The main visual will be the visual presented first in the product sheet."
|
||||
add_product_image: "Add an image"
|
||||
save: "Save"
|
||||
product_stock_form:
|
||||
low_stock_threshold: "Define a low stock threshold"
|
||||
stock_threshold_toggle: "Activate stock threshold"
|
||||
stock_threshold_information: "<strong>Information</strong></br>Define a low stock threshold and receive a notification when it's reached.<br>Above the threshold, the product is available in the store. When the threshold is reached, the product quantity is labeled as low."
|
||||
low_stock: "Low stock"
|
||||
threshold_level: "Minimum threshold level"
|
||||
threshold_alert: "Notify me when the threshold is reached"
|
||||
event_type: "Events:"
|
||||
stocks: "Stocks:"
|
||||
internal: "Private stock"
|
||||
external: "Public stock"
|
||||
all: "All types"
|
||||
stock_level: "Stock level"
|
||||
events:
|
||||
inward_stock: "Inward stock"
|
||||
returned: "Returned by client"
|
||||
canceled: "Canceled by client"
|
||||
sold: "Sold"
|
||||
missing: "Missing in stock"
|
||||
damaged: "Damaged product"
|
||||
events_history: "Events history"
|
||||
modal_title: "Manage stock"
|
||||
product_stock_modal:
|
||||
internal: "Private stock"
|
||||
external: "Public stock"
|
||||
new_event: "New stock event"
|
||||
addition: "Addition"
|
||||
withdrawal: "Withdrawal"
|
||||
update_stock: "Update stock"
|
||||
event_type: "Events:"
|
||||
stocks: "Stocks:"
|
||||
quantity: "Quantity"
|
||||
events:
|
||||
inward_stock: "Inward stock"
|
||||
returned: "Returned by client"
|
||||
canceled: "Canceled by client"
|
||||
sold: "Sold"
|
||||
missing: "Missing in stock"
|
||||
damaged: "Damaged product"
|
||||
orders:
|
||||
heading: "Orders"
|
||||
create_order: "Create an order"
|
||||
filter: "Filter"
|
||||
filter_clear: "Clear all"
|
||||
filter_apply: "Apply"
|
||||
filter_ref: "By reference"
|
||||
filter_status: "By status"
|
||||
filter_client: "By client"
|
||||
status:
|
||||
error: "Payment error"
|
||||
canceled: "Canceled"
|
||||
pending: "Pending payment"
|
||||
under_preparation: "Under preparation"
|
||||
paid: "Paid"
|
||||
ready: "Ready"
|
||||
collected: "Collected"
|
||||
refunded: "Refunded"
|
||||
sort:
|
||||
newest: "Newest first"
|
||||
oldest: "Oldest first"
|
||||
order_item:
|
||||
total: "Total"
|
||||
client: "Client"
|
||||
show_order:
|
||||
see_invoice: "See invoice"
|
||||
client: "Client"
|
||||
created_at: "Creation date"
|
||||
last_update: "Last update"
|
||||
cart: "Cart"
|
||||
reference_short: "ref:"
|
||||
unit: "Unit"
|
||||
item_total: "Total"
|
||||
payment_informations : "Payment informations"
|
||||
amount: "Amount"
|
||||
products_total: "Products total"
|
||||
gift_total: "Discount total"
|
||||
coupon: "Coupon"
|
||||
cart_total: "Cart total"
|
||||
store_settings:
|
||||
title: 'Settings'
|
||||
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"
|
@ -22,6 +22,7 @@ en:
|
||||
my_events: "My Events"
|
||||
my_invoices: "My Invoices"
|
||||
my_payment_schedules: "My payment schedules"
|
||||
my_orders: "My orders"
|
||||
my_wallet: "My Wallet"
|
||||
#contextual help
|
||||
help: "Help"
|
||||
@ -219,6 +220,11 @@ en:
|
||||
new_availability: "Open reservations"
|
||||
book: "Book"
|
||||
_or_the_: " or the "
|
||||
store_ad:
|
||||
title: "Discover our store"
|
||||
buy: "Check out products from members' projects along with consumable related to the different machines and tools of the workshop."
|
||||
sell: "If you also want to sell your creations, please let us know."
|
||||
link: "To the store"
|
||||
machines_filters:
|
||||
show_machines: "Show machines"
|
||||
status_enabled: "Enabled"
|
||||
@ -378,13 +384,33 @@ en:
|
||||
store:
|
||||
fablab_store: "FabLab Store"
|
||||
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
|
||||
add_to_cart_success: "Product added to the cart."
|
||||
products:
|
||||
all_products: "All the products"
|
||||
filter: "Filter"
|
||||
filter_clear: "Clear all"
|
||||
filter_apply: "Apply"
|
||||
filter_categories: "Categories"
|
||||
filter_machines: "By machines"
|
||||
filter_keywords_reference: "By keywords or reference"
|
||||
in_stock_only: "Available products only"
|
||||
sort:
|
||||
name_az: "A-Z"
|
||||
name_za: "Z-A"
|
||||
price_low: "Price: low to high"
|
||||
price_high: "Price: high to low"
|
||||
store_product:
|
||||
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
|
||||
show_more: "Display more"
|
||||
show_less: "Display less"
|
||||
documentation: "Documentation"
|
||||
store_product_item:
|
||||
available: "Available"
|
||||
limited_stock: "Limited stock"
|
||||
out_of_stock: "Out of stock"
|
||||
add: "Add"
|
||||
store_product:
|
||||
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
|
||||
add_to_cart: "Add to cart"
|
||||
unit: "unit"
|
||||
cart:
|
||||
my_cart: "My Cart"
|
||||
cart_button:
|
||||
@ -392,6 +418,18 @@ en:
|
||||
store_cart:
|
||||
checkout: "Checkout"
|
||||
cart_is_empty: "Your cart is empty"
|
||||
pickup: "Pickup your products"
|
||||
reference_short: "ref:"
|
||||
unit: "Unit"
|
||||
total: "Total"
|
||||
checkout_header: "Total amount for your cart"
|
||||
checkout_products_COUNT: "Your cart contains {COUNT} {COUNT, plural, =1{product} other{products}}"
|
||||
checkout_products_total: "Products total"
|
||||
checkout_gift_total: "Discount total"
|
||||
checkout_coupon: "Coupon"
|
||||
checkout_total: "Cart total"
|
||||
checkout_error: "An unexpected error occurred. Please contact the administrator."
|
||||
checkout_success: "Purchase confirmed. Thanks!"
|
||||
member_select:
|
||||
select_a_member: "Select a member"
|
||||
start_typing: "Start typing..."
|
||||
|
Loading…
x
Reference in New Issue
Block a user