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:
commit
79182fb8c1
2
Gemfile
2
Gemfile
@ -145,3 +145,5 @@ gem 'tzinfo-data'
|
||||
gem 'sassc', '= 2.1.0'
|
||||
|
||||
gem 'redis-session-store'
|
||||
|
||||
gem 'acts_as_list'
|
||||
|
@ -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
|
||||
|
39
app/controllers/api/cart_controller.rb
Normal file
39
app/controllers/api/cart_controller.rb
Normal 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
|
30
app/controllers/api/checkout_controller.rb
Normal file
30
app/controllers/api/checkout_controller.rb
Normal 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
|
62
app/controllers/api/product_categories_controller.rb
Normal file
62
app/controllers/api/product_categories_controller.rb
Normal 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
|
61
app/controllers/api/products_controller.rb
Normal file
61
app/controllers/api/products_controller.rb
Normal 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
|
22
app/controllers/concerns/api/order_concern.rb
Normal file
22
app/controllers/concerns/api/order_concern.rb
Normal 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
|
5
app/exceptions/cart/inactive_product_error.rb
Normal file
5
app/exceptions/cart/inactive_product_error.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when the product is out of stock
|
||||
class Cart::InactiveProductError < StandardError
|
||||
end
|
5
app/exceptions/cart/out_stock_error.rb
Normal file
5
app/exceptions/cart/out_stock_error.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when the product is out of stock
|
||||
class Cart::OutStockError < StandardError
|
||||
end
|
5
app/exceptions/cart/zero_price_error.rb
Normal file
5
app/exceptions/cart/zero_price_error.rb
Normal 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
BIN
app/frontend/images/no_avatar.png
Executable file → Normal file
Binary file not shown.
Before Width: | Height: | Size: 619 B After Width: | Height: | Size: 792 B |
BIN
app/frontend/images/no_image.png
Normal file
BIN
app/frontend/images/no_image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 686 B |
25
app/frontend/src/javascript/api/cart.ts
Normal file
25
app/frontend/src/javascript/api/cart.ts
Normal 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;
|
||||
}
|
||||
}
|
25
app/frontend/src/javascript/api/checkout.ts
Normal file
25
app/frontend/src/javascript/api/checkout.ts
Normal 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;
|
||||
}
|
||||
}
|
10
app/frontend/src/javascript/api/coupon.ts
Normal file
10
app/frontend/src/javascript/api/coupon.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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]) {
|
||||
|
35
app/frontend/src/javascript/api/product-category.ts
Normal file
35
app/frontend/src/javascript/api/product-category.ts
Normal 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;
|
||||
}
|
||||
}
|
92
app/frontend/src/javascript/api/product.ts
Normal file
92
app/frontend/src/javascript/api/product.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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]);
|
||||
|
||||
|
@ -12,11 +12,14 @@ import { MenuBar } from './menu-bar';
|
||||
import { WarningOctagon } from 'phosphor-react';
|
||||
|
||||
interface FabTextEditorProps {
|
||||
paragraphTools?: boolean,
|
||||
content?: string,
|
||||
limit?: number,
|
||||
heading?: boolean,
|
||||
bulletList?: boolean,
|
||||
blockquote?: boolean,
|
||||
link?: boolean,
|
||||
video?: boolean,
|
||||
image?: boolean,
|
||||
content?: string,
|
||||
limit?: number,
|
||||
onChange?: (content: string) => void,
|
||||
placeholder?: string,
|
||||
error?: string,
|
||||
@ -30,7 +33,7 @@ export interface FabTextEditorRef {
|
||||
/**
|
||||
* This component is a WYSIWYG text editor
|
||||
*/
|
||||
export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ paragraphTools, content, limit = 400, video, image, onChange, placeholder, error, disabled = false }, ref: RefObject<FabTextEditorRef>) => {
|
||||
export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ heading, bulletList, blockquote, content, limit = 400, video, image, link, onChange, placeholder, error, disabled = false }, ref: RefObject<FabTextEditorRef>) => {
|
||||
const { t } = useTranslation('shared');
|
||||
const placeholderText = placeholder || t('app.shared.text_editor.fab_text_editor.text_placeholder');
|
||||
// TODO: Add ctrl+click on link to visit
|
||||
@ -86,7 +89,7 @@ export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, Fab
|
||||
|
||||
return (
|
||||
<div className={`fab-text-editor ${disabled && 'is-disabled'}`}>
|
||||
<MenuBar editor={editor} paragraphTools={paragraphTools} video={video} image={image} disabled={disabled} />
|
||||
<MenuBar editor={editor} heading={heading} bulletList={bulletList} blockquote={blockquote} video={video} image={image} link={link} disabled={disabled} />
|
||||
<EditorContent editor={editor} />
|
||||
<div className="fab-text-editor-character-count">
|
||||
{editor?.storage.characterCount.characters()} / {limit}
|
||||
|
@ -6,7 +6,10 @@ import { TextAa, TextBolder, TextItalic, TextUnderline, LinkSimpleHorizontal, Li
|
||||
|
||||
interface MenuBarProps {
|
||||
editor?: Editor,
|
||||
paragraphTools?: boolean,
|
||||
heading?: boolean,
|
||||
bulletList?: boolean,
|
||||
blockquote?: boolean,
|
||||
link?: boolean,
|
||||
video?: boolean,
|
||||
image?: boolean,
|
||||
disabled?: boolean,
|
||||
@ -15,7 +18,7 @@ interface MenuBarProps {
|
||||
/**
|
||||
* This component is the menu bar for the WYSIWYG text editor
|
||||
*/
|
||||
export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video, image, disabled = false }) => {
|
||||
export const MenuBar: React.FC<MenuBarProps> = ({ editor, heading, bulletList, blockquote, link, video, image, disabled = false }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [submenu, setSubmenu] = useState('');
|
||||
@ -142,8 +145,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
return (
|
||||
<>
|
||||
<div className={`fab-text-editor-menu ${disabled ? 'fab-text-editor-menu--disabled' : ''}`}>
|
||||
{ paragraphTools &&
|
||||
(<>
|
||||
{heading &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
@ -152,6 +154,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<TextAa size={24} />
|
||||
</button>
|
||||
}
|
||||
{bulletList &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
@ -160,6 +164,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<ListBullets size={24} />
|
||||
</button>
|
||||
}
|
||||
{blockquote &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
@ -168,9 +174,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<Quotes size={24} />
|
||||
</button>
|
||||
<span className='menu-divider'></span>
|
||||
</>)
|
||||
}
|
||||
{ (heading || bulletList || blockquote) && <span className='menu-divider'></span> }
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
@ -195,14 +200,16 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<TextUnderline size={24} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => toggleSubmenu('link')}
|
||||
disabled={disabled}
|
||||
className={`ignore-onclickoutside ${editor.isActive('link') ? 'is-active' : ''}`}
|
||||
>
|
||||
<LinkSimpleHorizontal size={24} />
|
||||
</button>
|
||||
{link &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => toggleSubmenu('link')}
|
||||
disabled={disabled}
|
||||
className={`ignore-onclickoutside ${editor.isActive('link') ? 'is-active' : ''}`}
|
||||
>
|
||||
<LinkSimpleHorizontal size={24} />
|
||||
</button>
|
||||
}
|
||||
{ (video || image) && <span className='menu-divider'></span> }
|
||||
{ video &&
|
||||
(<>
|
||||
|
45
app/frontend/src/javascript/components/cart/cart-button.tsx
Normal file
45
app/frontend/src/javascript/components/cart/cart-button.tsx
Normal 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));
|
253
app/frontend/src/javascript/components/cart/store-cart.tsx
Normal file
253
app/frontend/src/javascript/components/cart/store-cart.tsx
Normal 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']));
|
100
app/frontend/src/javascript/components/coupon/coupon-input.tsx
Normal file
100
app/frontend/src/javascript/components/coupon/coupon-input.tsx
Normal 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>
|
||||
);
|
||||
};
|
109
app/frontend/src/javascript/components/form/form-checklist.tsx
Normal file
109
app/frontend/src/javascript/components/form/form-checklist.tsx
Normal 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>
|
||||
);
|
||||
};
|
123
app/frontend/src/javascript/components/form/form-file-upload.tsx
Normal file
123
app/frontend/src/javascript/components/form/form-file-upload.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -67,6 +67,7 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
||||
disabled={typeof disabled === 'function' ? disabled(id) : disabled}
|
||||
placeholder={placeholder}
|
||||
accept={accept} />
|
||||
{(type === 'file' && placeholder) && <span className='fab-button is-black file-placeholder'>{placeholder}</span>}
|
||||
{addOn && <span onClick={addOnAction} className={`addon ${addOnClassName || ''} ${addOnAction ? 'is-btn' : ''}`}>{addOn}</span>}
|
||||
</AbstractFormItem>
|
||||
);
|
||||
|
@ -10,15 +10,18 @@ import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
|
||||
interface FormRichTextProps<TFieldValues, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||
valueDefault?: string,
|
||||
limit?: number,
|
||||
paragraphTools?: boolean,
|
||||
heading?: boolean,
|
||||
bulletList?: boolean,
|
||||
blockquote?: boolean,
|
||||
link?: boolean,
|
||||
video?: boolean,
|
||||
image?: boolean,
|
||||
image?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a rich-text editor to use with react-hook-form.
|
||||
*/
|
||||
export const FormRichText = <TFieldValues extends FieldValues, TContext extends object>({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, paragraphTools, video, image }: FormRichTextProps<TFieldValues, TContext>) => {
|
||||
export const FormRichText = <TFieldValues extends FieldValues, TContext extends object>({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, heading, bulletList, blockquote, video, image, link }: FormRichTextProps<TFieldValues, TContext>) => {
|
||||
const textEditorRef = React.useRef<FabTextEditorRef>();
|
||||
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
|
||||
|
||||
@ -54,9 +57,12 @@ export const FormRichText = <TFieldValues extends FieldValues, TContext extends
|
||||
<FabTextEditor onChange={onChange}
|
||||
content={value}
|
||||
limit={limit}
|
||||
paragraphTools={paragraphTools}
|
||||
heading={heading}
|
||||
bulletList={bulletList}
|
||||
blockquote={blockquote}
|
||||
video={video}
|
||||
image={image}
|
||||
link={link}
|
||||
disabled={isDisabled}
|
||||
ref={textEditorRef} />
|
||||
} />
|
||||
|
@ -41,8 +41,11 @@ export const FormSwitch = <TFieldValues, TContext extends object>({ id, label, t
|
||||
onChangeCb(val);
|
||||
}}
|
||||
checked={value as boolean || false}
|
||||
height={19}
|
||||
width={40}
|
||||
height={19}
|
||||
uncheckedIcon={false}
|
||||
checkedIcon={false}
|
||||
handleDiameter={15}
|
||||
ref={ref}
|
||||
disabled={typeof disabled === 'function' ? disabled(id) : disabled} />
|
||||
} />
|
||||
|
@ -7,6 +7,8 @@ import MachineAPI from '../../api/machine';
|
||||
import { MachineCard } from './machine-card';
|
||||
import { MachinesFilters } from './machines-filters';
|
||||
import { User } from '../../models/user';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
@ -25,6 +27,7 @@ interface MachinesListProps {
|
||||
* This component shows a list of all machines and allows filtering on that list.
|
||||
*/
|
||||
export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user, canProposePacks }) => {
|
||||
const { t } = useTranslation('public');
|
||||
// shown machines
|
||||
const [machines, setMachines] = useState<Array<Machine>>(null);
|
||||
// we keep the full list of machines, for filtering
|
||||
@ -56,10 +59,30 @@ export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess,
|
||||
setMachines(allMachines.filter(m => !!m.disabled === !status));
|
||||
};
|
||||
|
||||
/**
|
||||
* Go to store
|
||||
*/
|
||||
const linkToStore = (): void => {
|
||||
window.location.href = '/#!/store';
|
||||
};
|
||||
|
||||
// TODO: Conditionally display the store ad
|
||||
return (
|
||||
<div className="machines-list">
|
||||
<MachinesFilters onStatusSelected={handleFilterByStatus} />
|
||||
<div className="all-machines">
|
||||
{false &&
|
||||
<div className='store-ad' onClick={() => linkToStore}>
|
||||
<div className='content'>
|
||||
<h3>{t('app.public.machines_list.store_ad.title')}</h3>
|
||||
<p>{t('app.public.machines_list.store_ad.buy')}</p>
|
||||
<p className='sell'>{t('app.public.machines_list.store_ad.sell')}</p>
|
||||
</div>
|
||||
<FabButton icon={<i className="fa fa-cart-plus fa-lg" />} className="cta" onClick={linkToStore}>
|
||||
{t('app.public.machines_list.store_ad.link')}
|
||||
</FabButton>
|
||||
</div>
|
||||
}
|
||||
{machines && machines.map(machine => {
|
||||
return <MachineCard key={machine.id}
|
||||
user={user}
|
||||
|
@ -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}>
|
||||
|
@ -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']));
|
||||
|
@ -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];
|
||||
};
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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();
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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
|
||||
};
|
@ -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']));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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']));
|
62
app/frontend/src/javascript/components/store/new-product.tsx
Normal file
62
app/frontend/src/javascript/components/store/new-product.tsx
Normal 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']));
|
40
app/frontend/src/javascript/components/store/order-item.tsx
Normal file
40
app/frontend/src/javascript/components/store/order-item.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Order } from '../../models/order';
|
||||
import FormatLib from '../../lib/format';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
|
||||
interface OrderItemProps {
|
||||
order?: Order
|
||||
statusColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* List item for an order
|
||||
*/
|
||||
export const OrderItem: React.FC<OrderItemProps> = ({ order, statusColor }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
/**
|
||||
* Go to order page
|
||||
*/
|
||||
const showOrder = (token: string) => {
|
||||
window.location.href = `/#!/admin/store/o/${token}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='order-item'>
|
||||
<p className="ref">order.token</p>
|
||||
<span className={`order-status ${statusColor}`}>order.state</span>
|
||||
<div className='client'>
|
||||
<span>{t('app.admin.store.order_item.client')}</span>
|
||||
<p>order.user.name</p>
|
||||
</div>
|
||||
<p className="date">order.created_at</p>
|
||||
<div className='price'>
|
||||
<span>{t('app.admin.store.order_item.total')}</span>
|
||||
<p>{FormatLib.price(order?.total)}</p>
|
||||
</div>
|
||||
<FabButton onClick={() => showOrder('orderToken')} icon={<i className="fas fa-eye" />} className="is-black" />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { StoreListHeader } from './store-list-header';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface OrdersDashboardProps {
|
||||
onError: (message: string) => void
|
||||
}
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* This component shows a list of all orders from the store for the current user
|
||||
*/
|
||||
// TODO: delete next eslint disable
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const OrdersDashboard: React.FC<OrdersDashboardProps> = ({ onError }) => {
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.public.store.orders_dashboard.sort.newest') },
|
||||
{ value: 1, label: t('app.public.store.orders_dashboard.sort.oldest') }
|
||||
];
|
||||
};
|
||||
/**
|
||||
* Display option: sorting
|
||||
*/
|
||||
const handleSorting = (option: selectOption) => {
|
||||
console.log('Sort option:', option);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="orders-dashboard">
|
||||
<header>
|
||||
<h2>{t('app.public.store.orders_dashboard.heading')}</h2>
|
||||
</header>
|
||||
|
||||
<div className="store-list">
|
||||
<StoreListHeader
|
||||
productsCount={0}
|
||||
selectOptions={buildOptions()}
|
||||
onSelectOptionsChange={handleSorting}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const OrdersDashboardWrapper: React.FC<OrdersDashboardProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
<OrdersDashboard {...props} />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('ordersDashboard', react2angular(OrdersDashboardWrapper, ['onError']));
|
242
app/frontend/src/javascript/components/store/orders.tsx
Normal file
242
app/frontend/src/javascript/components/store/orders.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useImmer } from 'use-immer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { StoreListHeader } from './store-list-header';
|
||||
import { AccordionItem } from './accordion-item';
|
||||
import { OrderItem } from './order-item';
|
||||
import { MemberSelect } from '../user/member-select';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface OrdersProps {
|
||||
onSuccess: (message: string) => void,
|
||||
onError: (message: string) => void,
|
||||
}
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* Option format, expected by checklist
|
||||
*/
|
||||
type checklistOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* Admin list of orders
|
||||
*/
|
||||
// TODO: delete next eslint disable
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const Orders: React.FC<OrdersProps> = ({ onSuccess, onError }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [filters, setFilters] = useImmer<Filters>(initFilters);
|
||||
const [clearFilters, setClearFilters] = useState<boolean>(false);
|
||||
const [accordion, setAccordion] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters();
|
||||
setClearFilters(false);
|
||||
}, [clearFilters]);
|
||||
|
||||
/**
|
||||
* Create a new order
|
||||
*/
|
||||
const newOrder = () => {
|
||||
console.log('Create new order');
|
||||
};
|
||||
|
||||
const statusOptions: checklistOption[] = [
|
||||
{ value: 0, label: t('app.admin.store.orders.status.error') },
|
||||
{ value: 1, label: t('app.admin.store.orders.status.canceled') },
|
||||
{ value: 2, label: t('app.admin.store.orders.status.pending') },
|
||||
{ value: 3, label: t('app.admin.store.orders.status.under_preparation') },
|
||||
{ value: 4, label: t('app.admin.store.orders.status.paid') },
|
||||
{ value: 5, label: t('app.admin.store.orders.status.ready') },
|
||||
{ value: 6, label: t('app.admin.store.orders.status.collected') },
|
||||
{ value: 7, label: t('app.admin.store.orders.status.refunded') }
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply filters
|
||||
*/
|
||||
const applyFilters = () => {
|
||||
console.log('Apply filters:', filters);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear filters
|
||||
*/
|
||||
const clearAllFilters = () => {
|
||||
setFilters(initFilters);
|
||||
setClearFilters(true);
|
||||
console.log('Clear all filters');
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.admin.store.orders.sort.newest') },
|
||||
{ value: 1, label: t('app.admin.store.orders.sort.oldest') }
|
||||
];
|
||||
};
|
||||
/**
|
||||
* Display option: sorting
|
||||
*/
|
||||
const handleSorting = (option: selectOption) => {
|
||||
console.log('Sort option:', option);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter: by status
|
||||
*/
|
||||
const handleSelectStatus = (s: checklistOption, checked) => {
|
||||
const list = [...filters.status];
|
||||
checked
|
||||
? list.push(s)
|
||||
: list.splice(list.indexOf(s), 1);
|
||||
setFilters(draft => {
|
||||
return { ...draft, status: list };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter: by member
|
||||
*/
|
||||
const handleSelectMember = (userId: number) => {
|
||||
setFilters(draft => {
|
||||
return { ...draft, memberId: userId };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/close accordion items
|
||||
*/
|
||||
const handleAccordion = (id, state) => {
|
||||
setAccordion({ ...accordion, [id]: state });
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a className according to the status
|
||||
*/
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'canceled':
|
||||
return 'canceled';
|
||||
case 'pending' || 'under_preparation':
|
||||
return 'pending';
|
||||
default:
|
||||
return 'normal';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='orders'>
|
||||
<header>
|
||||
<h2>{t('app.admin.store.orders.heading')}</h2>
|
||||
{false &&
|
||||
<div className='grpBtn'>
|
||||
<FabButton className="main-action-btn" onClick={newOrder}>{t('app.admin.store.orders.create_order')}</FabButton>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div className="store-filters">
|
||||
<header>
|
||||
<h3>{t('app.admin.store.orders.filter')}</h3>
|
||||
<div className='grpBtn'>
|
||||
<FabButton onClick={clearAllFilters} className="is-black">{t('app.admin.store.orders.filter_clear')}</FabButton>
|
||||
</div>
|
||||
</header>
|
||||
<div className="accordion">
|
||||
<AccordionItem id={0}
|
||||
isOpen={accordion[0]}
|
||||
onChange={handleAccordion}
|
||||
label={t('app.admin.store.orders.filter_ref')}
|
||||
>
|
||||
<div className='content'>
|
||||
<div className="group">
|
||||
<input type="text" />
|
||||
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
<AccordionItem id={1}
|
||||
isOpen={accordion[1]}
|
||||
onChange={handleAccordion}
|
||||
label={t('app.admin.store.orders.filter_status')}
|
||||
>
|
||||
<div className='content'>
|
||||
<div className="group u-scrollbar">
|
||||
{statusOptions.map(s => (
|
||||
<label key={s.value}>
|
||||
<input type="checkbox" checked={filters.status.some(o => o.label === s.label)} onChange={(event) => handleSelectStatus(s, event.target.checked)} />
|
||||
<p>{s.label}</p>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
<AccordionItem id={2}
|
||||
isOpen={accordion[2]}
|
||||
onChange={handleAccordion}
|
||||
label={t('app.admin.store.orders.filter_client')}
|
||||
>
|
||||
<div className='content'>
|
||||
<div className="group">
|
||||
<MemberSelect noHeader onSelected={handleSelectMember} />
|
||||
<FabButton onClick={applyFilters} className="is-info">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="store-list">
|
||||
<StoreListHeader
|
||||
productsCount={0}
|
||||
selectOptions={buildOptions()}
|
||||
onSelectOptionsChange={handleSorting}
|
||||
/>
|
||||
<div className="orders-list">
|
||||
<OrderItem statusColor={statusColor('error')} />
|
||||
<OrderItem statusColor={statusColor('canceled')} />
|
||||
<OrderItem statusColor={statusColor('pending')} />
|
||||
<OrderItem statusColor={statusColor('refunded')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OrdersWrapper: React.FC<OrdersProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
<Orders {...props} />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('orders', react2angular(OrdersWrapper, ['onSuccess', 'onError']));
|
||||
|
||||
interface Filters {
|
||||
reference: string,
|
||||
status: checklistOption[],
|
||||
memberId: number
|
||||
}
|
||||
|
||||
const initFilters: Filters = {
|
||||
reference: '',
|
||||
status: [],
|
||||
memberId: null
|
||||
};
|
419
app/frontend/src/javascript/components/store/product-form.tsx
Normal file
419
app/frontend/src/javascript/components/store/product-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
100
app/frontend/src/javascript/components/store/product-item.tsx
Normal file
100
app/frontend/src/javascript/components/store/product-item.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FormatLib from '../../lib/format';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { Product } from '../../models/product';
|
||||
import { PencilSimple, Trash } from 'phosphor-react';
|
||||
import noImage from '../../../../images/no_image.png';
|
||||
|
||||
interface ProductItemProps {
|
||||
product: Product,
|
||||
onEdit: (product: Product) => void,
|
||||
onDelete: (productId: number) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows a product item in the admin view
|
||||
*/
|
||||
export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDelete }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
/**
|
||||
* Get the main image
|
||||
*/
|
||||
const thumbnail = () => {
|
||||
const image = product.product_images_attributes
|
||||
.find(att => att.is_main);
|
||||
return image;
|
||||
};
|
||||
/**
|
||||
* Init the process of editing the given product
|
||||
*/
|
||||
const editProduct = (product: Product): () => void => {
|
||||
return (): void => {
|
||||
onEdit(product);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Init the process of delete the given product
|
||||
*/
|
||||
const deleteProduct = (productId: number): () => void => {
|
||||
return (): void => {
|
||||
onDelete(productId);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns CSS class from stock status
|
||||
*/
|
||||
const statusColor = (product: Product) => {
|
||||
if (product.stock.external === 0 && product.stock.internal === 0) {
|
||||
return 'out-of-stock';
|
||||
}
|
||||
if (product.low_stock_threshold && (product.stock.external < product.low_stock_threshold || product.stock.internal < product.low_stock_threshold)) {
|
||||
return 'low';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`product-item ${statusColor(product)}`}>
|
||||
<div className='itemInfo'>
|
||||
{/* TODO: image size version ? */}
|
||||
<img src={thumbnail()?.attachment_url || noImage} alt='' className='itemInfo-thumbnail' />
|
||||
<p className="itemInfo-name">{product.name}</p>
|
||||
</div>
|
||||
<div className='details'>
|
||||
<span className={`visibility ${product.is_active ? 'is-active' : ''}`}>
|
||||
{product.is_active
|
||||
? t('app.admin.store.product_item.visible')
|
||||
: t('app.admin.store.product_item.hidden')
|
||||
}
|
||||
</span>
|
||||
<div className={`stock ${product.stock.internal < product.low_stock_threshold ? 'low' : ''}`}>
|
||||
<span>{t('app.admin.store.product_item.stock.internal')}</span>
|
||||
<p>{product.stock.internal}</p>
|
||||
</div>
|
||||
<div className={`stock ${product.stock.external < product.low_stock_threshold ? 'low' : ''}`}>
|
||||
<span>{t('app.admin.store.product_item.stock.external')}</span>
|
||||
<p>{product.stock.external}</p>
|
||||
</div>
|
||||
{product.amount &&
|
||||
<div className='price'>
|
||||
<p>{FormatLib.price(product.amount)}</p>
|
||||
<span>/ {t('app.admin.store.product_item.unit')}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className='actions'>
|
||||
<div className='manage'>
|
||||
<FabButton className='edit-btn' onClick={editProduct(product)}>
|
||||
<PencilSimple size={20} weight="fill" />
|
||||
</FabButton>
|
||||
<FabButton className='delete-btn' onClick={deleteProduct(product.id)}>
|
||||
<Trash size={20} weight="fill" />
|
||||
</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,208 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Product } from '../../models/product';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { Control, FormState } from 'react-hook-form/dist/types/form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import { FormSwitch } from '../form/form-switch';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import Select from 'react-select';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { PencilSimple } from 'phosphor-react';
|
||||
import { FabModal, ModalSize } from '../base/fab-modal';
|
||||
import { ProductStockModal } from './product-stock-modal';
|
||||
|
||||
interface ProductStockFormProps<TFieldValues, TContext extends object> {
|
||||
product: Product,
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
formState: FormState<TFieldValues>,
|
||||
onSuccess: (product: Product) => void,
|
||||
onError: (message: string) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Form tab to manage a product's stock
|
||||
*/
|
||||
export const ProductStockForm = <TFieldValues, TContext extends object> ({ product, register, control, formState, onError, onSuccess }: ProductStockFormProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [activeThreshold, setActiveThreshold] = useState<boolean>(false);
|
||||
// is the modal open?
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
// Styles the React-select component
|
||||
const customStyles = {
|
||||
control: base => ({
|
||||
...base,
|
||||
width: '20ch',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent'
|
||||
}),
|
||||
indicatorSeparator: () => ({
|
||||
display: 'none'
|
||||
})
|
||||
};
|
||||
|
||||
type selectOption = { value: number, label: string };
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildEventsOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_form.events.inward_stock') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_form.events.returned') },
|
||||
{ value: 2, label: t('app.admin.store.product_stock_form.events.canceled') },
|
||||
{ value: 3, label: t('app.admin.store.product_stock_form.events.sold') },
|
||||
{ value: 4, label: t('app.admin.store.product_stock_form.events.missing') },
|
||||
{ value: 5, label: t('app.admin.store.product_stock_form.events.damaged') }
|
||||
];
|
||||
};
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildStocksOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_form.internal') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_form.external') },
|
||||
{ value: 2, label: t('app.admin.store.product_stock_form.all') }
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* On events option change
|
||||
*/
|
||||
const eventsOptionsChange = (evt: selectOption) => {
|
||||
console.log('Event option:', evt);
|
||||
};
|
||||
/**
|
||||
* On stocks option change
|
||||
*/
|
||||
const stocksOptionsChange = (evt: selectOption) => {
|
||||
console.log('Stocks option:', evt);
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle stock threshold
|
||||
*/
|
||||
const toggleStockThreshold = (checked: boolean) => {
|
||||
setActiveThreshold(checked);
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens/closes the product category modal
|
||||
*/
|
||||
const toggleModal = (): void => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle stock threshold alert
|
||||
*/
|
||||
const toggleStockThresholdAlert = (checked: boolean) => {
|
||||
console.log('Low stock notification:', checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className='product-stock-form'>
|
||||
<h4>Stock à jour <span>00/00/0000 - 00H30</span></h4>
|
||||
<div></div>
|
||||
<div className="stock-item">
|
||||
<p className='title'>Product name</p>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.internal')}</span>
|
||||
<p>00</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.external')}</span>
|
||||
<p>000</p>
|
||||
</div>
|
||||
<FabButton onClick={toggleModal} icon={<PencilSimple size={20} weight="fill" />} className="is-black">Modifier</FabButton>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div className="threshold-data">
|
||||
<div className="header-switch">
|
||||
<h4>{t('app.admin.store.product_stock_form.low_stock_threshold')}</h4>
|
||||
<FormSwitch control={control}
|
||||
id="is_active_threshold"
|
||||
label={t('app.admin.store.product_stock_form.stock_threshold_toggle')}
|
||||
defaultValue={activeThreshold}
|
||||
onChange={toggleStockThreshold} />
|
||||
</div>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_stock_form.stock_threshold_information" />
|
||||
</FabAlert>
|
||||
{activeThreshold && <>
|
||||
<span className='stock-label'>{t('app.admin.store.product_stock_form.low_stock')}</span>
|
||||
<div className="threshold-data-content">
|
||||
<FormInput id="threshold"
|
||||
type="number"
|
||||
register={register}
|
||||
rules={{ required: true, min: 1 }}
|
||||
step={1}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_form.threshold_level')} />
|
||||
<FormSwitch control={control}
|
||||
id="threshold_alert"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_form.threshold_alert')}
|
||||
defaultValue={activeThreshold}
|
||||
onChange={toggleStockThresholdAlert} />
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div className="store-list">
|
||||
<h4>{t('app.admin.store.product_stock_form.events_history')}</h4>
|
||||
<div className="store-list-header">
|
||||
<div className='sort-events'>
|
||||
<p>{t('app.admin.store.product_stock_form.event_type')}</p>
|
||||
<Select
|
||||
options={buildEventsOptions()}
|
||||
onChange={evt => eventsOptionsChange(evt)}
|
||||
styles={customStyles}
|
||||
/>
|
||||
</div>
|
||||
<div className='sort-stocks'>
|
||||
<p>{t('app.admin.store.product_stock_form.stocks')}</p>
|
||||
<Select
|
||||
options={buildStocksOptions()}
|
||||
onChange={evt => stocksOptionsChange(evt)}
|
||||
styles={customStyles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stock-history">
|
||||
<div className="stock-item">
|
||||
<p className='title'>Product name</p>
|
||||
<p>00/00/0000</p>
|
||||
<div className="group">
|
||||
<span>[stock type]</span>
|
||||
<p>00</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.event_type')}</span>
|
||||
<p>[event type]</p>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>{t('app.admin.store.product_stock_form.stock_level')}</span>
|
||||
<p>000</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FabModal title={t('app.admin.store.product_stock_form.modal_title')}
|
||||
className="fab-modal-lg"
|
||||
width={ModalSize.large}
|
||||
isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
closeButton>
|
||||
<ProductStockModal product={product} register={register} control={control} id="stock-modal" onError={onError} onSuccess={onSuccess} />
|
||||
</FabModal>
|
||||
</section>
|
||||
);
|
||||
};
|
@ -0,0 +1,98 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Product } from '../../models/product';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { Control, FormState } from 'react-hook-form/dist/types/form';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
interface ProductStockModalProps<TFieldValues, TContext extends object> {
|
||||
product: Product,
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
formState: FormState<TFieldValues>,
|
||||
onSuccess: (product: Product) => void,
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Form to manage a product's stock movement and quantity
|
||||
*/
|
||||
// TODO: delete next eslint disable
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const ProductStockModal = <TFieldValues, TContext extends object> ({ product, register, control, formState, onError, onSuccess }: ProductStockModalProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [movement, setMovement] = useState<'in' | 'out'>('in');
|
||||
|
||||
/**
|
||||
* Toggle between adding or removing product from stock
|
||||
*/
|
||||
const toggleMovementType = (evt: React.MouseEvent<HTMLButtonElement, MouseEvent>, type: 'in' | 'out') => {
|
||||
evt.preventDefault();
|
||||
setMovement(type);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildEventsOptions = (): Array<selectOption> => {
|
||||
let options = [];
|
||||
movement === 'in'
|
||||
? options = [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_modal.events.inward_stock') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_modal.events.returned') },
|
||||
{ value: 2, label: t('app.admin.store.product_stock_modal.events.canceled') }
|
||||
]
|
||||
: options = [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_modal.events.sold') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_modal.events.missing') },
|
||||
{ value: 2, label: t('app.admin.store.product_stock_modal.events.damaged') }
|
||||
];
|
||||
return options;
|
||||
};
|
||||
/**
|
||||
* Creates sorting options to the react-select format
|
||||
*/
|
||||
const buildStocksOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
{ value: 0, label: t('app.admin.store.product_stock_modal.internal') },
|
||||
{ value: 1, label: t('app.admin.store.product_stock_modal.external') }
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<form className='product-stock-modal'>
|
||||
<p className='subtitle'>{t('app.admin.store.product_stock_modal.new_event')}</p>
|
||||
<div className="movement">
|
||||
<button onClick={(evt) => toggleMovementType(evt, 'in')} className={movement === 'in' ? 'is-active' : ''}>
|
||||
{t('app.admin.store.product_stock_modal.addition')}
|
||||
</button>
|
||||
<button onClick={(evt) => toggleMovementType(evt, 'out')} className={movement === 'out' ? 'is-active' : ''}>
|
||||
{t('app.admin.store.product_stock_modal.withdrawal')}
|
||||
</button>
|
||||
</div>
|
||||
<FormSelect options={buildStocksOptions()}
|
||||
control={control}
|
||||
id="updated_stock_type"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.stocks')} />
|
||||
<FormInput id="updated_stock_quantity"
|
||||
type="number"
|
||||
register={register}
|
||||
rules={{ required: true, min: 1 }}
|
||||
step={1}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.quantity')} />
|
||||
<FormSelect options={buildEventsOptions()}
|
||||
control={control}
|
||||
id="updated_stock_event"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_stock_modal.event_type')} />
|
||||
<FabButton type='submit'>{t('app.admin.store.product_stock_modal.update_stock')} </FabButton>
|
||||
</form>
|
||||
);
|
||||
};
|
366
app/frontend/src/javascript/components/store/products.tsx
Normal file
366
app/frontend/src/javascript/components/store/products.tsx
Normal 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
|
||||
}
|
||||
};
|
127
app/frontend/src/javascript/components/store/show-order.tsx
Normal file
127
app/frontend/src/javascript/components/store/show-order.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import noImage from '../../../../images/no_image.png';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface ShowOrderProps {
|
||||
orderRef: string,
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows an order details
|
||||
*/
|
||||
// TODO: delete next eslint disable
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const ShowOrder: React.FC<ShowOrderProps> = ({ orderRef, onError, onSuccess }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
/**
|
||||
* Returns a className according to the status
|
||||
*/
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'canceled':
|
||||
return 'canceled';
|
||||
case 'pending' || 'under_preparation':
|
||||
return 'pending';
|
||||
default:
|
||||
return 'normal';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='show-order'>
|
||||
<header>
|
||||
<h2>[order.ref]</h2>
|
||||
<div className="grpBtn">
|
||||
<a href={''}
|
||||
target='_blank'
|
||||
className='fab-button is-black'
|
||||
rel='noreferrer'>
|
||||
{t('app.admin.store.show_order.see_invoice')}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="client-info">
|
||||
<label>{t('app.admin.store.show_order.client')}</label>
|
||||
<div className="content">
|
||||
<div className='group'>
|
||||
<span>{t('app.admin.store.show_order.client')}</span>
|
||||
<p>order.user.name</p>
|
||||
</div>
|
||||
<div className='group'>
|
||||
<span>{t('app.admin.store.show_order.created_at')}</span>
|
||||
<p>order.created_at</p>
|
||||
</div>
|
||||
<div className='group'>
|
||||
<span>{t('app.admin.store.show_order.last_update')}</span>
|
||||
<p>order.???</p>
|
||||
</div>
|
||||
<span className={`order-status ${statusColor('error')}`}>order.state</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cart">
|
||||
<label>{t('app.admin.store.show_order.cart')}</label>
|
||||
<div>
|
||||
{/* loop sur les articles du panier */}
|
||||
<article className='store-cart-list-item'>
|
||||
<div className='picture'>
|
||||
<img alt=''src={noImage} />
|
||||
</div>
|
||||
<div className="ref">
|
||||
<span>{t('app.admin.store.show_order.reference_short')} orderable_id?</span>
|
||||
<p>o.orderable_name</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className='price'>
|
||||
<p>o.amount</p>
|
||||
<span>/ {t('app.admin.store.show_order.unit')}</span>
|
||||
</div>
|
||||
|
||||
<span className="count">o.quantity</span>
|
||||
|
||||
<div className='total'>
|
||||
<span>{t('app.admin.store.show_order.item_total')}</span>
|
||||
<p>o.quantity * o.amount</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group">
|
||||
<div className="payment-info">
|
||||
<label>{t('app.admin.store.show_order.payment_informations')}</label>
|
||||
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Ipsum rerum commodi quaerat possimus! Odit, harum.</p>
|
||||
</div>
|
||||
<div className="amount">
|
||||
<label>{t('app.admin.store.show_order.amount')}</label>
|
||||
<p>{t('app.admin.store.show_order.products_total')}<span>order.amount</span></p>
|
||||
<p className='gift'>{t('app.admin.store.show_order.gift_total')}<span>-order.amount</span></p>
|
||||
<p>{t('app.admin.store.show_order.coupon')}<span>order.amount</span></p>
|
||||
<p className='total'>{t('app.admin.store.show_order.total')} <span>order.total</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ShowOrderWrapper: React.FC<ShowOrderProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
<ShowOrder {...props} />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('showOrder', react2angular(ShowOrderWrapper, ['orderRef', 'onError', 'onSuccess']));
|
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Select from 'react-select';
|
||||
import Switch from 'react-switch';
|
||||
|
||||
interface StoreListHeaderProps {
|
||||
productsCount: number,
|
||||
selectOptions: selectOption[],
|
||||
onSelectOptionsChange: (option: selectOption) => void,
|
||||
switchLabel?: string,
|
||||
switchChecked?: boolean,
|
||||
onSwitch?: (boolean) => void
|
||||
}
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* Renders an accordion item
|
||||
*/
|
||||
export const StoreListHeader: React.FC<StoreListHeaderProps> = ({ productsCount, selectOptions, onSelectOptionsChange, switchLabel, switchChecked, onSwitch }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
// Styles the React-select component
|
||||
const customStyles = {
|
||||
control: base => ({
|
||||
...base,
|
||||
width: '20ch',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent'
|
||||
}),
|
||||
indicatorSeparator: () => ({
|
||||
display: 'none'
|
||||
})
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='store-list-header'>
|
||||
<div className='count'>
|
||||
<p>{t('app.admin.store.store_list_header.result_count')}<span>{productsCount}</span></p>
|
||||
</div>
|
||||
<div className="display">
|
||||
<div className='sort'>
|
||||
<p>{t('app.admin.store.store_list_header.display_options')}</p>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
onChange={evt => onSelectOptionsChange(evt)}
|
||||
styles={customStyles}
|
||||
/>
|
||||
</div>
|
||||
{onSwitch &&
|
||||
<div className='visibility'>
|
||||
<label>
|
||||
<span>{switchLabel || t('app.admin.store.store_list_header.visible_only')}</span>
|
||||
<Switch
|
||||
checked={switchChecked}
|
||||
onChange={(checked) => onSwitch(checked)}
|
||||
width={40}
|
||||
height={19}
|
||||
uncheckedIcon={false}
|
||||
checkedIcon={false}
|
||||
handleDiameter={15} />
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
198
app/frontend/src/javascript/components/store/store-product.tsx
Normal file
198
app/frontend/src/javascript/components/store/store-product.tsx
Normal 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']));
|
@ -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']));
|
275
app/frontend/src/javascript/components/store/store.tsx
Normal file
275
app/frontend/src/javascript/components/store/store.tsx
Normal 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[]
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
64
app/frontend/src/javascript/controllers/admin/store.js
Normal file
64
app/frontend/src/javascript/controllers/admin/store.js
Normal 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();
|
||||
}
|
||||
|
||||
]);
|
@ -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();
|
||||
}
|
||||
|
||||
]);
|
@ -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}`);
|
||||
|
53
app/frontend/src/javascript/controllers/cart.js
Normal file
53
app/frontend/src/javascript/controllers/cart.js
Normal 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();
|
||||
}
|
||||
|
||||
]);
|
@ -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',
|
||||
|
49
app/frontend/src/javascript/controllers/orders.js
Normal file
49
app/frontend/src/javascript/controllers/orders.js
Normal file
@ -0,0 +1,49 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-undef,
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
Application.Controllers.controller('ShowOrdersController', ['$scope', 'CSRF', 'growl', '$state', '$transition$',
|
||||
function ($scope, CSRF, growl, $state, $transition$) {
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/* PUBLIC SCOPE */
|
||||
$scope.orderToken = $transition$.params().token;
|
||||
|
||||
/**
|
||||
* Callback triggered in case of error
|
||||
*/
|
||||
$scope.onError = (message) => {
|
||||
growl.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered in case of success
|
||||
*/
|
||||
$scope.onSuccess = (message) => {
|
||||
growl.success(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Click Callback triggered in case of back products list
|
||||
*/
|
||||
$scope.backProductsList = () => {
|
||||
$state.go('app.admin.store.orders');
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/**
|
||||
* Kind of constructor: these actions will be realized first when the controller is loaded
|
||||
*/
|
||||
const initialize = function () {
|
||||
// set the authenticity tokens in the forms
|
||||
CSRF.setMetaTags();
|
||||
};
|
||||
|
||||
// init the controller (call at the end !)
|
||||
return initialize();
|
||||
}
|
||||
|
||||
]);
|
42
app/frontend/src/javascript/controllers/products.js
Normal file
42
app/frontend/src/javascript/controllers/products.js
Normal 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();
|
||||
}
|
||||
|
||||
]);
|
41
app/frontend/src/javascript/controllers/store.js
Normal file
41
app/frontend/src/javascript/controllers/store.js
Normal 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();
|
||||
}
|
||||
|
||||
]);
|
44
app/frontend/src/javascript/hooks/use-cart.ts
Normal file
44
app/frontend/src/javascript/hooks/use-cart.ts
Normal 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 };
|
||||
}
|
23
app/frontend/src/javascript/lib/cart-token.ts
Normal file
23
app/frontend/src/javascript/lib/cart-token.ts
Normal 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);
|
||||
};
|
13
app/frontend/src/javascript/lib/coupon.ts
Normal file
13
app/frontend/src/javascript/lib/coupon.ts
Normal 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;
|
||||
};
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
8
app/frontend/src/javascript/models/coupon.ts
Normal file
8
app/frontend/src/javascript/models/coupon.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface Coupon {
|
||||
id: number,
|
||||
code: string,
|
||||
type: string,
|
||||
amount_off: number,
|
||||
percent_off: number,
|
||||
validity_per_user: string
|
||||
}
|
37
app/frontend/src/javascript/models/order.ts
Normal file
37
app/frontend/src/javascript/models/order.ts
Normal 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
|
||||
}
|
7
app/frontend/src/javascript/models/product-category.ts
Normal file
7
app/frontend/src/javascript/models/product-category.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface ProductCategory {
|
||||
id: number,
|
||||
name: string,
|
||||
slug: string,
|
||||
parent_id?: number,
|
||||
position: number,
|
||||
}
|
58
app/frontend/src/javascript/models/product.ts
Normal file
58
app/frontend/src/javascript/models/product.ts
Normal 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
|
||||
}>,
|
||||
}
|
@ -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',
|
||||
|
@ -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";
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
41
app/frontend/src/stylesheets/modules/cart/cart-button.scss
Normal file
41
app/frontend/src/stylesheets/modules/cart/cart-button.scss
Normal file
@ -0,0 +1,41 @@
|
||||
.cart-button {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.8rem 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
background-color: var(--secondary);
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--secondary-text-color);
|
||||
border-radius: 10rem;
|
||||
color: var(--secondary);
|
||||
@include text-sm(600);
|
||||
}
|
||||
i {
|
||||
margin-bottom: 0.8rem;
|
||||
font-size: 2.6rem;
|
||||
columns: var(--secondary-text-color);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
@include text-sm;
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
}
|
186
app/frontend/src/stylesheets/modules/cart/store-cart.scss
Normal file
186
app/frontend/src/stylesheets/modules/cart/store-cart.scss
Normal file
@ -0,0 +1,186 @@
|
||||
.store-cart {
|
||||
width: 100%;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
grid-template-rows: minmax(0, min-content);
|
||||
gap: 3.2rem;
|
||||
align-items: flex-start;
|
||||
|
||||
&-list {
|
||||
grid-area: 1 / 1 / 2 / 10;
|
||||
display: grid;
|
||||
gap: 1.6rem;
|
||||
|
||||
&-item {
|
||||
padding: 0.8rem;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: min-content 1fr;
|
||||
gap: 1.6rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--gray-soft-lightest);
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
.picture {
|
||||
width: 10rem !important;
|
||||
@include imageRatio(76%);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
.ref {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
span {
|
||||
@include text-sm;
|
||||
color: var(--gray-hard-lightest);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
p {
|
||||
max-width: 60ch;
|
||||
margin: 0;
|
||||
@include text-base(600);
|
||||
}
|
||||
}
|
||||
.actions {
|
||||
align-self: stretch;
|
||||
padding: 0.8rem;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2.4rem;
|
||||
background-color: var(--gray-soft-light);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
.offer {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.8rem 1.6rem;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
@include text-base;
|
||||
cursor: pointer;
|
||||
span { margin-right: 0.8rem; }
|
||||
}
|
||||
}
|
||||
.price,
|
||||
.total {
|
||||
min-width: 10rem;
|
||||
p {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
@include title-base;
|
||||
}
|
||||
span { @include text-sm; }
|
||||
}
|
||||
.count {
|
||||
padding: 0.8rem 1.6rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--gray-soft);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
.total {
|
||||
span {
|
||||
@include text-sm;
|
||||
color: var(--main);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.group {
|
||||
grid-area: 2 / 1 / 3 / 10;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 2.4rem;
|
||||
}
|
||||
&-info,
|
||||
&-coupon {
|
||||
padding: 2.4rem;
|
||||
background-color: var(--gray-soft-light);
|
||||
border-radius: var(--border-radius);
|
||||
h3, label {
|
||||
margin: 0 0 1.6rem;
|
||||
@include text-base(500);
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
}
|
||||
.fab-input .input-wrapper {
|
||||
width: 100%;
|
||||
.fab-input--input {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
&-info {
|
||||
p { @include text-sm; }
|
||||
}
|
||||
|
||||
aside {
|
||||
grid-area: 1 / 10 / 3 / 13;
|
||||
& > div {
|
||||
margin-bottom: 3.2rem;
|
||||
padding: 1.6rem;
|
||||
background-color: var(--gray-soft-lightest);
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
h3,
|
||||
.member-select-title {
|
||||
margin: 0 0 2.4rem;
|
||||
padding-bottom: 1.2rem;
|
||||
border-bottom: 1px solid var(--gray-hard);
|
||||
@include title-base;
|
||||
color: var(--gray-hard-dark) !important;
|
||||
}
|
||||
}
|
||||
.checkout {
|
||||
.list {
|
||||
margin: 0.8rem 0 2.4rem;
|
||||
padding: 2.4rem 0;
|
||||
border-top: 1px solid var(--main);
|
||||
border-bottom: 1px solid var(--main);
|
||||
p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
span { @include title-base; }
|
||||
}
|
||||
.gift { color: var(--information); }
|
||||
}
|
||||
.total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
@include text-base(600);
|
||||
span { @include title-lg; }
|
||||
}
|
||||
|
||||
&-btn {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1.6rem 0.8rem;
|
||||
background-color: var(--information);
|
||||
border: none;
|
||||
color: var(--gray-soft-lightest);
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
&:hover {
|
||||
color: var(--gray-soft-lightest);
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -32,7 +32,7 @@
|
||||
background-color: var(--information-lightest);
|
||||
color: var(--information);
|
||||
border: 1px solid var(--information);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
line-height: 1.2em;
|
||||
@ -52,7 +52,7 @@
|
||||
&.is-required &-header p::after {
|
||||
content: "*";
|
||||
margin-left: 0.5ch;
|
||||
color: var(--error);
|
||||
color: var(--alert);
|
||||
}
|
||||
|
||||
&-field {
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
37
app/frontend/src/stylesheets/modules/layout/header-page.scss
Normal file
37
app/frontend/src/stylesheets/modules/layout/header-page.scss
Normal file
@ -0,0 +1,37 @@
|
||||
.header-page {
|
||||
width: 100%;
|
||||
min-height: 9rem;
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
background-color: var(--gray-soft-lightest);
|
||||
border-bottom: 1px solid var(--gray-soft-dark);
|
||||
|
||||
.back {
|
||||
width: 9rem;
|
||||
border-right: 1px solid var(--gray-soft-dark);
|
||||
a {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--gray-hard-darkest) !important;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
padding: 3.2rem;
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
min-width: 9rem;
|
||||
border-left: 1px solid var(--gray-soft-dark);
|
||||
}
|
||||
}
|
@ -1,11 +1,9 @@
|
||||
.machine-card {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
margin: 0 15px 30px;
|
||||
width: 30%;
|
||||
min-width: 263px;
|
||||
border-radius: var(--border-radius);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.loading::before {
|
||||
content: '';
|
||||
@ -37,18 +35,6 @@
|
||||
100% { transform: rotate(360deg);}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1219px) {
|
||||
width: 45%;
|
||||
min-width: 195px;
|
||||
margin: 0 auto 30px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 674px) {
|
||||
width: 95%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto 30px;
|
||||
}
|
||||
|
||||
.machine-picture {
|
||||
height: 250px;
|
||||
background-size: cover;
|
||||
|
@ -1,6 +1,44 @@
|
||||
.machines-list {
|
||||
.machines-list {
|
||||
.all-machines {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 3.2rem;
|
||||
|
||||
.store-ad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--main);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
color: var(--main-text-color);
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 3.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
h3 {
|
||||
margin: 0 0 2.4rem;
|
||||
@include title-lg;
|
||||
color: var(--main-text-color) !important;
|
||||
}
|
||||
p { margin: 0; }
|
||||
.sell {
|
||||
margin-top: auto;
|
||||
@include text-lg(500);
|
||||
}
|
||||
}
|
||||
.cta {
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
height: 5.4rem;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-color: var(--gray-hard-darkest);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
106
app/frontend/src/stylesheets/modules/store/_utilities.scss
Normal file
106
app/frontend/src/stylesheets/modules/store/_utilities.scss
Normal 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
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
.orders-dashboard {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 6rem;
|
||||
@include grid-col(12);
|
||||
gap: 3.2rem;
|
||||
align-items: flex-start;
|
||||
|
||||
header {
|
||||
@include header();
|
||||
padding-bottom: 0;
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
}
|
158
app/frontend/src/stylesheets/modules/store/orders.scss
Normal file
158
app/frontend/src/stylesheets/modules/store/orders.scss
Normal file
@ -0,0 +1,158 @@
|
||||
.orders,
|
||||
.show-order {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 6rem;
|
||||
@include grid-col(12);
|
||||
gap: 3.2rem;
|
||||
align-items: flex-start;
|
||||
|
||||
header {
|
||||
@include header();
|
||||
padding-bottom: 0;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
&-list {
|
||||
& > *:not(:first-child) {
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: 1fr 15rem 15rem 10ch 12rem;
|
||||
gap: 2.4rem;
|
||||
justify-items: flex-start;
|
||||
align-items: center;
|
||||
padding: 1.6rem;
|
||||
border: 1px solid var(--gray-soft-dark);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--gray-soft-lightest);
|
||||
|
||||
p { margin: 0; }
|
||||
.ref { @include text-base(600); }
|
||||
.client {
|
||||
span {
|
||||
@include text-xs;
|
||||
color: var(--gray-hard-light);
|
||||
}
|
||||
p { @include text-sm; }
|
||||
}
|
||||
.date { @include text-sm; }
|
||||
.price {
|
||||
justify-self: flex-end;
|
||||
span {
|
||||
@include text-xs;
|
||||
color: var(--gray-hard-light);
|
||||
}
|
||||
p { @include text-base(600); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.show-order {
|
||||
&-nav {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
@include grid-col(12);
|
||||
gap: 3.2rem;
|
||||
justify-items: flex-start;
|
||||
& > * {
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
}
|
||||
header { grid-column: 2 / -2; }
|
||||
.client-info,
|
||||
.cart {
|
||||
grid-column: 2 / -2;
|
||||
label {
|
||||
margin-bottom: 1.6rem;
|
||||
@include title-base;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& > *:not(:last-child) {
|
||||
margin-right: 2.4rem;
|
||||
padding-right: 2.4rem;
|
||||
border-right: 1px solid var(--gray-hard-dark);
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.18;
|
||||
}
|
||||
.group {
|
||||
span {
|
||||
@include text-xs;
|
||||
color: var(--gray-hard-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .group {
|
||||
grid-column: 2 / -2;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 2.4rem;
|
||||
align-items: flex-start;
|
||||
|
||||
.payment-info,
|
||||
.amount {
|
||||
padding: 2.4rem;
|
||||
border: 1px solid var(--gray-soft);
|
||||
border-radius: var(--border-radius);
|
||||
label {
|
||||
margin: 0 0 2.4rem;
|
||||
padding: 0 0 0.8rem;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--gray-hard);
|
||||
@include title-base;
|
||||
}
|
||||
}
|
||||
.amount {
|
||||
p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
span { @include title-base; }
|
||||
}
|
||||
.gift { color: var(--information); }
|
||||
.total {
|
||||
padding: 1.6rem 0 0;
|
||||
align-items: flex-start;
|
||||
border-top: 1px solid var(--main);
|
||||
@include text-base(600);
|
||||
span { @include title-lg; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.order-status {
|
||||
--status-color: var(--success);
|
||||
&.error { --status-color: var(--alert); }
|
||||
&.canceled { --status-color: var(--alert-light); }
|
||||
&.pending { --status-color: var(--information); }
|
||||
&.normal { --status-color: var(--success); }
|
||||
padding: 0.4rem 0.8rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--gray-soft-light);
|
||||
border-radius: var(--border-radius);
|
||||
@include text-sm(500);
|
||||
line-height: 1.714;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
margin-right: 0.8rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: var(--status-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user