1
0
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:
Du Peng 2022-11-08 10:33:11 +01:00
commit 87a57f27f4
492 changed files with 22884 additions and 2252 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -48,6 +48,8 @@ GEM
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
acts_as_list (1.0.4)
activerecord (>= 4.2)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
aes_key_wrap (1.1.0)
@ -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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View 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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when the item's amount != product's amount
class Cart::ItemAmountError < StandardError
end

View File

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

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when the item's quantity < product's quantity min
class Cart::QuantityMinError < StandardError
end

View File

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

View File

@ -0,0 +1,3 @@
# Raised when update order state error
class UpdateOrderStateError < StandardError
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
app/frontend/images/no_avatar.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 619 B

After

Width:  |  Height:  |  Size: 792 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

View File

@ -0,0 +1,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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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;
}
}

View File

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

View File

@ -0,0 +1,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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,11 +12,14 @@ import { MenuBar } from './menu-bar';
import { WarningOctagon } from 'phosphor-react';
interface FabTextEditorProps {
paragraphTools?: boolean,
content?: string,
limit?: number,
heading?: boolean,
bulletList?: boolean,
blockquote?: boolean,
link?: boolean,
video?: boolean,
image?: boolean,
content?: string,
limit?: number,
onChange?: (content: string) => void,
placeholder?: string,
error?: string,
@ -30,7 +33,7 @@ export interface FabTextEditorRef {
/**
* This component is a WYSIWYG text editor
*/
export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ paragraphTools, content, limit = 400, video, image, onChange, placeholder, error, disabled = false }, ref: RefObject<FabTextEditorRef>) => {
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} />

View File

@ -6,7 +6,10 @@ import { TextAa, TextBolder, TextItalic, TextUnderline, LinkSimpleHorizontal, Li
interface MenuBarProps {
editor?: Editor,
paragraphTools?: boolean,
heading?: boolean,
bulletList?: boolean,
blockquote?: boolean,
link?: boolean,
video?: boolean,
image?: boolean,
disabled?: boolean,
@ -15,7 +18,7 @@ interface MenuBarProps {
/**
* This component is the menu bar for the WYSIWYG text editor
*/
export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video, image, disabled = false }) => {
export const MenuBar: React.FC<MenuBarProps> = ({ editor, heading, bulletList, blockquote, link, video, image, disabled = false }) => {
const { t } = useTranslation('shared');
const [submenu, setSubmenu] = useState('');
@ -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 &&
(<>

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,15 +10,18 @@ import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
interface FormRichTextProps<TFieldValues, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
valueDefault?: string,
limit?: number,
paragraphTools?: boolean,
heading?: boolean,
bulletList?: boolean,
blockquote?: boolean,
link?: boolean,
video?: boolean,
image?: boolean,
image?: boolean
}
/**
* This component is a rich-text editor to use with react-hook-form.
*/
export const FormRichText = <TFieldValues extends FieldValues, TContext extends object>({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, paragraphTools, video, image }: FormRichTextProps<TFieldValues, TContext>) => {
export const FormRichText = <TFieldValues extends FieldValues, TContext extends object>({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, heading, bulletList, blockquote, video, image, link }: FormRichTextProps<TFieldValues, TContext>) => {
const textEditorRef = React.useRef<FabTextEditorRef>();
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
@ -54,9 +57,12 @@ export const FormRichText = <TFieldValues extends FieldValues, TContext extends
<FabTextEditor onChange={onChange}
content={value}
limit={limit}
paragraphTools={paragraphTools}
heading={heading}
bulletList={bulletList}
blockquote={blockquote}
video={video}
image={image}
link={link}
disabled={isDisabled}
ref={textEditorRef} />
} />

View File

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

View File

@ -41,8 +41,11 @@ export const FormSwitch = <TFieldValues, TContext extends object>({ id, label, t
onChangeCb(val);
}}
checked={value as boolean || false}
height={19}
width={40}
height={19}
uncheckedIcon={false}
checkedIcon={false}
handleDiameter={15}
ref={ref}
disabled={typeof disabled === 'function' ? disabled(id) : disabled} />
} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,8 @@ import MachineAPI from '../../api/machine';
import { MachineCard } from './machine-card';
import { MachinesFilters } from './machines-filters';
import { User } from '../../models/user';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
declare const Application: IApplication;
@ -25,6 +27,7 @@ interface MachinesListProps {
* This component shows a list of all machines and allows filtering on that list.
*/
export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user, canProposePacks }) => {
const { t } = useTranslation('public');
// shown machines
const [machines, setMachines] = useState<Array<Machine>>(null);
// we keep the full list of machines, for filtering
@ -56,10 +59,30 @@ export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess,
setMachines(allMachines.filter(m => !!m.disabled === !status));
};
/**
* Go to store
*/
const linkToStore = (): void => {
window.location.href = '/#!/store';
};
// TODO: Conditionally display the store ad
return (
<div className="machines-list">
<MachinesFilters onStatusSelected={handleFilterByStatus} />
<div className="all-machines">
{false &&
<div className='store-ad' onClick={() => linkToStore}>
<div className='content'>
<h3>{t('app.public.machines_list.store_ad.title')}</h3>
<p>{t('app.public.machines_list.store_ad.buy')}</p>
<p className='sell'>{t('app.public.machines_list.store_ad.sell')}</p>
</div>
<FabButton icon={<i className="fa fa-cart-plus fa-lg" />} className="cta" onClick={linkToStore}>
{t('app.public.machines_list.store_ad.link')}
</FabButton>
</div>
}
{machines && machines.map(machine => {
return <MachineCard key={machine.id}
user={user}
@ -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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -8,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];
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,68 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { 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']));

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

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

View File

@ -0,0 +1,366 @@
import React, { useState, useEffect } from 'react';
import { useImmer } from 'use-immer';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { 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']));

View 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