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:
parent
99bd00949e
commit
f16cbc44ff
@ -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
|
||||
|
@ -6,8 +6,6 @@ class API::PricesController < API::ApiController
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
authorize Price
|
||||
|
||||
@prices = PriceService.list(params)
|
||||
end
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
||||
);
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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|
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user