1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-20 14:54:15 +01:00

WIP: buy pack modal

This commit is contained in:
Sylvain 2021-06-25 17:24:34 +02:00
parent 99bd00949e
commit f16cbc44ff
19 changed files with 252 additions and 26 deletions

View File

@ -1,5 +1,7 @@
# Changelog Fab-manager # Changelog Fab-manager
- [TODO DEPLOY] `rails db:seed`
## v5.0.7 2021 June 24 ## v5.0.7 2021 June 24
- Fix a bug: unable to export members list if no subscriptions was taken - Fix a bug: unable to export members list if no subscriptions was taken

View File

@ -6,8 +6,6 @@ class API::PricesController < API::ApiController
before_action :authenticate_user! before_action :authenticate_user!
def index def index
authorize Price
@prices = PriceService.list(params) @prices = PriceService.list(params)
end end

View File

@ -0,0 +1,113 @@
import React, { BaseSyntheticEvent, 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 { IFablab } from '../../models/fablab';
import { FabButton } from '../base/fab-button';
import PriceAPI from '../../api/price';
import { Price } from '../../models/price';
declare var Fablab: IFablab;
interface ProposePacksModalProps {
isOpen: boolean,
toggleModal: () => void,
machine: Machine,
customer: User,
onError: (message: string) => void,
onDecline: (machine: Machine) => void,
}
/**
* Modal dialog shown to offer prepaid-packs for purchase, to the current user.
*/
export const ProposePacksModal: React.FC<ProposePacksModalProps> = ({ isOpen, toggleModal, machine, customer, onError, onDecline }) => {
const { t } = useTranslation('logged');
const [price, setPrice] = useState<Price>(null);
const [packs, setPacks] = useState<Array<PrepaidPack>>(null);
useEffect(() => {
PrepaidPackAPI.index({ priceable_id: machine.id, priceable_type: 'Machine', group_id: customer.group_id, disabled: false })
.then(data => setPacks(data))
.catch(error => onError(error));
PriceAPI.index({ priceable_id: machine.id, priceable_type: 'Machine', group_id: customer.group_id, plan_id: null })
.then(data => setPrice(data[0]))
.catch(error => onError(error));
}, [machine]);
/**
* 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);
}
/**
* 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 });
}
/**
* The user has declined to buy a pack
*/
const handlePacksRefused = (): void => {
onDecline(machine);
}
/**
* The user has accepted to buy the provided pack, process with teh payment
*/
const handleBuyPack = (pack: PrepaidPack) => {
return (event: BaseSyntheticEvent): void => {
console.log(pack);
}
}
/**
* 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">{formatPrice(pack.amount)}</span>
{pack.amount < normalPrice && <span className="crossed-out-price">{formatPrice(normalPrice)}</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>
</FabModal>
);
}

View File

@ -8,6 +8,7 @@ import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application'; import { IApplication } from '../../models/application';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Loader } from '../base/loader'; import { Loader } from '../base/loader';
import { ProposePacksModal } from './propose-packs-modal';
declare var Application: IApplication; declare var Application: IApplication;
@ -33,6 +34,7 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
const [user, setUser] = useState<User>(currentUser); const [user, setUser] = useState<User>(currentUser);
const [pendingTraining, setPendingTraining] = useState<boolean>(false); const [pendingTraining, setPendingTraining] = useState<boolean>(false);
const [trainingRequired, setTrainingRequired] = useState<boolean>(false); const [trainingRequired, setTrainingRequired] = useState<boolean>(false);
const [proposePacks, setProposePacks] = useState<boolean>(false);
// handle login from an external process // handle login from an external process
useEffect(() => setUser(currentUser), [currentUser]); useEffect(() => setUser(currentUser), [currentUser]);
@ -78,6 +80,13 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
setTrainingRequired(!trainingRequired); setTrainingRequired(!trainingRequired);
}; };
/**
* Open/closes the modal dialog inviting the user to buy a prepaid-pack
*/
const toggleProposePacksModal = (): void => {
setProposePacks(!proposePacks);
}
/** /**
* Check that the current user has passed the required training before allowing him to book * Check that the current user has passed the required training before allowing him to book
*/ */
@ -94,11 +103,11 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
} }
// if the currently logged user has completed the training for this machine, or this machine does not require // if the currently logged user has completed the training for this machine, or this machine does not require
// a prior training, just let him reserve. // a prior training, move forward to the prepaid-packs verification.
// Moreover, if all associated trainings are disabled, let the user reserve too. // Moreover, if there's no enabled associated trainings, also move to the next step.
if (machine.current_user_is_trained || machine.trainings.length === 0 || if (machine.current_user_is_trained || machine.trainings.length === 0 ||
machine.trainings.map(t => t.disabled).reduce((acc, val) => acc && val, true)) { machine.trainings.map(t => t.disabled).reduce((acc, val) => acc && val, true)) {
return onReserveMachine(machine); return checkPrepaidPack();
} }
// if the currently logged user booked a training for this machine, tell him that he must wait // if the currently logged user booked a training for this machine, tell him that he must wait
@ -112,6 +121,20 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
setTrainingRequired(true); 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 ( return (
<span> <span>
<button onClick={handleClick} className={className ? className : ''}> <button onClick={handleClick} className={className ? className : ''}>
@ -126,6 +149,12 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
user={user} user={user}
machine={machine} machine={machine}
onEnrollRequested={onEnrollRequested} /> onEnrollRequested={onEnrollRequested} />
{machine && <ProposePacksModal isOpen={proposePacks}
toggleModal={toggleProposePacksModal}
machine={machine}
onError={onError}
customer={currentUser}
onDecline={onReserveMachine} />}
</span> </span>
); );

