diff --git a/CHANGELOG.md b/CHANGELOG.md index 05434a27c..143d7b3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,47 @@ # Changelog Fab-manager +# v5.3.1 2022 January 17 + +- Definition of extended prices for spaces is now made in hours (previously in minutes) +- Support for JSONPath syntax in OAuth2 SSO fields mapping +- Basic support for OAuth2 scopes through an environment variable +- Ability to enable debug logs for the SSO authentication process using `SSO_DEBUG=true` +- Remove case sensitivity for the SSO account mapping process +- Ability to cancel a payement schedule from the interface +- Ability to create slots in the past +- Ability to disable public account creation +- Ability to select "bank transfer" as the payment mean for a payment schedule +- When a payment schedule was canceled by the payment gateway, alert the users +- When a payment schedule is in error, alert the users +- When a payment schedule is in error or canceled, ability to re-enable it with another payment method +- Fix card image ratio +- Update events heading style +- Update some icons +- Optimized the load time of the payment schedules list +- Optimized multiple DB queries +- Updated caniuse db +- Fix a bug: do not load Stripe if no keys were defined +- Fix a bug: some links redirect to the home page instead of triggering the requested action +- Fix a bug: exports to Excel are corrupted (#49) +- Fix a bug: if a specialized VAT rate was defined when the VAT was disabled, the resulting VAT rate is wrong +- Fix a bug: unable to rebuild the PDF for invoices without subscriptions +- Fix a bug: the switch to enable/disable the VAT does not reflect the current state of the VAT +- Fix a bug: SSO configuration interface has a misnamed field (Common URL) +- Fix a bug: unable to bind Profile.birthday and Profile.gender from an SSO +- Fix a security issue: updated follow-redirects to 1.14.7 to fix [CVE-2022-0155](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-0155) +- [TODO DEPLOY] `rails db:seed` + # v5.3.0 2021 December 29 - Ability to configure multiple VAT rates, per kind of invoiced item -- Refactored the extended prices frontend code to allow future customization +- Ability to export the collected VAT, by rates, to a CSV file +- Refactored the extended prices' frontend code to allow future customization - Fix a bug: the amount label in not correctly shown in the extended prices modal -- Fix a bug: `extended_prices_in_same_day` apply the extended prices to each days +- Fix a bug: `extended_prices_in_same_day` apply the extended prices to each day ## v5.2.0 2021 December 23 -- Ability to configure prices for spaces by time slots different than the default hourly rate +- Ability to configure prices for spaces, by time slots different from the default hourly rate - Updated portuguese translation - Refactored the ReserveButton component to use the same user's data across all the component - First optimization the load time of the payment schedules list @@ -26,21 +58,21 @@ ## v5.1.11 2021 October 22 - Refactored subscription new/renew/free extend interfaces and API -- Ability to configure data sources for preventing booking on overlapping slots -- Updated production documentation -- Updated SSO documentation -- Improved stripe subscription process with better error handling +- Ability to configure the data sources of the booking prevention on overlapping slots +- Updated the production documentation +- Updated the SSO documentation +- Improved the stripe subscription process with better error handling - The upgrade script will check and report the ability to access the hub API -- Fix a bug: canceled training reservation is not marked as this in admin/edit members/trainings +- Fix a bug: canceled training reservation is not marked as this in admin > edit members > trainings - Fix a bug: users can set their birthdate in the future -- Fix a bug: the upgrade script won't add environment variables that are already present anymore +- Fix a bug: the upgrade script won't add anymore the environment variables that are already present - Fix a bug: admin cannot take or renew a subscription for a member from member/edit interface - Fix a bug: missing translations - Fix a bug: the upgrade script report an invalid version to upgrade to -- Fix a bug: invalid amount provided to the PayZen payment gateway when using a currency with anything else than 2 decimals +- Fix a bug: invalid amount provided to the PayZen payment gateway, when using a 0-decimal or a 3-decimal currency - Fix a bug: incorrect behavior for the setting "email confirmation required" - Fix a bug: invalid text shown when a member confirms a free cart -- Fix a bug: 3DS confirmation is not asked when an admin is subscribing a user through a payment schedule using PayZen +- Fix a bug: 3DS confirmation is not asked when an admin is subscribing a user through a payment schedule, using PayZen - Updated @rails/webpacker to 5.4.3 - Updated react-refresh-webpack-plugin to 0.5.1 - Updated react-refresh to 0.10.0 @@ -55,17 +87,17 @@ ## v5.1.10 2021 October 04 -- Fix a bug: the image of the about page is not using the image set in backoffice +- Fix a bug: the image of the about page is not using the image set in the backoffice - Fix a bug: updated sassc to 2.4.0 to fix ruby runtime error on some CPU architectures (#270) - Fix a security issue: prevent HTML code edition in projects, to prevent XSS vulnerability (#293) -- Fix a bug : cover image doesn't display in profile -- Fix a bug : it redirects to home when we delete a machine record photo +- Fix a bug: cover image doesn't display in profile +- Fix a bug: fab-manager redirects to the home page when we delete a machine photo ## v5.1.9 2021 September 21 -- Add a setting for the purchase and use of a prepaid pack is only possible for the user with a valid subscription -- Fix a bug: unable to show plan name in calendar reservations -- Fix a bug: book overlapping slot setting label name +- Add a setting to restrict the purchase and use of a prepaid pack to users with a valid subscription +- Fix a bug: unable to view the plans names in the reservation calendar +- Fix a bug: label name of the book overlapping slot setting ## v5.1.8 2021 September 13 @@ -280,7 +312,11 @@ - [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet` - [TODO DEPLOY] `\curl -sSL https://raw.githubusercontent.com/sleede/fab-manager/master/scripts/rename-adminsys.sh | bash` -## v4.7.13 2020 June 11 +## v4.7.14 2021 September 30 + +- Fix a bug: updated sassc to 2.4.0 to fix ruby runtime error on some CPU architectures + +## v4.7.13 2021 June 11 - Fix a bug: unable to process stripe payments with 3DS authentication diff --git a/Gemfile b/Gemfile index 85a7fb58f..cfb4267e9 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,7 @@ gem 'webpacker', '~> 5.x' gem 'jbuilder', '~> 2.5' gem 'jbuilder_cache_multi' gem 'json', '>= 2.3.0' +gem 'jsonpath' gem 'forgery' gem 'responders', '~> 2.0' @@ -29,6 +30,7 @@ group :development do # Access an IRB console on exception pages or by using <%= console %> in views gem 'active_record_query_trace' gem 'awesome_print' + gem 'bullet' gem 'coveralls_reborn', '~> 0.18.0', require: false gem 'foreman' gem 'web-console', '>= 3.3.0' @@ -64,6 +66,7 @@ gem 'pg_search' # authentication gem 'devise', '>= 4.6.0' + gem 'omniauth', '~> 1.9.0' gem 'omniauth-oauth2' gem 'omniauth-rails_csrf_protection', '~> 0.1' diff --git a/Gemfile.lock b/Gemfile.lock index 932f16792..ee7be9a7c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -67,6 +67,9 @@ GEM bootsnap (1.4.6) msgpack (~> 1.0) builder (3.2.4) + bullet (7.0.0) + activesupport (>= 3.0.0) + uniform_notifier (~> 1.11) camertron-eprun (1.1.1) carrierwave (2.1.1) activemodel (>= 5.0.0) @@ -172,6 +175,8 @@ GEM jbuilder_cache_multi (0.1.0) jbuilder (>= 1.5.0, < 3) json (2.3.1) + jsonpath (1.1.0) + multi_json jwt (2.2.1) kaminari (1.2.1) activesupport (>= 4.1.0) @@ -394,6 +399,7 @@ GEM tzinfo-data (1.2020.4) tzinfo (>= 1.0.0) unicode-display_width (1.4.1) + uniform_notifier (1.14.2) vcr (6.0.0) virtus (1.0.5) axiom-types (~> 0.1) @@ -431,6 +437,7 @@ DEPENDENCIES apipie-rails awesome_print bootsnap + bullet carrierwave caxlsx caxlsx_rails @@ -451,6 +458,7 @@ DEPENDENCIES jbuilder (~> 2.5) jbuilder_cache_multi json (>= 2.3.0) + jsonpath kaminari listen (~> 3.0.5) message_format @@ -498,4 +506,4 @@ DEPENDENCIES webpacker (~> 5.x) BUNDLED WITH - 2.1.4 + 2.2.19 diff --git a/app/controllers/api/members_controller.rb b/app/controllers/api/members_controller.rb index 621010089..f3e3ac575 100644 --- a/app/controllers/api/members_controller.rb +++ b/app/controllers/api/members_controller.rb @@ -19,7 +19,7 @@ class API::MembersController < API::ApiController def last_subscribed @query = User.active.with_role(:member) - .includes(profile: [:user_avatar]) + .includes(:statistic_profile, profile: [:user_avatar]) .where('is_allow_contact = true AND confirmed_at IS NOT NULL') .order('created_at desc') .limit(params[:last]) diff --git a/app/controllers/api/notifications_controller.rb b/app/controllers/api/notifications_controller.rb index 71cf9be71..a77a55e0a 100644 --- a/app/controllers/api/notifications_controller.rb +++ b/app/controllers/api/notifications_controller.rb @@ -6,12 +6,15 @@ class API::NotificationsController < API::ApiController include NotifyWith::NotificationsApi before_action :authenticate_user! + # notifications can have anything attached, so we won't eager load the whole database + around_action :skip_bullet, if: -> { defined?(Bullet) } + # Number of notifications added to the page when the user clicks on 'load next notifications' NOTIFICATIONS_PER_PAGE = 15 def index loop do - @notifications = current_user.notifications.page(params[:page]).per(NOTIFICATIONS_PER_PAGE).order('created_at DESC') + @notifications = current_user.notifications.includes(:attached_object).page(params[:page]).per(NOTIFICATIONS_PER_PAGE).order('created_at DESC') # we delete obsolete notifications on first access break unless delete_obsoletes(@notifications) end @@ -24,7 +27,7 @@ class API::NotificationsController < API::ApiController def last_unread loop do - @notifications = current_user.notifications.where(is_read: false).limit(3).order('created_at DESC') + @notifications = current_user.notifications.includes(:attached_object).where(is_read: false).limit(3).order('created_at DESC') # we delete obsolete notifications on first access break unless delete_obsoletes(@notifications) end diff --git a/app/controllers/api/payment_schedules_controller.rb b/app/controllers/api/payment_schedules_controller.rb index 1b909bd8d..b994c8eb7 100644 --- a/app/controllers/api/payment_schedules_controller.rb +++ b/app/controllers/api/payment_schedules_controller.rb @@ -3,9 +3,10 @@ # 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] + before_action :set_payment_schedule, only: %i[download cancel update] + before_action :set_payment_schedule_item, only: %i[show_item cash_check confirm_transfer refresh_item pay_item] + # retrieve all payment schedules for the current user, paginated def index @payment_schedules = PaymentSchedule.where('invoicing_profile_id = ?', current_user.invoicing_profile.id) .includes(:invoicing_profile, :payment_schedule_items, :payment_schedule_objects) @@ -15,6 +16,7 @@ class API::PaymentSchedulesController < API::ApiController .per(params[:size]) end + # retrieve all payment schedules for all users. Filtering is supported def list authorize PaymentSchedule @@ -44,6 +46,15 @@ class API::PaymentSchedulesController < API::ApiController render json: attrs, status: :ok end + def confirm_transfer + authorize @payment_schedule_item.payment_schedule + PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'transfer') + attrs = { state: 'paid', payment_method: 'transfer' } + @payment_schedule_item.update_attributes(attrs) + + render json: attrs, status: :ok + end + def refresh_item authorize @payment_schedule_item.payment_schedule PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id) @@ -62,6 +73,11 @@ class API::PaymentSchedulesController < API::ApiController end end + def show_item + authorize @payment_schedule_item.payment_schedule + render json: @payment_schedule_item, status: :ok + end + def cancel authorize @payment_schedule @@ -69,6 +85,17 @@ class API::PaymentSchedulesController < API::ApiController render json: { canceled_at: canceled_at }, status: :ok end + ## Only the update of the payment method is allowed + def update + authorize PaymentSchedule + + if PaymentScheduleService.new.update_payment_mean(@payment_schedule, update_params) + render :show, status: :ok, location: @payment_schedule + else + render json: @payment_schedule.errors, status: :unprocessable_entity + end + end + private def set_payment_schedule @@ -78,4 +105,8 @@ class API::PaymentSchedulesController < API::ApiController def set_payment_schedule_item @payment_schedule_item = PaymentScheduleItem.find(params[:id]) end + + def update_params + params.require(:payment_schedule).permit(:payment_method) + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2077f304a..1301763bd 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -80,4 +80,13 @@ class ApplicationController < ActionController::Base def authenticate_user! super end + + # N+1 query detection (https://github.com/flyerhzm/bullet) + def skip_bullet + previous_value = Bullet.enable? + Bullet.enable = false + yield + ensure + Bullet.enable = previous_value + end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 7ceee313d..c958d347d 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -4,6 +4,11 @@ class RegistrationsController < Devise::RegistrationsController # POST /users.json def create + # Is public registration allowed? + unless Setting.get('public_registrations') + render json: { errors: { signup: [t('errors.messages.registration_disabled')] } }, status: :forbidden and return + end + # first check the recaptcha check = RecaptchaService.verify(params[:user][:recaptcha]) render json: check['error-codes'], status: :unprocessable_entity and return unless check['success'] diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index f0a9ba7a5..6cf918af4 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -1,16 +1,25 @@ +# frozen_string_literal: true + +# Handle authentication actions via OmniAuth (used by SSO providers) class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController + require 'sso_logger' + logger = SsoLogger.new active_provider = AuthProvider.active define_method active_provider.strategy_name do + logger.info "[Users::OmniauthCallbacksController##{active_provider.strategy_name}] initiated" if request.env['omniauth.params'].blank? + logger.debug 'the user has not provided any authentication token' @user = User.from_omniauth(request.env['omniauth.auth']) # Here we create the new user or update the existing one with values retrieved from the SSO. if @user.id.nil? # => new user (ie. not updating existing) + logger.debug 'trying to create a new user' # If the username is mapped, we just check its uniqueness as it would break the postgresql # unique constraint otherwise. If the name is not unique, another unique is generated if active_provider.sso_fields.include?('user.username') + logger.debug 'the username was already in use, generating a new one' @user.username = generate_unique_username(@user.username) end # If the email is mapped, we check its uniqueness. If the email is already in use, we mark it as duplicate with an @@ -18,17 +27,21 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController # - if it is the same user, his email will be filled from the SSO when he merge his accounts # - if it is not the same user, this will prevent the raise of PG::UniqueViolation if active_provider.sso_fields.include?('user.email') && email_exists?(@user.email) + logger.debug 'the email was already in use, marking it as duplicate' old_mail = @user.email @user.email = "<#{old_mail}>#{Devise.friendly_token}-duplicate" flash[:alert] = t('omniauth.email_already_linked_to_another_account_please_input_your_authentication_code', OLD_MAIL: old_mail) end else # => update of an existing user + logger.debug "an existing user was found (id=#{@user.id})" if username_exists?(@user.username, @user.id) + logger.debug 'the username was already in use, alerting user' flash[:alert] = t('omniauth.your_username_is_already_linked_to_another_account_unable_to_update_it', USERNAME: @user.username) @user.username = User.find(@user.id).username end if email_exists?(@user.email, @user.id) + logger.debug 'the email was already in use, alerting user' flash[:alert] = t('omniauth.your_email_address_is_already_linked_to_another_account_unable_to_update_it', EMAIL: @user.email) @user.email = User.find(@user.id).email end @@ -36,19 +49,32 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController # We BYPASS THE VALIDATION because, in case of a new user, we want to save him anyway, we'll ask him later to complete his profile (on first login). # In case of an existing user, we trust the SSO validation as we want the SSO to have authority on users management and policy. - @user.save(validate: false) + logger.debug 'saving the user' + unless @user.save(validate: false) + logger.error "unable to save the user, an error occurred : #{@user.errors.full_messages.join(', ')}" + end + + logger.debug 'signing-in the user and redirecting' sign_in_and_redirect @user, event: :authentication # this will throw if @user is not activated else + logger.debug 'the user has provided an authentication token' @user = User.find_by(auth_token: request.env['omniauth.params']['auth_token']) # Here the user already exists in the database and request to be linked with the SSO # so let's update its sso attributes and log him on + logger.debug "found user id=#{@user.id}" begin + logger.debug 'linking with the omniauth provider' @user.link_with_omniauth_provider(request.env['omniauth.auth']) + logger.debug 'signing-in the user and redirecting' sign_in_and_redirect @user, event: :authentication rescue DuplicateIndexError + logger.error 'user already linked' redirect_to root_url, alert: t('omniauth.this_account_is_already_linked_to_an_user_of_the_platform', NAME: active_provider.name) + rescue StandardError => e + logger.unknown "an expected error occurred: #{e}" + raise e end end @@ -58,17 +84,17 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController def username_exists?(username, exclude_id = nil) if exclude_id.nil? - User.where(username: username).size.positive? + User.where('lower(username) = ?', username&.downcase).size.positive? else - User.where(username: username).where.not(id: exclude_id).size.positive? + User.where('lower(username) = ?', username&.downcase).where.not(id: exclude_id).size.positive? end end def email_exists?(email, exclude_id = nil) if exclude_id.nil? - User.where(email: email).size.positive? + User.where('lower(email) = ?', email&.downcase).size.positive? else - User.where(email: email).where.not(id: exclude_id).size.positive? + User.where('lower(email) = ?', email&.downcase).where.not(id: exclude_id).size.positive? end end diff --git a/app/exceptions/payzen_error.rb b/app/exceptions/payzen_error.rb index e449f6965..a64d0aa49 100644 --- a/app/exceptions/payzen_error.rb +++ b/app/exceptions/payzen_error.rb @@ -2,5 +2,8 @@ # Raised when an an error occurred with the PayZen payment gateway class PayzenError < PaymentGatewayError + def details + JSON.parse(message.gsub('=>', ':').gsub('nil', 'null')) + end end diff --git a/app/frontend/src/javascript/api/payment-schedule.ts b/app/frontend/src/javascript/api/payment-schedule.ts index 2a098a33b..c5ce67cf3 100644 --- a/app/frontend/src/javascript/api/payment-schedule.ts +++ b/app/frontend/src/javascript/api/payment-schedule.ts @@ -4,7 +4,7 @@ import { CancelScheduleResponse, CashCheckResponse, PayItemResponse, PaymentSchedule, - PaymentScheduleIndexRequest, RefreshItemResponse + PaymentScheduleIndexRequest, PaymentScheduleItem, RefreshItemResponse } from '../models/payment-schedule'; export default class PaymentScheduleAPI { @@ -23,6 +23,16 @@ export default class PaymentScheduleAPI { return res?.data; } + static async confirmTransfer (paymentScheduleItemId: number): Promise { + const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/confirm_transfer`); + return res?.data; + } + + static async getItem (paymentScheduleItemId: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/payment_schedules/items/${paymentScheduleItemId}`); + return res?.data; + } + static async refreshItem (paymentScheduleItemId: number): Promise { const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/refresh_item`); return res?.data; @@ -37,4 +47,9 @@ export default class PaymentScheduleAPI { const res: AxiosResponse = await apiClient.put(`/api/payment_schedules/${paymentScheduleId}/cancel`); return res?.data; } + + static async update (paymentSchedule: PaymentSchedule): Promise { + const res:AxiosResponse = await apiClient.patch(`/api/payment_schedules/${paymentSchedule.id}`, paymentSchedule); + return res?.data; + } } diff --git a/app/frontend/src/javascript/components/base/html-translate.tsx b/app/frontend/src/javascript/components/base/html-translate.tsx index 68abc8b52..9b0c190cd 100644 --- a/app/frontend/src/javascript/components/base/html-translate.tsx +++ b/app/frontend/src/javascript/components/base/html-translate.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; interface HtmlTranslateProps { trKey: string, - options?: Record + options?: Record } /** diff --git a/app/frontend/src/javascript/components/base/loader.tsx b/app/frontend/src/javascript/components/base/loader.tsx index 5866e9a19..5e842e810 100644 --- a/app/frontend/src/javascript/components/base/loader.tsx +++ b/app/frontend/src/javascript/components/base/loader.tsx @@ -1,7 +1,7 @@ import React, { Suspense } from 'react'; /** - * This component is a wrapper that display a loader while the children components have their rendering suspended + * This component is a wrapper that display a loader while the children components have their rendering suspended. */ export const Loader: React.FC = ({ children }) => { const loading = ( diff --git a/app/frontend/src/javascript/components/payment-schedule/payment-schedule-item-actions.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedule-item-actions.tsx new file mode 100644 index 000000000..bd041f268 --- /dev/null +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedule-item-actions.tsx @@ -0,0 +1,413 @@ +import { + PaymentMethod, + PaymentSchedule, + PaymentScheduleItem, + PaymentScheduleItemState +} from '../../models/payment-schedule'; +import React, { ReactElement, useState } from 'react'; +import { FabButton } from '../base/fab-button'; +import { useTranslation } from 'react-i18next'; +import { User, UserRole } from '../../models/user'; +import PaymentScheduleAPI from '../../api/payment-schedule'; +import { FabModal } from '../base/fab-modal'; +import FormatLib from '../../lib/format'; +import { StripeConfirmModal } from '../payment/stripe/stripe-confirm-modal'; +import { UpdateCardModal } from '../payment/update-card-modal'; +import { UpdatePaymentMeanModal } from './update-payment-mean-modal'; + +// we want to display some buttons only once. This is the types of buttons it applies to. +export enum TypeOnce { + CardUpdate = 'card-update', + SubscriptionCancel = 'subscription-cancel', + UpdatePaymentMean = 'update-payment-mean' +} + +interface PaymentScheduleItemActionsProps { + paymentScheduleItem: PaymentScheduleItem, + paymentSchedule: PaymentSchedule, + onError: (message: string) => void, + onSuccess: () => void, + onCardUpdateSuccess: () => void + operator: User, + displayOnceMap: Map>, + show: boolean, +} + +/** + * This component is responsible for rendering the actions buttons for a payment schedule item. + */ +export const PaymentScheduleItemActions: React.FC = ({ paymentScheduleItem, paymentSchedule, onError, onSuccess, onCardUpdateSuccess, displayOnceMap, operator, show }) => { + const { t } = useTranslation('shared'); + + // is open, the modal dialog to cancel the associated subscription? + const [showCancelSubscription, setShowCancelSubscription] = useState(false); + // is open, the modal dialog to confirm the cashing of a check? + const [showConfirmCashing, setShowConfirmCashing] = useState(false); + // is open, the modal dialog to confirm a back transfer? + const [showConfirmTransfer, setShowConfirmTransfer] = useState(false); + // is open, the modal dialog the resolve a pending card payment? + const [showResolveAction, setShowResolveAction] = useState(false); + // is open, the modal dialog to update the card details + const [showUpdateCard, setShowUpdateCard] = useState(false); + // is open, the modal dialog to update the payment mean + const [showUpdatePaymentMean, setShowUpdatePaymentMean] = useState(false); + // the user cannot confirm the action modal (3D secure), unless he has resolved the pending action + const [isConfirmActionDisabled, setConfirmActionDisabled] = useState(true); + + /** + * 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 a button to download a PDF invoice file + */ + const downloadInvoiceButton = (id: number): JSX.Element => { + const link = `api/invoices/${id}/download`; + return ( + + + {t('app.shared.payment_schedule_item_actions.download')} + + ); + }; + + /** + * Return a button to cancel the given subscription, if the user is privileged enough + */ + const cancelSubscriptionButton = (): ReactElement => { + const displayOnceStatus = displayOnceMap.get(TypeOnce.SubscriptionCancel).get(paymentSchedule.id); + if (isPrivileged() && (!displayOnceStatus || displayOnceStatus === paymentScheduleItem.id)) { + displayOnceMap.get(TypeOnce.SubscriptionCancel).set(paymentSchedule.id, paymentScheduleItem.id); + return ( + }> + {t('app.shared.payment_schedule_item_actions.cancel_subscription')} + + ); + } + }; + + /** + * Return a button to confirm the receipt of the bank transfer, if the user is privileged enough + */ + const confirmTransferButton = (): ReactElement => { + if (isPrivileged()) { + return ( + }> + {t('app.shared.payment_schedule_item_actions.confirm_payment')} + + ); + } + }; + + /** + * Return a button to confirm the cashing of the check, if the user is privileged enough + */ + const confirmCheckButton = (): ReactElement => { + if (isPrivileged()) { + return ( + }> + {t('app.shared.payment_schedule_item_actions.confirm_check')} + + ); + } + }; + + /** + * Return a button to resolve the 3DS security check + */ + const solveActionButton = (): ReactElement => { + return ( + }> + {t('app.shared.payment_schedule_item_actions.resolve_action')} + + ); + }; + + /** + * Return a button to update the default payment mean for the current payment schedule + */ + const updatePaymentMeanButton = (): ReactElement => { + const displayOnceStatus = displayOnceMap.get(TypeOnce.UpdatePaymentMean).get(paymentSchedule.id); + if (isPrivileged() && (!displayOnceStatus || displayOnceStatus === paymentScheduleItem.id)) { + displayOnceMap.get(TypeOnce.UpdatePaymentMean).set(paymentSchedule.id, paymentScheduleItem.id); + return ( + }> + {t('app.shared.payment_schedule_item_actions.update_payment_mean')} + + ); + } + }; + + /** + * Return a button to update the credit card associated with the payment schedule + */ + const updateCardButton = (): ReactElement => { + const displayOnceStatus = displayOnceMap.get(TypeOnce.SubscriptionCancel).get(paymentSchedule.id); + if (isPrivileged() && (!displayOnceStatus || displayOnceStatus === paymentScheduleItem.id)) { + displayOnceMap.get(TypeOnce.CardUpdate).set(paymentSchedule.id, paymentScheduleItem.id); + return ( + }> + {t('app.shared.payment_schedule_item_actions.update_card')} + + ); + } + }; + + /** + * Return the actions button(s) for current paymentScheduleItem with state Pending + */ + const pendingActions = (): ReactElement => { + if (isPrivileged()) { + if (paymentSchedule.payment_method === PaymentMethod.Transfer) { + return confirmTransferButton(); + } + if (paymentSchedule.payment_method === PaymentMethod.Check) { + return confirmCheckButton(); + } + } else { + return {t('app.shared.payment_schedule_item_actions.please_ask_reception')}; + } + }; + + /** + * Return the actions button(s) for current paymentScheduleItem with state Error or GatewayCanceled + */ + const errorActions = (): ReactElement[] => { + // if the payment schedule is canceled/in error, the schedule is over, and we can't update the card + displayOnceMap.get(TypeOnce.CardUpdate).set(paymentSchedule.id, paymentScheduleItem.id); + + const buttons = []; + if (isPrivileged()) { + buttons.push(cancelSubscriptionButton()); + buttons.push(updatePaymentMeanButton()); + } else { + buttons.push({t('app.shared.payment_schedule_item_actions.please_ask_reception')}); + } + return buttons; + }; + + /** + * Return the actions button(s) for current paymentScheduleItem with state New + */ + const newActions = (): Array => { + const buttons = []; + if (paymentSchedule.payment_method === PaymentMethod.Card) { + buttons.push(updateCardButton()); + } + if (isPrivileged()) { + buttons.push(cancelSubscriptionButton()); + } + return buttons; + }; + + /** + * Show/hide the modal dialog to cancel the current subscription + */ + const toggleCancelSubscriptionModal = (): void => { + setShowCancelSubscription(!showCancelSubscription); + }; + + /** + * 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 enable to confirm the bank transfer for a given deadline. + */ + const toggleConfirmTransferModal = (): void => { + setShowConfirmTransfer(!showConfirmTransfer); + }; + + /** + * Show/hide the modal dialog that trigger the card "action". + */ + const toggleResolveActionModal = (): void => { + setShowResolveAction(!showResolveAction); + }; + + /** + * Enable/disable the confirm button of the "action" modal + */ + const toggleConfirmActionButton = (): void => { + setConfirmActionDisabled(!isConfirmActionDisabled); + }; + + /** + * Show/hide the modal dialog to update the bank card details + */ + const toggleUpdateCardModal = (): void => { + setShowUpdateCard(!showUpdateCard); + }; + + /** + * Show/hide the modal dialog to update the payment mean + */ + const toggleUpdatePaymentMeanModal = (): void => { + setShowUpdatePaymentMean(!showUpdatePaymentMean); + }; + + /** + * 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 => { + PaymentScheduleAPI.cashCheck(paymentScheduleItem.id).then((res) => { + if (res.state === PaymentScheduleItemState.Paid) { + onSuccess(); + toggleConfirmCashingModal(); + } + }); + }; + + /** + * After the user has confirmed that he validates the transfer, update the API, refresh the list and close the modal. + */ + const onTransferConfirmed = (): void => { + PaymentScheduleAPI.confirmTransfer(paymentScheduleItem.id).then((res) => { + if (res.state === PaymentScheduleItemState.Paid) { + onSuccess(); + toggleConfirmTransferModal(); + } + }); + }; + + /** + * When the card was successfully updated, pay the invoice (using the new payment method) and close the modal + */ + const handleCardUpdateSuccess = (): void => { + if (paymentScheduleItem.state === PaymentScheduleItemState.RequirePaymentMethod) { + PaymentScheduleAPI.payItem(paymentScheduleItem.id).then(() => { + onSuccess(); + onCardUpdateSuccess(); + toggleUpdateCardModal(); + }).catch((err) => { + onError(err); + }); + } else { + // the user is updating his card number in a pro-active way, we don't need to trigger the payment + onCardUpdateSuccess(); + toggleUpdateCardModal(); + } + }; + + /** + * When the user has confirmed the cancellation, we transfer the request to the API + */ + const onCancelSubscriptionConfirmed = (): void => { + PaymentScheduleAPI.cancel(paymentSchedule.id).then(() => { + onSuccess(); + toggleCancelSubscriptionModal(); + }); + }; + + /** + * After the 3DS confirmation was done (successfully or not), ask the API to refresh the item status, + * then refresh the list and close the modal + */ + const afterConfirmAction = (): void => { + toggleConfirmActionButton(); + PaymentScheduleAPI.refreshItem(paymentScheduleItem.id).then(() => { + onSuccess(); + toggleResolveActionModal(); + }); + }; + + /** + * When the update of the payment mean was successful, refresh the list and close the modal + */ + const onPaymentMeanUpdateSuccess = (): void => { + onSuccess(); + toggleUpdatePaymentMeanModal(); + }; + + if (!show) return null; + + return ( + + {paymentScheduleItem.state === PaymentScheduleItemState.Paid && downloadInvoiceButton(paymentScheduleItem.invoice_id)} + {paymentScheduleItem.state === PaymentScheduleItemState.Pending && pendingActions()} + {paymentScheduleItem.state === PaymentScheduleItemState.RequireAction && solveActionButton()} + {paymentScheduleItem.state === PaymentScheduleItemState.RequirePaymentMethod && updateCardButton()} + {paymentScheduleItem.state === PaymentScheduleItemState.Error && errorActions()} + {paymentScheduleItem.state === PaymentScheduleItemState.GatewayCanceled && errorActions()} + {paymentScheduleItem.state === PaymentScheduleItemState.New && newActions()} +
+ {/* Confirm the cashing of the current deadline by check */} + + + {t('app.shared.payment_schedule_item_actions.confirm_check_cashing_body', { + AMOUNT: FormatLib.price(paymentScheduleItem.amount), + DATE: FormatLib.date(paymentScheduleItem.due_date) + })} + + + {/* Confirm the bank transfer for the current deadline */} + + + {t('app.shared.payment_schedule_item_actions.confirm_bank_transfer_body', { + AMOUNT: FormatLib.price(paymentScheduleItem.amount), + DATE: FormatLib.date(paymentScheduleItem.due_date) + })} + + + {/* Cancel the subscription */} + + {t('app.shared.payment_schedule_item_actions.confirm_cancel_subscription')} + + {/* 3D secure confirmation */} + + {/* Update credit card */} + + + {/* Update the payment mean */} + +
+
+ ); +}; + +PaymentScheduleItemActions.defaultProps = { show: false }; diff --git a/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx index 6abb085ff..b36a40335 100644 --- a/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx @@ -2,15 +2,15 @@ import React, { ReactEventHandler, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Loader } from '../base/loader'; import _ from 'lodash'; -import { FabButton } from '../base/fab-button'; -import { FabModal } from '../base/fab-modal'; -import { UpdateCardModal } from '../payment/update-card-modal'; -import { StripeElements } from '../payment/stripe/stripe-elements'; -import { StripeConfirm } from '../payment/stripe/stripe-confirm'; -import { User, UserRole } from '../../models/user'; -import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from '../../models/payment-schedule'; -import PaymentScheduleAPI from '../../api/payment-schedule'; +import { User } from '../../models/user'; +import { + PaymentSchedule, + PaymentScheduleItem, + PaymentScheduleItemState +} from '../../models/payment-schedule'; import FormatLib from '../../lib/format'; +import { PaymentScheduleItemActions, TypeOnce } from './payment-schedule-item-actions'; +import { StripeElements } from '../payment/stripe/stripe-elements'; interface PaymentSchedulesTableProps { paymentSchedules: Array, @@ -29,23 +29,12 @@ const PaymentSchedulesTableComponent: React.FC = ({ // for each payment schedule: are the details (all deadlines) shown or hidden? const [showExpanded, setShowExpanded] = useState>(new Map()); - // is open, the modal dialog to confirm the cashing of a check? - const [showConfirmCashing, setShowConfirmCashing] = useState(false); - // is open, the modal dialog the resolve a pending card payment? - const [showResolveAction, setShowResolveAction] = useState(false); - // the user cannot confirm the action modal (3D secure), unless he has resolved the pending action - const [isConfirmActionDisabled, setConfirmActionDisabled] = useState(true); - // is open, the modal dialog to update the card details - const [showUpdateCard, setShowUpdateCard] = useState(false); - // when an action is triggered on a deadline, the deadline is saved here until the action is done or cancelled. - const [tempDeadline, setTempDeadline] = useState(null); - // when an action is triggered on a deadline, the parent schedule is saved here until the action is done or cancelled. - const [tempSchedule, setTempSchedule] = useState(null); - // is open, the modal dialog to cancel the associated subscription? - const [showCancelSubscription, setShowCancelSubscription] = useState(false); - - // we want to display the card update button, only once. This is an association table keeping when we already shown one - const cardUpdateButton = new Map(); + // we want to display some buttons only once. This map keep track of the buttons that have been displayed. + const [displayOnceMap] = useState>>(new Map([ + [TypeOnce.SubscriptionCancel, new Map()], + [TypeOnce.CardUpdate, new Map()], + [TypeOnce.UpdatePaymentMean, new Map()] + ])); /** * Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them @@ -89,19 +78,11 @@ const PaymentSchedulesTableComponent: React.FC = ({ }; }; - /** - * 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`; + const downloadScheduleButton = (id: number): JSX.Element => { + const link = `api/payment_schedules/${id}/download`; return ( @@ -113,8 +94,8 @@ const PaymentSchedulesTableComponent: React.FC = ({ /** * 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}`); + const formatState = (item: PaymentScheduleItem, schedule: PaymentSchedule): JSX.Element => { + let res = t(`app.shared.schedules_table.state_${item.state}${item.state === 'pending' ? '_' + schedule.payment_method : ''}`); if (item.state === PaymentScheduleItemState.Paid) { const key = `app.shared.schedules_table.method_${item.payment_method}`; res += ` (${t(key)})`; @@ -122,96 +103,6 @@ const PaymentSchedulesTableComponent: React.FC = ({ return {res}; }; - /** - * 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 ( - }> - {t('app.shared.schedules_table.confirm_payment')} - - ); - } else { - return {t('app.shared.schedules_table.please_ask_reception')}; - } - case PaymentScheduleItemState.RequireAction: - return ( - }> - {t('app.shared.schedules_table.solve')} - - ); - case PaymentScheduleItemState.RequirePaymentMethod: - return ( - }> - {t('app.shared.schedules_table.update_card')} - - ); - case PaymentScheduleItemState.Error: - // if the payment is in error, the schedule is over, and we can't update the card - cardUpdateButton.set(schedule.id, true); - if (isPrivileged()) { - return ( - }> - {t('app.shared.schedules_table.cancel_subscription')} - - ); - } else { - return {t('app.shared.schedules_table.please_ask_reception')}; - } - case PaymentScheduleItemState.New: - if (!cardUpdateButton.get(schedule.id)) { - cardUpdateButton.set(schedule.id, true); - return ( - }> - {t('app.shared.schedules_table.update_card')} - - ); - } - return ; - default: - return ; - } - }; - - /** - * 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 => { - PaymentScheduleAPI.cashCheck(tempDeadline.id).then((res) => { - if (res.state === PaymentScheduleItemState.Paid) { - refreshSchedulesTable(); - toggleConfirmCashingModal(); - } - }); - }; - /** * Refresh all payment schedules in the table */ @@ -219,216 +110,74 @@ const PaymentSchedulesTableComponent: React.FC = ({ refreshList(); }; - /** - * 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(); - PaymentScheduleAPI.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 = (paymentSchedule: PaymentSchedule, item?: PaymentScheduleItem): ReactEventHandler => { - return (): void => { - setTempDeadline(item); - setTempSchedule(paymentSchedule); - toggleUpdateCardModal(); - }; - }; - - /** - * Show/hide the modal dialog to update the bank card details - */ - const toggleUpdateCardModal = (): void => { - setShowUpdateCard(!showUpdateCard); - }; - - /** - * When the card was successfully updated, pay the invoice (using the new payment method) and close the modal - */ - const handleCardUpdateSuccess = (): void => { - if (tempDeadline) { - PaymentScheduleAPI.payItem(tempDeadline.id).then(() => { - refreshSchedulesTable(); - onCardUpdateSuccess(); - toggleUpdateCardModal(); - }).catch((err) => { - handleCardUpdateError(err); - }); - } else { - // if no tempDeadline (i.e. PaymentScheduleItem), then the user is updating his card number in a pro-active way, we don't need to trigger the payment - onCardUpdateSuccess(); - toggleUpdateCardModal(); - } - }; - - /** - * When the card was not updated, raise the error - */ - const handleCardUpdateError = (error): void => { - onError(error); - }; - - /** - * 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 => { - PaymentScheduleAPI.cancel(tempSchedule.id).then(() => { - refreshSchedulesTable(); - toggleCancelSubscriptionModal(); - }); - }; - return (
- - - - - - - {showCustomer && } - - - - {paymentSchedules.map(p => - - )} - -
- {t('app.shared.schedules_table.schedule_num')}{t('app.shared.schedules_table.date')}{t('app.shared.schedules_table.price')}{t('app.shared.schedules_table.customer')} -
- - - - - - - - {showCustomer && } - - - - - - -
{expandCollapseIcon(p.id)}{p.reference}{FormatLib.date(_.minBy(p.items, 'due_date').due_date)}{FormatLib.price(p.total)}{p.user.name}{downloadButton(TargetType.PaymentSchedule, p.id)}
- -
- - - - - - - - - - {_.orderBy(p.items, 'due_date').map(item => - - - - - )} - -
{t('app.shared.schedules_table.deadline')}{t('app.shared.schedules_table.amount')}{t('app.shared.schedules_table.state')} -
{FormatLib.date(item.due_date)}{FormatLib.price(item.amount)}{formatState(item)}{itemButtons(item, p)}
-
-
-
-
- - {tempDeadline && - {t('app.shared.schedules_table.confirm_check_cashing_body', { - AMOUNT: FormatLib.price(tempDeadline.amount), - DATE: FormatLib.date(tempDeadline.due_date) - })} - } - - - {t('app.shared.schedules_table.confirm_cancel_subscription')} - - - - {tempDeadline && } - - {tempSchedule && - } - -
+ + + + + + + + {showCustomer && } + + + + {paymentSchedules.map(p => + + )} + +
+ {t('app.shared.schedules_table.schedule_num')}{t('app.shared.schedules_table.date')}{t('app.shared.schedules_table.price')}{t('app.shared.schedules_table.customer')} +
+ + + + + + + + {showCustomer && } + + + + + + +
{expandCollapseIcon(p.id)}{p.reference}{FormatLib.date(_.minBy(p.items, 'due_date').due_date)}{FormatLib.price(p.total)}{p.user.name}{downloadScheduleButton(p.id)}
+ +
+ + + + + + + + + + {_.orderBy(p.items, 'due_date').map(item => + + + + + )} + +
{t('app.shared.schedules_table.deadline')}{t('app.shared.schedules_table.amount')}{t('app.shared.schedules_table.state')} +
{FormatLib.date(item.due_date)}{FormatLib.price(item.amount)}{formatState(item, p)} + +
+
+
+
+
); }; diff --git a/app/frontend/src/javascript/components/payment-schedule/update-payment-mean-modal.tsx b/app/frontend/src/javascript/components/payment-schedule/update-payment-mean-modal.tsx new file mode 100644 index 000000000..20974e132 --- /dev/null +++ b/app/frontend/src/javascript/components/payment-schedule/update-payment-mean-modal.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import Select from 'react-select'; +import { useTranslation } from 'react-i18next'; +import { FabModal } from '../base/fab-modal'; +import { PaymentMethod, PaymentSchedule } from '../../models/payment-schedule'; +import PaymentScheduleAPI from '../../api/payment-schedule'; + +interface UpdatePaymentMeanModalProps { + isOpen: boolean, + toggleModal: () => void, + onError: (message: string) => void, + afterSuccess: () => void, + paymentSchedule: PaymentSchedule +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: PaymentMethod, label: string }; + +export const UpdatePaymentMeanModal: React.FC = ({ isOpen, toggleModal, onError, afterSuccess, paymentSchedule }) => { + const { t } = useTranslation('admin'); + + const [paymentMean, setPaymentMean] = React.useState(); + + /** + * Convert all payment means to the react-select format + */ + const buildOptions = (): Array => { + return Object.keys(PaymentMethod).filter(pm => PaymentMethod[pm] !== PaymentMethod.Card).map(pm => { + return { value: PaymentMethod[pm], label: t(`app.admin.update_payment_mean_modal.method_${pm}`) }; + }); + }; + + /** + * When the payment mean is changed in the select, update the state + */ + const handleMeanSelected = (option: selectOption): void => { + setPaymentMean(option.value); + }; + + /** + * When the user clicks on the update button, update the default payment mean for the given payment schedule + */ + const handlePaymentMeanUpdate = (): void => { + PaymentScheduleAPI.update({ + id: paymentSchedule.id, + payment_method: paymentMean + }).then(() => { + afterSuccess(); + }).catch(error => { + onError(error.message); + }); + }; + + return ( + + {t('app.admin.update_payment_mean_modal.update_info')} + + + ); +}; diff --git a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx index c526be301..75feb9514 100644 --- a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx @@ -55,7 +55,7 @@ interface AbstractPaymentModalProps { /** * This component is an abstract modal that must be extended by each payment gateway to include its payment form. * - * This component must not be called directly but must be extended for each implemented payment gateway + * This component must not be called directly but must be extended for each implemented payment gateway. * @see https://reactjs.org/docs/composition-vs-inheritance.html */ export const AbstractPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => { diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx index 0192fd4b8..dc3ded3aa 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx @@ -8,9 +8,9 @@ import SettingAPI from '../../../api/setting'; import { SettingName } from '../../../models/setting'; import { PaymentModal } from '../payment-modal'; import { PaymentSchedule } from '../../../models/payment-schedule'; -import { PaymentMethod } from '../../../models/payment'; +import { HtmlTranslate } from '../../base/html-translate'; -const ALL_SCHEDULE_METHODS = ['card', 'check'] as const; +const ALL_SCHEDULE_METHODS = ['card', 'check', 'transfer'] as const; type scheduleMethod = typeof ALL_SCHEDULE_METHODS[number]; /** @@ -31,11 +31,7 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce const [onlinePaymentModal, setOnlinePaymentModal] = useState(false); useEffect(() => { - if (cart.payment_method === PaymentMethod.Card) { - setMethod('card'); - } else { - setMethod('check'); - } + setMethod(cart.payment_method || 'check'); }, [cart]); /** @@ -65,11 +61,7 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce * Callback triggered when the user selects a payment method for the current payment schedule. */ const handleUpdateMethod = (option: selectOption) => { - if (option.value === 'card') { - updateCart(Object.assign({}, cart, { payment_method: PaymentMethod.Card })); - } else { - updateCart(Object.assign({}, cart, { payment_method: PaymentMethod.Other })); - } + updateCart(Object.assign({}, cart, { payment_method: option.value })); setMethod(option.value); }; @@ -140,6 +132,7 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce value={methodToOption(method)} /> {method === 'card' &&

{t('app.admin.local_payment.card_collection_info')}

} {method === 'check' &&

{t('app.admin.local_payment.check_collection_info', { DEADLINES: paymentSchedule.items.length })}

} + {method === 'transfer' && }
    diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-confirm-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-confirm-modal.tsx new file mode 100644 index 000000000..32aa0991c --- /dev/null +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-confirm-modal.tsx @@ -0,0 +1,55 @@ +import { StripeConfirm } from './stripe-confirm'; +import { FabModal } from '../../base/fab-modal'; +import React, { useEffect, useState } from 'react'; +import PaymentScheduleAPI from '../../../api/payment-schedule'; +import { PaymentScheduleItem } from '../../../models/payment-schedule'; +import { useTranslation } from 'react-i18next'; + +interface StripeConfirmModalProps { + isOpen: boolean, + toggleModal: () => void, + onSuccess: () => void, + paymentScheduleItemId: number, +} + +/** + * Modal dialog that trigger a 3D secure confirmation for the given payment schedule item (deadline for a payment schedule). + */ +export const StripeConfirmModal: React.FC = ({ isOpen, toggleModal, onSuccess, paymentScheduleItemId }) => { + const { t } = useTranslation('shared'); + + const [item, setItem] = useState(null); + const [isPending, setIsPending] = useState(false); + + useEffect(() => { + PaymentScheduleAPI.getItem(paymentScheduleItemId).then(data => { + setItem(data); + }); + }, [paymentScheduleItemId]); + + /** + * Callback triggered when the confirm button was clicked in the modal. + */ + const onConfirmed = (): void => { + togglePending(); + onSuccess(); + }; + + /** + * Enable/disable the confirm button of the "action" modal + */ + const togglePending = (): void => { + setIsPending(!isPending); + }; + + return ( + + {item && } + + ); +}; diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx index c9252b3ab..68a912733 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx @@ -1,6 +1,6 @@ import React, { memo, useEffect, useState } from 'react'; import { Elements } from '@stripe/react-stripe-js'; -import { loadStripe } from '@stripe/stripe-js'; +import { loadStripe, Stripe } from '@stripe/stripe-js'; import { SettingName } from '../../../models/setting'; import SettingAPI from '../../../api/setting'; @@ -8,15 +8,17 @@ import SettingAPI from '../../../api/setting'; * This component initializes the stripe's Elements tag with the API key */ export const StripeElements: React.FC = memo(({ children }) => { - const [stripe, setStripe] = useState(undefined); + const [stripe, setStripe] = useState>(undefined); /** * When this component is mounted, we initialize the tag with the Stripe's public key */ useEffect(() => { SettingAPI.get(SettingName.StripePublicKey).then(key => { - const promise = loadStripe(key.value); - setStripe(promise); + if (key?.value) { + const promise = loadStripe(key.value); + setStripe(promise); + } }); }, []); diff --git a/app/frontend/src/javascript/components/payment/update-card-modal.tsx b/app/frontend/src/javascript/components/payment/update-card-modal.tsx index 9ecb4e9cc..a57093dee 100644 --- a/app/frontend/src/javascript/components/payment/update-card-modal.tsx +++ b/app/frontend/src/javascript/components/payment/update-card-modal.tsx @@ -24,11 +24,7 @@ const UpdateCardModalComponent: React.FC = ({ isOpen, togg const [gateway, setGateway] = useState(''); useEffect(() => { - if (schedule.gateway_subscription.classname.match(/^PayZen::/)) { - setGateway('payzen'); - } else if (schedule.gateway_subscription.classname.match(/^Stripe::/)) { - setGateway('stripe'); - } + setGateway(schedule.gateway); }, [schedule]); /** @@ -44,7 +40,7 @@ const UpdateCardModalComponent: React.FC = ({ isOpen, togg /** * Render the PayZen update-card modal - */ // 1 + */ const renderPayZenModal = (): ReactElement => { return = ({ isOpen, togg */ switch (gateway) { - case 'stripe': + case 'Stripe': return renderStripeModal(); - case 'payzen': + case 'PayZen': return renderPayZenModal(); case '': + case undefined: return
    ; default: onError(t('app.shared.update_card_modal.unexpected_error')); - console.error(`[UpdateCardModal] unexpected gateway: ${schedule.gateway_subscription?.classname}`); + console.error(`[UpdateCardModal] unexpected gateway: ${schedule.gateway} for schedule ${schedule.id}`); return
    ; } }; diff --git a/app/frontend/src/javascript/components/plan-categories/edit-plan-category.tsx b/app/frontend/src/javascript/components/plan-categories/edit-plan-category.tsx index 7cd1ab22d..1d351627d 100644 --- a/app/frontend/src/javascript/components/plan-categories/edit-plan-category.tsx +++ b/app/frontend/src/javascript/components/plan-categories/edit-plan-category.tsx @@ -35,7 +35,7 @@ const EditPlanCategoryComponent: React.FC = ({ onSuccess, /** * The edit has been confirmed by the user. - * Call the API to trigger the update of the temporary set plan-category + * Call the API to trigger the update of the temporary set plan-category. */ const onEditConfirmed = (): void => { PlanCategoryAPI.update(tempCategory).then((updatedCategory) => { diff --git a/app/frontend/src/javascript/components/pricing/spaces/configure-extended-price-button.tsx b/app/frontend/src/javascript/components/pricing/spaces/configure-extended-price-button.tsx index f0d1d1c76..c45c99a41 100644 --- a/app/frontend/src/javascript/components/pricing/spaces/configure-extended-price-button.tsx +++ b/app/frontend/src/javascript/components/pricing/spaces/configure-extended-price-button.tsx @@ -28,10 +28,10 @@ export const ConfigureExtendedPriceButton: React.FC(false); /** - * Return the number of minutes, user-friendly formatted + * Return the number of hours, user-friendly formatted */ const formatDuration = (minutes: number): string => { - return t('app.admin.configure_extended_prices_button.extended_price_DURATION', { DURATION: minutes }); + return t('app.admin.configure_extended_prices_button.extended_price_DURATION', { DURATION: minutes / 60 }); }; /** diff --git a/app/frontend/src/javascript/components/pricing/spaces/extended-price-form.tsx b/app/frontend/src/javascript/components/pricing/spaces/extended-price-form.tsx index eff0bcc45..dbc8f4790 100644 --- a/app/frontend/src/javascript/components/pricing/spaces/extended-price-form.tsx +++ b/app/frontend/src/javascript/components/pricing/spaces/extended-price-form.tsx @@ -40,11 +40,11 @@ export const ExtendedPriceForm: React.FC = ({ formId, on }; /** - * Callback triggered when the user inputs a number of minutes for the current extended price. + * Callback triggered when the user inputs a number of hours for the current extended price. */ const handleUpdateHours = (minutes: string) => { updateExtendedPriceData(draft => { - draft.duration = parseInt(minutes, 10); + draft.duration = parseFloat(minutes) * 60; }); }; @@ -53,10 +53,10 @@ export const ExtendedPriceForm: React.FC = ({ formId, on } required /> diff --git a/app/frontend/src/javascript/components/user/avatar.tsx b/app/frontend/src/javascript/components/user/avatar.tsx index 7fe47f2b2..64e819a02 100644 --- a/app/frontend/src/javascript/components/user/avatar.tsx +++ b/app/frontend/src/javascript/components/user/avatar.tsx @@ -9,7 +9,7 @@ interface AvatarProps { } /** - * This component renders the user-profile's picture or a placeholder + * This component renders the user-profile's picture or a placeholder. */ export const Avatar: React.FC = ({ user, className }) => { /** diff --git a/app/frontend/src/javascript/controllers/admin/calendar.js b/app/frontend/src/javascript/controllers/admin/calendar.js index 49928ac7d..76d8702bc 100644 --- a/app/frontend/src/javascript/controllers/admin/calendar.js +++ b/app/frontend/src/javascript/controllers/admin/calendar.js @@ -407,10 +407,30 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state // check if slot is not in the past const today = new Date(); if (Math.trunc((start.valueOf() - today) / (60 * 1000)) < 0) { - growl.warning(_t('app.admin.calendar.event_in_the_past')); - return uiCalendarConfig.calendars.calendar.fullCalendar('unselect'); + return dialogs.confirm({ + resolve: { + object () { + return { + title: _t('app.admin.calendar.event_in_the_past'), + msg: _t('app.admin.calendar.confirm_create_event_in_the_past') + }; + } + } + }, + function () { // confirmed + startAvailabilityCreation(start, end); + }, function () { // canceled + uiCalendarConfig.calendars.calendar.fullCalendar('unselect'); + }); } + startAvailabilityCreation(start, end); + }; + + /** + * Start the process to create a new availability between the given start and end datetimes + */ + const startAvailabilityCreation = function (start, end) { // check that the selected slot is an multiple of SLOT_MULTIPLE (ie. not decimal) const slots = Math.trunc((end.valueOf() - start.valueOf()) / (60 * 1000)) / SLOT_MULTIPLE; if (!Number.isInteger(slots)) { diff --git a/app/frontend/src/javascript/controllers/admin/invoices.js b/app/frontend/src/javascript/controllers/admin/invoices.js index 960025762..c78f857ec 100644 --- a/app/frontend/src/javascript/controllers/admin/invoices.js +++ b/app/frontend/src/javascript/controllers/admin/invoices.js @@ -478,6 +478,13 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I $scope.isSelected = active; $scope.history = []; + // callback on "enable VAT" switch toggle + $scope.enableVATChanged = function (checked) { + setTimeout(() => { + $scope.isSelected = checked; + $scope.$apply(); + }, 1); + }; $scope.ok = function () { $uibModalInstance.close({ rate: $scope.rate, active: $scope.isSelected }); }; $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); }; $scope.editMultiVAT = function () { diff --git a/app/frontend/src/javascript/controllers/header.js b/app/frontend/src/javascript/controllers/header.js index 1d9759b68..a59536cdb 100644 --- a/app/frontend/src/javascript/controllers/header.js +++ b/app/frontend/src/javascript/controllers/header.js @@ -1,11 +1,18 @@ 'use strict'; -Application.Controllers.controller('HeaderController', ['$scope', '$rootScope', '$state', - function ($scope, $rootScope, $state) { +Application.Controllers.controller('HeaderController', ['$scope', '$rootScope', '$state', 'settingsPromise', + function ($scope, $rootScope, $state, settingsPromise) { $scope.aboutPage = ($state.current.name === 'app.public.about'); $rootScope.$on('$stateChangeStart', function (event, toState) { $scope.aboutPage = (toState.name === 'app.public.about'); }); + + /** + * Returns the current state of the public registration setting (allowed/blocked). + */ + $scope.registrationEnabled = function () { + return settingsPromise.public_registrations === 'true'; + }; } ]); diff --git a/app/frontend/src/javascript/controllers/main_nav.js b/app/frontend/src/javascript/controllers/main_nav.js index 04d3f4d97..443a01c0c 100644 --- a/app/frontend/src/javascript/controllers/main_nav.js +++ b/app/frontend/src/javascript/controllers/main_nav.js @@ -13,7 +13,7 @@ /** * Navigation controller. List the links availables in the left navigation pane and their icon. */ -Application.Controllers.controller('MainNavController', ['$scope', function ($scope) { +Application.Controllers.controller('MainNavController', ['$scope', 'settingsPromise', function ($scope, settingsPromise) { // Common links (public application) $scope.navLinks = [ { @@ -172,5 +172,12 @@ Application.Controllers.controller('MainNavController', ['$scope', function ($sc authorizedRoles: ['admin'] }); } + + /** + * Returns the current state of the public registration setting (allowed/blocked). + */ + $scope.registrationEnabled = function () { + return settingsPromise.public_registrations === 'true'; + }; } ]); diff --git a/app/frontend/src/javascript/directives/directives.js b/app/frontend/src/javascript/directives/directives.js index a32131965..9082905a2 100644 --- a/app/frontend/src/javascript/directives/directives.js +++ b/app/frontend/src/javascript/directives/directives.js @@ -34,8 +34,8 @@ Application.Directives.directive('fileread', [() => Application.Directives.directive('bsHolder', [() => ({ link (scope, element, attrs) { - Holder.addTheme('icon', { background: 'white', foreground: '#e9e9e9', size: 80, font: 'FontAwesome' }) - .addTheme('icon-xs', { background: 'white', foreground: '#e0e0e0', size: 20, font: 'FontAwesome' }) + Holder.addTheme('icon', { background: 'white', foreground: '#ebebeb', size: 60, font: 'FontAwesome' }) + .addTheme('icon-xs', { background: 'white', foreground: '#ebebeb', size: 20, font: 'FontAwesome' }) .addTheme('icon-black-xs', { background: 'black', foreground: 'white', size: 20, font: 'FontAwesome' }) .addTheme('avatar', { background: '#eeeeee', foreground: '#555555', size: 16, font: 'FontAwesome' }) .run(element[0]); diff --git a/app/frontend/src/javascript/models/payment-schedule.ts b/app/frontend/src/javascript/models/payment-schedule.ts index 1a528c70e..81cf6d899 100644 --- a/app/frontend/src/javascript/models/payment-schedule.ts +++ b/app/frontend/src/javascript/models/payment-schedule.ts @@ -4,13 +4,16 @@ export enum PaymentScheduleItemState { RequirePaymentMethod = 'requires_payment_method', RequireAction = 'requires_action', Paid = 'paid', - Error = 'error' + Error = 'error', + GatewayCanceled = 'gateway_canceled' } export enum PaymentMethod { - Stripe = 'stripe', + Card = 'card', + Transfer = 'transfer', Check = 'check' } + export interface PaymentScheduleItem { id: number, amount: number, @@ -22,30 +25,28 @@ export interface PaymentScheduleItem { } export interface PaymentSchedule { - max_length: number; + max_length?: number; id: number, - total: number, - reference: string, - payment_method: string, - items: Array, - created_at: Date, - chained_footprint: boolean, - main_object: { + total?: number, + reference?: string, + payment_method: PaymentMethod, + items?: Array, + created_at?: Date, + chained_footprint?: boolean, + main_object?: { type: string, id: number }, - user: { + user?: { id: number, name: string }, - operator: { + operator?: { id: number, first_name: string, last_name: string, }, - gateway_subscription: { - classname: string - } + gateway?: 'PayZen' | 'Stripe', } export interface PaymentScheduleIndexRequest { diff --git a/app/frontend/src/javascript/models/payment.ts b/app/frontend/src/javascript/models/payment.ts index b59440617..8212b6aaf 100644 --- a/app/frontend/src/javascript/models/payment.ts +++ b/app/frontend/src/javascript/models/payment.ts @@ -18,7 +18,8 @@ export interface IntentConfirmation { export enum PaymentMethod { Card = 'card', - Other = '' + Check = 'check', + Transfer = 'transfer' } export type CartItem = { reservation: Reservation }| diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index 420d1f374..89eb1c577 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -117,7 +117,8 @@ export enum SettingName { RenewPackThreshold = 'renew_pack_threshold', PackOnlyForSubscription = 'pack_only_for_subscription', OverlappingCategories = 'overlapping_categories', - ExtendedPricesInSameDay = 'extended_prices_in_same_day' + ExtendedPricesInSameDay = 'extended_prices_in_same_day', + PublicRegistrations = 'public_registrations' } export type SettingValue = string|boolean|number; diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index bb5a7d6c6..26b98543a 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -38,7 +38,8 @@ angular.module('application.router', ['ui.router']) logoFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'logo-file' }).$promise; }], logoBlackFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'logo-black-file' }).$promise; }], sharedTranslations: ['Translations', function (Translations) { return Translations.query(['app.shared', 'app.public.common']).$promise; }], - modulesPromise: ['Setting', function (Setting) { return Setting.query({ names: "['spaces_module', 'plans_module', 'invoicing_module', 'wallet_module', 'statistics_module', 'trainings_module', 'public_agenda_module']" }).$promise; }] + modulesPromise: ['Setting', function (Setting) { return Setting.query({ names: "['spaces_module', 'plans_module', 'invoicing_module', 'wallet_module', 'statistics_module', 'trainings_module', 'public_agenda_module']" }).$promise; }], + settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['public_registrations']" }).$promise; }] }, onEnter: ['$rootScope', 'logoFile', 'logoBlackFile', 'modulesPromise', 'CSRF', function ($rootScope, logoFile, logoBlackFile, modulesPromise, CSRF) { // Retrieve Anti-CSRF tokens from cookies @@ -1081,7 +1082,8 @@ angular.module('application.router', ['ui.router']) "'reminder_delay', 'visibility_yearly', 'visibility_others', 'wallet_module', 'trainings_module', " + "'display_name_enable', 'machines_sort_by', 'fab_analytics', 'statistics_module', 'address_required', " + "'link_name', 'home_content', 'home_css', 'phone_required', 'upcoming_events_shown', 'public_agenda_module'," + - "'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories', 'extended_prices_in_same_day']" + "'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories', 'public_registrations'," + + "'extended_prices_in_same_day']" }).$promise; }], privacyDraftsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'privacy_draft', history: true }).$promise; }], diff --git a/app/frontend/src/stylesheets/app.components.scss b/app/frontend/src/stylesheets/app.components.scss index bd315443e..544ff38cd 100644 --- a/app/frontend/src/stylesheets/app.components.scss +++ b/app/frontend/src/stylesheets/app.components.scss @@ -191,7 +191,9 @@ overflow: hidden; img { - max-height: 400px; + aspect-ratio: 16/9; + object-fit: cover; + object-position: center; } } @@ -245,6 +247,8 @@ height: 250px; background-size: cover; background-position: center; + background-repeat: no-repeat; + background-color: transparent; @include transition(opacity 0.5s ease); @@ -302,6 +306,7 @@ &.avatar-block { white-space: inherit; + width: 100%; height: 76px; .user-name { diff --git a/app/frontend/src/stylesheets/app.layout.scss b/app/frontend/src/stylesheets/app.layout.scss index 549bda911..b5eb89389 100644 --- a/app/frontend/src/stylesheets/app.layout.scss +++ b/app/frontend/src/stylesheets/app.layout.scss @@ -661,8 +661,121 @@ body.container { } .home-events { - .event-description { + h4 { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + column-gap: 1rem; + i { margin-right: 1rem;} + } +} + +.home-events-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 30px; + margin-bottom: 5rem; + @media (max-width: 480px) { + grid-template-columns: 1fr; + } + + .Event { + display: flex; + flex-direction: column; + border: 1px solid #ddd; + border-radius: 5px; overflow: hidden; + &:hover { + cursor: pointer; + & .Event-picture {opacity: 0.7;} + } + + &-picture { + height: 250px; + background-color: #fff; + border-bottom: 1px solid #ebebeb; + transition: opacity 0.4s ease-out; + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &-desc { + position: relative; + padding: 15px; + h3 { + max-width: 75%; + margin-top: 0; + font-size: 2rem; + font-weight: 900; + line-height: 1.3; + text-transform: uppercase; + color: #000 !important; + } + span { + position: absolute; + right: 15px; + top: 15px; + } + } + + &-info { + display: grid; + grid-template-rows: repeat(2, max-content); + grid-template-columns: repeat(2, 1fr); + gap: 15px; + margin-top: auto; + padding: 15px 30px 30px; + border-top: 1px solid #eee; + &-item { + height: 20px; + display: flex; + align-items: center; + i { + width: 16px; + height: 16px; + font-size: 16px; + text-align: center; + color: #cb1117; + } + h6 { margin: 0 0 0 15px;} + } + } + } +} + +.month-events-list { + display: grid; + grid-template-columns: 1fr; + gap: 15px; + margin-bottom: 2rem; + @media (min-width: 768px) { + grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); + } + + .Event { + display: flex; + border: 1px solid #ddd; + border-radius: 5px; + overflow: hidden; + + &-desc { + flex: 1; + padding: 10px 15px 15px; + } + &-picture { + width: 33%; + img { + width: 100%; + height: 100%; + object-fit: cover; + } + @media (max-width: 500px) { + display: none; + } + } } } diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index fb262274b..f01f1461b 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -76,3 +76,5 @@ @import "modules/subscriptions/renew-modal"; @import "app.responsive"; + +@import "overrides" diff --git a/app/frontend/src/stylesheets/modules/machines/machine-card.scss b/app/frontend/src/stylesheets/modules/machines/machine-card.scss index 87de4d5b2..540bcd77f 100644 --- a/app/frontend/src/stylesheets/modules/machines/machine-card.scss +++ b/app/frontend/src/stylesheets/modules/machines/machine-card.scss @@ -53,26 +53,28 @@ height: 250px; background-size: cover; background-position: center; + background-repeat: no-repeat; transition: opacity 0.5s ease; cursor: pointer; padding: 0; color: #333333; - background-color: #f5f5f5; - border-color: #ddd; - border-bottom: 1px solid transparent; + border-bottom: 1px solid #ebebeb; border-top-left-radius: 5px; border-top-right-radius: 5px; position: relative; &.no-picture::before { position: absolute; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; content: '\f03e'; font-family: 'Font Awesome 5 Free' !important; font-weight: 900; - font-size: 4em; - width: 100%; - text-align: center; - padding-top: 84px; + font-size: 80px; + color: #ebebeb; } } diff --git a/app/frontend/src/stylesheets/overrides.scss b/app/frontend/src/stylesheets/overrides.scss new file mode 100644 index 000000000..3dfa11c0c --- /dev/null +++ b/app/frontend/src/stylesheets/overrides.scss @@ -0,0 +1,26 @@ +.carousel { + position: relative; + border-radius: 5px; + overflow: hidden; + &::before { + position: absolute; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + content: '\f03e'; + font-family: 'Font Awesome 5 Free' !important; + font-weight: 900; + font-size: 80px; + color: #ebebeb; + } + + &-inner { + aspect-ratio: 16/9; + } + + &-inner > .item { + height: 100%; + } +} \ No newline at end of file diff --git a/app/frontend/templates/admin/abuses/index.html b/app/frontend/templates/admin/abuses/index.html index 6b0880c6a..f04092523 100644 --- a/app/frontend/templates/admin/abuses/index.html +++ b/app/frontend/templates/admin/abuses/index.html @@ -2,7 +2,7 @@
    diff --git a/app/frontend/templates/admin/admins/new.html b/app/frontend/templates/admin/admins/new.html index 931715766..3d661d2bf 100644 --- a/app/frontend/templates/admin/admins/new.html +++ b/app/frontend/templates/admin/admins/new.html @@ -2,7 +2,7 @@
    - +
    diff --git a/app/frontend/templates/admin/authentications/_oauth2_mapping.html b/app/frontend/templates/admin/authentications/_oauth2_mapping.html index a810ca5c7..d6d37811c 100644 --- a/app/frontend/templates/admin/authentications/_oauth2_mapping.html +++ b/app/frontend/templates/admin/authentications/_oauth2_mapping.html @@ -64,10 +64,11 @@ diff --git a/app/frontend/templates/admin/calendar/calendar.html b/app/frontend/templates/admin/calendar/calendar.html index 591ec09bc..c760602ef 100644 --- a/app/frontend/templates/admin/calendar/calendar.html +++ b/app/frontend/templates/admin/calendar/calendar.html @@ -2,7 +2,7 @@
    - +
    diff --git a/app/frontend/templates/admin/events/index.html b/app/frontend/templates/admin/events/index.html index 71b079479..b1ec3dd89 100644 --- a/app/frontend/templates/admin/events/index.html +++ b/app/frontend/templates/admin/events/index.html @@ -2,7 +2,7 @@
    - +
    diff --git a/app/frontend/templates/admin/events/reservations.html b/app/frontend/templates/admin/events/reservations.html index 360f331aa..aab4511af 100644 --- a/app/frontend/templates/admin/events/reservations.html +++ b/app/frontend/templates/admin/events/reservations.html @@ -2,7 +2,7 @@
    - +
    diff --git a/app/frontend/templates/admin/invoices/index.html b/app/frontend/templates/admin/invoices/index.html index 0bbd4b08a..2b30e830a 100644 --- a/app/frontend/templates/admin/invoices/index.html +++ b/app/frontend/templates/admin/invoices/index.html @@ -2,7 +2,7 @@
    - +
    diff --git a/app/frontend/templates/admin/invoices/list.html b/app/frontend/templates/admin/invoices/list.html index cc3590447..0ba0b00ef 100644 --- a/app/frontend/templates/admin/invoices/list.html +++ b/app/frontend/templates/admin/invoices/list.html @@ -37,13 +37,13 @@ - {{ 'app.admin.invoices.invoice_num' | translate }} + {{ 'app.admin.invoices.invoice_num' | translate }} - {{ 'app.admin.invoices.date' | translate }} + {{ 'app.admin.invoices.date' | translate }} - {{ 'app.admin.invoices.price' | translate }} + {{ 'app.admin.invoices.price' | translate }} - {{ 'app.admin.invoices.customer' | translate }} + {{ 'app.admin.invoices.customer' | translate }} @@ -64,7 +64,7 @@ {{ invoice.date | amDateFormat:'L' }} {{ invoice.total | currency}} - {{ invoice.name }} + {{ invoice.name }} {{ invoice.name }}
    @@ -88,4 +88,4 @@

    {{ 'app.admin.invoices.no_invoices_for_now' }}

    -
    \ No newline at end of file +
    diff --git a/app/frontend/templates/admin/invoices/settings/editVAT.html b/app/frontend/templates/admin/invoices/settings/editVAT.html index 5423aa74c..e6f253268 100644 --- a/app/frontend/templates/admin/invoices/settings/editVAT.html +++ b/app/frontend/templates/admin/invoices/settings/editVAT.html @@ -5,14 +5,11 @@