1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-11-29 10:24:20 +01:00

Merge branch 'dev' into l10n_dev

This commit is contained in:
Sylvain 2021-07-01 09:48:26 +02:00 committed by GitHub
commit fe1d0f5ae1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
208 changed files with 4405 additions and 905 deletions

View File

@ -1,6 +1,29 @@
# Changelog Fab-manager
- Prepaid packs of hours for the machines
- Tests for OpenAPI user trainings
- Norwegian language
- Fix a bug: unable to filter by multiple user IDs in OpenAPI
- Fix a bug: do not display two empty categories in the plan creation form
- Fix a bug: do not return to the home page when clicking on previous in the machine description page
- Fix a bug: public list of plans fails to render if a group has no plans
- [TODO DEPLOY] `rails db:seed`
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
## v5.0.8 2021 June 28
- Ability to run `fablab:chain:all` non interactively
- Full test coverage of the OpenAPI
- Fix a bug: unable to get the Events without images from the OpenAPI
- Fix a bug: unable to get the Space reservations from the OpenAPI
- Fix a bug: unable to get invoices from the OpenAPI
## v5.0.7 2021 June 24
- Fix a bug: unable to export members list if no subscriptions was taken
- Fix a bug: most OpenAPI endpoints were dysfunctional
- Fix a bug: unable to open some modals when the logo was undefined
- Fix a bug: stripe subscription generation fails if the user already has a subscription
## v5.0.6 2021 June 21
@ -252,6 +275,7 @@
- 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
- Fix a bug: unable to configure the app to use a german locale
- Fix a security issue: updated carrierwave to 2.1.1 to fix [CVE-2021-21305](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-21305)
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
- [TODO DEPLOY] `rails fablab:stripe:set_product_id`
@ -263,8 +287,6 @@
- [TODO DEPLOY] `\curl -sSL https://raw.githubusercontent.com/sleede/fab-manager/master/scripts/mount-payment-schedules.sh | bash`
- [TODO DEPLOY] -> (only dev) `bundle install`
- 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))
- OpenAPI endpoints to create/update/show/delete machines

View File

@ -6,12 +6,7 @@ class API::GroupsController < API::ApiController
before_action :authenticate_user!, except: :index
def index
@groups = if current_user&.admin?
Group.all
else
Group.where.not(slug: 'admins')
end
@groups = GroupService.list(current_user, params)
end
def create

View File

@ -7,12 +7,7 @@ class API::MachinesController < API::ApiController
respond_to :json
def index
sort_by = Setting.get('machines_sort_by') || 'default'
@machines = if sort_by == 'default'
Machine.includes(:machine_image, :plans)
else
Machine.includes(:machine_image, :plans).order(sort_by)
end
@machines = MachineService.list(params)
end
def show

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
# API Controller for resources of type PrepaidPack
# PrepaidPacks are used to provide discounts to users that bought many hours at once
class API::PrepaidPacksController < API::ApiController
before_action :authenticate_user!, except: :index
before_action :set_pack, only: %i[show update destroy]
def index
@packs = PrepaidPackService.list(params).order(minutes: :asc)
end
def show; end
def create
authorize PrepaidPack
@pack = PrepaidPack.new(pack_params)
if @pack.save
render status: :created
else
render json: @pack.errors.full_messages, status: :unprocessable_entity
end
end
def update
authorize @pack
if @pack.update(pack_params)
render status: :ok
else
render json: @pack.errors.full_messages, status: :unprocessable_entity
end
end
def destroy
authorize @pack
@pack.destroy
head :no_content
end
private
def set_pack
@pack = PrepaidPack.find(params[:id])
end
def pack_params
pack_params = params
pack_params[:pack][:amount] = pack_params[:pack][:amount].to_f * 100.0 if pack_params[:pack][:amount]
params.require(:pack).permit(:priceable_id, :priceable_type, :group_id, :amount, :minutes, :validity_count, :validity_interval,
:disabled)
end
end

View File

@ -6,22 +6,7 @@ class API::PricesController < API::ApiController
before_action :authenticate_user!
def index
authorize Price
@prices = Price.all
if params[:priceable_type]
@prices = @prices.where(priceable_type: params[:priceable_type])
@prices = @prices.where(priceable_id: params[:priceable_id]) if params[:priceable_id]
end
if params[:plan_id]
plan_id = if /no|nil|null|undefined/i.match?(params[:plan_id])
nil
else
params[:plan_id]
end
@prices = @prices.where(plan_id: plan_id)
end
@prices = @prices.where(group_id: params[:group_id]) if params[:group_id]
@prices = PriceService.list(params)
end
def update

View File

@ -30,7 +30,7 @@ class API::StripeController < API::PaymentsController
currency: Setting.get('stripe_currency'),
confirmation_method: 'manual',
confirm: true,
customer: current_user.payment_gateway_object.gateway_object_id
customer: cart.customer.payment_gateway_object.gateway_object_id
}, { api_key: Setting.get('stripe_secret_key') }
)
elsif params[:payment_intent_id].present?

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
# API Controller for resources of type StatisticProfilePrepaidPack
class API::UserPacksController < API::ApiController
before_action :authenticate_user!
def index
@user_packs = PrepaidPackService.user_packs(user, item)
end
private
def user
return User.find(params[:user_id]) if current_user.privileged?
current_user
end
def item
params[:priceable_type].classify.constantize.find(params[:priceable_id])
end
end

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
# module definition
module OpenAPI::V1; end
# Parameters for OpenAPI endpoints
class OpenAPI::V1::BaseController < ActionController::Base
protect_from_forgery with: :null_session

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# authorized 3rd party softwares can list the bookable machines through the OpenAPI
class OpenAPI::V1::BookableMachinesController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
expose_doc
@ -22,19 +25,15 @@ class OpenAPI::V1::BookableMachinesController < OpenAPI::V1::BaseController
if user.subscription
plan_id = user.subscription.plan_id
return unless user.subscription
@machines.each do |machine|
credit = Credit.find_by(plan_id: plan_id, creditable: machine)
users_credit = user.users_credits.find_by(credit: credit) if credit
plan_id = user.subscription.plan_id
if credit
@hours_remaining[machine.id] = credit.hours - (users_credit.try(:hours_used) || 0)
else
@hours_remaining[machine.id] = 0
end
end
@machines.each do |machine|
credit = Credit.find_by(plan_id: plan_id, creditable: machine)
users_credit = user.users_credits.find_by(credit: credit) if credit
@hours_remaining[machine.id] = credit ? credit.hours - (users_credit.try(:hours_used) || 0) : 0
end
end
end

View File

@ -1,32 +1,37 @@
# frozen_string_literal: true
# authorized 3rd party softwares can manage the events through the OpenAPI
class OpenAPI::V1::EventsController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
expose_doc
def index
@events = Event.includes(:event_image, :event_files, :availability, :category)
@events = if upcoming
@events.references(:availabilities)
.where('availabilities.end_at >= ?', DateTime.current)
.order('availabilities.start_at ASC')
else
@events.order(created_at: :desc)
end
if upcoming
@events = Event.includes(:event_image, :event_files, :availability, :category)
.where('availabilities.end_at >= ?', DateTime.current)
.order('availabilities.start_at ASC').references(:availabilities)
else
@events = Event.includes(:event_image, :event_files, :availability, :category).order(created_at: :desc)
end
if params[:id].present?
@events = @events.where(id: params[:id])
end
@events = @events.where(id: params[:id]) if params[:id].present?
if params[:page].present?
@events = @events.page(params[:page]).per(per_page)
paginate @events, per_page: per_page
end
return unless params[:page].present?
@events = @events.page(params[:page]).per(per_page)
paginate @events, per_page: per_page
end
private
def per_page
params[:per_page] || 20
end
def upcoming
params[:upcoming] || false
end
def per_page
params[:per_page] || 20
end
def upcoming
params[:upcoming] || false
end
end

View File

@ -1,12 +1,17 @@
# frozen_string_literal: true
# OpenAPI controller for the invoices
class OpenAPI::V1::InvoicesController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
expose_doc
def index
@invoices = Invoice.order(created_at: :desc)
.includes(invoicing_profile: :user)
.references(:invoicing_profiles)
@invoices = @invoices.where(user_id: params[:user_id]) if params[:user_id].present?
@invoices = @invoices.where(invoicing_profiles: { user_id: params[:user_id] }) if params[:user_id].present?
return unless params[:page].present?

View File

@ -1,36 +1,33 @@
# frozen_string_literal: true
# public API controller for resources of type Reservation
class OpenAPI::V1::ReservationsController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
expose_doc
def index
@reservations = Reservation.order(created_at: :desc)
.includes(statistic_profile: :user)
.references(:statistic_profiles)
if params[:user_id].present?
@reservations = @reservations.where(user_id: params[:user_id])
else
@reservations = @reservations.includes(user: :profile)
end
@reservations = @reservations.where(statistic_profiles: { user_id: params[:user_id] }) if params[:user_id].present?
@reservations = @reservations.where(reservable_type: format_type(params[:reservable_type])) if params[:reservable_type].present?
@reservations = @reservations.where(reservable_id: params[:reservable_id]) if params[:reservable_id].present?
if params[:reservable_type].present?
@reservations = @reservations.where(reservable_type: format_type(params[:reservable_type]))
end
return unless params[:page].present?
if params[:reservable_id].present?
@reservations = @reservations.where(reservable_id: params[:reservable_id])
end
if params[:page].present?
@reservations = @reservations.page(params[:page]).per(per_page)
paginate @reservations, per_page: per_page
end
@reservations = @reservations.page(params[:page]).per(per_page)
paginate @reservations, per_page: per_page
end
private
def format_type(type)
type.singularize.classify
end
def per_page
params[:per_page] || 20
end
def format_type(type)
type.singularize.classify
end
def per_page
params[:per_page] || 20
end
end

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# public API controller for resources of type Training
class OpenAPI::V1::TrainingsController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
expose_doc

View File

@ -1,30 +1,30 @@
# frozen_string_literal: true
# public API controller for user's trainings
class OpenAPI::V1::UserTrainingsController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
expose_doc
def index
@user_trainings = UserTraining.order(created_at: :desc)
@user_trainings = StatisticProfileTraining.includes(statistic_profile: :user)
.includes(:training)
.references(:statistic_profiles)
.order(created_at: :desc)
if params[:user_id].present?
@user_trainings = @user_trainings.where(user_id: params[:user_id])
else
@user_trainings = @user_trainings.includes(user: :profile)
end
if params[:training_id].present?
@user_trainings = @user_trainings.where(training_id: params[:training_id])
else
@user_trainings = @user_trainings.includes(:training)
end
@user_trainings = @user_trainings.where(statistic_profiles: { user_id: params[:user_id] }) if params[:user_id].present?
@user_trainings = @user_trainings.where(training_id: params[:training_id]) if params[:training_id].present?
if params[:page].present?
@user_trainings = @user_trainings.page(params[:page]).per(per_page)
paginate @user_trainings, per_page: per_page
end
return unless params[:page].present?
@user_trainings = @user_trainings.page(params[:page]).per(per_page)
paginate @user_trainings, per_page: per_page
end
private
def per_page
params[:per_page] || 20
end
def per_page
params[:per_page] || 20
end
end

View File

@ -1,5 +1,9 @@
# frozen_string_literal: true
# public API controller for users
class OpenAPI::V1::UsersController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
expose_doc
def index
@ -9,19 +13,17 @@ class OpenAPI::V1::UsersController < OpenAPI::V1::BaseController
email_param = params[:email].is_a?(String) ? params[:email].downcase : params[:email].map(&:downcase)
@users = @users.where(email: email_param)
end
@users = @users.where(id: params[:user_id]) if params[:user_id].present?
if params[:user_id].present?
@users = @users.where(id: params[:user_id])
end
return unless params[:page].present?
if params[:page].present?
@users = @users.page(params[:page]).per(per_page)
paginate @users, per_page: per_page
end
@users = @users.page(params[:page]).per(per_page)
paginate @users, per_page: per_page
end
private
def per_page
params[:per_page] || 20
end
def per_page
params[:per_page] || 20
end
end

View File

@ -16,7 +16,7 @@ class OpenAPI::V1::ReservationsDoc < OpenAPI::V1::BaseDoc
description 'Index of reservations made by users, with optional pagination. Order by *created_at* descendant.'
param_group :pagination
param :user_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various users.'
param :reservable_type, %w[Event Machine Training], optional: true, desc: 'Scope the request to a specific type of reservable.'
param :reservable_type, %w[Event Machine Space Training], optional: true, desc: 'Scope the request to a specific type of reservable.'
param :reservable_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various reservables.'
example <<-RESERVATIONS

View File

@ -1,17 +1,11 @@
import apiClient from './clients/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> {
static 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));
}
}

View File

