1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-17 11:54:22 +01:00

Merge branch 'product_store-store' into product-store

This commit is contained in:
Du Peng 2022-09-07 15:00:27 +02:00
commit 79182fb8c1
205 changed files with 8950 additions and 187 deletions

View File

@ -145,3 +145,5 @@ gem 'tzinfo-data'
gem 'sassc', '= 2.1.0'
gem 'redis-session-store'
gem 'acts_as_list'

View File

@ -48,6 +48,8 @@ GEM
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
acts_as_list (1.0.4)
activerecord (>= 4.2)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
aes_key_wrap (1.1.0)
@ -500,6 +502,7 @@ DEPENDENCIES
aasm
actionpack-page_caching (= 1.2.2)
active_record_query_trace
acts_as_list
api-pagination
apipie-rails
awesome_print

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
# API Controller for manage user's cart
class API::CartController < API::ApiController
include API::OrderConcern
before_action :current_order, except: %i[create]
before_action :ensure_order, except: %i[create]
def create
authorize :cart, :create?
@order ||= Cart::FindOrCreateService.new(current_user).call(order_token)
render 'api/orders/show'
end
def add_item
authorize @current_order, policy_class: CartPolicy
@order = Cart::AddItemService.new.call(@current_order, orderable, cart_params[:quantity])
render 'api/orders/show'
end
def remove_item
authorize @current_order, policy_class: CartPolicy
@order = Cart::RemoveItemService.new.call(@current_order, orderable)
render 'api/orders/show'
end
def set_quantity
authorize @current_order, policy_class: CartPolicy
@order = Cart::SetQuantityService.new.call(@current_order, orderable, cart_params[:quantity])
render 'api/orders/show'
end
private
def orderable
Product.find(cart_params[:orderable_id])
end
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
# API Controller for cart checkout
class API::CheckoutController < API::ApiController
include ::API::OrderConcern
before_action :authenticate_user!
before_action :current_order
before_action :ensure_order
def payment
authorize @current_order, policy_class: CheckoutPolicy
if @current_order.statistic_profile_id.nil? && current_user.privileged?
user = User.find(params[:customer_id])
@current_order.statistic_profile = user.statistic_profile
end
res = Checkout::PaymentService.new.payment(@current_order, current_user, params[:coupon_code],
params[:payment_id])
render json: res
rescue StandardError => e
render json: e, status: :unprocessable_entity
end
def confirm_payment
authorize @current_order, policy_class: CheckoutPolicy
res = Checkout::PaymentService.new.confirm_payment(@current_order, current_user, params[:coupon_code], params[:payment_id])
render json: res
rescue StandardError => e
render json: e, status: :unprocessable_entity
end
end

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
# API Controller for resources of type ProductCategory
# ProductCategories are used to group Products
class API::ProductCategoriesController < API::ApiController
before_action :authenticate_user!, except: :index
before_action :set_product_category, only: %i[update destroy position]
def index
@product_categories = ProductCategoryService.list
end
def show
@product_category = ProductCategory.friendly.find(params[:id])
end
def create
authorize ProductCategory
@product_category = ProductCategory.new(product_category_params)
if @product_category.save
render status: :created
else
render json: @product_category.errors.full_messages, status: :unprocessable_entity
end
end
def update
authorize @product_category
if @product_category.update(product_category_params)
render status: :ok
else
render json: @product_category.errors.full_messages, status: :unprocessable_entity
end
end
def position
authorize @product_category
if @product_category.insert_at(params[:position])
render :show
else
render json: @product_category.errors.full_messages, status: :unprocessable_entity
end
end
def destroy
authorize @product_category
ProductCategoryService.destroy(@product_category)
head :no_content
end
private
def set_product_category
@product_category = ProductCategory.find(params[:id])
end
def product_category_params
params.require(:product_category).permit(:name, :parent_id, :slug)
end
end

View File

@ -0,0 +1,61 @@
# frozen_string_literal: true
# API Controller for resources of type Product
# Products are used in store
class API::ProductsController < API::ApiController
before_action :authenticate_user!, except: %i[index show]
before_action :set_product, only: %i[update destroy]
def index
@products = ProductService.list(params)
end
def show
@product = Product.includes(:product_images, :product_files).friendly.find(params[:id])
end
def create
authorize Product
@product = Product.new(product_params)
@product.amount = ProductService.amount_multiplied_by_hundred(@product.amount)
if @product.save
render status: :created
else
render json: @product.errors.full_messages, status: :unprocessable_entity
end
end
def update
authorize @product
product_parameters = product_params
product_parameters[:amount] = ProductService.amount_multiplied_by_hundred(product_parameters[:amount])
if @product.update(product_parameters)
render status: :ok
else
render json: @product.errors.full_messages, status: :unprocessable_entity
end
end
def destroy
authorize @product
@product.destroy
head :no_content
end
private
def set_product
@product = Product.find(params[:id])
end
def product_params
params.require(:product).permit(:name, :slug, :sku, :description, :is_active,
:product_category_id, :amount, :quantity_min,
:low_stock_alert, :low_stock_threshold,
machine_ids: [],
product_files_attributes: %i[id attachment _destroy],
product_images_attributes: %i[id attachment is_main _destroy],
product_stock_movements_attributes: %i[id quantity reason stock_type _destroy])
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
# Concern for CartController and CheckoutController
module API::OrderConcern
private
def order_token
request.headers['X-Fablab-Order-Token'] || cart_params[:order_token]
end
def current_order
@current_order = Order.find_by(token: order_token, state: 'cart')
end
def ensure_order
raise ActiveRecord::RecordNotFound if @current_order.nil?
end
def cart_params
params.permit(:order_token, :orderable_id, :quantity, :user_id)
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when the product is out of stock
class Cart::InactiveProductError < StandardError
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when the product is out of stock
class Cart::OutStockError < StandardError
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when order amount = 0
class Cart::ZeroPriceError < StandardError
end

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

@ -0,0 +1,25 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Order } from '../models/order';
export default class CartAPI {
static async create (token?: string): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.post('/api/cart', { order_token: token });
return res?.data;
}
static async addItem (order: Order, orderableId: number, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/add_item', { order_token: order.token, orderable_id: orderableId, quantity });
return res?.data;
}
static async removeItem (order: Order, orderableId: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/remove_item', { order_token: order.token, orderable_id: orderableId });
return res?.data;
}
static async setQuantity (order: Order, orderableId: number, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_quantity', { order_token: order.token, orderable_id: orderableId, quantity });
return res?.data;
}
}

View File

@ -0,0 +1,25 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { OrderPayment, Order } from '../models/order';
export default class CheckoutAPI {
static async payment (order: Order, paymentId?: string): Promise<OrderPayment> {
const res: AxiosResponse<OrderPayment> = await apiClient.post('/api/checkout/payment', {
order_token: order.token,
coupon_code: order.coupon?.code,
payment_id: paymentId,
customer_id: order.user.id
});
return res?.data;
}
static async confirmPayment (order: Order, paymentId: string): Promise<OrderPayment> {
const res: AxiosResponse<OrderPayment> = await apiClient.post('/api/checkout/confirm_payment', {
order_token: order.token,
coupon_code: order.coupon?.code,
payment_id: paymentId,
customer_id: order.user.id
});
return res?.data;
}
}

View File

@ -0,0 +1,10 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Coupon } from '../models/coupon';
export default class CouponAPI {
static async validate (code: string, amount: number, userId?: number): Promise<Coupon> {
const res: AxiosResponse<Coupon> = await apiClient.post('/api/coupons/validate', { code, amount, user_id: userId });
return res?.data;
}
}

View File

@ -9,6 +9,16 @@ export default class MemberAPI {
return res?.data;
}
static async search (name: string): Promise<Array<User>> {
const res: AxiosResponse<Array<User>> = await apiClient.get(`/api/members/search/${name}`);
return res?.data;
}
static async get (id: number): Promise<User> {
const res: AxiosResponse<User> = await apiClient.get(`/api/members/${id}`);
return res?.data;
}
static async create (user: User): Promise<User> {
const data = serialize({ user });
if (user.profile_attributes?.user_avatar_attributes?.attachment_files[0]) {

View File

@ -0,0 +1,35 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ProductCategory } from '../models/product-category';
export default class ProductCategoryAPI {
static async index (): Promise<Array<ProductCategory>> {
const res: AxiosResponse<Array<ProductCategory>> = await apiClient.get('/api/product_categories');
return res?.data;
}
static async get (id: number): Promise<ProductCategory> {
const res: AxiosResponse<ProductCategory> = await apiClient.get(`/api/product_categories/${id}`);
return res?.data;
}
static async create (productCategory: ProductCategory): Promise<ProductCategory> {
const res: AxiosResponse<ProductCategory> = await apiClient.post('/api/product_categories', { product_category: productCategory });
return res?.data;
}
static async update (productCategory: ProductCategory): Promise<ProductCategory> {
const res: AxiosResponse<ProductCategory> = await apiClient.patch(`/api/product_categories/${productCategory.id}`, { product_category: productCategory });
return res?.data;
}
static async destroy (productCategoryId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/product_categories/${productCategoryId}`);
return res?.data;
}
static async updatePosition (productCategory: ProductCategory, position: number): Promise<ProductCategory> {
const res: AxiosResponse<ProductCategory> = await apiClient.patch(`/api/product_categories/${productCategory.id}/position`, { position });
return res?.data;
}
}

View File

@ -0,0 +1,92 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import { Product, ProductIndexFilter } from '../models/product';
import ApiLib from '../lib/api';
export default class ProductAPI {
static async index (filters?: ProductIndexFilter): Promise<Array<Product>> {
const res: AxiosResponse<Array<Product>> = await apiClient.get(`/api/products${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number | string): Promise<Product> {
const res: AxiosResponse<Product> = await apiClient.get(`/api/products/${id}`);
return res?.data;
}
static async create (product: Product): Promise<Product> {
const data = serialize({
product: {
...product,
product_files_attributes: null,
product_images_attributes: null
}
});
data.delete('product[product_files_attributes]');
data.delete('product[product_images_attributes]');
product.product_files_attributes?.forEach((file, i) => {
if (file?.attachment_files && file?.attachment_files[0]) {
data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]);
}
});
product.product_images_attributes?.forEach((image, i) => {
if (image?.attachment_files && image?.attachment_files[0]) {
data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]);
data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString());
}
});
const res: AxiosResponse<Product> = await apiClient.post('/api/products', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async update (product: Product): Promise<Product> {
const data = serialize({
product: {
...product,
product_files_attributes: null,
product_images_attributes: null
}
});
data.delete('product[product_files_attributes]');
data.delete('product[product_images_attributes]');
product.product_files_attributes?.forEach((file, i) => {
if (file?.attachment_files && file?.attachment_files[0]) {
data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]);
}
if (file?.id) {
data.set(`product[product_files_attributes][${i}][id]`, file.id.toString());
}
if (file?._destroy) {
data.set(`product[product_files_attributes][${i}][_destroy]`, file._destroy.toString());
}
});
product.product_images_attributes?.forEach((image, i) => {
if (image?.attachment_files && image?.attachment_files[0]) {
data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]);
}
if (image?.id) {
data.set(`product[product_images_attributes][${i}][id]`, image.id.toString());
}
if (image?._destroy) {
data.set(`product[product_images_attributes][${i}][_destroy]`, image._destroy.toString());
}
data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString());
});
const res: AxiosResponse<Product> = await apiClient.patch(`/api/products/${product.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async destroy (productId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/products/${productId}`);
return res?.data;
}
}