View File

@ -5,7 +5,6 @@ import { FabPopover } from '../base/fab-popover';
import { CreatePack } from './create-pack'; import { CreatePack } from './create-pack';
import PrepaidPackAPI from '../../api/prepaid-pack'; import PrepaidPackAPI from '../../api/prepaid-pack';
import { IFablab } from '../../models/fablab'; import { IFablab } from '../../models/fablab';
import { FabButton } from '../base/fab-button';
import { DeletePack } from './delete-pack'; import { DeletePack } from './delete-pack';
import { EditPack } from './edit-pack'; import { EditPack } from './edit-pack';

View File

@ -24,6 +24,9 @@ export interface Machine {
}>, }>,
current_user_is_trained?: boolean, current_user_is_trained?: boolean,
current_user_next_training_reservation?: Reservation, current_user_next_training_reservation?: Reservation,
current_user_has_packs?: boolean,
current_user_available_for_packs_renewal?: boolean,
has_prepaid_packs_for_current_user?: boolean,
machine_projects?: Array<{ machine_projects?: Array<{
id: number, id: number,
name: string, name: string,

View File

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

View File

@ -55,6 +55,7 @@
@import "modules/machines/machines-list"; @import "modules/machines/machines-list";
@import "modules/machines/machines-filters"; @import "modules/machines/machines-filters";
@import "modules/machines/required-training-modal"; @import "modules/machines/required-training-modal";
@import "modules/machines/propose-packs-modal";
@import "modules/user/avatar"; @import "modules/user/avatar";
@import "modules/pricing/machines-pricing"; @import "modules/pricing/machines-pricing";
@import "modules/pricing/editable-price"; @import "modules/pricing/editable-price";

View File

@ -0,0 +1,24 @@
.propose-packs-modal {
.list-of-packs {
display: flex;
.pack {
&::before {
content: '\f466';
font-family: 'Font Awesome 5 Free';
font-weight: 800;
font-size: 12px;
vertical-align: middle;
line-height: 38px;
}
.crossed-out-price {
text-decoration: line-through;
}
.buy-button {
margin-left: 10px;
margin-bottom: 5px;
}
}
}
}

View File

@ -21,6 +21,7 @@ class Machine < ApplicationRecord
validates :description, presence: true validates :description, presence: true
has_many :prices, as: :priceable, dependent: :destroy has_many :prices, as: :priceable, dependent: :destroy
has_many :prepaid_packs, as: :priceable, dependent: :destroy
has_many :reservations, as: :reservable, dependent: :destroy has_many :reservations, as: :reservable, dependent: :destroy
has_many :credits, as: :creditable, dependent: :destroy has_many :credits, as: :creditable, dependent: :destroy
@ -77,6 +78,13 @@ class Machine < ApplicationRecord
reservations.empty? reservations.empty?
end end
def packs?(user)
prepaid_packs.where(group_id: user.group_id)
.where(disabled: [false, nil])
.count
.positive?
end
private private
def update_gateway_product def update_gateway_product

View File

@ -8,6 +8,9 @@
# The number of hours in a pack is stored in minutes. # The number of hours in a pack is stored in minutes.
class PrepaidPack < ApplicationRecord class PrepaidPack < ApplicationRecord
belongs_to :priceable, polymorphic: true belongs_to :priceable, polymorphic: true
belongs_to :machine, foreign_type: 'Machine', foreign_key: 'priceable_id'
belongs_to :space, foreign_type: 'Space', foreign_key: 'priceable_id'
belongs_to :group belongs_to :group
has_many :statistic_profile_prepaid_packs has_many :statistic_profile_prepaid_packs

View File

@ -118,7 +118,8 @@ class Setting < ApplicationRecord
payzen_public_key payzen_public_key
payzen_hmac payzen_hmac
payzen_currency payzen_currency
public_agenda_module] } public_agenda_module
renew_pack_threshold] }
# WARNING: when adding a new key, you may also want to add it in app/policies/setting_policy.rb#public_whitelist # WARNING: when adding a new key, you may also want to add it in app/policies/setting_policy.rb#public_whitelist
def value def value

