mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-01-18 07:52:23 +01:00
Merge branch 'dev' into dependabot/bundler/carrierwave-2.1.1
This commit is contained in:
commit
881b534ff8
3
.gitignore
vendored
3
.gitignore
vendored
@ -34,6 +34,9 @@
|
||||
# PDF invoices
|
||||
/invoices/*
|
||||
|
||||
# PDF Payment Schedules
|
||||
/payment_schedules/*
|
||||
|
||||
# XLSX exports
|
||||
/exports/*
|
||||
|
||||
|
@ -16,6 +16,7 @@ Metrics/BlockLength:
|
||||
- 'lib/tasks/**/*.rake'
|
||||
- 'config/routes.rb'
|
||||
- 'app/pdfs/pdf/*.rb'
|
||||
- 'test/**/*.rb'
|
||||
Metrics/ParameterLists:
|
||||
CountKeywordArgs: false
|
||||
Style/BracesAroundHashParameters:
|
||||
|
24
CHANGELOG.md
24
CHANGELOG.md
@ -1,6 +1,28 @@
|
||||
# Changelog Fab-manager
|
||||
|
||||
## Next release
|
||||
- Payment schedules on subscriptions
|
||||
- Refactored theme builder to use scss files
|
||||
- Updated stripe gem to 5.29.0
|
||||
- Architecture documentation
|
||||
- Improved coupon creation/deletion workflow
|
||||
- Default texts for the login modal
|
||||
- Updated caniuse to 1.0.30001191
|
||||
- Fix a bug: unable to access embedded plan views
|
||||
- Fix a bug: warning message overflow in credit wallet modal
|
||||
- Fix a bug: when using a cash coupon, the amount shown in the statistics is invalid
|
||||
- Fix a bug: unable to create a coupon on stripe
|
||||
- Fix a bug: no notifications for refunds generated on wallet credit
|
||||
- Fix a bug: in staging environments, emails are not sent
|
||||
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
|
||||
- [TODO DEPLOY] `rails fablab:stripe:set_product_id`
|
||||
- [TODO DEPLOY] `rails fablab:setup:add_schedule_reference`
|
||||
- [TODO DEPLOY] `rails db:seed`
|
||||
- [TODO DEPLOY] add the `INTL_LOCALE` environment variable (see [doc/environment.md](doc/environment.md#INTL_LOCALE) for configuration details)
|
||||
- [TODO DEPLOY] add the `INTL_CURRENCY` environment variable (see [doc/environment.md](doc/environment.md#INTL_CURRENCY) for configuration details)
|
||||
- [TODO DEPLOY] `\curl -sSL https://raw.githubusercontent.com/sleede/fab-manager/master/scripts/mount-payment-schedules.sh | bash`
|
||||
|
||||
- Fix a bug: unable to configure the app to use a german locale
|
||||
|
||||
## v4.6.6 2021 February 02
|
||||
- Full German translation (thanks to [@korrupt](https://crowdin.com/profile/korrupt))
|
||||
@ -10,7 +32,7 @@
|
||||
- OpenAPI's endpoints will now return more detailed error messages when something wrong occurs
|
||||
- Fix a bug: when an event is modified, the member's reservations does not reflect the new event date
|
||||
- Fix a security issue: updated ini to 1.3.8 to fix [CVE-2020-7788](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-7788)
|
||||
− Fix a security issue: updated nokogiri to 1.11.1 to fix [CVE-2020-26247](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-26247)
|
||||
- Fix a security issue: updated nokogiri to 1.11.1 to fix [CVE-2020-26247](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-26247)
|
||||
- Updated caxlsx to 3.0.4, and the dependencies of caxlsx_rail
|
||||
- [TODO DEPLOY] -> (only dev) `bundle install`
|
||||
|
||||
|
@ -58,6 +58,7 @@ RUN apk del .build-deps && \
|
||||
RUN mkdir -p /usr/src/app && \
|
||||
mkdir -p /usr/src/app/config && \
|
||||
mkdir -p /usr/src/app/invoices && \
|
||||
mkdir -p /usr/src/app/payment_schedules && \
|
||||
mkdir -p /usr/src/app/exports && \
|
||||
mkdir -p /usr/src/app/imports && \
|
||||
mkdir -p /usr/src/app/log && \
|
||||
@ -72,6 +73,7 @@ COPY . /usr/src/app
|
||||
|
||||
# Volumes
|
||||
VOLUME /usr/src/app/invoices
|
||||
VOLUME /usr/src/app/payment_schedules
|
||||
VOLUME /usr/src/app/exports
|
||||
VOLUME /usr/src/app/imports
|
||||
VOLUME /usr/src/app/public
|
||||
|
2
Gemfile
2
Gemfile
@ -90,7 +90,7 @@ gem 'sidekiq', '>= 6.0.7'
|
||||
gem 'sidekiq-cron'
|
||||
gem 'sidekiq-unique-jobs', '~> 6.0.22'
|
||||
|
||||
gem 'stripe', '5.1.1'
|
||||
gem 'stripe', '5.29.0'
|
||||
|
||||
gem 'recurrence'
|
||||
|
||||
|
@ -375,7 +375,7 @@ GEM
|
||||
activesupport (>= 4.0)
|
||||
sprockets (>= 3.0.0)
|
||||
ssrf_filter (1.0.7)
|
||||
stripe (5.1.1)
|
||||
stripe (5.29.0)
|
||||
sync (0.5.0)
|
||||
sys-filesystem (1.3.3)
|
||||
ffi
|
||||
@ -490,7 +490,7 @@ DEPENDENCIES
|
||||
sidekiq-unique-jobs (~> 6.0.22)
|
||||
spring
|
||||
spring-watcher-listen (~> 2.0.0)
|
||||
stripe (= 5.1.1)
|
||||
stripe (= 5.29.0)
|
||||
sys-filesystem
|
||||
tzinfo-data
|
||||
vcr (= 3.0.1)
|
||||
|
4
Rakefile
4
Rakefile
@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Add your own tasks in files placed in lib/tasks ending in .rake,
|
||||
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
|
||||
|
||||
require File.expand_path('../config/application', __FILE__)
|
||||
require_relative 'config/application'
|
||||
|
||||
Rails.application.load_tasks
|
||||
|
@ -23,7 +23,6 @@ class API::InvoicesController < API::ApiController
|
||||
p = params.require(:query).permit(:number, :customer, :date, :order_by, :page, :size)
|
||||
|
||||
render json: { error: 'page must be an integer' }, status: :unprocessable_entity and return unless p[:page].is_a? Integer
|
||||
|
||||
render json: { error: 'size must be an integer' }, status: :unprocessable_entity and return unless p[:size].is_a? Integer
|
||||
|
||||
order = InvoicesService.parse_order(p[:order_by])
|
||||
|
86
app/controllers/api/payment_schedules_controller.rb
Normal file
86
app/controllers/api/payment_schedules_controller.rb
Normal file
@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# API Controller for resources of PaymentSchedule
|
||||
class API::PaymentSchedulesController < API::ApiController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_payment_schedule, only: %i[download cancel]
|
||||
before_action :set_payment_schedule_item, only: %i[cash_check refresh_item pay_item]
|
||||
|
||||
def index
|
||||
@payment_schedules = PaymentSchedule.where('invoicing_profile_id = ?', current_user.invoicing_profile.id)
|
||||
.includes(:invoicing_profile, :payment_schedule_items, :subscription)
|
||||
.joins(:invoicing_profile)
|
||||
.order('payment_schedules.created_at DESC')
|
||||
.page(params[:page])
|
||||
.per(params[:size])
|
||||
end
|
||||
|
||||
def list
|
||||
authorize PaymentSchedule
|
||||
|
||||
p = params.require(:query).permit(:reference, :customer, :date, :page, :size)
|
||||
|
||||
render json: { error: 'page must be an integer' }, status: :unprocessable_entity and return unless p[:page].is_a? Integer
|
||||
render json: { error: 'size must be an integer' }, status: :unprocessable_entity and return unless p[:size].is_a? Integer
|
||||
|
||||
@payment_schedules = PaymentScheduleService.list(
|
||||
p[:page],
|
||||
p[:size],
|
||||
reference: p[:reference], customer: p[:customer], date: p[:date]
|
||||
)
|
||||
end
|
||||
|
||||
def download
|
||||
authorize @payment_schedule
|
||||
send_file File.join(Rails.root, @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)
|
||||
attrs = { state: 'paid', payment_method: 'check' }
|
||||
@payment_schedule_item.update_attributes(attrs)
|
||||
|
||||
render json: attrs, status: :ok
|
||||
end
|
||||
|
||||
def refresh_item
|
||||
authorize @payment_schedule_item.payment_schedule
|
||||
PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id)
|
||||
|
||||
render json: { state: 'refreshed' }, status: :ok
|
||||
end
|
||||
|
||||
def pay_item
|
||||
authorize @payment_schedule_item.payment_schedule
|
||||
|
||||
stripe_key = Setting.get('stripe_secret_key')
|
||||
stp_invoice = Stripe::Invoice.pay(@payment_schedule_item.stp_invoice_id, {}, { api_key: stripe_key })
|
||||
PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id)
|
||||
|
||||
render json: { status: stp_invoice.status }, status: :ok
|
||||
rescue Stripe::StripeError => e
|
||||
stripe_key = Setting.get('stripe_secret_key')
|
||||
stp_invoice = Stripe::Invoice.retrieve(@payment_schedule_item.stp_invoice_id, api_key: stripe_key)
|
||||
PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id)
|
||||
|
||||
render json: { status: stp_invoice.status, error: e }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def cancel
|
||||
authorize @payment_schedule
|
||||
|
||||
canceled_at = PaymentScheduleService.cancel(@payment_schedule)
|
||||
render json: { canceled_at: canceled_at }, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_payment_schedule
|
||||
@payment_schedule = PaymentSchedule.find(params[:id])
|
||||
end
|
||||
|
||||
def set_payment_schedule_item
|
||||
@payment_schedule_item = PaymentScheduleItem.find(params[:id])
|
||||
end
|
||||
end
|
@ -49,7 +49,7 @@ class API::PaymentsController < API::ApiController
|
||||
if params[:cart_items][:reservation]
|
||||
res = on_reservation_success(intent, amount[:details])
|
||||
elsif params[:cart_items][:subscription]
|
||||
res = on_subscription_success(intent)
|
||||
res = on_subscription_success(intent, amount[:details])
|
||||
end
|
||||
end
|
||||
|
||||
@ -68,17 +68,65 @@ class API::PaymentsController < API::ApiController
|
||||
render json: { status: false }
|
||||
end
|
||||
|
||||
def setup_intent
|
||||
user = User.find(params[:user_id])
|
||||
key = Setting.get('stripe_secret_key')
|
||||
@intent = Stripe::SetupIntent.create({ customer: user.stp_customer_id }, { api_key: key })
|
||||
render json: { id: @intent.id, client_secret: @intent.client_secret }
|
||||
end
|
||||
|
||||
def confirm_payment_schedule
|
||||
key = Setting.get('stripe_secret_key')
|
||||
intent = Stripe::SetupIntent.retrieve(params[:setup_intent_id], api_key: key)
|
||||
|
||||
amount = card_amount
|
||||
if intent&.status == 'succeeded'
|
||||
if params[:cart_items][:reservation]
|
||||
res = on_reservation_success(intent, amount[:details])
|
||||
elsif params[:cart_items][:subscription]
|
||||
res = on_subscription_success(intent, amount[:details])
|
||||
end
|
||||
end
|
||||
|
||||
render generate_payment_response(intent, res)
|
||||
rescue Stripe::InvalidRequestError => e
|
||||
render json: e, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def update_card
|
||||
user = User.find(params[:user_id])
|
||||
key = Setting.get('stripe_secret_key')
|
||||
Stripe::Customer.update(user.stp_customer_id,
|
||||
{ invoice_settings: { default_payment_method: params[:payment_method_id] } },
|
||||
{ api_key: key })
|
||||
render json: { updated: true }, status: :ok
|
||||
rescue Stripe::StripeError => e
|
||||
render json: { updated: false, error: e }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def on_reservation_success(intent, details)
|
||||
@reservation = Reservation.new(reservation_params)
|
||||
is_reserve = Reservations::Reserve.new(current_user.id, current_user.invoicing_profile.id)
|
||||
.pay_and_save(@reservation, payment_details: details, payment_intent_id: intent.id)
|
||||
Stripe::PaymentIntent.update(
|
||||
intent.id,
|
||||
{ description: "Invoice reference: #{@reservation.invoice.reference}" },
|
||||
{ api_key: Setting.get('stripe_secret_key') }
|
||||
)
|
||||
payment_method = params[:cart_items][:reservation][:payment_method] || 'stripe'
|
||||
user_id = if current_user.admin? || current_user.manager?
|
||||
params[:cart_items][:reservation][:user_id]
|
||||
else
|
||||
current_user.id
|
||||
end
|
||||
is_reserve = Reservations::Reserve.new(user_id, current_user.invoicing_profile.id)
|
||||
.pay_and_save(@reservation,
|
||||
payment_details: details,
|
||||
intent_id: intent.id,
|
||||
schedule: params[:cart_items][:reservation][:payment_schedule],
|
||||
payment_method: payment_method)
|
||||
if intent.class == Stripe::PaymentIntent
|
||||
Stripe::PaymentIntent.update(
|
||||
intent.id,
|
||||
{ description: "Invoice reference: #{@reservation.invoice.reference}" },
|
||||
{ api_key: Setting.get('stripe_secret_key') }
|
||||
)
|
||||
end
|
||||
|
||||
if is_reserve
|
||||
SubscriptionExtensionAfterReservation.new(@reservation).extend_subscription_if_eligible
|
||||
@ -89,16 +137,26 @@ class API::PaymentsController < API::ApiController
|
||||
end
|
||||
end
|
||||
|
||||
def on_subscription_success(intent)
|
||||
def on_subscription_success(intent, details)
|
||||
@subscription = Subscription.new(subscription_params)
|
||||
is_subscribe = Subscriptions::Subscribe.new(current_user.invoicing_profile.id, current_user.id)
|
||||
.pay_and_save(@subscription, coupon: coupon_params[:coupon_code], invoice: true, payment_intent_id: intent.id)
|
||||
|
||||
Stripe::PaymentIntent.update(
|
||||
intent.id,
|
||||
{ description: "Invoice reference: #{@subscription.invoices.first.reference}" },
|
||||
{ api_key: Setting.get('stripe_secret_key') }
|
||||
)
|
||||
user_id = if current_user.admin? || current_user.manager?
|
||||
params[:cart_items][:subscription][:user_id]
|
||||
else
|
||||
current_user.id
|
||||
end
|
||||
is_subscribe = Subscriptions::Subscribe.new(current_user.invoicing_profile.id, user_id)
|
||||
.pay_and_save(@subscription,
|
||||
payment_details: details,
|
||||
intent_id: intent.id,
|
||||
schedule: params[:cart_items][:subscription][:payment_schedule],
|
||||
payment_method: 'stripe')
|
||||
if intent.class == Stripe::PaymentIntent
|
||||
Stripe::PaymentIntent.update(
|
||||
intent.id,
|
||||
{ description: "Invoice reference: #{@subscription.invoices.first.reference}" },
|
||||
{ api_key: Setting.get('stripe_secret_key') }
|
||||
)
|
||||
end
|
||||
|
||||
if is_subscribe
|
||||
{ template: 'api/subscriptions/show', status: :created, location: @subscription }
|
||||
@ -141,6 +199,11 @@ class API::PaymentsController < API::ApiController
|
||||
slots = cart_items_params[:slots_attributes] || []
|
||||
nb_places = cart_items_params[:nb_reserve_places]
|
||||
tickets = cart_items_params[:tickets_attributes]
|
||||
user_id = if current_user.admin? || current_user.manager?
|
||||
params[:cart_items][:reservation][:user_id]
|
||||
else
|
||||
current_user.id
|
||||
end
|
||||
else
|
||||
raise NotImplementedError unless params[:cart_items][:subscription]
|
||||
|
||||
@ -149,16 +212,21 @@ class API::PaymentsController < API::ApiController
|
||||
slots = []
|
||||
nb_places = nil
|
||||
tickets = nil
|
||||
user_id = if current_user.admin? || current_user.manager?
|
||||
params[:cart_items][:subscription][:user_id]
|
||||
else
|
||||
current_user.id
|
||||
end
|
||||
end
|
||||
|
||||
price_details = Price.compute(false,
|
||||
current_user,
|
||||
User.find(user_id),
|
||||
reservable,
|
||||
slots,
|
||||
plan_id,
|
||||
nb_places,
|
||||
tickets,
|
||||
coupon_params[:coupon_code])
|
||||
plan_id: plan_id,
|
||||
nb_places: nb_places,
|
||||
tickets: tickets,
|
||||
coupon_code: coupon_params[:coupon_code])
|
||||
|
||||
# Subtract wallet amount from total
|
||||
total = price_details[:total]
|
||||
|
@ -68,7 +68,7 @@ class API::PlansController < API::ApiController
|
||||
|
||||
@parameters = @parameters.require(:plan)
|
||||
.permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling,
|
||||
:training_credit_nb, :ui_weight, :disabled,
|
||||
:training_credit_nb, :ui_weight, :disabled, :monthly_payment,
|
||||
plan_file_attributes: %i[id attachment _destroy],
|
||||
prices_attributes: %i[id amount])
|
||||
end
|
||||
|
@ -14,7 +14,7 @@ class API::PricesController < API::ApiController
|
||||
@prices = @prices.where(priceable_id: params[:priceable_id]) if params[:priceable_id]
|
||||
end
|
||||
if params[:plan_id]
|
||||
plan_id = if params[:plan_id] =~ /no|nil|null|undefined/i
|
||||
plan_id = if /no|nil|null|undefined/i.match?(params[:plan_id])
|
||||
nil
|
||||
else
|
||||
params[:plan_id]
|
||||
@ -37,22 +37,31 @@ class API::PricesController < API::ApiController
|
||||
end
|
||||
|
||||
def compute
|
||||
price_parameters = compute_price_params
|
||||
price_parameters = if params[:reservation]
|
||||
compute_reservation_price_params
|
||||
elsif params[:subscription]
|
||||
compute_subscription_price_params
|
||||
end
|
||||
# user
|
||||
user = User.find(price_parameters[:user_id])
|
||||
# reservable
|
||||
if [nil, ''].include? price_parameters[:reservable_id]
|
||||
if [nil, ''].include?(price_parameters[:reservable_id]) && ['', nil].include?(price_parameters[:plan_id])
|
||||
@amount = { elements: nil, total: 0, before_coupon: 0 }
|
||||
else
|
||||
reservable = price_parameters[:reservable_type].constantize.find(price_parameters[:reservable_id])
|
||||
reservable = if [nil, ''].include?(price_parameters[:reservable_id])
|
||||
nil
|
||||
else
|
||||
price_parameters[:reservable_type].constantize.find(price_parameters[:reservable_id])
|
||||
end
|
||||
@amount = Price.compute(current_user.admin? || (current_user.manager? && current_user.id != user.id),
|
||||
user,
|
||||
reservable,
|
||||
price_parameters[:slots_attributes] || [],
|
||||
price_parameters[:plan_id],
|
||||
price_parameters[:nb_reserve_places],
|
||||
price_parameters[:tickets_attributes],
|
||||
coupon_params[:coupon_code])
|
||||
plan_id: price_parameters[:plan_id],
|
||||
nb_places: price_parameters[:nb_reserve_places],
|
||||
tickets: price_parameters[:tickets_attributes],
|
||||
coupon_code: coupon_params[:coupon_code],
|
||||
payment_schedule: price_parameters[:payment_schedule])
|
||||
end
|
||||
|
||||
|
||||
@ -69,12 +78,16 @@ class API::PricesController < API::ApiController
|
||||
params.require(:price).permit(:amount)
|
||||
end
|
||||
|
||||
def compute_price_params
|
||||
params.require(:reservation).permit(:reservable_id, :reservable_type, :plan_id, :user_id, :nb_reserve_places,
|
||||
def compute_reservation_price_params
|
||||
params.require(:reservation).permit(:reservable_id, :reservable_type, :plan_id, :user_id, :nb_reserve_places, :payment_schedule,
|
||||
tickets_attributes: %i[event_price_category_id booked],
|
||||
slots_attributes: %i[id start_at end_at availability_id offered])
|
||||
end
|
||||
|
||||
def compute_subscription_price_params
|
||||
params.require(:subscription).permit(:plan_id, :user_id, :payment_schedule)
|
||||
end
|
||||
|
||||
def coupon_params
|
||||
params.permit(:coupon_code)
|
||||
end
|
||||
|
@ -35,7 +35,10 @@ class API::ReservationsController < API::ApiController
|
||||
|
||||
@reservation = Reservation.new(reservation_params)
|
||||
is_reserve = Reservations::Reserve.new(user_id, current_user.invoicing_profile.id)
|
||||
.pay_and_save(@reservation, payment_details: price[:price_details])
|
||||
.pay_and_save(@reservation,
|
||||
payment_details: price[:price_details],
|
||||
schedule: params[:reservation][:payment_schedule],
|
||||
payment_method: params[:reservation][:payment_method])
|
||||
|
||||
if is_reserve
|
||||
SubscriptionExtensionAfterReservation.new(@reservation).extend_subscription_if_eligible
|
||||
@ -65,10 +68,10 @@ class API::ReservationsController < API::ApiController
|
||||
user,
|
||||
reservation_params[:reservable_type].constantize.find(reservation_params[:reservable_id]),
|
||||
reservation_params[:slots_attributes] || [],
|
||||
reservation_params[:plan_id],
|
||||
reservation_params[:nb_reserve_places],
|
||||
reservation_params[:tickets_attributes],
|
||||
coupon_params[:coupon_code])
|
||||
plan_id: reservation_params[:plan_id],
|
||||
nb_places: reservation_params[:nb_reserve_places],
|
||||
tickets: reservation_params[:tickets_attributes],
|
||||
coupon_code: coupon_params[:coupon_code])
|
||||
|
||||
# Subtract wallet amount from total
|
||||
total = price_details[:total]
|
||||
|
@ -14,13 +14,15 @@ class API::SubscriptionsController < API::ApiController
|
||||
# Managers can create subscriptions for other users
|
||||
def create
|
||||
user_id = current_user.admin? || current_user.manager? ? params[:subscription][:user_id] : current_user.id
|
||||
amount = transaction_amount(current_user.admin? || (current_user.manager? && current_user.id != user_id), user_id)
|
||||
transaction = transaction_amount(current_user.admin? || (current_user.manager? && current_user.id != user_id), user_id)
|
||||
|
||||
authorize SubscriptionContext.new(Subscription, amount, user_id)
|
||||
authorize SubscriptionContext.new(Subscription, transaction[:amount], user_id)
|
||||
|
||||
@subscription = Subscription.new(subscription_params)
|
||||
is_subscribe = Subscriptions::Subscribe.new(current_user.invoicing_profile.id, user_id)
|
||||
.pay_and_save(@subscription, coupon: coupon_params[:coupon_code], invoice: true)
|
||||
.pay_and_save(@subscription, payment_details: transaction[:details],
|
||||
schedule: params[:subscription][:payment_schedule],
|
||||
payment_method: params[:subscription][:payment_method])
|
||||
|
||||
if is_subscribe
|
||||
render :show, status: :created, location: @subscription
|
||||
@ -54,15 +56,15 @@ class API::SubscriptionsController < API::ApiController
|
||||
user,
|
||||
nil,
|
||||
[],
|
||||
subscription_params[:plan_id],
|
||||
nil,
|
||||
nil,
|
||||
coupon_params[:coupon_code])
|
||||
plan_id: subscription_params[:plan_id],
|
||||
nb_places: nil,
|
||||
tickets: nil,
|
||||
coupon_code: coupon_params[:coupon_code])
|
||||
|
||||
# Subtract wallet amount from total
|
||||
total = price_details[:total]
|
||||
wallet_debit = get_wallet_debit(user, total)
|
||||
total - wallet_debit
|
||||
{ amount: total - wallet_debit, details: price_details }
|
||||
end
|
||||
|
||||
def get_wallet_debit(user, total_amount)
|
||||
|
@ -17,5 +17,4 @@ class SocialBotController < ActionController::Base
|
||||
puts "unknown bot request : #{request.original_url}"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
6
app/exceptions/cannot_refund_error.rb
Normal file
6
app/exceptions/cannot_refund_error.rb
Normal file
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when the Avoir cannot be generated from an existing Invoice
|
||||
class CannotRefundError < StandardError
|
||||
end
|
||||
|
5
app/exceptions/invalid_subscription_error.rb
Normal file
5
app/exceptions/invalid_subscription_error.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when trying to create something based on a subscription but it does not exists or is expired
|
||||
class InvalidSubscriptionError < StandardError
|
||||
end
|
@ -91,6 +91,7 @@ importAll(require.context('../src/javascript/controllers/', true, /.*/));
|
||||
importAll(require.context('../src/javascript/services/', true, /.*/));
|
||||
importAll(require.context('../src/javascript/directives/', true, /.*/));
|
||||
importAll(require.context('../src/javascript/filters/', true, /.*/));
|
||||
importAll(require.context('../src/javascript/typings/', true, /.*/));
|
||||
|
||||
importAll(require.context('../images', true));
|
||||
importAll(require.context('../templates', true));
|
||||
|
62
app/frontend/src/javascript/api/api-client.ts
Normal file
62
app/frontend/src/javascript/api/api-client.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
|
||||
const token: HTMLMetaElement = document.querySelector('[name="csrf-token"]');
|
||||
const client: AxiosInstance = axios.create({
|
||||
headers: {
|
||||
common: {
|
||||
'X-CSRF-Token': token?.content || 'no-csrf-token'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.interceptors.response.use(function (response) {
|
||||
// Any status code that lie within the range of 2xx cause this function to trigger
|
||||
return response;
|
||||
}, function (error) {
|
||||
// Any status codes that falls outside the range of 2xx cause this function to trigger
|
||||
const message = error.response?.data || error.message || error;
|
||||
return Promise.reject(extractHumanReadableMessage(message));
|
||||
});
|
||||
|
||||
function extractHumanReadableMessage(error: any): string {
|
||||
if (typeof error === 'string') {
|
||||
if (error.match(/^<!DOCTYPE html>/)) {
|
||||
// parse ruby error pages
|
||||
const parser = new DOMParser();
|
||||
const htmlDoc = parser.parseFromString(error, 'text/html');
|
||||
if (htmlDoc.querySelectorAll('h2').length > 2) {
|
||||
return htmlDoc.querySelector('h2').textContent;
|
||||
} else {
|
||||
return htmlDoc.querySelector('h1').textContent;
|
||||
}
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
// parse Rails errors (as JSON) or API errors
|
||||
let message = '';
|
||||
if (error instanceof Object) {
|
||||
// API errors
|
||||
if (error.hasOwnProperty('error') && typeof error.error === 'string') {
|
||||
return error.error;
|
||||
}
|
||||
// iterate through all the keys to build the message
|
||||
for (const key in error) {
|
||||
if (Object.prototype.hasOwnProperty.call(error, key)) {
|
||||
message += `${key} : `;
|
||||
if (error[key] instanceof Array) {
|
||||
// standard rails messages are stored as {field: [error1, error2]}
|
||||
// we rebuild them as "field: error1, error2"
|
||||
message += error[key].join(', ');
|
||||
} else {
|
||||
message += error[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
return JSON.stringify(error);
|
||||
}
|
||||
|
||||
export default client;
|
17
app/frontend/src/javascript/api/custom-asset.ts
Normal file
17
app/frontend/src/javascript/api/custom-asset.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import apiClient from './api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { CustomAsset, CustomAssetName } from '../models/custom-asset';
|
||||
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
|
||||
|
||||
export default class CustomAssetAPI {
|
||||
async get (name: CustomAssetName): Promise<CustomAsset> {
|
||||
const res: AxiosResponse = await apiClient.get(`/api/custom_assets/${name}`);
|
||||
return res?.data?.custom_asset;
|
||||
}
|
||||
|
||||
static get (name: CustomAssetName): IWrapPromise<CustomAsset> {
|
||||
const api = new CustomAssetAPI();
|
||||
return wrapPromise(api.get(name));
|
||||
}
|
||||
}
|
||||
|
42
app/frontend/src/javascript/api/payment-schedule.ts
Normal file
42
app/frontend/src/javascript/api/payment-schedule.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import apiClient from './api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import {
|
||||
CancelScheduleResponse,
|
||||
CashCheckResponse, PayItemResponse,
|
||||
PaymentSchedule,
|
||||
PaymentScheduleIndexRequest, RefreshItemResponse
|
||||
} from '../models/payment-schedule';
|
||||
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
|
||||
|
||||
export default class PaymentScheduleAPI {
|
||||
async list (query: PaymentScheduleIndexRequest): Promise<Array<PaymentSchedule>> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/list`, query);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
async index (query: PaymentScheduleIndexRequest): Promise<Array<PaymentSchedule>> {
|
||||
const res: AxiosResponse = await apiClient.get(`/api/payment_schedules?page=${query.query.page}&size=${query.query.size}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
async cashCheck(paymentScheduleItemId: number): Promise<CashCheckResponse> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/cash_check`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
async refreshItem(paymentScheduleItemId: number): Promise<RefreshItemResponse> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/refresh_item`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
async payItem(paymentScheduleItemId: number): Promise<PayItemResponse> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/pay_item`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
async cancel (paymentScheduleId: number): Promise<CancelScheduleResponse> {
|
||||
const res: AxiosResponse = await apiClient.put(`/api/payment_schedules/${paymentScheduleId}/cancel`);
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
36
app/frontend/src/javascript/api/payment.ts
Normal file
36
app/frontend/src/javascript/api/payment.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import apiClient from './api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { CartItems, IntentConfirmation, PaymentConfirmation, UpdateCardResponse } from '../models/payment';
|
||||
|
||||
export default class PaymentAPI {
|
||||
static async confirm (stp_payment_method_id: string, cart_items: CartItems): Promise<PaymentConfirmation> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payments/confirm_payment`, {
|
||||
payment_method_id: stp_payment_method_id,
|
||||
cart_items
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async setupIntent (user_id: number): Promise<IntentConfirmation> {
|
||||
const res: AxiosResponse = await apiClient.get(`/api/payments/setup_intent/${user_id}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
// TODO, type the response
|
||||
static async confirmPaymentSchedule (setup_intent_id: string, cart_items: CartItems): Promise<any> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payments/confirm_payment_schedule`, {
|
||||
setup_intent_id,
|
||||
cart_items
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async updateCard (user_id: number, stp_payment_method_id: string): Promise<UpdateCardResponse> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payments/update_card`, {
|
||||
user_id,
|
||||
payment_method_id: stp_payment_method_id,
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
12
app/frontend/src/javascript/api/price.ts
Normal file
12
app/frontend/src/javascript/api/price.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import apiClient from './api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { CartItems } from '../models/payment';
|
||||
import { ComputePriceResult } from '../models/price';
|
||||
|
||||
export default class PriceAPI {
|
||||
static async compute (cartItems: CartItems): Promise<ComputePriceResult> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/prices/compute`, cartItems);
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
27
app/frontend/src/javascript/api/setting.ts
Normal file
27
app/frontend/src/javascript/api/setting.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import apiClient from './api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Setting, SettingName } from '../models/setting';
|
||||
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
|
||||
|
||||
export default class SettingAPI {
|
||||
async get (name: SettingName): Promise<Setting> {
|
||||
const res: AxiosResponse = await apiClient.get(`/api/settings/${name}`);
|
||||
return res?.data?.setting;
|
||||
}
|
||||
|
||||
async query (names: Array<SettingName>): Promise<Map<SettingName, any>> {
|
||||
const res: AxiosResponse = await apiClient.get(`/api/settings/?names=[${names.join(',')}]`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static get (name: SettingName): IWrapPromise<Setting> {
|
||||
const api = new SettingAPI();
|
||||
return wrapPromise(api.get(name));
|
||||
}
|
||||
|
||||
static query(names: Array<SettingName>): IWrapPromise<Map<SettingName, any>> {
|
||||
const api = new SettingAPI();
|
||||
return wrapPromise(api.query(names));
|
||||
}
|
||||
}
|
||||
|
12
app/frontend/src/javascript/api/wallet.ts
Normal file
12
app/frontend/src/javascript/api/wallet.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import apiClient from './api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
|
||||
import { Wallet } from '../models/wallet';
|
||||
|
||||
export default class WalletAPI {
|
||||
static async getByUser (user_id: number): Promise<Wallet> {
|
||||
const res: AxiosResponse = await apiClient.get(`/api/wallet/by_user/${user_id}`);
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
10
app/frontend/src/javascript/components/angular/switch.ts
Normal file
10
app/frontend/src/javascript/components/angular/switch.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This is a compatibility wrapper to allow usage of react-switch inside of the angular.js app
|
||||
*/
|
||||
import Switch from 'react-switch';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { IApplication } from '../../models/application';
|
||||
|
||||
declare var Application: IApplication;
|
||||
|
||||
Application.Components.component('switch', react2angular(Switch, ['checked', 'onChange', 'id', 'className', 'disabled']));
|
57
app/frontend/src/javascript/components/document-filters.tsx
Normal file
57
app/frontend/src/javascript/components/document-filters.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* This component shows 3 input fields for filtering invoices/payment-schedules by reference, customer name and date
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { LabelledInput } from './labelled-input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DocumentFiltersProps {
|
||||
onFilterChange: (value: { reference: string, customer: string, date: Date }) => void
|
||||
}
|
||||
|
||||
export const DocumentFilters: React.FC<DocumentFiltersProps> = ({ onFilterChange }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [referenceFilter, setReferenceFilter] = useState('');
|
||||
const [customerFilter, setCustomerFilter] = useState('');
|
||||
const [dateFilter, setDateFilter] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
onFilterChange({ reference: referenceFilter, customer: customerFilter, date: dateFilter });
|
||||
}, [referenceFilter, customerFilter, dateFilter])
|
||||
|
||||
const handleReferenceUpdate = (e) => {
|
||||
setReferenceFilter(e.target.value);
|
||||
}
|
||||
|
||||
const handleCustomerUpdate = (e) => {
|
||||
setCustomerFilter(e.target.value);
|
||||
}
|
||||
|
||||
const handleDateUpdate = (e) => {
|
||||
let date = e.target.value;
|
||||
if (e.target.value === '') date = null;
|
||||
setDateFilter(date);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="document-filters">
|
||||
<LabelledInput id="reference"
|
||||
label={t('app.admin.invoices.document_filters.reference')}
|
||||
type="text"
|
||||
onChange={handleReferenceUpdate}
|
||||
value={referenceFilter} />
|
||||
<LabelledInput id="customer"
|
||||
label={t('app.admin.invoices.document_filters.customer')}
|
||||
type="text"
|
||||
onChange={handleCustomerUpdate}
|
||||
value={customerFilter} />
|
||||
<LabelledInput id="reference"
|
||||
label={t('app.admin.invoices.document_filters.date')}
|
||||
type="date"
|
||||
onChange={handleDateUpdate}
|
||||
value={dateFilter ? dateFilter : ''} />
|
||||
</div>
|
||||
);
|
||||
}
|
43
app/frontend/src/javascript/components/fab-button.tsx
Normal file
43
app/frontend/src/javascript/components/fab-button.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* This component is a template for a clickable button that wraps the application style
|
||||
*/
|
||||
|
||||
import React, { ReactNode, SyntheticEvent } from 'react';
|
||||
|
||||
interface FabButtonProps {
|
||||
onClick?: (event: SyntheticEvent) => void,
|
||||
icon?: ReactNode,
|
||||
className?: string,
|
||||
disabled?: boolean,
|
||||
type?: 'submit' | 'reset' | 'button',
|
||||
form?: string,
|
||||
}
|
||||
|
||||
|
||||
export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className, disabled, type, form, children }) => {
|
||||
/**
|
||||
* Check if the current component was provided an icon to display
|
||||
*/
|
||||
const hasIcon = (): boolean => {
|
||||
return !!icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the action of the button
|
||||
*/
|
||||
const handleClick = (e: SyntheticEvent): void => {
|
||||
if (typeof onClick === 'function') {
|
||||
onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button type={type} form={form} onClick={handleClick} disabled={disabled} className={`fab-button ${className ? className : ''}`}>
|
||||
{hasIcon() && <span className="fab-button--icon">{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
FabButton.defaultProps = { type: 'button' };
|
||||
|
87
app/frontend/src/javascript/components/fab-modal.tsx
Normal file
87
app/frontend/src/javascript/components/fab-modal.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* This component is a template for a modal dialog that wraps the application style
|
||||
*/
|
||||
|
||||
import React, { ReactNode, SyntheticEvent } from 'react';
|
||||
import Modal from 'react-modal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader } from './loader';
|
||||
import CustomAssetAPI from '../api/custom-asset';
|
||||
import { CustomAssetName } from '../models/custom-asset';
|
||||
import { FabButton } from './fab-button';
|
||||
|
||||
Modal.setAppElement('body');
|
||||
|
||||
export enum ModalSize {
|
||||
small = 'sm',
|
||||
medium = 'md',
|
||||
large = 'lg'
|
||||
}
|
||||
|
||||
interface FabModalProps {
|
||||
title: string,
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
confirmButton?: ReactNode,
|
||||
closeButton?: boolean,
|
||||
className?: string,
|
||||
width?: ModalSize,
|
||||
customFooter?: ReactNode,
|
||||
onConfirm?: (event: SyntheticEvent) => void,
|
||||
preventConfirm?: boolean
|
||||
}
|
||||
|
||||
const blackLogoFile = CustomAssetAPI.get(CustomAssetName.LogoBlackFile);
|
||||
|
||||
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm, preventConfirm }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
const blackLogo = blackLogoFile.read();
|
||||
|
||||
/**
|
||||
* Check if the confirm button should be present
|
||||
*/
|
||||
const hasConfirmButton = (): boolean => {
|
||||
return confirmButton !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should we display the close button?
|
||||
*/
|
||||
const hasCloseButton = (): boolean => {
|
||||
return closeButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a custom footer
|
||||
*/
|
||||
const hasCustomFooter = (): boolean => {
|
||||
return customFooter !== undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen}
|
||||
className={`fab-modal fab-modal-${width} ${className}`}
|
||||
overlayClassName="fab-modal-overlay"
|
||||
onRequestClose={toggleModal}>
|
||||
<div className="fab-modal-header">
|
||||
<Loader>
|
||||
<img src={blackLogo.custom_asset_file_attributes.attachment_url}
|
||||
alt={blackLogo.custom_asset_file_attributes.attachment}
|
||||
className="modal-logo" />
|
||||
</Loader>
|
||||
<h1>{ title }</h1>
|
||||
</div>
|
||||
<div className="fab-modal-content">
|
||||
{children}
|
||||
</div>
|
||||
<div className="fab-modal-footer">
|
||||
<Loader>
|
||||
{hasCloseButton() &&<FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.buttons.close')}</FabButton>}
|
||||
{hasConfirmButton() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} onClick={onConfirm}>{confirmButton}</FabButton>}
|
||||
{hasCustomFooter() && customFooter}
|
||||
</Loader>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
18
app/frontend/src/javascript/components/html-translate.tsx
Normal file
18
app/frontend/src/javascript/components/html-translate.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* This component renders a translation with some HTML content.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface HtmlTranslateProps {
|
||||
trKey: string,
|
||||
options?: any
|
||||
}
|
||||
|
||||
export const HtmlTranslate: React.FC<HtmlTranslateProps> = ({ trKey, options }) => {
|
||||
const { t } = useTranslation(trKey?.split('.')[1]);
|
||||
|
||||
return (
|
||||
<span dangerouslySetInnerHTML={{__html: t(trKey, options)}} />
|
||||
);
|
||||
}
|
22
app/frontend/src/javascript/components/labelled-input.tsx
Normal file
22
app/frontend/src/javascript/components/labelled-input.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* This component shows input field with its label, styled
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface LabelledInputProps {
|
||||
id: string,
|
||||
type: string,
|
||||
label: string,
|
||||
value: any,
|
||||
onChange: (value: any) => void
|
||||
}
|
||||
|
||||
export const LabelledInput: React.FC<LabelledInputProps> = ({ id, type, label, value, onChange }) => {
|
||||
return (
|
||||
<div className="input-with-label">
|
||||
<label className="label" htmlFor={id}>{label}</label>
|
||||
<input className="input" id={id} type={type} value={value} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
20
app/frontend/src/javascript/components/loader.tsx
Normal file
20
app/frontend/src/javascript/components/loader.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* This component is a wrapper that display a loader while the children components have their rendering suspended
|
||||
*/
|
||||
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
|
||||
export const Loader: React.FC = ({children }) => {
|
||||
const loading = (
|
||||
<div className="fa-3x">
|
||||
<i className="fas fa-circle-notch fa-spin" />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={loading}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
@ -1,24 +0,0 @@
|
||||
// This is a demonstration of using react components inside an angular.js 1.x app
|
||||
// TODO remove this
|
||||
|
||||
import { IApplication } from "./application";
|
||||
declare var Application: IApplication;
|
||||
|
||||
import React from 'react';
|
||||
import { react2angular } from 'react2angular';
|
||||
|
||||
interface MyComponentProps {
|
||||
fooBar: number,
|
||||
baz: string
|
||||
}
|
||||
|
||||
const MyComponent: React.FC<MyComponentProps> = ({ fooBar, baz }) => {
|
||||
return (
|
||||
<div>
|
||||
<p>FooBar: {fooBar}</p>
|
||||
<p>Baz: {baz}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('myComponent', react2angular(MyComponent, ['fooBar', 'baz']));
|
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* This component displays a summary of the monthly payment schedule for the current cart, with a subscription
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import moment from 'moment';
|
||||
import { IApplication } from '../models/application';
|
||||
import '../lib/i18n';
|
||||
import { PaymentSchedule } from '../models/payment-schedule';
|
||||
import { Loader } from './loader';
|
||||
import { FabModal } from './fab-modal';
|
||||
import { IFablab } from '../models/fablab';
|
||||
|
||||
declare var Application: IApplication;
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
interface PaymentScheduleSummaryProps {
|
||||
schedule: PaymentSchedule
|
||||
}
|
||||
|
||||
const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedule }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
const [modal, setModal] = useState(false);
|
||||
|
||||
/**
|
||||
* Return the formatted localized date for the given date
|
||||
*/
|
||||
const formatDate = (date: Date): string => {
|
||||
return Intl.DateTimeFormat().format(moment(date).toDate());
|
||||
}
|
||||
/**
|
||||
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
|
||||
*/
|
||||
const formatPrice = (price: number): string => {
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price);
|
||||
}
|
||||
/**
|
||||
* Test if all payment deadlines have the same amount
|
||||
*/
|
||||
const hasEqualDeadlines = (): boolean => {
|
||||
const prices = schedule.items.map(i => i.amount);
|
||||
return prices.every(p => p === prices[0]);
|
||||
}
|
||||
/**
|
||||
* Open or closes the modal dialog showing the full payment schedule
|
||||
*/
|
||||
const toggleFullScheduleModal = (): void => {
|
||||
setModal(!modal);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="payment-schedule-summary">
|
||||
<div>
|
||||
<h4>{ t('app.shared.cart.your_payment_schedule') }</h4>
|
||||
{hasEqualDeadlines() && <ul>
|
||||
<li>
|
||||
<span className="schedule-item-info">
|
||||
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length, AMOUNT: formatPrice(schedule.items[0].amount) })}
|
||||
</span>
|
||||
<span className="schedule-item-date">{t('app.shared.cart.first_debit')}</span>
|
||||
</li>
|
||||
</ul>}
|
||||
{!hasEqualDeadlines() && <ul>
|
||||
<li>
|
||||
<span className="schedule-item-info">{t('app.shared.cart.monthly_payment_NUMBER', { NUMBER: 1 })}</span>
|
||||
<span className="schedule-item-price">{formatPrice(schedule.items[0].amount)}</span>
|
||||
<span className="schedule-item-date">{t('app.shared.cart.debit')}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="schedule-item-info">
|
||||
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length - 1, AMOUNT: formatPrice(schedule.items[1].amount) })}
|
||||
</span>
|
||||
</li>
|
||||
</ul>}
|
||||
<button className="view-full-schedule" onClick={toggleFullScheduleModal}>{t('app.shared.cart.view_full_schedule')}</button>
|
||||
<FabModal title={t('app.shared.cart.your_payment_schedule')} isOpen={modal} toggleModal={toggleFullScheduleModal}>
|
||||
<ul className="full-schedule">
|
||||
{schedule.items.map(item => (
|
||||
<li key={String(item.due_date)}>
|
||||
<span className="schedule-item-date">{formatDate(item.due_date)}</span>
|
||||
<span> </span>
|
||||
<span className="schedule-item-price">{formatPrice(item.amount)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</FabModal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const PaymentScheduleSummaryWrapper: React.FC<PaymentScheduleSummaryProps> = ({ schedule }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<PaymentScheduleSummary schedule={schedule} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('paymentScheduleSummary', react2angular(PaymentScheduleSummaryWrapper, ['schedule']));
|
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
|
||||
* for the currentUser
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { IApplication } from '../models/application';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader } from './loader';
|
||||
import { react2angular } from 'react2angular';
|
||||
import PaymentScheduleAPI from '../api/payment-schedule';
|
||||
import { PaymentSchedulesTable } from './payment-schedules-table';
|
||||
import { FabButton } from './fab-button';
|
||||
import { User } from '../models/user';
|
||||
import { PaymentSchedule } from '../models/payment-schedule';
|
||||
|
||||
declare var Application: IApplication;
|
||||
|
||||
interface PaymentSchedulesDashboardProps {
|
||||
currentUser: User
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser }) => {
|
||||
const { t } = useTranslation('logged');
|
||||
|
||||
const [paymentSchedules, setPaymentSchedules] = useState<Array<PaymentSchedule>>([]);
|
||||
const [pageNumber, setPageNumber] = useState<number>(1);
|
||||
|
||||
useEffect(() => {
|
||||
handleRefreshList();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch from the API the next payment schedules to display, for the current filters, and append them to the current results table.
|
||||
*/
|
||||
const handleLoadMore = (): void => {
|
||||
setPageNumber(pageNumber + 1);
|
||||
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.index({ query: { page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
|
||||
const list = paymentSchedules.concat(res);
|
||||
setPaymentSchedules(list);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload from te API all the currently displayed payment schedules
|
||||
*/
|
||||
const handleRefreshList = (onError?: (msg: any) => void): void => {
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.index({ query: { page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
|
||||
setPaymentSchedules(res);
|
||||
}).catch((err) => {
|
||||
if (typeof onError === 'function') { onError(err.message); }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current collection of payment schedules is empty or not.
|
||||
*/
|
||||
const hasSchedules = (): boolean => {
|
||||
return paymentSchedules.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are some results for the current filters that aren't currently shown.
|
||||
*/
|
||||
const hasMoreSchedules = (): boolean => {
|
||||
return hasSchedules() && paymentSchedules.length < paymentSchedules[0].max_length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="payment-schedules-dashboard">
|
||||
{!hasSchedules() && <div>{t('app.logged.dashboard.payment_schedules.no_payment_schedules')}</div>}
|
||||
{hasSchedules() && <div className="schedules-list">
|
||||
<PaymentSchedulesTable paymentSchedules={paymentSchedules} showCustomer={false} refreshList={handleRefreshList} operator={currentUser} />
|
||||
{hasMoreSchedules() && <FabButton className="load-more" onClick={handleLoadMore}>{t('app.logged.dashboard.payment_schedules.load_more')}</FabButton>}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const PaymentSchedulesDashboardWrapper: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<PaymentSchedulesDashboard currentUser={currentUser} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('paymentSchedulesDashboard', react2angular(PaymentSchedulesDashboardWrapper, ['currentUser']));
|
@ -0,0 +1,118 @@
|
||||
/**
|
||||
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { IApplication } from '../models/application';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader } from './loader';
|
||||
import { react2angular } from 'react2angular';
|
||||
import PaymentScheduleAPI from '../api/payment-schedule';
|
||||
import { DocumentFilters } from './document-filters';
|
||||
import { PaymentSchedulesTable } from './payment-schedules-table';
|
||||
import { FabButton } from './fab-button';
|
||||
import { User } from '../models/user';
|
||||
import { PaymentSchedule } from '../models/payment-schedule';
|
||||
|
||||
declare var Application: IApplication;
|
||||
|
||||
interface PaymentSchedulesListProps {
|
||||
currentUser: User
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [paymentSchedules, setPaymentSchedules] = useState<Array<PaymentSchedule>>([]);
|
||||
const [pageNumber, setPageNumber] = useState<number>(1);
|
||||
const [referenceFilter, setReferenceFilter] = useState<string>(null);
|
||||
const [customerFilter, setCustomerFilter] = useState<string>(null);
|
||||
const [dateFilter, setDateFilter] = useState<Date>(null);
|
||||
|
||||
useEffect(() => {
|
||||
handleRefreshList();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch from the API the payments schedules matching the given filters and reset the results table with the new schedules.
|
||||
*/
|
||||
const handleFiltersChange = ({ reference, customer, date }): void => {
|
||||
setReferenceFilter(reference);
|
||||
setCustomerFilter(customer);
|
||||
setDateFilter(date);
|
||||
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.list({ query: { reference, customer, date, page: 1, size: PAGE_SIZE }}).then((res) => {
|
||||
setPaymentSchedules(res);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch from the API the next payment schedules to display, for the current filters, and append them to the current results table.
|
||||
*/
|
||||
const handleLoadMore = (): void => {
|
||||
setPageNumber(pageNumber + 1);
|
||||
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
|
||||
const list = paymentSchedules.concat(res);
|
||||
setPaymentSchedules(list);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload from te API all the currently displayed payment schedules
|
||||
*/
|
||||
const handleRefreshList = (onError?: (msg: any) => void): void => {
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
|
||||
setPaymentSchedules(res);
|
||||
}).catch((err) => {
|
||||
if (typeof onError === 'function') { onError(err.message); }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current collection of payment schedules is empty or not.
|
||||
*/
|
||||
const hasSchedules = (): boolean => {
|
||||
return paymentSchedules.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are some results for the current filters that aren't currently shown.
|
||||
*/
|
||||
const hasMoreSchedules = (): boolean => {
|
||||
return hasSchedules() && paymentSchedules.length < paymentSchedules[0].max_length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="payment-schedules-list">
|
||||
<h3>
|
||||
<i className="fas fa-filter" />
|
||||
{t('app.admin.invoices.payment_schedules.filter_schedules')}
|
||||
</h3>
|
||||
<div className="schedules-filters">
|
||||
<DocumentFilters onFilterChange={handleFiltersChange} />
|
||||
</div>
|
||||
{!hasSchedules() && <div>{t('app.admin.invoices.payment_schedules.no_payment_schedules')}</div>}
|
||||
{hasSchedules() && <div className="schedules-list">
|
||||
<PaymentSchedulesTable paymentSchedules={paymentSchedules} showCustomer={true} refreshList={handleRefreshList} operator={currentUser} />
|
||||
{hasMoreSchedules() && <FabButton className="load-more" onClick={handleLoadMore}>{t('app.admin.invoices.payment_schedules.load_more')}</FabButton>}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const PaymentSchedulesListWrapper: React.FC<PaymentSchedulesListProps> = ({ currentUser }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<PaymentSchedulesList currentUser={currentUser} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper, ['currentUser']));
|
@ -0,0 +1,477 @@
|
||||
/**
|
||||
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
|
||||
*/
|
||||
|
||||
import React, { ReactEventHandler, ReactNode, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader } from './loader';
|
||||
import moment from 'moment';
|
||||
import { IFablab } from '../models/fablab';
|
||||
import _ from 'lodash';
|
||||
import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from '../models/payment-schedule';
|
||||
import { FabButton } from './fab-button';
|
||||
import { FabModal } from './fab-modal';
|
||||
import PaymentScheduleAPI from '../api/payment-schedule';
|
||||
import { StripeElements } from './stripe-elements';
|
||||
import { StripeConfirm } from './stripe-confirm';
|
||||
import stripeLogo from '../../../images/powered_by_stripe.png';
|
||||
import mastercardLogo from '../../../images/mastercard.png';
|
||||
import visaLogo from '../../../images/visa.png';
|
||||
import { StripeCardUpdate } from './stripe-card-update';
|
||||
import { User, UserRole } from '../models/user';
|
||||
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
interface PaymentSchedulesTableProps {
|
||||
paymentSchedules: Array<PaymentSchedule>,
|
||||
showCustomer?: boolean,
|
||||
refreshList: (onError: (msg: any) => void) => void,
|
||||
operator: User,
|
||||
}
|
||||
|
||||
const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [showExpanded, setShowExpanded] = useState<Map<number, boolean>>(new Map());
|
||||
const [showConfirmCashing, setShowConfirmCashing] = useState<boolean>(false);
|
||||
const [showResolveAction, setShowResolveAction] = useState<boolean>(false);
|
||||
const [isConfirmActionDisabled, setConfirmActionDisabled] = useState<boolean>(true);
|
||||
const [showUpdateCard, setShowUpdateCard] = useState<boolean>(false);
|
||||
const [tempDeadline, setTempDeadline] = useState<PaymentScheduleItem>(null);
|
||||
const [tempSchedule, setTempSchedule] = useState<PaymentSchedule>(null);
|
||||
const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState<boolean>(true);
|
||||
const [errors, setErrors] = useState<string>(null);
|
||||
const [showCancelSubscription, setShowCancelSubscription] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them
|
||||
*/
|
||||
const isExpanded = (paymentScheduleId: number): boolean => {
|
||||
return showExpanded.get(paymentScheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the formatted localized date for the given date
|
||||
*/
|
||||
const formatDate = (date: Date): string => {
|
||||
return Intl.DateTimeFormat().format(moment(date).toDate());
|
||||
}
|
||||
/**
|
||||
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
|
||||
*/
|
||||
const formatPrice = (price: number): string => {
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the value for the CSS property 'display', for the payment schedule deadlines
|
||||
*/
|
||||
const statusDisplay = (paymentScheduleId: number): string => {
|
||||
if (isExpanded(paymentScheduleId)) {
|
||||
return 'table-row'
|
||||
} else {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the action icon for showing/hiding the deadlines
|
||||
*/
|
||||
const expandCollapseIcon = (paymentScheduleId: number): JSX.Element => {
|
||||
if (isExpanded(paymentScheduleId)) {
|
||||
return <i className="fas fa-minus-square" />;
|
||||
} else {
|
||||
return <i className="fas fa-plus-square" />
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the deadlines for the provided payment schedule, inverting their current status
|
||||
*/
|
||||
const togglePaymentScheduleDetails = (paymentScheduleId: number): ReactEventHandler => {
|
||||
return (): void => {
|
||||
if (isExpanded(paymentScheduleId)) {
|
||||
setShowExpanded((prev) => new Map(prev).set(paymentScheduleId, false));
|
||||
} else {
|
||||
setShowExpanded((prev) => new Map(prev).set(paymentScheduleId, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For use with downloadButton()
|
||||
*/
|
||||
enum TargetType {
|
||||
Invoice = 'invoices',
|
||||
PaymentSchedule = 'payment_schedules'
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a button to download a PDF file, may be an invoice, or a payment schedule, depending or the provided parameters
|
||||
*/
|
||||
const downloadButton = (target: TargetType, id: number): JSX.Element => {
|
||||
const link = `api/${target}/${id}/download`;
|
||||
return (
|
||||
<a href={link} target="_blank" className="download-button">
|
||||
<i className="fas fa-download" />
|
||||
{t('app.shared.schedules_table.download')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the human-readable string for the status of the provided deadline.
|
||||
*/
|
||||
const formatState = (item: PaymentScheduleItem): JSX.Element => {
|
||||
let res = t(`app.shared.schedules_table.state_${item.state}`);
|
||||
if (item.state === PaymentScheduleItemState.Paid) {
|
||||
const key = `app.shared.schedules_table.method_${item.payment_method}`
|
||||
res += ` (${t(key)})`;
|
||||
}
|
||||
return <span className={`state-${item.state}`}>{res}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current operator has administrative rights or is a normal member
|
||||
*/
|
||||
const isPrivileged = (): boolean => {
|
||||
return (operator.role === UserRole.Admin || operator.role == UserRole.Manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the action button(s) for the given deadline
|
||||
*/
|
||||
const itemButtons = (item: PaymentScheduleItem, schedule: PaymentSchedule): JSX.Element => {
|
||||
switch (item.state) {
|
||||
case PaymentScheduleItemState.Paid:
|
||||
return downloadButton(TargetType.Invoice, item.invoice_id);
|
||||
case PaymentScheduleItemState.Pending:
|
||||
if (isPrivileged()) {
|
||||
return (
|
||||
<FabButton onClick={handleConfirmCheckPayment(item)}
|
||||
icon={<i className="fas fa-money-check" />}>
|
||||
{t('app.shared.schedules_table.confirm_payment')}
|
||||
</FabButton>
|
||||
);
|
||||
} else {
|
||||
return <span>{t('app.shared.schedules_table.please_ask_reception')}</span>
|
||||
}
|
||||
case PaymentScheduleItemState.RequireAction:
|
||||
return (
|
||||
<FabButton onClick={handleSolveAction(item)}
|
||||
icon={<i className="fas fa-wrench" />}>
|
||||
{t('app.shared.schedules_table.solve')}
|
||||
</FabButton>
|
||||
);
|
||||
case PaymentScheduleItemState.RequirePaymentMethod:
|
||||
return (
|
||||
<FabButton onClick={handleUpdateCard(item, schedule)}
|
||||
icon={<i className="fas fa-credit-card" />}>
|
||||
{t('app.shared.schedules_table.update_card')}
|
||||
</FabButton>
|
||||
);
|
||||
case PaymentScheduleItemState.Error:
|
||||
if (isPrivileged()) {
|
||||
return (
|
||||
<FabButton onClick={handleCancelSubscription(schedule)}
|
||||
icon={<i className="fas fa-times" />}>
|
||||
{t('app.shared.schedules_table.cancel_subscription')}
|
||||
</FabButton>
|
||||
)
|
||||
} else {
|
||||
return <span>{t('app.shared.schedules_table.please_ask_reception')}</span>
|
||||
}
|
||||
default:
|
||||
return <span />
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user's clicks on the "cash check" button: show a confirmation modal
|
||||
*/
|
||||
const handleConfirmCheckPayment = (item: PaymentScheduleItem): ReactEventHandler => {
|
||||
return (): void => {
|
||||
setTempDeadline(item);
|
||||
toggleConfirmCashingModal();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After the user has confirmed that he wants to cash the check, update the API, refresh the list and close the modal.
|
||||
*/
|
||||
const onCheckCashingConfirmed = (): void => {
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.cashCheck(tempDeadline.id).then((res) => {
|
||||
if (res.state === PaymentScheduleItemState.Paid) {
|
||||
refreshSchedulesTable();
|
||||
toggleConfirmCashingModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh all payment schedules in the table
|
||||
*/
|
||||
const refreshSchedulesTable = (): void => {
|
||||
refreshList(setErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide the modal dialog that enable to confirm the cashing of the check for a given deadline.
|
||||
*/
|
||||
const toggleConfirmCashingModal = (): void => {
|
||||
setShowConfirmCashing(!showConfirmCashing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide the modal dialog that trigger the card "action".
|
||||
*/
|
||||
const toggleResolveActionModal = (): void => {
|
||||
setShowResolveAction(!showResolveAction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user's clicks on the "resolve" button: show a modal that will trigger the action
|
||||
*/
|
||||
const handleSolveAction = (item: PaymentScheduleItem): ReactEventHandler => {
|
||||
return (): void => {
|
||||
setTempDeadline(item);
|
||||
toggleResolveActionModal();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After the action was done (successfully or not), ask the API to refresh the item status, then refresh the list and close the modal
|
||||
*/
|
||||
const afterAction = (): void => {
|
||||
toggleConfirmActionButton();
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.refreshItem(tempDeadline.id).then(() => {
|
||||
refreshSchedulesTable();
|
||||
toggleResolveActionModal();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable the confirm button of the "action" modal
|
||||
*/
|
||||
const toggleConfirmActionButton = (): void => {
|
||||
setConfirmActionDisabled(!isConfirmActionDisabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user's clicks on the "update card" button: show a modal to input a new card
|
||||
*/
|
||||
const handleUpdateCard = (item: PaymentScheduleItem, paymentSchedule: PaymentSchedule): ReactEventHandler => {
|
||||
return (): void => {
|
||||
setTempDeadline(item);
|
||||
setTempSchedule(paymentSchedule);
|
||||
toggleUpdateCardModal();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide the modal dialog to update the bank card details
|
||||
*/
|
||||
const toggleUpdateCardModal = (): void => {
|
||||
setShowUpdateCard(!showUpdateCard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the logos, shown in the modal footer.
|
||||
*/
|
||||
const logoFooter = (): ReactNode => {
|
||||
return (
|
||||
<div className="stripe-modal-icons">
|
||||
<i className="fa fa-lock fa-2x m-r-sm pos-rlt" />
|
||||
<img src={stripeLogo} alt="powered by stripe" />
|
||||
<img src={mastercardLogo} alt="mastercard" />
|
||||
<img src={visaLogo} alt="visa" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the submit button is pushed, disable it to prevent double form submission
|
||||
*/
|
||||
const handleCardUpdateSubmit = (): void => {
|
||||
setCanSubmitUpdateCard(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the card was successfully updated, pay the invoice (using the new payment method) and close the modal
|
||||
*/
|
||||
const handleCardUpdateSuccess = (): void => {
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.payItem(tempDeadline.id).then(() => {
|
||||
refreshSchedulesTable();
|
||||
toggleUpdateCardModal();
|
||||
}).catch((err) => {
|
||||
handleCardUpdateError(err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When the card was not updated, show the error
|
||||
*/
|
||||
const handleCardUpdateError = (error): void => {
|
||||
setErrors(error);
|
||||
setCanSubmitUpdateCard(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user clicks on the "cancel subscription" button
|
||||
*/
|
||||
const handleCancelSubscription = (schedule: PaymentSchedule): ReactEventHandler => {
|
||||
return (): void => {
|
||||
setTempSchedule(schedule);
|
||||
toggleCancelSubscriptionModal();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide the modal dialog to cancel the current subscription
|
||||
*/
|
||||
const toggleCancelSubscriptionModal = (): void => {
|
||||
setShowCancelSubscription(!showCancelSubscription);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the user has confirmed the cancellation, we transfer the request to the API
|
||||
*/
|
||||
const onCancelSubscriptionConfirmed = (): void => {
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.cancel(tempSchedule.id).then(() => {
|
||||
refreshSchedulesTable();
|
||||
toggleCancelSubscriptionModal();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<table className="schedules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-35" />
|
||||
<th className="w-200">{t('app.shared.schedules_table.schedule_num')}</th>
|
||||
<th className="w-200">{t('app.shared.schedules_table.date')}</th>
|
||||
<th className="w-120">{t('app.shared.schedules_table.price')}</th>
|
||||
{showCustomer && <th className="w-200">{t('app.shared.schedules_table.customer')}</th>}
|
||||
<th className="w-200"/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paymentSchedules.map(p => <tr key={p.id}>
|
||||
<td colSpan={showCustomer ? 6 : 5}>
|
||||
<table className="schedules-table-body">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="w-35 row-header" onClick={togglePaymentScheduleDetails(p.id)}>{expandCollapseIcon(p.id)}</td>
|
||||
<td className="w-200">{p.reference}</td>
|
||||
<td className="w-200">{formatDate(p.created_at)}</td>
|
||||
<td className="w-120">{formatPrice(p.total)}</td>
|
||||
{showCustomer && <td className="w-200">{p.user.name}</td>}
|
||||
<td className="w-200">{downloadButton(TargetType.PaymentSchedule, p.id)}</td>
|
||||
</tr>
|
||||
<tr style={{ display: statusDisplay(p.id) }}>
|
||||
<td className="w-35" />
|
||||
<td colSpan={showCustomer ? 5 : 4}>
|
||||
<div>
|
||||
<table className="schedule-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-120">{t('app.shared.schedules_table.deadline')}</th>
|
||||
<th className="w-120">{t('app.shared.schedules_table.amount')}</th>
|
||||
<th className="w-200">{t('app.shared.schedules_table.state')}</th>
|
||||
<th className="w-200" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{_.orderBy(p.items, 'due_date').map(item => <tr key={item.id}>
|
||||
<td>{formatDate(item.due_date)}</td>
|
||||
<td>{formatPrice(item.amount)}</td>
|
||||
<td>{formatState(item)}</td>
|
||||
<td>{itemButtons(item, p)}</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="modals">
|
||||
<FabModal title={t('app.shared.schedules_table.confirm_check_cashing')}
|
||||
isOpen={showConfirmCashing}
|
||||
toggleModal={toggleConfirmCashingModal}
|
||||
onConfirm={onCheckCashingConfirmed}
|
||||
closeButton={true}
|
||||
confirmButton={t('app.shared.schedules_table.confirm_button')}>
|
||||
{tempDeadline && <span>
|
||||
{t('app.shared.schedules_table.confirm_check_cashing_body', {
|
||||
AMOUNT: formatPrice(tempDeadline.amount),
|
||||
DATE: formatDate(tempDeadline.due_date)
|
||||
})}
|
||||
</span>}
|
||||
</FabModal>
|
||||
<FabModal title={t('app.shared.schedules_table.cancel_subscription')}
|
||||
isOpen={showCancelSubscription}
|
||||
toggleModal={toggleCancelSubscriptionModal}
|
||||
onConfirm={onCancelSubscriptionConfirmed}
|
||||
closeButton={true}
|
||||
confirmButton={t('app.shared.schedules_table.confirm_button')}>
|
||||
{t('app.shared.schedules_table.confirm_cancel_subscription')}
|
||||
</FabModal>
|
||||
<StripeElements>
|
||||
<FabModal title={t('app.shared.schedules_table.resolve_action')}
|
||||
isOpen={showResolveAction}
|
||||
toggleModal={toggleResolveActionModal}
|
||||
onConfirm={afterAction}
|
||||
confirmButton={t('app.shared.schedules_table.ok_button')}
|
||||
preventConfirm={isConfirmActionDisabled}>
|
||||
{tempDeadline && <StripeConfirm clientSecret={tempDeadline.client_secret} onResponse={toggleConfirmActionButton} />}
|
||||
</FabModal>
|
||||
<FabModal title={t('app.shared.schedules_table.update_card')}
|
||||
isOpen={showUpdateCard}
|
||||
toggleModal={toggleUpdateCardModal}
|
||||
closeButton={false}
|
||||
customFooter={logoFooter()}
|
||||
className="update-card-modal">
|
||||
{tempDeadline && tempSchedule && <StripeCardUpdate onSubmit={handleCardUpdateSubmit}
|
||||
onSuccess={handleCardUpdateSuccess}
|
||||
onError={handleCardUpdateError}
|
||||
customerId={tempSchedule.user.id}
|
||||
operator={operator}
|
||||
className="card-form" >
|
||||
{errors && <div className="stripe-errors">
|
||||
{errors}
|
||||
</div>}
|
||||
</StripeCardUpdate>}
|
||||
<div className="submit-card">
|
||||
{canSubmitUpdateCard && <button type="submit" disabled={!canSubmitUpdateCard} form="stripe-card" className="submit-card-btn">{t('app.shared.schedules_table.validate_button')}</button>}
|
||||
{!canSubmitUpdateCard && <div className="payment-pending">
|
||||
<div className="fa-2x">
|
||||
<i className="fas fa-circle-notch fa-spin" />
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</FabModal>
|
||||
</StripeElements>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
PaymentSchedulesTableComponent.defaultProps = { showCustomer: false };
|
||||
|
||||
|
||||
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} refreshList={refreshList} operator={operator} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
134
app/frontend/src/javascript/components/plan-card.tsx
Normal file
134
app/frontend/src/javascript/components/plan-card.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
/**
|
||||
* This component is a "card" publicly presenting the details of a plan
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash'
|
||||
import { IApplication } from '../models/application';
|
||||
import { Plan } from '../models/plan';
|
||||
import { User, UserRole } from '../models/user';
|
||||
import { Loader } from './loader';
|
||||
import '../lib/i18n';
|
||||
import { IFablab } from '../models/fablab';
|
||||
|
||||
declare var Application: IApplication;
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: Plan,
|
||||
userId?: number,
|
||||
subscribedPlanId?: number,
|
||||
operator: User,
|
||||
isSelected: boolean,
|
||||
onSelectPlan: (plan: Plan) => void,
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected }) => {
|
||||
const { t } = useTranslation('public');
|
||||
/**
|
||||
* Return the formatted localized amount of the given plan (eg. 20.5 => "20,50 €")
|
||||
*/
|
||||
const amount = () : string => {
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(plan.amount);
|
||||
}
|
||||
/**
|
||||
* Return the formatted localized amount, divided by the number of months (eg. 120 => "10,00 € / month")
|
||||
*/
|
||||
const monthlyAmount = (): string => {
|
||||
const monthly = plan.amount / moment.duration(plan.interval_count, plan.interval).asMonths();
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(monthly);
|
||||
}
|
||||
/**
|
||||
* Return the formatted localized duration of te given plan (eg. Month/3 => "3 mois")
|
||||
*/
|
||||
const duration = (): string => {
|
||||
return moment.duration(plan.interval_count, plan.interval).humanize();
|
||||
}
|
||||
/**
|
||||
* Check if the user can subscribe to the current plan, for himself
|
||||
*/
|
||||
const canSubscribeForMe = (): boolean => {
|
||||
return operator?.role === UserRole.Member || (operator?.role === UserRole.Manager && userId === operator?.id)
|
||||
}
|
||||
/**
|
||||
* Check if the user can subscribe to the current plan, for someone else
|
||||
*/
|
||||
const canSubscribeForOther = (): boolean => {
|
||||
return operator?.role === UserRole.Admin || (operator?.role === UserRole.Manager && userId !== operator?.id)
|
||||
}
|
||||
/**
|
||||
* Check it the user has subscribed to this plan or not
|
||||
*/
|
||||
const hasSubscribedToThisPlan = (): boolean => {
|
||||
return subscribedPlanId === plan.id;
|
||||
}
|
||||
/**
|
||||
* Check if the plan has an attached file
|
||||
*/
|
||||
const hasAttachment = (): boolean => {
|
||||
return !!plan.plan_file_url;
|
||||
}
|
||||
/**
|
||||
* Check if the plan is allowing a monthly payment schedule
|
||||
*/
|
||||
const canBeScheduled = (): boolean => {
|
||||
return plan.monthly_payment;
|
||||
}
|
||||
/**
|
||||
* Callback triggered when the user select the plan
|
||||
*/
|
||||
const handleSelectPlan = (): void => {
|
||||
onSelectPlan(plan);
|
||||
}
|
||||
return (
|
||||
<div className="plan-card">
|
||||
<h3 className="title">{plan.base_name}</h3>
|
||||
<div className="content">
|
||||
{canBeScheduled() && <div className="wrap-monthly">
|
||||
<div className="price">
|
||||
<div className="amount">{t('app.public.plans.AMOUNT_per_month', {AMOUNT: monthlyAmount()})}</div>
|
||||
<span className="period">{duration()}</span>
|
||||
</div>
|
||||
</div>}
|
||||
{!canBeScheduled() && <div className="wrap">
|
||||
<div className="price">
|
||||
<div className="amount">{amount()}</div>
|
||||
<span className="period">{duration()}</span>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
{canSubscribeForMe() && <div className="cta-button">
|
||||
{!hasSubscribedToThisPlan() && <button className={`subscribe-button ${isSelected ? 'selected-card' : ''}`}
|
||||
onClick={handleSelectPlan}
|
||||
disabled={!_.isNil(subscribedPlanId)}>
|
||||
{userId && <span>{t('app.public.plans.i_choose_that_plan')}</span>}
|
||||
{!userId && <span>{t('app.public.plans.i_subscribe_online')}</span>}
|
||||
</button>}
|
||||
{hasSubscribedToThisPlan() && <button className="subscribe-button selected-card" disabled>
|
||||
{ t('app.public.plans.i_already_subscribed') }
|
||||
</button>}
|
||||
</div>}
|
||||
{canSubscribeForOther() && <div className="cta-button">
|
||||
<button className={`subscribe-button ${isSelected ? 'selected-card' : ''}`}
|
||||
onClick={handleSelectPlan}
|
||||
disabled={_.isNil(userId)}>
|
||||
<span>{ t('app.public.plans.i_choose_that_plan') }</span>
|
||||
</button>
|
||||
</div>}
|
||||
{hasAttachment() && <a className="info-link" href={ plan.plan_file_url } target="_blank">{ t('app.public.plans.more_information') }</a>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PlanCardWrapper: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<PlanCard plan={plan} userId={userId} subscribedPlanId={subscribedPlanId} operator={operator} isSelected={isSelected} onSelectPlan={onSelectPlan}/>
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('planCard', react2angular(PlanCardWrapper, ['plan', 'userId', 'subscribedPlanId', 'operator', 'onSelectPlan', 'isSelected']));
|
44
app/frontend/src/javascript/components/select-schedule.tsx
Normal file
44
app/frontend/src/javascript/components/select-schedule.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* This component is a switch enabling the users to choose if they want to pay by monthly schedule
|
||||
* or with a one time payment
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Switch from 'react-switch';
|
||||
import { IApplication } from '../models/application';
|
||||
import { Loader } from './loader';
|
||||
import '../lib/i18n';
|
||||
|
||||
declare var Application: IApplication;
|
||||
|
||||
interface SelectScheduleProps {
|
||||
show: boolean,
|
||||
selected: boolean,
|
||||
onChange: (selected: boolean) => void,
|
||||
className: string,
|
||||
}
|
||||
|
||||
const SelectSchedule: React.FC<SelectScheduleProps> = ({ show, selected, onChange, className }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
return (
|
||||
<div className="select-schedule">
|
||||
{show && <div className={className}>
|
||||
<label htmlFor="payment_schedule">{ t('app.shared.cart.monthly_payment') }</label>
|
||||
<Switch checked={selected} id="payment_schedule" onChange={onChange} className="schedule-switch" />
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SelectScheduleWrapper: React.FC<SelectScheduleProps> = ({ show, selected, onChange, className }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<SelectSchedule show={show} selected={selected} onChange={onChange} className={className} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('selectSchedule', react2angular(SelectScheduleWrapper, ['show', 'selected', 'onChange', 'className']));
|
101
app/frontend/src/javascript/components/stripe-card-update.tsx
Normal file
101
app/frontend/src/javascript/components/stripe-card-update.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import React, { FormEvent } from 'react';
|
||||
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
|
||||
import { SetupIntent } from "@stripe/stripe-js";
|
||||
import PaymentAPI from '../api/payment';
|
||||
import { PaymentConfirmation } from '../models/payment';
|
||||
import { User } from '../models/user';
|
||||
|
||||
interface StripeCardUpdateProps {
|
||||
onSubmit: () => void,
|
||||
onSuccess: (result: SetupIntent|PaymentConfirmation|any) => void,
|
||||
onError: (message: string) => void,
|
||||
customerId: number,
|
||||
operator: User,
|
||||
className?: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple form component to collect and update the credit card details, for Stripe.
|
||||
*
|
||||
* The form validation button must be created elsewhere, using the attribute form="stripe-card".
|
||||
*/
|
||||
export const StripeCardUpdate: React.FC<StripeCardUpdateProps> = ({ onSubmit, onSuccess, onError, className, customerId, operator, children }) => {
|
||||
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
/**
|
||||
* Handle the submission of the form. Depending on the configuration, it will create the payment method on Stripe,
|
||||
* or it will process a payment with the inputted card.
|
||||
*/
|
||||
const handleSubmit = async (event: FormEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
|
||||
// Stripe.js has not loaded yet
|
||||
if (!stripe || !elements) { return; }
|
||||
|
||||
const cardElement = elements.getElement(CardElement);
|
||||
const { error, paymentMethod } = await stripe.createPaymentMethod({
|
||||
type: 'card',
|
||||
card: cardElement,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
// stripe error
|
||||
onError(error.message);
|
||||
} else {
|
||||
try {
|
||||
// we start by associating the payment method with the user
|
||||
const { client_secret } = await PaymentAPI.setupIntent(customerId);
|
||||
const { error } = await stripe.confirmCardSetup(client_secret, {
|
||||
payment_method: paymentMethod.id,
|
||||
mandate_data: {
|
||||
customer_acceptance: {
|
||||
type: 'online',
|
||||
online: {
|
||||
ip_address: operator.ip_address,
|
||||
user_agent: navigator.userAgent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
if (error) {
|
||||
onError(error.message);
|
||||
} else {
|
||||
// then we update the default payment method
|
||||
const res = await PaymentAPI.updateCard(customerId, paymentMethod.id);
|
||||
onSuccess(res);
|
||||
}
|
||||
} catch (err) {
|
||||
// catch api errors
|
||||
onError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the Stripe's card input
|
||||
*/
|
||||
const cardOptions = {
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#424770',
|
||||
'::placeholder': { color: '#aab7c4' }
|
||||
},
|
||||
invalid: {
|
||||
color: '#9e2146',
|
||||
iconColor: '#9e2146'
|
||||
},
|
||||
},
|
||||
hidePostalCode: true
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} id="stripe-card" className={className}>
|
||||
<CardElement options={cardOptions} />
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
34
app/frontend/src/javascript/components/stripe-confirm.tsx
Normal file
34
app/frontend/src/javascript/components/stripe-confirm.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStripe } from '@stripe/react-stripe-js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface StripeConfirmProps {
|
||||
clientSecret: string,
|
||||
onResponse: () => void,
|
||||
}
|
||||
|
||||
export const StripeConfirm: React.FC<StripeConfirmProps> = ({ clientSecret, onResponse }) => {
|
||||
const stripe = useStripe();
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [message, setMessage] = useState<string>(t('app.shared.stripe_confirm.pending'));
|
||||
const [type, setType] = useState<string>('info');
|
||||
|
||||
useEffect(() => {
|
||||
stripe.confirmCardPayment(clientSecret).then(function(result) {
|
||||
onResponse();
|
||||
if (result.error) {
|
||||
// Display error.message in your UI.
|
||||
setType('error');
|
||||
setMessage(result.error.message);
|
||||
} else {
|
||||
// The setup has succeeded. Display a success message.
|
||||
setType('success');
|
||||
setMessage(t('app.shared.stripe_confirm.success'));
|
||||
}
|
||||
});
|
||||
}, [])
|
||||
return <div className="stripe-confirm">
|
||||
<div className={`message--${type}`}><span className="message-text">{message}</span></div>
|
||||
</div>;
|
||||
}
|
29
app/frontend/src/javascript/components/stripe-elements.tsx
Normal file
29
app/frontend/src/javascript/components/stripe-elements.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* This component initializes the stripe's Elements tag with the API key
|
||||
*/
|
||||
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import SettingAPI from '../api/setting';
|
||||
import { SettingName } from '../models/setting';
|
||||
|
||||
const stripePublicKey = SettingAPI.get(SettingName.StripePublicKey);
|
||||
|
||||
export const StripeElements: React.FC = memo(({ children }) => {
|
||||
const [stripe, setStripe] = useState(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const key = stripePublicKey.read();
|
||||
const promise = loadStripe(key.value);
|
||||
setStripe(promise);
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{stripe && <Elements stripe={stripe}>
|
||||
{children}
|
||||
</Elements>}
|
||||
</div>
|
||||
);
|
||||
})
|
146
app/frontend/src/javascript/components/stripe-form.tsx
Normal file
146
app/frontend/src/javascript/components/stripe-form.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import React, { FormEvent } from 'react';
|
||||
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
|
||||
import { SetupIntent } from "@stripe/stripe-js";
|
||||
import PaymentAPI from '../api/payment';
|
||||
import { CartItems, PaymentConfirmation } from '../models/payment';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { User } from '../models/user';
|
||||
|
||||
interface StripeFormProps {
|
||||
onSubmit: () => void,
|
||||
onSuccess: (result: SetupIntent|PaymentConfirmation|any) => void,
|
||||
onError: (message: string) => void,
|
||||
customer: User,
|
||||
operator: User,
|
||||
className?: string,
|
||||
paymentSchedule?: boolean,
|
||||
cartItems?: CartItems
|
||||
}
|
||||
|
||||
/**
|
||||
* 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="stripe-form".
|
||||
*/
|
||||
export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cartItems, customer, operator }) => {
|
||||
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
/**
|
||||
* Handle the submission of the form. Depending on the configuration, it will create the payment method on Stripe,
|
||||
* or it will process a payment with the inputted card.
|
||||
*/
|
||||
const handleSubmit = async (event: FormEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
|
||||
// Stripe.js has not loaded yet
|
||||
if (!stripe || !elements) { return; }
|
||||
|
||||
const cardElement = elements.getElement(CardElement);
|
||||
const { error, paymentMethod } = await stripe.createPaymentMethod({
|
||||
type: 'card',
|
||||
card: cardElement,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
// stripe error
|
||||
onError(error.message);
|
||||
} else {
|
||||
try {
|
||||
if (!paymentSchedule) {
|
||||
// process the normal payment pipeline, including SCA validation
|
||||
const res = await PaymentAPI.confirm(paymentMethod.id, cartItems);
|
||||
await handleServerConfirmation(res);
|
||||
} else {
|
||||
// we start by associating the payment method with the user
|
||||
const { client_secret } = await PaymentAPI.setupIntent(customer.id);
|
||||
const { setupIntent, error } = await stripe.confirmCardSetup(client_secret, {
|
||||
payment_method: paymentMethod.id,
|
||||
mandate_data: {
|
||||
customer_acceptance: {
|
||||
type: 'online',
|
||||
online: {
|
||||
ip_address: operator.ip_address,
|
||||
user_agent: navigator.userAgent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
if (error) {
|
||||
onError(error.message);
|
||||
} else {
|
||||
// then we confirm the payment schedule
|
||||
const res = await PaymentAPI.confirmPaymentSchedule(setupIntent.id, cartItems);
|
||||
onSuccess(res);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// catch api errors
|
||||
onError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the server response about the Strong-customer authentication (SCA)
|
||||
* @param response can be a PaymentConfirmation, or a Reservation (if the reservation succeeded), or a Subscription (if the subscription succeeded)
|
||||
* @see app/controllers/api/payments_controller.rb#on_reservation_success
|
||||
* @see app/controllers/api/payments_controller.rb#on_subscription_success
|
||||
* @see app/controllers/api/payments_controller.rb#generate_payment_response
|
||||
*/
|
||||
const handleServerConfirmation = async (response: PaymentConfirmation|any) => {
|
||||
if (response.error) {
|
||||
if (response.error.statusText) {
|
||||
onError(response.error.statusText);
|
||||
} else {
|
||||
onError(`${t('app.shared.messages.payment_card_error')} ${response.error}`);
|
||||
}
|
||||
} else if (response.requires_action) {
|
||||
// Use Stripe.js to handle required card action
|
||||
const result = await stripe.handleCardAction(response.payment_intent_client_secret);
|
||||
if (result.error) {
|
||||
onError(result.error.message);
|
||||
} else {
|
||||
// The card action has been handled
|
||||
// The PaymentIntent can be confirmed again on the server
|
||||
try {
|
||||
const confirmation = await PaymentAPI.confirm(result.paymentIntent.id, cartItems);
|
||||
await handleServerConfirmation(confirmation);
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onSuccess(response);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Options for the Stripe's card input
|
||||
*/
|
||||
const cardOptions = {
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#424770',
|
||||
'::placeholder': { color: '#aab7c4' }
|
||||
},
|
||||
invalid: {
|
||||
color: '#9e2146',
|
||||
iconColor: '#9e2146'
|
||||
},
|
||||
},
|
||||
hidePostalCode: true
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} id="stripe-form" className={className}>
|
||||
<CardElement options={cardOptions} />
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
225
app/frontend/src/javascript/components/stripe-modal.tsx
Normal file
225
app/frontend/src/javascript/components/stripe-modal.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
/**
|
||||
* This component enables the user to input his card data or process payments.
|
||||
* Supports Strong-Customer Authentication (SCA).
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from './loader';
|
||||
import { IApplication } from '../models/application';
|
||||
import { StripeElements } from './stripe-elements';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabModal, ModalSize } from './fab-modal';
|
||||
import { SetupIntent } from '@stripe/stripe-js';
|
||||
import { WalletInfo } from './wallet-info';
|
||||
import { User } from '../models/user';
|
||||
import CustomAssetAPI from '../api/custom-asset';
|
||||
import { CustomAssetName } from '../models/custom-asset';
|
||||
import { PaymentSchedule } from '../models/payment-schedule';
|
||||
import { IFablab } from '../models/fablab';
|
||||
import WalletLib from '../lib/wallet';
|
||||
import { StripeForm } from './stripe-form';
|
||||
|
||||
import stripeLogo from '../../../images/powered_by_stripe.png';
|
||||
import mastercardLogo from '../../../images/mastercard.png';
|
||||
import visaLogo from '../../../images/visa.png';
|
||||
import { CartItems, PaymentConfirmation } from '../models/payment';
|
||||
import WalletAPI from '../api/wallet';
|
||||
import PriceAPI from '../api/price';
|
||||
import { HtmlTranslate } from './html-translate';
|
||||
|
||||
declare var Application: IApplication;
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
interface StripeModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
afterSuccess: (result: SetupIntent|PaymentConfirmation) => void,
|
||||
cartItems: CartItems,
|
||||
currentUser: User,
|
||||
schedule: PaymentSchedule,
|
||||
customer: User
|
||||
}
|
||||
|
||||
const cgvFile = CustomAssetAPI.get(CustomAssetName.CgvFile);
|
||||
|
||||
const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, cartItems, currentUser, schedule, customer }) => {
|
||||
// customer's wallet
|
||||
const [wallet, setWallet] = useState(null);
|
||||
// server-computed price with all details
|
||||
const [price, setPrice] = useState(null);
|
||||
// remaining price = total price - wallet amount
|
||||
const [remainingPrice, setRemainingPrice] = useState(0);
|
||||
// is the component ready to display?
|
||||
const [ready, setReady] = useState(false);
|
||||
// errors to display in the UI (stripe errors mainly)
|
||||
const [errors, setErrors] = useState(null);
|
||||
// are we currently processing the payment (ie. the form was submit, but the process is still running)?
|
||||
const [submitState, setSubmitState] = useState(false);
|
||||
// did the user accepts the terms of services (CGV)?
|
||||
const [tos, setTos] = useState(false);
|
||||
|
||||
const { t } = useTranslation('shared');
|
||||
const cgv = cgvFile.read();
|
||||
|
||||
|
||||
/**
|
||||
* On each display:
|
||||
* - Refresh the wallet
|
||||
* - Refresh the price
|
||||
* - Refresh the remaining price
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!cartItems) return;
|
||||
WalletAPI.getByUser(cartItems.reservation?.user_id || cartItems.subscription?.user_id).then((wallet) => {
|
||||
setWallet(wallet);
|
||||
PriceAPI.compute(cartItems).then((res) => {
|
||||
setPrice(res);
|
||||
const wLib = new WalletLib(wallet);
|
||||
setRemainingPrice(wLib.computeRemainingPrice(res.price));
|
||||
setReady(true);
|
||||
})
|
||||
})
|
||||
}, [cartItems]);
|
||||
|
||||
/**
|
||||
* Check if there is currently an error to display
|
||||
*/
|
||||
const hasErrors = (): boolean => {
|
||||
return errors !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user accepts the Terms of Sales document
|
||||
*/
|
||||
const hasCgv = (): boolean => {
|
||||
return cgv != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when the user accepts or declines the Terms of Sales
|
||||
*/
|
||||
const toggleTos = (): void => {
|
||||
setTos(!tos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we are currently creating a payment schedule
|
||||
*/
|
||||
const isPaymentSchedule = (): boolean => {
|
||||
return schedule !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
|
||||
*/
|
||||
const formatPrice = (amount: number): string => {
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the logos, shown in the modal footer.
|
||||
*/
|
||||
const logoFooter = (): ReactNode => {
|
||||
return (
|
||||
<div className="stripe-modal-icons">
|
||||
<i className="fa fa-lock fa-2x m-r-sm pos-rlt" />
|
||||
<img src={stripeLogo} alt="powered by stripe" />
|
||||
<img src={mastercardLogo} alt="mastercard" />
|
||||
<img src={visaLogo} alt="visa" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the component as 'currently submitting'
|
||||
*/
|
||||
const handleSubmit = (): void => {
|
||||
setSubmitState(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* After sending the form with success, process the resulting payment method
|
||||
*/
|
||||
const handleFormSuccess = async (result: SetupIntent|PaymentConfirmation|any): Promise<void> => {
|
||||
setSubmitState(false);
|
||||
afterSuccess(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* When stripe-form raise an error, it is handled by this callback which display it in the modal.
|
||||
*/
|
||||
const handleFormError = (message: string): void => {
|
||||
setSubmitState(false);
|
||||
setErrors(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the form can be submitted.
|
||||
* => We're not currently already submitting the form, and, if the terms of service are enabled, the user agrees with them.
|
||||
*/
|
||||
const canSubmit = (): boolean => {
|
||||
let terms = true;
|
||||
if (hasCgv()) { terms = tos; }
|
||||
return !submitState && terms;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<FabModal title={t('app.shared.stripe.online_payment')}
|
||||
isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
width={ModalSize.medium}
|
||||
closeButton={false}
|
||||
customFooter={logoFooter()}
|
||||
className="stripe-modal">
|
||||
{ready && <StripeElements>
|
||||
<WalletInfo cartItems={cartItems} currentUser={currentUser} wallet={wallet} price={price?.price} />
|
||||
<StripeForm onSubmit={handleSubmit}
|
||||
onSuccess={handleFormSuccess}
|
||||
onError={handleFormError}
|
||||
operator={currentUser}
|
||||
className="stripe-form"
|
||||
cartItems={cartItems}
|
||||
customer={customer}
|
||||
paymentSchedule={isPaymentSchedule()}>
|
||||
{hasErrors() && <div className="stripe-errors">
|
||||
{errors}
|
||||
</div>}
|
||||
{isPaymentSchedule() && <div className="payment-schedule-info">
|
||||
<HtmlTranslate trKey="app.shared.stripe.payment_schedule_html" options={{ DEADLINES: schedule.items.length }} />
|
||||
</div>}
|
||||
{hasCgv() && <div className="terms-of-sales">
|
||||
<input type="checkbox" id="acceptToS" name="acceptCondition" checked={tos} onChange={toggleTos} required />
|
||||
<label htmlFor="acceptToS">{ t('app.shared.stripe.i_have_read_and_accept_') }
|
||||
<a href={cgv.custom_asset_file_attributes.attachment_url} target="_blank">
|
||||
{ t('app.shared.stripe._the_general_terms_and_conditions') }
|
||||
</a>
|
||||
</label>
|
||||
</div>}
|
||||
</StripeForm>
|
||||
{!submitState && <button type="submit"
|
||||
disabled={!canSubmit()}
|
||||
form="stripe-form"
|
||||
className="validate-btn">
|
||||
{t('app.shared.stripe.confirm_payment_of_', { AMOUNT: formatPrice(remainingPrice) })}
|
||||
</button>}
|
||||
{submitState && <div className="payment-pending">
|
||||
<div className="fa-2x">
|
||||
<i className="fas fa-circle-notch fa-spin" />
|
||||
</div>
|
||||
</div>}
|
||||
</StripeElements>}
|
||||
</FabModal>
|
||||
);
|
||||
}
|
||||
|
||||
const StripeModalWrapper: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, currentUser, schedule , cartItems, customer }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<StripeModal isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} currentUser={currentUser} schedule={schedule} cartItems={cartItems} customer={customer} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('stripeModal', react2angular(StripeModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess','currentUser', 'schedule', 'cartItems', 'customer']));
|
@ -1,4 +0,0 @@
|
||||
import Switch from 'react-switch';
|
||||
import { react2angular } from 'react2angular';
|
||||
|
||||
Application.Components.component('switch', react2angular(Switch, ['checked', 'onChange', 'id', 'className']));
|
139
app/frontend/src/javascript/components/wallet-info.tsx
Normal file
139
app/frontend/src/javascript/components/wallet-info.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* This component displays a summary of the amount paid with the virtual wallet, for the current transaction
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { IApplication } from '../models/application';
|
||||
import '../lib/i18n';
|
||||
import { Loader } from './loader';
|
||||
import { User } from '../models/user';
|
||||
import { Wallet } from '../models/wallet';
|
||||
import { IFablab } from '../models/fablab';
|
||||
import WalletLib from '../lib/wallet';
|
||||
import { CartItems } from '../models/payment';
|
||||
import { Reservation } from '../models/reservation';
|
||||
import { SubscriptionRequest } from '../models/subscription';
|
||||
|
||||
declare var Application: IApplication;
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
interface WalletInfoProps {
|
||||
cartItems: CartItems,
|
||||
currentUser: User,
|
||||
wallet: Wallet,
|
||||
price: number,
|
||||
}
|
||||
|
||||
export const WalletInfo: React.FC<WalletInfoProps> = ({ cartItems, currentUser, wallet, price }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
const [remainingPrice, setRemainingPrice] = useState(0);
|
||||
|
||||
/**
|
||||
* Refresh the remaining price on each display
|
||||
*/
|
||||
useEffect(() => {
|
||||
const wLib = new WalletLib(wallet);
|
||||
setRemainingPrice(wLib.computeRemainingPrice(price));
|
||||
});
|
||||
|
||||
/**
|
||||
* Return the formatted localized amount for the given price (e.g. 20.5 => "20,50 €")
|
||||
*/
|
||||
const formatPrice = (price: number): string => {
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price);
|
||||
}
|
||||
/**
|
||||
* Check if the currently connected used is also the person making the reservation.
|
||||
* If the currently connected user (i.e. the operator), is an admin or a manager, he may book the reservation for someone else.
|
||||
*/
|
||||
const isOperatorAndClient = (): boolean => {
|
||||
return currentUser.id == buyingItem().user_id;
|
||||
}
|
||||
/**
|
||||
* Return the item currently bought (reservation or subscription)
|
||||
*/
|
||||
const buyingItem = (): Reservation|SubscriptionRequest => {
|
||||
return cartItems.reservation || cartItems.subscription;
|
||||
}
|
||||
/**
|
||||
* If the client has some money in his wallet & the price is not zero, then we should display this component.
|
||||
*/
|
||||
const shouldBeShown = (): boolean => {
|
||||
return wallet.amount > 0 && price > 0;
|
||||
}
|
||||
/**
|
||||
* If the amount in the wallet is not enough to cover the whole price, then the user must pay the remaining price
|
||||
* using another payment mean.
|
||||
*/
|
||||
const hasRemainingPrice = (): boolean => {
|
||||
return remainingPrice > 0;
|
||||
}
|
||||
/**
|
||||
* Does the current cart contains a payment schedule?
|
||||
*/
|
||||
const isPaymentSchedule = (): boolean => {
|
||||
return buyingItem().plan_id && buyingItem().payment_schedule;
|
||||
}
|
||||
/**
|
||||
* Return the human-readable name of the item currently bought with the wallet
|
||||
*/
|
||||
const getPriceItem = (): string => {
|
||||
let item = 'other';
|
||||
if (cartItems.reservation) {
|
||||
item = 'reservation';
|
||||
} else if (cartItems.subscription) {
|
||||
if (cartItems.subscription.payment_schedule) {
|
||||
item = 'first_deadline';
|
||||
} else item = 'subscription';
|
||||
}
|
||||
|
||||
return t(`app.shared.wallet.wallet_info.item_${item}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wallet-info">
|
||||
{shouldBeShown() && <div>
|
||||
{isOperatorAndClient() && <div>
|
||||
<h3>{t('app.shared.wallet.wallet_info.you_have_AMOUNT_in_wallet', {AMOUNT: formatPrice(wallet.amount)})}</h3>
|
||||
{!hasRemainingPrice() && <p>
|
||||
{t('app.shared.wallet.wallet_info.wallet_pay_ITEM', {ITEM: getPriceItem()})}
|
||||
</p>}
|
||||
{hasRemainingPrice() && <p>
|
||||
{t('app.shared.wallet.wallet_info.credit_AMOUNT_for_pay_ITEM', {
|
||||
AMOUNT: formatPrice(remainingPrice),
|
||||
ITEM: getPriceItem()
|
||||
})}
|
||||
</p>}
|
||||
</div>}
|
||||
{!isOperatorAndClient() && <div>
|
||||
<h3>{t('app.shared.wallet.wallet_info.client_have_AMOUNT_in_wallet', {AMOUNT: formatPrice(wallet.amount)})}</h3>
|
||||
{!hasRemainingPrice() && <p>
|
||||
{t('app.shared.wallet.wallet_info.client_wallet_pay_ITEM', {ITEM: getPriceItem()})}
|
||||
</p>}
|
||||
{hasRemainingPrice() && <p>
|
||||
{t('app.shared.wallet.wallet_info.client_credit_AMOUNT_for_pay_ITEM', {
|
||||
AMOUNT: formatPrice(remainingPrice),
|
||||
ITEM: getPriceItem()
|
||||
})}
|
||||
</p>}
|
||||
</div>}
|
||||
{!hasRemainingPrice() && isPaymentSchedule() && <p className="info-deadlines">
|
||||
<i className="fa fa-warning"/>
|
||||
<span>{t('app.shared.wallet.wallet_info.other_deadlines_no_wallet')}</span>
|
||||
</p>}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WalletInfoWrapper: React.FC<WalletInfoProps> = ({ currentUser, cartItems, price, wallet }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<WalletInfo currentUser={currentUser} cartItems={cartItems} price={price} wallet={wallet}/>
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('walletInfo', react2angular(WalletInfoWrapper, ['currentUser', 'price', 'cartItems', 'wallet']));
|
@ -260,6 +260,8 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
sample = sample.replace(/W\[([^\]]+)\]/g, '');
|
||||
// information about refunds (R[text]) - does not apply here
|
||||
sample = sample.replace(/R\[([^\]]+)\]/g, '');
|
||||
// information about payment schedules (S[text]) -does not apply here
|
||||
sample = sample.replace(/S\[([^\]]+)\]/g, '');
|
||||
}
|
||||
return sample;
|
||||
};
|
||||
@ -733,11 +735,21 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
placement: 'left'
|
||||
});
|
||||
}
|
||||
if (settings.invoicing_module === 'true') {
|
||||
uitour.createStep({
|
||||
selector: '.invoices-management .payment-schedules-list',
|
||||
stepId: 'payment-schedules',
|
||||
order: 5,
|
||||
title: _t('app.admin.tour.invoices.payment-schedules.title'),
|
||||
content: _t('app.admin.tour.invoices.payment-schedules.content'),
|
||||
placement: 'bottom'
|
||||
});
|
||||
}
|
||||
if (AuthService.isAuthorized('admin')) {
|
||||
uitour.createStep({
|
||||
selector: '.invoices-management .invoices-settings',
|
||||
stepId: 'settings',
|
||||
order: 5,
|
||||
order: 6,
|
||||
title: _t('app.admin.tour.invoices.settings.title'),
|
||||
content: _t('app.admin.tour.invoices.settings.content'),
|
||||
placement: 'bottom'
|
||||
@ -745,7 +757,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
uitour.createStep({
|
||||
selector: '.invoices-management .accounting-codes-tab',
|
||||
stepId: 'codes',
|
||||
order: 6,
|
||||
order: 7,
|
||||
title: _t('app.admin.tour.invoices.codes.title'),
|
||||
content: _t('app.admin.tour.invoices.codes.content'),
|
||||
placement: 'bottom'
|
||||
@ -753,7 +765,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
uitour.createStep({
|
||||
selector: '.heading .export-accounting-button',
|
||||
stepId: 'export',
|
||||
order: 7,
|
||||
order: 8,
|
||||
title: _t('app.admin.tour.invoices.export.title'),
|
||||
content: _t('app.admin.tour.invoices.export.content'),
|
||||
placement: 'bottom'
|
||||
@ -761,7 +773,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
uitour.createStep({
|
||||
selector: '.invoices-management .payment-settings',
|
||||
stepId: 'payment',
|
||||
order: 8,
|
||||
order: 9,
|
||||
title: _t('app.admin.tour.invoices.payment.title'),
|
||||
content: _t('app.admin.tour.invoices.payment.content'),
|
||||
placement: 'bottom',
|
||||
@ -770,7 +782,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
uitour.createStep({
|
||||
selector: '.heading .close-accounting-periods-button',
|
||||
stepId: 'periods',
|
||||
order: 9,
|
||||
order: 10,
|
||||
title: _t('app.admin.tour.invoices.periods.title'),
|
||||
content: _t('app.admin.tour.invoices.periods.content'),
|
||||
placement: 'bottom',
|
||||
@ -780,7 +792,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
uitour.createStep({
|
||||
selector: 'body',
|
||||
stepId: 'conclusion',
|
||||
order: 10,
|
||||
order: 11,
|
||||
title: _t('app.admin.tour.conclusion.title'),
|
||||
content: _t('app.admin.tour.conclusion.content'),
|
||||
placement: 'bottom',
|
||||
@ -788,7 +800,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
});
|
||||
// on step change, change the active tab if needed
|
||||
uitour.on('stepChanged', function (nextStep) {
|
||||
if (nextStep.stepId === 'list' || nextStep.stepId === 'settings') {
|
||||
if (nextStep.stepId === 'list' || nextStep.stepId === 'refund') {
|
||||
$scope.tabs.active = 0;
|
||||
}
|
||||
if (nextStep.stepId === 'settings') {
|
||||
@ -800,6 +812,9 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
if (nextStep.stepId === 'payment') {
|
||||
$scope.tabs.active = 3;
|
||||
}
|
||||
if (nextStep.stepId === 'payment-schedules') {
|
||||
$scope.tabs.active = 4;
|
||||
}
|
||||
});
|
||||
// on tour end, save the status in database
|
||||
uitour.on('ended', function () {
|
||||
|
@ -37,7 +37,7 @@
|
||||
*/
|
||||
class MembersController {
|
||||
constructor ($scope, $state, Group, Training) {
|
||||
// Retrieve the profiles groups (eg. students ...)
|
||||
// Retrieve the profiles groups (e.g. students ...)
|
||||
Group.query(function (groups) { $scope.groups = groups.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }); });
|
||||
|
||||
// Retrieve the list of available trainings
|
||||
@ -62,7 +62,7 @@ class MembersController {
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the birth day datepicker
|
||||
* Shows the birthday datepicker
|
||||
* @param $event {Object} jQuery event object
|
||||
*/
|
||||
$scope.openDatePicker = function ($event) {
|
||||
@ -85,7 +85,7 @@ class MembersController {
|
||||
* For use with ngUpload (https://github.com/twilson63/ngUpload).
|
||||
* Intended to be the callback when an upload is done: any raised error will be stacked in the
|
||||
* $scope.alerts array. If everything goes fine, the user is redirected to the members listing page.
|
||||
* @param content {Object} JSON - The upload's result
|
||||
* @param content {Object} JSON - The result of the upload
|
||||
*/
|
||||
$scope.submited = function (content) {
|
||||
if ((content.id == null)) {
|
||||
@ -110,7 +110,7 @@ class MembersController {
|
||||
|
||||
/**
|
||||
* For use with 'ng-class', returns the CSS class name for the uploads previews.
|
||||
* The preview may show a placeholder or the content of the file depending on the upload state.
|
||||
* The preview may show a placeholder, or the content of the file depending on the upload state.
|
||||
* @param v {*} any attribute, will be tested for truthiness (see JS evaluation rules)
|
||||
*/
|
||||
$scope.fileinputClass = function (v) {
|
||||
@ -143,7 +143,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
searchText: '',
|
||||
// Members ordering/sorting. Default: not sorted
|
||||
order: 'id',
|
||||
// currently displayed page of members
|
||||
// the currently displayed page of members
|
||||
page: 1,
|
||||
// true when all members where loaded
|
||||
noMore: false,
|
||||
@ -158,7 +158,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
};
|
||||
|
||||
// admins list
|
||||
$scope.admins = adminsPromise.admins.filter(function(m) { return m.id != Fablab.superadminId; });
|
||||
$scope.admins = adminsPromise.admins.filter(function (m) { return m.id !== Fablab.superadminId; });
|
||||
|
||||
// Admins ordering/sorting. Default: not sorted
|
||||
$scope.orderAdmin = null;
|
||||
@ -210,7 +210,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
* @param orderPartner {string} ordering criterion
|
||||
*/
|
||||
$scope.setOrderPartner = function (orderPartner) {
|
||||
if ($scope.orderPartner === orderPartner) {
|
||||
if ($scope.orderPartner === orderPartner) {
|
||||
return $scope.orderPartner = `-${orderPartner}`;
|
||||
} else {
|
||||
return $scope.orderPartner = orderPartner;
|
||||
@ -229,7 +229,6 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Open a modal dialog allowing the admin to create a new partner user
|
||||
*/
|
||||
@ -265,12 +264,11 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Ask for confirmation then delete the specified user
|
||||
* @param memberId {number} identifier of the user to delete
|
||||
*/
|
||||
$scope.deleteMember = function(memberId) {
|
||||
$scope.deleteMember = function (memberId) {
|
||||
dialogs.confirm(
|
||||
{
|
||||
resolve: {
|
||||
@ -289,11 +287,14 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
$scope.members.splice(findItemIdxById($scope.members, memberId), 1);
|
||||
return growl.success(_t('app.admin.members.member_successfully_deleted'));
|
||||
},
|
||||
function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_member')); }
|
||||
function (error) {
|
||||
growl.error(_t('app.admin.members.unable_to_delete_the_member'));
|
||||
console.error(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask for confirmation then delete the specified administrator
|
||||
@ -319,7 +320,10 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
admins.splice(findItemIdxById(admins, admin.id), 1);
|
||||
return growl.success(_t('app.admin.members.administrator_successfully_deleted'));
|
||||
},
|
||||
function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_administrator')); }
|
||||
function (error) {
|
||||
growl.error(_t('app.admin.members.unable_to_delete_the_administrator'));
|
||||
console.error(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
@ -349,11 +353,14 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
partners.splice(findItemIdxById(partners, partner.id), 1);
|
||||
return growl.success(_t('app.admin.members.partner_successfully_deleted'));
|
||||
},
|
||||
function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_partner')); }
|
||||
function (error) {
|
||||
growl.error(_t('app.admin.members.unable_to_delete_the_partner'));
|
||||
console.error(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask for confirmation then delete the specified manager
|
||||
@ -379,11 +386,14 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
managers.splice(findItemIdxById(managers, manager.id), 1);
|
||||
return growl.success(_t('app.admin.members.manager_successfully_deleted'));
|
||||
},
|
||||
function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_manager')); }
|
||||
function (error) {
|
||||
growl.error(_t('app.admin.members.unable_to_delete_the_manager'));
|
||||
console.error(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback for the 'load more' button.
|
||||
@ -399,7 +409,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
*/
|
||||
$scope.updateTextSearch = function () {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(function() {
|
||||
searchTimeout = setTimeout(function () {
|
||||
resetSearchMember();
|
||||
memberSearch();
|
||||
}, 300);
|
||||
@ -425,9 +435,8 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Setup the feature-tour for the admin/members page.
|
||||
* Set up the feature-tour for the admin/members page.
|
||||
* This is intended as a contextual help (when pressing F1)
|
||||
*/
|
||||
$scope.setupMembersTour = function () {
|
||||
@ -570,7 +579,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('members') < 0) {
|
||||
uitour.start();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
@ -586,22 +595,22 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
/**
|
||||
* Will temporize the search query to prevent overloading the API
|
||||
*/
|
||||
var searchTimeout = null;
|
||||
let searchTimeout = null;
|
||||
|
||||
/**
|
||||
* Iterate through the provided array and return the index of the requested item
|
||||
* @param items {Array} full list of users with role 'admin'
|
||||
* @param items {Array} full list of users with the 'admin' role
|
||||
* @param id {Number} id of the item to retrieve in the list
|
||||
* @returns {Number} index of the requested item, in the provided array
|
||||
*/
|
||||
var findItemIdxById = function (items, id) {
|
||||
const findItemIdxById = function (items, id) {
|
||||
return (items.map(function (item) { return item.id; })).indexOf(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reinitialize the context of members's search to display new results set
|
||||
* Reinitialize the context of the search to display new results set
|
||||
*/
|
||||
var resetSearchMember = function () {
|
||||
const resetSearchMember = function () {
|
||||
$scope.member.noMore = false;
|
||||
$scope.member.page = 1;
|
||||
};
|
||||
@ -609,9 +618,9 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
|
||||
/**
|
||||
* Run a search query with the current parameters set ($scope.member[searchText,order,page])
|
||||
* and affect or append the result in $scope.members, depending on the concat parameter
|
||||
* @param [concat] {boolean} if true, the result will be append to $scope.members instead of being affected
|
||||
* @param [concat] {boolean} if true, the result will be appended to $scope.members instead of being replaced
|
||||
*/
|
||||
var memberSearch = function (concat) {
|
||||
const memberSearch = function (concat) {
|
||||
Member.list({
|
||||
query: {
|
||||
search: $scope.member.searchText,
|
||||
@ -666,7 +675,6 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
|
||||
// the user subscription
|
||||
if (($scope.user.subscribed_plan != null) && ($scope.user.subscription != null)) {
|
||||
$scope.subscription = $scope.user.subscription;
|
||||
$scope.subscription.expired_at = $scope.subscription.expired_at;
|
||||
} else {
|
||||
Plan.query({ group_id: $scope.user.group_id }, function (plans) {
|
||||
$scope.plans = plans;
|
||||
@ -696,16 +704,15 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
|
||||
|
||||
/**
|
||||
* Open a modal dialog asking for confirmation to change the role of the given user
|
||||
* @param userId {number} id of the user to "promote"
|
||||
* @returns {*}
|
||||
*/
|
||||
$scope.changeUserRole = function() {
|
||||
$scope.changeUserRole = function () {
|
||||
const modalInstance = $uibModal.open({
|
||||
animation: true,
|
||||
templateUrl: '/admin/members/change_role_modal.html',
|
||||
size: 'lg',
|
||||
resolve: {
|
||||
user() { return $scope.user; }
|
||||
user () { return $scope.user; }
|
||||
},
|
||||
controller: ['$scope', '$uibModalInstance', 'Member', 'user', '_t', function ($scope, $uibModalInstance, Member, user, _t) {
|
||||
$scope.user = user;
|
||||
@ -715,7 +722,7 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
|
||||
$scope.roles = [
|
||||
{ key: 'admin', label: _t('app.admin.members_edit.admin') },
|
||||
{ key: 'manager', label: _t('app.admin.members_edit.manager'), notAnOption: (user.role === 'admin') },
|
||||
{ key: 'member', label: _t('app.admin.members_edit.member'), notAnOption: (user.role === 'admin' || user.role === 'manager') },
|
||||
{ key: 'member', label: _t('app.admin.members_edit.member'), notAnOption: (user.role === 'admin' || user.role === 'manager') }
|
||||
];
|
||||
|
||||
$scope.ok = function () {
|
||||
@ -740,7 +747,7 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
|
||||
return modalInstance.result.then(function (user) {
|
||||
// remove the user for the old list add to the new
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a modal dialog, allowing the admin to extend the current user's subscription (freely or not)
|
||||
@ -778,7 +785,10 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
|
||||
growl.success(_t('app.admin.members_edit.you_successfully_changed_the_expiration_date_of_the_user_s_subscription'));
|
||||
return $uibModalInstance.close(_subscription);
|
||||
},
|
||||
function (error) { growl.error(_t('app.admin.members_edit.a_problem_occurred_while_saving_the_date')); }
|
||||
function (error) {
|
||||
growl.error(_t('app.admin.members_edit.a_problem_occurred_while_saving_the_date'));
|
||||
console.error(error);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -792,30 +802,59 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
|
||||
/**
|
||||
* Open a modal dialog allowing the admin to set a subscription for the given user.
|
||||
* @param user {Object} User object, user currently reviewed, as recovered from GET /api/members/:id
|
||||
* @param plans {Array} List of plans, availables for the currently reviewed user, as recovered from GET /api/plans
|
||||
* @param plans {Array} List of plans, available for the currently reviewed user, as recovered from GET /api/plans
|
||||
*/
|
||||
$scope.createSubscriptionModal = function (user, plans) {
|
||||
const modalInstance = $uibModal.open({
|
||||
animation: true,
|
||||
templateUrl: '/admin/subscriptions/create_modal.html',
|
||||
size: 'lg',
|
||||
controller: ['$scope', '$uibModalInstance', 'Subscription', 'Group', function ($scope, $uibModalInstance, Subscription, Group) {
|
||||
// selected user
|
||||
controller: ['$scope', '$uibModalInstance', 'Subscription', function ($scope, $uibModalInstance, Subscription) {
|
||||
// selected user
|
||||
$scope.user = user;
|
||||
|
||||
// available plans for the selected user
|
||||
$scope.plans = plans;
|
||||
|
||||
// default parameters for the new subscription
|
||||
$scope.subscription = {
|
||||
payment_schedule: false,
|
||||
payment_method: 'check'
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a string identifying the given plan by literal human-readable name
|
||||
* @param plan {Object} Plan object, as recovered from GET /api/plan/:id
|
||||
* @param groups {Array} List of Groups objects, as recovered from GET /api/groups
|
||||
* @param short {boolean} If true, the generated name will contains the group slug, otherwise the group full name
|
||||
* @param short {boolean} If true, the generated name will contain the group slug, otherwise the group full name
|
||||
* will be included.
|
||||
* @returns {String}
|
||||
*/
|
||||
$scope.humanReadablePlanName = function (plan, groups, short) { return `${$filter('humanReadablePlanName')(plan, groups, short)}`; };
|
||||
|
||||
/**
|
||||
* Check if the currently selected plan can be paid with a payment schedule or not
|
||||
* @return {boolean}
|
||||
*/
|
||||
$scope.allowMonthlySchedule = function () {
|
||||
if (!$scope.subscription) return false;
|
||||
|
||||
const plan = plans.find(p => p.id === $scope.subscription.plan_id);
|
||||
return plan && plan.monthly_payment;
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggered by the <switch> component.
|
||||
* We must use a setTimeout to workaround the react integration.
|
||||
* @param checked {Boolean}
|
||||
*/
|
||||
$scope.toggleSchedule = function (checked) {
|
||||
setTimeout(() => {
|
||||
$scope.subscription.payment_schedule = checked;
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal dialog validation callback
|
||||
*/
|
||||
@ -902,8 +941,9 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
|
||||
*/
|
||||
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
|
||||
}
|
||||
] });
|
||||
// once the form was validated succesfully ...
|
||||
]
|
||||
});
|
||||
// once the form was validated successfully...
|
||||
return modalInstance.result.then(function (wallet) {
|
||||
$scope.wallet = wallet;
|
||||
return Wallet.transactions({ id: wallet.id }, function (transactions) { $scope.transactions = transactions; });
|
||||
@ -923,13 +963,12 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
|
||||
const initialize = function () {
|
||||
CSRF.setMetaTags();
|
||||
|
||||
// init the birth date to JS object
|
||||
// init the birthdate to JS object
|
||||
$scope.user.statistic_profile.birthday = moment($scope.user.statistic_profile.birthday).toDate();
|
||||
|
||||
// the user subscription
|
||||
if (($scope.user.subscribed_plan != null) && ($scope.user.subscription != null)) {
|
||||
$scope.subscription = $scope.user.subscription;
|
||||
$scope.subscription.expired_at = $scope.subscription.expired_at;
|
||||
} else {
|
||||
Plan.query({ group_id: $scope.user.group_id }, function (plans) {
|
||||
$scope.plans = plans;
|
||||
@ -996,7 +1035,7 @@ Application.Controllers.controller('NewMemberController', ['$scope', '$state', '
|
||||
* Controller used in the member's import page: import from CSV (admin view)
|
||||
*/
|
||||
Application.Controllers.controller('ImportMembersController', ['$scope', '$state', 'Group', 'Training', 'CSRF', 'tags', 'growl',
|
||||
function($scope, $state, Group, Training, CSRF, tags, growl) {
|
||||
function ($scope, $state, Group, Training, CSRF, tags, growl) {
|
||||
CSRF.setMetaTags();
|
||||
|
||||
/* PUBLIC SCOPE */
|
||||
@ -1008,19 +1047,19 @@ Application.Controllers.controller('ImportMembersController', ['$scope', '$state
|
||||
$scope.method = 'post';
|
||||
|
||||
// List of all tags
|
||||
$scope.tags = tags
|
||||
$scope.tags = tags;
|
||||
|
||||
/*
|
||||
* Callback run after the form was submitted
|
||||
* @param content {*} The result provided by the server, may be an Import object or an error message
|
||||
* @param content {*} The result provided by the server, may be an Import object, or an error message
|
||||
*/
|
||||
$scope.onImportResult = function(content) {
|
||||
$scope.onImportResult = function (content) {
|
||||
if (content.id) {
|
||||
$state.go('app.admin.members_import_result', { id: content.id });
|
||||
} else {
|
||||
growl.error(JSON.stringify(content));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Using the MembersController
|
||||
return new MembersController($scope, $state, Group, Training);
|
||||
@ -1041,7 +1080,7 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', '
|
||||
$scope.results = null;
|
||||
|
||||
/**
|
||||
* Changes the admin's view to the members import page
|
||||
* Changes the view of the admin to the members import page
|
||||
*/
|
||||
$scope.cancel = function () { $state.go('app.admin.members_import'); };
|
||||
|
||||
@ -1053,8 +1092,8 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', '
|
||||
const initialize = function () {
|
||||
$scope.results = JSON.parse($scope.import.results);
|
||||
if (!$scope.results) {
|
||||
setTimeout(function() {
|
||||
Import.get({ id: $scope.import.id }, function(data) {
|
||||
setTimeout(function () {
|
||||
Import.get({ id: $scope.import.id }, function (data) {
|
||||
$scope.import = data;
|
||||
initialize();
|
||||
});
|
||||
@ -1068,69 +1107,68 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', '
|
||||
]);
|
||||
|
||||
/**
|
||||
* Controller used in the admin's creation page (admin view)
|
||||
* Controller used in the admin creation page (admin view)
|
||||
*/
|
||||
Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'Admin', 'growl', '_t', 'phoneRequiredPromise',
|
||||
function ($state, $scope, Admin, growl, _t, phoneRequiredPromise) {
|
||||
// default admin profile
|
||||
let getGender;
|
||||
$scope.admin = {
|
||||
statistic_profile_attributes: {
|
||||
gender: true
|
||||
},
|
||||
profile_attributes: {},
|
||||
invoicing_profile_attributes: {}
|
||||
};
|
||||
let getGender;
|
||||
$scope.admin = {
|
||||
statistic_profile_attributes: {
|
||||
gender: true
|
||||
},
|
||||
profile_attributes: {},
|
||||
invoicing_profile_attributes: {}
|
||||
};
|
||||
|
||||
// Default parameters for AngularUI-Bootstrap datepicker
|
||||
$scope.datePicker = {
|
||||
format: Fablab.uibDateFormat,
|
||||
opened: false,
|
||||
options: {
|
||||
startingDay: Fablab.weekStartingDay
|
||||
}
|
||||
};
|
||||
// Default parameters for AngularUI-Bootstrap datepicker
|
||||
$scope.datePicker = {
|
||||
format: Fablab.uibDateFormat,
|
||||
opened: false,
|
||||
options: {
|
||||
startingDay: Fablab.weekStartingDay
|
||||
}
|
||||
};
|
||||
|
||||
// is the phone number required in _admin_form?
|
||||
$scope.phoneRequired = (phoneRequiredPromise.setting.value === 'true');
|
||||
// is the phone number required in _admin_form?
|
||||
$scope.phoneRequired = (phoneRequiredPromise.setting.value === 'true');
|
||||
|
||||
/**
|
||||
* Shows the birth day datepicker
|
||||
* @param $event {Object} jQuery event object
|
||||
/**
|
||||
* Shows the birthday datepicker
|
||||
*/
|
||||
$scope.openDatePicker = function ($event) { $scope.datePicker.opened = true; };
|
||||
$scope.openDatePicker = function () { $scope.datePicker.opened = true; };
|
||||
|
||||
/**
|
||||
/**
|
||||
* Send the new admin, currently stored in $scope.admin, to the server for database saving
|
||||
*/
|
||||
$scope.saveAdmin = function () {
|
||||
Admin.save(
|
||||
{},
|
||||
{ admin: $scope.admin },
|
||||
function () {
|
||||
growl.success(_t('app.admin.admins_new.administrator_successfully_created_he_will_receive_his_connection_directives_by_email', { GENDER: getGender($scope.admin) }));
|
||||
return $state.go('app.admin.members');
|
||||
}
|
||||
, function (error) {
|
||||
growl.error(_t('app.admin.admins_new.failed_to_create_admin') + JSON.stringify(error.data ? error.data : error));
|
||||
console.error(error);
|
||||
}
|
||||
);
|
||||
};
|
||||
$scope.saveAdmin = function () {
|
||||
Admin.save(
|
||||
{},
|
||||
{ admin: $scope.admin },
|
||||
function () {
|
||||
growl.success(_t('app.admin.admins_new.administrator_successfully_created_he_will_receive_his_connection_directives_by_email', { GENDER: getGender($scope.admin) }));
|
||||
return $state.go('app.admin.members');
|
||||
}
|
||||
, function (error) {
|
||||
growl.error(_t('app.admin.admins_new.failed_to_create_admin') + JSON.stringify(error.data ? error.data : error));
|
||||
console.error(error);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/**
|
||||
/**
|
||||
* Return an enumerable meaningful string for the gender of the provider user
|
||||
* @param user {Object} Database user record
|
||||
* @return {string} 'male' or 'female'
|
||||
*/
|
||||
return getGender = function (user) {
|
||||
if (user.statistic_profile_attributes) {
|
||||
if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; }
|
||||
} else { return 'other'; }
|
||||
};
|
||||
}
|
||||
return getGender = function (user) {
|
||||
if (user.statistic_profile_attributes) {
|
||||
if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; }
|
||||
} else { return 'other'; }
|
||||
};
|
||||
}
|
||||
|
||||
]);
|
||||
|
||||
@ -1140,65 +1178,64 @@ Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'A
|
||||
Application.Controllers.controller('NewManagerController', ['$state', '$scope', 'User', 'groupsPromise', 'tagsPromise', 'growl', '_t',
|
||||
function ($state, $scope, User, groupsPromise, tagsPromise, growl, _t) {
|
||||
// default admin profile
|
||||
$scope.manager = {
|
||||
statistic_profile_attributes: {
|
||||
gender: true
|
||||
},
|
||||
profile_attributes: {},
|
||||
invoicing_profile_attributes: {}
|
||||
};
|
||||
$scope.manager = {
|
||||
statistic_profile_attributes: {
|
||||
gender: true
|
||||
},
|
||||
profile_attributes: {},
|
||||
invoicing_profile_attributes: {}
|
||||
};
|
||||
|
||||
// Default parameters for AngularUI-Bootstrap datepicker
|
||||
$scope.datePicker = {
|
||||
format: Fablab.uibDateFormat,
|
||||
opened: false,
|
||||
options: {
|
||||
startingDay: Fablab.weekStartingDay
|
||||
}
|
||||
};
|
||||
// Default parameters for AngularUI-Bootstrap datepicker
|
||||
$scope.datePicker = {
|
||||
format: Fablab.uibDateFormat,
|
||||
opened: false,
|
||||
options: {
|
||||
startingDay: Fablab.weekStartingDay
|
||||
}
|
||||
};
|
||||
|
||||
// list of all groups
|
||||
$scope.groups = groupsPromise.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; });
|
||||
// list of all groups
|
||||
$scope.groups = groupsPromise.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; });
|
||||
|
||||
// list of all tags
|
||||
$scope.tags = tagsPromise;
|
||||
// list of all tags
|
||||
$scope.tags = tagsPromise;
|
||||
|
||||
/**
|
||||
* Shows the birth day datepicker
|
||||
* @param $event {Object} jQuery event object
|
||||
/**
|
||||
* Shows the birthday datepicker
|
||||
*/
|
||||
$scope.openDatePicker = function ($event) { $scope.datePicker.opened = true; };
|
||||
$scope.openDatePicker = function () { $scope.datePicker.opened = true; };
|
||||
|
||||
/**
|
||||
/**
|
||||
* Send the new manager, currently stored in $scope.manager, to the server for database saving
|
||||
*/
|
||||
$scope.saveManager = function () {
|
||||
User.save(
|
||||
{},
|
||||
{ manager: $scope.manager },
|
||||
function () {
|
||||
growl.success(_t('app.admin.manager_new.manager_successfully_created', { GENDER: getGender($scope.manager) }));
|
||||
return $state.go('app.admin.members');
|
||||
}
|
||||
, function (error) {
|
||||
growl.error(_t('app.admin.admins_new.failed_to_create_manager') + JSON.stringify(error.data ? error.data : error));
|
||||
console.error(error);
|
||||
}
|
||||
);
|
||||
};
|
||||
$scope.saveManager = function () {
|
||||
User.save(
|
||||
{},
|
||||
{ manager: $scope.manager },
|
||||
function () {
|
||||
growl.success(_t('app.admin.manager_new.manager_successfully_created', { GENDER: getGender($scope.manager) }));
|
||||
return $state.go('app.admin.members');
|
||||
}
|
||||
, function (error) {
|
||||
growl.error(_t('app.admin.admins_new.failed_to_create_manager') + JSON.stringify(error.data ? error.data : error));
|
||||
console.error(error);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/**
|
||||
/**
|
||||
* Return an enumerable meaningful string for the gender of the provider user
|
||||
* @param user {Object} Database user record
|
||||
* @return {string} 'male' or 'female'
|
||||
*/
|
||||
const getGender = function (user) {
|
||||
if (user.statistic_profile_attributes) {
|
||||
if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; }
|
||||
} else { return 'other'; }
|
||||
};
|
||||
}
|
||||
const getGender = function (user) {
|
||||
if (user.statistic_profile_attributes) {
|
||||
if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; }
|
||||
} else { return 'other'; }
|
||||
};
|
||||
}
|
||||
|
||||
]);
|
||||
|
@ -28,15 +28,15 @@ class PlanController {
|
||||
// groups list
|
||||
$scope.groups = groups
|
||||
.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; })
|
||||
.map(e => Object.assign({}, e, { category: 'app.shared.plan.groups', id: `${e.id}` }))
|
||||
.map(e => Object.assign({}, e, { category: 'app.shared.plan.groups', id: `${e.id}` }));
|
||||
$scope.groups.push({ id: 'all', name: 'app.shared.plan.transversal_all_groups', category: 'app.shared.plan.all' });
|
||||
|
||||
// dynamically translate a label if needed
|
||||
$scope.translateLabel = function (group, prop) {
|
||||
return group[prop] && group[prop].match(/^app\./) ? _t(group[prop]) : group[prop];
|
||||
}
|
||||
};
|
||||
|
||||
// users with role 'partner', notifiables for a partner plan
|
||||
// users with role 'partner', notifiable for a partner plan
|
||||
$scope.partners = partners.users;
|
||||
|
||||
// Subscriptions prices, machines prices and training prices, per groups
|
||||
@ -93,7 +93,8 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal',
|
||||
is_rolling: false,
|
||||
partnerId: null,
|
||||
partnerContact: null,
|
||||
ui_weight: 0
|
||||
ui_weight: 0,
|
||||
monthly_payment: false
|
||||
};
|
||||
|
||||
// API URL where the form will be posted
|
||||
@ -144,6 +145,22 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This will update the monthly_payment value when the user toggles the switch button
|
||||
* @param checked {Boolean}
|
||||
*/
|
||||
$scope.toggleMonthlyPayment = function (checked) {
|
||||
toggle('monthly_payment', checked);
|
||||
};
|
||||
|
||||
/**
|
||||
* This will update the is_rolling value when the user toggles the switch button
|
||||
* @param checked {Boolean}
|
||||
*/
|
||||
$scope.toggleIsRolling = function (checked) {
|
||||
toggle('is_rolling', checked);
|
||||
};
|
||||
|
||||
/**
|
||||
* Display some messages and redirect the user, once the form was submitted, depending on the result status
|
||||
* (failed/succeeded).
|
||||
@ -164,6 +181,28 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal',
|
||||
}
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
const initialize = function () {
|
||||
$scope.$watch(scope => scope.plan.interval,
|
||||
(newValue, oldValue) => {
|
||||
if (newValue === 'week') { $scope.plan.monthly_payment = false; }
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Asynchronously updates the given property with the new provided value
|
||||
* @param property {string}
|
||||
* @param value {*}
|
||||
*/
|
||||
const toggle = function (property, value) {
|
||||
setTimeout(() => {
|
||||
$scope.plan[property] = value;
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
initialize();
|
||||
return new PlanController($scope, groups, prices, partners, CSRF, _t);
|
||||
}
|
||||
]);
|
||||
@ -204,7 +243,7 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
|
||||
$scope.selectedGroup = function () {
|
||||
const group = $scope.groups.filter(g => g.id === $scope.plan.group_id);
|
||||
return $scope.translateLabel(group[0], 'name');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* If a parent plan was set ($scope.plan.parent), the prices will be copied from this parent plan into
|
||||
@ -216,7 +255,7 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
|
||||
Array.from(parentPlan.prices).map(function (parentPrice) {
|
||||
return (function () {
|
||||
const result = [];
|
||||
for (let childKey in $scope.plan.prices) {
|
||||
for (const childKey in $scope.plan.prices) {
|
||||
const childPrice = $scope.plan.prices[childKey];
|
||||
if ((childPrice.priceable_type === parentPrice.priceable_type) && (childPrice.priceable_id === parentPrice.priceable_id)) {
|
||||
$scope.plan.prices[childKey].amount = parentPrice.amount;
|
||||
@ -235,7 +274,7 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
|
||||
} else {
|
||||
return (function () {
|
||||
const result = [];
|
||||
for (let key in $scope.plan.prices) {
|
||||
for (const key in $scope.plan.prices) {
|
||||
const price = $scope.plan.prices[key];
|
||||
result.push($scope.plan.prices[key].amount = 0);
|
||||
}
|
||||
@ -273,7 +312,7 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
|
||||
* @returns {Object} Machine
|
||||
*/
|
||||
$scope.getMachine = function (machine_id) {
|
||||
for (let machine of Array.from($scope.machines)) {
|
||||
for (const machine of Array.from($scope.machines)) {
|
||||
if (machine.id === machine_id) {
|
||||
return machine;
|
||||
}
|
||||
@ -286,7 +325,7 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
|
||||
* @returns {Object} Space
|
||||
*/
|
||||
$scope.getSpace = function (space_id) {
|
||||
for (let space of Array.from($scope.spaces)) {
|
||||
for (const space of Array.from($scope.spaces)) {
|
||||
if (space.id === space_id) {
|
||||
return space;
|
||||
}
|
||||
|
@ -30,13 +30,13 @@ Application.Controllers.controller('DashboardController', ['$scope', 'memberProm
|
||||
const initialize = () => $scope.social.networks = filterNetworks();
|
||||
|
||||
/**
|
||||
* Filter social network or website that are associated with the profile of the user provided in promise
|
||||
* Filter the social networks or websites that are associated with the profile of the user provided in promise
|
||||
* and return the filtered networks
|
||||
* @return {Array}
|
||||
*/
|
||||
var filterNetworks = function () {
|
||||
const filterNetworks = function () {
|
||||
const networks = [];
|
||||
for (let network of Array.from(SocialNetworks)) {
|
||||
for (const network of Array.from(SocialNetworks)) {
|
||||
if ($scope.user.profile[network] && ($scope.user.profile[network].length > 0)) {
|
||||
networks.push(network);
|
||||
}
|
||||
|
@ -704,9 +704,12 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
|
||||
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'cgv', 'Auth', 'Reservation', 'growl', 'wallet', 'helpers', '$filter', 'coupon', 'cartItems', 'stripeKey',
|
||||
function ($scope, $uibModalInstance, $state, reservation, price, cgv, Auth, Reservation, growl, wallet, helpers, $filter, coupon, cartItems, stripeKey) {
|
||||
// User's wallet amount
|
||||
$scope.walletAmount = wallet.amount;
|
||||
$scope.wallet = wallet;
|
||||
|
||||
// Price
|
||||
$scope.price = price.price;
|
||||
|
||||
// Amount to pay
|
||||
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
|
||||
|
||||
// Cart items
|
||||
@ -753,16 +756,22 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
|
||||
},
|
||||
coupon () {
|
||||
return $scope.coupon.applied;
|
||||
}
|
||||
},
|
||||
cartItems () {
|
||||
return mkRequestParams(reservation, $scope.coupon.applied);
|
||||
},
|
||||
},
|
||||
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'wallet', 'helpers', '$filter', 'coupon',
|
||||
function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, wallet, helpers, $filter, coupon) {
|
||||
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'wallet', 'helpers', '$filter', 'coupon', 'cartItems',
|
||||
function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, wallet, helpers, $filter, coupon, cartItems) {
|
||||
// User's wallet amount
|
||||
$scope.walletAmount = wallet.amount;
|
||||
$scope.wallet = wallet;
|
||||
|
||||
// Price
|
||||
$scope.price = price.price;
|
||||
|
||||
// Cart items
|
||||
$scope.cartItems = cartItems;
|
||||
|
||||
// price to pay
|
||||
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
|
||||
|
||||
|
@ -620,13 +620,24 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
|
||||
* @param plan {Object} the plan to subscribe
|
||||
*/
|
||||
$scope.selectPlan = function (plan) {
|
||||
// toggle selected plan
|
||||
if ($scope.selectedPlan !== plan) {
|
||||
$scope.selectedPlan = plan;
|
||||
} else {
|
||||
$scope.selectedPlan = null;
|
||||
}
|
||||
return $scope.planSelectionTime = new Date();
|
||||
setTimeout(() => {
|
||||
// toggle selected plan
|
||||
if ($scope.selectedPlan !== plan) {
|
||||
$scope.selectedPlan = plan;
|
||||
} else {
|
||||
$scope.selectedPlan = null;
|
||||
}
|
||||
$scope.planSelectionTime = new Date();
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the provided plan is currently selected
|
||||
* @param plan {Object} Resource plan
|
||||
*/
|
||||
$scope.isSelected = function (plan) {
|
||||
return $scope.selectedPlan === plan;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -654,7 +665,9 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
|
||||
|
||||
if ($scope.selectedPlan) {
|
||||
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
if ($scope.ctrl.member.id === Auth._currentUser.id) {
|
||||
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
}
|
||||
$scope.plansAreShown = false;
|
||||
$scope.selectedPlan = null;
|
||||
}
|
||||
@ -745,6 +758,9 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
|
||||
*/
|
||||
const refetchCalendar = function () {
|
||||
uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
|
||||
setTimeout(() => {
|
||||
uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// !!! MUST BE CALLED AT THE END of the controller
|
||||
|
@ -12,8 +12,8 @@
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScope', '$state', '$uibModal', 'Auth', 'AuthService', 'dialogs', 'growl', 'plansPromise', 'groupsPromise', 'Subscription', 'Member', 'subscriptionExplicationsPromise', '_t', 'Wallet', 'helpers', 'settingsPromise',
|
||||
function ($scope, $rootScope, $state, $uibModal, Auth, AuthService, dialogs, growl, plansPromise, groupsPromise, Subscription, Member, subscriptionExplicationsPromise, _t, Wallet, helpers, settingsPromise) {
|
||||
Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScope', '$state', '$uibModal', 'Auth', 'AuthService', 'dialogs', 'growl', 'plansPromise', 'groupsPromise', 'Subscription', 'Member', 'subscriptionExplicationsPromise', '_t', 'Wallet', 'helpers', 'settingsPromise', 'Price',
|
||||
function ($scope, $rootScope, $state, $uibModal, Auth, AuthService, dialogs, growl, plansPromise, groupsPromise, Subscription, Member, subscriptionExplicationsPromise, _t, Wallet, helpers, settingsPromise, Price) {
|
||||
/* PUBLIC SCOPE */
|
||||
|
||||
// list of groups
|
||||
@ -42,14 +42,16 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
|
||||
// plan to subscribe (shopping cart)
|
||||
$scope.selectedPlan = null;
|
||||
|
||||
// the moment when the plan selection changed for the last time, used to trigger changes in the cart
|
||||
$scope.planSelectionTime = null;
|
||||
|
||||
// the application global settings
|
||||
$scope.settings = settingsPromise;
|
||||
|
||||
// Discount coupon to apply to the basket, if any
|
||||
$scope.coupon =
|
||||
{ applied: null };
|
||||
|
||||
// Storage for the total price (plan price + coupon, if any)
|
||||
$scope.cart =
|
||||
{ total: null };
|
||||
|
||||
// text that appears in the bottom-right box of the page (subscriptions rules details)
|
||||
$scope.subscriptionExplicationsAlert = subscriptionExplicationsPromise.setting.value;
|
||||
|
||||
@ -72,39 +74,27 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
|
||||
* @param plan {Object} The plan to subscribe to
|
||||
*/
|
||||
$scope.selectPlan = function (plan) {
|
||||
if ($scope.isAuthenticated()) {
|
||||
if ($scope.selectedPlan !== plan) {
|
||||
$scope.selectedPlan = plan;
|
||||
updateCartPrice();
|
||||
setTimeout(() => {
|
||||
if ($scope.isAuthenticated()) {
|
||||
if ($scope.selectedPlan !== plan) {
|
||||
$scope.selectedPlan = plan;
|
||||
$scope.planSelectionTime = new Date();
|
||||
} else {
|
||||
$scope.selectedPlan = null;
|
||||
}
|
||||
} else {
|
||||
$scope.selectedPlan = null;
|
||||
$scope.login();
|
||||
}
|
||||
} else {
|
||||
$scope.login();
|
||||
}
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback to trigger the payment process of the subscription
|
||||
* Check if the provided plan is currently selected
|
||||
* @param plan {Object} Resource plan
|
||||
*/
|
||||
$scope.openSubscribePlanModal = function () {
|
||||
Wallet.getWalletByUser({ user_id: $scope.ctrl.member.id }, function (wallet) {
|
||||
const amountToPay = helpers.getAmountToPay($scope.cart.total, wallet.amount);
|
||||
if ((AuthService.isAuthorized('member') && amountToPay > 0)
|
||||
|| (AuthService.isAuthorized('manager') && $scope.ctrl.member.id === $rootScope.currentUser.id && amountToPay > 0)) {
|
||||
if (settingsPromise.online_payment_module !== 'true') {
|
||||
growl.error(_t('app.public.plans.online_payment_disabled'));
|
||||
} else {
|
||||
return payByStripe();
|
||||
}
|
||||
} else {
|
||||
if (AuthService.isAuthorized('admin')
|
||||
|| (AuthService.isAuthorized('manager') && $scope.ctrl.member.id !== $rootScope.currentUser.id)
|
||||
|| amountToPay === 0) {
|
||||
return payOnSite();
|
||||
}
|
||||
}
|
||||
});
|
||||
$scope.isSelected = function (plan) {
|
||||
return $scope.selectedPlan === plan;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -171,6 +161,20 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
|
||||
*/
|
||||
$scope.filterDisabledPlans = function (plan) { return !plan.disabled; };
|
||||
|
||||
/**
|
||||
* Once the subscription has been confirmed (payment process successfully completed), mark the plan as subscribed,
|
||||
* and update the user's subscription
|
||||
*/
|
||||
$scope.afterPayment = function () {
|
||||
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
if ($scope.ctrl.member.id === Auth._currentUser.id) {
|
||||
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
}
|
||||
$scope.paid.plan = angular.copy($scope.selectedPlan);
|
||||
$scope.selectedPlan = null;
|
||||
$scope.coupon.applied = null;
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/**
|
||||
@ -180,7 +184,7 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
|
||||
// group all plans by Group
|
||||
for (const group of $scope.groups) {
|
||||
const groupObj = { id: group.id, name: group.name, plans: [], actives: 0 };
|
||||
for (let plan of plansPromise) {
|
||||
for (const plan of plansPromise) {
|
||||
if (plan.group_id === group.id) {
|
||||
groupObj.plans.push(plan);
|
||||
if (!plan.disabled) { groupObj.actives++; }
|
||||
@ -198,188 +202,6 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
|
||||
}
|
||||
|
||||
$scope.$on('devise:new-session', function (event, user) { if (user.role !== 'admin') { $scope.ctrl.member = user; } });
|
||||
|
||||
// watch when a coupon is applied to re-compute the total price
|
||||
$scope.$watch('coupon.applied', function (newValue, oldValue) {
|
||||
if ((newValue !== null) || (oldValue !== null)) {
|
||||
return updateCartPrice();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the total amount for the current reservation according to the previously set parameters
|
||||
* and assign the result in $scope.reserve.amountTotal
|
||||
*/
|
||||
const updateCartPrice = function () {
|
||||
// first we check the selection of a user
|
||||
if (Object.keys($scope.ctrl.member).length > 0 && $scope.selectedPlan) {
|
||||
$scope.cart.total = $scope.selectedPlan.amount;
|
||||
// apply the coupon if any
|
||||
if ($scope.coupon.applied) {
|
||||
let discount;
|
||||
if ($scope.coupon.applied.type === 'percent_off') {
|
||||
discount = ($scope.cart.total * $scope.coupon.applied.percent_off) / 100;
|
||||
} else if ($scope.coupon.applied.type === 'amount_off') {
|
||||
discount = $scope.coupon.applied.amount_off;
|
||||
}
|
||||
return $scope.cart.total -= discount;
|
||||
}
|
||||
} else {
|
||||
return $scope.reserve.amountTotal = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a modal window which trigger the stripe payment process
|
||||
*/
|
||||
const payByStripe = function () {
|
||||
$uibModal.open({
|
||||
templateUrl: '/stripe/payment_modal.html',
|
||||
size: 'md',
|
||||
resolve: {
|
||||
selectedPlan () { return $scope.selectedPlan; },
|
||||
member () { return $scope.ctrl.member; },
|
||||
price () { return $scope.cart.total; },
|
||||
wallet () {
|
||||
return Wallet.getWalletByUser({ user_id: $scope.ctrl.member.id }).$promise;
|
||||
},
|
||||
coupon () { return $scope.coupon.applied; },
|
||||
stripeKey: ['Setting', function (Setting) { return Setting.get({ name: 'stripe_public_key' }).$promise; }]
|
||||
},
|
||||
controller: ['$scope', '$uibModalInstance', '$state', 'selectedPlan', 'member', 'price', 'Subscription', 'CustomAsset', 'wallet', 'helpers', '$filter', 'coupon', 'stripeKey',
|
||||
function ($scope, $uibModalInstance, $state, selectedPlan, member, price, Subscription, CustomAsset, wallet, helpers, $filter, coupon, stripeKey) {
|
||||
// User's wallet amount
|
||||
$scope.walletAmount = wallet.amount;
|
||||
|
||||
// Final price to pay by the user
|
||||
$scope.amount = helpers.getAmountToPay(price, wallet.amount);
|
||||
|
||||
// The plan that the user is about to subscribe
|
||||
$scope.selectedPlan = selectedPlan;
|
||||
|
||||
// Used in wallet info template to interpolate some translations
|
||||
$scope.numberFilter = $filter('number');
|
||||
|
||||
// Cart items
|
||||
$scope.cartItems = {
|
||||
coupon_code: ((coupon ? coupon.code : undefined)),
|
||||
subscription: {
|
||||
plan_id: selectedPlan.id
|
||||
}
|
||||
};
|
||||
|
||||
// stripe publishable key
|
||||
$scope.stripeKey = stripeKey.setting.value;
|
||||
|
||||
// retrieve the CGV
|
||||
CustomAsset.get({ name: 'cgv-file' }, function (cgv) { $scope.cgv = cgv.custom_asset; });
|
||||
|
||||
/**
|
||||
* Callback for a click on the 'proceed' button.
|
||||
* Handle the stripe's card tokenization process response and save the subscription to the API with the
|
||||
* card token just created.
|
||||
*/
|
||||
$scope.onPaymentSuccess = function (response) {
|
||||
$uibModalInstance.close(response);
|
||||
};
|
||||
}
|
||||
]
|
||||
}).result['finally'](null).then(function (subscription) {
|
||||
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
$scope.paid.plan = angular.copy($scope.selectedPlan);
|
||||
$scope.selectedPlan = null;
|
||||
$scope.coupon.applied = null;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a modal window which trigger the local payment process
|
||||
*/
|
||||
const payOnSite = function () {
|
||||
$uibModal.open({
|
||||
templateUrl: '/plans/payment_modal.html',
|
||||
size: 'sm',
|
||||
resolve: {
|
||||
selectedPlan () { return $scope.selectedPlan; },
|
||||
member () { return $scope.ctrl.member; },
|
||||
price () { return $scope.cart.total; },
|
||||
wallet () {
|
||||
return Wallet.getWalletByUser({ user_id: $scope.ctrl.member.id }).$promise;
|
||||
},
|
||||
coupon () { return $scope.coupon.applied; }
|
||||
},
|
||||
controller: ['$scope', '$uibModalInstance', '$state', 'selectedPlan', 'member', 'price', 'Subscription', 'wallet', 'helpers', '$filter', 'coupon',
|
||||
function ($scope, $uibModalInstance, $state, selectedPlan, member, price, Subscription, wallet, helpers, $filter, coupon) {
|
||||
// user wallet amount
|
||||
$scope.walletAmount = wallet.amount;
|
||||
|
||||
// subscription price, coupon subtracted if any
|
||||
$scope.price = price;
|
||||
|
||||
// price to pay
|
||||
$scope.amount = helpers.getAmountToPay($scope.price, wallet.amount);
|
||||
|
||||
// Used in wallet info template to interpolate some translations
|
||||
$scope.numberFilter = $filter('number');
|
||||
|
||||
// The plan that the user is about to subscribe
|
||||
$scope.plan = selectedPlan;
|
||||
|
||||
// The member who is subscribing a plan
|
||||
$scope.member = member;
|
||||
|
||||
// Button label
|
||||
if ($scope.amount > 0) {
|
||||
$scope.validButtonName = _t('app.public.plans.confirm_payment_of_html', { ROLE: $scope.currentUser.role, AMOUNT: $filter('currency')($scope.amount) });
|
||||
} else {
|
||||
if ((price.price > 0) && ($scope.walletAmount === 0)) {
|
||||
$scope.validButtonName = _t('app.public.plans.confirm_payment_of_html', { ROLE: $scope.currentUser.role, AMOUNT: $filter('currency')(price.price) });
|
||||
} else {
|
||||
$scope.validButtonName = _t('app.shared.buttons.confirm');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for the 'proceed' button.
|
||||
* Save the subscription to the API
|
||||
*/
|
||||
$scope.ok = function () {
|
||||
$scope.attempting = true;
|
||||
Subscription.save({
|
||||
coupon_code: ((coupon ? coupon.code : undefined)),
|
||||
subscription: {
|
||||
plan_id: selectedPlan.id,
|
||||
user_id: member.id
|
||||
}
|
||||
}
|
||||
, function (data) { // success
|
||||
$uibModalInstance.close(data);
|
||||
}
|
||||
, function (data, status) { // failed
|
||||
$scope.alerts = [];
|
||||
$scope.alerts.push({ msg: _t('app.public.plans.an_error_occured_during_the_payment_process_please_try_again_later'), type: 'danger' });
|
||||
$scope.attempting = false;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback for the 'cancel' button.
|
||||
* Close the modal box.
|
||||
*/
|
||||
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
|
||||
}
|
||||
]
|
||||
}).result['finally'](null).then(function (subscription) {
|
||||
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
$scope.ctrl.member = null;
|
||||
$scope.paid.plan = angular.copy($scope.selectedPlan);
|
||||
$scope.selectedPlan = null;
|
||||
return $scope.coupon.applied = null;
|
||||
});
|
||||
};
|
||||
|
||||
// !!! MUST BE CALLED AT THE END of the controller
|
||||
|
@ -353,13 +353,13 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
|
||||
// the moment when the slot selection changed for the last time, used to trigger changes in the cart
|
||||
$scope.selectionTime = null;
|
||||
|
||||
// the last clicked event in the calender
|
||||
// the last clicked event in the calendar
|
||||
$scope.selectedEvent = null;
|
||||
|
||||
// indicates the state of the current view : calendar or plans information
|
||||
$scope.plansAreShown = false;
|
||||
|
||||
// will store the user's plan if he choosed to buy one
|
||||
// will store the user's plan if he chose to buy one
|
||||
$scope.selectedPlan = null;
|
||||
|
||||
// the moment when the plan selection changed for the last time, used to trigger changes in the cart
|
||||
@ -390,7 +390,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
|
||||
$scope.spaceExplicationsAlert = settingsPromise.space_explications_alert;
|
||||
|
||||
/**
|
||||
* Change the last selected slot's appearence to looks like 'added to cart'
|
||||
* Change the last selected slot's appearance to looks like 'added to cart'
|
||||
*/
|
||||
$scope.markSlotAsAdded = function () {
|
||||
$scope.selectedEvent.backgroundColor = SELECTED_EVENT_BG_COLOR;
|
||||
@ -398,7 +398,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the last selected slot's appearence to looks like 'never added to cart'
|
||||
* Change the last selected slot's appearance to looks like 'never added to cart'
|
||||
*/
|
||||
$scope.markSlotAsRemoved = function (slot) {
|
||||
slot.backgroundColor = 'white';
|
||||
@ -419,7 +419,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
|
||||
$scope.slotCancelled = function () { $scope.markSlotAsRemoved($scope.selectedEvent); };
|
||||
|
||||
/**
|
||||
* Change the last selected slot's appearence to looks like 'currently looking for a new destination to exchange'
|
||||
* Change the last selected slot's appearance to looks like 'currently looking for a new destination to exchange'
|
||||
*/
|
||||
$scope.markSlotAsModifying = function () {
|
||||
$scope.selectedEvent.backgroundColor = '#eee';
|
||||
@ -428,7 +428,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the last selected slot's appearence to looks like 'the slot being exchanged will take this place'
|
||||
* Change the last selected slot's appearance to looks like 'the slot being exchanged will take this place'
|
||||
*/
|
||||
$scope.changeModifySpaceSlot = function () {
|
||||
if ($scope.events.placable) {
|
||||
@ -517,17 +517,28 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
|
||||
* @param plan {Object} the plan to subscribe
|
||||
*/
|
||||
$scope.selectPlan = function (plan) {
|
||||
// toggle selected plan
|
||||
if ($scope.selectedPlan !== plan) {
|
||||
$scope.selectedPlan = plan;
|
||||
} else {
|
||||
$scope.selectedPlan = null;
|
||||
}
|
||||
return $scope.planSelectionTime = new Date();
|
||||
setTimeout(() => {
|
||||
// toggle selected plan
|
||||
if ($scope.selectedPlan !== plan) {
|
||||
$scope.selectedPlan = plan;
|
||||
} else {
|
||||
$scope.selectedPlan = null;
|
||||
}
|
||||
$scope.planSelectionTime = new Date();
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* Changes the user current view from the plan subsription screen to the machine reservation agenda
|
||||
* Check if the provided plan is currently selected
|
||||
* @param plan {Object} Resource plan
|
||||
*/
|
||||
$scope.isSelected = function (plan) {
|
||||
return $scope.selectedPlan === plan;
|
||||
};
|
||||
|
||||
/**
|
||||
* Changes the user current view from the plan subscription screen to the machine reservation agenda
|
||||
* @param e {Object} see https://docs.angularjs.org/guide/expression#-event-
|
||||
*/
|
||||
$scope.doNotSubscribePlan = function (e) {
|
||||
@ -560,14 +571,18 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
|
||||
|
||||
if ($scope.selectedPlan) {
|
||||
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
if ($scope.ctrl.member.id === Auth._currentUser.id) {
|
||||
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
}
|
||||
$scope.plansAreShown = false;
|
||||
$scope.selectedPlan = null;
|
||||
}
|
||||
$scope.ctrl.member.training_credits = angular.copy(reservation.user.training_credits);
|
||||
$scope.ctrl.member.machine_credits = angular.copy(reservation.user.machine_credits);
|
||||
Auth._currentUser.training_credits = angular.copy(reservation.user.training_credits);
|
||||
Auth._currentUser.machine_credits = angular.copy(reservation.user.machine_credits);
|
||||
if ($scope.ctrl.member.id === Auth._currentUser.id) {
|
||||
Auth._currentUser.training_credits = angular.copy(reservation.user.training_credits);
|
||||
Auth._currentUser.machine_credits = angular.copy(reservation.user.machine_credits);
|
||||
}
|
||||
|
||||
refetchCalendar();
|
||||
};
|
||||
@ -601,7 +616,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
|
||||
/**
|
||||
* Triggered when the user clicks on a reservation slot in the agenda.
|
||||
* Defines the behavior to adopt depending on the slot status (already booked, free, ready to be reserved ...),
|
||||
* the user's subscription (current or about to be took) and the time (the user cannot modify a booked reservation
|
||||
* the user's subscription (current or about to be took), and the time (the user cannot modify a booked reservation
|
||||
* if it's too late).
|
||||
* @see http://fullcalendar.io/docs/mouse/eventClick/
|
||||
*/
|
||||
@ -611,7 +626,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggered when fullCalendar tries to graphicaly render an event block.
|
||||
* Triggered when fullCalendar tries to graphically render an event block.
|
||||
* Append the event tag into the block, just after the event title.
|
||||
* @see http://fullcalendar.io/docs/event_rendering/eventRender/
|
||||
*/
|
||||
@ -654,6 +669,9 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
|
||||
*/
|
||||
const refetchCalendar = function () {
|
||||
uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
|
||||
setTimeout(() => {
|
||||
uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// !!! MUST BE CALLED AT THE END of the controller
|
||||
|
@ -307,13 +307,24 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$sta
|
||||
* @param plan {Object} the plan to subscribe
|
||||
*/
|
||||
$scope.selectPlan = function (plan) {
|
||||
// toggle selected plan
|
||||
if ($scope.selectedPlan !== plan) {
|
||||
$scope.selectedPlan = plan;
|
||||
} else {
|
||||
$scope.selectedPlan = null;
|
||||
}
|
||||
return $scope.planSelectionTime = new Date();
|
||||
setTimeout(() => {
|
||||
// toggle selected plan
|
||||
if ($scope.selectedPlan !== plan) {
|
||||
$scope.selectedPlan = plan;
|
||||
} else {
|
||||
$scope.selectedPlan = null;
|
||||
}
|
||||
$scope.planSelectionTime = new Date();
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the provided plan is currently selected
|
||||
* @param plan {Object} Resource plan
|
||||
*/
|
||||
$scope.isSelected = function (plan) {
|
||||
return $scope.selectedPlan === plan;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -350,14 +361,18 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$sta
|
||||
|
||||
if ($scope.selectedPlan) {
|
||||
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
if ($scope.ctrl.member.id === Auth._currentUser.id) {
|
||||
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
|
||||
}
|
||||
$scope.plansAreShown = false;
|
||||
$scope.selectedPlan = null;
|
||||
}
|
||||
$scope.ctrl.member.training_credits = angular.copy(reservation.user.training_credits);
|
||||
$scope.ctrl.member.machine_credits = angular.copy(reservation.user.machine_credits);
|
||||
Auth._currentUser.training_credits = angular.copy(reservation.user.training_credits);
|
||||
Auth._currentUser.machine_credits = angular.copy(reservation.user.machine_credits);
|
||||
if ($scope.ctrl.member.id === Auth._currentUser.id) {
|
||||
Auth._currentUser.training_credits = angular.copy(reservation.user.training_credits);
|
||||
Auth._currentUser.machine_credits = angular.copy(reservation.user.machine_credits);
|
||||
}
|
||||
|
||||
refetchCalendar();
|
||||
};
|
||||
@ -447,6 +462,9 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$sta
|
||||
*/
|
||||
const refetchCalendar = function () {
|
||||
uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
|
||||
setTimeout(() => {
|
||||
uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// !!! MUST BE CALLED AT THE END of the controller
|
||||
|
@ -10,8 +10,8 @@
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', 'growl', 'Auth', 'Price', 'Wallet', 'CustomAsset', 'Slot', 'AuthService', 'helpers', '_t',
|
||||
function ($rootScope, $uibModal, dialogs, growl, Auth, Price, Wallet, CustomAsset, Slot, AuthService, helpers, _t) {
|
||||
Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', 'growl', 'Auth', 'Price', 'Wallet', 'CustomAsset', 'Slot', 'AuthService', 'Payment', 'helpers', '_t',
|
||||
function ($rootScope, $uibModal, dialogs, growl, Auth, Price, Wallet, CustomAsset, Slot, AuthService, Payment, helpers, _t) {
|
||||
return ({
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
@ -40,7 +40,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
},
|
||||
templateUrl: '/shared/_cart.html',
|
||||
link ($scope, element, attributes) {
|
||||
// will store the user's plan if he choosed to buy one
|
||||
// will store the user's plan if he chose to buy one
|
||||
$scope.selectedPlan = null;
|
||||
|
||||
// total amount of the bill to pay
|
||||
@ -67,6 +67,21 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
// Global config: delay in hours before a booking while the cancellation is forbidden
|
||||
$scope.cancelBookingDelay = parseInt($scope.settings.booking_cancel_delay);
|
||||
|
||||
// Payment schedule
|
||||
$scope.schedule = {
|
||||
requested_schedule: false, // does the user requests a payment schedule for his subscription
|
||||
payment_schedule: undefined // the effective computed payment schedule
|
||||
};
|
||||
|
||||
// online payments (stripe)
|
||||
$scope.stripe = {
|
||||
showModal: false,
|
||||
cartItems: undefined
|
||||
};
|
||||
|
||||
// currently logged-in user
|
||||
$scope.currentUser = $rootScope.currentUser;
|
||||
|
||||
/**
|
||||
* Add the provided slot to the shopping cart (state transition from free to 'about to be reserved')
|
||||
* and increment the total amount of the cart if needed.
|
||||
@ -107,9 +122,13 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
*/
|
||||
$scope.isSlotsValid = function () {
|
||||
let isValid = true;
|
||||
angular.forEach($scope.events.reserved, function (m) {
|
||||
if (!m.isValid) { return isValid = false; }
|
||||
});
|
||||
if ($scope.events) {
|
||||
angular.forEach($scope.events.reserved, function (m) {
|
||||
if (!m.isValid) {
|
||||
return isValid = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
return isValid;
|
||||
};
|
||||
|
||||
@ -143,48 +162,58 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
const slotValidations = [];
|
||||
let slotNotValid;
|
||||
let slotNotValidError;
|
||||
$scope.events.reserved.forEach(function (slot) {
|
||||
if (slot.plan_ids.length > 0) {
|
||||
if (
|
||||
($scope.selectedPlan && _.includes(slot.plan_ids, $scope.selectedPlan.id)) ||
|
||||
($scope.user.subscribed_plan && _.includes(slot.plan_ids, $scope.user.subscribed_plan.id))
|
||||
) {
|
||||
slotValidations.push(true);
|
||||
} else {
|
||||
slotNotValid = slot;
|
||||
if ($scope.selectedPlan && !_.includes(slot.plan_ids, $scope.selectedPlan.id)) {
|
||||
slotNotValidError = 'selectedPlanError';
|
||||
if ($scope.events.reserved) {
|
||||
$scope.events.reserved.forEach(function (slot) {
|
||||
if (slot.plan_ids.length > 0) {
|
||||
if (
|
||||
($scope.selectedPlan && _.includes(slot.plan_ids, $scope.selectedPlan.id)) ||
|
||||
($scope.user.subscribed_plan && _.includes(slot.plan_ids, $scope.user.subscribed_plan.id))
|
||||
) {
|
||||
slotValidations.push(true);
|
||||
} else {
|
||||
slotNotValid = slot;
|
||||
if ($scope.selectedPlan && !_.includes(slot.plan_ids, $scope.selectedPlan.id)) {
|
||||
slotNotValidError = 'selectedPlanError';
|
||||
}
|
||||
if ($scope.user.subscribed_plan && !_.includes(slot.plan_ids, $scope.user.subscribed_plan.id)) {
|
||||
slotNotValidError = 'userPlanError';
|
||||
}
|
||||
if (!$scope.selectedPlan || !$scope.user.subscribed_plan) {
|
||||
slotNotValidError = 'noPlanError';
|
||||
}
|
||||
slotValidations.push(false);
|
||||
}
|
||||
if ($scope.user.subscribed_plan && !_.includes(slot.plan_ids, $scope.user.subscribed_plan.id)) {
|
||||
slotNotValidError = 'userPlanError';
|
||||
}
|
||||
if (!$scope.selectedPlan || !$scope.user.subscribed_plan) {
|
||||
slotNotValidError = 'noPlanError';
|
||||
}
|
||||
slotValidations.push(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
const hasPlanForSlot = slotValidations.every(function (a) { return a; });
|
||||
if (!hasPlanForSlot) {
|
||||
if (!AuthService.isAuthorized(['admin', 'manager'])) {
|
||||
return growl.error(_t('app.shared.cart.slot_restrict_subscriptions_must_select_plan'));
|
||||
});
|
||||
const hasPlanForSlot = slotValidations.every(function (a) {
|
||||
return a;
|
||||
});
|
||||
if (!hasPlanForSlot) {
|
||||
if (!AuthService.isAuthorized(['admin', 'manager'])) {
|
||||
return growl.error(_t('app.shared.cart.slot_restrict_subscriptions_must_select_plan'));
|
||||
} else {
|
||||
const modalInstance = $uibModal.open({
|
||||
animation: true,
|
||||
templateUrl: '/shared/_reserve_slot_without_plan.html',
|
||||
size: 'md',
|
||||
controller: 'ReserveSlotWithoutPlanController',
|
||||
resolve: {
|
||||
slot: function () {
|
||||
return slotNotValid;
|
||||
},
|
||||
slotNotValidError: function () {
|
||||
return slotNotValidError;
|
||||
}
|
||||
}
|
||||
});
|
||||
modalInstance.result.then(function (res) {
|
||||
return paySlots();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const modalInstance = $uibModal.open({
|
||||
animation: true,
|
||||
templateUrl: '/shared/_reserve_slot_without_plan.html',
|
||||
size: 'md',
|
||||
controller: 'ReserveSlotWithoutPlanController',
|
||||
resolve: {
|
||||
slot: function () { return slotNotValid; },
|
||||
slotNotValidError: function () { return slotNotValidError; }
|
||||
}
|
||||
});
|
||||
modalInstance.result.then(function (res) {
|
||||
return paySlots();
|
||||
});
|
||||
return paySlots();
|
||||
}
|
||||
} else {
|
||||
} else if ($scope.selectedPlan) {
|
||||
return paySlots();
|
||||
}
|
||||
} else {
|
||||
@ -271,6 +300,40 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* This will update the payment_schedule setting when the user toggles the switch button
|
||||
* @param checked {Boolean}
|
||||
*/
|
||||
$scope.togglePaymentSchedule = (checked) => {
|
||||
setTimeout(() => {
|
||||
$scope.schedule.requested_schedule = checked;
|
||||
updateCartPrice();
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* This will open/close the stripe payment modal
|
||||
*/
|
||||
$scope.toggleStripeModal = (beforeApply) => {
|
||||
setTimeout(() => {
|
||||
$scope.stripe.showModal = !$scope.stripe.showModal;
|
||||
if (typeof beforeApply === 'function') {
|
||||
beforeApply();
|
||||
}
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* Invoked atfer a successful Stripe payment
|
||||
* @param result {*} may be a reservation or a subscription
|
||||
*/
|
||||
$scope.afterStripeSuccess = (result) => {
|
||||
$scope.toggleStripeModal();
|
||||
afterPayment(result);
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/**
|
||||
@ -280,24 +343,24 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
// What the bound slot
|
||||
$scope.$watch('slotSelectionTime', function (newValue, oldValue) {
|
||||
if (newValue !== oldValue) {
|
||||
return slotSelectionChanged();
|
||||
slotSelectionChanged();
|
||||
}
|
||||
});
|
||||
$scope.$watch('user', function (newValue, oldValue) {
|
||||
if (newValue !== oldValue) {
|
||||
resetCartState();
|
||||
return updateCartPrice();
|
||||
updateCartPrice();
|
||||
}
|
||||
});
|
||||
$scope.$watch('planSelectionTime', function (newValue, oldValue) {
|
||||
if (newValue !== oldValue) {
|
||||
return planSelectionChanged();
|
||||
planSelectionChanged();
|
||||
}
|
||||
});
|
||||
// watch when a coupon is applied to re-compute the total price
|
||||
$scope.$watch('coupon.applied', function (newValue, oldValue) {
|
||||
if (newValue !== oldValue) {
|
||||
return updateCartPrice();
|
||||
updateCartPrice();
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -492,11 +555,14 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
*/
|
||||
const resetCartState = function () {
|
||||
$scope.selectedPlan = null;
|
||||
$scope.paidPlan = null;
|
||||
$scope.coupon.applied = null;
|
||||
$scope.events.moved = null;
|
||||
$scope.events.paid = [];
|
||||
$scope.events.modifiable = null;
|
||||
$scope.events.placable = null;
|
||||
$scope.schedule.requested_schedule = false;
|
||||
$scope.schedule.payment_schedule = null;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -528,6 +594,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
if (Auth.isAuthenticated()) {
|
||||
if ($scope.selectedPlan !== $scope.plan) {
|
||||
$scope.selectedPlan = $scope.plan;
|
||||
$scope.schedule.requested_schedule = $scope.plan.monthly_payment;
|
||||
} else {
|
||||
$scope.selectedPlan = null;
|
||||
}
|
||||
@ -546,8 +613,9 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
const updateCartPrice = function () {
|
||||
if (Object.keys($scope.user).length > 0) {
|
||||
const r = mkReservation($scope.user, $scope.events.reserved, $scope.selectedPlan);
|
||||
return Price.compute(mkRequestParams(r, $scope.coupon.applied), function (res) {
|
||||
return Price.compute(mkRequestParams({ reservation: r }, $scope.coupon.applied), function (res) {
|
||||
$scope.amountTotal = res.price;
|
||||
$scope.schedule.payment_schedule = res.schedule;
|
||||
$scope.totalNoCoupon = res.price_without_coupon;
|
||||
setSlotsDetails(res.details);
|
||||
});
|
||||
@ -571,23 +639,22 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
|
||||
/**
|
||||
* Format the parameters expected by /api/prices/compute or /api/reservations and return the resulting object
|
||||
* @param reservation {Object} as returned by mkReservation()
|
||||
* @param coupon {Object} Coupon as returned from the API
|
||||
* @return {{reservation:Object, coupon_code:string}}
|
||||
* @param request {{reservation: *}|{subscription: *}} as returned by mkReservation()
|
||||
* @param coupon {{code: string}} Coupon as returned from the API
|
||||
* @return {CartItems}
|
||||
*/
|
||||
const mkRequestParams = function (reservation, coupon) {
|
||||
return {
|
||||
reservation,
|
||||
const mkRequestParams = function (request, coupon) {
|
||||
return Object.assign({
|
||||
coupon_code: ((coupon ? coupon.code : undefined))
|
||||
};
|
||||
}, request);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an hash map implementing the Reservation specs
|
||||
* Create a hash map implementing the Reservation specs
|
||||
* @param member {Object} User as retrieved from the API: current user / selected user if current is admin
|
||||
* @param slots {Array<Object>} Array of fullCalendar events: slots selected on the calendar
|
||||
* @param [plan] {Object} Plan as retrieved from the API: plan to buy with the current reservation
|
||||
* @return {{user_id:Number, reservable_id:Number, reservable_type:String, slots_attributes:Array<Object>, plan_id:Number|null}}
|
||||
* @return {{reservable_type: string, payment_schedule: boolean, user_id: *, reservable_id: string, slots_attributes: [], plan_id: (*|undefined)}}
|
||||
*/
|
||||
const mkReservation = function (member, slots, plan) {
|
||||
const reservation = {
|
||||
@ -595,7 +662,8 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
reservable_id: $scope.reservableId,
|
||||
reservable_type: $scope.reservableType,
|
||||
slots_attributes: [],
|
||||
plan_id: ((plan ? plan.id : undefined))
|
||||
plan_id: ((plan ? plan.id : undefined)),
|
||||
payment_schedule: $scope.schedule.requested_schedule
|
||||
};
|
||||
angular.forEach(slots, function (slot) {
|
||||
reservation.slots_attributes.push({
|
||||
@ -609,66 +677,53 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
return reservation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a hash map implementing the Subscription specs
|
||||
* @param planId {number}
|
||||
* @param userId {number}
|
||||
* @param schedule {boolean}
|
||||
* @param method {String} 'stripe' | ''
|
||||
* @return {{subscription: {payment_schedule: boolean, user_id: number, plan_id: number}}}
|
||||
*/
|
||||
const mkSubscription = function (planId, userId, schedule, method) {
|
||||
return {
|
||||
subscription: {
|
||||
plan_id: planId,
|
||||
user_id: userId,
|
||||
payment_schedule: schedule,
|
||||
payment_method: method
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the CartItems object, from the current reservation
|
||||
* @param reservation {*}
|
||||
* @param paymentMethod {string}
|
||||
* @return {CartItems}
|
||||
*/
|
||||
const mkCartItems = function (reservation, paymentMethod) {
|
||||
let request = { reservation };
|
||||
if (reservation.slots_attributes.length === 0 && reservation.plan_id) {
|
||||
request = mkSubscription($scope.selectedPlan.id, reservation.user_id, $scope.schedule.requested_schedule, paymentMethod);
|
||||
} else {
|
||||
request.reservation.payment_method = paymentMethod;
|
||||
}
|
||||
return mkRequestParams(request, $scope.coupon.applied);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a modal window that allows the user to process a credit card payment for his current shopping cart.
|
||||
*/
|
||||
const payByStripe = function (reservation) {
|
||||
$uibModal.open({
|
||||
templateUrl: '/stripe/payment_modal.html',
|
||||
size: 'md',
|
||||
resolve: {
|
||||
reservation () {
|
||||
return reservation;
|
||||
},
|
||||
price () {
|
||||
return Price.compute(mkRequestParams(reservation, $scope.coupon.applied)).$promise;
|
||||
},
|
||||
wallet () {
|
||||
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
|
||||
},
|
||||
cgv () {
|
||||
return CustomAsset.get({ name: 'cgv-file' }).$promise;
|
||||
},
|
||||
coupon () {
|
||||
return $scope.coupon.applied;
|
||||
},
|
||||
cartItems () {
|
||||
return mkRequestParams(reservation, $scope.coupon.applied);
|
||||
},
|
||||
stripeKey: ['Setting', function (Setting) { return Setting.get({ name: 'stripe_public_key' }).$promise; }]
|
||||
},
|
||||
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'cgv', 'Auth', 'Reservation', 'wallet', 'helpers', '$filter', 'coupon', 'cartItems', 'stripeKey',
|
||||
function ($scope, $uibModalInstance, $state, reservation, price, cgv, Auth, Reservation, wallet, helpers, $filter, coupon, cartItems, stripeKey) {
|
||||
// user wallet amount
|
||||
$scope.walletAmount = wallet.amount;
|
||||
|
||||
// Price
|
||||
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
|
||||
|
||||
// Cart items
|
||||
$scope.cartItems = cartItems;
|
||||
|
||||
// CGV
|
||||
$scope.cgv = cgv.custom_asset;
|
||||
|
||||
// Reservation
|
||||
$scope.reservation = reservation;
|
||||
|
||||
// Used in wallet info template to interpolate some translations
|
||||
$scope.numberFilter = $filter('number');
|
||||
|
||||
// stripe publishable key
|
||||
$scope.stripeKey = stripeKey.setting.value;
|
||||
|
||||
/**
|
||||
* Callback to handle the post-payment and reservation
|
||||
*/
|
||||
$scope.onPaymentSuccess = function (response) {
|
||||
$uibModalInstance.close(response);
|
||||
};
|
||||
}
|
||||
]
|
||||
}).result.finally(null).then(function (reservation) { afterPayment(reservation); });
|
||||
// check that the online payment is enabled
|
||||
if ($scope.settings.online_payment_module !== 'true') {
|
||||
growl.error(_t('app.shared.cart.online_payment_disabled'));
|
||||
} else {
|
||||
$scope.toggleStripeModal(() => {
|
||||
$scope.stripe.cartItems = mkCartItems(reservation, 'stripe');
|
||||
});
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Open a modal window that allows the user to process a local payment for his current shopping cart (admin only).
|
||||
@ -676,25 +731,40 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
const payOnSite = function (reservation) {
|
||||
$uibModal.open({
|
||||
templateUrl: '/shared/valid_reservation_modal.html',
|
||||
size: 'sm',
|
||||
size: $scope.schedule.payment_schedule ? 'lg' : 'sm',
|
||||
resolve: {
|
||||
reservation () {
|
||||
return reservation;
|
||||
},
|
||||
price () {
|
||||
return Price.compute(mkRequestParams(reservation, $scope.coupon.applied)).$promise;
|
||||
return Price.compute(mkRequestParams({ reservation }, $scope.coupon.applied)).$promise;
|
||||
},
|
||||
cartItems () {
|
||||
return mkCartItems(reservation, 'stripe');
|
||||
},
|
||||
wallet () {
|
||||
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
|
||||
},
|
||||
coupon () {
|
||||
return $scope.coupon.applied;
|
||||
},
|
||||
selectedPlan () {
|
||||
return $scope.selectedPlan;
|
||||
},
|
||||
schedule () {
|
||||
return $scope.schedule;
|
||||
},
|
||||
user () {
|
||||
return $scope.user;
|
||||
},
|
||||
settings () {
|
||||
return $scope.settings;
|
||||
}
|
||||
},
|
||||
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'wallet', 'helpers', '$filter', 'coupon',
|
||||
function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, wallet, helpers, $filter, coupon) {
|
||||
// user wallet amount
|
||||
$scope.walletAmount = wallet.amount;
|
||||
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'Subscription', 'wallet', 'helpers', '$filter', 'coupon', 'selectedPlan', 'schedule', 'cartItems', 'user', 'settings',
|
||||
function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, Subscription, wallet, helpers, $filter, coupon, selectedPlan, schedule, cartItems, user, settings) {
|
||||
// user wallet amount
|
||||
$scope.wallet = wallet;
|
||||
|
||||
// Global price (total of all items)
|
||||
$scope.price = price.price;
|
||||
@ -702,57 +772,162 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
// Price to pay (wallet deducted)
|
||||
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
|
||||
|
||||
// Reservation
|
||||
// Reservation (simple & cartItems format)
|
||||
$scope.reservation = reservation;
|
||||
$scope.cartItems = cartItems;
|
||||
|
||||
// Subscription
|
||||
$scope.plan = selectedPlan;
|
||||
|
||||
// Used in wallet info template to interpolate some translations
|
||||
$scope.numberFilter = $filter('number');
|
||||
|
||||
// Button label
|
||||
if ($scope.amount > 0) {
|
||||
$scope.validButtonName = _t('app.shared.cart.confirm_payment_of_html', { ROLE: $rootScope.currentUser.role, AMOUNT: $filter('currency')($scope.amount) });
|
||||
} else {
|
||||
if ((price.price > 0) && ($scope.walletAmount === 0)) {
|
||||
$scope.validButtonName = _t('app.shared.cart.confirm_payment_of_html', { ROLE: $rootScope.currentUser.role, AMOUNT: $filter('currency')(price.price) });
|
||||
} else {
|
||||
$scope.validButtonName = _t('app.shared.buttons.confirm');
|
||||
}
|
||||
}
|
||||
// Shows the schedule info in the modal
|
||||
$scope.schedule = schedule.payment_schedule;
|
||||
|
||||
// how should we collect payments for the payment schedule
|
||||
$scope.method = {
|
||||
payment_method: 'stripe'
|
||||
};
|
||||
|
||||
// "valid" Button label
|
||||
$scope.validButtonName = '';
|
||||
|
||||
// stripe modal state
|
||||
// this is used to collect card data when a payment-schedule was selected, and paid with a card
|
||||
$scope.isOpenStripeModal = false;
|
||||
|
||||
// the customer
|
||||
$scope.user = user;
|
||||
|
||||
/**
|
||||
* Callback to process the local payment, triggered on button click
|
||||
*/
|
||||
$scope.ok = function () {
|
||||
$scope.attempting = true;
|
||||
return Reservation.save(mkRequestParams($scope.reservation, coupon), function (reservation) {
|
||||
$uibModalInstance.close(reservation);
|
||||
return $scope.attempting = true;
|
||||
if ($scope.schedule && $scope.method.payment_method === 'stripe') {
|
||||
// check that the online payment is enabled
|
||||
if (settings.online_payment_module !== 'true') {
|
||||
return growl.error(_t('app.shared.cart.online_payment_disabled'));
|
||||
} else {
|
||||
return $scope.toggleStripeModal();
|
||||
}
|
||||
}
|
||||
, function (response) {
|
||||
$scope.attempting = true;
|
||||
// save subscription (if there's only a subscription selected)
|
||||
if ($scope.reservation.slots_attributes.length === 0 && selectedPlan) {
|
||||
const sub = mkSubscription(selectedPlan.id, $scope.reservation.user_id, schedule.requested_schedule, $scope.method.payment_method);
|
||||
|
||||
return Subscription.save(mkRequestParams(sub, coupon),
|
||||
function (subscription) {
|
||||
$uibModalInstance.close(subscription);
|
||||
$scope.attempting = true;
|
||||
}, function (response) {
|
||||
$scope.alerts = [];
|
||||
$scope.alerts.push({ msg: _t('app.shared.cart.a_problem_occurred_during_the_payment_process_please_try_again_later'), type: 'danger' });
|
||||
$scope.attempting = false;
|
||||
});
|
||||
}
|
||||
// otherwise, save the reservation (may include a subscription)
|
||||
const rsrv = Object.assign({}, $scope.reservation, { payment_method: $scope.method.payment_method });
|
||||
Reservation.save(mkRequestParams({ reservation: rsrv }, coupon), function (reservation) {
|
||||
$uibModalInstance.close(reservation);
|
||||
$scope.attempting = true;
|
||||
}, function (response) {
|
||||
$scope.alerts = [];
|
||||
$scope.alerts.push({ msg: _t('app.shared.cart.a_problem_occurred_during_the_payment_process_please_try_again_later'), type: 'danger' });
|
||||
return $scope.attempting = false;
|
||||
$scope.attempting = false;
|
||||
});
|
||||
};
|
||||
/**
|
||||
* Callback to close the modal without processing the payment
|
||||
*/
|
||||
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
|
||||
|
||||
/**
|
||||
* Asynchronously updates the status of the stripe modal
|
||||
*/
|
||||
$scope.toggleStripeModal = function () {
|
||||
setTimeout(() => {
|
||||
$scope.isOpenStripeModal = !$scope.isOpenStripeModal;
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* After creating a payment schedule by card, from an administrator.
|
||||
* @param result {*} Reservation or Subscription
|
||||
*/
|
||||
$scope.afterCreatePaymentSchedule = function (result) {
|
||||
$scope.toggleStripeModal();
|
||||
$uibModalInstance.close(result);
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/**
|
||||
* Kind of constructor: these actions will be realized first when the directive is loaded
|
||||
*/
|
||||
const initialize = function () {
|
||||
$scope.$watch('method.payment_method', function (newValue) {
|
||||
$scope.validButtonName = computeValidButtonName();
|
||||
$scope.cartItems = mkCartItems($scope.reservation, newValue);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the Label of the confirmation button
|
||||
*/
|
||||
const computeValidButtonName = function () {
|
||||
let method = '';
|
||||
if ($scope.schedule) {
|
||||
if (AuthService.isAuthorized(['admin', 'manager']) && $rootScope.currentUser.id !== reservation.user_id) {
|
||||
method = $scope.method.payment_method;
|
||||
} else {
|
||||
method = 'stripe';
|
||||
}
|
||||
}
|
||||
if ($scope.amount > 0) {
|
||||
return _t('app.shared.cart.confirm_payment_of_html', { METHOD: method, AMOUNT: $filter('currency')($scope.amount) });
|
||||
} else {
|
||||
if ((price.price > 0) && ($scope.wallet.amount === 0)) {
|
||||
return _t('app.shared.cart.confirm_payment_of_html', { METHOD: method, AMOUNT: $filter('currency')(price.price) });
|
||||
} else {
|
||||
return _t('app.shared.buttons.confirm');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// # !!! MUST BE CALLED AT THE END of the controller
|
||||
initialize();
|
||||
}
|
||||
]
|
||||
}).result.finally(null).then(function (reservation) { afterPayment(reservation); });
|
||||
};
|
||||
|
||||
/**
|
||||
* Actions to run after the payment was successful
|
||||
* @param paymentResult {*} may be a reservation or a subscription
|
||||
*/
|
||||
const afterPayment = function (reservation) {
|
||||
const afterPayment = function (paymentResult) {
|
||||
// we set the cart content as 'paid' to display a summary of the transaction
|
||||
$scope.events.paid = $scope.events.reserved;
|
||||
$scope.amountPaid = $scope.amountTotal;
|
||||
// we call the external callback if present
|
||||
if (typeof $scope.afterPayment === 'function') { $scope.afterPayment(reservation); }
|
||||
// we reset the coupon and the cart content and we unselect the slot
|
||||
$scope.events.reserved = [];
|
||||
$scope.coupon.applied = null;
|
||||
$scope.slot = null;
|
||||
return $scope.selectedPlan = null;
|
||||
if (typeof $scope.afterPayment === 'function') { $scope.afterPayment(paymentResult); }
|
||||
// we reset the coupon, and the cart content, and we unselect the slot
|
||||
$scope.coupon.applied = undefined;
|
||||
if ($scope.slot) {
|
||||
// reservation (+ subscription)
|
||||
$scope.slot = undefined;
|
||||
$scope.events.reserved = [];
|
||||
} else {
|
||||
// subscription only
|
||||
$scope.events = {};
|
||||
}
|
||||
$scope.paidPlan = $scope.selectedPlan;
|
||||
$scope.selectedPlan = undefined;
|
||||
$scope.schedule.requested_schedule = false;
|
||||
$scope.schedule.payment_schedule = undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -763,23 +938,29 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
|
||||
return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) {
|
||||
const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount);
|
||||
if ((AuthService.isAuthorized(['member']) && amountToPay > 0) ||
|
||||
if ((AuthService.isAuthorized(['member']) && (amountToPay > 0 || (amountToPay === 0 && hasOtherDeadlines()))) ||
|
||||
(AuthService.isAuthorized('manager') && $scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) {
|
||||
if ($scope.settings.online_payment_module !== 'true') {
|
||||
growl.error(_t('app.shared.cart.online_payment_disabled'));
|
||||
} else {
|
||||
return payByStripe(reservation);
|
||||
}
|
||||
return payByStripe(reservation);
|
||||
} else {
|
||||
if (AuthService.isAuthorized(['admin']) ||
|
||||
(AuthService.isAuthorized('manager') && $scope.user.id !== $rootScope.currentUser.id) ||
|
||||
amountToPay === 0) {
|
||||
(amountToPay === 0 && !hasOtherDeadlines())) {
|
||||
return payOnSite(reservation);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the later deadlines of the payment schedule exists and are not equal to zero
|
||||
* @return {boolean}
|
||||
*/
|
||||
const hasOtherDeadlines = function () {
|
||||
if (!$scope.schedule.payment_schedule) return false;
|
||||
if ($scope.schedule.payment_schedule.items.length < 2) return false;
|
||||
return $scope.schedule.payment_schedule.items[1].amount !== 0;
|
||||
};
|
||||
|
||||
// !!! MUST BE CALLED AT THE END of the directive
|
||||
return initialize();
|
||||
}
|
||||
|
@ -47,15 +47,18 @@ Application.Directives.directive('coupon', [ '$rootScope', 'Coupon', '_t', funct
|
||||
$scope.messages = [];
|
||||
if ($scope.couponCode === '') {
|
||||
$scope.status = 'pending';
|
||||
return $scope.coupon = null;
|
||||
$scope.coupon = null;
|
||||
} else {
|
||||
return Coupon.validate({ code: $scope.couponCode, user_id: $scope.userId, amount: $scope.total }, function (res) {
|
||||
Coupon.validate({ code: $scope.couponCode, user_id: $scope.userId, amount: $scope.total }, function (res) {
|
||||
$scope.status = 'valid';
|
||||
$scope.coupon = res;
|
||||
if (res.type === 'percent_off') {
|
||||
return $scope.messages.push({ type: 'success', message: _t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_PERCENT_discount', { PERCENT: res.percent_off }) });
|
||||
$scope.messages.push({ type: 'success', message: _t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_PERCENT_discount', { PERCENT: res.percent_off }) });
|
||||
} else {
|
||||
return $scope.messages.push({ type: 'success', message: _t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_AMOUNT_CURRENCY', { AMOUNT: res.amount_off, CURRENCY: $rootScope.currencySymbol }) });
|
||||
$scope.messages.push({ type: 'success', message: _t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_AMOUNT_CURRENCY', { AMOUNT: res.amount_off, CURRENCY: $rootScope.currencySymbol }) });
|
||||
}
|
||||
if (res.validity_per_user === 'once') {
|
||||
$scope.messages.push({ type: 'warning', message: _t('app.shared.coupon_input.coupon_validity_once') });
|
||||
}
|
||||
}
|
||||
, function (err) {
|
||||
|
@ -23,10 +23,8 @@ Application.Directives.directive('booleanSetting', ['Setting', 'growl', '_t',
|
||||
/**
|
||||
* This will update the value when the user toggles the switch button
|
||||
* @param checked {Boolean}
|
||||
* @param event {string}
|
||||
* @param id {string}
|
||||
*/
|
||||
$scope.toggleSetting = (checked, event, id) => {
|
||||
$scope.toggleSetting = (checked) => {
|
||||
setTimeout(() => {
|
||||
$scope.setting.value = checked;
|
||||
$scope.$apply();
|
||||
|
@ -15,7 +15,7 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t',
|
||||
onPaymentSuccess: '=',
|
||||
stripeKey: '@'
|
||||
},
|
||||
link: function($scope, element, attributes) {
|
||||
link: function ($scope, element, attributes) {
|
||||
const stripe = Stripe($scope.stripeKey);
|
||||
const elements = stripe.elements();
|
||||
|
||||
@ -51,11 +51,11 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t',
|
||||
const cardElement = form.find('#card-element');
|
||||
card.mount(cardElement[0]);
|
||||
|
||||
form.bind('submit', function() {
|
||||
form.bind('submit', function () {
|
||||
const button = form.find('button');
|
||||
button.prop('disabled', true);
|
||||
|
||||
stripe.createPaymentMethod('card', card).then(function({ paymentMethod, error }) {
|
||||
stripe.createPaymentMethod('card', card).then(function ({ paymentMethod, error }) {
|
||||
if (error) {
|
||||
growl.error(error.message);
|
||||
button.prop('disabled', false);
|
||||
@ -64,12 +64,12 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t',
|
||||
Payment.confirm({ payment_method_id: paymentMethod.id, cart_items: $scope.cartItems }, function (response) {
|
||||
// Handle server response (see Step 3)
|
||||
handleServerResponse(response, button);
|
||||
}, function(error) { handleServerResponse({ error }, button) });
|
||||
}, function (error) { handleServerResponse({ error }, button); });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function handleServerResponse(response, confirmButton) {
|
||||
function handleServerResponse (response, confirmButton) {
|
||||
if (response.error) {
|
||||
if (response.error.statusText) {
|
||||
growl.error(response.error.statusText);
|
||||
@ -81,16 +81,16 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t',
|
||||
// Use Stripe.js to handle required card action
|
||||
stripe.handleCardAction(
|
||||
response.payment_intent_client_secret
|
||||
).then(function(result) {
|
||||
).then(function (result) {
|
||||
if (result.error) {
|
||||
growl.error(result.error.message);
|
||||
confirmButton.prop('disabled', false);
|
||||
} else {
|
||||
// The card action has been handled
|
||||
// The PaymentIntent can be confirmed again on the server
|
||||
Payment.confirm({ payment_intent_id: result.paymentIntent.id, cart_items: $scope.cartItems }, function(confirmResult) {
|
||||
Payment.confirm({ payment_intent_id: result.paymentIntent.id, cart_items: $scope.cartItems }, function (confirmResult) {
|
||||
handleServerResponse(confirmResult, confirmButton);
|
||||
}, function(error) { handleServerResponse({ error }, confirmButton) });
|
||||
}, function (error) { handleServerResponse({ error }, confirmButton); });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
@ -45,7 +45,7 @@ Application.Filters.filter('machineFilter', [function () {
|
||||
};
|
||||
}]);
|
||||
|
||||
Application.Filters.filter('projectMemberFilter', [ 'Auth', function (Auth) {
|
||||
Application.Filters.filter('projectMemberFilter', ['Auth', function (Auth) {
|
||||
return function (projects, selectedMember) {
|
||||
if (!angular.isUndefined(projects) && angular.isDefined(selectedMember) && (projects != null) && (selectedMember != null) && (selectedMember !== '')) {
|
||||
const filteredProject = [];
|
||||
@ -165,7 +165,7 @@ Application.Filters.filter('simpleText', [function () {
|
||||
};
|
||||
}]);
|
||||
|
||||
Application.Filters.filter('toTrusted', [ '$sce', function ($sce) {
|
||||
Application.Filters.filter('toTrusted', ['$sce', function ($sce) {
|
||||
return text => $sce.trustAsHtml(text);
|
||||
}]);
|
||||
|
||||
@ -178,7 +178,7 @@ Application.Filters.filter('humanReadablePlanName', ['$filter', function ($filte
|
||||
if (plan != null) {
|
||||
let result = plan.base_name;
|
||||
if (groups != null) {
|
||||
for (let group of Array.from(groups)) {
|
||||
for (const group of Array.from(groups)) {
|
||||
if (group.id === plan.group_id) {
|
||||
if (short != null) {
|
||||
result += ` - ${group.slug}`;
|
||||
@ -318,7 +318,7 @@ Application.Filters.filter('toIsoDate', [function () {
|
||||
};
|
||||
}]);
|
||||
|
||||
Application.Filters.filter('booleanFormat', [ '_t', function (_t) {
|
||||
Application.Filters.filter('booleanFormat', ['_t', function (_t) {
|
||||
return function (boolean) {
|
||||
if (((typeof boolean === 'boolean') && boolean) || ((typeof boolean === 'string') && (boolean === 'true'))) {
|
||||
return _t('app.shared.buttons.yes');
|
||||
@ -328,7 +328,7 @@ Application.Filters.filter('booleanFormat', [ '_t', function (_t) {
|
||||
};
|
||||
}]);
|
||||
|
||||
Application.Filters.filter('maxCount', [ '_t', function (_t) {
|
||||
Application.Filters.filter('maxCount', ['_t', function (_t) {
|
||||
return function (max) {
|
||||
if ((typeof max === 'undefined') || (max === null) || ((typeof max === 'number') && (max === 0))) {
|
||||
return _t('app.admin.pricing.unlimited');
|
||||
|
25
app/frontend/src/javascript/lib/i18n.ts
Normal file
25
app/frontend/src/javascript/lib/i18n.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import i18n from 'i18next';
|
||||
import ICU from 'i18next-icu';
|
||||
import HttpApi from 'i18next-http-backend';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
declare var Fablab: any;
|
||||
|
||||
i18n
|
||||
.use(ICU)
|
||||
.use(HttpApi)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: Fablab.locale,
|
||||
fallbackLng: 'en',
|
||||
ns: ['admin', 'logged', 'public', 'shared'],
|
||||
defaultNS: 'shared',
|
||||
backend: {
|
||||
loadPath: '/api/translations/{{lng}}/app.{{ns}}'
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
});
|
||||
|
||||
export default i18n;
|
20
app/frontend/src/javascript/lib/wallet.ts
Normal file
20
app/frontend/src/javascript/lib/wallet.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Wallet } from '../models/wallet';
|
||||
|
||||
export default class WalletLib {
|
||||
private wallet: Wallet;
|
||||
|
||||
constructor (wallet: Wallet) {
|
||||
this.wallet = wallet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the price remaining to pay, after we have used the maximum possible amount in the wallet
|
||||
*/
|
||||
computeRemainingPrice = (price: number): number => {
|
||||
if (this.wallet.amount > price) {
|
||||
return 0;
|
||||
} else {
|
||||
return price - this.wallet.amount;
|
||||
}
|
||||
}
|
||||
}
|
37
app/frontend/src/javascript/lib/wrap-promise.ts
Normal file
37
app/frontend/src/javascript/lib/wrap-promise.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* This function wraps a Promise to make it compatible with react Suspense
|
||||
*/
|
||||
export interface IWrapPromise<T> {
|
||||
read: () => T
|
||||
}
|
||||
|
||||
function wrapPromise(promise: Promise<any>): IWrapPromise<any> {
|
||||
let status: string = 'pending';
|
||||
let response: any;
|
||||
|
||||
const suspender: Promise<any> = promise.then(
|
||||
(res) => {
|
||||
status = 'success'
|
||||
response = res
|
||||
},
|
||||
(err) => {
|
||||
status = 'error'
|
||||
response = err
|
||||
},
|
||||
);
|
||||
|
||||
const read = (): any => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
throw suspender
|
||||
case 'error':
|
||||
throw response
|
||||
default:
|
||||
return response
|
||||
}
|
||||
};
|
||||
|
||||
return { read };
|
||||
}
|
||||
|
||||
export default wrapPromise;
|
@ -7,4 +7,3 @@ export interface IApplication {
|
||||
Filters: IModule,
|
||||
Directives: IModule
|
||||
}
|
||||
|
18
app/frontend/src/javascript/models/custom-asset.ts
Normal file
18
app/frontend/src/javascript/models/custom-asset.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export enum CustomAssetName {
|
||||
LogoFile = 'logo-file',
|
||||
LogoBlackFile = 'logo-black-file',
|
||||
CguFile = 'cgu-file',
|
||||
CgvFile = 'cgv-file',
|
||||
ProfileImageFile = 'profile-image-file',
|
||||
FaviconFile = 'favicon-file'
|
||||
}
|
||||
|
||||
export interface CustomAsset {
|
||||
id: number,
|
||||
name: CustomAssetName,
|
||||
custom_asset_file_attributes: {
|
||||
id: number,
|
||||
attachment: string
|
||||
attachment_url: string
|
||||
}
|
||||
}
|
29
app/frontend/src/javascript/models/fablab.ts
Normal file
29
app/frontend/src/javascript/models/fablab.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export interface IFablab {
|
||||
plansModule: boolean,
|
||||
spacesModule: boolean,
|
||||
walletModule: boolean,
|
||||
statisticsModule: boolean,
|
||||
defaultHost: string,
|
||||
trackingId: string,
|
||||
superadminId: number,
|
||||
baseHostUrl: string,
|
||||
locale: string,
|
||||
moment_locale: string,
|
||||
summernote_locale: string,
|
||||
fullcalendar_locale: string,
|
||||
intl_locale: string,
|
||||
intl_currency: string,
|
||||
timezone: string,
|
||||
weekStartingDay: string,
|
||||
d3DateFormat: string,
|
||||
uibDateFormat: string,
|
||||
sessionTours: Array<string>,
|
||||
translations: {
|
||||
app: {
|
||||
shared: {
|
||||
buttons: Object,
|
||||
messages: Object,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
9
app/frontend/src/javascript/models/history-value.ts
Normal file
9
app/frontend/src/javascript/models/history-value.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export interface HistoryValue {
|
||||
id: number,
|
||||
value: string,
|
||||
created_at: Date
|
||||
user: {
|
||||
id: number,
|
||||
name: string
|
||||
}
|
||||
}
|
83
app/frontend/src/javascript/models/payment-schedule.ts
Normal file
83
app/frontend/src/javascript/models/payment-schedule.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { StripeIbanElement } from '@stripe/stripe-js';
|
||||
|
||||
export enum PaymentScheduleItemState {
|
||||
New = 'new',
|
||||
Pending = 'pending',
|
||||
RequirePaymentMethod = 'requires_payment_method',
|
||||
RequireAction = 'requires_action',
|
||||
Paid = 'paid',
|
||||
Error = 'error'
|
||||
}
|
||||
|
||||
export enum PaymentMethod {
|
||||
Stripe = 'stripe',
|
||||
Check = 'check'
|
||||
}
|
||||
export interface PaymentScheduleItem {
|
||||
id: number,
|
||||
amount: number,
|
||||
due_date: Date,
|
||||
state: PaymentScheduleItemState,
|
||||
invoice_id: number,
|
||||
payment_method: PaymentMethod,
|
||||
client_secret?: string,
|
||||
details: {
|
||||
recurring: number,
|
||||
adjustment?: number,
|
||||
other_items?: number,
|
||||
without_coupon?: number,
|
||||
subscription_id: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaymentSchedule {
|
||||
max_length: number;
|
||||
id: number,
|
||||
scheduled_type: string,
|
||||
scheduled_id: number,
|
||||
total: number,
|
||||
stp_subscription_id: string,
|
||||
reference: string,
|
||||
payment_method: string,
|
||||
wallet_amount: number,
|
||||
items: Array<PaymentScheduleItem>,
|
||||
created_at: Date,
|
||||
chained_footprint: boolean,
|
||||
user: {
|
||||
id: number,
|
||||
name: string
|
||||
},
|
||||
operator: {
|
||||
id: number,
|
||||
first_name: string,
|
||||
last_name: string,
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaymentScheduleIndexRequest {
|
||||
query: {
|
||||
reference?: string,
|
||||
customer?: string,
|
||||
date?: Date,
|
||||
page: number,
|
||||
size: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface CashCheckResponse {
|
||||
state: PaymentScheduleItemState,
|
||||
payment_method: PaymentMethod
|
||||
}
|
||||
|
||||
export interface RefreshItemResponse {
|
||||
state: 'refreshed'
|
||||
}
|
||||
|
||||
export interface PayItemResponse {
|
||||
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void',
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CancelScheduleResponse {
|
||||
canceled_at: Date
|
||||
}
|
31
app/frontend/src/javascript/models/payment.ts
Normal file
31
app/frontend/src/javascript/models/payment.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Reservation } from './reservation';
|
||||
import { SubscriptionRequest } from './subscription';
|
||||
|
||||
export interface PaymentConfirmation {
|
||||
requires_action?: boolean,
|
||||
payment_intent_client_secret?: string,
|
||||
success?: boolean,
|
||||
error?: {
|
||||
statusText: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface IntentConfirmation {
|
||||
client_secret: string
|
||||
}
|
||||
|
||||
export enum PaymentMethod {
|
||||
Stripe = 'stripe',
|
||||
Other = ''
|
||||
}
|
||||
|
||||
export interface CartItems {
|
||||
reservation?: Reservation,
|
||||
subscription?: SubscriptionRequest,
|
||||
coupon_code?: string
|
||||
}
|
||||
|
||||
export interface UpdateCardResponse {
|
||||
updated: boolean,
|
||||
error?: string
|
||||
}
|
42
app/frontend/src/javascript/models/plan.ts
Normal file
42
app/frontend/src/javascript/models/plan.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Price } from './price';
|
||||
|
||||
export enum Interval {
|
||||
Year = 'year',
|
||||
Month = 'month',
|
||||
Week = 'week'
|
||||
}
|
||||
|
||||
export enum PlanType {
|
||||
Plan = 'Plan',
|
||||
PartnerPlan = 'PartnerPlan'
|
||||
}
|
||||
|
||||
export interface Partner {
|
||||
first_name: string,
|
||||
last_name: string,
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: number,
|
||||
base_name: string,
|
||||
name: string,
|
||||
interval: Interval,
|
||||
interval_count: number,
|
||||
group_id: number,
|
||||
training_credit_nb: number,
|
||||
is_rolling: boolean,
|
||||
description: string,
|
||||
type: PlanType,
|
||||
ui_weight: number,
|
||||
disabled: boolean,
|
||||
monthly_payment: boolean
|
||||
amount: number
|
||||
prices: Array<Price>,
|
||||
plan_file_attributes: {
|
||||
id: number,
|
||||
attachment_identifier: string
|
||||
},
|
||||
plan_file_url: string,
|
||||
partners: Array<Partner>
|
||||
}
|
27
app/frontend/src/javascript/models/price.ts
Normal file
27
app/frontend/src/javascript/models/price.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export interface Price {
|
||||
id: number,
|
||||
group_id: number,
|
||||
plan_id: number,
|
||||
priceable_type: string,
|
||||
priceable_id: number,
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface ComputePriceResult {
|
||||
price: number,
|
||||
price_without_coupon: number,
|
||||
details?: {
|
||||
slots: Array<{
|
||||
start_at: Date,
|
||||
price: number,
|
||||
promo: boolean
|
||||
}>
|
||||
plan?: number
|
||||
},
|
||||
schedule?: {
|
||||
items: Array<{
|
||||
amount: number,
|
||||
due_date: Date
|
||||
}>
|
||||
}
|
||||
}
|
21
app/frontend/src/javascript/models/reservation.ts
Normal file
21
app/frontend/src/javascript/models/reservation.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export interface ReservationSlot {
|
||||
id?: number,
|
||||
start_at: Date,
|
||||
end_at: Date,
|
||||
availability_id: number,
|
||||
offered: boolean
|
||||
}
|
||||
|
||||
export interface Reservation {
|
||||
user_id: number,
|
||||
reservable_id: number,
|
||||
reservable_type: string,
|
||||
slots_attributes: Array<ReservationSlot>,
|
||||
plan_id?: number,
|
||||
nb_reserve_places?: number,
|
||||
payment_schedule?: boolean,
|
||||
tickets_attributes?: {
|
||||
event_price_category_id: number,
|
||||
booked: boolean,
|
||||
},
|
||||
}
|
109
app/frontend/src/javascript/models/setting.ts
Normal file
109
app/frontend/src/javascript/models/setting.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { HistoryValue } from './history-value';
|
||||
|
||||
export enum SettingName {
|
||||
AboutTitle = 'about_title',
|
||||
AboutBody = 'about_body',
|
||||
AboutContacts = 'about_contacts',
|
||||
PrivacyDraft = 'privacy_draft',
|
||||
PrivacyBody = 'privacy_body',
|
||||
PrivacyDpo = 'privacy_dpo',
|
||||
TwitterName = 'twitter_name',
|
||||
HomeBlogpost = 'home_blogpost',
|
||||
MachineExplicationsAlert = 'machine_explications_alert',
|
||||
TrainingExplicationsAlert = 'training_explications_alert',
|
||||
TrainingInformationMessage = 'training_information_message',
|
||||
SubscriptionExplicationsAlert = 'subscription_explications_alert',
|
||||
InvoiceLogo = 'invoice_logo',
|
||||
InvoiceReference = 'invoice_reference',
|
||||
InvoiceCodeActive = 'invoice_code-active',
|
||||
InvoiceCodeValue = 'invoice_code-value',
|
||||
InvoiceOrderNb = 'invoice_order-nb',
|
||||
InvoiceVATActive = 'invoice_VAT-active',
|
||||
InvoiceVATRate = 'invoice_VAT-rate',
|
||||
InvoiceText = 'invoice_text',
|
||||
InvoiceLegals = 'invoice_legals',
|
||||
BookingWindowStart = 'booking_window_start',
|
||||
BookingWindowEnd = 'booking_window_end',
|
||||
BookingSlotDuration = 'booking_slot_duration',
|
||||
BookingMoveEnable = 'booking_move_enable',
|
||||
BookingMoveDelay = 'booking_move_delay',
|
||||
BookingCancelEnable = 'booking_cancel_enable',
|
||||
BookingCancelDelay = 'booking_cancel_delay',
|
||||
MainColor = 'main_color',
|
||||
SecondaryColor = 'secondary_color',
|
||||
FablabName = 'fablab_name',
|
||||
NameGenre = 'name_genre',
|
||||
ReminderEnable = 'reminder_enable',
|
||||
ReminderDelay = 'reminder_delay',
|
||||
EventExplicationsAlert = 'event_explications_alert',
|
||||
SpaceExplicationsAlert = 'space_explications_alert',
|
||||
VisibilityYearly = 'visibility_yearly',
|
||||
VisibilityOthers = 'visibility_others',
|
||||
DisplayNameEnable = 'display_name_enable',
|
||||
MachinesSortBy = 'machines_sort_by',
|
||||
AccountingJournalCode = 'accounting_journal_code',
|
||||
AccountingCardClientCode = 'accounting_card_client_code',
|
||||
AccountingCardClientLabel = 'accounting_card_client_label',
|
||||
AccountingWalletClientCode = 'accounting_wallet_client_code',
|
||||
AccountingWalletClientLabel = 'accounting_wallet_client_label',
|
||||
AccountingOtherClientCode = 'accounting_other_client_code',
|
||||
AccountingOtherClientLabel = 'accounting_other_client_label',
|
||||
AccountingWalletCode = 'accounting_wallet_code',
|
||||
AccountingWalletLabel = 'accounting_wallet_label',
|
||||
AccountingVATCode = 'accounting_VAT_code',
|
||||
AccountingVATLabel = 'accounting_VAT_label',
|
||||
AccountingSubscriptionCode = 'accounting_subscription_code',
|
||||
AccountingSubscriptionLabel = 'accounting_subscription_label',
|
||||
AccountingMachineCode = 'accounting_Machine_code',
|
||||
AccountingMachineLabel = 'accounting_Machine_label',
|
||||
AccountingTrainingCode = 'accounting_Training_code',
|
||||
AccountingTrainingLabel = 'accounting_Training_label',
|
||||
AccountingEventCode = 'accounting_Event_code',
|
||||
AccountingEventLabel = 'accounting_Event_label',
|
||||
AccountingSpaceCode = 'accounting_Space_code',
|
||||
AccountingSpaceLabel = 'accounting_Space_label',
|
||||
HubLastVersion = 'hub_last_version',
|
||||
HubPublicKey = 'hub_public_key',
|
||||
FabAnalytics = 'fab_analytics',
|
||||
LinkName = 'link_name',
|
||||
HomeContent = 'home_content',
|
||||
HomeCss = 'home_css',
|
||||
Origin = 'origin',
|
||||
Uuid = 'uuid',
|
||||
PhoneRequired = 'phone_required',
|
||||
TrackingId = 'tracking_id',
|
||||
BookOverlappingSlots = 'book_overlapping_slots',
|
||||
SlotDuration = 'slot_duration',
|
||||
EventsInCalendar = 'events_in_calendar',
|
||||
SpacesModule = 'spaces_module',
|
||||
PlansModule = 'plans_module',
|
||||
InvoicingModule = 'invoicing_module',
|
||||
FacebookAppId = 'facebook_app_id',
|
||||
TwitterAnalytics = 'twitter_analytics',
|
||||
RecaptchaSiteKey = 'recaptcha_site_key',
|
||||
RecaptchaSecretKey = 'recaptcha_secret_key',
|
||||
FeatureTourDisplay = 'feature_tour_display',
|
||||
EmailFrom = 'email_from',
|
||||
DisqusShortname = 'disqus_shortname',
|
||||
AllowedCadExtensions = 'allowed_cad_extensions',
|
||||
AllowedCadMimeTypes = 'allowed_cad_mime_types',
|
||||
OpenlabAppId = 'openlab_app_id',
|
||||
OpenlabAppSecret = 'openlab_app_secret',
|
||||
OpenlabDefault = 'openlab_default',
|
||||
OnlinePaymentModule = 'online_payment_module',
|
||||
StripePublicKey = 'stripe_public_key',
|
||||
StripeSecretKey = 'stripe_secret_key',
|
||||
StripeCurrency = 'stripe_currency',
|
||||
InvoicePrefix = 'invoice_prefix',
|
||||
ConfirmationRequired = 'confirmation_required',
|
||||
WalletModule = 'wallet_module',
|
||||
StatisticsModule = 'statistics_module',
|
||||
UpcomingEventsShown = 'upcoming_events_shown'
|
||||
}
|
||||
|
||||
export interface Setting {
|
||||
name: SettingName,
|
||||
value: string,
|
||||
last_update: Date,
|
||||
history: Array<HistoryValue>
|
||||
}
|
18
app/frontend/src/javascript/models/subscription.ts
Normal file
18
app/frontend/src/javascript/models/subscription.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Plan } from './plan';
|
||||
import { PaymentMethod } from './payment';
|
||||
|
||||
export interface Subscription {
|
||||
id: number,
|
||||
plan_id: number,
|
||||
expired_at: Date,
|
||||
canceled_at?: Date,
|
||||
stripe: boolean,
|
||||
plan: Plan
|
||||
}
|
||||
|
||||
export interface SubscriptionRequest {
|
||||
plan_id: number,
|
||||
user_id: number,
|
||||
payment_schedule: boolean,
|
||||
payment_method: PaymentMethod
|
||||
}
|
86
app/frontend/src/javascript/models/user.ts
Normal file
86
app/frontend/src/javascript/models/user.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { Plan } from './plan';
|
||||
|
||||
|
||||
export enum UserRole {
|
||||
Member = 'member',
|
||||
Manager = 'manager',
|
||||
Admin = 'admin'
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number,
|
||||
username: string,
|
||||
email: string,
|
||||
group_id: number,
|
||||
role: UserRole
|
||||
name: string,
|
||||
need_completion: boolean,
|
||||
ip_address: string,
|
||||
profile: {
|
||||
id: number,
|
||||
first_name: string,
|
||||
last_name: string,
|
||||
interest: string,
|
||||
software_mastered: string,
|
||||
phone: string,
|
||||
website: string,
|
||||
job: string,
|
||||
tours: Array<string>,
|
||||
facebook: string,
|
||||
twitter: string,
|
||||
google_plus: string,
|
||||
viadeo: string,
|
||||
linkedin: string,
|
||||
instagram: string,
|
||||
youtube: string,
|
||||
vimeo: string,
|
||||
dailymotion: string,
|
||||
github: string,
|
||||
echosciences: string,
|
||||
pinterest: string,
|
||||
lastfm: string,
|
||||
flickr: string,
|
||||
user_avatar: {
|
||||
id: number,
|
||||
attachment_url: string
|
||||
}
|
||||
},
|
||||
invoicing_profile: {
|
||||
id: number,
|
||||
address: {
|
||||
id: number,
|
||||
address: string
|
||||
},
|
||||
organization: {
|
||||
id: number,
|
||||
name: string,
|
||||
address: {
|
||||
id: number,
|
||||
address: string
|
||||
}
|
||||
}
|
||||
},
|
||||
statistic_profile: {
|
||||
id: number,
|
||||
gender: string,
|
||||
birthday: Date
|
||||
},
|
||||
subscribed_plan: Plan,
|
||||
subscription: {
|
||||
id: number,
|
||||
expired_at: Date,
|
||||
canceled_at: Date,
|
||||
stripe: boolean,
|
||||
plan: {
|
||||
id: number,
|
||||
base_name: string,
|
||||
name: string,
|
||||
interval: string,
|
||||
interval_count: number,
|
||||
amount: number
|
||||
}
|
||||
},
|
||||
training_credits: Array<number>,
|
||||
machine_credits: Array<{machine_id: number, hours_used: number}>,
|
||||
last_sign_in_at: Date
|
||||
}
|
6
app/frontend/src/javascript/models/wallet.ts
Normal file
6
app/frontend/src/javascript/models/wallet.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface Wallet {
|
||||
id: number,
|
||||
invoicing_profile_id: number,
|
||||
amount: number,
|
||||
user_id: number
|
||||
}
|
@ -205,6 +205,15 @@ angular.module('application.router', ['ui.router'])
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('app.logged.dashboard.payment_schedules', {
|
||||
url: '/payment_schedules',
|
||||
views: {
|
||||
'main@': {
|
||||
templateUrl: '/dashboard/payment_schedules.html',
|
||||
controller: 'DashboardController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('app.logged.dashboard.wallet', {
|
||||
url: '/wallet',
|
||||
abstract: !Fablab.walletModule,
|
||||
|
@ -28,7 +28,7 @@ Application.Services.factory('Help', ['$rootScope', '$uibModal', '$state', 'Auth
|
||||
|
||||
// if no tour, just open the guide
|
||||
if (tourName === undefined) {
|
||||
return window.open('https://github.com/sleede/fab-manager/raw/master/doc/fr/guide_utilisation_fab_manager_v4.5.pdf', '_blank');
|
||||
return window.open('https://github.com/sleede/fab-manager/raw/master/doc/fr/guide_utilisation_fab_manager_v4.7.pdf', '_blank');
|
||||
}
|
||||
|
||||
$uibModal.open({
|
||||
|
4
app/frontend/src/javascript/typings/import-png.d.ts
vendored
Normal file
4
app/frontend/src/javascript/typings/import-png.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module "*.png" {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
@ -291,33 +291,50 @@
|
||||
padding: 15px 0;
|
||||
background-color: $bg-gray;
|
||||
|
||||
.wrap {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
.wrap, .wrap-monthly {
|
||||
width: 130px;
|
||||
height: 130px;
|
||||
display: inline-block;
|
||||
background: white;
|
||||
|
||||
@include border-radius(50%, 50%, 50%, 50%);
|
||||
|
||||
border: 3px solid;
|
||||
|
||||
.price {
|
||||
width: 114px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
@include border-radius(50%, 50%, 50%, 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.wrap-monthly {
|
||||
& > .price {
|
||||
& > .amount {
|
||||
padding-top: 4px;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
& > .period {
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.price {
|
||||
position: relative;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
height: 84px;
|
||||
width: 84px;
|
||||
height: 114px;
|
||||
background-color: black;
|
||||
|
||||
@include border-radius(50%, 50%, 50%, 50%);
|
||||
|
||||
.amount {
|
||||
padding-top: 16px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
font-weight: bold;
|
||||
font-size: rem-calc(18);
|
||||
font-size: rem-calc(17);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@ -333,7 +350,10 @@
|
||||
.cta-button {
|
||||
margin: 20px 0;
|
||||
|
||||
.btn {
|
||||
.subscribe-button {
|
||||
@extend .btn;
|
||||
@extend .rounded;
|
||||
|
||||
outline: 0;
|
||||
font-weight: 600;
|
||||
font-size: rem-calc(16);
|
||||
@ -341,6 +361,12 @@
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
button.subscribe-button:focus, button.subscribe-button:hover {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
.info-link {
|
||||
margin-top: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
@ -766,3 +792,11 @@
|
||||
input[type=date].form-control {
|
||||
line-height: 25px;
|
||||
}
|
||||
|
||||
.select-schedule {
|
||||
margin-top: 0.5em;
|
||||
.schedule-switch {
|
||||
vertical-align: middle;
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
|
@ -21,5 +21,16 @@
|
||||
@import "modules/signup";
|
||||
@import "modules/stripe";
|
||||
@import "modules/tour";
|
||||
@import "modules/fab-modal";
|
||||
@import "modules/fab-button";
|
||||
@import "modules/payment-schedule-summary";
|
||||
@import "modules/wallet-info";
|
||||
@import "modules/stripe-modal";
|
||||
@import "modules/labelled-input";
|
||||
@import "modules/document-filters";
|
||||
@import "modules/payment-schedules-table";
|
||||
@import "modules/payment-schedules-list";
|
||||
@import "modules/stripe-confirm";
|
||||
@import "modules/payment-schedule-dashboard";
|
||||
|
||||
@import "app.responsive";
|
||||
|
@ -0,0 +1,8 @@
|
||||
.document-filters {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
& > * {
|
||||
width: 31%;
|
||||
}
|
||||
}
|
48
app/frontend/src/stylesheets/modules/fab-button.scss
Normal file
48
app/frontend/src/stylesheets/modules/fab-button.scss
Normal file
@ -0,0 +1,48 @@
|
||||
.fab-button {
|
||||
color: black;
|
||||
background-color: #fbfbfb;
|
||||
display: inline-block;
|
||||
margin-bottom: 0;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
background-image: none;
|
||||
border: 1px solid #c9c9c9;
|
||||
padding: 6px 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
color: black;
|
||||
border-color: #aaaaaa;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: black;
|
||||
background-color: #f2f2f2;
|
||||
border-color: #aaaaaa;
|
||||
outline: 0;
|
||||
box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
|
||||
}
|
||||
|
||||
|
||||
&[disabled] {
|
||||
color: #3a3a3a;
|
||||
}
|
||||
|
||||
&[disabled]:hover {
|
||||
color: #3a3a3a;
|
||||
}
|
||||
|
||||
&--icon {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
87
app/frontend/src/stylesheets/modules/fab-modal.scss
Normal file
87
app/frontend/src/stylesheets/modules/fab-modal.scss
Normal file
@ -0,0 +1,87 @@
|
||||
@keyframes slideInFromTop {
|
||||
0% { transform: translate(0, -25%); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 0.9; }
|
||||
}
|
||||
|
||||
.fab-modal-overlay {
|
||||
z-index: 1050;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
animation: 0.15s linear fadeIn;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fab-modal-sm { width: 340px; }
|
||||
.fab-modal-md { width: 440px; }
|
||||
.fab-modal-lg { width: 600px; }
|
||||
|
||||
.fab-modal {
|
||||
animation: 0.3s ease-out slideInFromTop;
|
||||
position: relative;
|
||||
top: 90px;
|
||||
margin: auto;
|
||||
opacity: 1;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
|
||||
background-color: #fff;
|
||||
background-clip: padding-box;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
outline: 0;
|
||||
|
||||
.fab-modal-header {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
.modal-logo {
|
||||
position: absolute;
|
||||
top: -70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
max-height: 44px;
|
||||
}
|
||||
h1 {
|
||||
margin: 25px 0 20px 0;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-modal-content {
|
||||
position: relative;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.fab-modal-footer {
|
||||
position: relative;
|
||||
padding: 15px;
|
||||
text-align: right;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
|
||||
.modal-btn {
|
||||
&--close {
|
||||
color: black;
|
||||
background-color: #fbfbfb;
|
||||
border: 1px solid #c9c9c9;
|
||||
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
48
app/frontend/src/stylesheets/modules/labelled-input.scss
Normal file
48
app/frontend/src/stylesheets/modules/labelled-input.scss
Normal file
@ -0,0 +1,48 @@
|
||||
.input-with-label {
|
||||
position: relative;
|
||||
display: inline-table;
|
||||
border-collapse: separate;
|
||||
box-sizing: border-box;
|
||||
|
||||
label.label {
|
||||
padding: 6px 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
color: #555555;
|
||||
text-align: center;
|
||||
background-color: #eeeeee;
|
||||
border: 1px solid #c4c4c4;
|
||||
border-radius: 4px 0 0 4px;
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
display: table-cell;
|
||||
box-sizing: border-box;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
input.input {
|
||||
padding: 6px 12px;
|
||||
height: 38px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
border: 1px solid #c4c4c4;
|
||||
border-radius: 0 4px 4px 0;
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
|
||||
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
|
||||
display: table-cell;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
float: left;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: #fdde3f;
|
||||
outline: 0;
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(253, 222, 63, .6);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
.payment-schedules-dashboard {
|
||||
|
||||
margin: 30px 15px 15px;
|
||||
|
||||
.schedules-list {
|
||||
text-align: center;
|
||||
|
||||
.load-more {
|
||||
margin-top: 2em;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
.payment-schedule-summary {
|
||||
h4 {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 2em;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
margin-bottom: 0.75em;
|
||||
.schedule-item-info {
|
||||
display: inline;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.schedule-item-price {
|
||||
display: inline;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.schedule-item-date {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-full-schedule {
|
||||
margin-left: 1em;
|
||||
font-size: 0.8em;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
margin-bottom: 2em;
|
||||
|
||||
&:before {
|
||||
content: '\f06e';
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
margin-right: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.full-schedule {
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
border-bottom: 1px solid #ddd;
|
||||
margin-right: 3em;
|
||||
}
|
||||
|
||||
.schedule-item-price {
|
||||
color: #5a5a5a;
|
||||
float: right;
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
.schedules-filters {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.schedules-list {
|
||||
text-align: center;
|
||||
|
||||
.load-more {
|
||||
margin-top: 2em;
|
||||
}
|
||||
}
|
@ -0,0 +1,181 @@
|
||||
.schedules-table {
|
||||
table-layout: fixed;
|
||||
border: 1px solid #e9e9e9;
|
||||
border-top: 0;
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background-color: transparent;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
|
||||
& > thead {
|
||||
border-top: 1px solid #e9e9e9;
|
||||
|
||||
& > tr > th {
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
line-height: 1.5;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.w-35 { width: 35px; }
|
||||
.w-120 { width: 120px; }
|
||||
.w-200 { width: 200px; }
|
||||
|
||||
.schedules-table-body {
|
||||
table-layout: fixed;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e9e9e9;
|
||||
border-top: 0;
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
|
||||
& > tbody {
|
||||
background: #f7f7f9;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
line-height: 1.5;
|
||||
|
||||
& > tr > td {
|
||||
padding: 12px 10px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-top: 0;
|
||||
vertical-align: middle;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.5;
|
||||
|
||||
&.row-header {
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-items-table {
|
||||
table-layout: fixed;
|
||||
background-color: #fff;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
border: 1px solid #e9e9e9;
|
||||
border-top: 0;
|
||||
|
||||
& > thead {
|
||||
border-top: 1px solid #e9e9e9;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.5;
|
||||
|
||||
& > tr > th {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-top: 0;
|
||||
border-bottom: 1px solid #e9e9e9;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
padding: 2rem 1rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
& > tbody > tr > td {
|
||||
vertical-align: middle;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-top: 0;
|
||||
padding: 12px 10px;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.download-button {
|
||||
@extend .fab-button;
|
||||
|
||||
& > i {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
// The color classes above are automatically generated from PaymentScheduleItem.state
|
||||
.state-new {
|
||||
color: #3a3a3a;
|
||||
}
|
||||
.state-pending,
|
||||
.state-requires_payment_method,
|
||||
.state-requires_action {
|
||||
color: #d43333;
|
||||
}
|
||||
.state-paid,
|
||||
.state-error {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-modal.update-card-modal {
|
||||
.fab-modal-content {
|
||||
.card-form {
|
||||
background-color: #f4f3f3;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px 6px 0 0;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
padding: 15px;
|
||||
|
||||
.stripe-errors {
|
||||
padding: 4px 0;
|
||||
color: #9e2146;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
.submit-card {
|
||||
.submit-card-btn {
|
||||
width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0 0 6px 6px;
|
||||
border-top: 0;
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
background-color: #1d98ec;
|
||||
margin-bottom: 15px;
|
||||
|
||||
&[disabled] {
|
||||
background-color: lighten(#1d98ec, 20%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.payment-pending {
|
||||
@extend .submit-card-btn;
|
||||
@extend .submit-card-btn[disabled];
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.fab-modal-footer {
|
||||
.stripe-modal-icons {
|
||||
& {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fa.fa-lock {
|
||||
top: 7px;
|
||||
color: #9edd78;
|
||||
}
|
||||
|
||||
img {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
41
app/frontend/src/stylesheets/modules/stripe-confirm.scss
Normal file
41
app/frontend/src/stylesheets/modules/stripe-confirm.scss
Normal file
@ -0,0 +1,41 @@
|
||||
@keyframes spin { 100% { transform:rotate(360deg); } }
|
||||
|
||||
.stripe-confirm {
|
||||
.message {
|
||||
&--success:before {
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
content: "\f00c";
|
||||
color: #3c763d;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
&--error:before {
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
content: "\f00d";
|
||||
color: #840b0f;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
&--info:before {
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
content: "\f1ce";
|
||||
color: #a0a0a0;
|
||||
margin-right: 2em;
|
||||
animation:spin 2s linear infinite;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&--info {
|
||||
.message-text {
|
||||
margin-left: 1.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-text {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
79
app/frontend/src/stylesheets/modules/stripe-modal.scss
Normal file
79
app/frontend/src/stylesheets/modules/stripe-modal.scss
Normal file
@ -0,0 +1,79 @@
|
||||
.stripe-modal {
|
||||
.fab-modal-content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.stripe-form {
|
||||
background-color: #f4f3f3;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px 6px 0 0;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
padding: 15px;
|
||||
|
||||
.stripe-errors {
|
||||
padding: 4px 0;
|
||||
color: #9e2146;
|
||||
overflow: auto;
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
}
|
||||
.terms-of-sales {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
input {
|
||||
display: inline;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
.stripe-modal-icons {
|
||||
text-align: center;
|
||||
|
||||
.fa.fa-lock {
|
||||
top: 7px;
|
||||
color: #9edd78;
|
||||
}
|
||||
|
||||
img {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-schedule-info {
|
||||
border: 1px solid #faebcc;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
background-color: #fcf8e3;
|
||||
color: #8a6d3b;
|
||||
margin-top: 1em;
|
||||
|
||||
p {
|
||||
font-size: small;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
.validate-btn {
|
||||
width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0 0 6px 6px;
|
||||
border-top: 0;
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
background-color: #1d98ec;
|
||||
margin-bottom: 15px;
|
||||
|
||||
&[disabled] {
|
||||
background-color: lighten(#1d98ec, 20%);
|
||||
}
|
||||
}
|
||||
|
||||
.payment-pending {
|
||||
@extend .validate-btn;
|
||||
@extend .validate-btn[disabled];
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
25
app/frontend/src/stylesheets/modules/wallet-info.scss
Normal file
25
app/frontend/src/stylesheets/modules/wallet-info.scss
Normal file
@ -0,0 +1,25 @@
|
||||
.wallet-info {
|
||||
margin-left: 15px;
|
||||
margin-right: 15px;
|
||||
h3 {
|
||||
margin-top: 5px;
|
||||
}
|
||||
p {
|
||||
font-style: italic;
|
||||
}
|
||||
.info-deadlines {
|
||||
border: 1px solid #faebcc;
|
||||
padding: 15px;
|
||||
color: #8a6d3b;
|
||||
background-color: #fcf8e3;
|
||||
border-radius: 4px;
|
||||
font-style: normal;
|
||||
display: flex;
|
||||
|
||||
i {
|
||||
vertical-align: middle;
|
||||
line-height: 2.5em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
@ -38,7 +38,7 @@
|
||||
<div class="legends">
|
||||
<span class="calendar-legend text-sm border-formation" translate>{{ 'app.admin.calendar.trainings' }}</span><br>
|
||||
<span class="calendar-legend text-sm border-machine" translate>{{ 'app.admin.calendar.machines' }}</span><br>
|
||||
<span class="calendar-legend text-sm border-space" ng-show="modules.spaces" translate>{{ 'app.admin.calendar.spaces' }}</span>
|
||||
<span class="calendar-legend text-sm border-space" ng-show="$root.modules.spaces" translate>{{ 'app.admin.calendar.spaces' }}</span>
|
||||
<span class="calendar-legend text-sm border-event" ng-show="eventsInCalendar" translate>{{ 'app.admin.calendar.events' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -18,7 +18,7 @@
|
||||
<span translate>{{ 'app.admin.calendar.machine' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio" ng-show="modules.spaces">
|
||||
<div class="radio" ng-show="$root.modules.spaces">
|
||||
<label>
|
||||
<input type="radio" id="space" name="available_type" value="space" ng-model="availability.available_type" ng-disabled="spaces.length === 0">
|
||||
<span translate>{{ 'app.admin.calendar.space' }}</span>
|
||||
|
@ -79,6 +79,8 @@
|
||||
</select>
|
||||
<span class="help-block error" ng-show="couponForm['coupon[validity_per_user]'].$dirty && couponForm['coupon[validity_per_user]'].$error.required" translate>{{ 'app.shared.coupon.validity_per_user_is_required' }}</span>
|
||||
</div>
|
||||
<p class="alert alert-warning" ng-show="coupon.validity_per_user == 'once'" translate>{{ 'app.shared.coupon.warn_validity_once' }}</p>
|
||||
<p class="alert alert-warning" ng-show="coupon.validity_per_user == 'forever'" translate>{{ 'app.shared.coupon.warn_validity_forever' }}</p>
|
||||
|
||||
<div class="form-group" ng-class="{'has-error': errors['valid_until']}">
|
||||
<label for="coupon[valid_until]" translate>{{ 'app.shared.coupon.valid_until' }}</label>
|
||||
|
@ -30,10 +30,14 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12" ng-if="isAuthorized('admin')">
|
||||
<uib-tabset justified="true" active="tabs.active">
|
||||
<uib-tab heading="{{ 'app.admin.invoices.invoices_list' | translate }}" ng-show="modules.invoicing" index="0">
|
||||
<uib-tab heading="{{ 'app.admin.invoices.invoices_list' | translate }}" ng-show="$root.modules.invoicing" index="0">
|
||||
<ng-include src="'/admin/invoices/list.html'"></ng-include>
|
||||
</uib-tab>
|
||||
|
||||
<uib-tab heading="{{ 'app.admin.invoices.payment_schedules_list' | translate }}" ng-show="$root.modules.invoicing" index="4" class="payment-schedules-list">
|
||||
<payment-schedules-list current-user="currentUser" />
|
||||
</uib-tab>
|
||||
|
||||
<uib-tab heading="{{ 'app.admin.invoices.invoicing_settings' | translate }}" index="1" class="invoices-settings">
|
||||
<ng-include src="'/admin/invoices/settings.html'"></ng-include>
|
||||
</uib-tab>
|
||||
@ -49,7 +53,15 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-12" ng-if="isAuthorized('manager')">
|
||||
<ng-include src="'/admin/invoices/list.html'"></ng-include>
|
||||
<uib-tabset justified="true" active="tabs.active">
|
||||
<uib-tab heading="{{ 'app.admin.invoices.invoices_list' | translate }}" index="0">
|
||||
<ng-include src="'/admin/invoices/list.html'"></ng-include>
|
||||
</uib-tab>
|
||||
|
||||
<uib-tab heading="{{ 'app.admin.invoices.payment_schedules_list' | translate }}" index="4" class="payment-schedules-list">
|
||||
<payment-schedules-list current-user="currentUser" />
|
||||
</uib-tab>
|
||||
</uib-tabset>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -137,12 +137,18 @@
|
||||
|
||||
<script type="text/ng-template" id="addOnlineInfo.html">
|
||||
<table class="invoice-element-legend">
|
||||
<tr><td><strong>X[texte]</strong></td><td>{{ 'app.admin.invoices.add_a_notice_regarding_the_online_sales_only_if_the_invoice_is_concerned' | translate }} <mark translate>{{ 'app.admin.invoices.this_will_never_be_added_when_a_refund_notice_is_present' }}</mark> {{ 'app.admin.invoices.eg_XVL_will_add_VL_to_the_invoices_settled_with_stripe' | translate }}</td></tr>
|
||||
<tr><td><strong>X[{{ 'app.admin.invoices.text' | translate }}]</strong></td><td>{{ 'app.admin.invoices.add_a_notice_regarding_the_online_sales_only_if_the_invoice_is_concerned' | translate }} <mark translate>{{ 'app.admin.invoices.this_will_never_be_added_when_a_refund_notice_is_present' }}</mark> {{ 'app.admin.invoices.eg_XVL_will_add_VL_to_the_invoices_settled_with_stripe' | translate }}</td></tr>
|
||||
</table>
|
||||
</script>
|
||||
|
||||
<script type="text/ng-template" id="addRefundInfo.html">
|
||||
<table class="invoice-element-legend">
|
||||
<tr><td><strong>R[texte]</strong></td><td>{{ 'app.admin.invoices.add_a_notice_regarding_refunds_only_if_the_invoice_is_concerned' | translate }}<mark translate>{{ 'app.admin.invoices.this_will_never_be_added_when_an_online_sales_notice_is_present' }}</mark> {{ 'app.admin.invoices.eg_RA_will_add_A_to_the_refund_invoices' | translate }}</td></tr>
|
||||
<tr><td><strong>R[{{ 'app.admin.invoices.text' | translate }}]</strong></td><td>{{ 'app.admin.invoices.add_a_notice_regarding_refunds_only_if_the_invoice_is_concerned' | translate }}<mark translate>{{ 'app.admin.invoices.this_will_never_be_added_when_an_online_sales_notice_is_present' }}</mark> {{ 'app.admin.invoices.eg_RA_will_add_A_to_the_refund_invoices' | translate }}</td></tr>
|
||||
</table>
|
||||
</script>
|
||||
|
||||
<script type="text/ng-template" id="addPaymentScheduleInfo.html">
|
||||
<table class="invoice-element-legend">
|
||||
<tr><td><strong>S[{{ 'app.admin.invoices.text' | translate }}]</strong></td><td>{{ 'app.admin.invoices.add_a_notice_regarding_payment_schedule' | translate }}<mark translate>{{ 'app.admin.invoices.this_will_never_be_added_with_other_notices' }}</mark> {{ 'app.admin.invoices.eg_SE_to_schedules' | translate }}</td></tr>
|
||||
</table>
|
||||
</script>
|
||||
|
@ -12,6 +12,7 @@
|
||||
<li ng-click="invoice.reference.help = 'addInvoiceNumber.html'">{{ 'app.admin.invoices.num_of_invoice' | translate }}</li>
|
||||
<li ng-click="invoice.reference.help = 'addOnlineInfo.html'">{{ 'app.admin.invoices.online_sales' | translate }}</li>
|
||||
<li ng-click="invoice.reference.help = 'addRefundInfo.html'">{{ 'app.admin.invoices.refund' | translate }}</li>
|
||||
<li ng-click="invoice.reference.help = 'addPaymentScheduleInfo.html'">{{ 'app.admin.invoices.payment_schedule' | translate }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
|
@ -60,7 +60,7 @@
|
||||
</form>
|
||||
</uib-tab>
|
||||
|
||||
<uib-tab heading="{{ 'app.admin.members_edit.subscription' | translate }}" ng-if="modules.plans">
|
||||
<uib-tab heading="{{ 'app.admin.members_edit.subscription' | translate }}" ng-if="$root.modules.plans">
|
||||
|
||||
|
||||
<section class="panel panel-default bg-light m-lg">
|
||||
@ -192,7 +192,7 @@
|
||||
</div>
|
||||
</uib-tab>
|
||||
|
||||
<uib-tab heading="{{ 'app.admin.members_edit.invoices' | translate }}" ng-show="modules.invoicing">
|
||||
<uib-tab heading="{{ 'app.admin.members_edit.invoices' | translate }}" ng-show="$root.modules.invoicing">
|
||||
<div class="col-md-12 m m-t-lg">
|
||||
|
||||
|
||||
@ -229,7 +229,7 @@
|
||||
</div>
|
||||
</uib-tab>
|
||||
|
||||
<uib-tab heading="{{ 'app.admin.members_edit.wallet' | translate }}" ng-show="modules.wallet">
|
||||
<uib-tab heading="{{ 'app.admin.members_edit.wallet' | translate }}" ng-show="$root.modules.wallet">
|
||||
<div class="col-md-12 m m-t-lg">
|
||||
<ng-include src="'/wallet/show.html'"></ng-include>
|
||||
|
||||
|
@ -25,7 +25,7 @@
|
||||
<a class="btn btn-default" ng-href="api/members/export_members.xlsx" target="export-frame" ng-click="alertExport('members')">
|
||||
<i class="fa fa-file-excel-o"></i> {{ 'app.admin.members.members' | translate }}
|
||||
</a>
|
||||
<a class="btn btn-default" ng-href="api/members/export_subscriptions.xlsx" target="export-frame" ng-if="modules.plans" ng-click="alertExport('subscriptions')">
|
||||
<a class="btn btn-default" ng-href="api/members/export_subscriptions.xlsx" target="export-frame" ng-if="$root.modules.plans" ng-click="alertExport('subscriptions')">
|
||||
<i class="fa fa-file-excel-o"></i> {{ 'app.admin.members.subscriptions' | translate }}
|
||||
</a>
|
||||
<a class="btn btn-default" ng-href="api/members/export_reservations.xlsx" target="export-frame" ng-click="alertExport('reservations')">
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user