1
0
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:
Du Peng 2022-09-07 09:27:57 +02:00
commit 79f01d4f78
82 changed files with 4110 additions and 1232 deletions

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

View File

@ -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}

View File

@ -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 &&
(<>

View File

@ -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 = () => {

View File

@ -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']));

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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} />
} />

View File

@ -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} />
} />

View File

@ -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}

View File

@ -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>
);
};

View File

@ -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} />);
}

View File

@ -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>

View File

@ -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
};

View File

@ -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);

View 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>
);
};

View File

@ -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']));

View 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
};

View File

@ -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>
</>
);

View 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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
))}
</>
);
};

View File

@ -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
}
};

View 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']));

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
}

View File

@ -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']));

View File

@ -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[]
}

View File

@ -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}

View 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();
}
]);

View File

@ -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',

View File

@ -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";

View File

@ -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));
}
}

View 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);
}
}

View 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;
}
}
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View 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);
}
}

View File

@ -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;

View File

@ -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);
}
}
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -1,3 +0,0 @@
.manage-product-category {
}

View File

@ -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;
}
}

View 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%;
}
}

View File

@ -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); }
}
}
}

View File

@ -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; }
}
}

View File

@ -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); }
}
}
}

View File

@ -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;
}
}
}

View File

@ -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; }
}
}
}

View 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) }
}
}
}
}

View File

@ -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; }
}

View 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;
}
}
}
}
}

View File

@ -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;
}
}
}
}
}

View 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;
}
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}
}

View File

@ -12,7 +12,7 @@
.file-item {
&.has-error {
color: var(--error);
color: var(--alert);
}
label {

View File

@ -16,7 +16,7 @@
}
.missing-file {
color: var(--error);
color: var(--alert);
}
}

View File

@ -23,7 +23,7 @@
}
}
.delete-avatar {
background-color: var(--error);
background-color: var(--alert);
color: white;
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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;

View 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>

View File

@ -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">

View File

@ -1 +1 @@
<h2>Orders page</h2>
<orders on-success="onSuccess" on-error="onError"/>

View File

@ -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>

View File

@ -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>

View File

@ -1 +1 @@
<h2>Settings page</h2>
<store-settings on-success="onSuccess" on-error="onError"/>

View File

@ -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" />

View File

@ -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>

View 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>

View File

@ -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" />

View File

@ -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>

View File

@ -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" />

View File

@ -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"

View File

@ -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..."