View File

@ -22,6 +22,7 @@ class Space < ApplicationRecord
has_many :reservations, as: :reservable, dependent: :destroy has_many :reservations, as: :reservable, dependent: :destroy
has_many :prices, as: :priceable, dependent: :destroy has_many :prices, as: :priceable, dependent: :destroy
has_many :prepaid_packs, as: :priceable, dependent: :destroy
has_many :credits, as: :creditable, dependent: :destroy has_many :credits, as: :creditable, dependent: :destroy
has_one :payment_gateway_object, as: :item has_one :payment_gateway_object, as: :item

View File

@ -128,6 +128,12 @@ class User < ApplicationRecord
trainings.map(&:machines).flatten.uniq.include?(machine) trainings.map(&:machines).flatten.uniq.include?(machine)
end end
def packs?(item, threshold = Setting.get('renew_pack_threshold'))
return true if admin?
PrepaidPackService.user_packs(self, item, threshold).count.positive?
end
def next_training_reservation_by_machine(machine) def next_training_reservation_by_machine(machine)
reservations.where(reservable_type: 'Training', reservable_id: machine.trainings.map(&:id)) reservations.where(reservable_type: 'Training', reservable_id: machine.trainings.map(&:id))
.includes(:slots) .includes(:slots)

View File

@ -1,8 +1,7 @@
class PricePolicy < ApplicationPolicy # frozen_string_literal: true
def index?
user.admin?
end
# Check the access policies for API::PricesController
class PricePolicy < ApplicationPolicy
def update? def update?
user.admin? user.admin?
end end

View File