@ -3,7 +3,7 @@ import { AxiosResponse } from 'axios';
import { EventTheme } from '../models/event-theme';
export default class EventThemeAPI {
async index (): Promise<Array<EventTheme>> {
static async index (): Promise<Array<EventTheme>> {
const res: AxiosResponse<Array<EventTheme>> = await apiClient.get(`/api/event_themes`);
return res?.data;
}

View File

@ -1,11 +1,17 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Group } from '../models/group';
import { Group, GroupIndexFilter } from '../models/group';
export default class GroupAPI {
static async index (): Promise<Array<Group>> {
const res: AxiosResponse<Array<Group>> = await apiClient.get('/api/groups');
static async index (filters?: GroupIndexFilter): Promise<Array<Group>> {
const res: AxiosResponse<Array<Group>> = await apiClient.get(`/api/groups${this.filtersToQuery(filters)}`);
return res?.data;
}
private static filtersToQuery(filters?: GroupIndexFilter): string {
if (!filters) return '';
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

@ -0,0 +1,13 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ShoppingCart } from '../models/payment';
import { PaymentSchedule } from '../models/payment-schedule';
import { Invoice } from '../models/invoice';
export default class LocalPaymentAPI {
static async confirmPayment (cart_items: ShoppingCart): Promise<PaymentSchedule|Invoice> {
const res: AxiosResponse<PaymentSchedule|Invoice> = await apiClient.post('/api/local_payment/confirm_payment', cart_items);
return res?.data;
}
}

View File

@ -0,0 +1,22 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Machine, MachineIndexFilter } from '../models/machine';
export default class MachineAPI {
static async index (filters?: MachineIndexFilter): Promise<Array<Machine>> {
const res: AxiosResponse<Array<Machine>> = await apiClient.get(`/api/machines${this.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number): Promise<Machine> {
const res: AxiosResponse<Machine> = await apiClient.get(`/api/machines/${id}`);
return res?.data;
}
private static filtersToQuery(filters?: MachineIndexFilter): string {
if (!filters) return '';
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

@ -8,32 +8,32 @@ import {
} from '../models/payment-schedule';
export default class PaymentScheduleAPI {
async list (query: PaymentScheduleIndexRequest): Promise<Array<PaymentSchedule>> {
static 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>> {
static 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> {
static 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> {
static 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> {
static 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> {
static async cancel (paymentScheduleId: number): Promise<CancelScheduleResponse> {
const res: AxiosResponse = await apiClient.put(`/api/payment_schedules/${paymentScheduleId}/cancel`);
return res?.data;
}

View File

@ -0,0 +1,38 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { PackIndexFilter, PrepaidPack } from '../models/prepaid-pack';
export default class PrepaidPackAPI {
static async index (filters?: PackIndexFilter): Promise<Array<PrepaidPack>> {
const res: AxiosResponse<Array<PrepaidPack>> = await apiClient.get(`/api/prepaid_packs${this.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number): Promise<PrepaidPack> {
const res: AxiosResponse<PrepaidPack> = await apiClient.get(`/api/prepaid_packs/${id}`);
return res?.data;
}
static async create (pack: PrepaidPack): Promise<PrepaidPack> {
const res: AxiosResponse<PrepaidPack> = await apiClient.post('/api/prepaid_packs', { pack });
return res?.data;
}
static async update (pack: PrepaidPack): Promise<PrepaidPack> {
const res: AxiosResponse<PrepaidPack> = await apiClient.patch(`/api/prepaid_packs/${pack.id}`, { pack });
return res?.data;
}
static async destroy (packId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/prepaid_packs/${packId}`);
return res?.data;
}
private static filtersToQuery(filters?: PackIndexFilter): string {
if (!filters) return '';
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

@ -1,12 +1,28 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ShoppingCart } from '../models/payment';
import { ComputePriceResult } from '../models/price';
import { ComputePriceResult, Price, PriceIndexFilter } from '../models/price';
export default class PriceAPI {
static async compute (cart: ShoppingCart): Promise<ComputePriceResult> {
const res: AxiosResponse = await apiClient.post(`/api/prices/compute`, cart);
const res: AxiosResponse<ComputePriceResult> = await apiClient.post(`/api/prices/compute`, cart);
return res?.data;
}
static async index (filters?: PriceIndexFilter): Promise<Array<Price>> {
const res: AxiosResponse = await apiClient.get(`/api/prices${this.filtersToQuery(filters)}`);
return res?.data;
}
static async update (price: Price): Promise<Price> {
const res: AxiosResponse<Price> = await apiClient.patch(`/api/prices/${price.id}`, { price });
return res?.data;
}
private static filtersToQuery(filters?: PriceIndexFilter): string {
if (!filters) return '';
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

@ -1,15 +1,14 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Setting, SettingBulkResult, SettingError, SettingName } from '../models/setting';
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
export default class SettingAPI {
async get (name: SettingName): Promise<Setting> {
static async get (name: SettingName): Promise<Setting> {
const res: AxiosResponse<{setting: Setting}> = await apiClient.get(`/api/settings/${name}`);
return res?.data?.setting;
}
async query (names: Array<SettingName>): Promise<Map<SettingName, any>> {
static async query (names: Array<SettingName>): Promise<Map<SettingName, any>> {
const params = new URLSearchParams();
params.append('names', `['${names.join("','")}']`);
@ -17,37 +16,22 @@ export default class SettingAPI {
return SettingAPI.toSettingsMap(names, res?.data);
}
async update (name: SettingName, value: any): Promise<Setting> {
static async update (name: SettingName, value: any): Promise<Setting> {
const res: AxiosResponse = await apiClient.patch(`/api/settings/${name}`, { setting: { value } });
if (res.status === 304) { return { name, value }; }
return res?.data?.setting;
}
async bulkUpdate (settings: Map<SettingName, any>, transactional: boolean = false): Promise<Map<SettingName, SettingBulkResult>> {
static async bulkUpdate (settings: Map<SettingName, any>, transactional: boolean = false): Promise<Map<SettingName, SettingBulkResult>> {
const res: AxiosResponse = await apiClient.patch(`/api/settings/bulk_update?transactional=${transactional}`, { settings: SettingAPI.toObjectArray(settings) });
return SettingAPI.toBulkMap(res?.data?.settings);
}
async isPresent (name: SettingName): Promise<boolean> {
static async isPresent (name: SettingName): Promise<boolean> {
const res: AxiosResponse = await apiClient.get(`/api/settings/is_present/${name}`);
return res?.data?.isPresent;
}
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));
}
static isPresent (name: SettingName): IWrapPromise<boolean> {
const api = new SettingAPI();
return wrapPromise(api.isPresent(name));
}
private static toSettingsMap(names: Array<SettingName>, data: Object): Map<SettingName, any> {
const map = new Map();
names.forEach(name => {

View File

@ -3,7 +3,7 @@ import { AxiosResponse } from 'axios';
import { Theme } from '../models/theme';
export default class ThemeAPI {
async index (): Promise<Array<Theme>> {
static async index (): Promise<Array<Theme>> {
const res: AxiosResponse<Array<Theme>> = await apiClient.get(`/api/themes`);
return res?.data;
}

View File

@ -0,0 +1,16 @@
import apiClient from './clients/api-client';
import { UserPack, UserPackIndexFilter } from '../models/user-pack';
import { AxiosResponse } from 'axios';
export default class UserPackAPI {
static async index(filters: UserPackIndexFilter): Promise<Array<UserPack>> {
const res: AxiosResponse<Array<UserPack>> = await apiClient.get(`/api/user_packs${this.filtersToQuery(filters)}`);
return res?.data;
}
private static filtersToQuery(filters?: UserPackIndexFilter): string {
if (!filters) return '';
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

@ -1,6 +1,5 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
import { Wallet } from '../models/wallet';
export default class WalletAPI {

View File

@ -3,7 +3,7 @@ import { debounce as _debounce } from 'lodash';
interface FabInputProps {
id: string,
onChange?: (value: any, validity?: ValidityState) => void,
onChange?: (value: string, validity?: ValidityState) => void,
defaultValue: any,
icon?: ReactNode,
addOn?: ReactNode,
@ -18,12 +18,15 @@ interface FabInputProps {
placeholder?: string,
error?: string,
type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week',
step?: number | 'any',
min?: number,
max?: number,
}
/**
* This component is a template for an input component that wraps the application style
*/
export const FabInput: React.FC<FabInputProps> = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly, maxLength, pattern, placeholder, error }) => {
export const FabInput: React.FC<FabInputProps> = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly, maxLength, pattern, placeholder, error, step, min, max }) => {
const [inputValue, setInputValue] = useState<any>(defaultValue);
/**
@ -86,6 +89,9 @@ export const FabInput: React.FC<FabInputProps> = ({ id, onChange, defaultValue,
{hasIcon() && <span className="fab-input--icon">{icon}</span>}
<input id={id}
type={type}
step={step}
min={min}
max={max}
className="fab-input--input"
value={inputValue}
onChange={handleChange}

View File

@ -1,9 +1,9 @@
import React, { ReactNode, BaseSyntheticEvent, useEffect } from 'react';
import React, { ReactNode, BaseSyntheticEvent, useEffect, useState } 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 { CustomAsset, CustomAssetName } from '../../models/custom-asset';
import { FabButton } from './fab-button';
Modal.setAppElement('body');
@ -15,37 +15,40 @@ export enum ModalSize {
}
interface FabModalProps {
title: string,
title?: string,
isOpen: boolean,
toggleModal: () => void,
confirmButton?: ReactNode,
closeButton?: boolean,
className?: string,
width?: ModalSize,
customHeader?: ReactNode,
customFooter?: ReactNode,
onConfirm?: (event: BaseSyntheticEvent) => void,
preventConfirm?: boolean,
onCreation?: () => void,
onConfirmSendFormId?: string,
}
// initial request to the API
const blackLogoFile = CustomAssetAPI.get(CustomAssetName.LogoBlackFile);
/**
* This component is a template for a modal dialog that wraps the application style
*/
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm, preventConfirm, onCreation }) => {
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => {
const { t } = useTranslation('shared');
const [blackLogo, setBlackLogo] = useState<CustomAsset>(null);
// initial request to the API to get the theme's logo, for back backgrounds
useEffect(() => {
CustomAssetAPI.get(CustomAssetName.LogoBlackFile).then(data => setBlackLogo(data));
}, []);
useEffect(() => {
if (typeof onCreation === 'function' && isOpen) {
onCreation();
}
}, [isOpen]);
// the theme's logo, for back backgrounds
const blackLogo = blackLogoFile.read();
/**
* Check if the confirm button should be present
*/
@ -53,6 +56,13 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
return confirmButton !== undefined;
}
/**
* Check if the behavior of the confirm button is to send a form, using the provided ID
*/
const confirmationSendForm = (): boolean => {
return onConfirmSendFormId !== undefined;
}
/**
* Should we display the close button?
*/
@ -67,6 +77,13 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
return customFooter !== undefined;
}
/**
* Check if there's a custom header
*/
const hasCustomHeader = (): boolean => {
return customHeader !== undefined;
}
return (
<Modal isOpen={isOpen}
className={`fab-modal fab-modal-${width} ${className}`}
@ -74,11 +91,12 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
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" />
{blackLogo && <img src={blackLogo.custom_asset_file_attributes.attachment_url}
alt={blackLogo.custom_asset_file_attributes.attachment}
className="modal-logo" />}
</Loader>
<h1>{ title }</h1>
{!hasCustomHeader() && <h1>{ title }</h1>}
{hasCustomHeader() && customHeader}
</div>
<div className="fab-modal-content">
{children}
@ -86,7 +104,8 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
<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>}
{hasConfirmButton() && !confirmationSendForm() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} onClick={onConfirm}>{confirmButton}</FabButton>}
{hasConfirmButton() && confirmationSendForm() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} type="submit" form={onConfirmSendFormId}>{confirmButton}</FabButton>}
{hasCustomFooter() && customFooter}
</Loader>
</div>

View File

@ -0,0 +1,32 @@
import React, { ReactNode } from 'react';
interface FabPopoverProps {
title: string,
className?: string,
headerButton?: ReactNode,
}
/**
* This component is a template for a popovers (bottom) that wraps the application style
*/
export const FabPopover: React.FC<FabPopoverProps> = ({ title, className, headerButton, children }) => {
/**
* Check if the header button should be present
*/
const hasHeaderButton = (): boolean => {
return !!headerButton;
}
return (
<div className={`fab-popover ${className ? className : ''}`}>
<div className="popover-title">
<h3>{title}</h3>
{hasHeaderButton() && headerButton}
</div>
<div className="popover-content">
{children}
</div>
</div>
);
}

View File

@ -30,7 +30,7 @@ const EventThemes: React.FC<EventThemesProps> = ({ event, onChange }) => {
const [themes, setThemes] = useState<Array<EventTheme>>([]);
useEffect(() => {
new EventThemeAPI().index().then(data => setThemes(data));
EventThemeAPI.index().then(data => setThemes(data));
}, []);
/**

View File

@ -0,0 +1,90 @@
import React, { ReactNode, useState } from 'react';
import { Machine } from '../../models/machine';
import { useTranslation } from 'react-i18next';
import { Loader } from '../base/loader';
import { ReserveButton } from './reserve-button';
import { User } from '../../models/user';
interface MachineCardProps {
user?: User,
machine: Machine,
onShowMachine: (machine: Machine) => void,
onReserveMachine: (machine: Machine) => void,
onLoginRequested: () => Promise<User>,
onEnrollRequested: (trainingId: number) => void,
onError: (message: string) => void,
onSuccess: (message: string) => void,
}
/**
* This component is a box showing the picture of the given machine and two buttons: one to start the reservation process
* and another to redirect the user to the machine description page.
*/
const MachineCardComponent: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onSuccess, onLoginRequested, onEnrollRequested }) => {
const { t } = useTranslation('public');
// shall we display a loader to prevent double-clicking, while the machine details are loading?
const [loading, setLoading] = useState<boolean>(false);
/**
* Callback triggered when the user clicks on the 'reserve' button and has passed all the verifications
*/
const handleReserveMachine = (): void => {
onReserveMachine(machine);
}
/**
* Callback triggered when the user clicks on the 'view' button
*/
const handleShowMachine = (): void => {
onShowMachine(machine);
}
const machinePicture = (): ReactNode => {
if (!machine.machine_image) {
return <div className="machine-picture no-picture" />;
}
return (
<div className="machine-picture" style={{ backgroundImage: `url(${machine.machine_image})` }} onClick={handleShowMachine} />
)
}
return (
<div className={`machine-card ${loading ? 'loading' : ''} ${machine.disabled ? 'disabled': ''}`}>
{machinePicture()}
<div className="machine-name">
{machine.name}
</div>
<div className="machine-actions">
{!machine.disabled && <ReserveButton currentUser={user}
machineId={machine.id}
onLoadingStart={() => setLoading(true)}
onLoadingEnd={() => setLoading(false)}
onError={onError}
onSuccess={onSuccess}
onReserveMachine={handleReserveMachine}
onLoginRequested={onLoginRequested}
onEnrollRequested={onEnrollRequested}
className="reserve-button">
<i className="fas fa-bookmark" />
{t('app.public.machine_card.book')}
</ReserveButton>}
<span>
<button onClick={handleShowMachine} className="show-button">
<i className="fas fa-eye" />
{t('app.public.machine_card.consult')}
</button>
</span>
</div>
</div>
);
}
export const MachineCard: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onSuccess, onLoginRequested, onEnrollRequested }) => {
return (
<Loader>
<MachineCardComponent user={user} machine={machine} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onError={onError} onSuccess={onSuccess} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} />
</Loader>
);
}

View File

@ -0,0 +1,50 @@
import React from 'react';
import Select from 'react-select';
import { useTranslation } from 'react-i18next';
interface MachinesFiltersProps {
onStatusSelected: (enabled: boolean) => void,
}
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption = { value: boolean, label: string };
export const MachinesFilters: React.FC<MachinesFiltersProps> = ({ onStatusSelected }) => {
const { t } = useTranslation('public');
const defaultValue = { value: true, label: t('app.public.machines_filters.status_enabled') };
/**
* Provides boolean options in the react-select format (yes/no/all)
*/
const buildBooleanOptions = (): Array<selectOption> => {
return [
defaultValue,
{ value: false, label: t('app.public.machines_filters.status_disabled') },
{ value: null, label: t('app.public.machines_filters.status_all') },
]
}
/**
* Callback triggered when the user selects a machine status in the dropdown list
*/
const handleStatusSelected = (option: selectOption): void => {
onStatusSelected(option.value);
}
return (
<div className="machines-filters">
<div className="status-filter">
<label htmlFor="status">{t('app.public.machines_filters.show_machines')}</label>
<Select defaultValue={defaultValue}
id="status"
className="status-select"
onChange={handleStatusSelected}
options={buildBooleanOptions()}/>
</div>
</div>
)
}

View File

@ -0,0 +1,87 @@
import React, { useEffect, useState } from 'react';
import { Machine } from '../../models/machine';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import MachineAPI from '../../api/machine';
import { MachineCard } from './machine-card';
import { MachinesFilters } from './machines-filters';
import { User } from '../../models/user';
declare var Application: IApplication;
interface MachinesListProps {
user?: User,
onError: (message: string) => void,
onSuccess: (message: string) => void,
onShowMachine: (machine: Machine) => void,
onReserveMachine: (machine: Machine) => void,
onLoginRequested: () => Promise<User>,
onEnrollRequested: (trainingId: number) => void,
}
/**
* This component shows a list of all machines and allows filtering on that list.
*/
const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user }) => {
// shown machines
const [machines, setMachines] = useState<Array<Machine>>(null);
// we keep the full list of machines, for filtering
const [allMachines, setAllMachines] = useState<Array<Machine>>(null);
// retrieve the full list of machines on component mount
useEffect(() => {
MachineAPI.index()
.then(data => setAllMachines(data))
.catch(e => onError(e));
}, []);
// filter the machines shown when the full list was retrieved
useEffect(() => {
handleFilterByStatus(true);
}, [allMachines])
/**
* Callback triggered when the user changes the status filter.
* Set the 'machines' state to a filtered list, depending on the provided parameter.
* @param status, true = enabled machines, false = disabled machines, null = all machines
*/
const handleFilterByStatus = (status: boolean): void => {
if (!allMachines) return;
if (status === null) return setMachines(allMachines);
// enabled machines may have the m.disabled property null (for never disabled machines)
// or false (for re-enabled machines)
setMachines(allMachines.filter(m => !!m.disabled === !status));
}
return (
<div className="machines-list">
<MachinesFilters onStatusSelected={handleFilterByStatus} />
<div className="all-machines">
{machines && machines.map(machine => {
return <MachineCard key={machine.id}
user={user}
machine={machine}
onShowMachine={onShowMachine}
onReserveMachine={onReserveMachine}
onError={onError}
onSuccess={onSuccess}
onLoginRequested={onLoginRequested}
onEnrollRequested={onEnrollRequested} />
})}
</div>
</div>
);
}
const MachinesListWrapper: React.FC<MachinesListProps> = ({ user, onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested }) => {
return (
<Loader>
<MachinesList user={user} onError={onError} onSuccess={onSuccess} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} />
</Loader>
);
}
Application.Components.component('machinesList', react2angular(MachinesListWrapper, ['user', 'onError', 'onSuccess', 'onShowMachine', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested']));

View File

@ -0,0 +1,41 @@
import React from 'react';
import moment from 'moment';
import { FabModal } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { HtmlTranslate } from '../base/html-translate';
import { IFablab } from '../../models/fablab';
declare var Fablab: IFablab;
interface PendingTrainingModalProps {
isOpen: boolean,
toggleModal: () => void,
nextReservation: Date,
}
/**
* Modal dialog shown if the current user has registered for a training but this training isn't validated
* when the user tries to book a machine.
*/
export const PendingTrainingModal: React.FC<PendingTrainingModalProps> = ({ isOpen, toggleModal, nextReservation }) => {
const { t } = useTranslation('logged');
/**
* Return the formatted localized date for the given date
*/
const formatDateTime = (date: Date): string => {
const day = Intl.DateTimeFormat().format(moment(date).toDate());
const time = Intl.DateTimeFormat(Fablab.intl_locale, { hour: 'numeric', minute: 'numeric' }).format(moment(date).toDate());
return t('app.logged.pending_training_modal.DATE_TIME', { DATE: day, TIME:time });
}
return (
<FabModal title={t('app.logged.pending_training_modal.machine_reservation')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={true}>
<p>{t('app.logged.pending_training_modal.wait_for_validated')}</p>
<p><HtmlTranslate trKey="app.logged.pending_training_modal.training_will_occur_DATE_html" options={{ DATE: formatDateTime(nextReservation) }} /></p>
</FabModal>
)
}

View File

@ -0,0 +1,81 @@
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../base/fab-modal';
import { HtmlTranslate } from '../base/html-translate';
import { Machine } from '../../models/machine';
import { User } from '../../models/user';
import { Avatar } from '../user/avatar';
import { FabButton } from '../base/fab-button';
interface RequiredTrainingModalProps {
isOpen: boolean,
toggleModal: () => void,
user?: User,
machine?: Machine,
onEnrollRequested: (trainingId: number) => void,
}
/**
* Modal dialog shown if the current user does not have the required training to book the given machine
*/
export const RequiredTrainingModal: React.FC<RequiredTrainingModalProps> = ({ isOpen, toggleModal, user, machine, onEnrollRequested }) => {
const { t } = useTranslation('logged');
/**
* Properly format the list of allowed trainings
*/
const formatTrainings = (): string => {
if (!machine) return '';
return machine.trainings.map(t => t.name).join(t('app.logged.required_training_modal.training_or_training_html'));
}
/**
* Callback triggered when the user has clicked on the "enroll" button
*/
const handleEnroll = (): void => {
onEnrollRequested(machine.trainings[0].id);
}
/**
* Custom header of the dialog: we display the username and avatar
*/
const header = (): ReactNode => {
return (
<div className="user-info">
<Avatar user={user} />
<span className="user-name">{user?.name}</span>
</div>
);
}
/**
* Custom footer of the dialog: we display a user-friendly message to close the dialog
*/
const footer = (): ReactNode => {
return (
<div className="not-now">
<p>{t('app.logged.required_training_modal.no_enroll_for_now')}</p>
<a onClick={toggleModal}>{t('app.logged.required_training_modal.close')}</a>
</div>
);
}
return (
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
className="required-training-modal"
closeButton={false}
customHeader={header()}
customFooter={footer()}>
<div className="training-info">
<p>
<HtmlTranslate trKey={'app.logged.required_training_modal.to_book_MACHINE_requires_TRAINING_html'}
options={{ MACHINE: machine?.name, TRAINING: formatTrainings() }} />
</p>
<div className="enroll-container">
<FabButton onClick={handleEnroll}>{t('app.logged.required_training_modal.enroll_now')}</FabButton>
</div>
</div>
</FabModal>
)
}

View File

@ -0,0 +1,186 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { PendingTrainingModal } from './pending-training-modal';
import { RequiredTrainingModal } from './required-training-modal';
import { Loader } from '../base/loader';
import { ProposePacksModal } from '../prepaid-packs/propose-packs-modal';
import MachineAPI from '../../api/machine';
import { Machine } from '../../models/machine';
import { User } from '../../models/user';
import { IApplication } from '../../models/application';
declare var Application: IApplication;
interface ReserveButtonProps {
currentUser?: User,
machineId: number,
onLoadingStart?: () => void,
onLoadingEnd?: () => void,
onError: (message: string) => void,
onSuccess: (message: string) => void,
onReserveMachine: (machine: Machine) => void,
onLoginRequested: () => Promise<User>,
onEnrollRequested: (trainingId: number) => void,
className?: string
}
/**
* Button component that makes the training verification before redirecting the user to the reservation calendar
*/
const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onSuccess, onReserveMachine, onEnrollRequested, className, children }) => {
const { t } = useTranslation('shared');
const [machine, setMachine] = useState<Machine>(null);
const [user, setUser] = useState<User>(currentUser);
const [pendingTraining, setPendingTraining] = useState<boolean>(false);
const [trainingRequired, setTrainingRequired] = useState<boolean>(false);
const [proposePacks, setProposePacks] = useState<boolean>(false);
// handle login from an external process
useEffect(() => setUser(currentUser), [currentUser]);
// check the trainings after we retrieved the machine data
useEffect(() => checkTraining(), [machine]);
/**
* Callback triggered when the user clicks on the 'reserve' button.
*/
const handleClick = (): void => {
getMachine();
};
/**
* We load the full machine data, including data on the current user status for this machine.
* Then we check if the user has passed the training for it (if it's needed)
*/
const getMachine = (): void => {
if (onLoadingStart) onLoadingStart();
MachineAPI.get(machineId)
.then(data => {
setMachine(data);
if (onLoadingEnd) onLoadingEnd();
})
.catch(error => {
onError(error);
if (onLoadingEnd) onLoadingEnd();
});
};
/**
* Open/closes the alert modal informing the user about his pending training
*/
const togglePendingTrainingModal = (): void => {
setPendingTraining(!pendingTraining);
};
/**
* Open/closes the alert modal informing the user about his pending training
*/
const toggleRequiredTrainingModal = (): void => {
setTrainingRequired(!trainingRequired);
};
/**
* Open/closes the modal dialog inviting the user to buy a prepaid-pack
*/
const toggleProposePacksModal = (): void => {
setProposePacks(!proposePacks);
}
/**
* Callback triggered when the user has successfully bought a pre-paid pack.
* Display the success message and redirect him to the booking page.
*/
const handlePackBought = (message: string, machine: Machine): void => {
onSuccess(message);
onReserveMachine(machine);
}
/**
* Check that the current user has passed the required training before allowing him to book
*/
const checkTraining = (): void => {
// do nothing if the machine is still not loaded
if (!machine) return;
// if there's no user currently logged, trigger the logging process
if (!user) {
onLoginRequested()
.then(() => getMachine())
.catch(error => onError(error));
return;
}
// if the currently logged user has completed the training for this machine, or this machine does not require
// a prior training, move forward to the prepaid-packs verification.
// Moreover, if there's no enabled associated trainings, also move to the next step.
if (machine.current_user_is_trained || machine.trainings.length === 0 ||
machine.trainings.map(t => t.disabled).reduce((acc, val) => acc && val, true)) {
return checkPrepaidPack();
}
// if the currently logged user booked a training for this machine, tell him that he must wait
// for an admin to validate the training before he can book the reservation
if (machine.current_user_next_training_reservation) {
return setPendingTraining(true);
}
// if the currently logged user doesn't have booked the required training, tell him to register
// for a training session first
setTrainingRequired(true);
};
/**
* Once the training condition has been verified, we check if there are prepaid-packs to propose to the customer.
*/
const checkPrepaidPack = (): void => {
// if the customer has already bought a pack or if there's no active packs for this machine,
// let the customer reserve
if (machine.current_user_has_packs || !machine.has_prepaid_packs_for_current_user) {
return onReserveMachine(machine);
}
// otherwise, we show a dialog modal to propose the customer to buy an available pack
setProposePacks(true);
}
return (
<span>
<button onClick={handleClick} className={className ? className : ''}>
{children && children}
{!children && <span>{t('app.shared.reserve_button.book_this_machine')}</span>}
</button>
<PendingTrainingModal isOpen={pendingTraining}
toggleModal={togglePendingTrainingModal}
nextReservation={machine?.current_user_next_training_reservation?.slots_attributes[0]?.start_at} />
<RequiredTrainingModal isOpen={trainingRequired}
toggleModal={toggleRequiredTrainingModal}
user={user}
machine={machine}
onEnrollRequested={onEnrollRequested} />
{machine && currentUser && <ProposePacksModal isOpen={proposePacks}
toggleModal={toggleProposePacksModal}
item={machine}
itemType="Machine"
onError={onError}
customer={currentUser}
onDecline={onReserveMachine}
operator={currentUser}
onSuccess={handlePackBought} />}
</span>
);
}
export const ReserveButton: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onSuccess, onReserveMachine, onEnrollRequested, className, children }) => {
return (
<Loader>
<ReserveButtonComponent currentUser={currentUser} machineId={machineId} onError={onError} onSuccess={onSuccess} onLoadingStart={onLoadingStart} onLoadingEnd={onLoadingEnd} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} className={className}>
{children}
</ReserveButtonComponent>
</Loader>
);
}
Application.Components.component('reserveButton', react2angular(ReserveButton, ['currentUser', 'machineId', 'onLoadingStart', 'onLoadingEnd', 'onError', 'onSuccess', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested', 'className']));

View File

@ -1,16 +1,14 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import moment from 'moment';
import '../../lib/i18n';
import { Loader } from '../base/loader';
import { FabModal } from '../base/fab-modal';
import { IFablab } from '../../models/fablab';
import { PaymentSchedule } from '../../models/payment-schedule';
import { IApplication } from '../../models/application';
import FormatLib from '../../lib/format';
declare var Application: IApplication;
declare var Fablab: IFablab;
interface PaymentScheduleSummaryProps {
schedule: PaymentSchedule
@ -25,18 +23,6 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
// is open, the modal dialog showing the full details of the payment schedule?
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
*/
@ -58,7 +44,7 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
{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) })}
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length, AMOUNT: FormatLib.price(schedule.items[0].amount) })}
</span>
<span className="schedule-item-date">{t('app.shared.cart.first_debit')}</span>
</li>
@ -66,12 +52,12 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
{!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-price">{FormatLib.price(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) })}
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length - 1, AMOUNT: FormatLib.price(schedule.items[1].amount) })}
</span>
</li>
</ul>}
@ -80,9 +66,9 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
<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 className="schedule-item-date">{FormatLib.date(item.due_date)}</span>
<span> </span>
<span className="schedule-item-price">{formatPrice(item.amount)}</span>
<span className="schedule-item-price">{FormatLib.price(item.amount)}</span>
</li>
))}
</ul>

View File

@ -45,8 +45,7 @@ const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ c
const handleLoadMore = (): void => {
setPageNumber(pageNumber + 1);
const api = new PaymentScheduleAPI();
api.index({ query: { page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
PaymentScheduleAPI.index({ query: { page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
const list = paymentSchedules.concat(res);
setPaymentSchedules(list);
}).catch((error) => onError(error.message));
@ -56,8 +55,7 @@ const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ c
* Reload from te API all the currently displayed payment schedules
*/
const handleRefreshList = (): void => {
const api = new PaymentScheduleAPI();
api.index({ query: { page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
PaymentScheduleAPI.index({ query: { page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
setPaymentSchedules(res);
}).catch((err) => {
onError(err.message);

View File

@ -53,8 +53,7 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
setCustomerFilter(customer);
setDateFilter(date);
const api = new PaymentScheduleAPI();
api.list({ query: { reference, customer, date, page: 1, size: PAGE_SIZE }}).then((res) => {
PaymentScheduleAPI.list({ query: { reference, customer, date, page: 1, size: PAGE_SIZE }}).then((res) => {
setPaymentSchedules(res);
}).catch((error) => onError(error.message));
};
@ -65,8 +64,7 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
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) => {
PaymentScheduleAPI.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
const list = paymentSchedules.concat(res);
setPaymentSchedules(list);
}).catch((error) => onError(error.message));
@ -76,8 +74,7 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
* Reload from te API all the currently displayed payment schedules
*/
const handleRefreshList = (): void => {
const api = new PaymentScheduleAPI();
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
PaymentScheduleAPI.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
setPaymentSchedules(res);
}).catch((err) => {
onError(err.message);

View File

@ -1,7 +1,6 @@
import React, { ReactEventHandler, ReactNode, useState } from 'react';
import React, { ReactEventHandler, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader } from '../base/loader';
import moment from 'moment';
import _ from 'lodash';
import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
@ -9,13 +8,9 @@ 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 { IFablab } from '../../models/fablab';
import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from '../../models/payment-schedule';
import PaymentScheduleAPI from '../../api/payment-schedule';
import { useImmer } from 'use-immer';
import { SettingName } from '../../models/setting';
declare var Fablab: IFablab;
import FormatLib from '../../lib/format';
interface PaymentSchedulesTableProps {
paymentSchedules: Array<PaymentSchedule>,
@ -59,19 +54,6 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
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
*/
@ -222,8 +204,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
* 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) => {
PaymentScheduleAPI.cashCheck(tempDeadline.id).then((res) => {
if (res.state === PaymentScheduleItemState.Paid) {
refreshSchedulesTable();
toggleConfirmCashingModal();
@ -267,8 +248,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
*/
const afterAction = (): void => {
toggleConfirmActionButton();
const api = new PaymentScheduleAPI();
api.refreshItem(tempDeadline.id).then(() => {
PaymentScheduleAPI.refreshItem(tempDeadline.id).then(() => {
refreshSchedulesTable();
toggleResolveActionModal();
});
@ -304,8 +284,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
*/
const handleCardUpdateSuccess = (): void => {
if (tempDeadline) {
const api = new PaymentScheduleAPI();
api.payItem(tempDeadline.id).then(() => {
PaymentScheduleAPI.payItem(tempDeadline.id).then(() => {
refreshSchedulesTable();
onCardUpdateSuccess();
toggleUpdateCardModal();
@ -347,8 +326,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
* 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(() => {
PaymentScheduleAPI.cancel(tempSchedule.id).then(() => {
refreshSchedulesTable();
toggleCancelSubscriptionModal();
});
@ -375,8 +353,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
<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>
<td className="w-200">{FormatLib.date(p.created_at)}</td>
<td className="w-120">{FormatLib.price(p.total)}</td>
{showCustomer && <td className="w-200">{p.user.name}</td>}
<td className="w-200">{downloadButton(TargetType.PaymentSchedule, p.id)}</td>
</tr>
@ -395,8 +373,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
</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>{FormatLib.date(item.due_date)}</td>
<td>{FormatLib.price(item.amount)}</td>
<td>{formatState(item)}</td>
<td>{itemButtons(item, p)}</td>
</tr>)}
@ -420,8 +398,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
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)
AMOUNT: FormatLib.price(tempDeadline.amount),
DATE: FormatLib.date(tempDeadline.due_date)
})}
</span>}
</FabModal>

View File

@ -4,8 +4,7 @@ import WalletLib from '../../lib/wallet';
import { WalletInfo } from '../wallet-info';
import { FabModal, ModalSize } from '../base/fab-modal';
import { HtmlTranslate } from '../base/html-translate';
import { CustomAssetName } from '../../models/custom-asset';
import { IFablab } from '../../models/fablab';
import { CustomAsset, CustomAssetName } from '../../models/custom-asset';
import { ShoppingCart } from '../../models/payment';
import { PaymentSchedule } from '../../models/payment-schedule';
import { User } from '../../models/user';
@ -17,8 +16,7 @@ import SettingAPI from '../../api/setting';
import { SettingName } from '../../models/setting';
import { ComputePriceResult } from '../../models/price';
import { Wallet } from '../../models/wallet';
declare var Fablab: IFablab;
import FormatLib from '../../lib/format';
export interface GatewayFormProps {
@ -28,7 +26,7 @@ export interface GatewayFormProps {
customer: User,
operator: User,
className?: string,
paymentSchedule?: boolean,
paymentSchedule?: PaymentSchedule,
cart?: ShoppingCart,
formId: string,
}
@ -39,26 +37,26 @@ interface AbstractPaymentModalProps {
afterSuccess: (result: Invoice|PaymentSchedule) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
schedule?: PaymentSchedule,
customer: User,
logoFooter: ReactNode,
GatewayForm: FunctionComponent<GatewayFormProps>,
formId: string,
className?: string,
formClassName?: string,
title?: string,
preventCgv?: boolean,
preventScheduleInfo?: boolean,
modalSize?: ModalSize,
}
// initial request to the API
const cgvFile = CustomAssetAPI.get(CustomAssetName.CgvFile);
/**
* 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
* @see https://reactjs.org/docs/composition-vs-inheritance.html
*/
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName }) => {
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => {
// customer's wallet
const [wallet, setWallet] = useState<Wallet>(null);
// server-computed price with all details
@ -75,17 +73,18 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
const [tos, setTos] = useState<boolean>(false);
// currently active payment gateway
const [gateway, setGateway] = useState<string>(null);
// the sales conditions
const [cgv, setCgv] = useState<CustomAsset>(null);
const { t } = useTranslation('shared');
const cgv = cgvFile.read();
/**
* When the component is loaded first, get the name of the currently active payment modal
* When the component loads first, get the name of the currently active payment modal
*/
useEffect(() => {
const api = new SettingAPI();
api.get(SettingName.PaymentGateway).then((setting) => {
CustomAssetAPI.get(CustomAssetName.CgvFile).then(asset => setCgv(asset));
SettingAPI.get(SettingName.PaymentGateway).then((setting) => {
// we capitalize the first letter of the name
setGateway(setting.value.replace(/^\w/, (c) => c.toUpperCase()));
})
@ -103,8 +102,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
setWallet(wallet);
PriceAPI.compute(cart).then((res) => {
setPrice(res);
const wLib = new WalletLib(wallet);
setRemainingPrice(wLib.computeRemainingPrice(res.price));
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price));
setReady(true);
})
})
@ -121,7 +119,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
* Check if the user accepts the Terms of Sales document
*/
const hasCgv = (): boolean => {
return cgv != null;
return cgv != null && !preventCgv;
}
/**
@ -132,17 +130,10 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
}
/**
* Check if we are currently creating a payment schedule
* Check if we must display the info box about the 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);
const hasPaymentScheduleInfo = (): boolean => {
return schedule !== undefined && !preventScheduleInfo;
}
/**
@ -178,12 +169,23 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
return !submitState && terms;
}
/**
* Build the modal title. If the provided title is a shared translation key, interpolate it through the
* translation service. Otherwise, just display the provided string.
*/
const getTitle = (): string => {
if (title.match(/^app\.shared\./)) {
return t(title);
}
return title;
}
return (
<FabModal title={t('app.shared.payment.online_payment') }
<FabModal title={getTitle()}
isOpen={isOpen}
toggleModal={toggleModal}
width={ModalSize.medium}
width={modalSize}
closeButton={false}
customFooter={logoFooter}
className={`payment-modal ${className ? className : ''}`}>
@ -197,11 +199,11 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
formId={formId}
cart={cart}
customer={customer}
paymentSchedule={isPaymentSchedule()}>
paymentSchedule={schedule}>
{hasErrors() && <div className="payment-errors">
{errors}
</div>}
{isPaymentSchedule() && <div className="payment-schedule-info">
{hasPaymentScheduleInfo() && <div className="payment-schedule-info">
<HtmlTranslate trKey="app.shared.payment.payment_schedule_html" options={{ DEADLINES: schedule.items.length, GATEWAY: gateway }} />
</div>}
{hasCgv() && <div className="terms-of-sales">
@ -217,7 +219,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
disabled={!canSubmit()}
form={formId}
className="validate-btn">
{t('app.shared.payment.confirm_payment_of_', { AMOUNT: formatPrice(remainingPrice) })}
{t('app.shared.payment.confirm_payment_of_', { AMOUNT: FormatLib.price(remainingPrice) })}
</button>}
{submitState && <div className="payment-pending">
<div className="fa-2x">
@ -228,3 +230,10 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
</FabModal>
);
}
AbstractPaymentModal.defaultProps = {
title: 'app.shared.payment.online_payment',
preventCgv: false,
preventScheduleInfo: false,
modalSize: ModalSize.medium
};

View File

@ -0,0 +1,140 @@
import React, { FormEvent, useState } from 'react';
import Select from 'react-select';
import { useTranslation } from 'react-i18next';
import { GatewayFormProps } from '../abstract-payment-modal';
import LocalPaymentAPI from '../../../api/local-payment';
import FormatLib from '../../../lib/format';
import SettingAPI from '../../../api/setting';
import { SettingName } from '../../../models/setting';
import { PaymentModal } from '../payment-modal';
import { PaymentSchedule } from '../../../models/payment-schedule';
const ALL_SCHEDULE_METHODS = ['card', 'check'] as const;
type scheduleMethod = typeof ALL_SCHEDULE_METHODS[number];
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption = { value: scheduleMethod, label: string };
/**
* A form component to ask for confirmation before cashing a payment directly at the FabLab's reception.
* This is intended for use by privileged users.
* The form validation button must be created elsewhere, using the attribute form={formId}.
*/
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, customer, operator, formId }) => {
const { t } = useTranslation('admin');
const [method, setMethod] = useState<scheduleMethod>('check');
const [onlinePaymentModal, setOnlinePaymentModal] = useState<boolean>(false);
/**
* Open/closes the online payment modal, used to collect card credentials when paying the payment schedule by card.
*/
const toggleOnlinePaymentModal = (): void => {
setOnlinePaymentModal(!onlinePaymentModal);
}
/**
* Convert all payement methods for schedules to the react-select format
*/
const buildMethodOptions = (): Array<selectOption> => {
return ALL_SCHEDULE_METHODS.map(i => methodToOption(i));
}
/**
* Convert the given payment-method to the react-select format
*/
const methodToOption = (value: scheduleMethod): selectOption => {
if (!value) return { value, label: '' };
return { value, label: t(`app.admin.local_payment.method_${value}`) };
}
/**
* Callback triggered when the user selects a payment method for the current payment schedule.
*/
const handleUpdateMethod = (option: selectOption) => {
setMethod(option.value);
}
/**
* Handle the submission of the form. It will process the local payment.
*/
const handleSubmit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
onSubmit();
if (paymentSchedule && method === 'card') {
// check that the online payment is active
try {
const online = await SettingAPI.get(SettingName.OnlinePaymentModule);
if (online.value !== 'true') {
return onError(t('app.admin.local_payment.online_payment_disabled'))
}
return toggleOnlinePaymentModal();
} catch (e) {
onError(e);
}
}
try {
const document = await LocalPaymentAPI.confirmPayment(cart);
onSuccess(document);
} catch (e) {
onError(e);
}
}
/**
* Callback triggered after a successful payment by online card for a schedule.
*/
const afterCreatePaymentSchedule = (document: PaymentSchedule) => {
toggleOnlinePaymentModal();
onSuccess(document);
}
return (
<form onSubmit={handleSubmit} id={formId} className={className ? className : ''}>
{!paymentSchedule && <p className="payment">{t('app.admin.local_payment.about_to_cash')}</p>}
{paymentSchedule && <div className="payment-schedule">
<div className="schedule-method">
<label htmlFor="payment-method">{t('app.admin.local_payment.payment_method')}</label>
<Select placeholder={ t('app.admin.local_payment.payment_method') }
id="payment-method"
className="method-select"
onChange={handleUpdateMethod}
options={buildMethodOptions()}
defaultValue={methodToOption(method)} />
{method === 'card' && <p>{t('app.admin.local_payment.card_collection_info')}</p>}
{method === 'check' && <p>{t('app.admin.local_payment.check_collection_info', { DEADLINES: paymentSchedule.items.length })}</p>}
</div>
<div className="full-schedule">
<ul>
{paymentSchedule.items.map(item => {
return (
<li key={`${item.due_date}`}>
<span className="schedule-item-date">{FormatLib.date(item.due_date)}</span>
<span> </span>
<span className="schedule-item-price">{FormatLib.price(item.amount)}</span>
</li>
)
})}
</ul>
</div>
<PaymentModal isOpen={onlinePaymentModal}
toggleModal={toggleOnlinePaymentModal}
afterSuccess={afterCreatePaymentSchedule}
onError={onError}
cart={cart}
currentUser={operator}
customer={customer} />
</div>}
{children}
</form>
);
}

View File

@ -0,0 +1,91 @@
import React, { FunctionComponent, ReactNode } from 'react';
import { AbstractPaymentModal, GatewayFormProps } from '../abstract-payment-modal';
import { LocalPaymentForm } from './local-payment-form';
import { ShoppingCart } from '../../../models/payment';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { User } from '../../../models/user';
import { Invoice } from '../../../models/invoice';
import { useTranslation } from 'react-i18next';
import { ModalSize } from '../../base/fab-modal';
import { Loader } from '../../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../../models/application';
declare var Application: IApplication;
interface LocalPaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
cart: ShoppingCart,
currentUser: User,
schedule?: PaymentSchedule,
customer: User
}
/**
* This component enables a privileged user to confirm a local payments.
*/
const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer }) => {
const { t } = useTranslation('admin');
/**
* Return the logos, shown in the modal footer.
*/
const logoFooter = (): ReactNode => {
return (
<div className="local-modal-icons">
<i className="fas fa-lock fa-2x" />
</div>
);
}
/**
* Integrates the LocalPaymentForm into the parent AbstractPaymentModal
*/
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children}) => {
return (
<LocalPaymentForm onSubmit={onSubmit}
onSuccess={onSuccess}
onError={onError}
operator={operator}
className={className}
formId={formId}
cart={cart}
customer={customer}
paymentSchedule={paymentSchedule}>
{children}
</LocalPaymentForm>
);
}
return (
<AbstractPaymentModal className="local-payment-modal"
isOpen={isOpen}
toggleModal={toggleModal}
logoFooter={logoFooter()}
title={t('app.admin.local_payment.offline_payment')}
formId="local-payment-form"
formClassName="local-payment-form"
currentUser={currentUser}
cart={cart}
customer={customer}
afterSuccess={afterSuccess}
schedule={schedule}
GatewayForm={renderForm}
modalSize={schedule ? ModalSize.large : ModalSize.medium}
preventCgv
preventScheduleInfo />
);
}
export const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, currentUser, schedule , cart, customer }) => {
return (
<Loader>
<LocalPaymentModalComponent isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
</Loader>
);
}
Application.Components.component('localPaymentModal', react2angular(LocalPaymentModal, ['isOpen', 'toggleModal', 'afterSuccess', 'currentUser', 'schedule', 'cart', 'customer']));

View File

@ -1,4 +1,4 @@
import React, { ReactElement } from 'react';
import React, { ReactElement, useEffect, useState } from 'react';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { StripeModal } from './stripe/stripe-modal';
@ -7,7 +7,7 @@ import { IApplication } from '../../models/application';
import { ShoppingCart } from '../../models/payment';
import { User } from '../../models/user';
import { PaymentSchedule } from '../../models/payment-schedule';
import { SettingName } from '../../models/setting';
import { Setting, SettingName } from '../../models/setting';
import { Invoice } from '../../models/invoice';
import SettingAPI from '../../api/setting';
import { useTranslation } from 'react-i18next';
@ -21,20 +21,24 @@ interface PaymentModalProps {
onError: (message: string) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
schedule?: PaymentSchedule,
customer: User
}
// initial request to the API
const paymentGateway = SettingAPI.get(SettingName.PaymentGateway);
/**
* This component open a modal dialog for the configured payment gateway, allowing the user to input his card data
* to process an online payment.
*/
const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
const PaymentModalComponent: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
const { t } = useTranslation('shared');
const gateway = paymentGateway.read();
const [gateway, setGateway] = useState<Setting>(null);
useEffect(() => {
SettingAPI.get(SettingName.PaymentGateway)
.then(setting => setGateway(setting))
.catch(error => onError(error));
}, []);
/**
* Render the Stripe payment modal
@ -65,6 +69,8 @@ const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterS
/**
* Determine which gateway is enabled and return the appropriate payment modal
*/
if (gateway === null || !isOpen) return <div/>;
switch (gateway.value) {
case 'stripe':
return renderStripeModal();
@ -82,12 +88,12 @@ const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterS
}
const PaymentModalWrapper: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
return (
<Loader>
<PaymentModal isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
<PaymentModalComponent isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
</Loader>
);
}
Application.Components.component('paymentModal', react2angular(PaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer']));
Application.Components.component('paymentModal', react2angular(PaymentModal, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer']));

View File

@ -67,11 +67,10 @@ export const PayzenCardUpdateModal: React.FC<PayzenCardUpdateModalProps> = ({ is
onSuccess={onSuccess}
onError={handleCardUpdateError}
className="card-form"
paymentSchedule={true}
paymentSchedule={schedule}
operator={operator}
customer={schedule.user as User}
updateCard={true}
paymentScheduleId={schedule.id}
formId={formId} >
{errors && <div className="payzen-errors">
{errors}

View File

@ -17,21 +17,19 @@ import { Invoice } from '../../../models/invoice';
// we use these two additional parameters to update the card, if provided
interface PayzenFormProps extends GatewayFormProps {
updateCard?: boolean,
paymentScheduleId: number,
}
/**
* A form component to collect the credit card details and to create the payment method on Stripe.
* The form validation button must be created elsewhere, using the attribute form={formId}.
*/
export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, updateCard = false, cart, customer, formId, paymentScheduleId }) => {
export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId }) => {
const PayZenKR = useRef<KryptonClient>(null);
const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader');
useEffect(() => {
const api = new SettingAPI();
api.query([SettingName.PayZenEndpoint, SettingName.PayZenPublicKey]).then(settings => {
SettingAPI.query([SettingName.PayZenEndpoint, SettingName.PayZenPublicKey]).then(settings => {
createToken().then(formToken => {
// Load the remote library
KRGlue.loadLibrary(settings.get(SettingName.PayZenEndpoint), settings.get(SettingName.PayZenPublicKey))
@ -55,7 +53,7 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
*/
const createToken = async (): Promise<CreateTokenResponse> => {
if (updateCard) {
return await PayzenAPI.updateToken(paymentScheduleId);
return await PayzenAPI.updateToken(paymentSchedule?.id);
} else if (paymentSchedule) {
return await PayzenAPI.chargeCreateToken(cart, customer);
} else {

View File

@ -46,8 +46,7 @@ const PayZenKeysFormComponent: React.FC<PayZenKeysFormProps> = ({ onValidKeys, o
* When the component loads for the first time, initialize the keys with the values fetched from the API (if any)
*/
useEffect(() => {
const api = new SettingAPI();
api.query(payZenSettings).then(payZenKeys => {
SettingAPI.query(payZenSettings).then(payZenKeys => {
updateSettings(new Map(payZenKeys));
}).catch(error => console.error(error));
}, []);

View File

@ -17,7 +17,7 @@ interface PayZenModalProps {
afterSuccess: (result: Invoice|PaymentSchedule) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
schedule?: PaymentSchedule,
customer: User
}

View File

@ -54,10 +54,9 @@ export const PayzenSettings: React.FC<PayzenSettingsProps> = ({ onEditKeys, onCu
* For the private settings, we initialize them with the placeholder value, if the setting is set.
*/
useEffect(() => {
const api = new SettingAPI();
api.query(payZenPublicSettings.concat(payZenOtherSettings)).then(payZenKeys => {
api.isPresent(SettingName.PayZenPassword).then(pzPassword => {
api.isPresent(SettingName.PayZenHmacKey).then(pzHmac => {
SettingAPI.query(payZenPublicSettings.concat(payZenOtherSettings)).then(payZenKeys => {
SettingAPI.isPresent(SettingName.PayZenPassword).then(pzPassword => {
SettingAPI.isPresent(SettingName.PayZenHmacKey).then(pzHmac => {
const map = new Map(payZenKeys);
map.set(SettingName.PayZenPassword, pzPassword ? PAYZEN_HIDDEN : '');
map.set(SettingName.PayZenHmacKey, pzHmac ? PAYZEN_HIDDEN : '');
@ -94,8 +93,7 @@ export const PayzenSettings: React.FC<PayzenSettingsProps> = ({ onEditKeys, onCu
* This will update the setting on the server.
*/
const saveCurrency = (): void => {
const api = new SettingAPI();
api.update(SettingName.PayZenCurrency, settings.get(SettingName.PayZenCurrency)).then(result => {
SettingAPI.update(SettingName.PayZenCurrency, settings.get(SettingName.PayZenCurrency)).then(result => {
setError('');
updateSettings(draft => draft.set(SettingName.PayZenCurrency, result.value));
onCurrencyUpdateSuccess(result.value);

View File

@ -4,9 +4,6 @@ import { loadStripe } from "@stripe/stripe-js";
import { SettingName } from '../../../models/setting';
import SettingAPI from '../../../api/setting';
// initial request to the API
const stripePublicKey = SettingAPI.get(SettingName.StripePublicKey);
/**
* This component initializes the stripe's Elements tag with the API key
*/
@ -17,9 +14,10 @@ export const StripeElements: React.FC = memo(({ children }) => {
* When this component is mounted, we initialize the <Elements> tag with the Stripe's public key
*/
useEffect(() => {
const key = stripePublicKey.read();
const promise = loadStripe(key.value);
setStripe(promise);
SettingAPI.get(SettingName.StripePublicKey).then(key => {
const promise = loadStripe(key.value);
setStripe(promise);
});
}, [])
return (

View File

@ -43,8 +43,7 @@ const StripeKeysFormComponent: React.FC<StripeKeysFormProps> = ({ onValidKeys, o
useEffect(() => {
mounted.current = true;
const api = new SettingAPI();
api.query([SettingName.StripePublicKey, SettingName.StripeSecretKey]).then(stripeKeys => {
SettingAPI.query([SettingName.StripePublicKey, SettingName.StripeSecretKey]).then(stripeKeys => {
setPublicKey(stripeKeys.get(SettingName.StripePublicKey));
setSecretKey(stripeKeys.get(SettingName.StripeSecretKey));
}).catch(error => console.error(error));

View File

@ -18,7 +18,7 @@ interface StripeModalProps {
afterSuccess: (result: Invoice|PaymentSchedule) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
schedule?: PaymentSchedule,
customer: User
}

View File

@ -231,7 +231,7 @@ const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLogin
return (
<div key={groupId} className="plans-per-group">
<h2 className="group-title">{ groupName(groupId) }</h2>
{renderPlansByCategory(plansByGroup)}
{plansByGroup && renderPlansByCategory(plansByGroup)}
</div>
)
})}

View File

@ -0,0 +1,166 @@
import React, { useEffect, useState } from 'react';
import _ from 'lodash';
import { Machine } from '../../models/machine';
import { User } from '../../models/user';
import { UserPack } from '../../models/user-pack';
import UserPackAPI from '../../api/user-pack';
import SettingAPI from '../../api/setting';
import { SettingName } from '../../models/setting';
import { FabButton } from '../base/fab-button';
import { useTranslation } from 'react-i18next';
import { ProposePacksModal } from './propose-packs-modal';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
import { PrepaidPack } from '../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
declare var Application: IApplication;
type PackableItem = Machine;
interface PacksSummaryProps {
item: PackableItem,
itemType: 'Machine',
customer?: User,
operator: User,
onError: (message: string) => void,
onSuccess: (message: string) => void,
refresh?: Promise<void>
}
const PacksSummaryComponent: React.FC<PacksSummaryProps> = ({ item, itemType, customer, operator, onError, onSuccess, refresh }) => {
const { t } = useTranslation('logged');
const [packs, setPacks] = useState<Array<PrepaidPack>>(null);
const [userPacks, setUserPacks] = useState<Array<UserPack>>(null);
const [threshold, setThreshold] = useState<number>(null);
const [packsModal, setPacksModal] = useState<boolean>(false);
useEffect(() => {
SettingAPI.get(SettingName.RenewPackThreshold)
.then(data => setThreshold(parseFloat(data.value)))
.catch(error => onError(error));
}, []);
useEffect(() => {
getUserPacksData();
}, [customer, item, itemType]);
useEffect(() => {
if (refresh instanceof Promise) {
refresh.then(getUserPacksData);
}
}, [refresh]);
/**
* Fetch the user packs data from the API
*/
const getUserPacksData = (): void => {
if (_.isEmpty(customer)) return;
UserPackAPI.index({ user_id: customer.id, priceable_type: itemType, priceable_id: item.id })
.then(data => setUserPacks(data))
.catch(error => onError(error));
PrepaidPackAPI.index({ priceable_id: item.id, priceable_type: itemType, group_id: customer.group_id, disabled: false })
.then(data => setPacks(data))
.catch(error => onError(error));
}
/**
* Total of minutes used by the customer
*/
const totalUsed = (): number => {
if (!userPacks) return 0;
return userPacks.map(up => up.minutes_used).reduce((acc, curr) => acc + curr, 0);
}
/**
* Total of minutes available is the packs bought by the customer
*/
const totalAvailable = (): number => {
if (!userPacks) return 0;
return userPacks.map(up => up.prepaid_pack.minutes).reduce((acc, curr) => acc + curr, 0);
}
/**
* Total prepaid hours remaining for the current customer
*/
const totalHours = (): number => {
return (totalAvailable() - totalUsed()) / 60;
}
/**
* Do we need to display the "buy new pack" button?
*/
const shouldDisplayButton = (): boolean => {
if (!packs?.length) return false;
if (threshold < 1) {
return totalAvailable() - totalUsed() <= totalAvailable() * threshold;
}
return totalAvailable() - totalUsed() <= threshold * 60;
}
/**
* Open/closes the prepaid-pack buying modal
*/
const togglePacksModal = (): void => {
setPacksModal(!packsModal);
}
/**
* Callback triggered when the customer has successfully bought a prepaid-pack
*/
const handlePackBoughtSuccess = (message: string): void => {
onSuccess(message);
togglePacksModal();
UserPackAPI.index({ user_id: customer.id, priceable_type: itemType, priceable_id: item.id })
.then(data => setUserPacks(data))
.catch(error => onError(error));
}
// prevent component rendering if no customer selected
if (_.isEmpty(customer)) return <div />;
// prevent component rendering if ths customer have no packs and there are no packs available
if (totalHours() === 0 && packs?.length === 0) return <div/>;
return (
<div className="packs-summary">
<h3>{t('app.logged.packs_summary.prepaid_hours')}</h3>
<div className="content">
<span className="remaining-hours">
{totalHours() > 0 && t('app.logged.packs_summary.remaining_HOURS', { HOURS: totalHours(), ITEM: itemType })}
{totalHours() === 0 && t('app.logged.packs_summary.no_hours', { ITEM: itemType })}
</span>
{shouldDisplayButton() && <div className="button-wrapper">
<FabButton className="buy-button" onClick={togglePacksModal} icon={<i className="fa fa-shopping-cart"/>}>
{t('app.logged.packs_summary.buy_a_new_pack')}
</FabButton>
<ProposePacksModal isOpen={packsModal}
toggleModal={togglePacksModal}
item={item}
itemType={itemType}
customer={customer}
operator={operator}
onError={onError}
onDecline={togglePacksModal}
onSuccess={handlePackBoughtSuccess} />
</div>}
</div>
</div>
);
}
export const PacksSummary: React.FC<PacksSummaryProps> = ({ item, itemType, customer, operator, onError, onSuccess, refresh }) => {
return (
<Loader>
<PacksSummaryComponent item={item} itemType={itemType} customer={customer} operator={operator} onError={onError} onSuccess={onSuccess} refresh={refresh} />
</Loader>
);
}
Application.Components.component('packsSummary', react2angular(PacksSummary, ['item', 'itemType', 'customer', 'operator', 'onError', 'onSuccess', 'refresh']));

View File

@ -0,0 +1,173 @@
import React, { useEffect, useState } from 'react';
import { Machine } from '../../models/machine';
import { FabModal, ModalSize } from '../base/fab-modal';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { User } from '../../models/user';
import { PrepaidPack } from '../../models/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import PriceAPI from '../../api/price';
import { Price } from '../../models/price';
import { PaymentMethod, ShoppingCart } from '../../models/payment';
import { PaymentModal } from '../payment/payment-modal';
import UserLib from '../../lib/user';
import { LocalPaymentModal } from '../payment/local-payment/local-payment-modal';
import FormatLib from '../../lib/format';
type PackableItem = Machine;
interface ProposePacksModalProps {
isOpen: boolean,
toggleModal: () => void,
item: PackableItem,
itemType: 'Machine',
customer: User,
operator: User,
onError: (message: string) => void,
onDecline: (item: PackableItem) => void,
onSuccess: (message:string, item: PackableItem) => void,
}
/**
* Modal dialog shown to offer prepaid-packs for purchase, to the current user.
*/
export const ProposePacksModal: React.FC<ProposePacksModalProps> = ({ isOpen, toggleModal, item, itemType, customer, operator, onError, onDecline, onSuccess }) => {
const { t } = useTranslation('logged');
const [price, setPrice] = useState<Price>(null);
const [packs, setPacks] = useState<Array<PrepaidPack>>(null);
const [cart, setCart] = useState<ShoppingCart>(null);
const [paymentModal, setPaymentModal] = useState<boolean>(false);
const [localPaymentModal, setLocalPaymentModal] = useState<boolean>(false);
useEffect(() => {
PrepaidPackAPI.index({ priceable_id: item.id, priceable_type: itemType, group_id: customer.group_id, disabled: false })
.then(data => setPacks(data))
.catch(error => onError(error));
PriceAPI.index({ priceable_id: item.id, priceable_type: itemType, group_id: customer.group_id, plan_id: null })
.then(data => setPrice(data[0]))
.catch(error => onError(error));
}, [item]);
/**
* Open/closes the payment modal
*/
const togglePaymentModal = (): void => {
setPaymentModal(!paymentModal);
}
/**
* Open/closes the local payment modal (for admins and managers)
*/
const toggleLocalPaymentModal = (): void => {
setLocalPaymentModal(!localPaymentModal);
}
/**
* Convert the hourly-based price of the given prive, to a total price, based on the duration of the given pack
*/
const hourlyPriceToTotal = (price: Price, pack: PrepaidPack): number => {
const hours = pack.minutes / 60;
return price.amount * hours;
}
/**
* Return the number of hours, user-friendly formatted
*/
const formatDuration = (minutes: number): string => {
return t('app.logged.propose_packs_modal.pack_DURATION', { DURATION: minutes / 60 });
}
/**
* Return a user-friendly string for the validity of the provided pack
*/
const formatValidity = (pack: PrepaidPack): string => {
const period = t(`app.logged.propose_packs_modal.period.${pack.validity_interval}`, { COUNT: pack.validity_count });
return t('app.logged.propose_packs_modal.validity', { COUNT: pack.validity_count, PERIODS: period });
}
/**
* The user has declined to buy a pack
*/
const handlePacksRefused = (): void => {
onDecline(item);
}
/**
* The user has accepted to buy the provided pack, process with the payment
*/
const handleBuyPack = (pack: PrepaidPack) => {
return (): void => {
setCart({
customer_id: customer.id,
payment_method: PaymentMethod.Card,
items: [
{ prepaid_pack: { id: pack.id }}
]
});
if (new UserLib(operator).isPrivileged(customer)) {
return toggleLocalPaymentModal();
}
togglePaymentModal();
}
}
/**
* Callback triggered when the user has bought the pack with a successful payment
*/
const handlePackBought = (): void => {
onSuccess(t('app.logged.propose_packs_modal.pack_bought_success'), item);
}
/**
* Render the given prepaid-pack
*/
const renderPack = (pack: PrepaidPack) => {
if (!price) return;
const normalPrice = hourlyPriceToTotal(price, pack)
return (
<div key={pack.id} className="pack">
<span className="duration">{formatDuration(pack.minutes)}</span>
<span className="amount">{FormatLib.price(pack.amount)}</span>
{pack.amount < normalPrice && <span className="crossed-out-price">{FormatLib.price(normalPrice)}</span>}
<span className="validity">{formatValidity(pack)}</span>
<FabButton className="buy-button" onClick={handleBuyPack(pack)} icon={<i className="fas fa-shopping-cart" />}>
{t('app.logged.propose_packs_modal.buy_this_pack')}
</FabButton>
</div>
)
}
return (
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
width={ModalSize.large}
confirmButton={t('app.logged.propose_packs_modal.no_thanks')}
onConfirm={handlePacksRefused}
className="propose-packs-modal"
title={t('app.logged.propose_packs_modal.available_packs')}>
<p>{t('app.logged.propose_packs_modal.packs_proposed')}</p>
<div className="list-of-packs">
{packs?.map(p => renderPack(p))}
</div>
{cart && <div>
<PaymentModal isOpen={paymentModal}
toggleModal={togglePaymentModal}
afterSuccess={handlePackBought}
onError={onError}
cart={cart}
currentUser={operator}
customer={customer} />
<LocalPaymentModal isOpen={localPaymentModal}
toggleModal={toggleLocalPaymentModal}
afterSuccess={handlePackBought}
cart={cart}
currentUser={operator}
customer={customer} />
</div>}
</FabModal>
);
}

View File

@ -0,0 +1,94 @@
import React, { ReactNode, useState } from 'react';
import { PrepaidPack } from '../../models/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { FabPopover } from '../base/fab-popover';
import { CreatePack } from './create-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { DeletePack } from './delete-pack';
import { EditPack } from './edit-pack';
import FormatLib from '../../lib/format';
interface ConfigurePacksButtonProps {
packsData: Array<PrepaidPack>,
onError: (message: string) => void,
onSuccess: (message: string) => void,
groupId: number,
priceableId: number,
priceableType: string,
}
/**
* This component is a button that shows the list of prepaid-packs when moving the mouse over it.
* It also triggers modal dialogs to configure (add/delete/edit/remove) prepaid-packs.
*/
export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ packsData, onError, onSuccess, groupId, priceableId, priceableType }) => {
const { t } = useTranslation('admin');
const [packs, setPacks] = useState<Array<PrepaidPack>>(packsData);
const [showList, setShowList] = useState<boolean>(false);
const [editPackModal, setEditPackModal] = useState<boolean>(false);
/**
* Return the number of hours, user-friendly formatted
*/
const formatDuration = (minutes: number): string => {
return t('app.admin.configure_packs_button.pack_DURATION', { DURATION: minutes / 60 });
}
/**
* Open/closes the popover listing the existing packs
*/
const toggleShowList = (): void => {
setShowList(!showList);
}
/**
* Open/closes the "edit pack" modal
*/
const toggleEditPackModal = (): void => {
setEditPackModal(!editPackModal);
}
/**
* Callback triggered when the PrepaidPack was successfully created/deleted/updated.
* We refresh the list of packs for the current tooltip to display the new data.
*/
const handleSuccess = (message: string) => {
onSuccess(message);
PrepaidPackAPI.index({ group_id: groupId, priceable_id: priceableId, priceable_type: priceableType })
.then(data => setPacks(data))
.catch(error => onError(error));
}
/**
* Render the button used to trigger the "new pack" modal
*/
const renderAddButton = (): ReactNode => {
return <CreatePack onSuccess={handleSuccess}
onError={onError}
groupId={groupId}
priceableId={priceableId}
priceableType={priceableType} />;
}
return (
<div className="configure-packs-button">
<button className="packs-button" onClick={toggleShowList}>
<i className="fas fa-box" />
</button>
{showList && <FabPopover title={t('app.admin.configure_packs_button.packs')} headerButton={renderAddButton()}>
<ul>
{packs?.map(p =>
<li key={p.id} className={p.disabled ? 'disabled' : ''}>
{formatDuration(p.minutes)} - {FormatLib.price(p.amount)}
<span className="pack-actions">
<EditPack onSuccess={handleSuccess} onError={onError} pack={p} />
<DeletePack onSuccess={handleSuccess} onError={onError} pack={p} />
</span>
</li>)}
</ul>
{packs?.length === 0 && <span>{t('app.admin.configure_packs_button.no_packs')}</span>}
</FabPopover>}
</div>
);
}

View File

@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { FabModal } from '../base/fab-modal';
import { PackForm } from './pack-form';
import { PrepaidPack } from '../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert';
interface CreatePackProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
groupId: number,
priceableId: number,
priceableType: string,
}
/**
* This component shows a button.
* When clicked, we show a modal dialog handing the process of creating a new PrepaidPack
*/
export const CreatePack: React.FC<CreatePackProps> = ({ onSuccess, onError, groupId, priceableId, priceableType }) => {
const { t } = useTranslation('admin');
const [isOpen, setIsOpen] = useState<boolean>(false);
/**
* Open/closes the "new pack" modal dialog
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
}
/**
* Callback triggered when the user has validated the creation of the new PrepaidPack
*/
const handleSubmit = (pack: PrepaidPack): void => {
// set the already-known attributes of the new pack
const newPack = Object.assign<PrepaidPack, PrepaidPack>({} as PrepaidPack, pack);
newPack.group_id = groupId;
newPack.priceable_id = priceableId;
newPack.priceable_type = priceableType;
// create it on the API
PrepaidPackAPI.create(newPack)
.then(() => {
onSuccess(t('app.admin.create_pack.pack_successfully_created'));
toggleModal();
})
.catch(error => onError(error));
}
return (
<div className="create-pack">
<button className="add-pack-button" onClick={toggleModal}><i className="fas fa-plus"/></button>
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.create_pack.new_pack')}
className="new-pack-modal"
closeButton
confirmButton={t('app.admin.create_pack.create_pack')}
onConfirmSendFormId="new-pack">
<FabAlert level="info">
{t('app.admin.create_pack.new_pack_info', { TYPE: priceableType })}
</FabAlert>
<PackForm formId="new-pack" onSubmit={handleSubmit} />
</FabModal>
</div>
);
}

View File

@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
import { Loader } from '../base/loader';
import { PrepaidPack } from '../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
interface DeletePackProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
pack: PrepaidPack,
}
/**
* This component shows a button.
* When clicked, we show a modal dialog to ask the user for confirmation about the deletion of the provided pack.
*/
const DeletePackComponent: React.FC<DeletePackProps> = ({ onSuccess, onError, pack }) => {
const { t } = useTranslation('admin');
const [deletionModal, setDeletionModal] = useState<boolean>(false);
/**
* Opens/closes the deletion modal
*/
const toggleDeletionModal = (): void => {
setDeletionModal(!deletionModal);
};
/**
* The deletion has been confirmed by the user.
* Call the API to trigger the deletion of the temporary set plan-category
*/
const onDeleteConfirmed = (): void => {
PrepaidPackAPI.destroy(pack.id).then(() => {
onSuccess(t('app.admin.delete_pack.pack_deleted'));
}).catch((error) => {
onError(t('app.admin.delete_pack.unable_to_delete') + error);
});
toggleDeletionModal();
};
return (
<div className="delete-pack">
<FabButton type='button' className="remove-pack-button" icon={<i className="fa fa-trash" />} onClick={toggleDeletionModal} />
<FabModal title={t('app.admin.delete_pack.delete_pack')}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}
closeButton={true}
confirmButton={t('app.admin.delete_pack.confirm_delete')}
onConfirm={onDeleteConfirmed}>
<span>{t('app.admin.delete_pack.delete_confirmation')}</span>
</FabModal>
</div>
)
};
export const DeletePack: React.FC<DeletePackProps> = ({ onSuccess, onError, pack }) => {
return (
<Loader>
<DeletePackComponent onSuccess={onSuccess} onError={onError} pack={pack} />
</Loader>
);
}

View File

@ -0,0 +1,70 @@
import React, { useState } from 'react';
import { FabModal } from '../base/fab-modal';
import { PackForm } from './pack-form';
import { PrepaidPack } from '../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
interface EditPackProps {
pack: PrepaidPack,
onSuccess: (message: string) => void,
onError: (message: string) => void
}
/**
* This component shows a button.
* When clicked, we show a modal dialog handing the process of creating a new PrepaidPack
*/
export const EditPack: React.FC<EditPackProps> = ({ pack, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [isOpen, setIsOpen] = useState<boolean>(false);
const [packData, setPackData] = useState<PrepaidPack>(null);
/**
* Open/closes the "edit pack" modal dialog
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
}
/**
* When the user clicks on the edition button, query the full data of the current pack from the API, then open te edition modal
*/
const handleRequestEdit = (): void => {
PrepaidPackAPI.get(pack.id)
.then(data => {
setPackData(data);
toggleModal();
})
.catch(error => onError(error));
}
/**
* Callback triggered when the user has validated the changes of the PrepaidPack
*/
const handleUpdate = (pack: PrepaidPack): void => {
PrepaidPackAPI.update(pack)
.then(() => {
onSuccess(t('app.admin.edit_pack.pack_successfully_updated'));
toggleModal();
})
.catch(error => onError(error));
}
return (
<div className="edit-pack">
<FabButton type='button' className="edit-pack-button" icon={<i className="fas fa-edit" />} onClick={handleRequestEdit} />
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.edit_pack.edit_pack')}
className="edit-pack-modal"
closeButton
confirmButton={t('app.admin.edit_pack.confirm_changes')}
onConfirmSendFormId="edit-pack">
{packData && <PackForm formId="edit-pack" onSubmit={handleUpdate} pack={packData} />}
</FabModal>
</div>
);
}

View File

@ -0,0 +1,50 @@
import React, { useState } from 'react';
import { IFablab } from '../../models/fablab';
import { FabInput } from '../base/fab-input';
import { FabButton } from '../base/fab-button';
import { Price } from '../../models/price';
import FormatLib from '../../lib/format';
declare var Fablab: IFablab;
interface EditablePriceProps {
price: Price,
onSave: (price: Price) => void,
}
/**
* Display the given price.
* When the user clics on the price, switch to the edition mode to allow him modifying the price.
*/
export const EditablePrice: React.FC<EditablePriceProps> = ({ price, onSave }) => {
const [edit, setEdit] = useState<boolean>(false);
const [tempPrice, setTempPrice] = useState<string>(`${price.amount}`);
/**
* Saves the new price
*/
const handleValidateEdit = (): void => {
const newPrice: Price = Object.assign({}, price);
newPrice.amount = parseFloat(tempPrice);
onSave(newPrice);
toggleEdit();
}
/**
* Enable or disable the edit mode
*/
const toggleEdit = (): void => {
setEdit(!edit);
}
return (
<span className="editable-price">
{!edit && <span className="display-price" onClick={toggleEdit}>{FormatLib.price(price.amount)}</span>}
{edit && <span>
<FabInput id="price" type="number" step={0.01} defaultValue={price.amount} addOn={Fablab.intl_currency} onChange={setTempPrice} required/>
<FabButton icon={<i className="fas fa-check" />} className="approve-button" onClick={handleValidateEdit} />
<FabButton icon={<i className="fas fa-times" />} className="cancel-button" onClick={toggleEdit} />
</span>}
</span>
);
}

View File

@ -0,0 +1,153 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { FabAlert } from '../base/fab-alert';
import { HtmlTranslate } from '../base/html-translate';
import MachineAPI from '../../api/machine';
import GroupAPI from '../../api/group';
import { IFablab } from '../../models/fablab';
import { Machine } from '../../models/machine';
import { Group } from '../../models/group';
import { IApplication } from '../../models/application';
import { EditablePrice } from './editable-price';
import { ConfigurePacksButton } from './configure-packs-button';
import PriceAPI from '../../api/price';
import { Price } from '../../models/price';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { PrepaidPack } from '../../models/prepaid-pack';
import { useImmer } from 'use-immer';
declare var Fablab: IFablab;
declare var Application: IApplication;
interface MachinesPricingProps {
onError: (message: string) => void,
onSuccess: (message: string) => void,
}
/**
* Interface to set and edit the prices of machines-hours, per group
*/
const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess }) => {
const { t } = useTranslation('admin');
const [machines, setMachines] = useState<Array<Machine>>(null);
const [groups, setGroups] = useState<Array<Group>>(null);
const [prices, updatePrices] = useImmer<Array<Price>>(null);
const [packs, setPacks] = useState<Array<PrepaidPack>>(null);
// retrieve the initial data
useEffect(() => {
MachineAPI.index({ disabled: false })
.then(data => setMachines(data))
.catch(error => onError(error));
GroupAPI.index({ disabled: false , admins: false })
.then(data => setGroups(data))
.catch(error => onError(error));
PriceAPI.index({ priceable_type: 'Machine', plan_id: null })
.then(data => updatePrices(data))
.catch(error => onError(error));
PrepaidPackAPI.index()
.then(data => setPacks(data))
.catch(error => onError(error))
}, []);
// duration of the example slot
const EXEMPLE_DURATION = 20;
/**
* Return the exemple price, formatted
*/
const examplePrice = (type: 'hourly_rate' | 'final_price'): string => {
const hourlyRate = 10;
if (type === 'hourly_rate') {
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(hourlyRate);
}
const price = (hourlyRate / 60) * EXEMPLE_DURATION;
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(price);
};
/**
* Find the price matching the given criterion
*/
const findPriceBy = (machineId, groupId): Price => {
return prices.find(price => price.priceable_id === machineId && price.group_id === groupId);
};
/**
* Filter the packs matching the given criterion
*/
const filterPacksBy = (machineId, groupId): Array<PrepaidPack> => {
return packs.filter(pack => pack.priceable_id === machineId && pack.group_id === groupId);
};
/**
* Update the given price in the internal state
*/
const updatePrice = (price: Price): void => {
updatePrices(draft => {
const index = draft.findIndex(p => p.id === price.id);
draft[index] = price;
return draft;
});
}
/**
* Callback triggered when the user has confirmed to update a price
*/
const handleUpdatePrice = (price: Price): void => {
PriceAPI.update(price)
.then(() => {
onSuccess(t('app.admin.machines_pricing.price_updated'));
updatePrice(price);
})
.catch(error => onError(error))
}
return (
<div className="machines-pricing">
<FabAlert level="warning">
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_match_machine_hours_rates_html"/></p>
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_calculated_on_hourly_rate_html" options={{ DURATION: EXEMPLE_DURATION, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }} /></p>
<p>{t('app.admin.machines_pricing.you_can_override')}</p>
</FabAlert>
<table>
<thead>
<tr>
<th>{t('app.admin.machines_pricing.machines')}</th>
{groups?.map(group => <th key={group.id} className="group-name">{group.name}</th>)}
</tr>
</thead>
<tbody>
{machines?.map(machine => <tr key={machine.id}>
<td>{machine.name}</td>
{groups?.map(group => <td key={group.id}>
{prices && <EditablePrice price={findPriceBy(machine.id, group.id)} onSave={handleUpdatePrice} />}
{packs && <ConfigurePacksButton packsData={filterPacksBy(machine.id, group.id)}
onError={onError}
onSuccess={onSuccess}
groupId={group.id}
priceableId={machine.id}
priceableType="Machine" />}
</td>)}
</tr>)}
</tbody>
</table>
</div>
);
}
const MachinesPricingWrapper: React.FC<MachinesPricingProps> = ({ onError, onSuccess }) => {
return (
<Loader>
<MachinesPricing onError={onError} onSuccess={onSuccess} />
</Loader>
);
}
Application.Components.component('machinesPricing', react2angular(MachinesPricingWrapper, ['onError', 'onSuccess']));

View File

@ -0,0 +1,145 @@
import React, { BaseSyntheticEvent } from 'react';
import Select from 'react-select';
import Switch from 'react-switch';
import { PrepaidPack } from '../../models/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { useImmer } from 'use-immer';
import { FabInput } from '../base/fab-input';
import { IFablab } from '../../models/fablab';
declare var Fablab: IFablab;
interface PackFormProps {
formId: string,
onSubmit: (pack: PrepaidPack) => void,
pack?: PrepaidPack,
}
const ALL_INTERVALS = ['day', 'week', 'month', 'year'] as const;
type interval = typeof ALL_INTERVALS[number];
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption = { value: interval, label: string };
/**
* A form component to create/edit a PrepaidPack.
* The form validation must be created elsewhere, using the attribute form={formId}.
*/
export const PackForm: React.FC<PackFormProps> = ({ formId, onSubmit, pack }) => {
const [packData, updatePackData] = useImmer<PrepaidPack>(pack || {} as PrepaidPack);
const { t } = useTranslation('admin');
/**
* Convert all validity-intervals to the react-select format
*/
const buildOptions = (): Array<selectOption> => {
return ALL_INTERVALS.map(i => intervalToOption(i));
}
/**
* Convert the given validity-interval to the react-select format
*/
const intervalToOption = (value: interval): selectOption => {
if (!value) return { value, label: '' };
return { value, label: t(`app.admin.pack_form.intervals.${value}`, { COUNT: packData.validity_count || 0 }) };
}
/**
* Callback triggered when the user sends the form.
*/
const handleSubmit = (event: BaseSyntheticEvent): void => {
event.preventDefault();
onSubmit(packData);
}
/**
* Callback triggered when the user inputs an amount for the current pack.
*/
const handleUpdateAmount = (amount: string) => {
updatePackData(draft => {
draft.amount = parseFloat(amount);
});
}
/**
* Callback triggered when the user inputs a number of hours for the current pack.
*/
const handleUpdateHours = (hours: string) => {
updatePackData(draft => {
draft.minutes = parseInt(hours, 10) * 60;
});
}
/**
* Callback triggered when the user inputs a number of periods for the current pack.
*/
const handleUpdateValidityCount = (count: string) => {
updatePackData(draft => {
draft.validity_count = parseInt(count, 10);
});
}
/**
* Callback triggered when the user selects a type of interval for the current pack.
*/
const handleUpdateValidityInterval = (option: selectOption) => {
updatePackData(draft => {
draft.validity_interval = option.value as interval;
});
}
/**
* Callback triggered when the user disables the pack.
*/
const handleUpdateDisabled = (checked: boolean) => {
updatePackData(draft => {
draft.disabled = checked;
})
}
return (
<form id={formId} onSubmit={handleSubmit} className="pack-form">
<label htmlFor="hours">{t('app.admin.pack_form.hours')} *</label>
<FabInput id="hours"
type="number"
defaultValue={packData?.minutes / 60 || ''}
onChange={handleUpdateHours}
min={1}
icon={<i className="fas fa-clock" />}
required />
<label htmlFor="amount">{t('app.admin.pack_form.amount')} *</label>
<FabInput id="amount"
type="number"
step={0.01}
min={0}
defaultValue={packData?.amount || ''}
onChange={handleUpdateAmount}
icon={<i className="fas fa-money-bill" />}
addOn={Fablab.intl_currency}
required />
<label htmlFor="validity_count">{t('app.admin.pack_form.validity_count')}</label>
<div className="interval-inputs">
<FabInput id="validity_count"
type="number"
min={0}
defaultValue={packData?.validity_count || ''}
onChange={handleUpdateValidityCount}
icon={<i className="fas fa-calendar-week" />} />
<Select placeholder={t('app.admin.pack_form.select_interval')}
className="select-interval"
defaultValue={intervalToOption(packData?.validity_interval)}
onChange={handleUpdateValidityInterval}
options={buildOptions()} />
</div>
<label htmlFor="disabled">{t('app.admin.pack_form.disabled')}</label>
<div>
<Switch checked={packData?.disabled || false} onChange={handleUpdateDisabled} id="disabled" />
</div>
</form>
);
}

View File

@ -27,9 +27,6 @@ interface SelectGatewayModalModalProps {
onSuccess: (results: Map<SettingName, SettingBulkResult>) => void,
}
// initial request to the API
const paymentGateway = SettingAPI.get(SettingName.PaymentGateway);
const SelectGatewayModal: React.FC<SelectGatewayModalModalProps> = ({ isOpen, toggleModal, onError, onSuccess }) => {
const { t } = useTranslation('admin');
@ -37,9 +34,11 @@ const SelectGatewayModal: React.FC<SelectGatewayModalModalProps> = ({ isOpen, to
const [selectedGateway, setSelectedGateway] = useState<string>('');
const [gatewayConfig, setGatewayConfig] = useState<Map<SettingName, string>>(new Map());
// request the configured gateway to the API
useEffect(() => {
const gateway = paymentGateway.read();
setSelectedGateway(gateway.value ? gateway.value : '');
SettingAPI.get(SettingName.PaymentGateway).then(gateway => {
setSelectedGateway(gateway.value ? gateway.value : '');
})
}, []);
/**
@ -101,8 +100,7 @@ const SelectGatewayModal: React.FC<SelectGatewayModalModalProps> = ({ isOpen, to
const settings = new Map<SettingName, string>(gatewayConfig);
settings.set(SettingName.PaymentGateway, selectedGateway);
const api = new SettingAPI();
api.bulkUpdate(settings, true).then(result => {
SettingAPI.bulkUpdate(settings, true).then(result => {
const errorResults = Array.from(result.values()).filter(item => !item.status);
if (errorResults.length > 0) {
onError(errorResults.map(item => item.error[0]).join(' '));

View File

@ -0,0 +1,29 @@
import React from 'react';
import { User } from '../../models/user';
import noAvatar from '../../../../images/no_avatar.png';
interface AvatarProps {
user: User,
className?: string,
}
/**
* This component renders the user-profile's picture or a placeholder
*/
export const Avatar: React.FC<AvatarProps> = ({ user, className }) => {
/**
* Check if the provided user has a configured avatar
*/
const hasAvatar = (): boolean => {
return !!user?.profile?.user_avatar?.attachment_url;
}
return (
<div className={`avatar ${className ? className : ''}`}>
{!hasAvatar() && <img src={noAvatar} alt="avatar placeholder"/>}
{hasAvatar() && <img src={user.profile.user_avatar.attachment_url} alt="user's avatar"/>}
</div>
);
}

View File

@ -6,12 +6,11 @@ import '../lib/i18n';
import { Loader } from './base/loader';
import { User } from '../models/user';
import { Wallet } from '../models/wallet';
import { IFablab } from '../models/fablab';
import WalletLib from '../lib/wallet';
import { ShoppingCart } from '../models/payment';
import FormatLib from '../lib/format';
declare var Application: IApplication;
declare var Fablab: IFablab;
interface WalletInfoProps {
cart: ShoppingCart,
@ -35,12 +34,6 @@ export const WalletInfo: React.FC<WalletInfoProps> = ({ cart, currentUser, walle
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.
@ -87,25 +80,25 @@ export const WalletInfo: React.FC<WalletInfoProps> = ({ cart, currentUser, walle
<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>
<h3>{t('app.shared.wallet.wallet_info.you_have_AMOUNT_in_wallet', {AMOUNT: FormatLib.price(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),
AMOUNT: FormatLib.price(remainingPrice),
ITEM: getPriceItem()
})}
</p>}
</div>}
{!isOperatorAndClient() && <div>
<h3>{t('app.shared.wallet.wallet_info.client_have_AMOUNT_in_wallet', {AMOUNT: formatPrice(wallet.amount)})}</h3>
<h3>{t('app.shared.wallet.wallet_info.client_have_AMOUNT_in_wallet', {AMOUNT: FormatLib.price(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),
AMOUNT: FormatLib.price(remainingPrice),
ITEM: getPriceItem()
})}
</p>}

View File

@ -107,7 +107,8 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal',
partnerId: null,
partnerContact: null,
ui_weight: 0,
monthly_payment: false
monthly_payment: false,
plan_category_id: null
};
// API URL where the form will be posted

View File

@ -18,13 +18,10 @@
/**
* Controller used in the prices edition page
*/
Application.Controllers.controller('EditPricingController', ['$scope', '$state', '$uibModal', '$filter', 'TrainingsPricing', 'Credit', 'Pricing', 'Plan', 'Coupon', 'plans', 'groups', 'growl', 'machinesPricesPromise', 'Price', 'dialogs', 'trainingsPricingsPromise', 'trainingsPromise', 'machineCreditsPromise', 'machinesPromise', 'trainingCreditsPromise', 'couponsPromise', 'spacesPromise', 'spacesPricesPromise', 'spacesCreditsPromise', 'settingsPromise', '_t', 'Member', 'uiTourService', 'planCategories',
function ($scope, $state, $uibModal, $filter, TrainingsPricing, Credit, Pricing, Plan, Coupon, plans, groups, growl, machinesPricesPromise, Price, dialogs, trainingsPricingsPromise, trainingsPromise, machineCreditsPromise, machinesPromise, trainingCreditsPromise, couponsPromise, spacesPromise, spacesPricesPromise, spacesCreditsPromise, settingsPromise, _t, Member, uiTourService, planCategories) {
Application.Controllers.controller('EditPricingController', ['$scope', '$state', '$uibModal', '$filter', 'TrainingsPricing', 'Credit', 'Pricing', 'Plan', 'Coupon', 'plans', 'groups', 'growl', 'Price', 'dialogs', 'trainingsPricingsPromise', 'trainingsPromise', 'machineCreditsPromise', 'machinesPromise', 'trainingCreditsPromise', 'couponsPromise', 'spacesPromise', 'spacesPricesPromise', 'spacesCreditsPromise', 'settingsPromise', '_t', 'Member', 'uiTourService', 'planCategories',
function ($scope, $state, $uibModal, $filter, TrainingsPricing, Credit, Pricing, Plan, Coupon, plans, groups, growl, Price, dialogs, trainingsPricingsPromise, trainingsPromise, machineCreditsPromise, machinesPromise, trainingCreditsPromise, couponsPromise, spacesPromise, spacesPricesPromise, spacesCreditsPromise, settingsPromise, _t, Member, uiTourService, planCategories) {
/* PUBLIC SCOPE */
// List of machines prices (not considering any plan)
$scope.machinesPrices = machinesPricesPromise;
// List of trainings pricing
$scope.trainingsPricings = trainingsPricingsPromise;
@ -640,6 +637,20 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
return $filter('currency')(price);
};
/**
* Callback triggered by react components
*/
$scope.onSuccess = function (message) {
growl.success(message);
};
/**
* Callback triggered by react components
*/
$scope.onError = function (message) {
growl.error(message);
};
/**
* Setup the feature-tour for the admin/pricing page.
* This is intended as a contextual help (when pressing F1)

View File

@ -95,123 +95,55 @@ class MachinesController {
}
}
/**
* Manages the transition when a user clicks on the reservation button.
* According to the status of user currently logged into the system, redirect him to the reservation page,
* or display a modal window asking him to complete a training before he can book a machine reservation.
* @param machine {{id:number}} An object containg the id of the machine to book,
* the object will be completed before the fonction returns.
* @param e {Object} see https://docs.angularjs.org/guide/expression#-event-
*/
const _reserveMachine = function (machine, e) {
const _this = this;
e.preventDefault();
e.stopPropagation();
// retrieve the full machine object
return machine = _this.Machine.get({ id: machine.id }, function () {
// if the currently logged'in user has completed the training for this machine, or this machine does not require
// a prior training, just redirect him to the machine's booking page
if (machine.current_user_is_training || (machine.trainings.length === 0)) {
return _this.$state.go('app.logged.machines_reserve', { id: machine.slug });
} else {
// otherwise, if a user is authenticated ...
if (_this.$scope.isAuthenticated()) {
// ... and have booked a training for this machine, tell him that he must wait for an admin to validate
// the training before he can book the reservation
if (machine.current_user_training_reservation) {
return _this.$uibModal.open({
templateUrl: '/machines/training_reservation_modal.html',
controller: ['$scope', '$uibModalInstance', function ($scope, $uibModalInstance) {
$scope.machine = machine;
return $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
}]
});
// ... but does not have booked the training, tell him to register for a training session first
// unless all associated trainings are disabled
} else {
// if all trainings are disabled, just redirect the user to the reservation calendar
if (machine.trainings.map(function (t) { return t.disabled; }).reduce(function (acc, val) { return acc && val; }, true)) {
return _this.$state.go('app.logged.machines_reserve', { id: machine.slug });
// otherwise open the information modal
} else {
return _this.$uibModal.open({
templateUrl: '/machines/request_training_modal.html',
controller: ['$scope', '$uibModalInstance', '$state', function ($scope, $uibModalInstance, $state) {
$scope.machine = machine;
$scope.member = _this.$scope.currentUser;
// transform the name of the trainings associated with the machine to integrate them in a sentence
$scope.humanizeTrainings = function () {
let text = '';
angular.forEach($scope.machine.trainings, function (training) {
if (text.length > 0) {
text += _this._t('app.public.machines_list._or_the_');
}
return text += training.name.substr(0, 1).toLowerCase() + training.name.substr(1);
});
return text;
};
// modal is closed with validation
$scope.ok = function () {
$state.go('app.logged.trainings_reserve', { id: $scope.machine.trainings[0].id });
return $uibModalInstance.close(machine);
};
// modal is closed with escaping
return $scope.cancel = function (e) {
e.preventDefault();
return $uibModalInstance.dismiss('cancel');
};
}
] });
}
}
// if the user is not logged, open the login modal window
} else {
return _this.$scope.login();
}
}
});
};
/**
* Controller used in the public listing page, allowing everyone to see the list of machines
*/
Application.Controllers.controller('MachinesController', ['$scope', '$state', '_t', 'AuthService', 'Machine', '$uibModal', 'machinesPromise', 'settingsPromise', 'Member', 'uiTourService',
function ($scope, $state, _t, AuthService, Machine, $uibModal, machinesPromise, settingsPromise, Member, uiTourService) {
Application.Controllers.controller('MachinesController', ['$scope', '$state', '_t', 'AuthService', 'Machine', '$uibModal', 'settingsPromise', 'Member', 'uiTourService', 'machinesPromise', 'growl',
function ($scope, $state, _t, AuthService, Machine, $uibModal, settingsPromise, Member, uiTourService, machinesPromise, growl) {
/* PUBLIC SCOPE */
// Retrieve the list of machines
$scope.machines = machinesPromise;
/**
* Redirect the user to the machine details page
*/
* Redirect the user to the machine details page
*/
$scope.showMachine = function (machine) { $state.go('app.public.machines_show', { id: machine.slug }); };
/**
* Callback to book a reservation for the current machine
*/
$scope.reserveMachine = _reserveMachine.bind({
$scope,
$state,
_t,
$uibModal,
Machine
});
* Shows an error message forwarded from a child component
*/
$scope.onError = function (message) {
growl.error(message);
}
// Default: we show only enabled machines
$scope.machineFiltering = 'enabled';
/**
* Shows a success message forwarded from a child react components
*/
$scope.onSuccess = function (message) {
growl.success(message)
}
// Available options for filtering machines by status
$scope.filterDisabled = [
'enabled',
'disabled',
'all'
];
/**
* Open the modal dialog to log the user and resolves the returned promise when the logging process
* was successfully completed.
*/
$scope.onLoginRequest = function (e) {
return new Promise((resolve, _reject) => {
$scope.login(e, resolve);
});
}
/**
* Redirect the user to the training reservation page
*/
$scope.onEnrollRequest = function (trainingId) {
$state.go('app.logged.trainings_reserve', { id: trainingId });
}
/**
* Callback to book a reservation for the current machine
*/
$scope.reserveMachine = function (machine) {
$state.go('app.logged.machines_reserve', { id: machine.slug });
}
/**
* Setup the feature-tour for the machines page. (admins only)
@ -232,7 +164,7 @@ Application.Controllers.controller('MachinesController', ['$scope', '$state', '_
placement: 'bottom',
orphan: true
});
if ($scope.machines.length > 0) {
if (machinesPromise.length > 0) {
uitour.createStep({
selector: '.machines-list .show-button',
stepId: 'view',
@ -253,7 +185,7 @@ Application.Controllers.controller('MachinesController', ['$scope', '$state', '_
orphan: true
});
}
if ($scope.machines.length > 0) {
if (machinesPromise.length > 0) {
uitour.createStep({
selector: '.machines-list .reserve-button',
stepId: 'reserve',
@ -286,16 +218,6 @@ Application.Controllers.controller('MachinesController', ['$scope', '$state', '_
}
}
}
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {}
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}
]);
@ -389,16 +311,44 @@ Application.Controllers.controller('ShowMachineController', ['$scope', '$state',
}
};
/**
* Shows an error message forwarded from a child component
*/
$scope.onError = function (message) {
growl.error(message);
}
/**
* Shows a success message forwarded from a child react components
*/
$scope.onSuccess = function (message) {
growl.success(message)
}
/**
* Open the modal dialog to log the user and resolves the returned promise when the logging process
* was successfully completed.
*/
$scope.onLoginRequest = function (e) {
return new Promise((resolve, _reject) => {
$scope.login(e, resolve);
});
}
/**
* Redirect the user to the training reservation page
*/
$scope.onEnrollRequest = function (trainingId) {
$state.go('app.logged.trainings_reserve', { id: trainingId });
}
/**
* Callback to book a reservation for the current machine
*/
return $scope.reserveMachine = _reserveMachine.bind({
$scope,
$state,
_t,
$uibModal,
Machine
});
$scope.reserveMachine = function (machine) {
$state.go('app.logged.machines_reserve', { id: machine.slug });
}
}
]);
@ -407,8 +357,8 @@ Application.Controllers.controller('ShowMachineController', ['$scope', '$state',
* This controller workflow is pretty similar to the trainings reservation controller.
*/
Application.Controllers.controller('ReserveMachineController', ['$scope', '$stateParams', '_t', 'moment', 'Auth', '$timeout', 'Member', 'Availability', 'plansPromise', 'groupsPromise', 'machinePromise', 'settingsPromise', 'uiCalendarConfig', 'CalendarConfig', 'Reservation',
function ($scope, $stateParams, _t, moment, Auth, $timeout, Member, Availability, plansPromise, groupsPromise, machinePromise, settingsPromise, uiCalendarConfig, CalendarConfig, Reservation) {
Application.Controllers.controller('ReserveMachineController', ['$scope', '$stateParams', '_t', 'moment', 'Auth', '$timeout', 'Member', 'Availability', 'plansPromise', 'groupsPromise', 'machinePromise', 'settingsPromise', 'uiCalendarConfig', 'CalendarConfig', 'Reservation', 'growl',
function ($scope, $stateParams, _t, moment, Auth, $timeout, Member, Availability, plansPromise, groupsPromise, machinePromise, settingsPromise, uiCalendarConfig, CalendarConfig, Reservation, growl) {
/* PRIVATE STATIC CONSTANTS */
// Slot free to be booked
@ -465,6 +415,9 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
// current machine to reserve
$scope.machine = machinePromise;
// will be set to a Promise and resolved after the payment is sone
$scope.afterPaymentPromise = null;
// fullCalendar (v2) configuration
$scope.calendarConfig = CalendarConfig({
minTime: moment.duration(moment(settingsPromise.booking_window_start).format('HH:mm:ss')),
@ -668,6 +621,14 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
}
refetchCalendar();
// trigger the refresh of react components
setTimeout(() => {
$scope.afterPaymentPromise = new Promise(resolve => {
resolve();
});
$scope.$apply();
}, 50);
});
};
@ -676,6 +637,21 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
*/
$scope.filterDisabledPlans = function (plan) { return !plan.disabled; };
/**
* Callback triggered after a successful prepaid-pack purchase
*/
$scope.onSuccess = function (message) {
growl.success(message);
};
/**
* Callback triggered by react components
*/
$scope.onError = function (message) {
growl.error(message);
};
/* PRIVATE SCOPE */
/**

View File

@ -93,6 +93,7 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
setTimeout(() => {
if (!$scope.isAuthenticated()) {
$scope.login();
$scope.$apply();
}
}, 50);
};

View File

@ -79,6 +79,12 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
cartItems: undefined
};
// offline payments (at the fablab's reception)
$scope.localPayment = {
showModal: false,
cartItems: undefined
};
// currently logged-in user
$scope.currentUser = $rootScope.currentUser;
@ -325,6 +331,19 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
}, 50);
};
/**
* This will open/close the local payment modal
*/
$scope.toggleLocalPaymentModal = (beforeApply) => {
setTimeout(() => {
$scope.localPayment.showModal = !$scope.localPayment.showModal;
if (typeof beforeApply === 'function') {
beforeApply();
}
$scope.$apply();
}, 50);
};
/**
* Invoked atfer a successful card payment
* @param invoice {*} may be an Invoice or a paymentSchedule
@ -334,6 +353,15 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
afterPayment(invoice);
};
/**
* Invoked atfer a successful offline payment
* @param invoice {*} may be an Invoice or a paymentSchedule
*/
$scope.afterLocalPaymentSuccess = (invoice) => {
$scope.toggleLocalPaymentModal();
afterPayment(invoice);
};
/**
* Invoked when something wrong occurred during the payment dialog initialization
* @param message {string}
@ -717,195 +745,14 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
});
}
};
/**
* Open a modal window that allows the user to process a local payment for his current shopping cart (admin only).
*/
const payOnSite = function (items) {
$uibModal.open({
templateUrl: '/shared/valid_reservation_modal.html',
size: $scope.schedule.payment_schedule ? 'lg' : 'sm',
resolve: {
price () {
return Price.compute(mkCartItems(items, '')).$promise;
},
cartItems () {
return mkCartItems(items, '');
},
wallet () {
return Wallet.getWalletByUser({ user_id: $scope.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', 'price', 'Auth', 'LocalPayment', 'wallet', 'helpers', '$filter', 'coupon', 'selectedPlan', 'schedule', 'cartItems', 'user', 'settings',
function ($scope, $uibModalInstance, $state, price, Auth, LocalPayment, 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;
// Price to pay (wallet deducted)
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
// Reservation &| subscription
$scope.cartItems = cartItems;
// Subscription
$scope.plan = selectedPlan;
// Used in wallet info template to interpolate some translations
$scope.numberFilter = $filter('number');
// 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: 'card'
};
// "valid" Button label
$scope.validButtonName = '';
// online payment modal state
// this is used to collect card data when a payment-schedule was selected, and paid with a card
$scope.isOpenOnlinePaymentModal = false;
// the customer
$scope.user = user;
/**
* Check if the shopping cart contains a reservation
* @return {Reservation|boolean}
*/
$scope.reservation = (function () {
const item = cartItems.items.find(i => i.reservation);
if (item && item.reservation.slots_attributes.length > 0) {
return item.reservation;
}
return false;
})();
/**
* Check if the shopping cart contains a subscription
* @return {Subscription|boolean}
*/
$scope.subscription = (function () {
const item = cartItems.items.find(i => i.subscription);
if (item && item.subscription.plan_id) {
return item.subscription;
}
return false;
})();
/**
* Callback to process the local payment, triggered on button click
*/
$scope.ok = function () {
if ($scope.schedule && $scope.method.payment_method === 'card') {
// 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.toggleOnlinePaymentModal();
}
}
$scope.attempting = true;
LocalPayment.confirm(cartItems, 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' });
$scope.attempting = false;
});
};
/**
* Callback to close the modal without processing the payment
*/
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
/**
* Asynchronously updates the status of the online payment modal
*/
$scope.toggleOnlinePaymentModal = function () {
setTimeout(() => {
$scope.isOpenOnlinePaymentModal = !$scope.isOpenOnlinePaymentModal;
$scope.$apply();
}, 50);
};
/**
* After creating a payment schedule by card, from an administrator.
* @param result {*} PaymentSchedule
*/
$scope.afterCreatePaymentSchedule = function (result) {
$scope.toggleOnlinePaymentModal();
$uibModalInstance.close(result);
};
/**
* Invoked when something wrong occurred during the payment dialog initialization
* @param message {string}
*/
$scope.onCreatePaymentScheduleError = (message) => {
growl.error(message);
};
/* 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.payment_method = newValue;
});
};
/**
* Compute the Label of the confirmation button
*/
const computeValidButtonName = function () {
let method = '';
if ($scope.schedule) {
if (AuthService.isAuthorized(['admin', 'manager']) && $rootScope.currentUser.id !== cartItems.customer_id) {
method = $scope.method.payment_method;
} else {
method = 'card';
}
}
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 (paymentSchedule) { afterPayment(paymentSchedule); });
$scope.toggleLocalPaymentModal(() => {
$scope.localPayment.cartItems = mkCartItems(items);
});
};
/**

View File

@ -10,6 +10,7 @@ Application.Directives.directive('numberSetting', ['Setting', 'growl', '_t',
faIcon: '@',
helperText: '@',
min: '@',
step: '@',
required: '<'
},
templateUrl: '/admin/settings/number.html',
@ -17,7 +18,7 @@ Application.Directives.directive('numberSetting', ['Setting', 'growl', '_t',
// The setting
$scope.setting = {
name: $scope.name,
value: parseInt($scope.settings[$scope.name], 10)
value: parseFloat($scope.settings[$scope.name])
};
/**

View File

@ -0,0 +1,20 @@
import moment from 'moment';
import { IFablab } from '../models/fablab';
declare var Fablab: IFablab;
export default class FormatLib {
/**
* Return the formatted localized date for the given date
*/
static date = (date: Date): string => {
return Intl.DateTimeFormat().format(moment(date).toDate());
}
/**
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
*/
static price = (price: number): string => {
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price);
}
}

View File

@ -2,8 +2,9 @@ import i18n from 'i18next';
import ICU from 'i18next-icu';
import HttpApi from 'i18next-http-backend';
import { initReactI18next } from 'react-i18next';
import { IFablab } from '../models/fablab';
declare var Fablab: any;
declare var Fablab: IFablab;
i18n
.use(ICU)

View File

@ -0,0 +1,22 @@
import { User, UserRole } from '../models/user';
export default class UserLib {
private user: User;
constructor (user: User) {
this.user = user;
}
/**
* Check if the current user has privileged access for resources concerning the provided customer
*/
isPrivileged = (customer: User): boolean => {
if (this.user.role === UserRole.Admin) return true;
if (this.user.role === UserRole.Manager) {
return (this.user.id !== customer.id);
}
return false;
}
}

View File

@ -1,37 +0,0 @@
/**
* 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;

View File

@ -1,3 +1,8 @@
export interface GroupIndexFilter {
disabled?: boolean,
admins?: boolean,
}
export interface Group {
id: number,
slug: string,

View File

@ -0,0 +1,34 @@
import { Reservation } from './reservation';
export interface MachineIndexFilter {
disabled: boolean,
}
export interface Machine {
id: number,
name: string,
description?: string,
spec?: string,
disabled: boolean,
slug: string,
machine_image: string,
machine_files_attributes?: Array<{
id: number,
attachment: string,
attachment_url: string
}>,
trainings?: Array<{
id: number,
name: string,
disabled: boolean,
}>,
current_user_is_trained?: boolean,
current_user_next_training_reservation?: Reservation,
current_user_has_packs?: boolean,
has_prepaid_packs_for_current_user?: boolean,
machine_projects?: Array<{
id: number,
name: string,
slug: string,
}>
}

View File

@ -19,7 +19,7 @@ export enum PaymentMethod {
Other = ''
}
export type CartItem = { reservation: Reservation }|{ subscription: SubscriptionRequest }|{ card_update: { date: Date } };
export type CartItem = { reservation: Reservation }|{ subscription: SubscriptionRequest }|{ prepaid_pack: { id: number } };
export interface ShoppingCart {
customer_id: number,

View File

@ -0,0 +1,19 @@
export interface PackIndexFilter {
group_id?: number,
priceable_id?: number,
priceable_type?: string,
disabled?: boolean,
}
export interface PrepaidPack {
id?: number,
priceable_id?: number,
priceable_type?: string,
group_id: number,
validity_interval?: 'day' | 'week' | 'month' | 'year',
validity_count?: number,
minutes: number,
amount: number,
disabled?: boolean,
}

View File

@ -1,3 +1,10 @@
export interface PriceIndexFilter {
priceable_type?: string,
priceable_id?: number,
group_id?: number,
plan_id?: number|null,
}
export interface Price {
id: number,
group_id: number,

View File

@ -108,7 +108,8 @@ export enum SettingName {
PayZenPublicKey = 'payzen_public_key',
PayZenHmacKey = 'payzen_hmac',
PayZenCurrency = 'payzen_currency',
PublicAgendaModule = 'public_agenda_module'
PublicAgendaModule = 'public_agenda_module',
RenewPackThreshold = 'renew_pack_threshold',
}
export interface Setting {

View File

@ -0,0 +1,14 @@
export interface UserPackIndexFilter {
user_id?: number,
priceable_type: string,
priceable_id: number
}
export interface UserPack {
minutes_used: number,
expires_at: Date,
prepaid_pack: {
minutes: number,
}
}

View File

@ -772,7 +772,6 @@ angular.module('application.router', ['ui.router'])
resolve: {
plans: ['Plan', function (Plan) { return Plan.query().$promise; }],
groups: ['Group', function (Group) { return Group.query().$promise; }],
machinesPricesPromise: ['Price', function (Price) { return Price.query({ priceable_type: 'Machine', plan_id: 'null' }).$promise; }],
trainingsPricingsPromise: ['TrainingsPricing', function (TrainingsPricing) { return TrainingsPricing.query().$promise; }],
trainingsPromise: ['Training', function (Training) { return Training.query().$promise; }],
machineCreditsPromise: ['Credit', function (Credit) { return Credit.query({ creditable_type: 'Machine' }).$promise; }],
@ -1080,7 +1079,8 @@ angular.module('application.router', ['ui.router'])
"'fablab_name', 'name_genre', 'reminder_enable', 'plans_module', 'confirmation_required', " +
"'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']"
"'link_name', 'home_content', 'home_css', 'phone_required', 'upcoming_events_shown', 'public_agenda_module'," +
"'renew_pack_threshold']"
}).$promise;
}],
privacyDraftsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'privacy_draft', history: true }).$promise; }],

View File

@ -21,34 +21,49 @@
@import "modules/signup";
@import "modules/stripe";
@import "modules/tour";
@import "modules/fab-modal";
@import "modules/fab-input";
@import "modules/fab-button";
@import "modules/fab-alert";
@import "modules/payment-schedule-summary";
@import "modules/base/fab-modal";
@import "modules/base/fab-input";
@import "modules/base/fab-button";
@import "modules/base/fab-alert";
@import "modules/base/fab-popover";
@import "modules/base/labelled-input";
@import "modules/payment-schedule/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 "modules/plans-list";
@import "modules/plan-card";
@import "modules/payment-schedule/payment-schedules-table";
@import "modules/payment-schedule/payment-schedules-list";
@import "modules/payment-schedule/payment-schedule-dashboard";
@import "modules/plans/plans-list";
@import "modules/plans/plan-card";
@import "modules/plans/plans-filter";
@import "modules/event-themes";
@import "modules/select-gateway-modal";
@import "modules/stripe-keys-form";
@import "modules/payzen-keys-form";
@import "modules/payzen-settings";
@import "modules/payment-modal";
@import "modules/payzen-modal";
@import "modules/stripe-update-card-modal";
@import "modules/payzen-update-card-modal";
@import "modules/plan-categories-list";
@import "modules/create-plan-category";
@import "modules/edit-plan-category";
@import "modules/delete-plan-category";
@import "modules/plans-filter";
@import "modules/payment/payment-modal";
@import "modules/payment/stripe/stripe-keys-form";
@import "modules/payment/stripe/stripe-confirm";
@import "modules/payment/stripe/stripe-modal";
@import "modules/payment/stripe/stripe-update-card-modal";
@import "modules/payment/payzen/payzen-keys-form";
@import "modules/payment/payzen/payzen-settings";
@import "modules/payment/payzen/payzen-modal";
@import "modules/payment/payzen/payzen-update-card-modal";
@import "modules/payment/local-payment/local-payment-modal";
@import "modules/plan-categories/plan-categories-list";
@import "modules/plan-categories/create-plan-category";
@import "modules/plan-categories/edit-plan-category";
@import "modules/plan-categories/delete-plan-category";
@import "modules/machines/machine-card";
@import "modules/machines/machines-list";
@import "modules/machines/machines-filters";
@import "modules/machines/required-training-modal";
@import "modules/user/avatar";
@import "modules/pricing/machines-pricing";
@import "modules/pricing/editable-price";
@import "modules/pricing/configure-packs-button";
@import "modules/pricing/pack-form";
@import "modules/pricing/delete-pack";
@import "modules/pricing/edit-pack";
@import "modules/prepaid-packs/propose-packs-modal";
@import "modules/prepaid-packs/packs-summary";
@import "app.responsive";

View File

@ -0,0 +1,54 @@
.fab-popover {
& {
position: absolute;
width: 276px;
border: 1px solid rgba(0,0,0,.2);
border-radius: .3rem;
top: 35px;
left: -125px;
z-index: 1;
}
&::before {
content: "";
background-color: transparent;
position: absolute;
left: 132px;
top: -7px;
height: 7px;
width: 12px;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid #ccc;
}
&::after {
content: "";
background-color: transparent;
position: absolute;
left: 133px;
top: -6px;
height: 6px;
width: 10px;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #f0f0f0;
}
.popover-title {
padding: .5rem 1rem;
margin-bottom: 0;
font-size: 1rem;
background-color: #f0f0f0;
border-bottom: 1px solid rgba(0,0,0,.2);
border-top-left-radius: calc(.3rem - 1px);
border-top-right-radius: calc(.3rem - 1px);
& > h3 {
margin: 2px;
}
}
.popover-content {
padding: 1rem 1rem;
color: #212529;
background-color: white;
}
}

View File

@ -0,0 +1,142 @@
.machine-card {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 6px;
margin: 0 15px 30px;
width: 30%;
min-width: 263px;
position: relative;
&.loading::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(29, 29, 29, 0.5);
border-radius: 6px;
margin: -1px;
z-index: 1;
}
&.loading::after {
content: '\f1ce';
font-family: 'Font Awesome 5 Free';
text-align: center;
font-weight: 900;
position: absolute;
width: 100%;
font-size: 4em;
top: 110px;
color: white;
animation: spin 2s linear infinite;
}
@keyframes spin {
100% { transform: rotate(360deg);}
}
@media screen and (max-width: 1219px) {
width: 45%;
min-width: 195px;
margin: 0 auto 30px;
}
@media screen and (max-width: 674px) {
width: 95%;
max-width: 400px;
margin: 0 auto 30px;
}
.machine-picture {
height: 250px;
background-size: cover;
background-position: center;
transition: opacity 0.5s ease;
cursor: pointer;
padding: 0;
color: #333333;
background-color: #f5f5f5;
border-color: #ddd;
border-bottom: 1px solid transparent;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
position: relative;
&.no-picture::before {
position: absolute;
content: '\f03e';
font-family: 'Font Awesome 5 Free' !important;
font-weight: 900;
font-size: 4em;
width: 100%;
text-align: center;
padding-top: 84px;
}
}
.machine-name {
text-align: center;
padding: 15px;
font-size: 1.6rem;
text-transform: uppercase;
font-weight: 600;
color: #000;
margin: 0;
height: 4em;
display: flex;
justify-content: center;
align-items: center;
}
.machine-actions {
padding: 0;
background-color: #fff;
border-top: 1px solid #ddd;
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
text-align: center;
display: flex;
& > span {
width: 50%;
& > button {
border: none !important;
padding: 15px 12px;
display: block;
width: 100%;
background-color: #fbfbfb;
margin-bottom: 0;
font-weight: normal;
text-align: center;
white-space: nowrap;
vertical-align: middle;
touch-action: manipulation;
cursor: pointer;
background-image: none;
font-size: 16px;
line-height: 1.5;
border-radius: 4px;
& > i {
margin-right: 5px;
}
&.reserve-button {
border-right: 1px solid #dddddd !important;
}
}
}
}
&.disabled {
opacity: 0.5;
.machine-actions > span {
width: 100%;
}
}
}

View File

@ -0,0 +1,31 @@
.machines-filters {
margin: 1.5em;
.status-filter {
& {
display: inline-flex;
width: 50%;
}
& > label {
white-space: nowrap;
line-height: 2em;
}
& > * {
display: inline-block;
}
.status-select {
width: 100%;
margin-left: 10px;
}
}
}
@media screen and (max-width: 720px){
.machines-filters {
.status-filter {
padding-right: 0;
display: inline-block;
width: 100%;
}
}
}

View File

@ -0,0 +1,6 @@
.machines-list {
.all-machines {
display: flex;
flex-wrap: wrap;
}
}

View File

@ -0,0 +1,20 @@
.required-training-modal {
.user-info {
text-align: center;
padding: 15px 0;
.avatar img { width: 50px; }
.user-name { margin-left: 10px; }
}
.training-info {
text-align: center;
}
.not-now {
text-align: center;
& > a {
cursor: pointer;
}
}
}

Some files were not shown because too many files have changed in this diff Show More