1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-19 13:54:25 +01:00

display list of plans grouped by category

This commit is contained in:
Sylvain 2021-06-09 13:03:58 +02:00
parent b83a9e44d6
commit 05882b3743
18 changed files with 339 additions and 125 deletions

View File

@ -3,12 +3,10 @@
# API Controller for resources of type PlanCategory
# PlanCategory are used to sort plans
class API::PlanCategoriesController < API::ApiController
before_action :authenticate_user!
before_action :authenticate_user!, except: :index
before_action :set_category, only: %i[show update destroy]
def index
authorize PlanCategory
@categories = PlanCategory.order(weight: :desc)
end

View File

@ -0,0 +1,11 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Group } from '../models/group';
export default class GroupAPI {
static async index (): Promise<Array<Group>> {
const res: AxiosResponse<Array<Group>> = await apiClient.get('/api/groups');
return res?.data;
}
}

View File

@ -0,0 +1,11 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Plan } from '../models/plan';
export default class PlanAPI {
static async index (): Promise<Array<Plan>> {
const res: AxiosResponse<Array<Plan>> = await apiClient.get('/api/plans');
return res?.data;
}
}

View File

@ -1,16 +1,13 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import moment from 'moment';
import _ from 'lodash'
import { IApplication } from '../models/application';
import { Plan } from '../models/plan';
import { User, UserRole } from '../models/user';
import { Loader } from './base/loader';
import '../lib/i18n';
import { IFablab } from '../models/fablab';
import { IFablab } from '../../models/fablab';
import { Plan } from '../../models/plan';
import { User, UserRole } from '../../models/user';
import { Loader } from '../base/loader';
import '../../lib/i18n';
declare var Application: IApplication;
declare var Fablab: IFablab;
interface PlanCardProps {
@ -26,7 +23,7 @@ interface PlanCardProps {
/**
* This component is a "card" (visually), publicly presenting the details of a plan and allowing a user to subscribe.
*/
const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected, onLoginRequested }) => {
const PlanCardComponent: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected, onLoginRequested }) => {
const { t } = useTranslation('public');
/**
* Return the formatted localized amount of the given plan (eg. 20.5 => "20,50 €")
@ -146,12 +143,10 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, ope
);
}
const PlanCardWrapper: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected, onLoginRequested }) => {
export const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected, onLoginRequested }) => {
return (
<Loader>
<PlanCard plan={plan} userId={userId} subscribedPlanId={subscribedPlanId} operator={operator} isSelected={isSelected} onSelectPlan={onSelectPlan} onLoginRequested={onLoginRequested}/>
<PlanCardComponent plan={plan} userId={userId} subscribedPlanId={subscribedPlanId} operator={operator} isSelected={isSelected} onSelectPlan={onSelectPlan} onLoginRequested={onLoginRequested}/>
</Loader>
);
}
Application.Components.component('planCard', react2angular(PlanCardWrapper, ['plan', 'userId', 'subscribedPlanId', 'operator', 'onSelectPlan', 'isSelected', 'onLoginRequested']));

View File

@ -0,0 +1,213 @@
import React, { ReactNode, useEffect, useState } from 'react';
import _ from 'lodash';
import PlanAPI from '../../api/plan';
import { Plan } from '../../models/plan';
import { PlanCategory } from '../../models/plan-category';
import PlanCategoryAPI from '../../api/plan-category';
import { User } from '../../models/user';
import { Group } from '../../models/group';
import GroupAPI from '../../api/group';
import { PlanCard } from './plan-card';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
import { useTranslation } from 'react-i18next';
declare var Application: IApplication;
interface PlansListProps {
onError: (message: string) => void,
onPlanSelection: (plan: Plan) => void,
onLoginRequest: () => void,
operator?: User,
customer?: User,
}
// A list of plans, organized by group ID - then organized by plan-category ID (or NaN if the plan has no category)
type PlansTree = Map<number, Map<number, Array<Plan>>>;
/**
* This component display an organized list of plans to allow the end-user to select one and subscribe online
*/
const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLoginRequest, operator, customer }) => {
const { t } = useTranslation('public');
// all plans
const [plans, setPlans] = useState<PlansTree>(null);
// all plan-categories, ordered by weight
const [planCategories, setPlanCategories] = useState<Array<PlanCategory>>(null);
// all groups
const [groups, setGroups] = useState<Array<Group>>(null);
// currently selected plan
const [selectedPlan, setSelectedPlan] = useState<Plan>(null);
// fetch data on component mounted
useEffect(() => {
PlanCategoryAPI.index()
.then(data => setPlanCategories(data))
.catch(error => onError(error));
GroupAPI.index()
.then(groupsData => {
setGroups(groupsData);
PlanAPI.index()
.then(data => setPlans(sortPlans(data, groupsData)))
.catch(error => onError(error));
})
.catch(error => onError(error))
}, []);
/**
* Group a flat array of plans and return a collection of the same plans, grouped by the given property
*/
const groupBy = (plans: Array<Plan>, criteria: string): Map<number, Array<Plan>> => {
const grouped = _.groupBy(plans, criteria);
const map = new Map<number, Array<Plan>>();
for (const criteriaId in grouped) {
if (Object.prototype.hasOwnProperty.call(grouped, criteriaId)) {
const enabled = grouped[criteriaId].filter(plan => !plan.disabled);
// null ids will be converted to NaN
map.set(Number(criteriaId), enabled);
}
}
return map;
};
/**
* Sort the plans, by group and by category and return the corresponding map
*/
const sortPlans = (plans: Array<Plan>, groups: Array<Group>): PlansTree => {
const byGroup = groupBy(plans, 'group_id');
const res = new Map<number, Map<number, Array<Plan>>>();
for (const [groupId, plansByGroup] of byGroup) {
const group = groups.find(g => g.id === groupId);
if (!group.disabled) {
res.set(groupId, groupBy(plansByGroup, 'plan_category_id'));
}
}
return res;
}
/**
* Filter the plans to display, depending on the connected/selected user
*/
const filteredPlans = (): PlansTree => {
if (!customer) return plans;
return new Map([[customer.group_id, plans.get(customer.group_id)]]);
}
/**
* When called with a group ID, returns the name of the requested group
*/
const groupName = (groupId: number): string => {
return groups.find(g => g.id === groupId)?.name;
}
/**
* When called with a category ID, returns the name of the requested plan-category
*/
const categoryName = (categoryId: number): string => {
return planCategories.find(c => c.id === categoryId)?.name;
}
/**
* Check if the currently selected plan matched the provided one
*/
const isSelectedPlan = (plan: Plan): boolean => {
return (plan === selectedPlan);
}
/**
* Callback for sorting plans by weight
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
*/
const comparePlans = (plan1: Plan, plan2: Plan): number => {
return (plan2.ui_weight - plan1.ui_weight);
}
/**
* Callback for sorting categories by weight
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
*/
const compareCategories = (category1: [number, Array<Plan>], category2: [number, Array<Plan>]): number => {
if (isNaN(category1[0])) return -1;
if (isNaN(category2[0])) return 1;
const categoryObject1 = planCategories.find(c => c.id === category1[0]);
const categoryObject2 = planCategories.find(c => c.id === category2[0]);
return (categoryObject2.weight - categoryObject1.weight);
}
/**
* Callback triggered when the user chooses a plan to subscribe
*/
const handlePlanSelection = (plan: Plan): void => {
setSelectedPlan(plan);
onPlanSelection(plan);
}
/**
* Render the provided list of categories, with each associated plans
*/
const renderPlansByCategory = (plans: Map<number, Array<Plan>>): ReactNode => {
return (
<div className="list-of-categories">
{Array.from(plans).sort(compareCategories).map(([categoryId, plansByCategory]) => {
return (
<div key={categoryId} className={`plans-per-category ${categoryId ? 'with-category' : 'no-category' }`}>
{!!categoryId && <h3 className="category-title">{ categoryName(categoryId) }</h3>}
{renderPlans(plansByCategory)}
</div>
)
})}
</div>
);
}
/**
* Render the provided list of plans, ordered by ui_weight.
*/
const renderPlans = (categoryPlans: Array<Plan>): ReactNode => {
return (
<div className="list-of-plans">
{categoryPlans.length === 0 && <span className="no-plans">
{t('app.public.plans.no_plans')}
</span>}
{categoryPlans.sort(comparePlans).map(plan => (
<PlanCard key={plan.id}
plan={plan}
operator={operator}
isSelected={isSelectedPlan(plan)}
onSelectPlan={handlePlanSelection}
onLoginRequested={onLoginRequest} />
))}
</div>
);
}
return (
<div className="plans-list">
{plans && Array.from(filteredPlans()).map(([groupId, plansByGroup]) => {
return (
<div key={groupId} className="plans-per-group">
<h2 className="group-title">{ groupName(groupId) }</h2>
{renderPlansByCategory(plansByGroup)}
</div>
)
})}
</div>
);
}
const PlansListWrapper: React.FC<PlansListProps> = ({ customer, onError, onPlanSelection, onLoginRequest, operator }) => {
return (
<Loader>
<PlansList customer={customer} onError={onError} onPlanSelection={onPlanSelection} onLoginRequest={onLoginRequest} operator={operator} />
</Loader>
);
}
Application.Components.component('plansList', react2angular(PlansListWrapper, ['customer', 'onError', 'onPlanSelection', 'onLoginRequest', 'operator']));

