mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-01-30 19:52:20 +01:00
Merge branch 'dev' for release X.Y.Z
This commit is contained in:
commit
87a57f27f4
@ -6,7 +6,8 @@
|
|||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"semi": ["error", "always"],
|
"semi": ["error", "always"],
|
||||||
"no-use-before-define": "off"
|
"no-use-before-define": "off",
|
||||||
|
"no-case-declarations": "off"
|
||||||
},
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"Application": true,
|
"Application": true,
|
||||||
|
@ -7,9 +7,9 @@ Layout/LineLength:
|
|||||||
Metrics/MethodLength:
|
Metrics/MethodLength:
|
||||||
Max: 35
|
Max: 35
|
||||||
Metrics/CyclomaticComplexity:
|
Metrics/CyclomaticComplexity:
|
||||||
Max: 13
|
Max: 14
|
||||||
Metrics/PerceivedComplexity:
|
Metrics/PerceivedComplexity:
|
||||||
Max: 11
|
Max: 14
|
||||||
Metrics/AbcSize:
|
Metrics/AbcSize:
|
||||||
Max: 45
|
Max: 45
|
||||||
Metrics/ClassLength:
|
Metrics/ClassLength:
|
||||||
@ -34,3 +34,7 @@ Style/AndOr:
|
|||||||
EnforcedStyle: conditionals
|
EnforcedStyle: conditionals
|
||||||
Style/FormatString:
|
Style/FormatString:
|
||||||
EnforcedStyle: sprintf
|
EnforcedStyle: sprintf
|
||||||
|
Rails/RedundantPresenceValidationOnBelongsTo:
|
||||||
|
Enabled: false
|
||||||
|
Rails/UnknownEnv:
|
||||||
|
Environments: development, test, staging, production
|
||||||
|
17
CHANGELOG.md
17
CHANGELOG.md
@ -1,5 +1,18 @@
|
|||||||
# Changelog Fab-manager
|
# Changelog Fab-manager
|
||||||
|
|
||||||
|
- Allow searching by username (#401)
|
||||||
|
- Fix a bug: adding a new event without updating the dates results in internal server error (undefined method `div' for nil)
|
||||||
|
- Fix a bug: portuguese time formatting (#405)
|
||||||
|
- Fix a bug: admin users groups being overriden by SSO group_id (#404)
|
||||||
|
- Fix a bug: no statistics on trainings and spaces reservations
|
||||||
|
- Fix a bug: invalid ventilation for amount coupons
|
||||||
|
- Fix a bug: invalid VAT for invoices using amount coupons
|
||||||
|
- Fix a bug: invalid 1 cent rounding for invoices using coupons
|
||||||
|
- Fix a bug: plans list error when there was no plan for the user's group
|
||||||
|
- Fix a security issue: updated nokogiri to 1.13.9 to fix [GHSA-2qc6-mcvw-92cw](https://github.com/advisories/GHSA-2qc6-mcvw-92cw)
|
||||||
|
- [TODO DEPLOY] `rails fablab:maintenance:regenerate_statistics[2021,6]`
|
||||||
|
- [TODO DEPLOY] `rails fablab:setup:set_admins_group`
|
||||||
|
|
||||||
## v5.4.25 2022 October 19
|
## v5.4.25 2022 October 19
|
||||||
|
|
||||||
- Fix a bug: unable apply a coupon if this coupon has used by an user removed
|
- Fix a bug: unable apply a coupon if this coupon has used by an user removed
|
||||||
@ -12,6 +25,10 @@
|
|||||||
## v5.4.23 2022 October 12
|
## v5.4.23 2022 October 12
|
||||||
|
|
||||||
- Fix a bug: unable to build docker image
|
- Fix a bug: unable to build docker image
|
||||||
|
- Fablab's store module
|
||||||
|
- Fix a bug: missing translations in PayZen configuration screens
|
||||||
|
- Fix a bug: wrong translation key prevents the display of the schedule deadline's payment mean
|
||||||
|
- [TODO DEPLOY] `rails db:seed`
|
||||||
|
|
||||||
## v5.4.22 2022 October 10
|
## v5.4.22 2022 October 10
|
||||||
|
|
||||||
|
2
Gemfile
2
Gemfile
@ -145,3 +145,5 @@ gem 'tzinfo-data'
|
|||||||
gem 'sassc', '= 2.1.0'
|
gem 'sassc', '= 2.1.0'
|
||||||
|
|
||||||
gem 'redis-session-store'
|
gem 'redis-session-store'
|
||||||
|
|
||||||
|
gem 'acts_as_list'
|
||||||
|
@ -48,6 +48,8 @@ GEM
|
|||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
minitest (~> 5.1)
|
minitest (~> 5.1)
|
||||||
tzinfo (~> 1.1)
|
tzinfo (~> 1.1)
|
||||||
|
acts_as_list (1.0.4)
|
||||||
|
activerecord (>= 4.2)
|
||||||
addressable (2.8.0)
|
addressable (2.8.0)
|
||||||
public_suffix (>= 2.0.2, < 5.0)
|
public_suffix (>= 2.0.2, < 5.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
@ -234,7 +236,7 @@ GEM
|
|||||||
multi_xml (0.6.0)
|
multi_xml (0.6.0)
|
||||||
multipart-post (2.1.1)
|
multipart-post (2.1.1)
|
||||||
nio4r (2.5.8)
|
nio4r (2.5.8)
|
||||||
nokogiri (1.13.8)
|
nokogiri (1.13.9)
|
||||||
mini_portile2 (~> 2.8.0)
|
mini_portile2 (~> 2.8.0)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
notify_with (0.0.2)
|
notify_with (0.0.2)
|
||||||
@ -500,6 +502,7 @@ DEPENDENCIES
|
|||||||
aasm
|
aasm
|
||||||
actionpack-page_caching (= 1.2.2)
|
actionpack-page_caching (= 1.2.2)
|
||||||
active_record_query_trace
|
active_record_query_trace
|
||||||
|
acts_as_list
|
||||||
api-pagination
|
api-pagination
|
||||||
apipie-rails
|
apipie-rails
|
||||||
awesome_print
|
awesome_print
|
||||||
|
@ -35,7 +35,7 @@ class API::AdminsController < API::ApiController
|
|||||||
|
|
||||||
def admin_params
|
def admin_params
|
||||||
params.require(:admin).permit(
|
params.require(:admin).permit(
|
||||||
:username, :email,
|
:username, :email, :group_id,
|
||||||
profile_attributes: %i[first_name last_name phone],
|
profile_attributes: %i[first_name last_name phone],
|
||||||
invoicing_profile_attributes: [address_attributes: [:address]],
|
invoicing_profile_attributes: [address_attributes: [:address]],
|
||||||
statistic_profile_attributes: %i[gender birthday]
|
statistic_profile_attributes: %i[gender birthday]
|
||||||
|
57
app/controllers/api/cart_controller.rb
Normal file
57
app/controllers/api/cart_controller.rb
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
def set_offer
|
||||||
|
authorize CartContext.new(params[:customer_id], cart_params[:is_offered])
|
||||||
|
@order = Cart::SetOfferService.new.call(@current_order, orderable, cart_params[:is_offered])
|
||||||
|
render 'api/orders/show'
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_item
|
||||||
|
authorize @current_order, policy_class: CartPolicy
|
||||||
|
@order = Cart::RefreshItemService.new.call(@current_order, orderable)
|
||||||
|
render 'api/orders/show'
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate
|
||||||
|
authorize @current_order, policy_class: CartPolicy
|
||||||
|
@order_errors = Cart::CheckCartService.new.call(@current_order)
|
||||||
|
render json: @order_errors
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def orderable
|
||||||
|
Product.find(cart_params[:orderable_id])
|
||||||
|
end
|
||||||
|
end
|
37
app/controllers/api/checkout_controller.rb
Normal file
37
app/controllers/api/checkout_controller.rb
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'stripe/helper'
|
||||||
|
require 'pay_zen/helper'
|
||||||
|
|
||||||
|
# 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 Stripe::StripeError => e
|
||||||
|
render json: Stripe::Helper.human_error(e), status: :unprocessable_entity
|
||||||
|
rescue PayzenError => e
|
||||||
|
render json: PayZen::Helper.human_error(e), status: :unprocessable_entity
|
||||||
|
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
|
@ -3,7 +3,7 @@
|
|||||||
# API Controller for resources of type Coupon
|
# API Controller for resources of type Coupon
|
||||||
# Coupons are used in payments
|
# Coupons are used in payments
|
||||||
class API::CouponsController < API::ApiController
|
class API::CouponsController < API::ApiController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!, except: %i[validate]
|
||||||
before_action :set_coupon, only: %i[show update destroy]
|
before_action :set_coupon, only: %i[show update destroy]
|
||||||
|
|
||||||
# Number of notifications added to the page when the user clicks on 'load next notifications'
|
# Number of notifications added to the page when the user clicks on 'load next notifications'
|
||||||
@ -31,18 +31,18 @@ class API::CouponsController < API::ApiController
|
|||||||
if @coupon.nil?
|
if @coupon.nil?
|
||||||
render json: { status: 'rejected' }, status: :not_found
|
render json: { status: 'rejected' }, status: :not_found
|
||||||
else
|
else
|
||||||
_user_id = if !current_user.admin?
|
_user_id = if current_user&.admin?
|
||||||
current_user.id
|
|
||||||
else
|
|
||||||
params[:user_id]
|
params[:user_id]
|
||||||
|
else
|
||||||
|
current_user&.id
|
||||||
end
|
end
|
||||||
|
|
||||||
amount = params[:amount].to_f * 100.0
|
amount = params[:amount].to_f * 100.0
|
||||||
status = @coupon.status(_user_id, amount)
|
status = @coupon.status(_user_id, amount)
|
||||||
if status != 'active'
|
if status == 'active'
|
||||||
render json: { status: status }, status: :unprocessable_entity
|
|
||||||
else
|
|
||||||
render :validate, status: :ok, location: @coupon
|
render :validate, status: :ok, location: @coupon
|
||||||
|
else
|
||||||
|
render json: { status: status }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -6,7 +6,7 @@ class API::GroupsController < API::ApiController
|
|||||||
before_action :authenticate_user!, except: :index
|
before_action :authenticate_user!, except: :index
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@groups = GroupService.list(current_user, params)
|
@groups = GroupService.list(params)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
@ -157,7 +157,7 @@ class API::MembersController < API::ApiController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def search
|
def search
|
||||||
@members = Members::ListService.search(current_user, params[:query], params[:subscription], params[:include_admins])
|
@members = Members::ListService.search(current_user, params[:query], params[:subscription])
|
||||||
end
|
end
|
||||||
|
|
||||||
def mapping
|
def mapping
|
||||||
|
43
app/controllers/api/orders_controller.rb
Normal file
43
app/controllers/api/orders_controller.rb
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# API Controller for resources of type Order
|
||||||
|
# Orders are used in store
|
||||||
|
class API::OrdersController < API::ApiController
|
||||||
|
before_action :authenticate_user!, except: %i[withdrawal_instructions]
|
||||||
|
before_action :set_order, only: %i[show update destroy withdrawal_instructions]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@result = ::Orders::OrderService.list(params, current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @order
|
||||||
|
|
||||||
|
@order = ::Orders::OrderService.update_state(@order, current_user, order_params[:state], order_params[:note])
|
||||||
|
render :show
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
authorize @order
|
||||||
|
@order.destroy
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
def withdrawal_instructions
|
||||||
|
authorize @order
|
||||||
|
|
||||||
|
render html: ::Orders::OrderService.withdrawal_instructions(@order)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_order
|
||||||
|
@order = Order.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def order_params
|
||||||
|
params.require(:order).permit(:state, :note)
|
||||||
|
end
|
||||||
|
end
|
@ -8,7 +8,7 @@ class API::PaymentSchedulesController < API::ApiController
|
|||||||
|
|
||||||
# retrieve all payment schedules for the current user, paginated
|
# retrieve all payment schedules for the current user, paginated
|
||||||
def index
|
def index
|
||||||
@payment_schedules = PaymentSchedule.where('invoicing_profile_id = ?', current_user.invoicing_profile.id)
|
@payment_schedules = PaymentSchedule.where(invoicing_profile_id: current_user.invoicing_profile.id)
|
||||||
.includes(:invoicing_profile, :payment_schedule_items, :payment_schedule_objects)
|
.includes(:invoicing_profile, :payment_schedule_items, :payment_schedule_objects)
|
||||||
.joins(:invoicing_profile)
|
.joins(:invoicing_profile)
|
||||||
.order('payment_schedules.created_at DESC')
|
.order('payment_schedules.created_at DESC')
|
||||||
@ -34,14 +34,14 @@ class API::PaymentSchedulesController < API::ApiController
|
|||||||
|
|
||||||
def download
|
def download
|
||||||
authorize @payment_schedule
|
authorize @payment_schedule
|
||||||
send_file File.join(Rails.root, @payment_schedule.file), type: 'application/pdf', disposition: 'attachment'
|
send_file Rails.root.join(@payment_schedule.file), type: 'application/pdf', disposition: 'attachment'
|
||||||
end
|
end
|
||||||
|
|
||||||
def cash_check
|
def cash_check
|
||||||
authorize @payment_schedule_item.payment_schedule
|
authorize @payment_schedule_item.payment_schedule
|
||||||
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'check')
|
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'check')
|
||||||
attrs = { state: 'paid', payment_method: 'check' }
|
attrs = { state: 'paid', payment_method: 'check' }
|
||||||
@payment_schedule_item.update_attributes(attrs)
|
@payment_schedule_item.update(attrs)
|
||||||
|
|
||||||
render json: attrs, status: :ok
|
render json: attrs, status: :ok
|
||||||
end
|
end
|
||||||
@ -50,7 +50,7 @@ class API::PaymentSchedulesController < API::ApiController
|
|||||||
authorize @payment_schedule_item.payment_schedule
|
authorize @payment_schedule_item.payment_schedule
|
||||||
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'transfer')
|
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'transfer')
|
||||||
attrs = { state: 'paid', payment_method: 'transfer' }
|
attrs = { state: 'paid', payment_method: 'transfer' }
|
||||||
@payment_schedule_item.update_attributes(attrs)
|
@payment_schedule_item.update(attrs)
|
||||||
|
|
||||||
render json: attrs, status: :ok
|
render json: attrs, status: :ok
|
||||||
end
|
end
|
||||||
|
63
app/controllers/api/product_categories_controller.rb
Normal file
63
app/controllers/api/product_categories_controller.rb
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# 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
|
||||||
|
render json: @product_category, status: :not_modified and return if @product_category.position == params[:position]
|
||||||
|
|
||||||
|
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
|
78
app/controllers/api/products_controller.rb
Normal file
78
app/controllers/api/products_controller.rb
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# 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 clone destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@products = ProductService.list(params, current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@product = Product.includes(:product_images, :product_files).friendly.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize Product
|
||||||
|
@product = ProductService.create(product_params, params[:product][:product_stock_movements_attributes])
|
||||||
|
if @product.save
|
||||||
|
render status: :created
|
||||||
|
else
|
||||||
|
render json: @product.errors.full_messages, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @product
|
||||||
|
|
||||||
|
@product = ProductService.update(@product, product_params, params[:product][:product_stock_movements_attributes])
|
||||||
|
if @product.save
|
||||||
|
render status: :ok
|
||||||
|
else
|
||||||
|
render json: @product.errors.full_messages, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def clone
|
||||||
|
authorize @product
|
||||||
|
|
||||||
|
@product = ProductService.clone(@product, product_params)
|
||||||
|
if @product.save
|
||||||
|
render status: :ok
|
||||||
|
else
|
||||||
|
render json: @product.errors.full_messages, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
authorize @product
|
||||||
|
begin
|
||||||
|
ProductService.destroy(@product)
|
||||||
|
head :no_content
|
||||||
|
rescue StandardError => e
|
||||||
|
render json: e, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def stock_movements
|
||||||
|
authorize Product
|
||||||
|
@movements = ProductService.stock_movements(params)
|
||||||
|
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])
|
||||||
|
end
|
||||||
|
end
|
@ -26,13 +26,13 @@ class API::SettingsController < API::ApiController
|
|||||||
authorize Setting
|
authorize Setting
|
||||||
|
|
||||||
@settings = []
|
@settings = []
|
||||||
may_transaction(params[:transactional]) do
|
may_transaction params[:transactional] do
|
||||||
params[:settings].each do |setting|
|
params[:settings].each do |setting|
|
||||||
next if !setting[:name] || !setting[:value]
|
next if !setting[:name] || !setting[:value]
|
||||||
|
|
||||||
db_setting = Setting.find_or_initialize_by(name: setting[:name])
|
db_setting = Setting.find_or_initialize_by(name: setting[:name])
|
||||||
if !SettingService.before_update(db_setting)
|
if !SettingService.before_update(db_setting)
|
||||||
db_setting.errors[:-] << I18n.t("settings.#{setting[:name]}") + ': ' + I18n.t('settings.locked_setting')
|
db_setting.errors.add(:-, "#{I18n.t("settings.#{setting[:name]}")}: #{I18n.t('settings.locked_setting')}")
|
||||||
elsif db_setting.save
|
elsif db_setting.save
|
||||||
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
|
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
|
||||||
SettingService.after_update(db_setting)
|
SettingService.after_update(db_setting)
|
||||||
@ -66,7 +66,7 @@ class API::SettingsController < API::ApiController
|
|||||||
first_val = setting.history_values.order(created_at: :asc).limit(1).first
|
first_val = setting.history_values.order(created_at: :asc).limit(1).first
|
||||||
new_val = HistoryValue.create!(
|
new_val = HistoryValue.create!(
|
||||||
setting_id: setting.id,
|
setting_id: setting.id,
|
||||||
value: first_val.value,
|
value: first_val&.value,
|
||||||
invoicing_profile_id: current_user.invoicing_profile.id
|
invoicing_profile_id: current_user.invoicing_profile.id
|
||||||
)
|
)
|
||||||
SettingService.after_update(setting)
|
SettingService.after_update(setting)
|
||||||
@ -84,11 +84,9 @@ class API::SettingsController < API::ApiController
|
|||||||
end
|
end
|
||||||
|
|
||||||
# run the given block in a transaction if `should` is true. Just run it normally otherwise
|
# run the given block in a transaction if `should` is true. Just run it normally otherwise
|
||||||
def may_transaction(should)
|
def may_transaction(should, &block)
|
||||||
if should == 'true'
|
if should == 'true'
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction(&block)
|
||||||
yield
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
yield
|
yield
|
||||||
end
|
end
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
# API Controller for resources of type Space
|
# API Controller for various statistical resources (gateway to elasticsearch DB)
|
||||||
class API::StatisticsController < API::ApiController
|
class API::StatisticsController < API::ApiController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
|
||||||
@ -9,49 +9,25 @@ class API::StatisticsController < API::ApiController
|
|||||||
@statistics = StatisticIndex.all
|
@statistics = StatisticIndex.all
|
||||||
end
|
end
|
||||||
|
|
||||||
%w[account event machine project subscription training user space].each do |path|
|
%w[account event machine project subscription training user space order].each do |path|
|
||||||
class_eval %{
|
class_eval %{
|
||||||
def #{path}
|
def #{path} # def account
|
||||||
authorize :statistic, :#{path}?
|
authorize :statistic, :#{path}? # authorize :statistic, :account
|
||||||
|
render json: Statistics::QueryService.query('#{path}', request) # render json: Statistics::QueryService.query('account', request)
|
||||||
|
end # end
|
||||||
|
|
||||||
# remove additional parameters
|
def export_#{path} # def export_account
|
||||||
statistic_type = request.query_parameters.delete('stat-type')
|
authorize :statistic, :export_#{path}? # authorize :statistic, :export_account?
|
||||||
custom_query = request.query_parameters.delete('custom-query')
|
|
||||||
start_date = request.query_parameters.delete('start-date')
|
|
||||||
end_date = request.query_parameters.delete('end-date')
|
|
||||||
|
|
||||||
# run main query in elasticSearch
|
@export = Statistics::QueryService.export('#{path}', params) # @export = Statistics::QueryService.export('account', params)
|
||||||
query = MultiJson.load(request.body.read)
|
if @export.is_a?(Export)
|
||||||
results = Stats::#{path.classify}.search(query, request.query_parameters.symbolize_keys).response
|
|
||||||
|
|
||||||
# run additional custom aggregations, if any
|
|
||||||
CustomAggregationService.new.("#{path}", statistic_type, start_date, end_date, custom_query, results)
|
|
||||||
|
|
||||||
# return result
|
|
||||||
render json: results
|
|
||||||
end
|
|
||||||
}, __FILE__, __LINE__ - 20
|
|
||||||
end
|
|
||||||
|
|
||||||
%w[account event machine project subscription training user space].each do |path|
|
|
||||||
class_eval %{
|
|
||||||
def export_#{path}
|
|
||||||
authorize :statistic, :export_#{path}?
|
|
||||||
|
|
||||||
export = Export.where(category:'statistics', export_type: '#{path}', query: params[:body], key: params[:type_key]).last
|
|
||||||
if export.nil? || !FileTest.exist?(export.file)
|
|
||||||
@export = Export.new(category:'statistics',
|
|
||||||
export_type: '#{path}',
|
|
||||||
user: current_user,
|
|
||||||
query: params[:body],
|
|
||||||
key: params[:type_key])
|
|
||||||
if @export.save
|
if @export.save
|
||||||
render json: {export_id: @export.id}, status: :ok
|
render json: { export_id: @export.id }, status: :ok
|
||||||
else
|
else
|
||||||
render json: @export.errors, status: :unprocessable_entity
|
render json: @export.errors, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
send_file File.join(Rails.root, export.file),
|
send_file @export,
|
||||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
disposition: 'attachment'
|
disposition: 'attachment'
|
||||||
end
|
end
|
||||||
@ -62,16 +38,15 @@ class API::StatisticsController < API::ApiController
|
|||||||
def export_global
|
def export_global
|
||||||
authorize :statistic, :export_global?
|
authorize :statistic, :export_global?
|
||||||
|
|
||||||
export = Export.where(category: 'statistics', export_type: 'global', query: params[:body]).last
|
@export = Statistics::QueryService.export(global, params)
|
||||||
if export.nil? || !FileTest.exist?(export.file)
|
if @export.is_a?(Export)
|
||||||
@export = Export.new(category: 'statistics', export_type: 'global', user: current_user, query: params[:body])
|
|
||||||
if @export.save
|
if @export.save
|
||||||
render json: { export_id: @export.id }, status: :ok
|
render json: { export_id: @export.id }, status: :ok
|
||||||
else
|
else
|
||||||
render json: @export.errors, status: :unprocessable_entity
|
render json: @export.errors, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
send_file File.join(Rails.root, export.file),
|
send_file @export,
|
||||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
disposition: 'attachment'
|
disposition: 'attachment'
|
||||||
end
|
end
|
||||||
|
@ -18,7 +18,7 @@ class API::WalletController < API::ApiController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def credit
|
def credit
|
||||||
return head 422 unless Setting.get('wallet_module')
|
return head :unprocessable_entity unless Setting.get('wallet_module')
|
||||||
|
|
||||||
@wallet = Wallet.find(credit_params[:id])
|
@wallet = Wallet.find(credit_params[:id])
|
||||||
authorize @wallet
|
authorize @wallet
|
||||||
@ -28,7 +28,7 @@ class API::WalletController < API::ApiController
|
|||||||
service.create_avoir(transaction, credit_params[:avoir_date], credit_params[:avoir_description]) if credit_params[:avoir]
|
service.create_avoir(transaction, credit_params[:avoir_date], credit_params[:avoir_description]) if credit_params[:avoir]
|
||||||
render :show
|
render :show
|
||||||
else
|
else
|
||||||
head 422
|
head :unprocessable_entity
|
||||||
end
|
end
|
||||||
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, :is_offered)
|
||||||
|
end
|
||||||
|
end
|
5
app/exceptions/cannot_delete_product_error.rb
Normal file
5
app/exceptions/cannot_delete_product_error.rb
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Raised when deleting a product, if this product is used in orders
|
||||||
|
class CannotDeleteProductError < StandardError
|
||||||
|
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/item_amount_error.rb
Normal file
5
app/exceptions/cart/item_amount_error.rb
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Raised when the item's amount != product's amount
|
||||||
|
class Cart::ItemAmountError < 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/quantity_min_error.rb
Normal file
5
app/exceptions/cart/quantity_min_error.rb
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Raised when the item's quantity < product's quantity min
|
||||||
|
class Cart::QuantityMinError < 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
|
3
app/exceptions/update_order_state_error.rb
Normal file
3
app/exceptions/update_order_state_error.rb
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Raised when update order state error
|
||||||
|
class UpdateOrderStateError < StandardError
|
||||||
|
end
|
BIN
app/frontend/images/default-image.png
Normal file
BIN
app/frontend/images/default-image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
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 |
40
app/frontend/src/javascript/api/cart.ts
Normal file
40
app/frontend/src/javascript/api/cart.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import apiClient from './clients/api-client';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { Order, OrderErrors } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async setOffer (order: Order, orderableId: number, isOffered: boolean): Promise<Order> {
|
||||||
|
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, is_offered: isOffered, customer_id: order.user?.id });
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async refreshItem (order: Order, orderableId: number): Promise<Order> {
|
||||||
|
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/refresh_item', { order_token: order.token, orderable_id: orderableId });
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async validate (order: Order): Promise<OrderErrors> {
|
||||||
|
const res: AxiosResponse<OrderErrors> = await apiClient.post('/api/cart/validate', { order_token: order.token });
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
}
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import ParsingLib from '../../lib/parsing';
|
||||||
|
|
||||||
type Error = { error: string };
|
type Error = { error: string };
|
||||||
|
|
||||||
@ -48,7 +49,9 @@ function extractHumanReadableMessage (error: string|Error): string {
|
|||||||
// iterate through all the keys to build the message
|
// iterate through all the keys to build the message
|
||||||
for (const key in error) {
|
for (const key in error) {
|
||||||
if (Object.prototype.hasOwnProperty.call(error, key)) {
|
if (Object.prototype.hasOwnProperty.call(error, key)) {
|
||||||
message += `${key} : `;
|
if (!ParsingLib.isInteger(key)) {
|
||||||
|
message += `${key} : `;
|
||||||
|
}
|
||||||
if (error[key] instanceof Array) {
|
if (error[key] instanceof Array) {
|
||||||
// standard rails messages are stored as {field: [error1, error2]}
|
// standard rails messages are stored as {field: [error1, error2]}
|
||||||
// we rebuild them as "field: error1, error2"
|
// we rebuild them as "field: error1, error2"
|
||||||
|
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;
|
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> {
|
static async create (user: User): Promise<User> {
|
||||||
const data = serialize({ user });
|
const data = serialize({ user });
|
||||||
if (user.profile_attributes?.user_avatar_attributes?.attachment_files[0]) {
|
if (user.profile_attributes?.user_avatar_attributes?.attachment_files[0]) {
|
||||||
|
26
app/frontend/src/javascript/api/order.ts
Normal file
26
app/frontend/src/javascript/api/order.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import apiClient from './clients/api-client';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { Order, OrderIndexFilter, OrderIndex } from '../models/order';
|
||||||
|
import ApiLib from '../lib/api';
|
||||||
|
|
||||||
|
export default class OrderAPI {
|
||||||
|
static async index (filters?: OrderIndexFilter): Promise<OrderIndex> {
|
||||||
|
const res: AxiosResponse<OrderIndex> = await apiClient.get(`/api/orders${ApiLib.filtersToQuery(filters, false)}`);
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async get (id: number | string): Promise<Order> {
|
||||||
|
const res: AxiosResponse<Order> = await apiClient.get(`/api/orders/${id}`);
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateState (order: Order, state: string, note?: string): Promise<Order> {
|
||||||
|
const res: AxiosResponse<Order> = await apiClient.patch(`/api/orders/${order.id}`, { order: { state, note } });
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async withdrawalInstructions (order?: Order): Promise<string> {
|
||||||
|
const res: AxiosResponse<string> = await apiClient.get(`/api/orders/${order?.id}/withdrawal_instructions`);
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
}
|
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;
|
||||||
|
}
|
||||||
|
}
|
110
app/frontend/src/javascript/api/product.ts
Normal file
110
app/frontend/src/javascript/api/product.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import apiClient from './clients/api-client';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { serialize } from 'object-to-formdata';
|
||||||
|
import {
|
||||||
|
Product,
|
||||||
|
ProductIndexFilter,
|
||||||
|
ProductsIndex,
|
||||||
|
StockMovementIndex, StockMovementIndexFilter
|
||||||
|
} from '../models/product';
|
||||||
|
import ApiLib from '../lib/api';
|
||||||
|
import ProductLib from '../lib/product';
|
||||||
|
|
||||||
|
export default class ProductAPI {
|
||||||
|
static async index (filters?: ProductIndexFilter): Promise<ProductsIndex> {
|
||||||
|
const res: AxiosResponse<ProductsIndex> = await apiClient.get(`/api/products${ApiLib.filtersToQuery(ProductLib.indexFiltersToIds(filters), false)}`);
|
||||||
|
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 clone (product: Product, data: Product): Promise<Product> {
|
||||||
|
const res: AxiosResponse<Product> = await apiClient.put(`/api/products/${product.id}/clone`, {
|
||||||
|
product: data
|
||||||
|
});
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async destroy (productId: number): Promise<void> {
|
||||||
|
const res: AxiosResponse<void> = await apiClient.delete(`/api/products/${productId}`);
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async stockMovements (productId: number, filters: StockMovementIndexFilter): Promise<StockMovementIndex> {
|
||||||
|
const res: AxiosResponse<StockMovementIndex> = await apiClient.get(`/api/products/${productId}/stock_movements${ApiLib.filtersToQuery(filters)}`);
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,13 @@
|
|||||||
import apiClient from './clients/api-client';
|
import apiClient from './clients/api-client';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { Setting, SettingBulkResult, SettingError, SettingName, SettingValue } from '../models/setting';
|
import {
|
||||||
|
Setting,
|
||||||
|
SettingBulkArray,
|
||||||
|
SettingBulkResult,
|
||||||
|
SettingError,
|
||||||
|
SettingName,
|
||||||
|
SettingValue
|
||||||
|
} from '../models/setting';
|
||||||
|
|
||||||
export default class SettingAPI {
|
export default class SettingAPI {
|
||||||
static async get (name: SettingName): Promise<Setting> {
|
static async get (name: SettingName): Promise<Setting> {
|
||||||
@ -8,7 +15,7 @@ export default class SettingAPI {
|
|||||||
return res?.data?.setting;
|
return res?.data?.setting;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async query (names: Array<SettingName>): Promise<Map<SettingName, string>> {
|
static async query (names: readonly SettingName[]): Promise<Map<SettingName, string>> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('names', `['${names.join("','")}']`);
|
params.append('names', `['${names.join("','")}']`);
|
||||||
|
|
||||||
@ -32,7 +39,7 @@ export default class SettingAPI {
|
|||||||
return res?.data?.isPresent;
|
return res?.data?.isPresent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static toSettingsMap (names: Array<SettingName>, data: Record<string, string|null>): Map<SettingName, string> {
|
private static toSettingsMap (names: readonly SettingName[], data: Record<string, string|null>): Map<SettingName, string> {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
names.forEach(name => {
|
names.forEach(name => {
|
||||||
map.set(name, data[name] || '');
|
map.set(name, data[name] || '');
|
||||||
@ -60,7 +67,7 @@ export default class SettingAPI {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static toObjectArray (data: Map<SettingName, SettingValue>): Array<Record<string, SettingValue>> {
|
private static toObjectArray (data: Map<SettingName, SettingValue>): SettingBulkArray {
|
||||||
const array = [];
|
const array = [];
|
||||||
data.forEach((value, key) => {
|
data.forEach((value, key) => {
|
||||||
array.push({
|
array.push({
|
||||||
|
@ -0,0 +1,30 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -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.
|
* If the default value changes, update the value of the input until there's no content in it.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inputValue) {
|
setInputValue(defaultValue);
|
||||||
setInputValue(defaultValue);
|
if (typeof onChange === 'function') {
|
||||||
if (typeof onChange === 'function') {
|
onChange(defaultValue);
|
||||||
onChange(defaultValue);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [defaultValue]);
|
}, [defaultValue]);
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@ interface FabModalProps {
|
|||||||
customHeader?: ReactNode,
|
customHeader?: ReactNode,
|
||||||
customFooter?: ReactNode,
|
customFooter?: ReactNode,
|
||||||
onConfirm?: (event: BaseSyntheticEvent) => void,
|
onConfirm?: (event: BaseSyntheticEvent) => void,
|
||||||
|
onClose?: (event: BaseSyntheticEvent) => void,
|
||||||
preventConfirm?: boolean,
|
preventConfirm?: boolean,
|
||||||
onCreation?: () => void,
|
onCreation?: () => void,
|
||||||
onConfirmSendFormId?: string,
|
onConfirmSendFormId?: string,
|
||||||
@ -31,7 +32,7 @@ interface FabModalProps {
|
|||||||
/**
|
/**
|
||||||
* This component is a template for a modal dialog that wraps the application style
|
* This component is a template for a modal dialog that wraps the application style
|
||||||
*/
|
*/
|
||||||
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => {
|
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, onClose, preventConfirm, onCreation, onConfirmSendFormId }) => {
|
||||||
const { t } = useTranslation('shared');
|
const { t } = useTranslation('shared');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -40,12 +41,20 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user request to close the modal without confirming.
|
||||||
|
*/
|
||||||
|
const handleClose = (event) => {
|
||||||
|
if (typeof onClose === 'function') onClose(event);
|
||||||
|
toggleModal();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen}
|
<Modal isOpen={isOpen}
|
||||||
className={`fab-modal fab-modal-${width} ${className}`}
|
className={`fab-modal fab-modal-${width} ${className || ''}`}
|
||||||
overlayClassName="fab-modal-overlay"
|
overlayClassName="fab-modal-overlay"
|
||||||
onRequestClose={toggleModal}>
|
onRequestClose={handleClose}>
|
||||||
{closeButton && <FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.fab_modal.close')}</FabButton>}
|
{closeButton && <FabButton className="modal-btn--close" onClick={handleClose}>{t('app.shared.fab_modal.close')}</FabButton>}
|
||||||
<div className="fab-modal-header">
|
<div className="fab-modal-header">
|
||||||
{!customHeader && <h1>{ title }</h1>}
|
{!customHeader && <h1>{ title }</h1>}
|
||||||
{customHeader && customHeader}
|
{customHeader && customHeader}
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CaretDoubleLeft, CaretLeft, CaretRight, CaretDoubleRight } from 'phosphor-react';
|
||||||
|
|
||||||
|
interface FabPaginationProps {
|
||||||
|
pageCount: number,
|
||||||
|
currentPage: number,
|
||||||
|
selectPage: (page: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a pagination navigation
|
||||||
|
*/
|
||||||
|
export const FabPagination: React.FC<FabPaginationProps> = ({ pageCount, currentPage, selectPage }) => {
|
||||||
|
return (
|
||||||
|
<nav className='fab-pagination'>
|
||||||
|
{currentPage - 2 > 1 &&
|
||||||
|
<button type="button" onClick={() => selectPage(1)}><CaretDoubleLeft size={24} /></button>
|
||||||
|
}
|
||||||
|
{currentPage - 1 >= 1 &&
|
||||||
|
<button type="button" onClick={() => selectPage(currentPage - 1)}><CaretLeft size={24} /></button>
|
||||||
|
}
|
||||||
|
{currentPage - 2 >= 1 &&
|
||||||
|
<button type="button" onClick={() => selectPage(currentPage - 2)}>{currentPage - 2}</button>
|
||||||
|
}
|
||||||
|
{currentPage - 1 >= 1 &&
|
||||||
|
<button type="button" onClick={() => selectPage(currentPage - 1)}>{currentPage - 1}</button>
|
||||||
|
}
|
||||||
|
<button type="button" className='is-active'>{currentPage}</button>
|
||||||
|
{currentPage + 1 <= pageCount &&
|
||||||
|
<button type="button" onClick={() => selectPage(currentPage + 1)}>{currentPage + 1}</button>
|
||||||
|
}
|
||||||
|
{currentPage + 2 <= pageCount &&
|
||||||
|
<button type="button" onClick={() => selectPage(currentPage + 2)}>{currentPage + 2}</button>
|
||||||
|
}
|
||||||
|
{currentPage + 1 <= pageCount &&
|
||||||
|
<button type="button" onClick={() => selectPage(currentPage + 1)}><CaretRight size={24} /></button>
|
||||||
|
}
|
||||||
|
{currentPage + 2 < pageCount &&
|
||||||
|
<button type="button" onClick={() => selectPage(pageCount)}><CaretDoubleRight size={24} /></button>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface FabStateLabelProps {
|
||||||
|
status?: string,
|
||||||
|
background?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a label preceded by a bot
|
||||||
|
*/
|
||||||
|
export const FabStateLabel: React.FC<FabStateLabelProps> = ({ status, background, children }) => {
|
||||||
|
return (
|
||||||
|
<span className={`fab-state-label ${status !== undefined ? status : ''} ${background ? 'bg' : ''}`}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
@ -12,11 +12,14 @@ import { MenuBar } from './menu-bar';
|
|||||||
import { WarningOctagon } from 'phosphor-react';
|
import { WarningOctagon } from 'phosphor-react';
|
||||||
|
|
||||||
interface FabTextEditorProps {
|
interface FabTextEditorProps {
|
||||||
paragraphTools?: boolean,
|
heading?: boolean,
|
||||||
content?: string,
|
bulletList?: boolean,
|
||||||
limit?: number,
|
blockquote?: boolean,
|
||||||
|
link?: boolean,
|
||||||
video?: boolean,
|
video?: boolean,
|
||||||
image?: boolean,
|
image?: boolean,
|
||||||
|
content?: string,
|
||||||
|
limit?: number,
|
||||||
onChange?: (content: string) => void,
|
onChange?: (content: string) => void,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
error?: string,
|
error?: string,
|
||||||
@ -30,7 +33,7 @@ export interface FabTextEditorRef {
|
|||||||
/**
|
/**
|
||||||
* This component is a WYSIWYG text editor
|
* 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>) => {
|
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 { t } = useTranslation('shared');
|
||||||
const placeholderText = placeholder || t('app.shared.text_editor.fab_text_editor.text_placeholder');
|
const placeholderText = placeholder || t('app.shared.text_editor.fab_text_editor.text_placeholder');
|
||||||
// TODO: Add ctrl+click on link to visit
|
// TODO: Add ctrl+click on link to visit
|
||||||
@ -72,7 +75,11 @@ export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, Fab
|
|||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
onChange(editor.getHTML());
|
if (editor.isEmpty) {
|
||||||
|
onChange('');
|
||||||
|
} else {
|
||||||
|
onChange(editor.getHTML());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -80,17 +87,23 @@ export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, Fab
|
|||||||
editor?.setEditable(!disabled);
|
editor?.setEditable(!disabled);
|
||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor?.getHTML() !== content) {
|
||||||
|
editor?.commands.setContent(content);
|
||||||
|
}
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
// bind the editor to the ref, once it is ready
|
// bind the editor to the ref, once it is ready
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`fab-text-editor ${disabled && 'is-disabled'}`}>
|
<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} />
|
<EditorContent editor={editor} />
|
||||||
<div className="fab-text-editor-character-count">
|
{limit && <div className="fab-text-editor-character-count">
|
||||||
{editor?.storage.characterCount.characters()} / {limit}
|
{editor?.storage.characterCount.characters()} / {limit}
|
||||||
</div>
|
</div>}
|
||||||
{error &&
|
{error &&
|
||||||
<div className="fab-text-editor-error">
|
<div className="fab-text-editor-error">
|
||||||
<WarningOctagon size={24} />
|
<WarningOctagon size={24} />
|
||||||
|
@ -6,7 +6,10 @@ import { TextAa, TextBolder, TextItalic, TextUnderline, LinkSimpleHorizontal, Li
|
|||||||
|
|
||||||
interface MenuBarProps {
|
interface MenuBarProps {
|
||||||
editor?: Editor,
|
editor?: Editor,
|
||||||
paragraphTools?: boolean,
|
heading?: boolean,
|
||||||
|
bulletList?: boolean,
|
||||||
|
blockquote?: boolean,
|
||||||
|
link?: boolean,
|
||||||
video?: boolean,
|
video?: boolean,
|
||||||
image?: boolean,
|
image?: boolean,
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
@ -15,7 +18,7 @@ interface MenuBarProps {
|
|||||||
/**
|
/**
|
||||||
* This component is the menu bar for the WYSIWYG text editor
|
* 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 { t } = useTranslation('shared');
|
||||||
|
|
||||||
const [submenu, setSubmenu] = useState('');
|
const [submenu, setSubmenu] = useState('');
|
||||||
@ -44,6 +47,10 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
|||||||
if (submenu !== type) {
|
if (submenu !== type) {
|
||||||
setSubmenu(type);
|
setSubmenu(type);
|
||||||
if (type === 'link') {
|
if (type === 'link') {
|
||||||
|
if (editor.view.state.selection.from === editor.view.state.selection.to) {
|
||||||
|
setSubmenu('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const previousUrl = {
|
const previousUrl = {
|
||||||
href: editor.getAttributes('link').href,
|
href: editor.getAttributes('link').href,
|
||||||
target: editor.getAttributes('link').target || ''
|
target: editor.getAttributes('link').target || ''
|
||||||
@ -72,7 +79,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
|||||||
// Support keyboard "Enter" key event to validate
|
// Support keyboard "Enter" key event to validate
|
||||||
const handleEnter = (evt) => {
|
const handleEnter = (evt) => {
|
||||||
if (evt.keyCode === 13) {
|
if (evt.keyCode === 13) {
|
||||||
setLink();
|
setLink(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -142,8 +149,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`fab-text-editor-menu ${disabled ? 'fab-text-editor-menu--disabled' : ''}`}>
|
<div className={`fab-text-editor-menu ${disabled ? 'fab-text-editor-menu--disabled' : ''}`}>
|
||||||
{ paragraphTools &&
|
{heading &&
|
||||||
(<>
|
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
@ -152,6 +158,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
|||||||
>
|
>
|
||||||
<TextAa size={24} />
|
<TextAa size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
{bulletList &&
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
@ -160,6 +168,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
|||||||
>
|
>
|
||||||
<ListBullets size={24} />
|
<ListBullets size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
{blockquote &&
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
@ -168,9 +178,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
|||||||
>
|
>
|
||||||
<Quotes size={24} />
|
<Quotes size={24} />
|
||||||
</button>
|
</button>
|
||||||
<span className='menu-divider'></span>
|
|
||||||
</>)
|
|
||||||
}
|
}
|
||||||
|
{ (heading || bulletList || blockquote) && <span className='menu-divider'></span> }
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
@ -195,14 +204,16 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
|||||||
>
|
>
|
||||||
<TextUnderline size={24} />
|
<TextUnderline size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{link &&
|
||||||
type='button'
|
<button
|
||||||
onClick={() => toggleSubmenu('link')}
|
type='button'
|
||||||
disabled={disabled}
|
onClick={() => toggleSubmenu('link')}
|
||||||
className={`ignore-onclickoutside ${editor.isActive('link') ? 'is-active' : ''}`}
|
disabled={disabled}
|
||||||
>
|
className={`ignore-onclickoutside ${editor.isActive('link') ? 'is-active' : ''}`}
|
||||||
<LinkSimpleHorizontal size={24} />
|
>
|
||||||
</button>
|
<LinkSimpleHorizontal size={24} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
{ (video || image) && <span className='menu-divider'></span> }
|
{ (video || image) && <span className='menu-divider'></span> }
|
||||||
{ video &&
|
{ video &&
|
||||||
(<>
|
(<>
|
||||||
|
47
app/frontend/src/javascript/components/cart/cart-button.tsx
Normal file
47
app/frontend/src/javascript/components/cart/cart-button.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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 = '/#!/store/cart';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cart-button" onClick={showCart}>
|
||||||
|
<i className="fas fa-cart-arrow-down" />
|
||||||
|
{cart && cart.order_items_attributes.length > 0 &&
|
||||||
|
<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));
|
383
app/frontend/src/javascript/components/cart/store-cart.tsx
Normal file
383
app/frontend/src/javascript/components/cart/store-cart.tsx
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
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 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, OrderErrors } from '../../models/order';
|
||||||
|
import { MemberSelect } from '../user/member-select';
|
||||||
|
import { CouponInput } from '../coupon/coupon-input';
|
||||||
|
import { Coupon } from '../../models/coupon';
|
||||||
|
import noImage from '../../../../images/no_image.png';
|
||||||
|
import Switch from 'react-switch';
|
||||||
|
import OrderLib from '../../lib/order';
|
||||||
|
import { CaretDown, CaretUp } from 'phosphor-react';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import OrderAPI from '../../api/order';
|
||||||
|
|
||||||
|
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, reloadCart } = useCart(currentUser);
|
||||||
|
const [cartErrors, setCartErrors] = useState<OrderErrors>(null);
|
||||||
|
const [noMemberError, setNoMemberError] = useState<boolean>(false);
|
||||||
|
const [paymentModal, setPaymentModal] = useState<boolean>(false);
|
||||||
|
const [withdrawalInstructions, setWithdrawalInstructions] = useState<string>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cart) {
|
||||||
|
checkCart();
|
||||||
|
}
|
||||||
|
if (cart && !withdrawalInstructions) {
|
||||||
|
OrderAPI.withdrawalInstructions(cart)
|
||||||
|
.then(setWithdrawalInstructions)
|
||||||
|
.catch(onError);
|
||||||
|
}
|
||||||
|
}, [cart]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the product from cart
|
||||||
|
*/
|
||||||
|
const removeProductFromCart = (item) => {
|
||||||
|
return (e: React.BaseSyntheticEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const errors = getItemErrors(item);
|
||||||
|
if (errors.length === 1 && errors[0].error === 'not_found') {
|
||||||
|
reloadCart().catch(onError);
|
||||||
|
} else {
|
||||||
|
CartAPI.removeItem(cart, item.orderable_id).then(data => {
|
||||||
|
setCart(data);
|
||||||
|
}).catch(onError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change product quantity
|
||||||
|
*/
|
||||||
|
const changeProductQuantity = (e: React.BaseSyntheticEvent, item) => {
|
||||||
|
CartAPI.setQuantity(cart, item.orderable_id, e.target.value)
|
||||||
|
.then(data => {
|
||||||
|
setCart(data);
|
||||||
|
})
|
||||||
|
.catch(() => onError(t('app.public.store_cart.stock_limit')));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment/decrement product quantity
|
||||||
|
*/
|
||||||
|
const increaseOrDecreaseProductQuantity = (item, direction: 'up' | 'down') => {
|
||||||
|
CartAPI.setQuantity(cart, item.orderable_id, direction === 'up' ? item.quantity + 1 : item.quantity - 1)
|
||||||
|
.then(data => {
|
||||||
|
setCart(data);
|
||||||
|
})
|
||||||
|
.catch(() => onError(t('app.public.store_cart.stock_limit')));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh product amount
|
||||||
|
*/
|
||||||
|
const refreshItem = (item) => {
|
||||||
|
return (e: React.BaseSyntheticEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
CartAPI.refreshItem(cart, item.orderable_id).then(data => {
|
||||||
|
setCart(data);
|
||||||
|
}).catch(onError);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the current cart's items (available, price, stock, quantity_min)
|
||||||
|
*/
|
||||||
|
const checkCart = async (): Promise<OrderErrors> => {
|
||||||
|
const errors = await CartAPI.validate(cart);
|
||||||
|
setCartErrors(errors);
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkout cart
|
||||||
|
*/
|
||||||
|
const checkout = () => {
|
||||||
|
if (!currentUser) {
|
||||||
|
userLogin();
|
||||||
|
} else {
|
||||||
|
if (!cart.user) {
|
||||||
|
setNoMemberError(true);
|
||||||
|
onError(t('app.public.store_cart.select_user'));
|
||||||
|
} else {
|
||||||
|
setNoMemberError(false);
|
||||||
|
checkCart().then(errors => {
|
||||||
|
if (!hasCartErrors(errors)) {
|
||||||
|
setPaymentModal(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the carrent cart has any error
|
||||||
|
*/
|
||||||
|
const hasCartErrors = (errors: OrderErrors) => {
|
||||||
|
if (!errors) return false;
|
||||||
|
for (const item of cart.order_items_attributes) {
|
||||||
|
const error = _.find(errors.details, (e) => e.item_id === item.id);
|
||||||
|
if (!error || error?.errors?.length > 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get givean item's error
|
||||||
|
*/
|
||||||
|
const getItemErrors = (item) => {
|
||||||
|
if (!cartErrors) return [];
|
||||||
|
const errors = _.find(cartErrors.details, (e) => e.item_id === item.id);
|
||||||
|
return errors?.errors || [{ error: 'not_found' }];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/closes the payment modal
|
||||||
|
*/
|
||||||
|
const togglePaymentModal = (): void => {
|
||||||
|
setPaymentModal(!paymentModal);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle payment
|
||||||
|
*/
|
||||||
|
const handlePaymentSuccess = (data: Order): void => {
|
||||||
|
if (data.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 = (user: User): void => {
|
||||||
|
// if the selected user is the operator, he cannot offer products to himself
|
||||||
|
if (user.id === currentUser.id && cart.order_items_attributes.filter(item => item.is_offered).length > 0) {
|
||||||
|
Promise.all(cart.order_items_attributes.filter(item => item.is_offered).map(item => {
|
||||||
|
return CartAPI.setOffer(cart, item.orderable_id, false);
|
||||||
|
})).then((data) => setCart({ ...data[data.length - 1], user: { id: user.id, role: user.role } }));
|
||||||
|
} else {
|
||||||
|
setCart({ ...cart, user: { id: user.id, role: user.role } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 toggleProductOffer = (item) => {
|
||||||
|
return (checked: boolean) => {
|
||||||
|
CartAPI.setOffer(cart, item.orderable_id, checked).then(data => {
|
||||||
|
setCart(data);
|
||||||
|
}).catch(e => {
|
||||||
|
if (e.match(/code 403/)) {
|
||||||
|
onError(t('app.public.store_cart.errors.unauthorized_offering_product'));
|
||||||
|
} else {
|
||||||
|
onError(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply coupon to current cart
|
||||||
|
*/
|
||||||
|
const applyCoupon = (coupon?: Coupon): void => {
|
||||||
|
if (coupon !== cart.coupon) {
|
||||||
|
setCart({ ...cart, coupon });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show item error
|
||||||
|
*/
|
||||||
|
const itemError = (item, error) => {
|
||||||
|
if (error.error === 'is_active' || error.error === 'not_found') {
|
||||||
|
return <div className='error'><p>{t('app.public.store_cart.errors.product_not_found')}</p></div>;
|
||||||
|
}
|
||||||
|
if (error.error === 'stock' && error.value === 0) {
|
||||||
|
return <div className='error'><p>{t('app.public.store_cart.errors.out_of_stock')}</p></div>;
|
||||||
|
}
|
||||||
|
if (error.error === 'stock' && error.value > 0) {
|
||||||
|
return <div className='error'><p>{t('app.public.store_cart.errors.stock_limit_QUANTITY', { QUANTITY: error.value })}</p></div>;
|
||||||
|
}
|
||||||
|
if (error.error === 'quantity_min') {
|
||||||
|
return <div className='error'><p>{t('app.public.store_cart.errors.quantity_min_QUANTITY', { QUANTITY: error.value })}</p></div>;
|
||||||
|
}
|
||||||
|
if (error.error === 'amount') {
|
||||||
|
return <div className='error'>
|
||||||
|
<p>{t('app.public.store_cart.errors.price_changed_PRICE', { PRICE: `${FormatLib.price(error.value)} / ${t('app.public.store_cart.unit')}` })}</p>
|
||||||
|
<span className='refresh-btn' onClick={refreshItem(item)}>{t('app.public.store_cart.update_item')}</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ${getItemErrors(item).length > 0 ? 'error' : ''}`}>
|
||||||
|
<div className='picture'>
|
||||||
|
<img alt='' src={item.orderable_main_image_url || noImage} />
|
||||||
|
</div>
|
||||||
|
<div className="ref">
|
||||||
|
<span>{t('app.public.store_cart.reference_short')} {item.orderable_ref || ''}</span>
|
||||||
|
<p><a className="text-black" href={`/#!/store/p/${item.orderable_slug}`}>{item.orderable_name}</a></p>
|
||||||
|
{item.quantity_min > 1 &&
|
||||||
|
<span className='min'>{t('app.public.store_cart.minimum_purchase')}{item.quantity_min}</span>
|
||||||
|
}
|
||||||
|
{getItemErrors(item).map(e => {
|
||||||
|
return itemError(item, e);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<div className='price'>
|
||||||
|
<p>{FormatLib.price(item.amount)}</p>
|
||||||
|
<span>/ {t('app.public.store_cart.unit')}</span>
|
||||||
|
</div>
|
||||||
|
<div className='quantity'>
|
||||||
|
<input type='number'
|
||||||
|
onChange={e => changeProductQuantity(e, item)}
|
||||||
|
min={item.quantity_min}
|
||||||
|
max={item.orderable_external_stock}
|
||||||
|
value={item.quantity}
|
||||||
|
/>
|
||||||
|
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'up')}><CaretUp size={12} weight="fill" /></button>
|
||||||
|
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'down')}><CaretDown size={12} weight="fill" /></button>
|
||||||
|
</div>
|
||||||
|
<div className='total'>
|
||||||
|
<span>{t('app.public.store_cart.total')}</span>
|
||||||
|
<p>{FormatLib.price(OrderLib.itemAmount(item))}</p>
|
||||||
|
</div>
|
||||||
|
<FabButton className="main-action-btn" onClick={removeProductFromCart(item)}>
|
||||||
|
<i className="fa fa-trash" />
|
||||||
|
</FabButton>
|
||||||
|
</div>
|
||||||
|
{isPrivileged() &&
|
||||||
|
<div className='offer'>
|
||||||
|
<label>
|
||||||
|
<span>{t('app.public.store_cart.offer_product')}</span>
|
||||||
|
<Switch
|
||||||
|
checked={item.is_offered || false}
|
||||||
|
onChange={toggleProductOffer(item)}
|
||||||
|
width={40}
|
||||||
|
height={19}
|
||||||
|
uncheckedIcon={false}
|
||||||
|
checkedIcon={false}
|
||||||
|
handleDiameter={15} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="group">
|
||||||
|
{cart && !cartIsEmpty() &&
|
||||||
|
<div className='store-cart-info'>
|
||||||
|
<h3>{t('app.public.store_cart.pickup')}</h3>
|
||||||
|
<p dangerouslySetInnerHTML={{ __html: withdrawalInstructions }} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{cart && !cartIsEmpty() &&
|
||||||
|
<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} defaultUser={cart.user as User} hasError={noMemberError} /></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(OrderLib.totalBeforeOfferedAmount(cart))}</span></p>
|
||||||
|
{OrderLib.hasOfferedItem(cart) &&
|
||||||
|
<p className='gift'>{t('app.public.store_cart.checkout_gift_total')} <span>-{FormatLib.price(OrderLib.offeredAmount(cart))}</span></p>
|
||||||
|
}
|
||||||
|
{cart.coupon &&
|
||||||
|
<p>{t('app.public.store_cart.checkout_coupon')} <span>-{FormatLib.price(OrderLib.couponAmount(cart))}</span></p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p className='total'>{t('app.public.store_cart.checkout_total')} <span>{FormatLib.price(OrderLib.paidTotal(cart))}</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']));
|
114
app/frontend/src/javascript/components/coupon/coupon-input.tsx
Normal file
114
app/frontend/src/javascript/components/coupon/coupon-input.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import React, { useState, useEffect } 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>();
|
||||||
|
const [code, setCode] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && code) {
|
||||||
|
handleChange(code);
|
||||||
|
}
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (code) {
|
||||||
|
handleChange(code);
|
||||||
|
}
|
||||||
|
}, [amount]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* callback for validate the code
|
||||||
|
*/
|
||||||
|
const handleChange = (value: string) => {
|
||||||
|
const mgs = [];
|
||||||
|
setMessages([]);
|
||||||
|
setError(false);
|
||||||
|
setCoupon(null);
|
||||||
|
setCode(value);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,108 @@
|
|||||||
|
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 { StoreListHeader } from '../../store/store-list-header';
|
||||||
|
import { OrderItem } from '../../store/order-item';
|
||||||
|
import { FabPagination } from '../../base/fab-pagination';
|
||||||
|
import OrderAPI from '../../../api/order';
|
||||||
|
import { Order, OrderSortOption } from '../../../models/order';
|
||||||
|
import { User } from '../../../models/user';
|
||||||
|
import { SelectOption } from '../../../models/select';
|
||||||
|
|
||||||
|
declare const Application: IApplication;
|
||||||
|
|
||||||
|
interface OrdersDashboardProps {
|
||||||
|
currentUser: User,
|
||||||
|
onError: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component shows a list of all orders from the store for the current user
|
||||||
|
*/
|
||||||
|
export const OrdersDashboard: React.FC<OrdersDashboardProps> = ({ currentUser, onError }) => {
|
||||||
|
const { t } = useTranslation('public');
|
||||||
|
|
||||||
|
const [orders, setOrders] = useState<Array<Order>>([]);
|
||||||
|
const [pageCount, setPageCount] = useState<number>(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
OrderAPI.index({ user_id: currentUser.id }).then(res => {
|
||||||
|
setPageCount(res.total_pages);
|
||||||
|
setTotalCount(res.total_count);
|
||||||
|
setOrders(res.data);
|
||||||
|
}).catch(onError);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates sorting options to the react-select format
|
||||||
|
*/
|
||||||
|
const buildOptions = (): Array<SelectOption<OrderSortOption>> => {
|
||||||
|
return [
|
||||||
|
{ value: 'created_at-desc', label: t('app.public.orders_dashboard.sort.newest') },
|
||||||
|
{ value: 'created_at-asc', label: t('app.public.orders_dashboard.sort.oldest') }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Display option: sorting
|
||||||
|
*/
|
||||||
|
const handleSorting = (option: SelectOption<OrderSortOption>) => {
|
||||||
|
OrderAPI.index({ page: 1, sort: option.value }).then(res => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setOrders(res.data);
|
||||||
|
setPageCount(res.total_pages);
|
||||||
|
setTotalCount(res.total_count);
|
||||||
|
}).catch(onError);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle orders pagination
|
||||||
|
*/
|
||||||
|
const handlePagination = (page: number) => {
|
||||||
|
if (page !== currentPage) {
|
||||||
|
OrderAPI.index({ user_id: currentUser.id, page }).then(res => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
setOrders(res.data);
|
||||||
|
setPageCount(res.total_pages);
|
||||||
|
setTotalCount(res.total_count);
|
||||||
|
}).catch(onError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="orders-dashboard">
|
||||||
|
<header>
|
||||||
|
<h2>{t('app.public.orders_dashboard.heading')}</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="store-list">
|
||||||
|
<StoreListHeader
|
||||||
|
productsCount={totalCount}
|
||||||
|
selectOptions={buildOptions()}
|
||||||
|
onSelectOptionsChange={handleSorting}
|
||||||
|
/>
|
||||||
|
<div className="orders-list">
|
||||||
|
{orders.map(order => (
|
||||||
|
<OrderItem key={order.id} order={order} currentUser={currentUser} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{pageCount > 1 &&
|
||||||
|
<FabPagination pageCount={pageCount} currentPage={currentPage} selectPage={handlePagination} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrdersDashboardWrapper: React.FC<OrdersDashboardProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Loader>
|
||||||
|
<OrdersDashboard {...props} />
|
||||||
|
</Loader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Application.Components.component('ordersDashboard', react2angular(OrdersDashboardWrapper, ['onError', 'currentUser']));
|
@ -7,6 +7,7 @@ import { Event } from '../../models/event';
|
|||||||
import { EventTheme } from '../../models/event-theme';
|
import { EventTheme } from '../../models/event-theme';
|
||||||
import { IApplication } from '../../models/application';
|
import { IApplication } from '../../models/application';
|
||||||
import EventThemeAPI from '../../api/event-theme';
|
import EventThemeAPI from '../../api/event-theme';
|
||||||
|
import { SelectOption } from '../../models/select';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
@ -15,12 +16,6 @@ interface EventThemesProps {
|
|||||||
onChange: (themes: Array<EventTheme>) => void
|
onChange: (themes: Array<EventTheme>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Option format, expected by react-select
|
|
||||||
* @see https://github.com/JedWatson/react-select
|
|
||||||
*/
|
|
||||||
type selectOption = { value: number, label: string };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component shows a select input to edit the themes associated with the event
|
* This component shows a select input to edit the themes associated with the event
|
||||||
*/
|
*/
|
||||||
@ -43,7 +38,7 @@ export const EventThemes: React.FC<EventThemesProps> = ({ event, onChange }) =>
|
|||||||
/**
|
/**
|
||||||
* Return the current theme(s) for the given event, formatted to match the react-select format
|
* Return the current theme(s) for the given event, formatted to match the react-select format
|
||||||
*/
|
*/
|
||||||
const defaultValues = (): Array<selectOption> => {
|
const defaultValues = (): Array<SelectOption<number>> => {
|
||||||
const res = [];
|
const res = [];
|
||||||
themes.forEach(t => {
|
themes.forEach(t => {
|
||||||
if (event.event_theme_ids && event.event_theme_ids.indexOf(t.id) > -1) {
|
if (event.event_theme_ids && event.event_theme_ids.indexOf(t.id) > -1) {
|
||||||
@ -57,7 +52,7 @@ export const EventThemes: React.FC<EventThemesProps> = ({ event, onChange }) =>
|
|||||||
* Callback triggered when the selection has changed.
|
* Callback triggered when the selection has changed.
|
||||||
* Convert the react-select specific format to an array of EventTheme, and call the provided callback.
|
* Convert the react-select specific format to an array of EventTheme, and call the provided callback.
|
||||||
*/
|
*/
|
||||||
const handleChange = (selectedOptions: Array<selectOption>): void => {
|
const handleChange = (selectedOptions: Array<SelectOption<number>>): void => {
|
||||||
const res = [];
|
const res = [];
|
||||||
selectedOptions.forEach(opt => {
|
selectedOptions.forEach(opt => {
|
||||||
res.push(themes.find(t => t.id === opt.value));
|
res.push(themes.find(t => t.id === opt.value));
|
||||||
@ -68,7 +63,7 @@ export const EventThemes: React.FC<EventThemesProps> = ({ event, onChange }) =>
|
|||||||
/**
|
/**
|
||||||
* Convert all themes to the react-select format
|
* Convert all themes to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildOptions = (): Array<selectOption> => {
|
const buildOptions = (): Array<SelectOption<number>> => {
|
||||||
return themes.map(t => {
|
return themes.map(t => {
|
||||||
return { value: t.id, label: t.name };
|
return { value: t.id, label: t.name };
|
||||||
});
|
});
|
||||||
|
@ -63,7 +63,7 @@ export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label,
|
|||||||
<>
|
<>
|
||||||
{(label && !inLine) && <div className='form-item-header'>
|
{(label && !inLine) && <div className='form-item-header'>
|
||||||
<p onClick={handleLabelClick}>{label}</p>
|
<p onClick={handleLabelClick}>{label}</p>
|
||||||
{tooltip && <div className="item-tooltip">
|
{tooltip && <div className="fab-tooltip">
|
||||||
<span className="trigger"><i className="fa fa-question-circle" /></span>
|
<span className="trigger"><i className="fa fa-question-circle" /></span>
|
||||||
<div className="content">{tooltip}</div>
|
<div className="content">{tooltip}</div>
|
||||||
</div>}
|
</div>}
|
||||||
@ -71,7 +71,7 @@ export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label,
|
|||||||
|
|
||||||
<div className='form-item-field'>
|
<div className='form-item-field'>
|
||||||
{inLine && <div className='form-item-header'><p>{label}</p>
|
{inLine && <div className='form-item-header'><p>{label}</p>
|
||||||
{tooltip && <div className="item-tooltip">
|
{tooltip && <div className="fab-tooltip">
|
||||||
<span className="trigger"><i className="fa fa-question-circle" /></span>
|
<span className="trigger"><i className="fa fa-question-circle" /></span>
|
||||||
<div className="content">{tooltip}</div>
|
<div className="content">{tooltip}</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
105
app/frontend/src/javascript/components/form/form-checklist.tsx
Normal file
105
app/frontend/src/javascript/components/form/form-checklist.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
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';
|
||||||
|
import { ChecklistOption } from '../../models/select';
|
||||||
|
|
||||||
|
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-secondary">{t('app.shared.form_checklist.select_all')}</FabButton>
|
||||||
|
<FabButton type="button" onClick={unselectAll(onChange)} className="is-secondary">{t('app.shared.form_checklist.unselect_all')}</FabButton>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}} />
|
||||||
|
</AbstractFormItem>
|
||||||
|
);
|
||||||
|
};
|
106
app/frontend/src/javascript/components/form/form-file-upload.tsx
Normal file
106
app/frontend/src/javascript/components/form/form-file-upload.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
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-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';
|
||||||
|
import { FileType } from '../../models/file';
|
||||||
|
import FileUploadLib from '../../lib/file-upload';
|
||||||
|
|
||||||
|
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 FileUploadLib.hasFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 () {
|
||||||
|
FileUploadLib.onRemoveFile(file, id, setFile, setValue, 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,135 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Path, Controller } from 'react-hook-form';
|
||||||
|
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||||
|
import { FieldPath, FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||||
|
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||||
|
import { FormInput } from './form-input';
|
||||||
|
import { FormComponent, FormControlledComponent } 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';
|
||||||
|
import { ImageType } from '../../models/file';
|
||||||
|
import FileUploadLib from '../../lib/file-upload';
|
||||||
|
|
||||||
|
interface FormImageUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||||
|
setValue: UseFormSetValue<TFieldValues>,
|
||||||
|
defaultImage?: ImageType,
|
||||||
|
accept?: string,
|
||||||
|
size?: 'small' | 'medium' | 'large',
|
||||||
|
mainOption?: boolean,
|
||||||
|
onFileChange?: (value: ImageType) => void,
|
||||||
|
onFileRemove?: () => void,
|
||||||
|
onFileIsMain?: (setIsMain: () => void) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component allows to upload image, in forms managed by react-hook-form.
|
||||||
|
*/
|
||||||
|
export const FormImageUpload = <TFieldValues extends FieldValues, TContext extends object>({ id, label, register, control, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size, onFileIsMain, mainOption = false }: FormImageUploadProps<TFieldValues, TContext>) => {
|
||||||
|
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 FileUploadLib.hasFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 as Path<TFieldValues>,
|
||||||
|
{
|
||||||
|
attachment_name: f.name,
|
||||||
|
_destroy: 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 () {
|
||||||
|
FileUploadLib.onRemoveFile(file, id, setFile, setValue, onFileRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns placeholder text
|
||||||
|
*/
|
||||||
|
const placeholder = (): string => hasImage() ? t('app.shared.form_image_upload.edit') : t('app.shared.form_image_upload.browse');
|
||||||
|
|
||||||
|
// Compose classnames from props
|
||||||
|
const classNames = [
|
||||||
|
`${className || ''}`
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`form-image-upload form-image-upload--${size} ${label ? 'with-label' : ''} ${classNames}`}>
|
||||||
|
<div className={`image image--${size}`}>
|
||||||
|
<img src={hasImage() ? image : noImage} alt={file?.attachment_name || 'no image'} onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = noImage;
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
{mainOption &&
|
||||||
|
<label className='fab-button'>
|
||||||
|
{t('app.shared.form_image_upload.main_image')}
|
||||||
|
<Controller name={`${id}.is_main` as FieldPath<TFieldValues>}
|
||||||
|
control={control}
|
||||||
|
render={({ field: { onChange, value } }) =>
|
||||||
|
<input id={`${id}.is_main`}
|
||||||
|
type="radio"
|
||||||
|
checked={value}
|
||||||
|
onChange={() => { onFileIsMain(onChange); }} />
|
||||||
|
} />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<FormInput className="image-file-input"
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
register={register}
|
||||||
|
label={label}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
FormImageUpload.defaultProps = {
|
||||||
|
size: 'medium'
|
||||||
|
};
|
@ -18,12 +18,13 @@ interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldV
|
|||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
step?: number | 'any',
|
step?: number | 'any',
|
||||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
||||||
|
nullable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component is a template for an input component to use within React Hook Form
|
* This component is a template for an input component to use within React Hook Form
|
||||||
*/
|
*/
|
||||||
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept }: FormInputProps<TFieldValues, TInputType>) => {
|
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false }: FormInputProps<TFieldValues, TInputType>) => {
|
||||||
/**
|
/**
|
||||||
* Debounced (ie. temporised) version of the 'on change' callback.
|
* Debounced (ie. temporised) version of the 'on change' callback.
|
||||||
*/
|
*/
|
||||||
@ -57,8 +58,8 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
|||||||
<input id={id}
|
<input id={id}
|
||||||
{...register(id as FieldPath<TFieldValues>, {
|
{...register(id as FieldPath<TFieldValues>, {
|
||||||
...rules,
|
...rules,
|
||||||
valueAsNumber: type === 'number',
|
|
||||||
valueAsDate: type === 'date',
|
valueAsDate: type === 'date',
|
||||||
|
setValueAs: v => ([null, ''].includes(v) && nullable) ? null : (type === 'number' ? parseFloat(v) : v),
|
||||||
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
|
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
|
||||||
onChange: (e) => { handleChange(e); }
|
onChange: (e) => { handleChange(e); }
|
||||||
})}
|
})}
|
||||||
@ -67,6 +68,7 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
|||||||
disabled={typeof disabled === 'function' ? disabled(id) : disabled}
|
disabled={typeof disabled === 'function' ? disabled(id) : disabled}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
accept={accept} />
|
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>}
|
{addOn && <span onClick={addOnAction} className={`addon ${addOnClassName || ''} ${addOnAction ? 'is-btn' : ''}`}>{addOn}</span>}
|
||||||
</AbstractFormItem>
|
</AbstractFormItem>
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,48 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { FormFileUpload } from './form-file-upload';
|
||||||
|
import { FabButton } from '../base/fab-button';
|
||||||
|
import { Plus } from 'phosphor-react';
|
||||||
|
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||||
|
import { FormComponent, FormControlledComponent } from '../../models/form-component';
|
||||||
|
import { AbstractFormItemProps } from './abstract-form-item';
|
||||||
|
import { UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||||
|
import { ArrayPath, FieldArray, useFieldArray } from 'react-hook-form';
|
||||||
|
import { FileType } from '../../models/file';
|
||||||
|
import { UnpackNestedValue } from 'react-hook-form/dist/types';
|
||||||
|
|
||||||
|
interface FormMultiFileUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||||
|
setValue: UseFormSetValue<TFieldValues>,
|
||||||
|
addButtonLabel: ReactNode,
|
||||||
|
accept: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component allows to upload multiple files, in forms managed by react-hook-form.
|
||||||
|
*/
|
||||||
|
export const FormMultiFileUpload = <TFieldValues extends FieldValues, TContext extends object>({ id, className, register, control, setValue, formState, addButtonLabel, accept }: FormMultiFileUploadProps<TFieldValues, TContext>) => {
|
||||||
|
const { fields, append, remove } = useFieldArray({ control, name: id as ArrayPath<TFieldValues> });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`form-multi-file-upload ${className || ''}`}>
|
||||||
|
<div className="list">
|
||||||
|
{fields.map((field: FileType, index) => (
|
||||||
|
<FormFileUpload key={field.id}
|
||||||
|
defaultFile={field}
|
||||||
|
id={`${id}.${index}`}
|
||||||
|
accept={accept}
|
||||||
|
register={register}
|
||||||
|
setValue={setValue}
|
||||||
|
formState={formState}
|
||||||
|
className={field._destroy ? 'hidden' : ''}
|
||||||
|
onFileRemove={() => remove(index)}/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FabButton
|
||||||
|
onClick={() => append({ _destroy: false } as UnpackNestedValue<FieldArray<TFieldValues, ArrayPath<TFieldValues>>>)}
|
||||||
|
className='is-secondary'
|
||||||
|
icon={<Plus size={24} />}>
|
||||||
|
{addButtonLabel}
|
||||||
|
</FabButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,102 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||||
|
import { FormComponent, FormControlledComponent } from '../../models/form-component';
|
||||||
|
import { AbstractFormItemProps } from './abstract-form-item';
|
||||||
|
import { UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||||
|
import { ArrayPath, FieldArray, Path, useFieldArray, useWatch } from 'react-hook-form';
|
||||||
|
import { FormImageUpload } from './form-image-upload';
|
||||||
|
import { FabButton } from '../base/fab-button';
|
||||||
|
import { Plus } from 'phosphor-react';
|
||||||
|
import { ImageType } from '../../models/file';
|
||||||
|
import { UnpackNestedValue } from 'react-hook-form/dist/types';
|
||||||
|
import { FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||||
|
|
||||||
|
interface FormMultiImageUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||||
|
setValue: UseFormSetValue<TFieldValues>,
|
||||||
|
addButtonLabel: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component allows to upload multiple images, in forms managed by react-hook-form.
|
||||||
|
*/
|
||||||
|
export const FormMultiImageUpload = <TFieldValues extends FieldValues, TContext extends object>({ id, className, register, control, setValue, formState, addButtonLabel }: FormMultiImageUploadProps<TFieldValues, TContext>) => {
|
||||||
|
const { fields, append, remove } = useFieldArray({ control, name: id as ArrayPath<TFieldValues> });
|
||||||
|
const output = useWatch({ control, name: id as Path<TFieldValues> });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new image, set as main if it is the first
|
||||||
|
*/
|
||||||
|
const addImage = () => {
|
||||||
|
append({
|
||||||
|
is_main: output.filter(i => i.is_main).length === 0,
|
||||||
|
_destroy: false
|
||||||
|
} as UnpackNestedValue<FieldArray<TFieldValues, ArrayPath<TFieldValues>>>);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an image and set the first image as the new main image if the provided was main
|
||||||
|
*/
|
||||||
|
const handleRemoveImage = (image: ImageType, index: number) => {
|
||||||
|
return () => {
|
||||||
|
if (image.is_main && output.length > 1) {
|
||||||
|
setValue(
|
||||||
|
`${id}.${index === 0 ? 1 : 0}.is_main` as Path<TFieldValues>,
|
||||||
|
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof image.id === 'string') {
|
||||||
|
remove(index);
|
||||||
|
} else {
|
||||||
|
setValue(
|
||||||
|
`${id}.${index}._destroy` as Path<TFieldValues>,
|
||||||
|
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the image at the given index as the new main image, and unset the current main image
|
||||||
|
*/
|
||||||
|
const handleSetMainImage = (index: number) => {
|
||||||
|
return (setNewImageValue) => {
|
||||||
|
const mainImageIndex = output.findIndex(i => i.is_main && i !== index);
|
||||||
|
if (mainImageIndex > -1) {
|
||||||
|
setValue(
|
||||||
|
`${id}.${mainImageIndex}.is_main` as Path<TFieldValues>,
|
||||||
|
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setNewImageValue(true);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`form-multi-image-upload ${className || ''}`}>
|
||||||
|
<div className="list">
|
||||||
|
{fields.map((field: ImageType, index) => (
|
||||||
|
<FormImageUpload key={field.id}
|
||||||
|
defaultImage={field}
|
||||||
|
id={`${id}.${index}`}
|
||||||
|
accept="image/*"
|
||||||
|
size="small"
|
||||||
|
register={register}
|
||||||
|
control={control}
|
||||||
|
setValue={setValue}
|
||||||
|
formState={formState}
|
||||||
|
className={field._destroy ? 'hidden' : ''}
|
||||||
|
onFileRemove={handleRemoveImage(field, index)}
|
||||||
|
onFileIsMain={handleSetMainImage(index)}
|
||||||
|
mainOption
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FabButton
|
||||||
|
onClick={addImage}
|
||||||
|
className='is-secondary'
|
||||||
|
icon={<Plus size={24} />}>
|
||||||
|
{addButtonLabel}
|
||||||
|
</FabButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -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> {
|
interface FormRichTextProps<TFieldValues, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||||
valueDefault?: string,
|
valueDefault?: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
paragraphTools?: boolean,
|
heading?: boolean,
|
||||||
|
bulletList?: boolean,
|
||||||
|
blockquote?: boolean,
|
||||||
|
link?: boolean,
|
||||||
video?: boolean,
|
video?: boolean,
|
||||||
image?: boolean,
|
image?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component is a rich-text editor to use with react-hook-form.
|
* 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 textEditorRef = React.useRef<FabTextEditorRef>();
|
||||||
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
|
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
|
||||||
|
|
||||||
@ -54,9 +57,12 @@ export const FormRichText = <TFieldValues extends FieldValues, TContext extends
|
|||||||
<FabTextEditor onChange={onChange}
|
<FabTextEditor onChange={onChange}
|
||||||
content={value}
|
content={value}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
paragraphTools={paragraphTools}
|
heading={heading}
|
||||||
|
bulletList={bulletList}
|
||||||
|
blockquote={blockquote}
|
||||||
video={video}
|
video={video}
|
||||||
image={image}
|
image={image}
|
||||||
|
link={link}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
ref={textEditorRef} />
|
ref={textEditorRef} />
|
||||||
} />
|
} />
|
||||||
|
@ -7,9 +7,10 @@ import { FieldPath } from 'react-hook-form/dist/types/path';
|
|||||||
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
|
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
|
||||||
import { FormControlledComponent } from '../../models/form-component';
|
import { FormControlledComponent } from '../../models/form-component';
|
||||||
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
|
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
|
||||||
|
import { SelectOption } from '../../models/select';
|
||||||
|
|
||||||
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue, TOptionLabel> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||||
options: Array<selectOption<TOptionValue>>,
|
options: Array<SelectOption<TOptionValue, TOptionLabel>>,
|
||||||
valueDefault?: TOptionValue,
|
valueDefault?: TOptionValue,
|
||||||
onChange?: (value: TOptionValue) => void,
|
onChange?: (value: TOptionValue) => void,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
@ -17,16 +18,10 @@ interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue> e
|
|||||||
creatable?: boolean,
|
creatable?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Option format, expected by react-select
|
|
||||||
* @see https://github.com/JedWatson/react-select
|
|
||||||
*/
|
|
||||||
type selectOption<TOptionValue> = { value: TOptionValue, label: string };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component is a wrapper for react-select to use with react-hook-form
|
* This component is a wrapper for react-select to use with react-hook-form
|
||||||
*/
|
*/
|
||||||
export const FormSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue>({ id, label, tooltip, className, control, placeholder, options, valueDefault, error, warning, rules, disabled = false, onChange, clearable = false, formState, creatable = false }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
|
export const FormSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue, TOptionLabel>({ id, label, tooltip, className, control, placeholder, options, valueDefault, error, warning, rules, disabled = false, onChange, clearable = false, formState, creatable = false }: FormSelectProps<TFieldValues, TContext, TOptionValue, TOptionLabel>) => {
|
||||||
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
|
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -41,8 +41,11 @@ export const FormSwitch = <TFieldValues, TContext extends object>({ id, label, t
|
|||||||
onChangeCb(val);
|
onChangeCb(val);
|
||||||
}}
|
}}
|
||||||
checked={value as boolean || false}
|
checked={value as boolean || false}
|
||||||
height={19}
|
|
||||||
width={40}
|
width={40}
|
||||||
|
height={19}
|
||||||
|
uncheckedIcon={false}
|
||||||
|
checkedIcon={false}
|
||||||
|
handleDiameter={15}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
disabled={typeof disabled === 'function' ? disabled(id) : disabled} />
|
disabled={typeof disabled === 'function' ? disabled(id) : disabled} />
|
||||||
} />
|
} />
|
||||||
|
@ -0,0 +1,112 @@
|
|||||||
|
import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { UIRouter } from '@uirouter/angularjs';
|
||||||
|
import { FormState } from 'react-hook-form/dist/types/form';
|
||||||
|
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||||
|
import { FabModal } from '../base/fab-modal';
|
||||||
|
import Deferred from '../../lib/deferred';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface UnsavedFormAlertProps<TFieldValues> {
|
||||||
|
uiRouter: UIRouter,
|
||||||
|
formState: FormState<TFieldValues>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert the user about unsaved changes in the given form, before leaving the current page.
|
||||||
|
* This component is highly dependent of these external libraries:
|
||||||
|
* - [react-hook-form](https://react-hook-form.com/)
|
||||||
|
* - [ui-router](https://ui-router.github.io/)
|
||||||
|
*/
|
||||||
|
export const UnsavedFormAlert = <TFieldValues extends FieldValues>({ uiRouter, formState, children }: PropsWithChildren<UnsavedFormAlertProps<TFieldValues>>) => {
|
||||||
|
const { t } = useTranslation('shared');
|
||||||
|
|
||||||
|
const [showAlertModal, setShowAlertModal] = useState<boolean>(false);
|
||||||
|
const [promise, setPromise] = useState<Deferred<boolean>>(null);
|
||||||
|
const [dirty, setDirty] = useState<boolean>(formState.isDirty);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const submitStatus = (!formState.isSubmitting && (!formState.isSubmitted || !formState.isSubmitSuccessful));
|
||||||
|
setDirty(submitStatus && Object.keys(formState.dirtyFields).length > 0);
|
||||||
|
}, [formState]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current form is dirty. If so, show the confirmation modal and return a promise
|
||||||
|
*/
|
||||||
|
const alertOnDirtyForm = (isDirty: boolean): Promise<boolean>|void => {
|
||||||
|
if (isDirty) {
|
||||||
|
toggleAlertModal();
|
||||||
|
const userChoicePromise = new Deferred<boolean>();
|
||||||
|
setPromise(userChoicePromise);
|
||||||
|
return userChoicePromise.promise;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// memoised version of the alertOnDirtyForm function, will be updated only when the form becames dirty
|
||||||
|
const alertDirty = useCallback<() => Promise<boolean>|void>(() => alertOnDirtyForm(dirty), [dirty]);
|
||||||
|
|
||||||
|
// we should place this useEffect after the useCallback declaration (because it's a scoped variable)
|
||||||
|
useEffect(() => {
|
||||||
|
const { transitionService, globals: { current } } = uiRouter;
|
||||||
|
const deregisters = transitionService.onBefore({ from: current.name }, alertDirty);
|
||||||
|
return () => {
|
||||||
|
deregisters();
|
||||||
|
};
|
||||||
|
}, [alertDirty]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the user tries to close the current page (tab/window), we alert him about unsaved changes
|
||||||
|
*/
|
||||||
|
const alertOnExit = (event: BeforeUnloadEvent, isDirty: boolean) => {
|
||||||
|
if (isDirty) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// memoised version of the alertOnExit function, will be updated only when the form becames dirty
|
||||||
|
const alertExit = useCallback<(event: BeforeUnloadEvent) => void>((event) => alertOnExit(event, dirty), [dirty]);
|
||||||
|
|
||||||
|
// we should place this useEffect after the useCallback declaration (because it's a scoped variable)
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('beforeunload', alertExit);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', alertExit);
|
||||||
|
};
|
||||||
|
}, [alertExit]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide/show the alert modal "you have some unsaved content, are you sure you want to leave?"
|
||||||
|
*/
|
||||||
|
const toggleAlertModal = () => {
|
||||||
|
setShowAlertModal(!showAlertModal);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user has choosen: continue and exit
|
||||||
|
*/
|
||||||
|
const handleConfirmation = () => {
|
||||||
|
promise.resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user has choosen: cancel and stay
|
||||||
|
*/
|
||||||
|
const handleCancel = () => {
|
||||||
|
promise.resolve(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="unsaved-form-alert">
|
||||||
|
{children}
|
||||||
|
<FabModal isOpen={showAlertModal}
|
||||||
|
toggleModal={toggleAlertModal}
|
||||||
|
confirmButton={t('app.shared.unsaved_form_alert.confirmation_button')}
|
||||||
|
title={t('app.shared.unsaved_form_alert.modal_title')}
|
||||||
|
onConfirm={handleConfirmation}
|
||||||
|
onClose={handleCancel}
|
||||||
|
closeButton>
|
||||||
|
{t('app.shared.unsaved_form_alert.confirmation_message')}
|
||||||
|
</FabModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -12,6 +12,7 @@ import { FormSelect } from '../form/form-select';
|
|||||||
import MemberAPI from '../../api/member';
|
import MemberAPI from '../../api/member';
|
||||||
import SettingAPI from '../../api/setting';
|
import SettingAPI from '../../api/setting';
|
||||||
import UserLib from '../../lib/user';
|
import UserLib from '../../lib/user';
|
||||||
|
import { SelectOption } from '../../models/select';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
@ -23,12 +24,6 @@ interface ChangeGroupProps {
|
|||||||
className?: string,
|
className?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Option format, expected by react-select
|
|
||||||
* @see https://github.com/JedWatson/react-select
|
|
||||||
*/
|
|
||||||
type selectOption = { value: number, label: string };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component to display the group of the provided user, and allow him to change his group.
|
* Component to display the group of the provided user, and allow him to change his group.
|
||||||
*/
|
*/
|
||||||
@ -43,7 +38,7 @@ export const ChangeGroup: React.FC<ChangeGroupProps> = ({ user, onSuccess, onErr
|
|||||||
const { handleSubmit, control } = useForm();
|
const { handleSubmit, control } = useForm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GroupAPI.index({ disabled: false, admins: user?.role === 'admin' }).then(setGroups).catch(onError);
|
GroupAPI.index({ disabled: false }).then(setGroups).catch(onError);
|
||||||
MemberAPI.current().then(setOperator).catch(onError);
|
MemberAPI.current().then(setOperator).catch(onError);
|
||||||
SettingAPI.get('user_change_group').then((setting) => {
|
SettingAPI.get('user_change_group').then((setting) => {
|
||||||
setAllowedUserChangeGoup(setting.value === 'true');
|
setAllowedUserChangeGoup(setting.value === 'true');
|
||||||
@ -72,7 +67,7 @@ export const ChangeGroup: React.FC<ChangeGroupProps> = ({ user, onSuccess, onErr
|
|||||||
/**
|
/**
|
||||||
* Convert the provided array of items to the react-select format
|
* Convert the provided array of items to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildGroupsOptions = (): Array<selectOption> => {
|
const buildGroupsOptions = (): Array<SelectOption<number>> => {
|
||||||
return groups?.map(t => {
|
return groups?.map(t => {
|
||||||
return { value: t.id, label: t.name };
|
return { value: t.id, label: t.name };
|
||||||
});
|
});
|
||||||
|
@ -44,12 +44,12 @@ const MachineCard: React.FC<MachineCardProps> = ({ user, machine, onShowMachine,
|
|||||||
* Return the machine's picture or a placeholder
|
* Return the machine's picture or a placeholder
|
||||||
*/
|
*/
|
||||||
const machinePicture = (): ReactNode => {
|
const machinePicture = (): ReactNode => {
|
||||||
if (!machine.machine_image) {
|
if (!machine.machine_image_attributes) {
|
||||||
return <div className="machine-picture no-picture" />;
|
return <div className="machine-picture no-picture" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="machine-picture" style={{ backgroundImage: `url(${machine.machine_image})` }} onClick={handleShowMachine} />
|
<div className="machine-picture" style={{ backgroundImage: `url(${machine.machine_image_attributes.attachment_url}), url('/default-image.png')` }} onClick={handleShowMachine} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SubmitHandler, useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { Machine } from '../../models/machine';
|
||||||
|
import MachineAPI from '../../api/machine';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FormInput } from '../form/form-input';
|
||||||
|
import { FormImageUpload } from '../form/form-image-upload';
|
||||||
|
import { IApplication } from '../../models/application';
|
||||||
|
import { Loader } from '../base/loader';
|
||||||
|
import { react2angular } from 'react2angular';
|
||||||
|
import { ErrorBoundary } from '../base/error-boundary';
|
||||||
|
import { FormRichText } from '../form/form-rich-text';
|
||||||
|
import { FormSwitch } from '../form/form-switch';
|
||||||
|
|
||||||
|
declare const Application: IApplication;
|
||||||
|
|
||||||
|
interface MachineFormProps {
|
||||||
|
action: 'create' | 'update',
|
||||||
|
machine?: Machine,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
onSuccess: (message: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form to edit or create machines
|
||||||
|
*/
|
||||||
|
export const MachineForm: React.FC<MachineFormProps> = ({ action, machine, onError, onSuccess }) => {
|
||||||
|
const { handleSubmit, register, control, setValue, formState } = useForm<Machine>({ defaultValues: { ...machine } });
|
||||||
|
const output = useWatch<Machine>({ control });
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user validates the machine form: handle create or update
|
||||||
|
*/
|
||||||
|
const onSubmit: SubmitHandler<Machine> = (data: Machine) => {
|
||||||
|
MachineAPI[action](data).then(() => {
|
||||||
|
onSuccess(t(`app.admin.machine_form.${action}_success`));
|
||||||
|
}).catch(error => {
|
||||||
|
onError(error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="machine-form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<FormInput register={register} id="name"
|
||||||
|
formState={formState}
|
||||||
|
rules={{ required: true }}
|
||||||
|
label={t('app.admin.machine_form.name')} />
|
||||||
|
<FormImageUpload setValue={setValue}
|
||||||
|
register={register}
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
rules={{ required: true }}
|
||||||
|
id="machine_image_attributes"
|
||||||
|
accept="image/*"
|
||||||
|
defaultImage={output.machine_image_attributes}
|
||||||
|
label={t('app.admin.machine_form.illustration')} />
|
||||||
|
<FormRichText control={control}
|
||||||
|
id="description"
|
||||||
|
rules={{ required: true }}
|
||||||
|
label={t('app.admin.machine_form.description')}
|
||||||
|
limit={null}
|
||||||
|
heading bulletList blockquote link video image />
|
||||||
|
<FormRichText control={control}
|
||||||
|
id="spec"
|
||||||
|
rules={{ required: true }}
|
||||||
|
label={t('app.admin.machine_form.technical_specifications')}
|
||||||
|
limit={null}
|
||||||
|
heading bulletList blockquote link video image />
|
||||||
|
|
||||||
|
<FormSwitch control={control}
|
||||||
|
id="disabled"
|
||||||
|
label={t('app.admin.machine_form.disable_machine')}
|
||||||
|
tooltip={t('app.admin.machine_form.disabled_help')} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MachineFormWrapper: React.FC<MachineFormProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Loader>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<MachineForm {...props} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Loader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Application.Components.component('machineForm', react2angular(MachineFormWrapper, ['action', 'machine', 'onError', 'onSuccess']));
|
@ -1,17 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SelectOption } from '../../models/select';
|
||||||
|
|
||||||
interface MachinesFiltersProps {
|
interface MachinesFiltersProps {
|
||||||
onStatusSelected: (enabled: boolean) => void,
|
onStatusSelected: (enabled: boolean) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Option format, expected by react-select
|
|
||||||
* @see https://github.com/JedWatson/react-select
|
|
||||||
*/
|
|
||||||
type selectOption = { value: boolean, label: string };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows filtering on machines list
|
* Allows filtering on machines list
|
||||||
*/
|
*/
|
||||||
@ -23,7 +18,7 @@ export const MachinesFilters: React.FC<MachinesFiltersProps> = ({ onStatusSelect
|
|||||||
/**
|
/**
|
||||||
* Provides boolean options in the react-select format (yes/no/all)
|
* Provides boolean options in the react-select format (yes/no/all)
|
||||||
*/
|
*/
|
||||||
const buildBooleanOptions = (): Array<selectOption> => {
|
const buildBooleanOptions = (): Array<SelectOption<boolean>> => {
|
||||||
return [
|
return [
|
||||||
defaultValue,
|
defaultValue,
|
||||||
{ value: false, label: t('app.public.machines_filters.status_disabled') },
|
{ value: false, label: t('app.public.machines_filters.status_disabled') },
|
||||||
@ -34,7 +29,7 @@ export const MachinesFilters: React.FC<MachinesFiltersProps> = ({ onStatusSelect
|
|||||||
/**
|
/**
|
||||||
* Callback triggered when the user selects a machine status in the dropdown list
|
* Callback triggered when the user selects a machine status in the dropdown list
|
||||||
*/
|
*/
|
||||||
const handleStatusSelected = (option: selectOption): void => {
|
const handleStatusSelected = (option: SelectOption<boolean>): void => {
|
||||||
onStatusSelected(option.value);
|
onStatusSelected(option.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -7,6 +7,8 @@ import MachineAPI from '../../api/machine';
|
|||||||
import { MachineCard } from './machine-card';
|
import { MachineCard } from './machine-card';
|
||||||
import { MachinesFilters } from './machines-filters';
|
import { MachinesFilters } from './machines-filters';
|
||||||
import { User } from '../../models/user';
|
import { User } from '../../models/user';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FabButton } from '../base/fab-button';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
@ -25,6 +27,7 @@ interface MachinesListProps {
|
|||||||
* This component shows a list of all machines and allows filtering on that list.
|
* 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 }) => {
|
export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user, canProposePacks }) => {
|
||||||
|
const { t } = useTranslation('public');
|
||||||
// shown machines
|
// shown machines
|
||||||
const [machines, setMachines] = useState<Array<Machine>>(null);
|
const [machines, setMachines] = useState<Array<Machine>>(null);
|
||||||
// we keep the full list of machines, for filtering
|
// 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));
|
setMachines(allMachines.filter(m => !!m.disabled === !status));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to store
|
||||||
|
*/
|
||||||
|
const linkToStore = (): void => {
|
||||||
|
window.location.href = '/#!/store';
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Conditionally display the store ad
|
||||||
return (
|
return (
|
||||||
<div className="machines-list">
|
<div className="machines-list">
|
||||||
<MachinesFilters onStatusSelected={handleFilterByStatus} />
|
<MachinesFilters onStatusSelected={handleFilterByStatus} />
|
||||||
<div className="all-machines">
|
<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 => {
|
{machines && machines.map(machine => {
|
||||||
return <MachineCard key={machine.id}
|
return <MachineCard key={machine.id}
|
||||||
user={user}
|
user={user}
|
||||||
@ -77,10 +100,10 @@ export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MachinesListWrapper: React.FC<MachinesListProps> = ({ user, onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, canProposePacks }) => {
|
const MachinesListWrapper: React.FC<MachinesListProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Loader>
|
<Loader>
|
||||||
<MachinesList user={user} onError={onError} onSuccess={onSuccess} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} canProposePacks={canProposePacks}/>
|
<MachinesList {...props} />
|
||||||
</Loader>
|
</Loader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -109,7 +109,7 @@ const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSc
|
|||||||
const formatState = (item: PaymentScheduleItem, schedule: PaymentSchedule): JSX.Element => {
|
const formatState = (item: PaymentScheduleItem, schedule: PaymentSchedule): JSX.Element => {
|
||||||
let res = t(`app.shared.payment_schedules_table.state_${item.state}${item.state === 'pending' ? '_' + schedule.payment_method : ''}`);
|
let res = t(`app.shared.payment_schedules_table.state_${item.state}${item.state === 'pending' ? '_' + schedule.payment_method : ''}`);
|
||||||
if (item.state === PaymentScheduleItemState.Paid) {
|
if (item.state === PaymentScheduleItemState.Paid) {
|
||||||
const key = `app.shared.schedules_table.method_${item.payment_method}`;
|
const key = `app.shared.payment_schedules_table.method_${item.payment_method}`;
|
||||||
res += ` (${t(key)})`;
|
res += ` (${t(key)})`;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line fabmanager/component-class-named-as-component
|
// eslint-disable-next-line fabmanager/component-class-named-as-component
|
||||||
|
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { FabModal } from '../base/fab-modal';
|
import { FabModal } from '../base/fab-modal';
|
||||||
import { PaymentMethod, PaymentSchedule } from '../../models/payment-schedule';
|
import { PaymentMethod, PaymentSchedule } from '../../models/payment-schedule';
|
||||||
import PaymentScheduleAPI from '../../api/payment-schedule';
|
import PaymentScheduleAPI from '../../api/payment-schedule';
|
||||||
|
import { SelectOption } from '../../models/select';
|
||||||
|
|
||||||
interface UpdatePaymentMeanModalProps {
|
interface UpdatePaymentMeanModalProps {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@ -13,12 +14,6 @@ interface UpdatePaymentMeanModalProps {
|
|||||||
paymentSchedule: PaymentSchedule
|
paymentSchedule: PaymentSchedule
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Option format, expected by react-select
|
|
||||||
* @see https://github.com/JedWatson/react-select
|
|
||||||
*/
|
|
||||||
type selectOption = { value: PaymentMethod, label: string };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component to allow the member to change his payment mean for the given payment schedule (e.g. from card to transfer)
|
* Component to allow the member to change his payment mean for the given payment schedule (e.g. from card to transfer)
|
||||||
*/
|
*/
|
||||||
@ -30,7 +25,7 @@ export const UpdatePaymentMeanModal: React.FC<UpdatePaymentMeanModalProps> = ({
|
|||||||
/**
|
/**
|
||||||
* Convert all payment means to the react-select format
|
* Convert all payment means to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildOptions = (): Array<selectOption> => {
|
const buildOptions = (): Array<SelectOption<PaymentMethod>> => {
|
||||||
return Object.keys(PaymentMethod).filter(pm => PaymentMethod[pm] !== PaymentMethod.Card).map(pm => {
|
return Object.keys(PaymentMethod).filter(pm => PaymentMethod[pm] !== PaymentMethod.Card).map(pm => {
|
||||||
return { value: PaymentMethod[pm], label: t(`app.admin.update_payment_mean_modal.method_${pm}`) };
|
return { value: PaymentMethod[pm], label: t(`app.admin.update_payment_mean_modal.method_${pm}`) };
|
||||||
});
|
});
|
||||||
@ -39,7 +34,7 @@ export const UpdatePaymentMeanModal: React.FC<UpdatePaymentMeanModalProps> = ({
|
|||||||
/**
|
/**
|
||||||
* When the payment mean is changed in the select, update the state
|
* When the payment mean is changed in the select, update the state
|
||||||
*/
|
*/
|
||||||
const handleMeanSelected = (option: selectOption): void => {
|
const handleMeanSelected = (option: SelectOption<PaymentMethod>): void => {
|
||||||
setPaymentMean(option.value);
|
setPaymentMean(option.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -17,16 +17,19 @@ import { GoogleTagManager } from '../../models/gtm';
|
|||||||
import { ComputePriceResult } from '../../models/price';
|
import { ComputePriceResult } from '../../models/price';
|
||||||
import { Wallet } from '../../models/wallet';
|
import { Wallet } from '../../models/wallet';
|
||||||
import FormatLib from '../../lib/format';
|
import FormatLib from '../../lib/format';
|
||||||
|
import { Order } from '../../models/order';
|
||||||
|
import { computePriceWithCoupon } from '../../lib/coupon';
|
||||||
|
|
||||||
export interface GatewayFormProps {
|
export interface GatewayFormProps {
|
||||||
onSubmit: () => void,
|
onSubmit: () => void,
|
||||||
onSuccess: (result: Invoice|PaymentSchedule) => void,
|
onSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
customer: User,
|
customer: User,
|
||||||
operator: User,
|
operator: User,
|
||||||
className?: string,
|
className?: string,
|
||||||
paymentSchedule?: PaymentSchedule,
|
paymentSchedule?: PaymentSchedule,
|
||||||
cart?: ShoppingCart,
|
cart?: ShoppingCart,
|
||||||
|
order?: Order,
|
||||||
updateCart?: (cart: ShoppingCart) => void,
|
updateCart?: (cart: ShoppingCart) => void,
|
||||||
formId: string,
|
formId: string,
|
||||||
}
|
}
|
||||||
@ -34,9 +37,10 @@ export interface GatewayFormProps {
|
|||||||
interface AbstractPaymentModalProps {
|
interface AbstractPaymentModalProps {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
toggleModal: () => void,
|
toggleModal: () => void,
|
||||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
cart: ShoppingCart,
|
cart: ShoppingCart,
|
||||||
|
order?: Order,
|
||||||
updateCart?: (cart: ShoppingCart) => void,
|
updateCart?: (cart: ShoppingCart) => void,
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
schedule?: PaymentSchedule,
|
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.
|
* 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
|
* @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
|
// customer's wallet
|
||||||
const [wallet, setWallet] = useState<Wallet>(null);
|
const [wallet, setWallet] = useState<Wallet>(null);
|
||||||
// server-computed price with all details
|
// server-computed price with all details
|
||||||
@ -107,16 +111,25 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
|||||||
* - Refresh the remaining price
|
* - Refresh the remaining price
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cart) return;
|
if (order && order?.user?.id) {
|
||||||
WalletAPI.getByUser(cart.customer_id).then((wallet) => {
|
WalletAPI.getByUser(order.user.id).then((wallet) => {
|
||||||
setWallet(wallet);
|
setWallet(wallet);
|
||||||
PriceAPI.compute(cart).then((res) => {
|
const p = { price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total };
|
||||||
setPrice(res);
|
setPrice(p);
|
||||||
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price));
|
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(p.price));
|
||||||
setReady(true);
|
setReady(true);
|
||||||
});
|
});
|
||||||
});
|
} else if (cart && cart.customer_id) {
|
||||||
}, [cart]);
|
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
|
* 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
|
* 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);
|
setSubmitState(false);
|
||||||
GTM.trackPurchase(result.id, result.total);
|
GTM.trackPurchase(result.id, result.total);
|
||||||
afterSuccess(result);
|
afterSuccess(result);
|
||||||
@ -212,6 +225,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
|||||||
className={`gateway-form ${formClassName || ''}`}
|
className={`gateway-form ${formClassName || ''}`}
|
||||||
formId={formId}
|
formId={formId}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
|
order={order}
|
||||||
updateCart={updateCart}
|
updateCart={updateCart}
|
||||||
customer={customer}
|
customer={customer}
|
||||||
paymentSchedule={schedule}>
|
paymentSchedule={schedule}>
|
||||||
|
@ -11,15 +11,17 @@ import { Setting } from '../../models/setting';
|
|||||||
import { Invoice } from '../../models/invoice';
|
import { Invoice } from '../../models/invoice';
|
||||||
import SettingAPI from '../../api/setting';
|
import SettingAPI from '../../api/setting';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Order } from '../../models/order';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
interface CardPaymentModalProps {
|
interface CardPaymentModalProps {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
toggleModal: () => void,
|
toggleModal: () => void,
|
||||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
cart: ShoppingCart,
|
cart: ShoppingCart,
|
||||||
|
order?: Order,
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
schedule?: PaymentSchedule,
|
schedule?: PaymentSchedule,
|
||||||
customer: User
|
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
|
* This component open a modal dialog for the configured payment gateway, allowing the user to input his card data
|
||||||
* to process an online payment.
|
* 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 { t } = useTranslation('shared');
|
||||||
|
|
||||||
const [gateway, setGateway] = useState<Setting>(null);
|
const [gateway, setGateway] = useState<Setting>(null);
|
||||||
@ -49,6 +51,7 @@ const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal
|
|||||||
afterSuccess={afterSuccess}
|
afterSuccess={afterSuccess}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
|
order={order}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
customer={customer} />;
|
customer={customer} />;
|
||||||
@ -63,6 +66,7 @@ const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal
|
|||||||
afterSuccess={afterSuccess}
|
afterSuccess={afterSuccess}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
|
order={order}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
customer={customer} />;
|
customer={customer} />;
|
||||||
@ -99,4 +103,4 @@ const CardPaymentModalWrapper: React.FC<CardPaymentModalProps> = (props) => {
|
|||||||
|
|
||||||
export { CardPaymentModalWrapper as CardPaymentModal };
|
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,22 +8,18 @@ import SettingAPI from '../../../api/setting';
|
|||||||
import { CardPaymentModal } from '../card-payment-modal';
|
import { CardPaymentModal } from '../card-payment-modal';
|
||||||
import { PaymentSchedule } from '../../../models/payment-schedule';
|
import { PaymentSchedule } from '../../../models/payment-schedule';
|
||||||
import { HtmlTranslate } from '../../base/html-translate';
|
import { HtmlTranslate } from '../../base/html-translate';
|
||||||
|
import CheckoutAPI from '../../../api/checkout';
|
||||||
|
import { SelectOption } from '../../../models/select';
|
||||||
|
|
||||||
const ALL_SCHEDULE_METHODS = ['card', 'check', 'transfer'] as const;
|
const ALL_SCHEDULE_METHODS = ['card', 'check', 'transfer'] as const;
|
||||||
type scheduleMethod = typeof ALL_SCHEDULE_METHODS[number];
|
type scheduleMethod = typeof ALL_SCHEDULE_METHODS[number];
|
||||||
|
|
||||||
/**
|
|
||||||
* Option format, expected by react-select
|
|
||||||
* @see https://github.com/JedWatson/react-select
|
|
||||||
*/
|
|
||||||
type selectOption = { value: scheduleMethod, label: string };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A form component to ask for confirmation before cashing a payment directly at the FabLab's reception.
|
* A form component to ask for confirmation before cashing a payment directly at the FabLab's reception.
|
||||||
* This is intended for use by privileged users.
|
* This is intended for use by privileged users.
|
||||||
* The form validation button must be created elsewhere, using the attribute form={formId}.
|
* 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 { t } = useTranslation('admin');
|
||||||
|
|
||||||
const [method, setMethod] = useState<scheduleMethod>('check');
|
const [method, setMethod] = useState<scheduleMethod>('check');
|
||||||
@ -43,14 +39,14 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
|
|||||||
/**
|
/**
|
||||||
* Convert all payement methods for schedules to the react-select format
|
* Convert all payement methods for schedules to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildMethodOptions = (): Array<selectOption> => {
|
const buildMethodOptions = (): Array<SelectOption<scheduleMethod>> => {
|
||||||
return ALL_SCHEDULE_METHODS.map(i => methodToOption(i));
|
return ALL_SCHEDULE_METHODS.map(i => methodToOption(i));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert the given payment-method to the react-select format
|
* Convert the given payment-method to the react-select format
|
||||||
*/
|
*/
|
||||||
const methodToOption = (value: scheduleMethod): selectOption => {
|
const methodToOption = (value: scheduleMethod): SelectOption<scheduleMethod> => {
|
||||||
if (!value) return { value, label: '' };
|
if (!value) return { value, label: '' };
|
||||||
|
|
||||||
return { value, label: t(`app.admin.local_payment_form.method_${value}`) };
|
return { value, label: t(`app.admin.local_payment_form.method_${value}`) };
|
||||||
@ -59,7 +55,7 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
|
|||||||
/**
|
/**
|
||||||
* Callback triggered when the user selects a payment method for the current payment schedule.
|
* Callback triggered when the user selects a payment method for the current payment schedule.
|
||||||
*/
|
*/
|
||||||
const handleUpdateMethod = (option: selectOption) => {
|
const handleUpdateMethod = (option: SelectOption<scheduleMethod>) => {
|
||||||
updateCart(Object.assign({}, cart, { payment_method: option.value }));
|
updateCart(Object.assign({}, cart, { payment_method: option.value }));
|
||||||
setMethod(option.value);
|
setMethod(option.value);
|
||||||
};
|
};
|
||||||
@ -85,8 +81,14 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const document = await LocalPaymentAPI.confirmPayment(cart);
|
let res;
|
||||||
onSuccess(document);
|
if (order) {
|
||||||
|
res = await CheckoutAPI.payment(order);
|
||||||
|
res = res.order;
|
||||||
|
} else {
|
||||||
|
res = await LocalPaymentAPI.confirmPayment(cart);
|
||||||
|
}
|
||||||
|
onSuccess(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError(e);
|
onError(e);
|
||||||
}
|
}
|
||||||
@ -113,6 +115,9 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
|
|||||||
* Get the type of the main item in the cart compile
|
* Get the type of the main item in the cart compile
|
||||||
*/
|
*/
|
||||||
const mainItemType = (): string => {
|
const mainItemType = (): string => {
|
||||||
|
if (order) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
return Object.keys(cart.items[0])[0];
|
return Object.keys(cart.items[0])[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -10,15 +10,17 @@ import { ModalSize } from '../../base/fab-modal';
|
|||||||
import { Loader } from '../../base/loader';
|
import { Loader } from '../../base/loader';
|
||||||
import { react2angular } from 'react2angular';
|
import { react2angular } from 'react2angular';
|
||||||
import { IApplication } from '../../../models/application';
|
import { IApplication } from '../../../models/application';
|
||||||
|
import { Order } from '../../../models/order';
|
||||||
|
|
||||||
declare const Application: IApplication;
|
declare const Application: IApplication;
|
||||||
|
|
||||||
interface LocalPaymentModalProps {
|
interface LocalPaymentModalProps {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
toggleModal: () => void,
|
toggleModal: () => void,
|
||||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
cart: ShoppingCart,
|
cart: ShoppingCart,
|
||||||
|
order?: Order,
|
||||||
updateCart: (cart: ShoppingCart) => void,
|
updateCart: (cart: ShoppingCart) => void,
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
schedule?: PaymentSchedule,
|
schedule?: PaymentSchedule,
|
||||||
@ -28,7 +30,7 @@ interface LocalPaymentModalProps {
|
|||||||
/**
|
/**
|
||||||
* This component enables a privileged user to confirm a local payments.
|
* 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');
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,7 +56,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
|
|||||||
/**
|
/**
|
||||||
* Integrates the LocalPaymentForm into the parent AbstractPaymentModal
|
* 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 (
|
return (
|
||||||
<LocalPaymentForm onSubmit={onSubmit}
|
<LocalPaymentForm onSubmit={onSubmit}
|
||||||
onSuccess={onSuccess}
|
onSuccess={onSuccess}
|
||||||
@ -63,6 +65,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
|
|||||||
className={className}
|
className={className}
|
||||||
formId={formId}
|
formId={formId}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
|
order={order}
|
||||||
updateCart={updateCart}
|
updateCart={updateCart}
|
||||||
customer={customer}
|
customer={customer}
|
||||||
paymentSchedule={paymentSchedule}>
|
paymentSchedule={paymentSchedule}>
|
||||||
@ -81,6 +84,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
|
|||||||
formClassName="local-payment-form"
|
formClassName="local-payment-form"
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
|
order={order}
|
||||||
updateCart={updateCart}
|
updateCart={updateCart}
|
||||||
customer={customer}
|
customer={customer}
|
||||||
afterSuccess={afterSuccess}
|
afterSuccess={afterSuccess}
|
||||||
|
@ -11,6 +11,8 @@ import {
|
|||||||
} from '../../../models/payzen';
|
} from '../../../models/payzen';
|
||||||
import { PaymentSchedule } from '../../../models/payment-schedule';
|
import { PaymentSchedule } from '../../../models/payment-schedule';
|
||||||
import { Invoice } from '../../../models/invoice';
|
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
|
// we use these two additional parameters to update the card, if provided
|
||||||
interface PayzenFormProps extends GatewayFormProps {
|
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.
|
* 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}.
|
* 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 PayZenKR = useRef<KryptonClient>(null);
|
||||||
const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader');
|
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));
|
||||||
}).catch(error => onError(error));
|
}).catch(error => onError(error));
|
||||||
});
|
});
|
||||||
}, [cart, paymentSchedule, customer]);
|
}, [cart, paymentSchedule, customer, order]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the API to create the form token.
|
* 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);
|
return await PayzenAPI.updateToken(paymentSchedule?.id);
|
||||||
} else if (paymentSchedule) {
|
} else if (paymentSchedule) {
|
||||||
return await PayzenAPI.chargeCreateToken(cart, customer);
|
return await PayzenAPI.chargeCreateToken(cart, customer);
|
||||||
|
} else if (order) {
|
||||||
|
const res = await CheckoutAPI.payment(order);
|
||||||
|
return res.payment as CreateTokenResponse;
|
||||||
} else {
|
} else {
|
||||||
return await PayzenAPI.chargeCreatePayment(cart, customer);
|
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)
|
* 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) {
|
if (paymentSchedule) {
|
||||||
return await PayzenAPI.confirmPaymentSchedule(event.clientAnswer.orderDetails.orderId, transaction.uuid, cart);
|
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 {
|
} else {
|
||||||
return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart);
|
return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart);
|
||||||
}
|
}
|
||||||
@ -131,7 +139,9 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
|
|||||||
try {
|
try {
|
||||||
const { result } = await PayZenKR.current.validateForm();
|
const { result } = await PayZenKR.current.validateForm();
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
await PayzenAPI.checkCart(cart, customer);
|
if (!order) {
|
||||||
|
await PayzenAPI.checkCart(cart, customer);
|
||||||
|
}
|
||||||
await PayZenKR.current.onSubmit(onPaid);
|
await PayZenKR.current.onSubmit(onPaid);
|
||||||
await PayZenKR.current.onError(handleError);
|
await PayZenKR.current.onError(handleError);
|
||||||
await PayZenKR.current.submit();
|
await PayZenKR.current.submit();
|
||||||
|
@ -9,13 +9,15 @@ import payzenLogo from '../../../../../images/payzen-secure.png';
|
|||||||
import mastercardLogo from '../../../../../images/mastercard.png';
|
import mastercardLogo from '../../../../../images/mastercard.png';
|
||||||
import visaLogo from '../../../../../images/visa.png';
|
import visaLogo from '../../../../../images/visa.png';
|
||||||
import { PayzenForm } from './payzen-form';
|
import { PayzenForm } from './payzen-form';
|
||||||
|
import { Order } from '../../../models/order';
|
||||||
|
|
||||||
interface PayzenModalProps {
|
interface PayzenModalProps {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
toggleModal: () => void,
|
toggleModal: () => void,
|
||||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
cart: ShoppingCart,
|
cart: ShoppingCart,
|
||||||
|
order?: Order,
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
schedule?: PaymentSchedule,
|
schedule?: PaymentSchedule,
|
||||||
customer: User
|
customer: User
|
||||||
@ -28,7 +30,7 @@ interface PayzenModalProps {
|
|||||||
* This component should not be called directly. Prefer using <CardPaymentModal> which can handle the configuration
|
* This component should not be called directly. Prefer using <CardPaymentModal> which can handle the configuration
|
||||||
* of a different payment gateway.
|
* 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.
|
* 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
|
* 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 (
|
return (
|
||||||
<PayzenForm onSubmit={onSubmit}
|
<PayzenForm onSubmit={onSubmit}
|
||||||
onSuccess={onSuccess}
|
onSuccess={onSuccess}
|
||||||
@ -54,6 +56,7 @@ export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, a
|
|||||||
operator={operator}
|
operator={operator}
|
||||||
formId={formId}
|
formId={formId}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
|
order={order}
|
||||||
className={className}
|
className={className}
|
||||||
paymentSchedule={paymentSchedule}>
|
paymentSchedule={paymentSchedule}>
|
||||||
{children}
|
{children}
|
||||||
@ -70,6 +73,7 @@ export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, a
|
|||||||
className="payzen-modal"
|
className="payzen-modal"
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
|
order={order}
|
||||||
customer={customer}
|
customer={customer}
|
||||||
afterSuccess={afterSuccess}
|
afterSuccess={afterSuccess}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
|
@ -11,13 +11,16 @@ import { LocalPaymentModal } from '../local-payment/local-payment-modal';
|
|||||||
import { CardPaymentModal } from '../card-payment-modal';
|
import { CardPaymentModal } from '../card-payment-modal';
|
||||||
import PriceAPI from '../../../api/price';
|
import PriceAPI from '../../../api/price';
|
||||||
import { ComputePriceResult } from '../../../models/price';
|
import { ComputePriceResult } from '../../../models/price';
|
||||||
|
import { Order } from '../../../models/order';
|
||||||
|
import { computePriceWithCoupon } from '../../../lib/coupon';
|
||||||
|
|
||||||
interface PaymentModalProps {
|
interface PaymentModalProps {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
toggleModal: () => void,
|
toggleModal: () => void,
|
||||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
cart: ShoppingCart,
|
cart: ShoppingCart,
|
||||||
|
order?: Order,
|
||||||
updateCart: (cart: ShoppingCart) => void,
|
updateCart: (cart: ShoppingCart) => void,
|
||||||
operator: User,
|
operator: User,
|
||||||
schedule?: PaymentSchedule,
|
schedule?: PaymentSchedule,
|
||||||
@ -27,7 +30,7 @@ interface PaymentModalProps {
|
|||||||
/**
|
/**
|
||||||
* This component is responsible for rendering the payment modal.
|
* 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
|
// the user's wallet
|
||||||
const [wallet, setWallet] = useState<Wallet>(null);
|
const [wallet, setWallet] = useState<Wallet>(null);
|
||||||
// the price of the cart
|
// the price of the cart
|
||||||
@ -44,10 +47,14 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
|
|||||||
|
|
||||||
// refresh the price when the cart changes
|
// refresh the price when the cart changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
PriceAPI.compute(cart).then(price => {
|
if (order) {
|
||||||
setPrice(price);
|
setPrice({ price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total });
|
||||||
});
|
} else {
|
||||||
}, [cart]);
|
PriceAPI.compute(cart).then(price => {
|
||||||
|
setPrice(price);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [cart, order]);
|
||||||
|
|
||||||
// refresh the remaining price when the cart price was computed and the wallet was retrieved
|
// refresh the remaining price when the cart price was computed and the wallet was retrieved
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -73,6 +80,7 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
|
|||||||
afterSuccess={afterSuccess}
|
afterSuccess={afterSuccess}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
|
order={order}
|
||||||
updateCart={updateCart}
|
updateCart={updateCart}
|
||||||
currentUser={operator}
|
currentUser={operator}
|
||||||
customer={customer}
|
customer={customer}
|
||||||
@ -86,6 +94,7 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
|
|||||||
afterSuccess={afterSuccess}
|
afterSuccess={afterSuccess}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
|
order={order}
|
||||||
currentUser={operator}
|
currentUser={operator}
|
||||||
customer={customer}
|
customer={customer}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
|
@ -95,7 +95,7 @@ export const StripeCardUpdate: React.FC<StripeCardUpdateProps> = ({ onSubmit, on
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} id="stripe-card" className={`stripe-card-update ${className}`}>
|
<form onSubmit={handleSubmit} id="stripe-card" className={`stripe-card-update ${className || ''}`}>
|
||||||
<CardElement options={cardOptions} />
|
<CardElement options={cardOptions} />
|
||||||
{children}
|
{children}
|
||||||
</form>
|
</form>
|
||||||
|
@ -6,12 +6,14 @@ import { PaymentConfirmation } from '../../../models/payment';
|
|||||||
import StripeAPI from '../../../api/stripe';
|
import StripeAPI from '../../../api/stripe';
|
||||||
import { Invoice } from '../../../models/invoice';
|
import { Invoice } from '../../../models/invoice';
|
||||||
import { PaymentSchedule } from '../../../models/payment-schedule';
|
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.
|
* 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}.
|
* 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 { t } = useTranslation('shared');
|
||||||
|
|
||||||
const stripe = useStripe();
|
const stripe = useStripe();
|
||||||
@ -41,9 +43,18 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
if (!paymentSchedule) {
|
if (!paymentSchedule) {
|
||||||
// process the normal payment pipeline, including SCA validation
|
if (order) {
|
||||||
const res = await StripeAPI.confirmMethod(paymentMethod.id, cart);
|
const res = await CheckoutAPI.payment(order, paymentMethod.id);
|
||||||
await handleServerConfirmation(res);
|
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 {
|
} else {
|
||||||
const res = await StripeAPI.setupSubscription(paymentMethod.id, cart);
|
const res = await StripeAPI.setupSubscription(paymentMethod.id, cart);
|
||||||
await handleServerConfirmation(res, paymentMethod.id);
|
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
|
* @param paymentMethodId ID of the payment method, required only when confirming a payment schedule
|
||||||
* @see app/controllers/api/stripe_controller.rb#confirm_payment
|
* @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 ('error' in response) {
|
||||||
if (response.error.statusText) {
|
if (response.error.statusText) {
|
||||||
onError(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 card action has been handled
|
||||||
// The PaymentIntent can be confirmed again on the server
|
// The PaymentIntent can be confirmed again on the server
|
||||||
try {
|
try {
|
||||||
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
|
if (order) {
|
||||||
await handleServerConfirmation(confirmation);
|
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) {
|
} catch (e) {
|
||||||
onError(e);
|
onError(e);
|
||||||
}
|
}
|
||||||
|
@ -10,13 +10,15 @@ import stripeLogo from '../../../../../images/powered_by_stripe.png';
|
|||||||
import mastercardLogo from '../../../../../images/mastercard.png';
|
import mastercardLogo from '../../../../../images/mastercard.png';
|
||||||
import visaLogo from '../../../../../images/visa.png';
|
import visaLogo from '../../../../../images/visa.png';
|
||||||
import { Invoice } from '../../../models/invoice';
|
import { Invoice } from '../../../models/invoice';
|
||||||
|
import { Order } from '../../../models/order';
|
||||||
|
|
||||||
interface StripeModalProps {
|
interface StripeModalProps {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
toggleModal: () => void,
|
toggleModal: () => void,
|
||||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||||
onError: (message: string) => void,
|
onError: (message: string) => void,
|
||||||
cart: ShoppingCart,
|
cart: ShoppingCart,
|
||||||
|
order?: Order,
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
schedule?: PaymentSchedule,
|
schedule?: PaymentSchedule,
|
||||||
customer: User
|
customer: User
|
||||||
@ -29,7 +31,7 @@ interface StripeModalProps {
|
|||||||
* This component should not be called directly. Prefer using <CardPaymentModal> which can handle the configuration
|
* This component should not be called directly. Prefer using <CardPaymentModal> which can handle the configuration
|
||||||
* of a different payment gateway.
|
* 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.
|
* 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
|
* 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 (
|
return (
|
||||||
<StripeElements>
|
<StripeElements>
|
||||||
<StripeForm onSubmit={onSubmit}
|
<StripeForm onSubmit={onSubmit}
|
||||||
@ -57,6 +59,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
|
|||||||
className={className}
|
className={className}
|
||||||
formId={formId}
|
formId={formId}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
|
order={order}
|
||||||
customer={customer}
|
customer={customer}
|
||||||
paymentSchedule={paymentSchedule}>
|
paymentSchedule={paymentSchedule}>
|
||||||
{children}
|
{children}
|
||||||
@ -74,6 +77,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
|
|||||||
formClassName="stripe-form"
|
formClassName="stripe-form"
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
|
order={order}
|
||||||
customer={customer}
|
customer={customer}
|
||||||
afterSuccess={afterSuccess}
|
afterSuccess={afterSuccess}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
|
@ -5,6 +5,7 @@ import { Group } from '../../models/group';
|
|||||||
import { User } from '../../models/user';
|
import { User } from '../../models/user';
|
||||||
import PlanAPI from '../../api/plan';
|
import PlanAPI from '../../api/plan';
|
||||||
import { PlansDuration } from '../../models/plan';
|
import { PlansDuration } from '../../models/plan';
|
||||||
|
import { SelectOption } from '../../models/select';
|
||||||
|
|
||||||
interface PlansFilterProps {
|
interface PlansFilterProps {
|
||||||
user?: User,
|
user?: User,
|
||||||
@ -14,12 +15,6 @@ interface PlansFilterProps {
|
|||||||
onDurationSelected: (plansIds: Array<number>) => void,
|
onDurationSelected: (plansIds: Array<number>) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Option format, expected by react-select
|
|
||||||
* @see https://github.com/JedWatson/react-select
|
|
||||||
*/
|
|
||||||
type selectOption = { value: number, label: string };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows filtering on plans list
|
* Allows filtering on plans list
|
||||||
*/
|
*/
|
||||||
@ -38,8 +33,8 @@ export const PlansFilter: React.FC<PlansFilterProps> = ({ user, groups, onGroupS
|
|||||||
/**
|
/**
|
||||||
* Convert all groups to the react-select format
|
* Convert all groups to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildGroupOptions = (): Array<selectOption> => {
|
const buildGroupOptions = (): Array<SelectOption<number>> => {
|
||||||
return groups.filter(g => !g.disabled && g.slug !== 'admins').map(g => {
|
return groups.filter(g => !g.disabled).map(g => {
|
||||||
return { value: g.id, label: g.name };
|
return { value: g.id, label: g.name };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -47,7 +42,7 @@ export const PlansFilter: React.FC<PlansFilterProps> = ({ user, groups, onGroupS
|
|||||||
/**
|
/**
|
||||||
* Convert all durations to the react-select format
|
* Convert all durations to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildDurationOptions = (): Array<selectOption> => {
|
const buildDurationOptions = (): Array<SelectOption<number>> => {
|
||||||
const options = durations.map((d, index) => {
|
const options = durations.map((d, index) => {
|
||||||
return { value: index, label: d.name };
|
return { value: index, label: d.name };
|
||||||
});
|
});
|
||||||
@ -58,14 +53,14 @@ export const PlansFilter: React.FC<PlansFilterProps> = ({ user, groups, onGroupS
|
|||||||
/**
|
/**
|
||||||
* Callback triggered when the user selects a group in the dropdown list
|
* Callback triggered when the user selects a group in the dropdown list
|
||||||
*/
|
*/
|
||||||
const handleGroupSelected = (option: selectOption): void => {
|
const handleGroupSelected = (option: SelectOption<number>): void => {
|
||||||
onGroupSelected(option.value);
|
onGroupSelected(option.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback triggered when the user selects a duration in the dropdown list
|
* Callback triggered when the user selects a duration in the dropdown list
|
||||||
*/
|
*/
|
||||||
const handleDurationSelected = (option: selectOption): void => {
|
const handleDurationSelected = (option: SelectOption<number>): void => {
|
||||||
onDurationSelected(durations[option.value]?.plans_ids);
|
onDurationSelected(durations[option.value]?.plans_ids);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -235,7 +235,7 @@ export const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection,
|
|||||||
{plans && Array.from(filteredPlans()).map(([groupId, plansByGroup]) => {
|
{plans && Array.from(filteredPlans()).map(([groupId, plansByGroup]) => {
|
||||||
return (
|
return (
|
||||||
<div key={groupId} className="plans-per-group">
|
<div key={groupId} className="plans-per-group">
|
||||||
{plansByGroup.size > 0 && <h2 className="group-title">{ groupName(groupId) }</h2>}
|
{plansByGroup?.size > 0 && <h2 className="group-title">{ groupName(groupId) }</h2>}
|
||||||
{plansByGroup && renderPlansByCategory(plansByGroup)}
|
{plansByGroup && renderPlansByCategory(plansByGroup)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -41,7 +41,7 @@ export const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuc
|
|||||||
MachineAPI.index({ disabled: false })
|
MachineAPI.index({ disabled: false })
|
||||||
.then(data => setMachines(data))
|
.then(data => setMachines(data))
|
||||||
.catch(error => onError(error));
|
.catch(error => onError(error));
|
||||||
GroupAPI.index({ disabled: false, admins: false })
|
GroupAPI.index({ disabled: false })
|
||||||
.then(data => setGroups(data))
|
.then(data => setGroups(data))
|
||||||
.catch(error => onError(error));
|
.catch(error => onError(error));
|
||||||
PriceAPI.index({ priceable_type: 'Machine', plan_id: null })
|
PriceAPI.index({ priceable_type: 'Machine', plan_id: null })
|
||||||
|
@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useImmer } from 'use-immer';
|
import { useImmer } from 'use-immer';
|
||||||
import { FabInput } from '../../base/fab-input';
|
import { FabInput } from '../../base/fab-input';
|
||||||
import { IFablab } from '../../../models/fablab';
|
import { IFablab } from '../../../models/fablab';
|
||||||
|
import { SelectOption } from '../../../models/select';
|
||||||
|
|
||||||
declare let Fablab: IFablab;
|
declare let Fablab: IFablab;
|
||||||
|
|
||||||
@ -18,12 +19,6 @@ interface PackFormProps {
|
|||||||
const ALL_INTERVALS = ['day', 'week', 'month', 'year'] as const;
|
const ALL_INTERVALS = ['day', 'week', 'month', 'year'] as const;
|
||||||
type interval = typeof ALL_INTERVALS[number];
|
type interval = typeof ALL_INTERVALS[number];
|
||||||
|
|
||||||
/**
|
|
||||||
* Option format, expected by react-select
|
|
||||||
* @see https://github.com/JedWatson/react-select
|
|
||||||
*/
|
|
||||||
type selectOption = { value: interval, label: string };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A form component to create/edit a PrepaidPack.
|
* A form component to create/edit a PrepaidPack.
|
||||||
* The form validation must be created elsewhere, using the attribute form={formId}.
|
* The form validation must be created elsewhere, using the attribute form={formId}.
|
||||||
@ -36,14 +31,14 @@ export const PackForm: React.FC<PackFormProps> = ({ formId, onSubmit, pack }) =>
|
|||||||
/**
|
/**
|
||||||
* Convert all validity-intervals to the react-select format
|
* Convert all validity-intervals to the react-select format
|
||||||
*/
|
*/
|
||||||
const buildOptions = (): Array<selectOption> => {
|
const buildOptions = (): Array<SelectOption<interval>> => {
|
||||||
return ALL_INTERVALS.map(i => intervalToOption(i));
|
return ALL_INTERVALS.map(i => intervalToOption(i));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert the given validity-interval to the react-select format
|
* Convert the given validity-interval to the react-select format
|
||||||
*/
|
*/
|
||||||
const intervalToOption = (value: interval): selectOption => {
|
const intervalToOption = (value: interval): SelectOption<interval> => {
|
||||||
if (!value) return { value, label: '' };
|
if (!value) return { value, label: '' };
|
||||||
|
|
||||||
return { value, label: t(`app.admin.pack_form.intervals.${value}`, { COUNT: packData.validity_count || 0 }) };
|
return { value, label: t(`app.admin.pack_form.intervals.${value}`, { COUNT: packData.validity_count || 0 }) };
|
||||||
@ -87,7 +82,7 @@ export const PackForm: React.FC<PackFormProps> = ({ formId, onSubmit, pack }) =>
|
|||||||
/**
|
/**
|
||||||
* Callback triggered when the user selects a type of interval for the current pack.
|
* Callback triggered when the user selects a type of interval for the current pack.
|
||||||
*/
|
*/
|
||||||
const handleUpdateValidityInterval = (option: selectOption) => {
|
const handleUpdateValidityInterval = (option: SelectOption<interval>) => {
|
||||||
updatePackData(draft => {
|
updatePackData(draft => {
|
||||||
draft.validity_interval = option.value as interval;
|
draft.validity_interval = option.value as interval;
|
||||||
});
|
});
|
||||||
|
@ -38,7 +38,7 @@ export const SpacesPricing: React.FC<SpacesPricingProps> = ({ onError, onSuccess
|
|||||||
SpaceAPI.index()
|
SpaceAPI.index()
|
||||||
.then(data => setSpaces(data))
|
.then(data => setSpaces(data))
|
||||||
.catch(error => onError(error));
|
.catch(error => onError(error));
|
||||||
GroupAPI.index({ disabled: false, admins: false })
|
GroupAPI.index({ disabled: false })
|
||||||
.then(data => setGroups(data))
|
.then(data => setGroups(data))
|
||||||
.catch(error => onError(error));
|
.catch(error => onError(error));
|
||||||
PriceAPI.index({ priceable_type: 'Space', plan_id: null })
|
PriceAPI.index({ priceable_type: 'Space', plan_id: null })
|
||||||
|
@ -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="main-action-btn"
|
||||||
|
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}`)}
|
||||||
|
width={ModalSize.large}
|
||||||
|
isOpen={isOpen}
|
||||||
|
toggleModal={toggleModal}
|
||||||
|
closeButton>
|
||||||
|
{ action === 'update' && <p className='subtitle'>{productCategory.name}</p>}
|
||||||
|
<ProductCategoryForm action={action}
|
||||||
|
productCategories={productCategories}
|
||||||
|
productCategory={productCategory}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
onError={onError} />
|
||||||
|
</FabModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,78 @@
|
|||||||
|
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, ArrowLeft, 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') && <ArrowLeft 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' hidden>{category.products_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,381 @@
|
|||||||
|
import React, { useEffect } 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>, activeCategory: ProductCategory, position: number) => 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 if (Math.ceil(delta.x) < -32 && getStatus(over.id) === 'child') {
|
||||||
|
setActiveData(draft => {
|
||||||
|
return { ...draft, offset: 'up' };
|
||||||
|
});
|
||||||
|
} 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;
|
||||||
|
let droppedItem = getCategory(active.id);
|
||||||
|
const activeStatus = getStatus(active.id);
|
||||||
|
const overStatus = getStatus(over.id);
|
||||||
|
let newPosition = getCategory(over.id).position;
|
||||||
|
|
||||||
|
// [A]:Single dropped over [B]:Single
|
||||||
|
if (activeStatus === 'single' && overStatus === '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) };
|
||||||
|
droppedItem = category;
|
||||||
|
newPosition = 1;
|
||||||
|
} else if (activeData.offset === 'down' && sortedId === active.id && (activeData.index > newIndex || active.id === over.id)) {
|
||||||
|
const adopter = getPreviousAdopter(over.id);
|
||||||
|
const siblingsLength = getChildren(adopter)?.length | 0;
|
||||||
|
category = { ...category, parent_id: adopter };
|
||||||
|
droppedItem = category;
|
||||||
|
newPosition = siblingsLength + 1;
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// [A]:Child dropped over [B]:Single
|
||||||
|
if ((activeStatus === 'child') && overStatus === '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) };
|
||||||
|
droppedItem = category;
|
||||||
|
newPosition = 1;
|
||||||
|
} else if (activeData.offset === 'down' && sortedId === active.id && activeData.index > newIndex) {
|
||||||
|
category = { ...category, parent_id: getPreviousAdopter(over.id) };
|
||||||
|
droppedItem = category;
|
||||||
|
newPosition = getChildren(getPreviousAdopter(over.id))?.length || 1;
|
||||||
|
} else if (sortedId === active.id) {
|
||||||
|
category = { ...category, parent_id: null };
|
||||||
|
droppedItem = category;
|
||||||
|
newPosition = getCategory(over.id).position + 1;
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// [A]:Single || [A]:Child dropped over…
|
||||||
|
if (activeStatus === 'single' || activeStatus === 'child') {
|
||||||
|
// [B]:Parent
|
||||||
|
if (overStatus === '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) };
|
||||||
|
droppedItem = category;
|
||||||
|
newPosition = 1;
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
droppedItem = category;
|
||||||
|
} else if (sortedId === active.id && activeData.offset === 'down') {
|
||||||
|
category = { ...category, parent_id: getPreviousAdopter(over.id) };
|
||||||
|
droppedItem = category;
|
||||||
|
newPosition = getChildren(getPreviousAdopter(over.id))?.length || 1;
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [B]:Child
|
||||||
|
if (overStatus === 'child') {
|
||||||
|
if (activeData.offset === 'up') {
|
||||||
|
const lastChildIndex = newOrder.findIndex(c => c.id === getChildren(getCategory(over.id).parent_id).pop().id);
|
||||||
|
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, lastChildIndex);
|
||||||
|
newOrder = newIdsOrder.map(sortedId => {
|
||||||
|
let category = getCategory(sortedId);
|
||||||
|
if (sortedId === active.id) {
|
||||||
|
category = { ...category, parent_id: null };
|
||||||
|
droppedItem = category;
|
||||||
|
newPosition = getCategory(getCategory(over.id).parent_id).position + 1;
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
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 };
|
||||||
|
droppedItem = category;
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [A]:Parent dropped over…
|
||||||
|
if (activeStatus === 'parent') {
|
||||||
|
// [B]:Single
|
||||||
|
if (overStatus === 'single') {
|
||||||
|
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
|
||||||
|
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
|
||||||
|
}
|
||||||
|
// [B]:Parent
|
||||||
|
if (overStatus === 'parent') {
|
||||||
|
if (activeData.index < newIndex) {
|
||||||
|
newIndex = newOrder.findIndex(c => c.id === getChildren(over.id).pop().id);
|
||||||
|
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 (overStatus === 'child') {
|
||||||
|
const parent = newOrder.find(c => c.id === getCategory(over.id).parent_id);
|
||||||
|
if (activeData.index < newIndex) {
|
||||||
|
newIndex = newOrder.findIndex(c => c.id === getChildren(parent.id).pop().id);
|
||||||
|
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
|
||||||
|
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
|
||||||
|
newPosition = parent.position;
|
||||||
|
} else {
|
||||||
|
newIndex = currentIdsOrder.indexOf(getCategory(over.id).parent_id);
|
||||||
|
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
|
||||||
|
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
|
||||||
|
newPosition = parent.position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// insert children back
|
||||||
|
newOrder = showChildren(active.id, newOrder, newIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveData(initActiveData);
|
||||||
|
onDnd(newOrder, droppedItem, newPosition);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 hiding/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,99 @@
|
|||||||
|
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 { HtmlTranslate } from '../../base/html-translate';
|
||||||
|
import { IApplication } from '../../../models/application';
|
||||||
|
import { Loader } from '../../base/loader';
|
||||||
|
import { react2angular } from 'react2angular';
|
||||||
|
import ProductLib from '../../../lib/product';
|
||||||
|
|
||||||
|
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 = (list: ProductCategory[], activeCategory: ProductCategory, position: number) => {
|
||||||
|
setProductCategories(list);
|
||||||
|
ProductCategoryAPI
|
||||||
|
.update(activeCategory)
|
||||||
|
.then(c => {
|
||||||
|
ProductCategoryAPI
|
||||||
|
.updatePosition(c, position)
|
||||||
|
.then(refreshCategories)
|
||||||
|
.catch(error => onError(error));
|
||||||
|
})
|
||||||
|
.catch(error => onError(error));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the list of categories
|
||||||
|
*/
|
||||||
|
const refreshCategories = () => {
|
||||||
|
ProductCategoryAPI.index().then(data => {
|
||||||
|
setProductCategories(ProductLib.sortCategories(data));
|
||||||
|
}).catch((error) => onError(error));
|
||||||
|
};
|
||||||
|
|
||||||
|
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} />
|
||||||
|
</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,124 @@
|
|||||||
|
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 ProductCategoryAPI from '../../../api/product-category';
|
||||||
|
import { HtmlTranslate } from '../../base/html-translate';
|
||||||
|
import { SelectOption } from '../../../models/select';
|
||||||
|
|
||||||
|
interface ProductCategoryFormProps {
|
||||||
|
action: 'create' | 'update' | 'delete',
|
||||||
|
productCategories: Array<ProductCategory>,
|
||||||
|
productCategory?: ProductCategory,
|
||||||
|
onSuccess: (message: string) => void,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<number>> => {
|
||||||
|
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, strict: true });
|
||||||
|
setValue('slug', _slug);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [watch]);
|
||||||
|
// Check slug pattern
|
||||||
|
// Only lowercase alphanumeric groups of characters separated by an hyphen
|
||||||
|
const slugPattern = /^[a-z\d]+(?:-[a-z\d]+)*$/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'
|
||||||
|
? <>
|
||||||
|
<HtmlTranslate trKey="app.admin.store.product_category_form.delete.confirm" options={{ CATEGORY: productCategory?.name }} />
|
||||||
|
<FabButton type='submit'>{t('app.admin.store.product_category_form.delete.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,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||||
|
import { FormInput } from '../form/form-input';
|
||||||
|
import { FormSwitch } from '../form/form-switch';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FabModal, ModalSize } from '../base/fab-modal';
|
||||||
|
import { Product } from '../../models/product';
|
||||||
|
import ProductAPI from '../../api/product';
|
||||||
|
|
||||||
|
interface CloneProductModalProps {
|
||||||
|
isOpen: boolean,
|
||||||
|
toggleModal: () => void,
|
||||||
|
onSuccess: (product: Product) => void,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
product: Product,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal dialog to clone a product
|
||||||
|
*/
|
||||||
|
export const CloneProductModal: React.FC<CloneProductModalProps> = ({ isOpen, toggleModal, onSuccess, onError, product }) => {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const { handleSubmit, register, control, formState, reset } = useForm<Product>({
|
||||||
|
defaultValues: {
|
||||||
|
name: product.name,
|
||||||
|
sku: product.sku,
|
||||||
|
is_active: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call product clone api
|
||||||
|
*/
|
||||||
|
const handleClone: SubmitHandler<Product> = (data: Product) => {
|
||||||
|
ProductAPI.clone(product, data).then((res) => {
|
||||||
|
reset(res);
|
||||||
|
onSuccess(res);
|
||||||
|
}).catch(onError);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FabModal title={t('app.admin.store.clone_product_modal.clone_product')}
|
||||||
|
closeButton
|
||||||
|
isOpen={isOpen}
|
||||||
|
toggleModal={toggleModal}
|
||||||
|
width={ModalSize.medium}
|
||||||
|
confirmButton={t('app.admin.store.clone_product_modal.clone')}
|
||||||
|
onConfirm={handleSubmit(handleClone)}>
|
||||||
|
<form className="clone-product-form" onSubmit={handleSubmit(handleClone)}>
|
||||||
|
<FormInput id="name"
|
||||||
|
register={register}
|
||||||
|
rules={{ required: true }}
|
||||||
|
formState={formState}
|
||||||
|
label={t('app.admin.store.clone_product_modal.name')}
|
||||||
|
className="span-12" />
|
||||||
|
<FormInput id="sku"
|
||||||
|
register={register}
|
||||||
|
formState={formState}
|
||||||
|
label={t('app.admin.store.clone_product_modal.sku')}
|
||||||
|
className="span-12" />
|
||||||
|
{product.is_active &&
|
||||||
|
<FormSwitch control={control}
|
||||||
|
id="is_active"
|
||||||
|
formState={formState}
|
||||||
|
label={t('app.admin.store.clone_product_modal.is_show_in_store')}
|
||||||
|
tooltip={t('app.admin.store.clone_product_modal.active_price_info')}
|
||||||
|
className='span-12' />
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
</FabModal>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,71 @@
|
|||||||
|
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';
|
||||||
|
import { UIRouter } from '@uirouter/angularjs';
|
||||||
|
|
||||||
|
declare const Application: IApplication;
|
||||||
|
|
||||||
|
interface EditProductProps {
|
||||||
|
productId: number,
|
||||||
|
onSuccess: (message: string) => void,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
uiRouter: UIRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component show edit product form
|
||||||
|
*/
|
||||||
|
const EditProduct: React.FC<EditProductProps> = ({ productId, onSuccess, onError, uiRouter }) => {
|
||||||
|
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
|
||||||
|
* or
|
||||||
|
* Success to clone product and return to new product
|
||||||
|
*/
|
||||||
|
const saveProductSuccess = (data: Product) => {
|
||||||
|
if (data.id === product.id) {
|
||||||
|
onSuccess(t('app.admin.store.edit_product.successfully_updated'));
|
||||||
|
window.location.href = '/#!/admin/store/products';
|
||||||
|
} else {
|
||||||
|
onSuccess(t('app.admin.store.edit_product.successfully_cloned'));
|
||||||
|
window.location.href = `/#!/admin/store/products/${data.id}/edit`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (product) {
|
||||||
|
return (
|
||||||
|
<div className="edit-product">
|
||||||
|
<ProductForm product={product}
|
||||||
|
title={product.name}
|
||||||
|
onSuccess={saveProductSuccess}
|
||||||
|
onError={onError}
|
||||||
|
uiRouter={uiRouter} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditProductWrapper: React.FC<EditProductProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Loader>
|
||||||
|
<EditProduct {...props} />
|
||||||
|
</Loader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError', 'uiRouter']));
|
@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { ProductIndexFilter } from '../../../models/product';
|
||||||
|
import { X } from 'phosphor-react';
|
||||||
|
import { ProductCategory } from '../../../models/product-category';
|
||||||
|
import { Machine } from '../../../models/machine';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface ActiveFiltersTagsProps {
|
||||||
|
filters: ProductIndexFilter,
|
||||||
|
displayCategories?: boolean,
|
||||||
|
onRemoveCategory?: (category: ProductCategory) => void,
|
||||||
|
onRemoveMachine: (machine: Machine) => void,
|
||||||
|
onRemoveKeyword: () => void,
|
||||||
|
onRemoveStock?: () => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some tags listing the currently actives filters for a product list
|
||||||
|
*/
|
||||||
|
export const ActiveFiltersTags: React.FC<ActiveFiltersTagsProps> = ({ filters, displayCategories = true, onRemoveCategory, onRemoveMachine, onRemoveKeyword, onRemoveStock }) => {
|
||||||
|
const { t } = useTranslation('shared');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{displayCategories && filters.categories.map(c => (
|
||||||
|
<div key={c.id} className='features-item'>
|
||||||
|
<p>{c.name}</p>
|
||||||
|
<button onClick={() => onRemoveCategory(c)}><X size={16} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filters.machines.map(m => (
|
||||||
|
<div key={m.id} className='features-item'>
|
||||||
|
<p>{m.name}</p>
|
||||||
|
<button onClick={() => onRemoveMachine(m)}><X size={16} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filters.keywords[0] && <div className='features-item'>
|
||||||
|
<p>{t('app.shared.active_filters_tags.keyword', { KEYWORD: filters.keywords[0] })}</p>
|
||||||
|
<button onClick={onRemoveKeyword}><X size={16} weight="light" /></button>
|
||||||
|
</div>}
|
||||||
|
{(!_.isNil(filters.stock_to) && (filters.stock_to !== 0 || filters.stock_from !== 0)) && <div className='features-item'>
|
||||||
|
<p>{t(`app.shared.active_filters_tags.stock_${filters.stock_type}`)} [{filters.stock_from || '…'} ⟶ {filters.stock_to || '…'}]</p>
|
||||||
|
<button onClick={onRemoveStock}><X size={16} weight="light" /></button>
|
||||||
|
</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,72 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import ProductLib from '../../../lib/product';
|
||||||
|
import { ProductCategory } from '../../../models/product-category';
|
||||||
|
import { FabButton } from '../../base/fab-button';
|
||||||
|
import { AccordionItem } from '../../base/accordion-item';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface CategoriesFilterProps {
|
||||||
|
productCategories: Array<ProductCategory>,
|
||||||
|
onApplyFilters: (categories: Array<ProductCategory>) => void,
|
||||||
|
currentFilters: Array<ProductCategory>,
|
||||||
|
openDefault?: boolean,
|
||||||
|
instantUpdate?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to filter the products list by categories
|
||||||
|
*/
|
||||||
|
export const CategoriesFilter: React.FC<CategoriesFilterProps> = ({ productCategories, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
|
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<ProductCategory[]>(currentFilters || []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentFilters && !_.isEqual(currentFilters, selectedCategories)) {
|
||||||
|
setSelectedCategories(currentFilters);
|
||||||
|
}
|
||||||
|
}, [currentFilters]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/close the accordion item
|
||||||
|
*/
|
||||||
|
const handleAccordion = (id, state: boolean) => {
|
||||||
|
setOpenedAccordion(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when a category filter is selected or unselected.
|
||||||
|
* This may cause other categories to be selected or unselected accordingly.
|
||||||
|
*/
|
||||||
|
const handleSelectCategory = (currentCategory: ProductCategory, checked: boolean) => {
|
||||||
|
const list = ProductLib.categoriesSelectionTree(productCategories, selectedCategories, currentCategory, checked ? 'add' : 'remove');
|
||||||
|
|
||||||
|
setSelectedCategories(list);
|
||||||
|
if (instantUpdate) {
|
||||||
|
onApplyFilters(list);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AccordionItem id={0}
|
||||||
|
isOpen={openedAccordion}
|
||||||
|
onChange={handleAccordion}
|
||||||
|
label={t('app.admin.store.categories_filter.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={selectedCategories.includes(pc)} onChange={(event) => handleSelectCategory(pc, event.target.checked)} />
|
||||||
|
<p>{pc.name}</p>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FabButton onClick={() => onApplyFilters(selectedCategories)} className="is-secondary">{t('app.admin.store.categories_filter.filter_apply')}</FabButton>
|
||||||
|
</div>
|
||||||
|
</AccordionItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,63 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FabButton } from '../../base/fab-button';
|
||||||
|
import { AccordionItem } from '../../base/accordion-item';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
interface KeywordFilterProps {
|
||||||
|
onApplyFilters: (keywork: string) => void,
|
||||||
|
currentFilters?: string,
|
||||||
|
openDefault?: boolean,
|
||||||
|
instantUpdate?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to filter the products list by keyword or product reference
|
||||||
|
*/
|
||||||
|
export const KeywordFilter: React.FC<KeywordFilterProps> = ({ onApplyFilters, currentFilters = '', openDefault = false, instantUpdate = false }) => {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
|
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
|
||||||
|
const [keyword, setKeyword] = useState<string>(currentFilters || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!_.isEqual(currentFilters, keyword)) {
|
||||||
|
setKeyword(currentFilters);
|
||||||
|
}
|
||||||
|
}, [currentFilters]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/close the accordion item
|
||||||
|
*/
|
||||||
|
const handleAccordion = (id, state: boolean) => {
|
||||||
|
setOpenedAccordion(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user types anything in the input
|
||||||
|
*/
|
||||||
|
const handleKeywordTyping = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setKeyword(evt.target.value);
|
||||||
|
|
||||||
|
if (instantUpdate) {
|
||||||
|
onApplyFilters(evt.target.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AccordionItem id={2}
|
||||||
|
isOpen={openedAccordion}
|
||||||
|
onChange={handleAccordion}
|
||||||
|
label={t('app.admin.store.keyword_filter.filter_keywords_reference')}
|
||||||
|
>
|
||||||
|
<div className="content">
|
||||||
|
<div className="group">
|
||||||
|
<input type="text" onChange={event => handleKeywordTyping(event)} value={keyword} />
|
||||||
|
<FabButton onClick={() => onApplyFilters(keyword || undefined)} className="is-secondary">{t('app.admin.store.keyword_filter.filter_apply')}</FabButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,84 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FabButton } from '../../base/fab-button';
|
||||||
|
import { AccordionItem } from '../../base/accordion-item';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Machine } from '../../../models/machine';
|
||||||
|
import MachineAPI from '../../../api/machine';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
interface MachinesFilterProps {
|
||||||
|
allMachines?: Array<Machine>,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
onApplyFilters: (categories: Array<Machine>) => void,
|
||||||
|
currentFilters: Array<Machine>,
|
||||||
|
openDefault?: boolean,
|
||||||
|
instantUpdate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to filter the products list by associated machine
|
||||||
|
*/
|
||||||
|
export const MachinesFilter: React.FC<MachinesFilterProps> = ({ allMachines, onError, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
|
const [machines, setMachines] = useState<Machine[]>(allMachines || []);
|
||||||
|
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
|
||||||
|
const [selectedMachines, setSelectedMachines] = useState<Machine[]>(currentFilters || []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (_.isEmpty(allMachines)) {
|
||||||
|
MachineAPI.index({ disabled: false }).then(data => {
|
||||||
|
setMachines(data);
|
||||||
|
}).catch(onError);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentFilters && !_.isEqual(currentFilters, selectedMachines)) {
|
||||||
|
setSelectedMachines(currentFilters);
|
||||||
|
}
|
||||||
|
}, [currentFilters]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/close the accordion item
|
||||||
|
*/
|
||||||
|
const handleAccordion = (id, state: boolean) => {
|
||||||
|
setOpenedAccordion(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when a machine filter is seleced or unselected.
|
||||||
|
*/
|
||||||
|
const handleSelectMachine = (currentMachine: Machine, checked: boolean) => {
|
||||||
|
const list = [...selectedMachines];
|
||||||
|
checked
|
||||||
|
? list.push(currentMachine)
|
||||||
|
: list.splice(list.indexOf(currentMachine), 1);
|
||||||
|
|
||||||
|
setSelectedMachines(list);
|
||||||
|
if (instantUpdate) {
|
||||||
|
onApplyFilters(list);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AccordionItem id={1}
|
||||||
|
isOpen={openedAccordion}
|
||||||
|
onChange={handleAccordion}
|
||||||
|
label={t('app.admin.store.machines_filter.filter_machines')}>
|
||||||
|
<div className='content'>
|
||||||
|
<div className="group u-scrollbar">
|
||||||
|
{machines.map(m => (
|
||||||
|
<label key={m.id}>
|
||||||
|
<input type="checkbox" checked={selectedMachines.includes(m)} onChange={(event) => handleSelectMachine(m, event.target.checked)} />
|
||||||
|
<p>{m.name}</p>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FabButton onClick={() => onApplyFilters(selectedMachines)} className="is-secondary">{t('app.admin.store.machines_filter.filter_apply')}</FabButton>
|
||||||
|
</div>
|
||||||
|
</AccordionItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,87 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FabButton } from '../../base/fab-button';
|
||||||
|
import { AccordionItem } from '../../base/accordion-item';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ProductIndexFilter, StockType } from '../../../models/product';
|
||||||
|
import { FormSelect } from '../../form/form-select';
|
||||||
|
import { FormInput } from '../../form/form-input';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { SelectOption } from '../../../models/select';
|
||||||
|
|
||||||
|
interface StockFilterProps {
|
||||||
|
onApplyFilters: (filters: ProductIndexFilter) => void,
|
||||||
|
currentFilters: ProductIndexFilter,
|
||||||
|
openDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to filter the products list by stock
|
||||||
|
*/
|
||||||
|
export const StockFilter: React.FC<StockFilterProps> = ({ onApplyFilters, currentFilters, openDefault = false }) => {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
|
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
|
||||||
|
|
||||||
|
const { register, control, handleSubmit, getValues, reset } = useForm<ProductIndexFilter>({ defaultValues: { ...currentFilters } });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentFilters && !_.isEqual(currentFilters, getValues())) {
|
||||||
|
reset(currentFilters);
|
||||||
|
}
|
||||||
|
}, [currentFilters]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/close the accordion item
|
||||||
|
*/
|
||||||
|
const handleAccordion = (id, state: boolean) => {
|
||||||
|
setOpenedAccordion(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the user clicks on "apply" to apply teh current filters.
|
||||||
|
*/
|
||||||
|
const onSubmit = (data: ProductIndexFilter) => {
|
||||||
|
onApplyFilters(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Creates sorting options to the react-select format */
|
||||||
|
const buildStockOptions = (): Array<SelectOption<StockType>> => {
|
||||||
|
return [
|
||||||
|
{ value: 'internal', label: t('app.admin.store.stock_filter.stock_internal') },
|
||||||
|
{ value: 'external', label: t('app.admin.store.stock_filter.stock_external') }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AccordionItem id={3}
|
||||||
|
isOpen={openedAccordion}
|
||||||
|
onChange={handleAccordion}
|
||||||
|
label={t('app.admin.store.stock_filter.filter_stock')}>
|
||||||
|
<form className="content" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="group">
|
||||||
|
<FormSelect id="stock_type"
|
||||||
|
options={buildStockOptions()}
|
||||||
|
valueDefault="internal"
|
||||||
|
control={control}
|
||||||
|
/>
|
||||||
|
<div className='range'>
|
||||||
|
<FormInput id="stock_from"
|
||||||
|
label={t('app.admin.store.stock_filter.filter_stock_from')}
|
||||||
|
register={register}
|
||||||
|
defaultValue={0}
|
||||||
|
type="number" />
|
||||||
|
<FormInput id="stock_to"
|
||||||
|
label={t('app.admin.store.stock_filter.filter_stock_to')}
|
||||||
|
register={register}
|
||||||
|
defaultValue={0}
|
||||||
|
type="number" />
|
||||||
|
</div>
|
||||||
|
<FabButton type="submit" className="is-secondary">{t('app.admin.store.stock_filter.filter_apply')}</FabButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AccordionItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
68
app/frontend/src/javascript/components/store/new-product.tsx
Normal file
68
app/frontend/src/javascript/components/store/new-product.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { react2angular } from 'react2angular';
|
||||||
|
import { Loader } from '../base/loader';
|
||||||
|
import { IApplication } from '../../models/application';
|
||||||
|
import { ProductForm } from './product-form';
|
||||||
|
import { UIRouter } from '@uirouter/angularjs';
|
||||||
|
|
||||||
|
declare const Application: IApplication;
|
||||||
|
|
||||||
|
interface NewProductProps {
|
||||||
|
onSuccess: (message: string) => void,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
uiRouter: UIRouter,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component show new product form
|
||||||
|
*/
|
||||||
|
const NewProduct: React.FC<NewProductProps> = ({ onSuccess, onError, uiRouter }) => {
|
||||||
|
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}
|
||||||
|
uiRouter={uiRouter} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NewProductWrapper: React.FC<NewProductProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Loader>
|
||||||
|
<NewProduct {...props} />
|
||||||
|
</Loader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError', 'uiRouter']));
|
125
app/frontend/src/javascript/components/store/order-actions.tsx
Normal file
125
app/frontend/src/javascript/components/store/order-actions.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Select from 'react-select';
|
||||||
|
import { FabModal } from '../base/fab-modal';
|
||||||
|
import OrderAPI from '../../api/order';
|
||||||
|
import { Order } from '../../models/order';
|
||||||
|
import FabTextEditor from '../base/text-editor/fab-text-editor';
|
||||||
|
import { HtmlTranslate } from '../base/html-translate';
|
||||||
|
import { SelectOption } from '../../models/select';
|
||||||
|
|
||||||
|
interface OrderActionsProps {
|
||||||
|
order: Order,
|
||||||
|
onSuccess: (order: Order, message: string) => void,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions for an order
|
||||||
|
*/
|
||||||
|
export const OrderActions: React.FC<OrderActionsProps> = ({ order, onSuccess, onError }) => {
|
||||||
|
const { t } = useTranslation('shared');
|
||||||
|
const [currentAction, setCurrentAction] = useState<SelectOption<string>>();
|
||||||
|
const [modalIsOpen, setModalIsOpen] = useState<boolean>(false);
|
||||||
|
const [readyNote, setReadyNote] = useState<string>('');
|
||||||
|
|
||||||
|
// Styles the React-select component
|
||||||
|
const customStyles = {
|
||||||
|
control: base => ({
|
||||||
|
...base,
|
||||||
|
width: '20ch',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}),
|
||||||
|
indicatorSeparator: () => ({
|
||||||
|
display: 'none'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the action confirmation modal
|
||||||
|
*/
|
||||||
|
const closeModal = (): void => {
|
||||||
|
setModalIsOpen(false);
|
||||||
|
setCurrentAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates sorting options to the react-select format
|
||||||
|
*/
|
||||||
|
const buildOptions = (): Array<SelectOption<string>> => {
|
||||||
|
let actions = [];
|
||||||
|
switch (order.state) {
|
||||||
|
case 'paid':
|
||||||
|
actions = actions.concat(['in_progress', 'ready', 'delivered', 'canceled', 'refunded']);
|
||||||
|
break;
|
||||||
|
case 'payment_failed':
|
||||||
|
actions = actions.concat(['canceled']);
|
||||||
|
break;
|
||||||
|
case 'in_progress':
|
||||||
|
actions = actions.concat(['ready', 'delivered', 'canceled', 'refunded']);
|
||||||
|
break;
|
||||||
|
case 'ready':
|
||||||
|
actions = actions.concat(['delivered', 'canceled', 'refunded']);
|
||||||
|
break;
|
||||||
|
case 'canceled':
|
||||||
|
actions = actions.concat(['refunded']);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
actions = [];
|
||||||
|
}
|
||||||
|
return actions.map(action => {
|
||||||
|
return { value: action, label: t(`app.shared.store.order_actions.state.${action}`) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback after selecting an action
|
||||||
|
*/
|
||||||
|
const handleAction = (action: SelectOption<string>) => {
|
||||||
|
setCurrentAction(action);
|
||||||
|
setModalIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback after confirm an action
|
||||||
|
*/
|
||||||
|
const handleActionConfirmation = () => {
|
||||||
|
OrderAPI.updateState(order, currentAction.value, readyNote).then(data => {
|
||||||
|
onSuccess(data, t(`app.shared.store.order_actions.order_${currentAction.value}_success`));
|
||||||
|
setCurrentAction(null);
|
||||||
|
setModalIsOpen(false);
|
||||||
|
}).catch((e) => {
|
||||||
|
onError(e);
|
||||||
|
setCurrentAction(null);
|
||||||
|
setModalIsOpen(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{buildOptions().length > 0 &&
|
||||||
|
<Select
|
||||||
|
options={buildOptions()}
|
||||||
|
onChange={option => handleAction(option)}
|
||||||
|
value={currentAction}
|
||||||
|
styles={customStyles}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<FabModal title={t('app.shared.store.order_actions.confirmation_required')}
|
||||||
|
isOpen={modalIsOpen}
|
||||||
|
toggleModal={closeModal}
|
||||||
|
closeButton={true}
|
||||||
|
confirmButton={t('app.shared.store.order_actions.confirm')}
|
||||||
|
onConfirm={handleActionConfirmation}
|
||||||
|
className="order-actions-confirmation-modal">
|
||||||
|
<HtmlTranslate trKey={`app.shared.store.order_actions.confirm_order_${currentAction?.value}_html`} />
|
||||||
|
{currentAction?.value === 'ready' &&
|
||||||
|
<FabTextEditor
|
||||||
|
content={readyNote}
|
||||||
|
placeholder={t('app.shared.store.order_actions.order_ready_note')}
|
||||||
|
onChange={setReadyNote} />
|
||||||
|
}
|
||||||
|
</FabModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
70
app/frontend/src/javascript/components/store/order-item.tsx
Normal file
70
app/frontend/src/javascript/components/store/order-item.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
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';
|
||||||
|
import { User } from '../../models/user';
|
||||||
|
import { FabStateLabel } from '../base/fab-state-label';
|
||||||
|
import OrderLib from '../../lib/order';
|
||||||
|
import { PlusCircle } from 'phosphor-react';
|
||||||
|
|
||||||
|
interface OrderItemProps {
|
||||||
|
order?: Order,
|
||||||
|
currentUser?: User
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List item for an order
|
||||||
|
*/
|
||||||
|
export const OrderItem: React.FC<OrderItemProps> = ({ order, currentUser }) => {
|
||||||
|
const { t } = useTranslation('shared');
|
||||||
|
/**
|
||||||
|
* Go to order page
|
||||||
|
*/
|
||||||
|
const showOrder = (order: Order) => {
|
||||||
|
isPrivileged()
|
||||||
|
? window.location.href = `/#!/admin/store/orders/${order.id}`
|
||||||
|
: window.location.href = `/#!/dashboard/orders/${order.id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current operator has administrative rights or is a normal member
|
||||||
|
*/
|
||||||
|
const isPrivileged = (): boolean => {
|
||||||
|
return (currentUser?.role === 'admin' || currentUser?.role === 'manager');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='order-item'>
|
||||||
|
<div className="ref">{order.reference}</div>
|
||||||
|
<FabStateLabel status={OrderLib.statusColor(order)} background>
|
||||||
|
{t(`app.shared.store.order_item.state.${OrderLib.statusText(order)}`)}
|
||||||
|
</FabStateLabel>
|
||||||
|
{isPrivileged() &&
|
||||||
|
<div className='client'>
|
||||||
|
<span>{t('app.shared.store.order_item.client')}</span>
|
||||||
|
<p>{order?.user?.name || ''}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div className="date">
|
||||||
|
<span>{t('app.shared.store.order_item.created_at')}</span>
|
||||||
|
<div>
|
||||||
|
<p>{FormatLib.date(order.created_at)}
|
||||||
|
<span className="fab-tooltip">
|
||||||
|
<span className="trigger"><PlusCircle size={16} weight="light" /></span>
|
||||||
|
<span className="content">
|
||||||
|
{t('app.shared.store.order_item.last_update')}<br />
|
||||||
|
{FormatLib.date(order.updated_at)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='price'>
|
||||||
|
<span>{t('app.shared.store.order_item.total')}</span>
|
||||||
|
<p>{FormatLib.price(order.state === 'cart' ? order.total : order.paid_total)}</p>
|
||||||
|
</div>
|
||||||
|
<FabButton onClick={() => showOrder(order)} icon={<i className="fas fa-eye" />} className="is-black" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
366
app/frontend/src/javascript/components/store/orders.tsx
Normal file
366
app/frontend/src/javascript/components/store/orders.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 { useForm } from 'react-hook-form';
|
||||||
|
import { FabButton } from '../base/fab-button';
|
||||||
|
import { StoreListHeader } from './store-list-header';
|
||||||
|
import { AccordionItem } from '../base/accordion-item';
|
||||||
|
import { OrderItem } from './order-item';
|
||||||
|
import { MemberSelect } from '../user/member-select';
|
||||||
|
import { User } from '../../models/user';
|
||||||
|
import { FormInput } from '../form/form-input';
|
||||||
|
import OrderAPI from '../../api/order';
|
||||||
|
import { Order, OrderIndexFilter, OrderSortOption } from '../../models/order';
|
||||||
|
import { FabPagination } from '../base/fab-pagination';
|
||||||
|
import { CaretDoubleUp, X } from 'phosphor-react';
|
||||||
|
import { ChecklistOption, SelectOption } from '../../models/select';
|
||||||
|
|
||||||
|
declare const Application: IApplication;
|
||||||
|
|
||||||
|
interface OrdersProps {
|
||||||
|
currentUser?: User,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const initFilters: OrderIndexFilter = {
|
||||||
|
reference: '',
|
||||||
|
states: [],
|
||||||
|
page: 1,
|
||||||
|
sort: 'created_at-desc'
|
||||||
|
};
|
||||||
|
|
||||||
|
const FablabOrdersFilters = 'FablabOrdersFilters';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin list of orders
|
||||||
|
*/
|
||||||
|
const Orders: React.FC<OrdersProps> = ({ currentUser, onError }) => {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
|
const { register, setValue } = useForm();
|
||||||
|
|
||||||
|
const [orders, setOrders] = useState<Array<Order>>([]);
|
||||||
|
const [filters, setFilters] = useImmer<OrderIndexFilter>(window[FablabOrdersFilters] || initFilters);
|
||||||
|
const [accordion, setAccordion] = useState({});
|
||||||
|
const [filtersPanel, setFiltersPanel] = useState<boolean>(true);
|
||||||
|
const [pageCount, setPageCount] = useState<number>(0);
|
||||||
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
|
const [reference, setReference] = useState<string>(filters.reference);
|
||||||
|
const [states, setStates] = useState<Array<string>>(filters.states);
|
||||||
|
const [user, setUser] = useState<{ id: number, name?: string }>(filters.user);
|
||||||
|
const [periodFrom, setPeriodFrom] = useState<string>(filters.period_from);
|
||||||
|
const [periodTo, setPeriodTo] = useState<string>(filters.period_to);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window[FablabOrdersFilters] = filters;
|
||||||
|
OrderAPI.index(filters).then(res => {
|
||||||
|
setPageCount(res.total_pages);
|
||||||
|
setTotalCount(res.total_count);
|
||||||
|
setOrders(res.data);
|
||||||
|
}).catch(onError);
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const statusOptions: ChecklistOption<string>[] = [
|
||||||
|
{ value: 'cart', label: t('app.admin.store.orders.state.cart') },
|
||||||
|
{ value: 'paid', label: t('app.admin.store.orders.state.paid') },
|
||||||
|
{ value: 'payment_failed', label: t('app.admin.store.orders.state.payment_failed') },
|
||||||
|
{ value: 'in_progress', label: t('app.admin.store.orders.state.in_progress') },
|
||||||
|
{ value: 'ready', label: t('app.admin.store.orders.state.ready') },
|
||||||
|
{ value: 'canceled', label: t('app.admin.store.orders.state.canceled') }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply filters
|
||||||
|
*/
|
||||||
|
const applyFilters = (filterType: string) => {
|
||||||
|
return () => {
|
||||||
|
setFilters(draft => {
|
||||||
|
switch (filterType) {
|
||||||
|
case 'reference':
|
||||||
|
draft.reference = reference;
|
||||||
|
break;
|
||||||
|
case 'states':
|
||||||
|
draft.states = states;
|
||||||
|
break;
|
||||||
|
case 'user':
|
||||||
|
draft.user_id = user.id;
|
||||||
|
draft.user = user;
|
||||||
|
break;
|
||||||
|
case 'period':
|
||||||
|
if (periodFrom && periodTo) {
|
||||||
|
draft.period_from = periodFrom;
|
||||||
|
draft.period_to = periodTo;
|
||||||
|
} else {
|
||||||
|
draft.period_from = '';
|
||||||
|
draft.period_to = '';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear filter by type
|
||||||
|
*/
|
||||||
|
const removeFilter = (filterType: string, state?: string) => {
|
||||||
|
return () => {
|
||||||
|
setFilters(draft => {
|
||||||
|
draft.page = 1;
|
||||||
|
draft.sort = 'created_at-desc';
|
||||||
|
switch (filterType) {
|
||||||
|
case 'reference':
|
||||||
|
draft.reference = '';
|
||||||
|
setReference('');
|
||||||
|
break;
|
||||||
|
case 'states': {
|
||||||
|
const s = [...draft.states];
|
||||||
|
s.splice(states.indexOf(state), 1);
|
||||||
|
setStates(s);
|
||||||
|
draft.states = s;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'user':
|
||||||
|
delete draft.user_id;
|
||||||
|
delete draft.user;
|
||||||
|
setUser(null);
|
||||||
|
break;
|
||||||
|
case 'period':
|
||||||
|
draft.period_from = '';
|
||||||
|
draft.period_to = '';
|
||||||
|
setPeriodFrom(null);
|
||||||
|
setPeriodTo(null);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear filters
|
||||||
|
*/
|
||||||
|
const clearAllFilters = () => {
|
||||||
|
setFilters(initFilters);
|
||||||
|
setReference('');
|
||||||
|
setStates([]);
|
||||||
|
setUser(null);
|
||||||
|
setPeriodFrom(null);
|
||||||
|
setPeriodTo(null);
|
||||||
|
setValue('period_from', '');
|
||||||
|
setValue('period_to', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates sorting options to the react-select format
|
||||||
|
*/
|
||||||
|
const buildOptions = (): Array<SelectOption<OrderSortOption>> => {
|
||||||
|
return [
|
||||||
|
{ value: 'created_at-desc', label: t('app.admin.store.orders.sort.newest') },
|
||||||
|
{ value: 'created_at-asc', label: t('app.admin.store.orders.sort.oldest') }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display option: sorting
|
||||||
|
*/
|
||||||
|
const handleSorting = (option: SelectOption<OrderSortOption>) => {
|
||||||
|
setFilters(draft => {
|
||||||
|
draft.sort = option.value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter: by reference
|
||||||
|
*/
|
||||||
|
const handleReferenceChanged = (value: string) => {
|
||||||
|
setReference(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter: by status
|
||||||
|
*/
|
||||||
|
const handleSelectStatus = (s: ChecklistOption<string>, checked: boolean) => {
|
||||||
|
const list = [...states];
|
||||||
|
checked
|
||||||
|
? list.push(s.value)
|
||||||
|
: list.splice(list.indexOf(s.value), 1);
|
||||||
|
setStates(list);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter: by member
|
||||||
|
*/
|
||||||
|
const handleSelectMember = (user: User) => {
|
||||||
|
setUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter: by period
|
||||||
|
*/
|
||||||
|
const handlePeriodChanged = (period: string) => {
|
||||||
|
return (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (period === 'period_from') {
|
||||||
|
setPeriodFrom(value);
|
||||||
|
}
|
||||||
|
if (period === 'period_to') {
|
||||||
|
setPeriodTo(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/close accordion items
|
||||||
|
*/
|
||||||
|
const handleAccordion = (id, state) => {
|
||||||
|
setAccordion({ ...accordion, [id]: state });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle orders pagination
|
||||||
|
*/
|
||||||
|
const handlePagination = (page: number) => {
|
||||||
|
setFilters(draft => {
|
||||||
|
draft.page = page;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='orders'>
|
||||||
|
<header>
|
||||||
|
<h2>{t('app.admin.store.orders.heading')}</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<aside className={`store-filters ${filtersPanel ? '' : 'collapsed'}`}>
|
||||||
|
<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>
|
||||||
|
<CaretDoubleUp className='filters-toggle' size={16} weight="bold" onClick={() => setFiltersPanel(!filtersPanel)} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="grp 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" value={reference} onChange={(event) => handleReferenceChanged(event.target.value)}/>
|
||||||
|
<FabButton onClick={applyFilters('reference')} className="is-secondary">{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={states.some(o => o === s.value)} onChange={(event) => handleSelectStatus(s, event.target.checked)} />
|
||||||
|
<p>{s.label}</p>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FabButton onClick={applyFilters('states')} className="is-secondary">{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 value={user as User} onSelected={handleSelectMember} />
|
||||||
|
<FabButton onClick={applyFilters('user')} className="is-secondary">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem id={3}
|
||||||
|
isOpen={accordion[3]}
|
||||||
|
onChange={handleAccordion}
|
||||||
|
label={t('app.admin.store.orders.filter_period')}
|
||||||
|
>
|
||||||
|
<div className='content'>
|
||||||
|
<div className="group">
|
||||||
|
<div className="range">
|
||||||
|
<FormInput id="period_from"
|
||||||
|
label={t('app.admin.store.orders.filter_period_from')}
|
||||||
|
register={register}
|
||||||
|
onChange={handlePeriodChanged('period_from')}
|
||||||
|
defaultValue={periodFrom}
|
||||||
|
type="date" />
|
||||||
|
<FormInput id="period_to"
|
||||||
|
label={t('app.admin.store.orders.filter_period_to')}
|
||||||
|
register={register}
|
||||||
|
onChange={handlePeriodChanged('period_to')}
|
||||||
|
defaultValue={periodTo}
|
||||||
|
type="date" />
|
||||||
|
</div>
|
||||||
|
<FabButton onClick={applyFilters('period')} className="is-secondary">{t('app.admin.store.orders.filter_apply')}</FabButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionItem>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="store-list">
|
||||||
|
<StoreListHeader
|
||||||
|
productsCount={totalCount}
|
||||||
|
selectOptions={buildOptions()}
|
||||||
|
selectValue={filters.sort}
|
||||||
|
onSelectOptionsChange={handleSorting}
|
||||||
|
/>
|
||||||
|
<div className='features'>
|
||||||
|
{filters.reference && <div className='features-item'>
|
||||||
|
<p>{filters.reference}</p>
|
||||||
|
<button onClick={removeFilter('reference')}><X size={16} weight="light" /></button>
|
||||||
|
</div>}
|
||||||
|
{filters.states?.map((status, index) => (
|
||||||
|
<div key={index} className='features-item'>
|
||||||
|
<p>{t(`app.admin.store.orders.state.${status}`)}</p>
|
||||||
|
<button onClick={removeFilter('states', status)}><X size={16} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filters.user_id > 0 && <div className='features-item'>
|
||||||
|
<p>{user?.name}</p>
|
||||||
|
<button onClick={removeFilter('user')}><X size={16} weight="light" /></button>
|
||||||
|
</div>}
|
||||||
|
{filters.period_from && <div className='features-item'>
|
||||||
|
<p>{filters.period_from} {'>'} {filters.period_to}</p>
|
||||||
|
<button onClick={removeFilter('period')}><X size={16} weight="light" /></button>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="orders-list">
|
||||||
|
{orders.map(order => (
|
||||||
|
<OrderItem key={order.id} order={order} currentUser={currentUser} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{pageCount > 1 &&
|
||||||
|
<FabPagination pageCount={pageCount} currentPage={filters.page} selectPage={handlePagination} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrdersWrapper: React.FC<OrdersProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Loader>
|
||||||
|
<Orders {...props} />
|
||||||
|
</Loader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Application.Components.component('orders', react2angular(OrdersWrapper, ['currentUser', 'onError']));
|
321
app/frontend/src/javascript/components/store/product-form.tsx
Normal file
321
app/frontend/src/javascript/components/store/product-form.tsx
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { SubmitHandler, 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 { 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 { ProductStockForm } from './product-stock-form';
|
||||||
|
import { CloneProductModal } from './clone-product-modal';
|
||||||
|
import ProductLib from '../../lib/product';
|
||||||
|
import { UnsavedFormAlert } from '../form/unsaved-form-alert';
|
||||||
|
import { UIRouter } from '@uirouter/angularjs';
|
||||||
|
import { SelectOption, ChecklistOption } from '../../models/select';
|
||||||
|
import { FormMultiFileUpload } from '../form/form-multi-file-upload';
|
||||||
|
import { FormMultiImageUpload } from '../form/form-multi-image-upload';
|
||||||
|
|
||||||
|
interface ProductFormProps {
|
||||||
|
product: Product,
|
||||||
|
title: string,
|
||||||
|
onSuccess: (product: Product) => void,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
uiRouter: UIRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form component to create or update a product
|
||||||
|
*/
|
||||||
|
export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSuccess, onError, uiRouter }) => {
|
||||||
|
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));
|
||||||
|
const [productCategories, setProductCategories] = useState<SelectOption<number, string | JSX.Element>[]>([]);
|
||||||
|
const [machines, setMachines] = useState<ChecklistOption<number>[]>([]);
|
||||||
|
const [stockTab, setStockTab] = useState<boolean>(false);
|
||||||
|
const [openCloneModal, setOpenCloneModal] = useState<boolean>(false);
|
||||||
|
const [saving, setSaving] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ProductCategoryAPI.index().then(data => {
|
||||||
|
setProductCategories(buildSelectOptions(ProductLib.sortCategories(data)));
|
||||||
|
}).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, parent_id?: number }>): Array<SelectOption<number, string | JSX.Element>> => {
|
||||||
|
return items.map(t => {
|
||||||
|
return {
|
||||||
|
value: t.id,
|
||||||
|
label: t.parent_id
|
||||||
|
? <span className='u-leading-space'>{t.name}</span>
|
||||||
|
: t.name
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the provided array of items to the checklist format
|
||||||
|
*/
|
||||||
|
const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array<ChecklistOption<number>> => {
|
||||||
|
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 the user toggles the visibility of the product in the store.
|
||||||
|
*/
|
||||||
|
const handleIsActiveChanged = (value: boolean): void => {
|
||||||
|
if (value) {
|
||||||
|
setValue('is_active_price', true);
|
||||||
|
setIsActivePrice(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when is active price has changed.
|
||||||
|
*/
|
||||||
|
const toggleIsActivePrice = (value: boolean) => {
|
||||||
|
if (!value) {
|
||||||
|
setValue('amount', null);
|
||||||
|
setValue('is_active', false);
|
||||||
|
}
|
||||||
|
setIsActivePrice(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback triggered when the form is submitted: process with the product creation or update.
|
||||||
|
*/
|
||||||
|
const onSubmit: SubmitHandler<Product> = (data: Product) => {
|
||||||
|
saveProduct(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call product creation or update api
|
||||||
|
*/
|
||||||
|
const saveProduct = (data: Product) => {
|
||||||
|
setSaving(true);
|
||||||
|
if (product.id) {
|
||||||
|
ProductAPI.update(data).then((res) => {
|
||||||
|
reset(res);
|
||||||
|
setSaving(false);
|
||||||
|
onSuccess(res);
|
||||||
|
}).catch(e => {
|
||||||
|
setSaving(false);
|
||||||
|
onError(e);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ProductAPI.create(data).then((res) => {
|
||||||
|
reset(res);
|
||||||
|
onSuccess(res);
|
||||||
|
}).catch(e => {
|
||||||
|
setSaving(false);
|
||||||
|
onError(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle clone product modal
|
||||||
|
*/
|
||||||
|
const toggleCloneModal = () => {
|
||||||
|
setOpenCloneModal(!openCloneModal);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<div className="grpBtn">
|
||||||
|
{product.id &&
|
||||||
|
<>
|
||||||
|
<FabButton onClick={toggleCloneModal}>{t('app.admin.store.product_form.clone')}</FabButton>
|
||||||
|
<CloneProductModal isOpen={openCloneModal} toggleModal={toggleCloneModal} product={product} onSuccess={onSuccess} onError={onError} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
<FabButton className="main-action-btn" onClick={handleSubmit(saveProduct)} disabled={saving}>
|
||||||
|
{!saving && t('app.admin.store.product_form.save')}
|
||||||
|
{saving && <i className="fa fa-spinner fa-pulse fa-fw text-white" />}
|
||||||
|
</FabButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<form className="product-form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<UnsavedFormAlert uiRouter={uiRouter} formState={formState} />
|
||||||
|
<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 currentFormValues={output as Product} register={register} control={control} formState={formState} setValue={setValue} 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')}
|
||||||
|
onChange={handleIsActiveChanged}
|
||||||
|
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: isActivePrice, min: 0 }}
|
||||||
|
step={0.01}
|
||||||
|
formState={formState}
|
||||||
|
label={t('app.admin.store.product_form.price')}
|
||||||
|
nullable />
|
||||||
|
<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>
|
||||||
|
<FormMultiImageUpload setValue={setValue}
|
||||||
|
addButtonLabel={t('app.admin.store.product_form.add_product_image')}
|
||||||
|
register={register}
|
||||||
|
control={control}
|
||||||
|
id="product_images_attributes"
|
||||||
|
className="product-images" />
|
||||||
|
</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>
|
||||||
|
<FormMultiFileUpload setValue={setValue}
|
||||||
|
addButtonLabel={t('app.admin.store.product_form.add_product_file')}
|
||||||
|
control={control}
|
||||||
|
accept="application/pdf"
|
||||||
|
register={register}
|
||||||
|
id="product_files_attributes"
|
||||||
|
className="product-documents" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="main-actions">
|
||||||
|
<FabButton type="submit" className="main-action-btn" disabled={saving}>
|
||||||
|
{!saving && t('app.admin.store.product_form.save')}
|
||||||
|
{saving && <i className="fa fa-spinner fa-pulse fa-fw" />}
|
||||||
|
</FabButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
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