mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-01-18 07:52:23 +01:00
Merge branch 'dev' for release X.Y.Z
This commit is contained in:
commit
87a57f27f4
@ -6,7 +6,8 @@
|
||||
],
|
||||
"rules": {
|
||||
"semi": ["error", "always"],
|
||||
"no-use-before-define": "off"
|
||||
"no-use-before-define": "off",
|
||||
"no-case-declarations": "off"
|
||||
},
|
||||
"globals": {
|
||||
"Application": true,
|
||||
|
@ -7,9 +7,9 @@ Layout/LineLength:
|
||||
Metrics/MethodLength:
|
||||
Max: 35
|
||||
Metrics/CyclomaticComplexity:
|
||||
Max: 13
|
||||
Max: 14
|
||||
Metrics/PerceivedComplexity:
|
||||
Max: 11
|
||||
Max: 14
|
||||
Metrics/AbcSize:
|
||||
Max: 45
|
||||
Metrics/ClassLength:
|
||||
@ -34,3 +34,7 @@ Style/AndOr:
|
||||
EnforcedStyle: conditionals
|
||||
Style/FormatString:
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
|
2
Gemfile
2
Gemfile
@ -145,3 +145,5 @@ gem 'tzinfo-data'
|
||||
gem 'sassc', '= 2.1.0'
|
||||
|
||||
gem 'redis-session-store'
|
||||
|
||||
gem 'acts_as_list'
|
||||
|
@ -48,6 +48,8 @@ GEM
|
||||
i18n (>= 0.7, < 2)
|
||||
minitest (~> 5.1)
|
||||
tzinfo (~> 1.1)
|
||||
acts_as_list (1.0.4)
|
||||
activerecord (>= 4.2)
|
||||
addressable (2.8.0)
|
||||
public_suffix (>= 2.0.2, < 5.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
@ -234,7 +236,7 @@ GEM
|
||||
multi_xml (0.6.0)
|
||||
multipart-post (2.1.1)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.13.8)
|
||||
nokogiri (1.13.9)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
racc (~> 1.4)
|
||||
notify_with (0.0.2)
|
||||
@ -500,6 +502,7 @@ DEPENDENCIES
|
||||
aasm
|
||||
actionpack-page_caching (= 1.2.2)
|
||||
active_record_query_trace
|
||||
acts_as_list
|
||||
api-pagination
|
||||
apipie-rails
|
||||
awesome_print
|
||||
|
@ -35,7 +35,7 @@ class API::AdminsController < API::ApiController
|
||||
|
||||
def admin_params
|
||||
params.require(:admin).permit(
|
||||
:username, :email,
|
||||
:username, :email, :group_id,
|
||||
profile_attributes: %i[first_name last_name phone],
|
||||
invoicing_profile_attributes: [address_attributes: [:address]],
|
||||
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
|
||||
# Coupons are used in payments
|
||||
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]
|
||||
|
||||
# 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?
|
||||
render json: { status: 'rejected' }, status: :not_found
|
||||
else
|
||||
_user_id = if !current_user.admin?
|
||||
current_user.id
|
||||
else
|
||||
_user_id = if current_user&.admin?
|
||||
params[:user_id]
|
||||
else
|
||||
current_user&.id
|
||||
end
|
||||
|
||||
amount = params[:amount].to_f * 100.0
|
||||
status = @coupon.status(_user_id, amount)
|
||||
if status != 'active'
|
||||
render json: { status: status }, status: :unprocessable_entity
|
||||
else
|
||||
if status == 'active'
|
||||
render :validate, status: :ok, location: @coupon
|
||||
else
|
||||
render json: { status: status }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -6,7 +6,7 @@ class API::GroupsController < API::ApiController
|
||||
before_action :authenticate_user!, except: :index
|
||||
|
||||
def index
|
||||
@groups = GroupService.list(current_user, params)
|
||||
@groups = GroupService.list(params)
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -157,7 +157,7 @@ class API::MembersController < API::ApiController
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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)
|
||||
.joins(:invoicing_profile)
|
||||
.order('payment_schedules.created_at DESC')
|
||||
@ -34,14 +34,14 @@ class API::PaymentSchedulesController < API::ApiController
|
||||
|
||||
def download
|
||||
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
|
||||
|
||||
def cash_check
|
||||
authorize @payment_schedule_item.payment_schedule
|
||||
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, 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
|
||||
end
|
||||
@ -50,7 +50,7 @@ class API::PaymentSchedulesController < API::ApiController
|
||||
authorize @payment_schedule_item.payment_schedule
|
||||
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, 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
|
||||
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
|
||||
|
||||
@settings = []
|
||||
may_transaction(params[:transactional]) do
|
||||
may_transaction params[:transactional] do
|
||||
params[:settings].each do |setting|
|
||||
next if !setting[:name] || !setting[:value]
|
||||
|
||||
db_setting = Setting.find_or_initialize_by(name: setting[:name])
|
||||
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
|
||||
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
|
||||
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
|
||||
new_val = HistoryValue.create!(
|
||||
setting_id: setting.id,
|
||||
value: first_val.value,
|
||||
value: first_val&.value,
|
||||
invoicing_profile_id: current_user.invoicing_profile.id
|
||||
)
|
||||
SettingService.after_update(setting)
|
||||
@ -84,11 +84,9 @@ class API::SettingsController < API::ApiController
|
||||
end
|
||||
|
||||
# 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'
|
||||
ActiveRecord::Base.transaction do
|
||||
yield
|
||||
end
|
||||
ActiveRecord::Base.transaction(&block)
|
||||
else
|
||||
yield
|
||||
end
|
||||
|
@ -1,6 +1,6 @@
|
||||
# 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
|
||||
before_action :authenticate_user!
|
||||
|
||||
@ -9,49 +9,25 @@ class API::StatisticsController < API::ApiController
|
||||
@statistics = StatisticIndex.all
|
||||
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 %{
|
||||
def #{path}
|
||||
authorize :statistic, :#{path}?
|
||||
def #{path} # def account
|
||||
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
|
||||
statistic_type = request.query_parameters.delete('stat-type')
|
||||
custom_query = request.query_parameters.delete('custom-query')
|
||||
start_date = request.query_parameters.delete('start-date')
|
||||
end_date = request.query_parameters.delete('end-date')
|
||||
def export_#{path} # def export_account
|
||||
authorize :statistic, :export_#{path}? # authorize :statistic, :export_account?
|
||||
|
||||
# run main query in elasticSearch
|
||||
query = MultiJson.load(request.body.read)
|
||||
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])
|
||||
@export = Statistics::QueryService.export('#{path}', params) # @export = Statistics::QueryService.export('account', params)
|
||||
if @export.is_a?(Export)
|
||||
if @export.save
|
||||
render json: {export_id: @export.id}, status: :ok
|
||||
render json: { export_id: @export.id }, status: :ok
|
||||
else
|
||||
render json: @export.errors, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
send_file File.join(Rails.root, export.file),
|
||||
send_file @export,
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment'
|
||||
end
|
||||
@ -62,16 +38,15 @@ class API::StatisticsController < API::ApiController
|
||||
def export_global
|
||||
authorize :statistic, :export_global?
|
||||
|
||||
export = Export.where(category: 'statistics', export_type: 'global', query: params[:body]).last
|
||||
if export.nil? || !FileTest.exist?(export.file)
|
||||
@export = Export.new(category: 'statistics', export_type: 'global', user: current_user, query: params[:body])
|
||||
@export = Statistics::QueryService.export(global, params)
|
||||
if @export.is_a?(Export)
|
||||
if @export.save
|
||||
render json: { export_id: @export.id }, status: :ok
|
||||
else
|
||||
render json: @export.errors, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
send_file File.join(Rails.root, export.file),
|
||||
send_file @export,
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
@ -18,7 +18,7 @@ class API::WalletController < API::ApiController
|
||||
end
|
||||
|
||||
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])
|
||||
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]
|
||||
render :show
|
||||
else
|
||||
head 422
|
||||
head :unprocessable_entity
|
||||
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 ParsingLib from '../../lib/parsing';
|
||||
|
||||
type Error = { error: string };
|
||||
|
||||
@ -48,7 +49,9 @@ function extractHumanReadableMessage (error: string|Error): string {
|
||||
// iterate through all the keys to build the message
|
||||
for (const key in error) {
|
||||
if (Object.prototype.hasOwnProperty.call(error, key)) {
|
||||
message += `${key} : `;
|
||||
if (!ParsingLib.isInteger(key)) {
|
||||
message += `${key} : `;
|
||||
}
|
||||
if (error[key] instanceof Array) {
|
||||
// standard rails messages are stored 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;
|
||||
}
|
||||
|
||||
static async search (name: string): Promise<Array<User>> {
|
||||
const res: AxiosResponse<Array<User>> = await apiClient.get(`/api/members/search/${name}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async get (id: number): Promise<User> {
|
||||
const res: AxiosResponse<User> = await apiClient.get(`/api/members/${id}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async create (user: User): Promise<User> {
|
||||
const data = serialize({ user });
|
||||
if (user.profile_attributes?.user_avatar_attributes?.attachment_files[0]) {
|
||||
|
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 { 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 {
|
||||
static async get (name: SettingName): Promise<Setting> {
|
||||
@ -8,7 +15,7 @@ export default class SettingAPI {
|
||||
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();
|
||||
params.append('names', `['${names.join("','")}']`);
|
||||
|
||||
@ -32,7 +39,7 @@ export default class SettingAPI {
|
||||
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();
|
||||
names.forEach(name => {
|
||||
map.set(name, data[name] || '');
|
||||
@ -60,7 +67,7 @@ export default class SettingAPI {
|
||||
return map;
|
||||
}
|
||||
|
||||
private static toObjectArray (data: Map<SettingName, SettingValue>): Array<Record<string, SettingValue>> {
|
||||
private static toObjectArray (data: Map<SettingName, SettingValue>): SettingBulkArray {
|
||||
const array = [];
|
||||
data.forEach((value, key) => {
|
||||
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.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!inputValue) {
|
||||
setInputValue(defaultValue);
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(defaultValue);
|
||||
}
|
||||
setInputValue(defaultValue);
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(defaultValue);
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
|
@ -23,6 +23,7 @@ interface FabModalProps {
|
||||
customHeader?: ReactNode,
|
||||
customFooter?: ReactNode,
|
||||
onConfirm?: (event: BaseSyntheticEvent) => void,
|
||||
onClose?: (event: BaseSyntheticEvent) => void,
|
||||
preventConfirm?: boolean,
|
||||
onCreation?: () => void,
|
||||
onConfirmSendFormId?: string,
|
||||
@ -31,7 +32,7 @@ interface FabModalProps {
|
||||
/**
|
||||
* 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');
|
||||
|
||||
useEffect(() => {
|
||||
@ -40,12 +41,20 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
/**
|
||||
* Callback triggered when the user request to close the modal without confirming.
|
||||
*/
|
||||
const handleClose = (event) => {
|
||||
if (typeof onClose === 'function') onClose(event);
|
||||
toggleModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen}
|
||||
className={`fab-modal fab-modal-${width} ${className}`}
|
||||
className={`fab-modal fab-modal-${width} ${className || ''}`}
|
||||
overlayClassName="fab-modal-overlay"
|
||||
onRequestClose={toggleModal}>
|
||||
{closeButton && <FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.fab_modal.close')}</FabButton>}
|
||||
onRequestClose={handleClose}>
|
||||
{closeButton && <FabButton className="modal-btn--close" onClick={handleClose}>{t('app.shared.fab_modal.close')}</FabButton>}
|
||||
<div className="fab-modal-header">
|
||||
{!customHeader && <h1>{ title }</h1>}
|
||||
{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';
|
||||
|
||||
interface FabTextEditorProps {
|
||||
paragraphTools?: boolean,
|
||||
content?: string,
|
||||
limit?: number,
|
||||
heading?: boolean,
|
||||
bulletList?: boolean,
|
||||
blockquote?: boolean,
|
||||
link?: boolean,
|
||||
video?: boolean,
|
||||
image?: boolean,
|
||||
content?: string,
|
||||
limit?: number,
|
||||
onChange?: (content: string) => void,
|
||||
placeholder?: string,
|
||||
error?: string,
|
||||
@ -30,7 +33,7 @@ export interface FabTextEditorRef {
|
||||
/**
|
||||
* This component is a WYSIWYG text editor
|
||||
*/
|
||||
export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ paragraphTools, content, limit = 400, video, image, onChange, placeholder, error, disabled = false }, ref: RefObject<FabTextEditorRef>) => {
|
||||
const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ heading, bulletList, blockquote, content, limit = 400, video, image, link, onChange, placeholder, error, disabled = false }, ref: RefObject<FabTextEditorRef>) => {
|
||||
const { t } = useTranslation('shared');
|
||||
const placeholderText = placeholder || t('app.shared.text_editor.fab_text_editor.text_placeholder');
|
||||
// TODO: Add ctrl+click on link to visit
|
||||
@ -72,7 +75,11 @@ export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, Fab
|
||||
],
|
||||
content,
|
||||
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);
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor?.getHTML() !== content) {
|
||||
editor?.commands.setContent(content);
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
// bind the editor to the ref, once it is ready
|
||||
if (!editor) return null;
|
||||
editorRef.current = editor;
|
||||
|
||||
return (
|
||||
<div className={`fab-text-editor ${disabled && 'is-disabled'}`}>
|
||||
<MenuBar editor={editor} paragraphTools={paragraphTools} video={video} image={image} disabled={disabled} />
|
||||
<div className={`fab-text-editor ${disabled ? 'is-disabled' : ''}`}>
|
||||
<MenuBar editor={editor} heading={heading} bulletList={bulletList} blockquote={blockquote} video={video} image={image} link={link} disabled={disabled} />
|
||||
<EditorContent editor={editor} />
|
||||
<div className="fab-text-editor-character-count">
|
||||
{limit && <div className="fab-text-editor-character-count">
|
||||
{editor?.storage.characterCount.characters()} / {limit}
|
||||
</div>
|
||||
</div>}
|
||||
{error &&
|
||||
<div className="fab-text-editor-error">
|
||||
<WarningOctagon size={24} />
|
||||
|
@ -6,7 +6,10 @@ import { TextAa, TextBolder, TextItalic, TextUnderline, LinkSimpleHorizontal, Li
|
||||
|
||||
interface MenuBarProps {
|
||||
editor?: Editor,
|
||||
paragraphTools?: boolean,
|
||||
heading?: boolean,
|
||||
bulletList?: boolean,
|
||||
blockquote?: boolean,
|
||||
link?: boolean,
|
||||
video?: boolean,
|
||||
image?: boolean,
|
||||
disabled?: boolean,
|
||||
@ -15,7 +18,7 @@ interface MenuBarProps {
|
||||
/**
|
||||
* This component is the menu bar for the WYSIWYG text editor
|
||||
*/
|
||||
export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video, image, disabled = false }) => {
|
||||
export const MenuBar: React.FC<MenuBarProps> = ({ editor, heading, bulletList, blockquote, link, video, image, disabled = false }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [submenu, setSubmenu] = useState('');
|
||||
@ -44,6 +47,10 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
if (submenu !== type) {
|
||||
setSubmenu(type);
|
||||
if (type === 'link') {
|
||||
if (editor.view.state.selection.from === editor.view.state.selection.to) {
|
||||
setSubmenu('');
|
||||
return;
|
||||
}
|
||||
const previousUrl = {
|
||||
href: editor.getAttributes('link').href,
|
||||
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
|
||||
const handleEnter = (evt) => {
|
||||
if (evt.keyCode === 13) {
|
||||
setLink();
|
||||
setLink(true);
|
||||
}
|
||||
};
|
||||
|
||||
@ -142,8 +149,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
return (
|
||||
<>
|
||||
<div className={`fab-text-editor-menu ${disabled ? 'fab-text-editor-menu--disabled' : ''}`}>
|
||||
{ paragraphTools &&
|
||||
(<>
|
||||
{heading &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
@ -152,6 +158,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<TextAa size={24} />
|
||||
</button>
|
||||
}
|
||||
{bulletList &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
@ -160,6 +168,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<ListBullets size={24} />
|
||||
</button>
|
||||
}
|
||||
{blockquote &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
@ -168,9 +178,8 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<Quotes size={24} />
|
||||
</button>
|
||||
<span className='menu-divider'></span>
|
||||
</>)
|
||||
}
|
||||
{ (heading || bulletList || blockquote) && <span className='menu-divider'></span> }
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
@ -195,14 +204,16 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
|
||||
>
|
||||
<TextUnderline size={24} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => toggleSubmenu('link')}
|
||||
disabled={disabled}
|
||||
className={`ignore-onclickoutside ${editor.isActive('link') ? 'is-active' : ''}`}
|
||||
>
|
||||
<LinkSimpleHorizontal size={24} />
|
||||
</button>
|
||||
{link &&
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => toggleSubmenu('link')}
|
||||
disabled={disabled}
|
||||
className={`ignore-onclickoutside ${editor.isActive('link') ? 'is-active' : ''}`}
|
||||
>
|
||||
<LinkSimpleHorizontal size={24} />
|
||||
</button>
|
||||
}
|
||||
{ (video || image) && <span className='menu-divider'></span> }
|
||||
{ video &&
|
||||
(<>
|
||||
|
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 { IApplication } from '../../models/application';
|
||||
import EventThemeAPI from '../../api/event-theme';
|
||||
import { SelectOption } from '../../models/select';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
@ -15,12 +16,6 @@ interface EventThemesProps {
|
||||
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
|
||||
*/
|
||||
@ -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
|
||||
*/
|
||||
const defaultValues = (): Array<selectOption> => {
|
||||
const defaultValues = (): Array<SelectOption<number>> => {
|
||||
const res = [];
|
||||
themes.forEach(t => {
|
||||
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.
|
||||
* 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 = [];
|
||||
selectedOptions.forEach(opt => {
|
||||
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
|
||||
*/
|
||||
const buildOptions = (): Array<selectOption> => {
|
||||
const buildOptions = (): Array<SelectOption<number>> => {
|
||||
return themes.map(t => {
|
||||
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'>
|
||||
<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>
|
||||
<div className="content">{tooltip}</div>
|
||||
</div>}
|
||||
@ -71,7 +71,7 @@ export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label,
|
||||
|
||||
<div className='form-item-field'>
|
||||
{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>
|
||||
<div className="content">{tooltip}</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,
|
||||
step?: number | 'any',
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
||||
nullable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@ -57,8 +58,8 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
||||
<input id={id}
|
||||
{...register(id as FieldPath<TFieldValues>, {
|
||||
...rules,
|
||||
valueAsNumber: type === 'number',
|
||||
valueAsDate: type === 'date',
|
||||
setValueAs: v => ([null, ''].includes(v) && nullable) ? null : (type === 'number' ? parseFloat(v) : v),
|
||||
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
|
||||
onChange: (e) => { handleChange(e); }
|
||||
})}
|
||||
@ -67,6 +68,7 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
||||
disabled={typeof disabled === 'function' ? disabled(id) : disabled}
|
||||
placeholder={placeholder}
|
||||
accept={accept} />
|
||||
{(type === 'file' && placeholder) && <span className='fab-button is-black file-placeholder'>{placeholder}</span>}
|
||||
{addOn && <span onClick={addOnAction} className={`addon ${addOnClassName || ''} ${addOnAction ? 'is-btn' : ''}`}>{addOn}</span>}
|
||||
</AbstractFormItem>
|
||||
);
|
||||
|
@ -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> {
|
||||
valueDefault?: string,
|
||||
limit?: number,
|
||||
paragraphTools?: boolean,
|
||||
heading?: boolean,
|
||||
bulletList?: boolean,
|
||||
blockquote?: boolean,
|
||||
link?: boolean,
|
||||
video?: boolean,
|
||||
image?: boolean,
|
||||
image?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a rich-text editor to use with react-hook-form.
|
||||
*/
|
||||
export const FormRichText = <TFieldValues extends FieldValues, TContext extends object>({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, paragraphTools, video, image }: FormRichTextProps<TFieldValues, TContext>) => {
|
||||
export const FormRichText = <TFieldValues extends FieldValues, TContext extends object>({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, heading, bulletList, blockquote, video, image, link }: FormRichTextProps<TFieldValues, TContext>) => {
|
||||
const textEditorRef = React.useRef<FabTextEditorRef>();
|
||||
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
|
||||
|
||||
@ -54,9 +57,12 @@ export const FormRichText = <TFieldValues extends FieldValues, TContext extends
|
||||
<FabTextEditor onChange={onChange}
|
||||
content={value}
|
||||
limit={limit}
|
||||
paragraphTools={paragraphTools}
|
||||
heading={heading}
|
||||
bulletList={bulletList}
|
||||
blockquote={blockquote}
|
||||
video={video}
|
||||
image={image}
|
||||
link={link}
|
||||
disabled={isDisabled}
|
||||
ref={textEditorRef} />
|
||||
} />
|
||||
|
@ -7,9 +7,10 @@ import { FieldPath } from 'react-hook-form/dist/types/path';
|
||||
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
|
||||
import { FormControlledComponent } from '../../models/form-component';
|
||||
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> {
|
||||
options: Array<selectOption<TOptionValue>>,
|
||||
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue, TOptionLabel> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||
options: Array<SelectOption<TOptionValue, TOptionLabel>>,
|
||||
valueDefault?: TOptionValue,
|
||||
onChange?: (value: TOptionValue) => void,
|
||||
placeholder?: string,
|
||||
@ -17,16 +18,10 @@ interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue> e
|
||||
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
|
||||
*/
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -41,8 +41,11 @@ export const FormSwitch = <TFieldValues, TContext extends object>({ id, label, t
|
||||
onChangeCb(val);
|
||||
}}
|
||||
checked={value as boolean || false}
|
||||
height={19}
|
||||
width={40}
|
||||
height={19}
|
||||
uncheckedIcon={false}
|
||||
checkedIcon={false}
|
||||
handleDiameter={15}
|
||||
ref={ref}
|
||||
disabled={typeof disabled === 'function' ? disabled(id) : disabled} />
|
||||
} />
|
||||
|
@ -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 SettingAPI from '../../api/setting';
|
||||
import UserLib from '../../lib/user';
|
||||
import { SelectOption } from '../../models/select';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
@ -23,12 +24,6 @@ interface ChangeGroupProps {
|
||||
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.
|
||||
*/
|
||||
@ -43,7 +38,7 @@ export const ChangeGroup: React.FC<ChangeGroupProps> = ({ user, onSuccess, onErr
|
||||
const { handleSubmit, control } = useForm();
|
||||
|
||||
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);
|
||||
SettingAPI.get('user_change_group').then((setting) => {
|
||||
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
|
||||
*/
|
||||
const buildGroupsOptions = (): Array<selectOption> => {
|
||||
const buildGroupsOptions = (): Array<SelectOption<number>> => {
|
||||
return groups?.map(t => {
|
||||
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
|
||||
*/
|
||||
const machinePicture = (): ReactNode => {
|
||||
if (!machine.machine_image) {
|
||||
if (!machine.machine_image_attributes) {
|
||||
return <div className="machine-picture no-picture" />;
|
||||
}
|
||||
|
||||
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 Select from 'react-select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SelectOption } from '../../models/select';
|
||||
|
||||
interface MachinesFiltersProps {
|
||||
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
|
||||
*/
|
||||
@ -23,7 +18,7 @@ export const MachinesFilters: React.FC<MachinesFiltersProps> = ({ onStatusSelect
|
||||
/**
|
||||
* Provides boolean options in the react-select format (yes/no/all)
|
||||
*/
|
||||
const buildBooleanOptions = (): Array<selectOption> => {
|
||||
const buildBooleanOptions = (): Array<SelectOption<boolean>> => {
|
||||
return [
|
||||
defaultValue,
|
||||
{ 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
|
||||
*/
|
||||
const handleStatusSelected = (option: selectOption): void => {
|
||||
const handleStatusSelected = (option: SelectOption<boolean>): void => {
|
||||
onStatusSelected(option.value);
|
||||
};
|
||||
|
||||
|
@ -7,6 +7,8 @@ import MachineAPI from '../../api/machine';
|
||||
import { MachineCard } from './machine-card';
|
||||
import { MachinesFilters } from './machines-filters';
|
||||
import { User } from '../../models/user';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
@ -25,6 +27,7 @@ interface MachinesListProps {
|
||||
* This component shows a list of all machines and allows filtering on that list.
|
||||
*/
|
||||
export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user, canProposePacks }) => {
|
||||
const { t } = useTranslation('public');
|
||||
// shown machines
|
||||
const [machines, setMachines] = useState<Array<Machine>>(null);
|
||||
// we keep the full list of machines, for filtering
|
||||
@ -56,10 +59,30 @@ export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess,
|
||||
setMachines(allMachines.filter(m => !!m.disabled === !status));
|
||||
};
|
||||
|
||||
/**
|
||||
* Go to store
|
||||
*/
|
||||
const linkToStore = (): void => {
|
||||
window.location.href = '/#!/store';
|
||||
};
|
||||
|
||||
// TODO: Conditionally display the store ad
|
||||
return (
|
||||
<div className="machines-list">
|
||||
<MachinesFilters onStatusSelected={handleFilterByStatus} />
|
||||
<div className="all-machines">
|
||||
{false &&
|
||||
<div className='store-ad' onClick={() => linkToStore}>
|
||||
<div className='content'>
|
||||
<h3>{t('app.public.machines_list.store_ad.title')}</h3>
|
||||
<p>{t('app.public.machines_list.store_ad.buy')}</p>
|
||||
<p className='sell'>{t('app.public.machines_list.store_ad.sell')}</p>
|
||||
</div>
|
||||
<FabButton icon={<i className="fa fa-cart-plus fa-lg" />} className="cta" onClick={linkToStore}>
|
||||
{t('app.public.machines_list.store_ad.link')}
|
||||
</FabButton>
|
||||
</div>
|
||||
}
|
||||
{machines && machines.map(machine => {
|
||||
return <MachineCard key={machine.id}
|
||||
user={user}
|
||||
@ -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 (
|
||||
<Loader>
|
||||
<MachinesList user={user} onError={onError} onSuccess={onSuccess} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} canProposePacks={canProposePacks}/>
|
||||
<MachinesList {...props} />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
@ -109,7 +109,7 @@ const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSc
|
||||
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 : ''}`);
|
||||
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)})`;
|
||||
}
|
||||
// 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 { PaymentMethod, PaymentSchedule } from '../../models/payment-schedule';
|
||||
import PaymentScheduleAPI from '../../api/payment-schedule';
|
||||
import { SelectOption } from '../../models/select';
|
||||
|
||||
interface UpdatePaymentMeanModalProps {
|
||||
isOpen: boolean,
|
||||
@ -13,12 +14,6 @@ interface UpdatePaymentMeanModalProps {
|
||||
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)
|
||||
*/
|
||||
@ -30,7 +25,7 @@ export const UpdatePaymentMeanModal: React.FC<UpdatePaymentMeanModalProps> = ({
|
||||
/**
|
||||
* 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 { 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
|
||||
*/
|
||||
const handleMeanSelected = (option: selectOption): void => {
|
||||
const handleMeanSelected = (option: SelectOption<PaymentMethod>): void => {
|
||||
setPaymentMean(option.value);
|
||||
};
|
||||
|
||||
|
@ -17,16 +17,19 @@ import { GoogleTagManager } from '../../models/gtm';
|
||||
import { ComputePriceResult } from '../../models/price';
|
||||
import { Wallet } from '../../models/wallet';
|
||||
import FormatLib from '../../lib/format';
|
||||
import { Order } from '../../models/order';
|
||||
import { computePriceWithCoupon } from '../../lib/coupon';
|
||||
|
||||
export interface GatewayFormProps {
|
||||
onSubmit: () => void,
|
||||
onSuccess: (result: Invoice|PaymentSchedule) => void,
|
||||
onSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||
onError: (message: string) => void,
|
||||
customer: User,
|
||||
operator: User,
|
||||
className?: string,
|
||||
paymentSchedule?: PaymentSchedule,
|
||||
cart?: ShoppingCart,
|
||||
order?: Order,
|
||||
updateCart?: (cart: ShoppingCart) => void,
|
||||
formId: string,
|
||||
}
|
||||
@ -34,9 +37,10 @@ export interface GatewayFormProps {
|
||||
interface AbstractPaymentModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||
onError: (message: string) => void,
|
||||
cart: ShoppingCart,
|
||||
order?: Order,
|
||||
updateCart?: (cart: ShoppingCart) => void,
|
||||
currentUser: User,
|
||||
schedule?: PaymentSchedule,
|
||||
@ -60,7 +64,7 @@ declare const GTM: GoogleTagManager;
|
||||
* This component must not be called directly but must be extended for each implemented payment gateway.
|
||||
* @see https://reactjs.org/docs/composition-vs-inheritance.html
|
||||
*/
|
||||
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => {
|
||||
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize, order }) => {
|
||||
// customer's wallet
|
||||
const [wallet, setWallet] = useState<Wallet>(null);
|
||||
// server-computed price with all details
|
||||
@ -107,16 +111,25 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
||||
* - Refresh the remaining price
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!cart) return;
|
||||
WalletAPI.getByUser(cart.customer_id).then((wallet) => {
|
||||
setWallet(wallet);
|
||||
PriceAPI.compute(cart).then((res) => {
|
||||
setPrice(res);
|
||||
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price));
|
||||
if (order && order?.user?.id) {
|
||||
WalletAPI.getByUser(order.user.id).then((wallet) => {
|
||||
setWallet(wallet);
|
||||
const p = { price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total };
|
||||
setPrice(p);
|
||||
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(p.price));
|
||||
setReady(true);
|
||||
});
|
||||
});
|
||||
}, [cart]);
|
||||
} else if (cart && cart.customer_id) {
|
||||
WalletAPI.getByUser(cart.customer_id).then((wallet) => {
|
||||
setWallet(wallet);
|
||||
PriceAPI.compute(cart).then((res) => {
|
||||
setPrice(res);
|
||||
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price));
|
||||
setReady(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [cart, order]);
|
||||
|
||||
/**
|
||||
* Check if there is currently an error to display
|
||||
@ -156,7 +169,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
||||
/**
|
||||
* After sending the form with success, process the resulting payment method
|
||||
*/
|
||||
const handleFormSuccess = async (result: Invoice|PaymentSchedule): Promise<void> => {
|
||||
const handleFormSuccess = async (result: Invoice|PaymentSchedule|Order): Promise<void> => {
|
||||
setSubmitState(false);
|
||||
GTM.trackPurchase(result.id, result.total);
|
||||
afterSuccess(result);
|
||||
@ -212,6 +225,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
||||
className={`gateway-form ${formClassName || ''}`}
|
||||
formId={formId}
|
||||
cart={cart}
|
||||
order={order}
|
||||
updateCart={updateCart}
|
||||
customer={customer}
|
||||
paymentSchedule={schedule}>
|
||||
|
@ -11,15 +11,17 @@ import { Setting } from '../../models/setting';
|
||||
import { Invoice } from '../../models/invoice';
|
||||
import SettingAPI from '../../api/setting';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Order } from '../../models/order';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface CardPaymentModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||
onError: (message: string) => void,
|
||||
cart: ShoppingCart,
|
||||
order?: Order,
|
||||
currentUser: User,
|
||||
schedule?: PaymentSchedule,
|
||||
customer: User
|
||||
@ -29,7 +31,7 @@ interface CardPaymentModalProps {
|
||||
* This component open a modal dialog for the configured payment gateway, allowing the user to input his card data
|
||||
* to process an online payment.
|
||||
*/
|
||||
const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer }) => {
|
||||
const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer, order }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [gateway, setGateway] = useState<Setting>(null);
|
||||
@ -49,6 +51,7 @@ const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal
|
||||
afterSuccess={afterSuccess}
|
||||
onError={onError}
|
||||
cart={cart}
|
||||
order={order}
|
||||
currentUser={currentUser}
|
||||
schedule={schedule}
|
||||
customer={customer} />;
|
||||
@ -63,6 +66,7 @@ const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal
|
||||
afterSuccess={afterSuccess}
|
||||
onError={onError}
|
||||
cart={cart}
|
||||
order={order}
|
||||
currentUser={currentUser}
|
||||
schedule={schedule}
|
||||
customer={customer} />;
|
||||
@ -99,4 +103,4 @@ const CardPaymentModalWrapper: React.FC<CardPaymentModalProps> = (props) => {
|
||||
|
||||
export { CardPaymentModalWrapper as CardPaymentModal };
|
||||
|
||||
Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer']));
|
||||
Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer', 'order']));
|
||||
|
@ -8,22 +8,18 @@ import SettingAPI from '../../../api/setting';
|
||||
import { CardPaymentModal } from '../card-payment-modal';
|
||||
import { PaymentSchedule } from '../../../models/payment-schedule';
|
||||
import { HtmlTranslate } from '../../base/html-translate';
|
||||
import CheckoutAPI from '../../../api/checkout';
|
||||
import { SelectOption } from '../../../models/select';
|
||||
|
||||
const ALL_SCHEDULE_METHODS = ['card', 'check', 'transfer'] as const;
|
||||
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.
|
||||
* This is intended for use by privileged users.
|
||||
* The form validation button must be created elsewhere, using the attribute form={formId}.
|
||||
*/
|
||||
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId }) => {
|
||||
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId, order }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [method, setMethod] = useState<scheduleMethod>('check');
|
||||
@ -43,14 +39,14 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
|
||||
/**
|
||||
* 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));
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: '' };
|
||||
|
||||
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.
|
||||
*/
|
||||
const handleUpdateMethod = (option: selectOption) => {
|
||||
const handleUpdateMethod = (option: SelectOption<scheduleMethod>) => {
|
||||
updateCart(Object.assign({}, cart, { payment_method: option.value }));
|
||||
setMethod(option.value);
|
||||
};
|
||||
@ -85,8 +81,14 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await LocalPaymentAPI.confirmPayment(cart);
|
||||
onSuccess(document);
|
||||
let res;
|
||||
if (order) {
|
||||
res = await CheckoutAPI.payment(order);
|
||||
res = res.order;
|
||||
} else {
|
||||
res = await LocalPaymentAPI.confirmPayment(cart);
|
||||
}
|
||||
onSuccess(res);
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
@ -113,6 +115,9 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
|
||||
* Get the type of the main item in the cart compile
|
||||
*/
|
||||
const mainItemType = (): string => {
|
||||
if (order) {
|
||||
return '';
|
||||
}
|
||||
return Object.keys(cart.items[0])[0];
|
||||
};
|
||||
|
||||
|
@ -10,15 +10,17 @@ import { ModalSize } from '../../base/fab-modal';
|
||||
import { Loader } from '../../base/loader';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { IApplication } from '../../../models/application';
|
||||
import { Order } from '../../../models/order';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface LocalPaymentModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||
onError: (message: string) => void,
|
||||
cart: ShoppingCart,
|
||||
order?: Order,
|
||||
updateCart: (cart: ShoppingCart) => void,
|
||||
currentUser: User,
|
||||
schedule?: PaymentSchedule,
|
||||
@ -28,7 +30,7 @@ interface LocalPaymentModalProps {
|
||||
/**
|
||||
* This component enables a privileged user to confirm a local payments.
|
||||
*/
|
||||
const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer }) => {
|
||||
const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, order }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
/**
|
||||
@ -54,7 +56,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
|
||||
/**
|
||||
* Integrates the LocalPaymentForm into the parent AbstractPaymentModal
|
||||
*/
|
||||
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children }) => {
|
||||
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children, order }) => {
|
||||
return (
|
||||
<LocalPaymentForm onSubmit={onSubmit}
|
||||
onSuccess={onSuccess}
|
||||
@ -63,6 +65,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
|
||||
className={className}
|
||||
formId={formId}
|
||||
cart={cart}
|
||||
order={order}
|
||||
updateCart={updateCart}
|
||||
customer={customer}
|
||||
paymentSchedule={paymentSchedule}>
|
||||
@ -81,6 +84,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
|
||||
formClassName="local-payment-form"
|
||||
currentUser={currentUser}
|
||||
cart={cart}
|
||||
order={order}
|
||||
updateCart={updateCart}
|
||||
customer={customer}
|
||||
afterSuccess={afterSuccess}
|
||||
|
@ -11,6 +11,8 @@ import {
|
||||
} from '../../../models/payzen';
|
||||
import { PaymentSchedule } from '../../../models/payment-schedule';
|
||||
import { Invoice } from '../../../models/invoice';
|
||||
import CheckoutAPI from '../../../api/checkout';
|
||||
import { Order } from '../../../models/order';
|
||||
|
||||
// we use these two additional parameters to update the card, if provided
|
||||
interface PayzenFormProps extends GatewayFormProps {
|
||||
@ -21,7 +23,7 @@ interface PayzenFormProps extends GatewayFormProps {
|
||||
* A form component to collect the credit card details and to create the payment method on Stripe.
|
||||
* The form validation button must be created elsewhere, using the attribute form={formId}.
|
||||
*/
|
||||
export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId }) => {
|
||||
export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId, order }) => {
|
||||
const PayZenKR = useRef<KryptonClient>(null);
|
||||
const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader');
|
||||
|
||||
@ -43,7 +45,7 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
|
||||
.catch(error => onError(error));
|
||||
}).catch(error => onError(error));
|
||||
});
|
||||
}, [cart, paymentSchedule, customer]);
|
||||
}, [cart, paymentSchedule, customer, order]);
|
||||
|
||||
/**
|
||||
* Ask the API to create the form token.
|
||||
@ -54,6 +56,9 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
|
||||
return await PayzenAPI.updateToken(paymentSchedule?.id);
|
||||
} else if (paymentSchedule) {
|
||||
return await PayzenAPI.chargeCreateToken(cart, customer);
|
||||
} else if (order) {
|
||||
const res = await CheckoutAPI.payment(order);
|
||||
return res.payment as CreateTokenResponse;
|
||||
} else {
|
||||
return await PayzenAPI.chargeCreatePayment(cart, customer);
|
||||
}
|
||||
@ -87,9 +92,12 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
|
||||
/**
|
||||
* Confirm the payment, depending on the current type of payment (single shot or recurring)
|
||||
*/
|
||||
const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise<Invoice|PaymentSchedule> => {
|
||||
const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise<Invoice|PaymentSchedule|Order> => {
|
||||
if (paymentSchedule) {
|
||||
return await PayzenAPI.confirmPaymentSchedule(event.clientAnswer.orderDetails.orderId, transaction.uuid, cart);
|
||||
} else if (order) {
|
||||
const res = await CheckoutAPI.confirmPayment(order, event.clientAnswer.orderDetails.orderId);
|
||||
return res.order;
|
||||
} else {
|
||||
return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart);
|
||||
}
|
||||
@ -131,7 +139,9 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
|
||||
try {
|
||||
const { result } = await PayZenKR.current.validateForm();
|
||||
if (result === null) {
|
||||
await PayzenAPI.checkCart(cart, customer);
|
||||
if (!order) {
|
||||
await PayzenAPI.checkCart(cart, customer);
|
||||
}
|
||||
await PayZenKR.current.onSubmit(onPaid);
|
||||
await PayZenKR.current.onError(handleError);
|
||||
await PayZenKR.current.submit();
|
||||
|
@ -9,13 +9,15 @@ import payzenLogo from '../../../../../images/payzen-secure.png';
|
||||
import mastercardLogo from '../../../../../images/mastercard.png';
|
||||
import visaLogo from '../../../../../images/visa.png';
|
||||
import { PayzenForm } from './payzen-form';
|
||||
import { Order } from '../../../models/order';
|
||||
|
||||
interface PayzenModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||
onError: (message: string) => void,
|
||||
cart: ShoppingCart,
|
||||
order?: Order,
|
||||
currentUser: User,
|
||||
schedule?: PaymentSchedule,
|
||||
customer: User
|
||||
@ -28,7 +30,7 @@ interface PayzenModalProps {
|
||||
* This component should not be called directly. Prefer using <CardPaymentModal> which can handle the configuration
|
||||
* of a different payment gateway.
|
||||
*/
|
||||
export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => {
|
||||
export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => {
|
||||
/**
|
||||
* Return the logos, shown in the modal footer.
|
||||
*/
|
||||
@ -45,7 +47,7 @@ export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, a
|
||||
/**
|
||||
* Integrates the PayzenForm into the parent PaymentModal
|
||||
*/
|
||||
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => {
|
||||
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => {
|
||||
return (
|
||||
<PayzenForm onSubmit={onSubmit}
|
||||
onSuccess={onSuccess}
|
||||
@ -54,6 +56,7 @@ export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, a
|
||||
operator={operator}
|
||||
formId={formId}
|
||||
cart={cart}
|
||||
order={order}
|
||||
className={className}
|
||||
paymentSchedule={paymentSchedule}>
|
||||
{children}
|
||||
@ -70,6 +73,7 @@ export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, a
|
||||
className="payzen-modal"
|
||||
currentUser={currentUser}
|
||||
cart={cart}
|
||||
order={order}
|
||||
customer={customer}
|
||||
afterSuccess={afterSuccess}
|
||||
onError={onError}
|
||||
|
@ -11,13 +11,16 @@ import { LocalPaymentModal } from '../local-payment/local-payment-modal';
|
||||
import { CardPaymentModal } from '../card-payment-modal';
|
||||
import PriceAPI from '../../../api/price';
|
||||
import { ComputePriceResult } from '../../../models/price';
|
||||
import { Order } from '../../../models/order';
|
||||
import { computePriceWithCoupon } from '../../../lib/coupon';
|
||||
|
||||
interface PaymentModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||
onError: (message: string) => void,
|
||||
cart: ShoppingCart,
|
||||
order?: Order,
|
||||
updateCart: (cart: ShoppingCart) => void,
|
||||
operator: User,
|
||||
schedule?: PaymentSchedule,
|
||||
@ -27,7 +30,7 @@ interface PaymentModalProps {
|
||||
/**
|
||||
* This component is responsible for rendering the payment modal.
|
||||
*/
|
||||
export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer }) => {
|
||||
export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer, order }) => {
|
||||
// the user's wallet
|
||||
const [wallet, setWallet] = useState<Wallet>(null);
|
||||
// the price of the cart
|
||||
@ -44,10 +47,14 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
|
||||
|
||||
// refresh the price when the cart changes
|
||||
useEffect(() => {
|
||||
PriceAPI.compute(cart).then(price => {
|
||||
setPrice(price);
|
||||
});
|
||||
}, [cart]);
|
||||
if (order) {
|
||||
setPrice({ price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total });
|
||||
} else {
|
||||
PriceAPI.compute(cart).then(price => {
|
||||
setPrice(price);
|
||||
});
|
||||
}
|
||||
}, [cart, order]);
|
||||
|
||||
// refresh the remaining price when the cart price was computed and the wallet was retrieved
|
||||
useEffect(() => {
|
||||
@ -73,6 +80,7 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
|
||||
afterSuccess={afterSuccess}
|
||||
onError={onError}
|
||||
cart={cart}
|
||||
order={order}
|
||||
updateCart={updateCart}
|
||||
currentUser={operator}
|
||||
customer={customer}
|
||||
@ -86,6 +94,7 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
|
||||
afterSuccess={afterSuccess}
|
||||
onError={onError}
|
||||
cart={cart}
|
||||
order={order}
|
||||
currentUser={operator}
|
||||
customer={customer}
|
||||
schedule={schedule}
|
||||
|
@ -95,7 +95,7 @@ export const StripeCardUpdate: React.FC<StripeCardUpdateProps> = ({ onSubmit, on
|
||||
};
|
||||
|
||||
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} />
|
||||
{children}
|
||||
</form>
|
||||
|
@ -6,12 +6,14 @@ import { PaymentConfirmation } from '../../../models/payment';
|
||||
import StripeAPI from '../../../api/stripe';
|
||||
import { Invoice } from '../../../models/invoice';
|
||||
import { PaymentSchedule } from '../../../models/payment-schedule';
|
||||
import CheckoutAPI from '../../../api/checkout';
|
||||
import { Order } from '../../../models/order';
|
||||
|
||||
/**
|
||||
* A form component to collect the credit card details and to create the payment method on Stripe.
|
||||
* The form validation button must be created elsewhere, using the attribute form={formId}.
|
||||
*/
|
||||
export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId }) => {
|
||||
export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId, order }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const stripe = useStripe();
|
||||
@ -41,9 +43,18 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
|
||||
} else {
|
||||
try {
|
||||
if (!paymentSchedule) {
|
||||
// process the normal payment pipeline, including SCA validation
|
||||
const res = await StripeAPI.confirmMethod(paymentMethod.id, cart);
|
||||
await handleServerConfirmation(res);
|
||||
if (order) {
|
||||
const res = await CheckoutAPI.payment(order, paymentMethod.id);
|
||||
if (res.payment) {
|
||||
await handleServerConfirmation(res.payment as PaymentConfirmation);
|
||||
} else {
|
||||
await handleServerConfirmation(res.order);
|
||||
}
|
||||
} else {
|
||||
// process the normal payment pipeline, including SCA validation
|
||||
const res = await StripeAPI.confirmMethod(paymentMethod.id, cart);
|
||||
await handleServerConfirmation(res);
|
||||
}
|
||||
} else {
|
||||
const res = await StripeAPI.setupSubscription(paymentMethod.id, cart);
|
||||
await handleServerConfirmation(res, paymentMethod.id);
|
||||
@ -61,7 +72,7 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
|
||||
* @param paymentMethodId ID of the payment method, required only when confirming a payment schedule
|
||||
* @see app/controllers/api/stripe_controller.rb#confirm_payment
|
||||
*/
|
||||
const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule, paymentMethodId?: string) => {
|
||||
const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule|Order, paymentMethodId?: string) => {
|
||||
if ('error' in response) {
|
||||
if (response.error.statusText) {
|
||||
onError(response.error.statusText);
|
||||
@ -78,8 +89,13 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
|
||||
// The card action has been handled
|
||||
// The PaymentIntent can be confirmed again on the server
|
||||
try {
|
||||
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
|
||||
await handleServerConfirmation(confirmation);
|
||||
if (order) {
|
||||
const confirmation = await CheckoutAPI.confirmPayment(order, result.paymentIntent.id);
|
||||
await handleServerConfirmation(confirmation.order);
|
||||
} else {
|
||||
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
|
||||
await handleServerConfirmation(confirmation);
|
||||
}
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
|
@ -10,13 +10,15 @@ import stripeLogo from '../../../../../images/powered_by_stripe.png';
|
||||
import mastercardLogo from '../../../../../images/mastercard.png';
|
||||
import visaLogo from '../../../../../images/visa.png';
|
||||
import { Invoice } from '../../../models/invoice';
|
||||
import { Order } from '../../../models/order';
|
||||
|
||||
interface StripeModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
|
||||
onError: (message: string) => void,
|
||||
cart: ShoppingCart,
|
||||
order?: Order,
|
||||
currentUser: User,
|
||||
schedule?: PaymentSchedule,
|
||||
customer: User
|
||||
@ -29,7 +31,7 @@ interface StripeModalProps {
|
||||
* This component should not be called directly. Prefer using <CardPaymentModal> which can handle the configuration
|
||||
* of a different payment gateway.
|
||||
*/
|
||||
export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => {
|
||||
export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => {
|
||||
/**
|
||||
* Return the logos, shown in the modal footer.
|
||||
*/
|
||||
@ -47,7 +49,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
|
||||
/**
|
||||
* Integrates the StripeForm into the parent PaymentModal
|
||||
*/
|
||||
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => {
|
||||
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => {
|
||||
return (
|
||||
<StripeElements>
|
||||
<StripeForm onSubmit={onSubmit}
|
||||
@ -57,6 +59,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
|
||||
className={className}
|
||||
formId={formId}
|
||||
cart={cart}
|
||||
order={order}
|
||||
customer={customer}
|
||||
paymentSchedule={paymentSchedule}>
|
||||
{children}
|
||||
@ -74,6 +77,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
|
||||
formClassName="stripe-form"
|
||||
currentUser={currentUser}
|
||||
cart={cart}
|
||||
order={order}
|
||||
customer={customer}
|
||||
afterSuccess={afterSuccess}
|
||||
onError={onError}
|
||||
|
@ -5,6 +5,7 @@ import { Group } from '../../models/group';
|
||||
import { User } from '../../models/user';
|
||||
import PlanAPI from '../../api/plan';
|
||||
import { PlansDuration } from '../../models/plan';
|
||||
import { SelectOption } from '../../models/select';
|
||||
|
||||
interface PlansFilterProps {
|
||||
user?: User,
|
||||
@ -14,12 +15,6 @@ interface PlansFilterProps {
|
||||
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
|
||||
*/
|
||||
@ -38,8 +33,8 @@ export const PlansFilter: React.FC<PlansFilterProps> = ({ user, groups, onGroupS
|
||||
/**
|
||||
* Convert all groups to the react-select format
|
||||
*/
|
||||
const buildGroupOptions = (): Array<selectOption> => {
|
||||
return groups.filter(g => !g.disabled && g.slug !== 'admins').map(g => {
|
||||
const buildGroupOptions = (): Array<SelectOption<number>> => {
|
||||
return groups.filter(g => !g.disabled).map(g => {
|
||||
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
|
||||
*/
|
||||
const buildDurationOptions = (): Array<selectOption> => {
|
||||
const buildDurationOptions = (): Array<SelectOption<number>> => {
|
||||
const options = durations.map((d, index) => {
|
||||
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
|
||||
*/
|
||||
const handleGroupSelected = (option: selectOption): void => {
|
||||
const handleGroupSelected = (option: SelectOption<number>): void => {
|
||||
onGroupSelected(option.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
|
||||
|
@ -235,7 +235,7 @@ export const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection,
|
||||
{plans && Array.from(filteredPlans()).map(([groupId, plansByGroup]) => {
|
||||
return (
|
||||
<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)}
|
||||
</div>
|
||||
);
|
||||
|
@ -41,7 +41,7 @@ export const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuc
|
||||
MachineAPI.index({ disabled: false })
|
||||
.then(data => setMachines(data))
|
||||
.catch(error => onError(error));
|
||||
GroupAPI.index({ disabled: false, admins: false })
|
||||
GroupAPI.index({ disabled: false })
|
||||
.then(data => setGroups(data))
|
||||
.catch(error => onError(error));
|
||||
PriceAPI.index({ priceable_type: 'Machine', plan_id: null })
|
||||
|
@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useImmer } from 'use-immer';
|
||||
import { FabInput } from '../../base/fab-input';
|
||||
import { IFablab } from '../../../models/fablab';
|
||||
import { SelectOption } from '../../../models/select';
|
||||
|
||||
declare let Fablab: IFablab;
|
||||
|
||||
@ -18,12 +19,6 @@ interface PackFormProps {
|
||||
const ALL_INTERVALS = ['day', 'week', 'month', 'year'] as const;
|
||||
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.
|
||||
* 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
|
||||
*/
|
||||
const buildOptions = (): Array<selectOption> => {
|
||||
const buildOptions = (): Array<SelectOption<interval>> => {
|
||||
return ALL_INTERVALS.map(i => intervalToOption(i));
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: '' };
|
||||
|
||||
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.
|
||||
*/
|
||||
const handleUpdateValidityInterval = (option: selectOption) => {
|
||||
const handleUpdateValidityInterval = (option: SelectOption<interval>) => {
|
||||
updatePackData(draft => {
|
||||
draft.validity_interval = option.value as interval;
|
||||
});
|
||||
|
@ -38,7 +38,7 @@ export const SpacesPricing: React.FC<SpacesPricingProps> = ({ onError, onSuccess
|
||||
SpaceAPI.index()
|
||||
.then(data => setSpaces(data))
|
||||
.catch(error => onError(error));
|
||||
GroupAPI.index({ disabled: false, admins: false })
|
||||
GroupAPI.index({ disabled: false })
|
||||
.then(data => setGroups(data))
|
||||
.catch(error => onError(error));
|
||||
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