1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-11-28 09:24:24 +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
- [TODO DEPLOY] `rails db:seed`
## v5.0.7 2021 June 24
- 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!
def index
authorize Price
@prices = PriceService.list(params)
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 { useTranslation } from 'react-i18next';
import { Loader } from '../base/loader';
import { ProposePacksModal } from './propose-packs-modal';
declare var Application: IApplication;
@ -33,6 +34,7 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
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]);
@ -78,6 +80,13 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
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
*/
@ -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
// a prior training, just let him reserve.
// Moreover, if all associated trainings are disabled, let the user reserve too.
// 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 onReserveMachine(machine);
return checkPrepaidPack();
}
// 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);
};
/**
* 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 : ''}>
@ -126,6 +149,12 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
user={user}
machine={machine}
onEnrollRequested={onEnrollRequested} />
{machine && <ProposePacksModal isOpen={proposePacks}
toggleModal={toggleProposePacksModal}
machine={machine}
onError={onError}
customer={currentUser}
onDecline={onReserveMachine} />}
</span>
);

View File

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

View File

@ -24,6 +24,9 @@ export interface Machine {
}>,
current_user_is_trained?: boolean,
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<{
id: number,
name: string,

View File

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

View File

@ -55,6 +55,7 @@
@import "modules/machines/machines-list";
@import "modules/machines/machines-filters";
@import "modules/machines/required-training-modal";
@import "modules/machines/propose-packs-modal";
@import "modules/user/avatar";
@import "modules/pricing/machines-pricing";
@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
has_many :prices, as: :priceable, dependent: :destroy
has_many :prepaid_packs, as: :priceable, dependent: :destroy
has_many :reservations, as: :reservable, dependent: :destroy
has_many :credits, as: :creditable, dependent: :destroy
@ -77,6 +78,13 @@ class Machine < ApplicationRecord
reservations.empty?
end
def packs?(user)
prepaid_packs.where(group_id: user.group_id)
.where(disabled: [false, nil])
.count
.positive?
end
private
def update_gateway_product

View File

@ -8,6 +8,9 @@
# The number of hours in a pack is stored in minutes.
class PrepaidPack < ApplicationRecord
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
has_many :statistic_profile_prepaid_packs

View File

@ -118,7 +118,8 @@ class Setting < ApplicationRecord
payzen_public_key
payzen_hmac
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
def value

View File

@ -22,6 +22,7 @@ class Space < ApplicationRecord
has_many :reservations, as: :reservable, 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_one :payment_gateway_object, as: :item

View File

@ -128,6 +128,12 @@ class User < ApplicationRecord
trainings.map(&:machines).flatten.uniq.include?(machine)
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)
reservations.where(reservable_type: 'Training', reservable_id: machine.trainings.map(&:id))
.includes(:slots)

View File

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

View File

@ -2,13 +2,38 @@
# Provides methods for PrepaidPack
class PrepaidPackService
def self.list(filters)
packs = PrepaidPack.where(nil)
class << self
def list(filters)
packs = PrepaidPack.where(nil)
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_type: filters[:priceable_type]) if filters[:priceable_type].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_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

View File

@ -8,11 +8,16 @@ json.machine_files_attributes @machine.machine_files do |f|
json.attachment_url f.attachment_url
end
json.trainings @machine.trainings.each, :id, :name, :disabled
json.current_user_is_trained current_user.training_machine?(@machine) if current_user
if current_user && !current_user.training_machine?(@machine) && 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)
if current_user
json.current_user_is_trained current_user.training_machine?(@machine)
if !current_user.training_machine?(@machine) && 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
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
json.machine_projects @machine.projects.published.last(10) do |p|

View File

@ -178,6 +178,12 @@ en:
enroll_now: "Enroll to the training"
no_enroll_for_now: "I don't want to enroll now"
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
trainings_reserve:
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('renew_pack_threshold', 0.2) unless Setting.find_by(name: 'renew_pack_threshold').try(:value)
if StatisticCustomAggregation.count.zero?
# available reservations hours for machines
machine_hours = StatisticType.find_by(key: 'hour', statistic_index_id: 2)