View File

@ -12,8 +12,8 @@
*/
'use strict';
Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScope', '$state', '$uibModal', 'Auth', 'AuthService', 'dialogs', 'growl', 'plansPromise', 'groupsPromise', 'Subscription', 'Member', 'subscriptionExplicationsPromise', '_t', 'Wallet', 'helpers', 'settingsPromise', 'Price',
function ($scope, $rootScope, $state, $uibModal, Auth, AuthService, dialogs, growl, plansPromise, groupsPromise, Subscription, Member, subscriptionExplicationsPromise, _t, Wallet, helpers, settingsPromise, Price) {
Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScope', '$state', '$uibModal', 'Auth', 'AuthService', 'dialogs', 'growl', 'groupsPromise', 'Subscription', 'Member', 'subscriptionExplicationsPromise', '_t', 'Wallet', 'helpers', 'settingsPromise', 'Price',
function ($scope, $rootScope, $state, $uibModal, Auth, AuthService, dialogs, growl, groupsPromise, Subscription, Member, subscriptionExplicationsPromise, _t, Wallet, helpers, settingsPromise, Price) {
/* PUBLIC SCOPE */
// list of groups
@ -26,9 +26,6 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
id: null
};
// list of plans, classified by group
$scope.plansClassifiedByGroup = [];
// user to deal with
$scope.ctrl = {
member: null,
@ -93,22 +90,19 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
* Open the modal dialog allowing the user to log into the system
*/
$scope.userLogin = function () {
console.log('userLogin');
setTimeout(() => {
console.log('going throught timeout');
if (!$scope.isAuthenticated()) {
console.log('! authenticated');
$scope.login();
}
}, 50);
};
/**
* Check if the provided plan is currently selected
* @param plan {Object} Resource plan
* Callback triggered when an error is raised on a lower-level component
* @param message {string}
*/
$scope.isSelected = function (plan) {
return $scope.selectedPlan === plan;
$scope.onError = function (message) {
growl.error(message);
};
/**
@ -195,18 +189,6 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {
// group all plans by Group
for (const group of $scope.groups) {
const groupObj = { id: group.id, name: group.name, plans: [], actives: 0 };
for (const plan of plansPromise) {
if (plan.group_id === group.id) {
groupObj.plans.push(plan);
if (!plan.disabled) { groupObj.actives++; }
}
}
$scope.plansClassifiedByGroup.push(groupObj);
}
if ($scope.currentUser) {
if (!AuthService.isAuthorized('admin')) {
$scope.ctrl.member = $scope.currentUser;

View File

@ -0,0 +1,7 @@
export interface Group {
id: number,
slug: string,
name: string,
disabled: boolean,
users: number
}

View File

@ -24,6 +24,7 @@ export interface Plan {
interval: Interval,
interval_count: number,
group_id: number,
plan_category_id: number,
training_credit_nb: number,
is_rolling: boolean,
description: string,

View File

@ -532,7 +532,6 @@ angular.module('application.router', ['ui.router'])
},
resolve: {
subscriptionExplicationsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'subscription_explications_alert' }).$promise; }],
plansPromise: ['Plan', function (Plan) { return Plan.query().$promise; }],
groupsPromise: ['Group', function (Group) { return Group.query().$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['online_payment_module', 'payment_gateway']" }).$promise; }]
}

View File

@ -267,56 +267,6 @@
}
}
.list-of-plans {
.active-group ~ .active-group .group-title {
/* select all active groups but the first (the first have no margin at the top) */
margin: 3em auto 1em;
}
.group-title {
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 2em;
width: 83.33%;
box-sizing: border-box;
margin: 0 auto 1em;
$border: 5px;
background: #FFF;
background-clip: padding-box;
border: solid $border transparent;
&:before {
content: '';
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
z-index: -1;
margin: -$border;
border-radius: inherit;
}
}
.plans-per-group {
& {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
border: 1px solid transparent;
}
& > * {
width: 50%;
}
@media screen and (max-width: 992px) {
& > * {
width: 100%;
}
}
}
}
.well {
&.well-warning {
@include border-radius(3px, 3px, 3px, 3px);

View File

@ -34,6 +34,7 @@
@import "modules/payment-schedules-list";
@import "modules/stripe-confirm";
@import "modules/payment-schedule-dashboard";
@import "modules/plans-list";
@import "modules/plan-card";
@import "modules/event-themes";
@import "modules/select-gateway-modal";

View File

@ -0,0 +1,60 @@
.plans-list {
.plans-per-group ~ .plans-per-group .group-title {
/* select all groups but the first (the first have no margin at the top) */
margin: 5em auto 1em;
}
.group-title {
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 2em;
width: 83.33%;
box-sizing: border-box;
margin: 1em auto 1em;
$border: 5px;
background: #FFF;
background-clip: padding-box;
border: solid $border transparent;
&:before {
content: '';
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
z-index: -1;
margin: -$border;
border-radius: inherit;
}
}
.plans-per-category {
text-align: center;
margin-top: 3em;
.category-title {
width: 50%;
margin: 2em auto;
line-height: 3em;
}
}
.list-of-plans {
& {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
border: 1px solid transparent;
}
& > * {
width: 50%;
}
@media screen and (max-width: 992px) {
& > * {
width: 100%;
}
}
}
}

View File

@ -49,7 +49,7 @@
</div>
<div class="form-group" ng-class="{'has-error': planForm['plan[plan_category_id]'].$dirty && planForm['plan[group_id]'].$invalid}">
<label for="plan[plan_category_id]">{{ 'app.shared.plan.category' | translate }} *</label>
<label for="plan[plan_category_id]">{{ 'app.shared.plan.category' | translate }}</label>
<select id="plan[plan_category_id]"
class="form-control"
ng-model="plan.plan_category_id"

View File

@ -17,38 +17,18 @@
<div class="row no-gutter list-of-plans">
<div class="col-sm-12 col-md-9 b-r">
<div class="row m-t m-b padder" ng-repeat="plansGroup in plansClassifiedByGroup | groupFilter:ctrl.member" ng-class="{'active-group': plansGroup.actives > 0}">
<div ng-show="plansGroup.actives > 0">
<h2 class="text-u-c group-title">{{plansGroup.name}}</h2>
<div class="row row-centered padder">
<div class="plans-per-group">
<plan-card ng-repeat="(key, plan) in plansGroup.plans.filter(filterDisabledPlans) | orderBy: '-ui_weight'"
plan="plan"
user-id="ctrl.member.id"
subscribed-plan-id="ctrl.member.subscribed_plan.id"
operator="currentUser"
on-select-plan="selectPlan"
on-login-requested="userLogin"
is-selected="isSelected(plan)">
</plan-card>
</div>
<div class="col-xs-12 col-md-12 col-lg-10 col-centered no-gutter" ng-if="ctrl.member.subscription && isInPast(ctrl.member.subscription.expired_at)">
<uib-alert type="info">
{{ 'app.public.plans.your_subscription_expires_on_the_DATE' | translate:{DATE:(ctrl.member.subscription.expired_at | amDateFormat:'L' )} }}
</uib-alert>
</div>
</div>
</div>
<div ng-show="plansGroup.actives === 0 && ctrl.member" class="m-lg" translate>
{{ 'app.public.plans.no_plans' }}
</div>
<div class="col-xs-12 col-md-12 col-lg-10 col-centered no-gutter" ng-if="ctrl.member.subscription && isInPast(ctrl.member.subscription.expired_at)">
<uib-alert type="info">
{{ 'app.public.plans.your_subscription_expires_on_the_DATE' | translate:{DATE:(ctrl.member.subscription.expired_at | amDateFormat:'L' )} }}
</uib-alert>
</div>
<plans-list customer="ctrl.member"
on-error="onError"
on-plan-selection="selectPlan"
on-login-request="userLogin"
operator="currentUser" />
</div>
<div class="col-sm-12 col-md-12 col-lg-3">

View File

@ -2,7 +2,7 @@
# Check the access policies for API::PlanCategoriesController
class PlanCategoryPolicy < ApplicationPolicy
%w[index show create update destroy].each do |action|
%w[show create update destroy].each do |action|
define_method "#{action}?" do
user.admin?
end

View File

@ -306,10 +306,14 @@ section#cookies-modal div.cookies-consent .cookies-actions button.accept {
color: $secondary-text-color;
}
.list-of-plans {
.plans-list {
.group-title:before {
background: linear-gradient(to left, white, $primary, white);
}
.category-title {
background-color: $primary;
color: $primary-text-color !important;
}
}
.plan-card {

View File

@ -1,2 +1,4 @@
# frozen_string_literal: true
json.extract! group, :id, :slug, :name, :disabled
json.users group.users.count

View File

@ -2,7 +2,7 @@
json.array!(@plans) do |plan|
json.extract! plan, :id, :base_name, :name, :interval, :interval_count, :group_id, :training_credit_nb, :description, :type, :ui_weight,
:slug, :disabled, :monthly_payment
:slug, :disabled, :monthly_payment, :plan_category_id
json.amount plan.amount / 100.00
json.plan_file_url plan.plan_file.attachment_url if plan.plan_file
end