View File

@ -36,11 +36,9 @@ export const FabInput: React.FC<FabInputProps> = ({ id, onChange, defaultValue,
* If the default value changes, update the value of the input until there's no content in it.
*/
useEffect(() => {
if (!inputValue) {
setInputValue(defaultValue);
if (typeof onChange === 'function') {
onChange(defaultValue);
}
setInputValue(defaultValue);
if (typeof onChange === 'function') {
onChange(defaultValue);
}
}, [defaultValue]);

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

@ -0,0 +1,45 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { Order } from '../../models/order';
import { useCustomEventListener } from 'react-custom-events';
declare const Application: IApplication;
/**
* This component shows my cart button
*/
const CartButton: React.FC = () => {
const { t } = useTranslation('public');
const [cart, setCart] = useState<Order>();
useCustomEventListener<Order>('CartUpdate', (data) => {
setCart(data);
});
/**
* Goto cart page
*/
const showCart = () => {
window.location.href = '/#!/cart';
};
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 = () => {
return (
<Loader>
<CartButton />
</Loader>
);
};
Application.Components.component('cartButton', react2angular(CartButtonWrapper));

View File

@ -0,0 +1,253 @@
import React, { useState } from 'react';
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 useCart from '../../hooks/use-cart';
import FormatLib from '../../lib/format';
import CartAPI from '../../api/cart';
import { User } from '../../models/user';
import { PaymentModal } from '../payment/stripe/payment-modal';
import { PaymentMethod } from '../../models/payment';
import { Order } from '../../models/order';
import { 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
}
/**
* This component shows user's cart
*/
const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser, userLogin }) => {
const { t } = useTranslation('public');
const { cart, setCart } = useCart(currentUser);
const [paymentModal, setPaymentModal] = useState<boolean>(false);
/**
* Remove the product from cart
*/
const removeProductFromCart = (item) => {
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
CartAPI.removeItem(cart, item.orderable_id).then(data => {
setCart(data);
}).catch(onError);
};
};
/**
* Change product quantity
*/
const changeProductQuantity = (item) => {
return (e: React.BaseSyntheticEvent) => {
CartAPI.setQuantity(cart, item.orderable_id, e.target.value).then(data => {
setCart(data);
}).catch(onError);
};
};
/**
* Checkout cart
*/
const checkout = () => {
if (!currentUser) {
userLogin();
} else {
setPaymentModal(true);
}
};
/**
* Open/closes the payment modal
*/
const togglePaymentModal = (): void => {
setPaymentModal(!paymentModal);
};
/**
* 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(t('app.public.store_cart.checkout_error'));
}
};
/**
* Change cart's customer by admin/manger
*/
const handleChangeMember = (userId: number): void => {
setCart({ ...cart, user: { id: userId, role: 'member' } });
};
/**
* Check if the current operator has administrative rights or is a normal member
*/
const isPrivileged = (): boolean => {
return (currentUser?.role === 'admin' || currentUser?.role === 'manager');
};
/**
* Check if the current cart is empty ?
*/
const cartIsEmpty = (): boolean => {
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
*/
const applyCoupon = (coupon?: Coupon): void => {
if (coupon !== cart.coupon) {
setCart({ ...cart, coupon });
}
};
/**
* 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'>
<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 &&
<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}>
{t('app.public.store_cart.checkout')}
</FabButton>
</>}
</aside>
{cart && !cartIsEmpty() && cart.user && <div>
<PaymentModal isOpen={paymentModal}
toggleModal={togglePaymentModal}
afterSuccess={handlePaymentSuccess}
onError={onError}
cart={{ customer_id: cart.user.id, items: [], payment_method: PaymentMethod.Card }}
order={cart}
operator={currentUser}
customer={cart.user as User}
updateCart={() => 'dont need update shopping cart'} />
</div>}
</div>
);
};
const StoreCartWrapper: React.FC<StoreCartProps> = (props) => {
return (
<Loader>
<StoreCart {...props} />
</Loader>
);
};
Application.Components.component('storeCart', react2angular(StoreCartWrapper, ['onSuccess', 'onError', 'currentUser', 'userLogin']));

View File

@ -0,0 +1,100 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabInput } from '../base/fab-input';
import { FabAlert } from '../base/fab-alert';
import CouponAPI from '../../api/coupon';
import { Coupon } from '../../models/coupon';
import { User } from '../../models/user';
import FormatLib from '../../lib/format';
interface CouponInputProps {
amount: number,
user?: User,
onChange?: (coupon?: Coupon) => void
}
interface Message {
type: 'info' | 'warning' | 'danger',
message: string
}
/**
* This component renders an input of coupon
*/
export const CouponInput: React.FC<CouponInputProps> = ({ user, amount, onChange }) => {
const { t } = useTranslation('shared');
const [messages, setMessages] = useState<Array<Message>>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<boolean>(false);
const [coupon, setCoupon] = useState<Coupon>();
/**
* callback for validate the code
*/
const handleChange = (value: string) => {
const mgs = [];
setMessages([]);
setError(false);
setCoupon(null);
if (value) {
setLoading(true);
CouponAPI.validate(value, amount, user?.id).then((res) => {
setCoupon(res);
if (res.type === 'percent_off') {
mgs.push({ type: 'info', message: t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_PERCENT_discount', { PERCENT: res.percent_off }) });
} else {
mgs.push({ type: 'info', message: t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_AMOUNT_CURRENCY', { AMOUNT: res.amount_off, CURRENCY: FormatLib.currencySymbol() }) });
}
if (res.validity_per_user === 'once') {
mgs.push({ type: 'warning', message: t('app.shared.coupon_input.coupon_validity_once') });
}
setMessages(mgs);
setLoading(false);
if (typeof onChange === 'function') {
onChange(res);
}
}).catch((err) => {
const state = err.split(':')[1].trim();
setError(true);
setCoupon(null);
setLoading(false);
setMessages([{ type: 'danger', message: t(`app.shared.coupon_input.unable_to_apply_the_coupon_because_${state}`) }]);
onChange(null);
});
} else {
onChange(null);
}
};
// input addon
const inputAddOn = () => {
if (error) {
return <i className="fa fa-times" />;
} else {
if (loading) {
return <i className="fa fa-spinner fa-pulse fa-fw" />;
}
if (coupon) {
return <i className="fa fa-check" />;
}
}
};
return (
<div className="coupon-input">
<label htmlFor="coupon-input_input">{t('app.shared.coupon_input.i_have_a_coupon')}</label>
<FabInput id="coupon-input_input"
type="text"
addOn={inputAddOn()}
debounce={500}
onChange={handleChange} />
{messages.map((m, i) => {
return (
<FabAlert key={i} level={m.type}>
{m.message}
</FabAlert>
);
})}
</div>
);
};

View File

@ -0,0 +1,109 @@
import React from 'react';
import { Controller, Path, FieldPathValue } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { useTranslation } from 'react-i18next';
import { UnpackNestedValue } from 'react-hook-form/dist/types';
import { FormControlledComponent } from '../../models/form-component';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
import { FabButton } from '../base/fab-button';
/**
* Checklist Option format
*/
export type ChecklistOption<TOptionValue> = { value: TOptionValue, label: string };
interface FormChecklistProps<TFieldValues, TOptionValue, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
defaultValue?: Array<TOptionValue>,
options: Array<ChecklistOption<TOptionValue>>,
onChange?: (values: Array<TOptionValue>) => void,
}
/**
* This component is a template for a checklist component to use within React Hook Form
*/
export const FormChecklist = <TFieldValues extends FieldValues, TOptionValue, TContext extends object>({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormChecklistProps<TFieldValues, TOptionValue, TContext>) => {
const { t } = useTranslation('shared');
/**
* Verify if the provided option is currently ticked
*/
const isChecked = (values: Array<TOptionValue>, option: ChecklistOption<TOptionValue>): boolean => {
return !!values?.includes(option.value);
};
/**
* Callback triggered when a checkbox is ticked or unticked.
*/
const toggleCheckbox = (option: ChecklistOption<TOptionValue>, rhfValues: Array<TOptionValue> = [], rhfCallback: (value: Array<TOptionValue>) => void) => {
return (event: React.ChangeEvent<HTMLInputElement>) => {
let newValues: Array<TOptionValue> = [];
if (event.target.checked) {
newValues = rhfValues.concat(option.value);
} else {
newValues = rhfValues.filter(v => v !== option.value);
}
rhfCallback(newValues);
if (typeof onChange === 'function') {
onChange(newValues);
}
};
};
/**
* Mark all options as selected
*/
const selectAll = (rhfCallback: (value: Array<TOptionValue>) => void) => {
return () => {
const newValues: Array<TOptionValue> = options.map(o => o.value);
rhfCallback(newValues);
if (typeof onChange === 'function') {
onChange(newValues);
}
};
};
/**
* Mark all options as non-selected
*/
const unselectAll = (rhfCallback: (value: Array<TOptionValue>) => void) => {
return () => {
rhfCallback([]);
if (typeof onChange === 'function') {
onChange([]);
}
};
};
return (
<AbstractFormItem id={id} formState={formState} label={label}
className={`form-checklist form-input ${className || ''}`} tooltip={tooltip}
disabled={disabled}
rules={rules} error={error} warning={warning}>
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={defaultValue as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
rules={rules}
render={({ field: { onChange, value } }) => {
return (
<>
<div className="checklist">
{options.map((option, k) => {
return (
<div key={k} className="checklist-item">
<input id={`option-${k}`} type="checkbox" checked={isChecked(value, option)} onChange={toggleCheckbox(option, value, onChange)} />
<label htmlFor={`option-${k}`}>{option.label}</label>
</div>
);
})}
</div>
<div className="actions">
<FabButton type="button" onClick={selectAll(onChange)} className="is-info">{t('app.shared.form_checklist.select_all')}</FabButton>
<FabButton type="button" onClick={unselectAll(onChange)} className="is-info">{t('app.shared.form_checklist.unselect_all')}</FabButton>
</div>
</>
);
}} />
</AbstractFormItem>
);
};

View File

@ -0,0 +1,123 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Path } from 'react-hook-form';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
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,
attachment_name?: string,
attachment_url?: string
}
interface FormFileUploadProps<TFieldValues> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
setValue: UseFormSetValue<TFieldValues>,
defaultFile?: FileType,
accept?: string,
onFileChange?: (value: FileType) => void,
onFileRemove?: () => void,
}
/**
* This component allows to upload file, in forms managed by react-hook-form.
*/
export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps<TFieldValues>) => {
const { t } = useTranslation('shared');
const [file, setFile] = useState<FileType>(defaultFile);
/**
* Check if file is selected
*/
const hasFile = (): boolean => {
return !!file?.attachment_name;
};
/**
* Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled).
*/
function onFileSelected (event: React.ChangeEvent<HTMLInputElement>) {
const f = event.target?.files[0];
if (f) {
setFile({
attachment_name: f.name
});
setValue(
`${id}[_destroy]` as Path<TFieldValues>,
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
if (typeof onFileChange === 'function') {
onFileChange({ attachment_name: f.name });
}
}
}
/**
* Callback triggered when the user clicks on the delete button.
*/
function onRemoveFile () {
if (file?.id) {
setValue(
`${id}[_destroy]` as Path<TFieldValues>,
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
}
setValue(
`${id}[attachment_files]` as Path<TFieldValues>,
null as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
setFile(null);
if (typeof onFileRemove === 'function') {
onFileRemove();
}
}
// Compose classnames from props
const classNames = [
`${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 ${classNames}`}>
{hasFile() && (
<span>{file.attachment_name}</span>
)}
<div className="actions">
{file?.id && file?.attachment_url && (
<a href={file.attachment_url}
target="_blank"
className="fab-button"
rel="noreferrer">
<FilePdf size={24} />
</a>
)}
<FormInput type="file"
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

@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Path } from 'react-hook-form';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
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 noImage from '../../../../images/no_image.png';
import { Trash } from 'phosphor-react';
export interface ImageType {
id?: number,
attachment_name?: string,
attachment_url?: string,
is_main?: boolean
}
interface FormImageUploadProps<TFieldValues> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
setValue: UseFormSetValue<TFieldValues>,
defaultImage?: ImageType,
accept?: string,
size?: 'small' | 'medium' | 'large',
mainOption?: boolean,
onFileChange?: (value: ImageType) => void,
onFileRemove?: () => void,
onFileIsMain?: () => void,
}
/**
* This component allows to upload image, in forms managed by react-hook-form.
*/
export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size, onFileIsMain, mainOption = false }: FormImageUploadProps<TFieldValues>) => {
const { t } = useTranslation('shared');
const [file, setFile] = useState<ImageType>(defaultImage);
const [image, setImage] = useState<string | ArrayBuffer>(defaultImage.attachment_url);
useEffect(() => {
setFile(defaultImage);
}, [defaultImage]);
/**
* Check if image is selected
*/
const hasImage = (): boolean => {
return !!file?.attachment_name;
};
/**
* Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled).
*/
function onFileSelected (event: React.ChangeEvent<HTMLInputElement>) {
const f = event.target?.files[0];
if (f) {
const reader = new FileReader();
reader.onload = (): void => {
setImage(reader.result);
};
reader.readAsDataURL(f);
setFile({
...file,
attachment_name: f.name
});
setValue(
`${id}[attachment_name]` as Path<TFieldValues>,
f.name as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
setValue(
`${id}[_destroy]` as Path<TFieldValues>,
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
if (typeof onFileChange === 'function') {
onFileChange({ attachment_name: f.name });
}
}
}
/**
* Callback triggered when the user clicks on the delete button.
*/
function onRemoveFile () {
if (file?.id) {
setValue(
`${id}[_destroy]` as Path<TFieldValues>,
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
}
setValue(
`${id}[attachment_files]` as Path<TFieldValues>,
null as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
setFile(null);
if (typeof onFileRemove === 'function') {
onFileRemove();
}
}
/**
* 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
*/
function setMainImage () {
setValue(
`${id}[is_main]` as Path<TFieldValues>,
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
onFileIsMain();
}
// Compose classnames from props
const classNames = [
`${className || ''}`
].join(' ');
return (
<div className={`form-image-upload form-image-upload--${size} ${classNames}`}>
<div className={`image image--${size}`}>
<img src={image || noImage} />
</div>
<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>
</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

@ -17,16 +17,19 @@ import { GoogleTagManager } from '../../models/gtm';
import { ComputePriceResult } from '../../models/price';
import { Wallet } from '../../models/wallet';
import FormatLib from '../../lib/format';
import { Order } from '../../models/order';
import { computePriceWithCoupon } from '../../lib/coupon';
export interface GatewayFormProps {
onSubmit: () => void,
onSuccess: (result: Invoice|PaymentSchedule) => void,
onSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
customer: User,
operator: User,
className?: string,
paymentSchedule?: PaymentSchedule,
cart?: ShoppingCart,
order?: Order,
updateCart?: (cart: ShoppingCart) => void,
formId: string,
}
@ -34,9 +37,10 @@ export interface GatewayFormProps {
interface AbstractPaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
updateCart?: (cart: ShoppingCart) => void,
currentUser: User,
schedule?: PaymentSchedule,
@ -60,7 +64,7 @@ declare const GTM: GoogleTagManager;
* This component must not be called directly but must be extended for each implemented payment gateway.
* @see https://reactjs.org/docs/composition-vs-inheritance.html
*/
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => {
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize, order }) => {
// customer's wallet
const [wallet, setWallet] = useState<Wallet>(null);
// server-computed price with all details
@ -107,16 +111,25 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
* - Refresh the remaining price
*/
useEffect(() => {
if (!cart) return;
WalletAPI.getByUser(cart.customer_id).then((wallet) => {
setWallet(wallet);
PriceAPI.compute(cart).then((res) => {
setPrice(res);
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price));
if (order && order?.user?.id) {
WalletAPI.getByUser(order.user.id).then((wallet) => {
setWallet(wallet);
const p = { price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total };
setPrice(p);
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(p.price));
setReady(true);
});
});
}, [cart]);
} else if (cart && cart.customer_id) {
WalletAPI.getByUser(cart.customer_id).then((wallet) => {
setWallet(wallet);
PriceAPI.compute(cart).then((res) => {
setPrice(res);
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price));
setReady(true);
});
});
}
}, [cart, order]);
/**
* Check if there is currently an error to display
@ -156,7 +169,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
/**
* After sending the form with success, process the resulting payment method
*/
const handleFormSuccess = async (result: Invoice|PaymentSchedule): Promise<void> => {
const handleFormSuccess = async (result: Invoice|PaymentSchedule|Order): Promise<void> => {
setSubmitState(false);
GTM.trackPurchase(result.id, result.total);
afterSuccess(result);
@ -212,6 +225,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
className={`gateway-form ${formClassName || ''}`}
formId={formId}
cart={cart}
order={order}
updateCart={updateCart}
customer={customer}
paymentSchedule={schedule}>

View File

@ -11,15 +11,17 @@ import { Setting } from '../../models/setting';
import { Invoice } from '../../models/invoice';
import SettingAPI from '../../api/setting';
import { useTranslation } from 'react-i18next';
import { Order } from '../../models/order';
declare const Application: IApplication;
interface CardPaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
currentUser: User,
schedule?: PaymentSchedule,
customer: User
@ -29,7 +31,7 @@ interface CardPaymentModalProps {
* This component open a modal dialog for the configured payment gateway, allowing the user to input his card data
* to process an online payment.
*/
const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer }) => {
const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer, order }) => {
const { t } = useTranslation('shared');
const [gateway, setGateway] = useState<Setting>(null);
@ -49,6 +51,7 @@ const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal
afterSuccess={afterSuccess}
onError={onError}
cart={cart}
order={order}
currentUser={currentUser}
schedule={schedule}
customer={customer} />;
@ -63,6 +66,7 @@ const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal
afterSuccess={afterSuccess}
onError={onError}
cart={cart}
order={order}
currentUser={currentUser}
schedule={schedule}
customer={customer} />;
@ -99,4 +103,4 @@ const CardPaymentModalWrapper: React.FC<CardPaymentModalProps> = (props) => {
export { CardPaymentModalWrapper as CardPaymentModal };
Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer']));
Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer', 'order']));

View File

@ -8,6 +8,7 @@ import SettingAPI from '../../../api/setting';
import { CardPaymentModal } from '../card-payment-modal';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { HtmlTranslate } from '../../base/html-translate';
import CheckoutAPI from '../../../api/checkout';
const ALL_SCHEDULE_METHODS = ['card', 'check', 'transfer'] as const;
type scheduleMethod = typeof ALL_SCHEDULE_METHODS[number];
@ -23,7 +24,7 @@ type selectOption = { value: scheduleMethod, label: string };
* This is intended for use by privileged users.
* The form validation button must be created elsewhere, using the attribute form={formId}.
*/
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId }) => {
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId, order }) => {
const { t } = useTranslation('admin');
const [method, setMethod] = useState<scheduleMethod>('check');
@ -85,8 +86,14 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
}
try {
const document = await LocalPaymentAPI.confirmPayment(cart);
onSuccess(document);
let res;
if (order) {
res = await CheckoutAPI.payment(order);
res = res.order;
} else {
res = await LocalPaymentAPI.confirmPayment(cart);
}
onSuccess(res);
} catch (e) {
onError(e);
}
@ -113,6 +120,9 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
* Get the type of the main item in the cart compile
*/
const mainItemType = (): string => {
if (order) {
return '';
}
return Object.keys(cart.items[0])[0];
};

View File

@ -10,15 +10,17 @@ import { ModalSize } from '../../base/fab-modal';
import { Loader } from '../../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../../models/application';
import { Order } from '../../../models/order';
declare const Application: IApplication;
interface LocalPaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
updateCart: (cart: ShoppingCart) => void,
currentUser: User,
schedule?: PaymentSchedule,
@ -28,7 +30,7 @@ interface LocalPaymentModalProps {
/**
* This component enables a privileged user to confirm a local payments.
*/
const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer }) => {
const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, order }) => {
const { t } = useTranslation('admin');
/**
@ -54,7 +56,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
/**
* Integrates the LocalPaymentForm into the parent AbstractPaymentModal
*/
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children }) => {
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children, order }) => {
return (
<LocalPaymentForm onSubmit={onSubmit}
onSuccess={onSuccess}
@ -63,6 +65,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
className={className}
formId={formId}
cart={cart}
order={order}
updateCart={updateCart}
customer={customer}
paymentSchedule={paymentSchedule}>
@ -81,6 +84,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
formClassName="local-payment-form"
currentUser={currentUser}
cart={cart}
order={order}
updateCart={updateCart}
customer={customer}
afterSuccess={afterSuccess}

View File

@ -11,6 +11,8 @@ import {
} from '../../../models/payzen';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { Invoice } from '../../../models/invoice';
import CheckoutAPI from '../../../api/checkout';
import { Order } from '../../../models/order';
// we use these two additional parameters to update the card, if provided
interface PayzenFormProps extends GatewayFormProps {
@ -21,7 +23,7 @@ interface PayzenFormProps extends GatewayFormProps {
* A form component to collect the credit card details and to create the payment method on Stripe.
* The form validation button must be created elsewhere, using the attribute form={formId}.
*/
export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId }) => {
export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId, order }) => {
const PayZenKR = useRef<KryptonClient>(null);
const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader');
@ -43,7 +45,7 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
.catch(error => onError(error));
}).catch(error => onError(error));
});
}, [cart, paymentSchedule, customer]);
}, [cart, paymentSchedule, customer, order]);
/**
* Ask the API to create the form token.
@ -54,6 +56,9 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
return await PayzenAPI.updateToken(paymentSchedule?.id);
} else if (paymentSchedule) {
return await PayzenAPI.chargeCreateToken(cart, customer);
} else if (order) {
const res = await CheckoutAPI.payment(order);
return res.payment as CreateTokenResponse;
} else {
return await PayzenAPI.chargeCreatePayment(cart, customer);
}
@ -87,9 +92,12 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
/**
* Confirm the payment, depending on the current type of payment (single shot or recurring)
*/
const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise<Invoice|PaymentSchedule> => {
const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise<Invoice|PaymentSchedule|Order> => {
if (paymentSchedule) {
return await PayzenAPI.confirmPaymentSchedule(event.clientAnswer.orderDetails.orderId, transaction.uuid, cart);
} else if (order) {
const res = await CheckoutAPI.confirmPayment(order, event.clientAnswer.orderDetails.orderId);
return res.order;
} else {
return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart);
}
@ -131,7 +139,9 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
try {
const { result } = await PayZenKR.current.validateForm();
if (result === null) {
await PayzenAPI.checkCart(cart, customer);
if (!order) {
await PayzenAPI.checkCart(cart, customer);
}
await PayZenKR.current.onSubmit(onPaid);
await PayZenKR.current.onError(handleError);
await PayZenKR.current.submit();

View File

@ -9,13 +9,15 @@ import payzenLogo from '../../../../../images/payzen-secure.png';
import mastercardLogo from '../../../../../images/mastercard.png';
import visaLogo from '../../../../../images/visa.png';
import { PayzenForm } from './payzen-form';
import { Order } from '../../../models/order';
interface PayzenModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
currentUser: User,
schedule?: PaymentSchedule,
customer: User
@ -28,7 +30,7 @@ interface PayzenModalProps {
* This component should not be called directly. Prefer using <CardPaymentModal> which can handle the configuration
* of a different payment gateway.
*/
export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => {
export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => {
/**
* Return the logos, shown in the modal footer.
*/
@ -45,7 +47,7 @@ export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, a
/**
* Integrates the PayzenForm into the parent PaymentModal
*/
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => {
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => {
return (
<PayzenForm onSubmit={onSubmit}
onSuccess={onSuccess}
@ -54,6 +56,7 @@ export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, a
operator={operator}
formId={formId}
cart={cart}
order={order}
className={className}
paymentSchedule={paymentSchedule}>
{children}
@ -70,6 +73,7 @@ export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, a
className="payzen-modal"
currentUser={currentUser}
cart={cart}
order={order}
customer={customer}
afterSuccess={afterSuccess}
onError={onError}

View File

@ -11,13 +11,16 @@ import { LocalPaymentModal } from '../local-payment/local-payment-modal';
import { CardPaymentModal } from '../card-payment-modal';
import PriceAPI from '../../../api/price';
import { ComputePriceResult } from '../../../models/price';
import { Order } from '../../../models/order';
import { computePriceWithCoupon } from '../../../lib/coupon';
interface PaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
updateCart: (cart: ShoppingCart) => void,
operator: User,
schedule?: PaymentSchedule,
@ -27,7 +30,7 @@ interface PaymentModalProps {
/**
* This component is responsible for rendering the payment modal.
*/
export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer }) => {
export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer, order }) => {
// the user's wallet
const [wallet, setWallet] = useState<Wallet>(null);
// the price of the cart
@ -44,10 +47,14 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
// refresh the price when the cart changes
useEffect(() => {
PriceAPI.compute(cart).then(price => {
setPrice(price);
});
}, [cart]);
if (order) {
setPrice({ price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total });
} else {
PriceAPI.compute(cart).then(price => {
setPrice(price);
});
}
}, [cart, order]);
// refresh the remaining price when the cart price was computed and the wallet was retrieved
useEffect(() => {
@ -73,6 +80,7 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
afterSuccess={afterSuccess}
onError={onError}
cart={cart}
order={order}
updateCart={updateCart}
currentUser={operator}
customer={customer}
@ -86,6 +94,7 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
afterSuccess={afterSuccess}
onError={onError}
cart={cart}
order={order}
currentUser={operator}
customer={customer}
schedule={schedule}

View File

@ -6,12 +6,14 @@ import { PaymentConfirmation } from '../../../models/payment';
import StripeAPI from '../../../api/stripe';
import { Invoice } from '../../../models/invoice';
import { PaymentSchedule } from '../../../models/payment-schedule';
import CheckoutAPI from '../../../api/checkout';
import { Order } from '../../../models/order';
/**
* A form component to collect the credit card details and to create the payment method on Stripe.
* The form validation button must be created elsewhere, using the attribute form={formId}.
*/
export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId }) => {
export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId, order }) => {
const { t } = useTranslation('shared');
const stripe = useStripe();
@ -41,9 +43,18 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
} else {
try {
if (!paymentSchedule) {
// process the normal payment pipeline, including SCA validation
const res = await StripeAPI.confirmMethod(paymentMethod.id, cart);
await handleServerConfirmation(res);
if (order) {
const res = await CheckoutAPI.payment(order, paymentMethod.id);
if (res.payment) {
await handleServerConfirmation(res.payment as PaymentConfirmation);
} else {
await handleServerConfirmation(res.order);
}
} else {
// process the normal payment pipeline, including SCA validation
const res = await StripeAPI.confirmMethod(paymentMethod.id, cart);
await handleServerConfirmation(res);
}
} else {
const res = await StripeAPI.setupSubscription(paymentMethod.id, cart);
await handleServerConfirmation(res, paymentMethod.id);
@ -61,7 +72,7 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
* @param paymentMethodId ID of the payment method, required only when confirming a payment schedule
* @see app/controllers/api/stripe_controller.rb#confirm_payment
*/
const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule, paymentMethodId?: string) => {
const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule|Order, paymentMethodId?: string) => {
if ('error' in response) {
if (response.error.statusText) {
onError(response.error.statusText);
@ -78,8 +89,13 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
// The card action has been handled
// The PaymentIntent can be confirmed again on the server
try {
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
await handleServerConfirmation(confirmation);
if (order) {
const confirmation = await CheckoutAPI.confirmPayment(order, result.paymentIntent.id);
await handleServerConfirmation(confirmation.order);
} else {
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
await handleServerConfirmation(confirmation);
}
} catch (e) {
onError(e);
}

View File

@ -10,13 +10,15 @@ import stripeLogo from '../../../../../images/powered_by_stripe.png';
import mastercardLogo from '../../../../../images/mastercard.png';
import visaLogo from '../../../../../images/visa.png';
import { Invoice } from '../../../models/invoice';
import { Order } from '../../../models/order';
interface StripeModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
currentUser: User,
schedule?: PaymentSchedule,
customer: User
@ -29,7 +31,7 @@ interface StripeModalProps {
* This component should not be called directly. Prefer using <CardPaymentModal> which can handle the configuration
* of a different payment gateway.
*/
export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => {
export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => {
/**
* Return the logos, shown in the modal footer.
*/
@ -47,7 +49,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
/**
* Integrates the StripeForm into the parent PaymentModal
*/
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => {
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => {
return (
<StripeElements>
<StripeForm onSubmit={onSubmit}
@ -57,6 +59,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
className={className}
formId={formId}
cart={cart}
order={order}
customer={customer}
paymentSchedule={paymentSchedule}>
{children}
@ -74,6 +77,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
formClassName="stripe-form"
currentUser={currentUser}
cart={cart}
order={order}
customer={customer}
afterSuccess={afterSuccess}
onError={onError}

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

@ -0,0 +1,85 @@
import { PencilSimple, Trash } from 'phosphor-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ProductCategory } from '../../../models/product-category';
import { FabButton } from '../../base/fab-button';
import { FabModal, ModalSize } from '../../base/fab-modal';
import { ProductCategoryForm } from './product-category-form';
interface ManageProductCategoryProps {
action: 'create' | 'update' | 'delete',
productCategories: Array<ProductCategory>,
productCategory?: ProductCategory,
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component shows a button.
* When clicked, we show a modal dialog allowing to fill the parameters of a product category.
*/
export const ManageProductCategory: React.FC<ManageProductCategoryProps> = ({ productCategories, productCategory, action, onSuccess, onError }) => {
const { t } = useTranslation('admin');
// is the modal open?
const [isOpen, setIsOpen] = useState<boolean>(false);
/**
* Opens/closes the product category modal
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* Close the modal if the form submission was successful
*/
const handleSuccess = (message) => {
setIsOpen(false);
onSuccess(message);
};
/**
* Render the appropriate button depending on the action type
*/
const toggleBtn = () => {
switch (action) {
case 'create':
return (
<FabButton type='button'
className="create-button"
onClick={toggleModal}>
{t('app.admin.store.manage_product_category.create')}
</FabButton>
);
case 'update':
return (<FabButton type='button'
icon={<PencilSimple size={20} weight="fill" />}
className="edit-btn"
onClick={toggleModal} />);
case 'delete':
return (<FabButton type='button'
icon={<Trash size={20} weight="fill" />}
className="delete-btn"
onClick={toggleModal} />);
}
};
return (
<div className='manage-product-category'>
{ toggleBtn() }
<FabModal title={t(`app.admin.store.manage_product_category.${action}`)}
className="fab-modal-lg"
width={ModalSize.large}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton>
{ (action === 'update' || action === 'delete') && <p className='subtitle'>{productCategory.name}</p>}
<ProductCategoryForm action={action}
productCategories={productCategories}
productCategory={productCategory}
onSuccess={handleSuccess} onError={onError} />
</FabModal>
</div>
);
};

View File

@ -0,0 +1,80 @@
// TODO: Remove next eslint-disable
/* eslint-disable @typescript-eslint/no-unused-vars */
import React from 'react';
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 { ArrowElbowDownRight, ArrowElbowLeftUp, CaretDown, DotsSixVertical } from 'phosphor-react';
interface ProductCategoriesItemProps {
productCategories: Array<ProductCategory>,
category: ProductCategory,
offset: 'up' | 'down' | null,
collapsed?: boolean,
handleCollapse?: (id: number) => void,
status: 'child' | 'single' | 'parent',
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* Renders a draggable category item
*/
export const ProductCategoriesItem: React.FC<ProductCategoriesItemProps> = ({ productCategories, category, offset, collapsed, handleCollapse, status, onSuccess, onError }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: category.id });
const style = {
transition,
transform: CSS.Transform.toString(transform)
};
return (
<div ref={setNodeRef} style={style}
className={`product-categories-item ${(status === 'child' && collapsed) ? 'is-collapsed' : ''}`}>
{((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='itemInfo'>
{status === 'parent' && <div className='collapse-handle'>
<button className={collapsed || isDragging ? '' : 'rotate'} onClick={() => handleCollapse(category.id)}>
<CaretDown size={16} weight="bold" />
</button>
</div>}
<p className='itemInfo-name'>{category.name}</p>
<span className='itemInfo-count'>[count]</span>
</div>
<div className='actions'>
{!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={20} />
</button>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,343 @@
// TODO: Remove next eslint-disable
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, { useEffect, useState } from 'react';
import { useImmer } from 'use-immer';
import { ProductCategory } from '../../../models/product-category';
import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { restrictToWindowEdges } from '@dnd-kit/modifiers';
import { ProductCategoriesItem } from './product-categories-item';
interface ProductCategoriesTreeProps {
productCategories: Array<ProductCategory>,
onDnd: (list: Array<ProductCategory>) => void,
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component shows a tree list of all Product's Categories
*/
export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ productCategories, onDnd, onSuccess, onError }) => {
const [categoriesList, setCategoriesList] = useImmer<ProductCategory[]>(productCategories);
const [activeData, setActiveData] = useImmer<ActiveData>(initActiveData);
const [extractedChildren, setExtractedChildren] = useImmer({});
const [collapsed, setCollapsed] = useImmer<number[]>([]);
// Initialize state from props
useEffect(() => {
setCategoriesList(productCategories);
}, [productCategories]);
// @dnd-kit config
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
);
/**
* On drag start
* Collect dragged items' data
* Extract children from list
*/
const handleDragStart = ({ active }: DragMoveEvent) => {
const activeIndex = active.data.current.sortable.index;
const children = getChildren(active.id);
setActiveData(draft => {
draft.index = activeIndex;
draft.category = getCategory(active.id);
draft.status = getStatus(active.id);
draft.children = children?.length ? children : null;
});
setExtractedChildren(draft => { draft[active.id] = children; });
hideChildren(active.id, activeIndex);
};
/**
* On drag move
*/
const handleDragMove = ({ delta, active, over }: DragMoveEvent) => {
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 {
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 };
});
}
}
};
/**
* On drag End
* Insert children back in list
*/
const handleDragEnd = ({ active, over }: DragMoveEvent) => {
let newOrder = [...categoriesList];
const currentIdsOrder = over?.data.current.sortable.items;
let newIndex = over.data.current.sortable.index;
// [A] Single |> [B] Single
if (getStatus(active.id) === 'single' && getStatus(over.id) === 'single') {
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => {
let category = getCategory(sortedId);
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;
});
}
// [A] Child |> [B] Single
if ((getStatus(active.id) === 'child') && getStatus(over.id) === 'single') {
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => {
let category = getCategory(sortedId);
if (activeData.offset === 'down' && sortedId === active.id && activeData.index < newIndex) {
category = { ...category, parent_id: Number(over.id) };
} 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;
});
}
// [A] Single || Child |>…
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) {
newOrder = newIdsOrder.map(sortedId => {
let category = getCategory(sortedId);
if (sortedId === active.id) {
category = { ...category, parent_id: Number(over.id) };
}
return category;
});
} else if (activeData.index > newIndex) {
newOrder = newIdsOrder.map(sortedId => {
let category = getCategory(sortedId);
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;
});
}
}
// [B] Child
if (getStatus(over.id) === 'child') {
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => {
let category = getCategory(sortedId);
if (sortedId === active.id) {
category = { ...category, parent_id: getCategory(over.id).parent_id };
}
return category;
});
}
}
// [A] Parent |>…
if (getStatus(active.id) === 'parent') {
// [B] Single
if (getStatus(over.id) === 'single') {
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
}
// [B] Parent
if (getStatus(over.id) === 'parent') {
if (activeData.index < newIndex) {
const lastOverChildIndex = newOrder.findIndex(c => c.id === getChildren(over.id).pop().id);
newIndex = lastOverChildIndex;
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
} else {
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
}
}
// [B] Child
if (getStatus(over.id) === 'child') {
if (activeData.index < newIndex) {
const parent = newOrder.find(c => c.id === getCategory(over.id).parent_id);
const lastSiblingIndex = newOrder.findIndex(c => c.id === getChildren(parent.id).pop().id);
newIndex = lastSiblingIndex;
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
} else {
const parentIndex = currentIdsOrder.indexOf(getCategory(over.id).parent_id);
newIndex = parentIndex;
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
}
}
// insert children back
newOrder = showChildren(active.id, newOrder, newIndex);
}
setActiveData(initActiveData);
onDnd(newOrder);
};
/**
* On drag cancel
* Reset states
*/
const handleDragCancel = ({ active }: DragMoveEvent) => {
setCategoriesList(productCategories);
setActiveData(initActiveData);
setExtractedChildren({ ...extractedChildren, [active.id]: null });
};
/**
* Get a category by its id
*/
const getCategory = (id) => {
return categoriesList.find(c => c.id === id);
};
/**
* Get the children categories of a parent category by its id
*/
const getChildren = (id) => {
const displayedChildren = categoriesList.filter(c => c.parent_id === id);
if (displayedChildren.length) {
return displayedChildren;
}
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
*/
const getStatus = (id) => {
const c = getCategory(id);
return !c.parent_id
? getChildren(id)?.length
? 'parent'
: 'single'
: 'child';
};
/**
* Extract children from the list by their parent's id
*/
const hideChildren = (parentId, parentIndex) => {
const children = getChildren(parentId);
if (children?.length) {
const shortenList = [...categoriesList];
shortenList.splice(parentIndex + 1, children.length);
setCategoriesList(shortenList);
}
};
/**
* Insert children back in the list by their parent's id
*/
const showChildren = (parentId, currentList, insertIndex) => {
if (extractedChildren[parentId]?.length) {
currentList.splice(insertIndex + 1, 0, ...extractedChildren[parentId]);
setExtractedChildren({ ...extractedChildren, [parentId]: null });
}
return currentList;
};
/**
* Toggle parent category by hidding/showing its children
*/
const handleCollapse = (id) => {
const i = collapsed.findIndex(el => el === id);
if (i === -1) {
setCollapsed([...collapsed, id]);
} else {
const copy = [...collapsed];
copy.splice(i, 1);
setCollapsed(copy);
}
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToWindowEdges]}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={categoriesList} strategy={verticalListSortingStrategy}>
<div className='product-categories-tree'>
{categoriesList
.map((category) => (
<ProductCategoriesItem key={category.id}
productCategories={productCategories}
category={category}
onSuccess={onSuccess}
onError={onError}
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)}
/>
))}
</div>
</SortableContext>
</DndContext>
);
};
interface ActiveData {
index: number,
category: ProductCategory,
status: 'child' | 'single' | 'parent',
children: ProductCategory[],
offset: 'up' | 'down' | null
}
const initActiveData: ActiveData = {
index: null,
category: null,
status: null,
children: [],
offset: null
};

View File

@ -0,0 +1,110 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ProductCategory } from '../../../models/product-category';
import ProductCategoryAPI from '../../../api/product-category';
import { ManageProductCategory } from './manage-product-category';
import { ProductCategoriesTree } from './product-categories-tree';
import { FabAlert } from '../../base/fab-alert';
import { FabButton } from '../../base/fab-button';
import { HtmlTranslate } from '../../base/html-translate';
import { IApplication } from '../../../models/application';
import { Loader } from '../../base/loader';
import { react2angular } from 'react2angular';
declare const Application: IApplication;
interface ProductCategoriesProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component shows a tree list of all product categories and offer to manager them
* by creating, deleting, modifying and reordering each product categories.
*/
const ProductCategories: React.FC<ProductCategoriesProps> = ({ onSuccess, onError }) => {
const { t } = useTranslation('admin');
// List of all products' categories
const [productCategories, setProductCategories] = useState<Array<ProductCategory>>([]);
// load the categories list on component mount
useEffect(() => {
refreshCategories();
}, []);
/**
* The creation/edition/deletion was successful.
* Show the provided message and refresh the list
*/
const handleSuccess = (message: string): void => {
onSuccess(message);
refreshCategories();
};
/**
* Update state after drop
*/
const handleDnd = (data: ProductCategory[]) => {
setProductCategories(data);
};
/**
* Refresh the list of categories
*/
const refreshCategories = () => {
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((error) => onError(error));
};
/**
* Save list's new order
*/
const handleSave = () => {
// TODO: index to position -> send to API
console.log('save order:', productCategories);
};
return (
<div className='product-categories'>
<header>
<h2>{t('app.admin.store.product_categories.title')}</h2>
<div className='grpBtn'>
<ManageProductCategory action='create'
productCategories={productCategories}
onSuccess={handleSuccess} onError={onError} />
<FabButton className='main-action-btn' onClick={handleSave}>Save</FabButton>
</div>
</header>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_categories.info" />
</FabAlert>
<ProductCategoriesTree
productCategories={productCategories}
onDnd={handleDnd}
onSuccess={handleSuccess} onError={onError} />
</div>
);
};
const ProductCategoriesWrapper: React.FC<ProductCategoriesProps> = ({ onSuccess, onError }) => {
return (
<Loader>
<ProductCategories onSuccess={onSuccess} onError={onError} />
</Loader>
);
};
Application.Components.component('productCategories', react2angular(ProductCategoriesWrapper, ['onSuccess', 'onError']));

View File

@ -0,0 +1,131 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm, SubmitHandler } from 'react-hook-form';
import slugify from 'slugify';
import { FormInput } from '../../form/form-input';
import { FormSelect } from '../../form/form-select';
import { ProductCategory } from '../../../models/product-category';
import { FabButton } from '../../base/fab-button';
import { FabAlert } from '../../base/fab-alert';
import ProductCategoryAPI from '../../../api/product-category';
interface ProductCategoryFormProps {
action: 'create' | 'update' | 'delete',
productCategories: Array<ProductCategory>,
productCategory?: ProductCategory,
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 };
/**
* Form to create/edit/delete a product category
*/
export const ProductCategoryForm: React.FC<ProductCategoryFormProps> = ({ action, productCategories, productCategory, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const { register, watch, setValue, control, handleSubmit, formState } = useForm<ProductCategory>({ defaultValues: { ...productCategory } });
// filter all first level product categorie
let parents = productCategories.filter(c => !c.parent_id);
if (action === 'update') {
parents = parents.filter(c => c.id !== productCategory.id);
}
/**
* Convert all parents to the react-select format
*/
const buildOptions = (): Array<selectOption> => {
const options = parents.map(t => {
return { value: t.id, label: t.name };
});
if (action === 'update') {
options.unshift({ value: null, label: t('app.admin.store.product_category_form.no_parent') });
}
return options;
};
// Create slug from category's name
useEffect(() => {
const subscription = watch((value, { name }) => {
if (name === 'name') {
const _slug = slugify(value.name, { lower: true });
setValue('slug', _slug);
}
});
return () => subscription.unsubscribe();
}, [watch]);
// Check slug pattern
// Only lowercase alphanumeric groups of characters separated by an hyphen
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/g;
// Form submit
const onSubmit: SubmitHandler<ProductCategory> = (category: ProductCategory) => {
switch (action) {
case 'create':
ProductCategoryAPI.create(category).then(() => {
onSuccess(t('app.admin.store.product_category_form.create.success'));
}).catch((error) => {
onError(t('app.admin.store.product_category_form.create.error') + error);
});
break;
case 'update':
ProductCategoryAPI.update(category).then(() => {
onSuccess(t('app.admin.store.product_category_form.update.success'));
}).catch((error) => {
onError(t('app.admin.store.product_category_form.update.error') + error);
});
break;
case 'delete':
ProductCategoryAPI.destroy(category.id).then(() => {
onSuccess(t('app.admin.store.product_category_form.delete.success'));
}).catch((error) => {
onError(t('app.admin.store.product_category_form.delete.error') + error);
});
break;
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} name="productCategoryForm" className="product-category-form">
{ action === 'delete'
? <>
<FabAlert level='danger'>
{t('app.admin.store.product_category_form.delete.confirm')}
</FabAlert>
<FabButton type='submit'>{t('app.admin.store.product_category_form.save')}</FabButton>
</>
: <>
<FormInput id='name'
register={register}
rules={{ required: `${t('app.admin.store.product_category_form.required')}` }}
formState={formState}
label={t('app.admin.store.product_category_form.name')}
defaultValue={productCategory?.name || ''} />
<FormInput id='slug'
register={register}
rules={{
required: `${t('app.admin.store.product_category_form.required')}`,
pattern: {
value: slugPattern,
message: `${t('app.admin.store.product_category_form.slug_pattern')}`
}
}}
formState={formState}
label={t('app.admin.store.product_category_form.slug')}
defaultValue={productCategory?.slug} />
<FormSelect id='parent_id'
control={control}
options={buildOptions()}
label={t('app.admin.store.product_category_form.select_parent_product_category')} />
<FabButton type='submit'>{t('app.admin.store.product_category_form.save')}</FabButton>
</>
}
</form>
);
};

View File

@ -0,0 +1,58 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { ProductForm } from './product-form';
import { Product } from '../../models/product';
import ProductAPI from '../../api/product';
declare const Application: IApplication;
interface EditProductProps {
productId: number,
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component show edit product form
*/
const EditProduct: React.FC<EditProductProps> = ({ productId, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [product, setProduct] = useState<Product>();
useEffect(() => {
ProductAPI.get(productId).then(data => {
setProduct(data);
}).catch(onError);
}, []);
/**
* Success to save product and return to product list
*/
const saveProductSuccess = () => {
onSuccess(t('app.admin.store.edit_product.successfully_updated'));
window.location.href = '/#!/admin/store/products';
};
if (product) {
return (
<div className="edit-product">
<ProductForm product={product} title={product.name} onSuccess={saveProductSuccess} onError={onError} />
</div>
);
}
return null;
};
const EditProductWrapper: React.FC<EditProductProps> = ({ productId, onSuccess, onError }) => {
return (
<Loader>
<EditProduct productId={productId} onSuccess={onSuccess} onError={onError} />
</Loader>
);
};
Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError']));

View File

@ -0,0 +1,62 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { ProductForm } from './product-form';
declare const Application: IApplication;
interface NewProductProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component show new product form
*/
const NewProduct: React.FC<NewProductProps> = ({ onSuccess, onError }) => {
const { t } = useTranslation('admin');
const product = {
id: undefined,
name: '',
slug: '',
sku: '',
description: '',
is_active: false,
quantity_min: 1,
stock: {
internal: 0,
external: 0
},
low_stock_alert: false,
machine_ids: [],
product_files_attributes: [],
product_images_attributes: []
};
/**
* Success to save product and return to product list
*/
const saveProductSuccess = () => {
onSuccess(t('app.admin.store.new_product.successfully_created'));
window.location.href = '/#!/admin/store/products';
};
return (
<div className="new-product">
<ProductForm product={product} title={t('app.admin.store.new_product.add_a_new_product')} onSuccess={saveProductSuccess} onError={onError} />
</div>
);
};
const NewProductWrapper: React.FC<NewProductProps> = ({ onSuccess, onError }) => {
return (
<Loader>
<NewProduct onSuccess={onSuccess} onError={onError} />
</Loader>
);
};
Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError']));

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

@ -0,0 +1,419 @@
import React, { useEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import slugify from 'slugify';
import _ from 'lodash';
import { HtmlTranslate } from '../base/html-translate';
import { Product } from '../../models/product';
import { FormInput } from '../form/form-input';
import { FormSwitch } from '../form/form-switch';
import { FormSelect } from '../form/form-select';
import { FormChecklist } from '../form/form-checklist';
import { FormRichText } from '../form/form-rich-text';
import { FormFileUpload } from '../form/form-file-upload';
import { FormImageUpload } from '../form/form-image-upload';
import { FabButton } from '../base/fab-button';
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,
title: string,
onSuccess: (product: Product) => 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 };
/**
* Form component to create or update a product
*/
export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const { handleSubmit, register, control, formState, setValue, reset } = useForm<Product>({ defaultValues: { ...product } });
const output = useWatch<Product>({ control });
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 => {
// 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));
}).catch(onError);
}, []);
/**
* Convert the provided array of items to the react-select format
*/
const buildSelectOptions = (items: Array<{ id?: number, name: string }>): Array<selectOption> => {
return items.map(t => {
return { value: t.id, label: t.name };
});
};
/**
* 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 };
});
};
/**
* Callback triggered when the name has changed.
*/
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const name = event.target.value;
const slug = slugify(name, { lower: true, strict: true });
setValue('slug', slug);
};
/**
* Callback triggered when is active price has changed.
*/
const toggleIsActivePrice = (value: boolean) => {
if (!value) {
setValue('amount', null);
}
setIsActivePrice(value);
};
/**
* Callback triggered when the form is submitted: process with the product creation or update.
*/
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
return handleSubmit((data: Product) => {
saveProduct(data);
})(event);
};
/**
* Call product creation or update api
*/
const saveProduct = (data: Product) => {
if (product.id) {
ProductAPI.update(data).then((res) => {
reset(res);
onSuccess(res);
}).catch(onError);
} else {
ProductAPI.create(data).then((res) => {
reset(res);
onSuccess(res);
}).catch(onError);
}
};
/**
* Add new product file
*/
const addProductFile = () => {
setValue('product_files_attributes', output.product_files_attributes.concat({}));
};
/**
* Remove a product file
*/
const handleRemoveProductFile = (i: number) => {
return () => {
const productFile = output.product_files_attributes[i];
if (!productFile.id) {
output.product_files_attributes.splice(i, 1);
setValue('product_files_attributes', output.product_files_attributes);
}
};
};
/**
* Add new product image
*/
const addProductImage = () => {
setValue('product_images_attributes', output.product_images_attributes.concat({
is_main: output.product_images_attributes.length === 0
}));
};
/**
* Remove a product image
*/
const handleRemoveProductImage = (i: number) => {
return () => {
const productImage = output.product_images_attributes[i];
if (!productImage.id) {
output.product_images_attributes.splice(i, 1);
if (productImage.is_main) {
setValue('product_images_attributes', output.product_images_attributes.map((image, k) => {
if (k === 0) {
return {
...image,
is_main: true
};
}
return image;
}));
} else {
setValue('product_images_attributes', output.product_images_attributes);
}
} else {
if (productImage.is_main) {
let mainImage = false;
setValue('product_images_attributes', output.product_images_attributes.map((image, k) => {
if (i !== k && !mainImage) {
mainImage = true;
return {
...image,
_destroy: i === k,
is_main: true
};
}
return {
...image,
_destroy: i === k
};
}));
}
}
};
};
/**
* Remove main image in others product images
*/
const handleSetMainImage = (i: number) => {
return () => {
if (output.product_images_attributes.length > 1) {
setValue('product_images_attributes', output.product_images_attributes.map((image, k) => {
if (i !== k) {
return {
...image,
is_main: false
};
}
return {
...image,
is_main: true
};
}));
}
};
};
return (
<>
<header>
<h2>{title}</h2>
<div className="grpBtn">
<FabButton className="main-action-btn" onClick={handleSubmit(saveProduct)}>{t('app.admin.store.product_form.save')}</FabButton>
</div>
</header>
<form className="product-form" onSubmit={onSubmit}>
<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>
{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 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>
<hr />
<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 />
<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

@ -0,0 +1,366 @@
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 { 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;
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 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
*/
const editProduct = (product: Product) => {
window.location.href = `/#!/admin/store/products/${product.id}/edit`;
};
/**
* Delete a product
*/
const deleteProduct = async (productId: number): Promise<void> => {
try {
await ProductAPI.destroy(productId);
const data = await ProductAPI.index();
setProducts(data);
onSuccess(t('app.admin.store.products.successfully_deleted'));
} catch (e) {
onError(t('app.admin.store.products.unable_to_delete') + e);
}
};
/**
* Goto new product page
*/
const newProduct = (): void => {
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>
<h2>{t('app.admin.store.products.all_products')}</h2>
<div className='grpBtn'>
<FabButton className="main-action-btn" onClick={newProduct}>{t('app.admin.store.products.create_a_product')}</FabButton>
</div>
</header>
<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>
<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>
</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>
))}
{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>
<div className="products-list">
{filteredProductsList.map((product) => (
<ProductItem
key={product.id}
product={product}
onEdit={editProduct}
onDelete={deleteProduct}
/>
))}
</div>
</div>
</div>
);
};
const ProductsWrapper: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
return (
<Loader>
<Products onSuccess={onSuccess} onError={onError} />
</Loader>
);
};
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

@ -0,0 +1,97 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import _ from 'lodash';
import { FabButton } from '../base/fab-button';
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,
cart: Order,
onSuccessAddProductToCart: (cart: Order) => void
}
/**
* This component shows a product item in store
*/
export const StoreProductItem: React.FC<StoreProductItemProps> = ({ product, cart, onSuccessAddProductToCart }) => {
const { t } = useTranslation('public');
/**
* 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 noImage;
};
/**
* Add the product to cart
*/
const addProductToCart = (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
CartAPI.addItem(cart, product.id, 1).then(onSuccessAddProductToCart);
};
/**
* Goto show product page
*/
const showProduct = (product: Product): void => {
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 ${statusColor(product)}`} onClick={() => showProduct(product)}>
<div className="picture">
<img src={productImageUrl(product)} alt='' />
</div>
<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

@ -0,0 +1,198 @@
/* 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;
interface StoreProductProps {
productSlug: string,
onError: (message: string) => void,
}
/**
* This component shows a product
*/
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 no image, show default image
*/
const productImageUrl = (id: number) => {
const productImage = _.find(product.product_images_attributes, { id });
if (productImage) {
return productImage.attachment_url;
}
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 ${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>
);
}
return null;
};
const StoreProductWrapper: React.FC<StoreProductProps> = ({ productSlug, onError }) => {
return (
<Loader>
<StoreProduct productSlug={productSlug} onError={onError} />
</Loader>
);
};
Application.Components.component('storeProduct', react2angular(StoreProductWrapper, ['productSlug', 'onError']));

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

@ -0,0 +1,275 @@
import React, { useState, useEffect } from 'react';
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 { 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, 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 => {
setProducts(data);
}).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">
<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>{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 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>
</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>
<Store {...props} />
</Loader>
);
};
Application.Components.component('store', react2angular(StoreWrapper, ['onError', 'onSuccess', 'currentUser']));
interface ActiveCategory {
id: number,
parent: number
}
interface ParentCategory {
parent: ProductCategory,
children: ProductCategory[]
}

View File

@ -0,0 +1,69 @@
import React, { useState, useEffect } from 'react';
import AsyncSelect from 'react-select/async';
import { useTranslation } from 'react-i18next';
import MemberAPI from '../../api/member';
import { User } from '../../models/user';
interface MemberSelectProps {
defaultUser?: User,
onSelected?: (userId: number) => void,
noHeader?: boolean
}
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption = { value: number, label: string };
/**
* This component renders the member select for manager.
*/
export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, onSelected, noHeader }) => {
const { t } = useTranslation('public');
const [value, setValue] = useState<selectOption>();
useEffect(() => {
if (defaultUser) {
setValue({ value: defaultUser.id, label: defaultUser.name });
}
}, []);
/**
* search members by name
*/
const loadMembers = async (inputValue: string): Promise<Array<selectOption>> => {
if (!inputValue) {
return [];
}
const data = await MemberAPI.search(inputValue);
return data.map(u => {
return { value: u.id, label: u.name };
});
};
/**
* callback for handle select changed
*/
const onChange = (v: selectOption) => {
setValue(v);
onSelected(v.value);
};
return (
<div className="member-select">
{!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}
defaultOptions
onChange={onChange}
value={value}
/>
</div>
);
};

View File

@ -0,0 +1,64 @@
/* eslint-disable
no-return-assign,
no-undef,
*/
'use strict';
Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'growl', '$state',
function ($scope, CSRF, growl, $state) {
/* PRIVATE SCOPE */
// Map of tab state and index
const TABS = {
'app.admin.store.settings': 0,
'app.admin.store.products': 1,
'app.admin.store.categories': 2,
'app.admin.store.orders': 3
};
/* PUBLIC SCOPE */
// default tab: products
$scope.tabs = {
active: TABS[$state.current.name]
};
/**
* Callback triggered in click tab
*/
$scope.selectTab = () => {
setTimeout(function () {
const currentTab = _.keys(TABS)[$scope.tabs.active];
if (currentTab !== $state.current.name) {
$state.go(currentTab, { location: true, notify: false, reload: false });
}
});
};
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
/**
* Callback triggered in case of success
*/
$scope.onSuccess = (message) => {
growl.success(message);
};
/* 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

@ -0,0 +1,47 @@
/* eslint-disable
no-return-assign,
no-undef,
*/
'use strict';
Application.Controllers.controller('AdminStoreProductController', ['$scope', 'CSRF', 'growl', '$state', '$transition$',
function ($scope, CSRF, growl, $state, $transition$) {
/* PUBLIC SCOPE */
$scope.productId = $transition$.params().id;
/**
* 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.products');
};
/* 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

@ -1,6 +1,6 @@
Application.Controllers.controller('ApplicationController', ['$rootScope', '$scope', '$transitions', '$window', '$locale', '$timeout', 'Session', 'AuthService', 'Auth', '$uibModal', '$state', 'growl', 'Notification', '$interval', 'Setting', '_t', 'Version', 'Help',
function ($rootScope, $scope, $transitions, $window, $locale, $timeout, Session, AuthService, Auth, $uibModal, $state, growl, Notification, $interval, Setting, _t, Version, Help) {
Application.Controllers.controller('ApplicationController', ['$rootScope', '$scope', '$transitions', '$window', '$locale', '$timeout', 'Session', 'AuthService', 'Auth', '$uibModal', '$state', 'growl', 'Notification', '$interval', 'Setting', '_t', 'Version', 'Help', '$cookies',
function ($rootScope, $scope, $transitions, $window, $locale, $timeout, Session, AuthService, Auth, $uibModal, $state, growl, Notification, $interval, Setting, _t, Version, Help, $cookies) {
/* PRIVATE STATIC CONSTANTS */
// User's notifications will get refreshed every 30s
@ -58,6 +58,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
total: 0,
unread: 0
};
$cookies.remove('fablab_cart_token');
return $state.go('app.public.home');
}, function (error) {
console.error(`An error occurred logging out: ${error}`);

View File

@ -0,0 +1,53 @@
/* eslint-disable
no-return-assign,
no-undef,
*/
'use strict';
Application.Controllers.controller('CartController', ['$scope', 'CSRF', 'growl',
function ($scope, CSRF, growl) {
/* PRIVATE SCOPE */
/* PUBLIC SCOPE */
/**
* Open the modal dialog allowing the user to log into the system
*/
$scope.userLogin = function () {
setTimeout(() => {
if (!$scope.isAuthenticated()) {
$scope.login();
$scope.$apply();
}
}, 50);
};
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
/**
* Callback triggered in case of success
*/
$scope.onSuccess = (message) => {
growl.success(message);
};
/* 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

@ -53,6 +53,12 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm
linkIcon: 'tags',
class: 'reserve-event-link'
},
{
state: 'app.public.store',
linkText: 'app.public.common.fablab_store',
linkIcon: 'cart-plus',
class: 'store-link'
},
{ class: 'menu-spacer' },
{
state: 'app.public.projects_list',
@ -83,6 +89,12 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm
linkIcon: 'cogs',
authorizedRoles: ['admin', 'manager']
},
{
state: 'app.admin.store.products',
linkText: 'app.public.common.manage_the_store',
linkIcon: 'cart-plus',
authorizedRoles: ['admin', 'manager']
},
$scope.$root.modules.trainings && {
state: 'app.admin.trainings',
linkText: 'app.public.common.trainings_monitoring',

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

@ -0,0 +1,42 @@
/* eslint-disable
no-return-assign,
no-undef,
*/
'use strict';
Application.Controllers.controller('ShowProductController', ['$scope', 'CSRF', 'growl', '$transition$',
function ($scope, CSRF, growl, $transition$) {
/* PRIVATE SCOPE */
/* PUBLIC SCOPE */
$scope.productSlug = $transition$.params().slug;
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
/**
* Callback triggered in case of success
*/
$scope.onSuccess = (message) => {
growl.success(message);
};
/* 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

@ -0,0 +1,41 @@
/* eslint-disable
no-return-assign,
no-undef,
*/
'use strict';
Application.Controllers.controller('StoreController', ['$scope', 'CSRF', 'growl', '$state',
function ($scope, CSRF, growl, $state) {
/* PRIVATE SCOPE */
/* PUBLIC SCOPE */
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
/**
* Callback triggered in case of success
*/
$scope.onSuccess = (message) => {
growl.success(message);
};
/* 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

@ -0,0 +1,44 @@
import { useState, useEffect } from 'react';
import { Order } from '../models/order';
import CartAPI from '../api/cart';
import { getCartToken, setCartToken } from '../lib/cart-token';
import { User } from '../models/user';
export default function useCart (user?: User) {
const [cart, setCart] = useState<Order>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState(null);
useEffect(() => {
async function createCart () {
const currentCartToken = getCartToken();
const data = await CartAPI.create(currentCartToken);
setCart(data);
setLoading(false);
setCartToken(data.token);
}
setLoading(true);
try {
createCart();
} catch (e) {
setLoading(false);
setError(e);
}
}, []);
const reloadCart = async () => {
setLoading(true);
const currentCartToken = getCartToken();
const data = await CartAPI.create(currentCartToken);
setCart(data);
setLoading(false);
};
useEffect(() => {
if (user && cart && (!cart.statistic_profile_id || !cart.operator_id)) {
reloadCart();
}
}, [user]);
return { loading, cart, error, setCart, reloadCart };
}

View File

@ -0,0 +1,23 @@
import Cookies from 'js-cookie';
export const cartCookieName = 'fablab_cart_token';
export const cartCookieExpire = 7;
export const getCartToken = () =>
Cookies.get(cartCookieName);
export const setCartToken = (cartToken: string) => {
const cookieOptions = {
expires: cartCookieExpire
};
Cookies.set(
cartCookieName,
cartToken,
cookieOptions
);
};
export const removeCartToken = () => {
Cookies.remove(cartCookieName);
};

View File

@ -0,0 +1,13 @@
import { Coupon } from '../models/coupon';
export const computePriceWithCoupon = (price: number, coupon?: Coupon): number => {
if (!coupon) {
return price;
}
if (coupon.type === 'percent_off') {
return price - (price * coupon.percent_off / 100.00);
} else if (coupon.type === 'amount_off' && price > coupon.amount_off) {
return price - coupon.amount_off;
}
return price;
};

View File

@ -32,4 +32,11 @@ export default class FormatLib {
static price = (price: number): string => {
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(price);
};
/**
* Return currency symbol for currency setting
*/
static currencySymbol = (): string => {
return new Intl.NumberFormat('fr', { style: 'currency', currency: Fablab.intl_currency }).formatToParts()[2].value;
};
}

View File

@ -0,0 +1,8 @@
export interface Coupon {
id: number,
code: string,
type: string,
amount_off: number,
percent_off: number,
validity_per_user: string
}

View File

@ -0,0 +1,37 @@
import { TDateISO } from '../typings/date-iso';
import { PaymentConfirmation } from './payment';
import { CreateTokenResponse } from './payzen';
import { UserRole } from './user';
import { Coupon } from './coupon';
export interface Order {
id: number,
token: string,
statistic_profile_id?: number,
user?: {
id: number,
role: UserRole
name?: string,
},
operator_profile_id?: number,
reference?: string,
state?: string,
payment_state?: string,
total?: number,
coupon?: Coupon,
created_at?: TDateISO,
order_items_attributes: Array<{
id: number,
orderable_type: string,
orderable_id: number,
orderable_name: string,
quantity: number,
amount: number,
is_offered: boolean
}>,
}
export interface OrderPayment {
order: Order,
payment?: PaymentConfirmation|CreateTokenResponse
}

View File

@ -0,0 +1,7 @@
export interface ProductCategory {
id: number,
name: string,
slug: string,
parent_id?: number,
position: number,
}

View File

@ -0,0 +1,58 @@
import { TDateISO } from '../typings/date-iso';
import { ApiFilter } from './api';
export interface ProductIndexFilter extends ApiFilter {
is_active: boolean,
}
export enum StockType {
internal = 'internal',
external = 'external'
}
export interface Stock {
internal: number,
external: number,
}
export interface Product {
id: number,
name: string,
slug: string,
sku: string,
description: string,
is_active: boolean,
product_category_id?: number,
amount?: number,
quantity_min?: number,
stock: Stock,
low_stock_alert: boolean,
low_stock_threshold?: number,
machine_ids: number[],
product_files_attributes: Array<{
id?: number,
attachment?: File,
attachment_files?: FileList,
attachment_name?: string,
attachment_url?: string,
_destroy?: boolean
}>,
product_images_attributes: Array<{
id?: number,
attachment?: File,
attachment_files?: FileList,
attachment_name?: string,
attachment_url?: string,
_destroy?: boolean,
is_main?: boolean
}>,
product_stock_movements_attributes: Array<{
id?: number,
quantity?: number,
reason?: string,
stock_type?: string,
remaining_stock?: number,
date?: TDateISO,
_destroy?: boolean
}>,
}

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,
@ -600,6 +609,39 @@ angular.module('application.router', ['ui.router'])
}
})
// store
.state('app.public.store', {
url: '/store',
views: {
'main@': {
templateUrl: '/store/index.html',
controller: 'StoreController'
}
}
})
// show product
.state('app.public.product_show', {
url: '/store/p/:slug',
views: {
'main@': {
templateUrl: '/products/show.html',
controller: 'ShowProductController'
}
}
})
// cart
.state('app.public.cart', {
url: '/cart',
views: {
'main@': {
templateUrl: '/cart/index.html',
controller: 'CartController'
}
}
})
// --- namespace /admin/... ---
// calendar
.state('app.admin.calendar', {
@ -871,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',
@ -1104,6 +1157,71 @@ angular.module('application.router', ['ui.router'])
}
})
.state('app.admin.store', {
abstract: true,
url: '/admin/store'
})
.state('app.admin.store.settings', {
url: '/settings',
views: {
'main@': {
templateUrl: '/admin/store/index.html',
controller: 'AdminStoreController'
}
}
})
.state('app.admin.store.products', {
url: '/products',
views: {
'main@': {
templateUrl: '/admin/store/index.html',
controller: 'AdminStoreController'
}
}
})
.state('app.admin.store.products_new', {
url: '/products/new',
views: {
'main@': {
templateUrl: '/admin/store/product_new.html',
controller: 'AdminStoreProductController'
}
}
})
.state('app.admin.store.products_edit', {
url: '/products/:id/edit',
views: {
'main@': {
templateUrl: '/admin/store/product_edit.html',
controller: 'AdminStoreProductController'
}
}
})
.state('app.admin.store.categories', {
url: '/categories',
views: {
'main@': {
templateUrl: '/admin/store/index.html',
controller: 'AdminStoreController'
}
}
})
.state('app.admin.store.orders', {
url: '/orders',
views: {
'main@': {
templateUrl: '/admin/store/index.html',
controller: 'AdminStoreController'
}
}
})
// OpenAPI Clients
.state('app.admin.open_api_clients', {
url: '/open_api_clients',

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";
@ -38,7 +40,11 @@
@import "modules/form/form-input";
@import "modules/form/form-rich-text";
@import "modules/form/form-switch";
@import "modules/form/form-checklist";
@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";
@ -85,6 +91,21 @@
@import "modules/settings/check-list-setting";
@import "modules/settings/user-validation-setting";
@import "modules/socials/fab-socials";
@import "modules/store/_utilities";
@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";
@import "modules/supporting-documents/supporting-documents-files";

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,5 +45,31 @@
&--icon {
margin-right: 0.5em;
display: flex;
}
&--icon-only {
display: flex;
}
// color variants
@mixin colorVariant($color, $textColor) {
border-color: $color;
background-color: $color;
color: $textColor;
&:hover {
border-color: $color;
background-color: $color;
color: $textColor;
opacity: 0.75;
}
}
&.is-info {
@include colorVariant(var(--information), var(--gray-soft-lightest));
}
&.is-black {
@include colorVariant(var(--gray-hard-darkest), var(--gray-soft-lightest));
}
&.is-main {
@include colorVariant(var(--main), var(--gray-soft-lightest));
}
}

View File

@ -81,6 +81,12 @@
position: relative;
padding: 15px;
.subtitle {
margin-bottom: 3.2rem;
@include title-base;
color: var(--gray-hard-darkest);
}
form {
display: flex;
flex-direction: column;

View File

@ -1,7 +1,7 @@
.fab-output-copy {
.form-item-field {
& > input {
background-color: var(--gray-soft);
background-color: var(--gray-soft-dark);
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}

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 {
@ -64,6 +64,7 @@
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
transition: border-color ease-in-out 0.15s;
font-weight: 400;
.icon,
.addon {
@ -151,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,
@ -173,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

@ -0,0 +1,28 @@
.form-checklist {
.form-item-field {
display: flex;
flex-direction: column;
border: none;
}
.checklist {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.6rem 3.2rem;
.checklist-item input {
margin-right: 1em;
}
}
.actions {
align-self: flex-end;
margin: 2.4rem 0;
display: flex;
justify-content: flex-end;
align-items: center;
& > *:not(:first-child) {
margin-left: 1.6rem;
}
}
}

View File

@ -0,0 +1,25 @@
.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);
.actions {
margin-left: auto;
display: flex;
align-items: center;
& > *:not(:first-child) {
margin-left: 1rem;
}
a {
display: flex;
}
.image-file-input {
margin-bottom: 0;
}
}
}

View File

@ -0,0 +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 {
flex-shrink: 0;
display: flex;
object-fit: cover;
border-radius: var(--border-radius-sm);
overflow: hidden;
&--small {
width: 8rem;
height: 8rem;
}
&--medium {
width: 20rem;
height: 20rem;
}
&--large {
width: 40rem;
height: 40rem;
}
img {
width: 100%;
}
}
.actions {
display: flex;
align-items: center;
& > *:not(:first-child) {
margin-left: 1rem;
}
input[type="radio"] { margin-left: 0.5rem; }
.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

@ -3,8 +3,12 @@
margin-right: 5px;
.create-button {
background-color: var(--secondary-dark);
border-color: var(--secondary-dark);
background-color: var(--secondary);
border-color: var(--secondary);
color: var(--secondary-text-color);
&:hover {
background-color: var(--secondary-dark);
border-color: var(--secondary-dark);
}
}
}

View File

@ -10,9 +10,13 @@
vertical-align: middle;
}
.save-btn {
background-color: var(--secondary-dark);
border-color: var(--secondary-dark);
color: var(--secondary-text-color);
margin-left: 15px;
background-color: var(--secondary);
border-color: var(--secondary);
color: var(--secondary-text-color);
&:hover {
background-color: var(--secondary-dark);
border-color: var(--secondary-dark);
}
}
}

View File

@ -1,8 +1,12 @@
.user-validation-setting {
.save-btn {
background-color: var(--secondary-dark);
border-color: var(--secondary-dark);
color: var(--secondary-text-color);
margin-top: 15px;
background-color: var(--secondary);
border-color: var(--secondary);
color: var(--secondary-text-color);
&:hover {
background-color: var(--secondary-dark);
border-color: var(--secondary-dark);
}
}
}

View File

@ -1,7 +1,11 @@
.fab-socials {
.save-btn {
background-color: var(--secondary-dark);
border-color: var(--secondary-dark);
background-color: var(--secondary);
border-color: var(--secondary);
color: var(--secondary-text-color);
&:hover {
background-color: var(--secondary-dark);
border-color: var(--secondary-dark);
}
}
}

View File

@ -0,0 +1,106 @@
@mixin btn {
width: 4rem;
height: 4rem;
display: inline-flex;
justify-content: center;
align-items: center;
padding: 0;
background: none;
border: none;
&:active {
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

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

Some files were not shown because too many files have changed in this diff Show More