@ -2,13 +2,38 @@
# Provides methods for PrepaidPack # Provides methods for PrepaidPack
class PrepaidPackService class PrepaidPackService
def self.list(filters) class << self
packs = PrepaidPack.where(nil) def list(filters)
packs = PrepaidPack.where(nil)
packs = packs.where(group_id: filters[:group_id]) if filters[:group_id].present? packs = packs.where(group_id: filters[:group_id]) if filters[:group_id].present?
packs = packs.where(priceable_id: filters[:priceable_id]) if filters[:priceable_id].present? packs = packs.where(priceable_id: filters[:priceable_id]) if filters[:priceable_id].present?
packs = packs.where(priceable_type: filters[:priceable_type]) if filters[:priceable_type].present? packs = packs.where(priceable_type: filters[:priceable_type]) if filters[:priceable_type].present?
packs if filters[:disabled].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
packs = packs.where(disabled: state)
end
packs
end
def user_packs(user, priceable, threshold)
query = StatisticProfilePrepaidPack
.includes(:prepaid_pack)
.references(:prepaid_packs)
.where('statistic_profile_id = ?', user.statistic_profile.id)
.where('expires_at < ?', DateTime.current)
.where('prepaid_packs.priceable_id = ?', priceable.id)
.where('prepaid_packs.priceable_type = ?', priceable.class.name)
if threshold.class == Float
query = query.where('prepaid_packs.minutes - minutes_used >= prepaid_packs.minutes * ?', threshold)
elsif threshold.class == Integer
query = query.where('prepaid_packs.minutes - minutes_used >= ?', threshold)
end
query
end
end end
end end

View File

@ -8,11 +8,16 @@ json.machine_files_attributes @machine.machine_files do |f|
json.attachment_url f.attachment_url json.attachment_url f.attachment_url
end end
json.trainings @machine.trainings.each, :id, :name, :disabled json.trainings @machine.trainings.each, :id, :name, :disabled
json.current_user_is_trained current_user.training_machine?(@machine) if current_user if current_user
if current_user && !current_user.training_machine?(@machine) && current_user.next_training_reservation_by_machine(@machine) json.current_user_is_trained current_user.training_machine?(@machine)
json.current_user_next_training_reservation do if !current_user.training_machine?(@machine) && current_user.next_training_reservation_by_machine(@machine)
json.partial! 'api/reservations/reservation', reservation: current_user.next_training_reservation_by_machine(@machine) json.current_user_next_training_reservation do
json.partial! 'api/reservations/reservation', reservation: current_user.next_training_reservation_by_machine(@machine)
end
end end
json.current_user_has_packs current_user.packs?(@machine, nil)
json.current_user_available_for_packs_renewal current_user.packs?(@machine)
json.has_prepaid_packs_for_current_user @machine.packs?(current_user)
end end
json.machine_projects @machine.projects.published.last(10) do |p| json.machine_projects @machine.projects.published.last(10) do |p|

View File

@ -178,6 +178,12 @@ en:
enroll_now: "Enroll to the training" enroll_now: "Enroll to the training"
no_enroll_for_now: "I don't want to enroll now" no_enroll_for_now: "I don't want to enroll now"
close: "Close" close: "Close"
propose_packs_modal:
available_packs: "Prepaid packs available"
packs_proposed: "You can buy a prepaid pack of hours for this machine. These packs allows you to benefit from volume discounts."
no_thanks: "No, thanks"
pack_DURATION: "{DURATION} hours"
buy_this_pack: "Buy this pack"
#book a training #book a training
trainings_reserve: trainings_reserve:
trainings_planning: "Trainings planning" trainings_planning: "Trainings planning"

View File

@ -897,6 +897,8 @@ Setting.set('trainings_module', true) unless Setting.find_by(name: 'trainings_mo
Setting.set('public_agenda_module', true) unless Setting.find_by(name: 'public_agenda_module').try(:value) Setting.set('public_agenda_module', true) unless Setting.find_by(name: 'public_agenda_module').try(:value)
Setting.set('renew_pack_threshold', 0.2) unless Setting.find_by(name: 'renew_pack_threshold').try(:value)
if StatisticCustomAggregation.count.zero? if StatisticCustomAggregation.count.zero?
# available reservations hours for machines # available reservations hours for machines
machine_hours = StatisticType.find_by(key: 'hour', statistic_index_id: 2) machine_hours = StatisticType.find_by(key: 'hour', statistic_index_id: 2)