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

Merge branch 'product_store-store' into product_store-inte

This commit is contained in:
vincent 2022-08-22 10:00:09 +02:00
commit 857261ba62
52 changed files with 1303 additions and 17 deletions

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
# API Controller for manage user's cart
class API::CartController < API::ApiController
include API::OrderConcern
before_action :current_order, except: %i[create]
before_action :ensure_order, except: %i[create]
def create
authorize :cart, :create?
@order = Order.find_by(token: order_token)
@order = Order.find_by(statistic_profile_id: current_user.statistic_profile.id, state: 'cart') if @order.nil? && current_user&.member?
if @order
@order.update(statistic_profile_id: current_user.statistic_profile.id) if @order.statistic_profile_id.nil? && current_user&.member?
@order.update(operator_id: current_user.id) if @order.operator_id.nil? && current_user&.privileged?
end
@order ||= Cart::CreateService.new.call(current_user)
render 'api/orders/show'
end
def add_item
authorize @current_order, policy_class: CartPolicy
@order = Cart::AddItemService.new.call(@current_order, orderable, cart_params[:quantity])
render 'api/orders/show'
end
def remove_item
authorize @current_order, policy_class: CartPolicy
@order = Cart::RemoveItemService.new.call(@current_order, orderable)
render 'api/orders/show'
end
def set_quantity
authorize @current_order, policy_class: CartPolicy
@order = Cart::SetQuantityService.new.call(@current_order, orderable, cart_params[:quantity])
render 'api/orders/show'
end
private
def orderable
Product.find(cart_params[:orderable_id])
end
def cart_params
params.permit(:order_token, :orderable_id, :quantity)
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
# API Controller for cart checkout
class API::CheckoutController < API::ApiController
include ::API::OrderConcern
end

View File

@ -4,13 +4,15 @@
# ProductCategorys are used to group products
class API::ProductCategoriesController < API::ApiController
before_action :authenticate_user!, except: :index
before_action :set_product_category, only: %i[show update destroy position]
before_action :set_product_category, only: %i[update destroy position]
def index
@product_categories = ProductCategoryService.list
end
def show; end
def show
@product_category = ProductCategory.friendly.find(params[:id])
end
def create
authorize ProductCategory

View File

@ -4,13 +4,15 @@
# Products are used in store
class API::ProductsController < API::ApiController
before_action :authenticate_user!, except: %i[index show]
before_action :set_product, only: %i[show update destroy]
before_action :set_product, only: %i[update destroy]
def index
@products = ProductService.list
@products = ProductService.list(params)
end
def show; end
def show
@product = Product.includes(:product_images, :product_files).friendly.find(params[:id])
end
def create
authorize Product

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
# Concern for CartController and CheckoutController
module API::OrderConcern
private
def order_token
request.headers['X-Fablab-Order-Token'] || cart_params[:order_token]
end
def current_order
@current_order = Order.find_by(token: order_token)
end
def ensure_order
raise ActiveRecord::RecordNotFound if @current_order.nil?
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when the product is out of stock
class Cart::InactiveProductError < StandardError
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when the product is out of stock
class Cart::OutStockError < StandardError
end

View File

@ -0,0 +1,25 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Order } from '../models/order';
export default class CartAPI {
static async create (token?: string): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.post('/api/cart', { order_token: token });
return res?.data;
}
static async addItem (order: Order, orderableId: number, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/add_item', { order_token: order.token, orderable_id: orderableId, quantity });
return res?.data;
}
static async removeItem (order: Order, orderableId: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/remove_item', { order_token: order.token, orderable_id: orderableId });
return res?.data;
}
static async setQuantity (order: Order, orderableId: number, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_quantity', { order_token: order.token, orderable_id: orderableId, quantity });
return res?.data;
}
}

View File

@ -1,15 +1,16 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import { Product } from '../models/product';
import { Product, ProductIndexFilter } from '../models/product';
import ApiLib from '../lib/api';
export default class ProductAPI {
static async index (): Promise<Array<Product>> {
const res: AxiosResponse<Array<Product>> = await apiClient.get('/api/products');
static async index (filters?: ProductIndexFilter): Promise<Array<Product>> {
const res: AxiosResponse<Array<Product>> = await apiClient.get(`/api/products${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number): Promise<Product> {
static async get (id: number | string): Promise<Product> {
const res: AxiosResponse<Product> = await apiClient.get(`/api/products/${id}`);
return res?.data;
}

View File

@ -0,0 +1,48 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { Order } from '../../models/order';
import { useCustomEventListener } from 'react-custom-events';
declare const Application: IApplication;
/**
* This component shows my cart button
*/
const CartButton: React.FC = () => {
const { t } = useTranslation('public');
const [cart, setCart] = useState<Order>();
useCustomEventListener<Order>('CartUpdate', (data) => {
setCart(data);
});
/**
* Goto cart page
*/
const showCart = () => {
window.location.href = '/#!/cart';
};
if (cart) {
return (
<div className="cart-button" onClick={showCart}>
<i className="fas fa-cart-arrow-down" />
<span>{cart.order_items_attributes.length}</span>
<div>{t('app.public.cart_button.my_cart')}</div>
</div>
);
}
return null;
};
const CartButtonWrapper: React.FC = () => {
return (
<Loader>
<CartButton />
</Loader>
);
};
Application.Components.component('cartButton', react2angular(CartButtonWrapper));

View File

@ -0,0 +1,98 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { FabButton } from '../base/fab-button';
import useCart from '../../hooks/use-cart';
import FormatLib from '../../lib/format';
import CartAPI from '../../api/cart';
import { User } from '../../models/user';
declare const Application: IApplication;
interface StoreCartProps {
onError: (message: string) => void,
currentUser: User,
}
/**
* This component shows user's cart
*/
const StoreCart: React.FC<StoreCartProps> = ({ onError, currentUser }) => {
const { t } = useTranslation('public');
const { cart, setCart, reloadCart } = useCart();
useEffect(() => {
if (currentUser) {
reloadCart();
}
}, [currentUser]);
/**
* Remove the product from cart
*/
const removeProductFromCart = (item) => {
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
CartAPI.removeItem(cart, item.orderable_id).then(data => {
setCart(data);
});
};
};
/**
* Change product quantity
*/
const changeProductQuantity = (item) => {
return (e: React.BaseSyntheticEvent) => {
CartAPI.setQuantity(cart, item.orderable_id, e.target.value).then(data => {
setCart(data);
});
};
};
/**
* Checkout cart
*/
const checkout = () => {
console.log('checkout .....');
};
return (
<div className="store-cart">
{cart && cart.order_items_attributes.map(item => (
<div key={item.id}>
<div>{item.orderable_name}</div>
<div>{FormatLib.price(item.amount)}</div>
<div>{item.quantity}</div>
<select value={item.quantity} onChange={changeProductQuantity(item)}>
{Array.from({ length: 100 }, (_, i) => i + 1).map(v => (
<option key={v} value={v}>{v}</option>
))}
</select>
<div>{FormatLib.price(item.quantity * item.amount)}</div>
<FabButton className="delete-btn" onClick={removeProductFromCart(item)}>
<i className="fa fa-trash" />
</FabButton>
</div>
))}
{cart && <p>Totale: {FormatLib.price(cart.amount)}</p>}
<FabButton className="checkout-btn" onClick={checkout}>
{t('app.public.store_cart.checkout')}
</FabButton>
</div>
);
};
const StoreCartWrapper: React.FC<StoreCartProps> = (props) => {
return (
<Loader>
<StoreCart {...props} />
</Loader>
);
};
Application.Components.component('storeCart', react2angular(StoreCartWrapper, ['onError', 'currentUser']));

View File

@ -0,0 +1,81 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import _ from 'lodash';
import { FabButton } from '../base/fab-button';
import { Product } from '../../models/product';
import { Order } from '../../models/order';
import FormatLib from '../../lib/format';
import CartAPI from '../../api/cart';
interface StoreProductItemProps {
product: Product,
cart: Order,
onSuccessAddProductToCart: (cart: Order) => void
}
/**
* This component shows a product item in store
*/
export const StoreProductItem: React.FC<StoreProductItemProps> = ({ product, cart, onSuccessAddProductToCart }) => {
const { t } = useTranslation('public');
/**
* Return main image of Product, if the product has not any image, show default image
*/
const productImageUrl = (product: Product) => {
const productImage = _.find(product.product_images_attributes, { is_main: true });
if (productImage) {
return productImage.attachment_url;
}
return 'https://via.placeholder.com/300';
};
/**
* Return product's stock status
*/
const productStockStatus = (product: Product) => {
if (product.stock.external === 0) {
return <span>{t('app.public.store_product_item.out_of_stock')}</span>;
}
if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) {
return <span>{t('app.public.store_product_item.limited_stock')}</span>;
}
return <span>{t('app.public.store_product_item.available')}</span>;
};
/**
* Add the product to cart
*/
const addProductToCart = (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
CartAPI.addItem(cart, product.id, 1).then(onSuccessAddProductToCart);
};
/**
* Goto show product page
*/
const showProduct = (product: Product): void => {
window.location.href = `/#!/store/p/${product.slug}`;
};
return (
<div className="store-product-item" onClick={() => showProduct(product)}>
<div className='itemInfo-image'>
<img src={productImageUrl(product)} alt='' className='itemInfo-thumbnail' />
</div>
<p className="itemInfo-name">{product.name}</p>
<div className=''>
<span>
<div>{FormatLib.price(product.amount)}</div>
{productStockStatus(product)}
</span>
{product.stock.external > 0 &&
<FabButton className="edit-btn" onClick={addProductToCart}>
<i className="fas fa-cart-arrow-down" /> {t('app.public.store_product_item.add')}
</FabButton>
}
</div>
</div>
);
};

View File

@ -0,0 +1,64 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import _ from 'lodash';
import { Product } from '../../models/product';
import ProductAPI from '../../api/product';
declare const Application: IApplication;
interface StoreProductProps {
productSlug: string,
onError: (message: string) => void,
}
/**
* This component shows a product
*/
export const StoreProduct: React.FC<StoreProductProps> = ({ productSlug, onError }) => {
const { t } = useTranslation('public');
const [product, setProduct] = useState<Product>();
useEffect(() => {
ProductAPI.get(productSlug).then(data => {
setProduct(data);
}).catch(() => {
onError(t('app.public.store_product.unexpected_error_occurred'));
});
}, []);
/**
* Return main image of Product, if the product has not any image, show default image
*/
const productImageUrl = (product: Product) => {
const productImage = _.find(product.product_images_attributes, { is_main: true });
if (productImage) {
return productImage.attachment_url;
}
return 'https://via.placeholder.com/300';
};
if (product) {
return (
<div className="store-product">
<img src={productImageUrl(product)} alt='' className='itemInfo-thumbnail' />
<p className="itemInfo-name">{product.name}</p>
<div dangerouslySetInnerHTML={{ __html: product.description }} />
</div>
);
}
return null;
};
const StoreProductWrapper: React.FC<StoreProductProps> = ({ productSlug, onError }) => {
return (
<Loader>
<StoreProduct productSlug={productSlug} onError={onError} />
</Loader>
);
};
Application.Components.component('storeProduct', react2angular(StoreProductWrapper, ['productSlug', 'onError']));

View File

@ -0,0 +1,104 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { FabButton } from '../base/fab-button';
import { Product } from '../../models/product';
import ProductAPI from '../../api/product';
import { StoreProductItem } from './store-product-item';
import useCart from '../../hooks/use-cart';
import { emitCustomEvent } from 'react-custom-events';
import { User } from '../../models/user';
declare const Application: IApplication;
interface StoreProps {
onError: (message: string) => void,
currentUser: User,
}
/**
* This component shows public store
*/
const Store: React.FC<StoreProps> = ({ onError, currentUser }) => {
const { t } = useTranslation('public');
const { cart, setCart, reloadCart } = useCart();
const [products, setProducts] = useState<Array<Product>>([]);
useEffect(() => {
ProductAPI.index({ is_active: true }).then(data => {
setProducts(data);
}).catch(() => {
onError(t('app.public.store.unexpected_error_occurred'));
});
}, []);
useEffect(() => {
emitCustomEvent('CartUpdate', cart);
}, [cart]);
useEffect(() => {
if (currentUser) {
reloadCart();
}
}, [currentUser]);
return (
<div className="store">
<div className='layout'>
<div className='store-filters span-3'>
<header>
<h3>Filtrer</h3>
<div className='grpBtn'>
<FabButton className="is-black">Clear</FabButton>
</div>
</header>
</div>
<div className='store-products-list span-7'>
<div className='status'>
<div className='count'>
<p>Result count: <span>{products.length}</span></p>
</div>
<div className="">
<div className='sort'>
<p>Display options:</p>
</div>
<div className='visibility'>
</div>
</div>
</div>
<div className='features'>
<div className='features-item'>
<p>feature name</p>
<button><i className="fa fa-times" /></button>
</div>
<div className='features-item'>
<p>long feature name</p>
<button><i className="fa fa-times" /></button>
</div>
</div>
<div className="products">
{products.map((product) => (
<StoreProductItem key={product.id} product={product} cart={cart} onSuccessAddProductToCart={setCart} />
))}
</div>
</div>
</div>
</div>
);
};
const StoreWrapper: React.FC<StoreProps> = (props) => {
return (
<Loader>
<Store {...props} />
</Loader>
);
};
Application.Components.component('store', react2angular(StoreWrapper, ['onError', 'currentUser']));

View File

@ -1,6 +1,6 @@
Application.Controllers.controller('ApplicationController', ['$rootScope', '$scope', '$transitions', '$window', '$locale', '$timeout', 'Session', 'AuthService', 'Auth', '$uibModal', '$state', 'growl', 'Notification', '$interval', 'Setting', '_t', 'Version', 'Help',
function ($rootScope, $scope, $transitions, $window, $locale, $timeout, Session, AuthService, Auth, $uibModal, $state, growl, Notification, $interval, Setting, _t, Version, Help) {
Application.Controllers.controller('ApplicationController', ['$rootScope', '$scope', '$transitions', '$window', '$locale', '$timeout', 'Session', 'AuthService', 'Auth', '$uibModal', '$state', 'growl', 'Notification', '$interval', 'Setting', '_t', 'Version', 'Help', '$cookies',
function ($rootScope, $scope, $transitions, $window, $locale, $timeout, Session, AuthService, Auth, $uibModal, $state, growl, Notification, $interval, Setting, _t, Version, Help, $cookies) {
/* PRIVATE STATIC CONSTANTS */
// User's notifications will get refreshed every 30s
@ -58,6 +58,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
total: 0,
unread: 0
};
$cookies.remove('fablab_cart_token');
return $state.go('app.public.home');
}, function (error) {
console.error(`An error occurred logging out: ${error}`);

View File

@ -0,0 +1,41 @@
/* eslint-disable
no-return-assign,
no-undef,
*/
'use strict';
Application.Controllers.controller('CartController', ['$scope', 'CSRF', 'growl', '$state',
function ($scope, CSRF, growl, $state) {
/* PRIVATE SCOPE */
/* PUBLIC SCOPE */
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
/**
* Callback triggered in case of success
*/
$scope.onSuccess = (message) => {
growl.success(message);
};
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {
// set the authenticity tokens in the forms
CSRF.setMetaTags();
};
// init the controller (call at the end !)
return initialize();
}
]);

View File

@ -53,6 +53,12 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm
linkIcon: 'tags',
class: 'reserve-event-link'
},
{
state: 'app.public.store',
linkText: 'app.public.common.fablab_store',
linkIcon: 'cart-plus',
class: 'store-link'
},
{ class: 'menu-spacer' },
{
state: 'app.public.projects_list',

View File

@ -0,0 +1,42 @@
/* eslint-disable
no-return-assign,
no-undef,
*/
'use strict';
Application.Controllers.controller('ShowProductController', ['$scope', 'CSRF', 'growl', '$transition$',
function ($scope, CSRF, growl, $transition$) {
/* PRIVATE SCOPE */
/* PUBLIC SCOPE */
$scope.productSlug = $transition$.params().slug;
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
/**
* Callback triggered in case of success
*/
$scope.onSuccess = (message) => {
growl.success(message);
};
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {
// set the authenticity tokens in the forms
CSRF.setMetaTags();
};
// init the controller (call at the end !)
return initialize();
}
]);

View File

@ -0,0 +1,41 @@
/* eslint-disable
no-return-assign,
no-undef,
*/
'use strict';
Application.Controllers.controller('StoreController', ['$scope', 'CSRF', 'growl', '$state',
function ($scope, CSRF, growl, $state) {
/* PRIVATE SCOPE */
/* PUBLIC SCOPE */
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
/**
* Callback triggered in case of success
*/
$scope.onSuccess = (message) => {
growl.success(message);
};
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {
// set the authenticity tokens in the forms
CSRF.setMetaTags();
};
// init the controller (call at the end !)
return initialize();
}
]);

View File

@ -0,0 +1,37 @@
import { useState, useEffect } from 'react';
import { Order } from '../models/order';
import CartAPI from '../api/cart';
import { getCartToken, setCartToken } from '../lib/cart-token';
export default function useCart () {
const [cart, setCart] = useState<Order>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState(null);
useEffect(() => {
async function createCart () {
const currentCartToken = getCartToken();
const data = await CartAPI.create(currentCartToken);
setCart(data);
setLoading(false);
setCartToken(data.token);
}
setLoading(true);
try {
createCart();
} catch (e) {
setLoading(false);
setError(e);
}
}, []);
const reloadCart = async () => {
setLoading(true);
const currentCartToken = getCartToken();
const data = await CartAPI.create(currentCartToken);
setCart(data);
setLoading(false);
};
return { loading, cart, error, setCart, reloadCart };
}

View File

@ -0,0 +1,23 @@
import Cookies from 'js-cookie';
export const cartCookieName = 'fablab_cart_token';
export const cartCookieExpire = 7;
export const getCartToken = () =>
Cookies.get(cartCookieName);
export const setCartToken = (cartToken: string) => {
const cookieOptions = {
expires: cartCookieExpire
};
Cookies.set(
cartCookieName,
cartToken,
cookieOptions
);
};
export const removeCartToken = () => {
Cookies.remove(cartCookieName);
};

View File

@ -0,0 +1,21 @@
import { TDateISO } from '../typings/date-iso';
export interface Order {
id: number,
token: string,
statistic_profile_id?: number,
operator_id?: number,
reference?: string,
state?: string,
amount?: number,
created_at?: TDateISO,
order_items_attributes: Array<{
id: number,
orderable_type: string,
orderable_id: number,
orderable_name: string,
quantity: number,
amount: number,
is_offered: boolean
}>,
}

View File

@ -1,4 +1,9 @@
import { TDateISO } from '../typings/date-iso';
import { ApiFilter } from './api';
export interface ProductIndexFilter extends ApiFilter {
is_active: boolean,
}
export enum StockType {
internal = 'internal',

View File

@ -600,6 +600,39 @@ angular.module('application.router', ['ui.router'])
}
})
// store
.state('app.public.store', {
url: '/store',
views: {
'main@': {
templateUrl: '/store/index.html',
controller: 'StoreController'
}
}
})
// show product
.state('app.public.product_show', {
url: '/store/p/:slug',
views: {
'main@': {
templateUrl: '/products/show.html',
controller: 'ShowProductController'
}
}
})
// cart
.state('app.public.cart', {
url: '/cart',
views: {
'main@': {
templateUrl: '/cart/index.html',
controller: 'CartController'
}
}
})
// --- namespace /admin/... ---
// calendar
.state('app.admin.calendar', {

View File

@ -95,6 +95,7 @@
@import "modules/store/products-filters";
@import "modules/store/products-list";
@import "modules/store/products";
@import "modules/store/store";
@import "modules/subscriptions/free-extend-modal";
@import "modules/subscriptions/renew-modal";
@import "modules/supporting-documents/supporting-documents-files";

View File

@ -0,0 +1,170 @@
.store {
margin: 0 auto;
padding-bottom: 6rem;
.back-btn {
margin: 2.4rem 0;
padding: 0.4rem 0.8rem;
display: inline-flex;
align-items: center;
background-color: var(--gray-soft-darkest);
border-radius: var(--border-radius-sm);
color: var(--gray-soft-lightest);
i { margin-right: 0.8rem; }
&:hover {
background-color: var(--gray-hard-lightest);
cursor: pointer;
}
}
header {
padding: 2.4rem 0;
display: flex;
justify-content: space-between;
align-items: center;
.grpBtn {
display: flex;
& > *:not(:first-child) { margin-left: 2.4rem; }
}
h2 {
margin: 0;
@include title-lg;
color: var(--gray-hard-darkest) !important;
}
h3 {
margin: 0;
@include text-lg(600);
color: var(--gray-hard-darkest) !important;
}
}
.layout {
display: flex;
align-items: flex-end;
gap: 0 3.2rem;
.span-7 { flex: 1 1 70%; }
.span-3 { flex: 1 1 30%; }
}
.main-action-btn {
background-color: var(--main);
color: var(--gray-soft-lightest);
border: none;
&:hover { opacity: 0.75; }
}
.main-actions {
display: flex;
justify-content: center;
align-items: center;
& > *:not(:first-child) {
margin-left: 1.6rem;
}
}
}
.store {
max-width: 1600px;
.layout {
align-items: flex-start;
}
&-filters {
}
&-products-list {
.products {
display: flex;
flex-wrap: wrap;
}
.status {
padding: 1.6rem 2.4rem;
display: flex;
justify-content: space-between;
background-color: var(--gray-soft);
border-radius: var(--border-radius);
p { margin: 0; }
.count {
p {
display: flex;
align-items: center;
@include text-sm;
span {
margin-left: 1.6rem;
@include text-lg(600);
}
}
}
}
.features {
margin: 2.4rem 0 1.6rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.6rem 2.4rem;
&-item {
padding-left: 1.6rem;
display: flex;
align-items: center;
background-color: var(--information-light);
border-radius: 100px;
color: var(--information-dark);
p { margin: 0; }
button {
width: 3.2rem;
height: 3.2rem;
background: none;
border: none;
}
}
}
}
&-product-item {
padding: 1rem 1.8rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
background-color: var(--gray-soft-lightest);
margin-right: 1.6rem;
.itemInfo-image {
align-items: center;
img {
width: 19.8rem;
height: 14.8rem;
object-fit: cover;
border-radius: var(--border-radius);
background-color: var(--gray-soft);
}
}
.itemInfo-name {
margin: 1rem 0;
@include text-base;
font-weight: 600;
color: var(--gray-hard-darkest);
}
.actions {
display: flex;
align-items: center;
.manage {
overflow: hidden;
display: flex;
border-radius: var(--border-radius-sm);
button {
@include btn;
border-radius: 0;
color: var(--gray-soft-lightest);
&:hover { opacity: 0.75; }
}
.edit-btn {background: var(--gray-hard-darkest) }
.delete-btn {background: var(--error) }
}
}
}
}

View File

@ -0,0 +1,19 @@
<section class="heading b-b">
<div class="row no-gutter">
<div class="col-xs-2 col-sm-2 col-md-1">
<section class="heading-btn">
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
</section>
</div>
<div class="col-xs-10 col-sm-10 col-md-8 b-l b-r-md">
<section class="heading-title">
<h1 translate>{{ 'app.public.cart.my_cart' }}</h1>
</section>
</div>
</div>
</section>
<section class="m-lg">
<store-cart current-user="currentUser" on-error="onError" on-success="onSuccess" />
</section>

View File

@ -0,0 +1,24 @@
<section class="heading b-b">
<div class="row no-gutter">
<div class="col-xs-2 col-sm-2 col-md-1">
<section class="heading-btn">
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
</section>
</div>
<div class="col-xs-10 col-sm-10 col-md-8 b-l b-r-md">
<section class="heading-title">
<h1 translate>{{ 'app.public.store.fablab_store' }}</h1>
</section>
</div>
<div class="col-xs-12 col-sm-12 col-md-3 b-t hide-b-md">
<section class="heading-actions wrapper">
</section>
</div>
</div>
</section>
<section class="m-lg">
<store-product product-slug="productSlug" on-error="onError" on-success="onSuccess" />
</section>

View File

@ -0,0 +1,25 @@
<section class="heading b-b">
<div class="row no-gutter">
<div class="col-xs-2 col-sm-2 col-md-1">
<section class="heading-btn">
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
</section>
</div>
<div class="col-xs-10 col-sm-10 col-md-8 b-l b-r-md">
<section class="heading-title">
<h1 translate>{{ 'app.public.store.fablab_store' }}</h1>
</section>
</div>
<div class="col-xs-12 col-sm-12 col-md-3 b-t hide-b-md">
<section class="heading-actions wrapper">
<cart-button />
</section>
</div>
</div>
</section>
<section class="m-lg">
<store current-user="currentUser" on-error="onError" on-success="onSuccess" />
</section>

12
app/models/order.rb Normal file
View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
# Order is a model for the user hold information of order
class Order < ApplicationRecord
belongs_to :statistic_profile
has_many :order_items, dependent: :destroy
ALL_STATES = %w[cart].freeze
enum state: ALL_STATES.zip(ALL_STATES).to_h
validates :token, :state, presence: true
end

9
app/models/order_item.rb Normal file
View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
# A single line inside an Order. Can be an article of Order
class OrderItem < ApplicationRecord
belongs_to :order
belongs_to :orderable, polymorphic: true
validates :orderable, :order_id, :amount, presence: true
end

View File

@ -2,6 +2,9 @@
# Product is a model for the merchandise hold information of product in store
class Product < ApplicationRecord
extend FriendlyId
friendly_id :name, use: :slugged
belongs_to :product_category
has_and_belongs_to_many :machines
@ -15,6 +18,7 @@ class Product < ApplicationRecord
has_many :product_stock_movements, dependent: :destroy
accepts_nested_attributes_for :product_stock_movements, allow_destroy: true, reject_if: :all_blank
validates :name, :slug, presence: true
validates :amount, numericality: { greater_than: 0, allow_nil: true }
scope :active, -> { where(is_active: true) }

View File

@ -3,6 +3,9 @@
# Category is a first-level filter, used to categorize Products.
# It is mandatory to choose a Category when creating a Product.
class ProductCategory < ApplicationRecord
extend FriendlyId
friendly_id :name, use: :slugged
validates :name, :slug, presence: true
belongs_to :parent, class_name: 'ProductCategory'

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
# Check the access policies for API::CartController
class CartPolicy < ApplicationPolicy
def create?
true
end
%w[add_item remove_item set_quantity].each do |action|
define_method "#{action}?" do
return user.privileged? || (record.statistic_profile_id == user.statistic_profile.id) if user
record.statistic_profile_id.nil?
end
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
# Provides methods for add order item to cart
class Cart::AddItemService
def call(order, orderable, quantity = 1)
return order if quantity.to_i.zero?
raise Cart::InactiveProductError unless orderable.is_active
raise Cart::OutStockError if quantity > orderable.stock['external']
item = order.order_items.find_by(orderable: orderable)
if item.nil?
item = order.order_items.new(quantity: quantity, orderable: orderable, amount: orderable.amount)
else
item.quantity += quantity.to_i
end
order.amount += (orderable.amount * quantity.to_i)
ActiveRecord::Base.transaction do
item.save
order.save
end
order.reload
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
# Provides methods for create cart
class Cart::CreateService
def call(user)
token = GenerateTokenService.new.call(Order)
order_param = {
token: token,
state: 'cart',
amount: 0
}
if user
order_param[:statistic_profile_id] = user.statistic_profile.id if user.member?
order_param[:operator_id] = user.id if user.privileged?
end
Order.create!(order_param)
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
# Provides methods for remove order item to cart
class Cart::RemoveItemService
def call(order, orderable)
item = order.order_items.find_by(orderable: orderable)
raise ActiveRecord::RecordNotFound if item.nil?
order.amount -= (item.amount * item.quantity.to_i)
ActiveRecord::Base.transaction do
item.destroy!
order.save
end
order.reload
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
# Provides methods for update quantity of order item
class Cart::SetQuantityService
def call(order, orderable, quantity = nil)
return order if quantity.to_i.zero?
raise Cart::OutStockError if quantity.to_i > orderable.stock['external']
item = order.order_items.find_by(orderable: orderable)
raise ActiveRecord::RecordNotFound if item.nil?
different_quantity = quantity.to_i - item.quantity
order.amount += (orderable.amount * different_quantity)
ActiveRecord::Base.transaction do
item.update(quantity: quantity.to_i)
order.save
end
order.reload
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
# Generate a unique token
class GenerateTokenService
def call(model_class = Order)
loop do
token = "#{random_token}#{unique_ending}"
break token unless model_class.exists?(token: token)
end
end
private
def random_token
SecureRandom.urlsafe_base64(nil, false)
end
def unique_ending
(Time.now.to_f * 1000).to_i
end
end

View File

@ -2,8 +2,13 @@
# Provides methods for Product
class ProductService
def self.list
Product.all
def self.list(filters)
products = Product.includes(:product_images)
if filters[:is_active].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
products = products.where(is_active: state)
end
products
end
# amount params multiplied by hundred

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
json.extract! order, :id, :token, :statistic_profile_id, :operator_id, :reference, :state, :created_at
json.amount order.amount / 100.0 if order.amount.present?
json.order_items_attributes order.order_items do |item|
json.id item.id
json.orderable_type item.orderable_type
json.orderable_id item.orderable_id
json.orderable_name item.orderable.name
json.quantity item.quantity
json.amount item.amount / 100.0
json.is_offered item.is_offered
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/orders/order', order: @order

View File

@ -1,7 +1,8 @@
# frozen_string_literal: true
json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert,
json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert,
:low_stock_threshold, :machine_ids
json.description sanitize(product.description)
json.amount product.amount / 100.0 if product.amount.present?
json.product_files_attributes product.product_files do |f|
json.id f.id

View File

@ -1,5 +1,13 @@
# frozen_string_literal: true
json.array! @products do |product|
json.partial! 'api/products/product', product: product
json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :machine_ids,
:low_stock_threshold
json.amount product.amount / 100.0 if product.amount.present?
json.product_images_attributes product.product_images do |f|
json.id f.id
json.attachment_name f.attachment_identifier
json.attachment_url f.attachment_url
json.is_main f.is_main
end
end

View File

@ -43,6 +43,7 @@ en:
projects_gallery: "Projects gallery"
subscriptions: "Subscriptions"
public_calendar: "Calendar"
fablab_store: "Fablab Store"
#left menu (admin)
trainings_monitoring: "Trainings"
manage_the_calendar: "Calendar"
@ -373,6 +374,23 @@ en:
characteristics: "Characteristics"
files_to_download: "Files to download"
projects_using_the_space: "Projects using the space"
#public store
store:
fablab_store: "FabLab Store"
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
store_product_item:
available: "Available"
limited_stock: "Limited stock"
out_of_stock: "Out of stock"
add: "Add"
store_product:
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
cart:
my_cart: "My Cart"
cart_button:
my_cart: "My Cart"
store_cart:
checkout: "Checkout"
tour:
conclusion:
title: "Thank you for your attention"

View File

@ -43,6 +43,7 @@ fr:
projects_gallery: "Galerie de projets"
subscriptions: "Abonnements"
public_calendar: "Agenda"
fablab_store: "Boutique Fablab"
#left menu (admin)
trainings_monitoring: "Formations"
manage_the_calendar: "Agenda"
@ -373,6 +374,23 @@ fr:
characteristics: "Caractéristiques"
files_to_download: "Fichiers à télécharger"
projects_using_the_space: "Projets utilisant l'espace"
#public store
store:
fablab_store: "Boutique FabLab"
unexpected_error_occurred: "Une erreur inattendue s'est produite. Veuillez réessayer ultérieurement."
store_product_item:
available: "Disponible"
limited_stock: "Stock limité"
out_of_stock: "Épuisé"
add: "Ajouter"
store_product:
unexpected_error_occurred: "Une erreur inattendue s'est produite. Veuillez réessayer ultérieurement."
cart:
my_cart: "Mon Panier"
cart_button:
my_cart: "Mon Panier"
store_cart:
checkout: "Valider mon panier"
tour:
conclusion:
title: "Merci de votre attention"

View File

@ -155,6 +155,11 @@ Rails.application.routes.draw do
end
resources :products
resources :cart, only: %i[create] do
put 'add_item', on: :collection
put 'remove_item', on: :collection
put 'set_quantity', on: :collection
end
# for admin
resources :trainings do
@ -268,7 +273,7 @@ Rails.application.routes.draw do
post '/stats/global/export', to: 'api/statistics#export_global'
post '_search/scroll', to: 'api/statistics#scroll'
match '/project_collaborator/:valid_token', to: 'api/projects#collaborator_valid', via: :get
get '/project_collaborator/:valid_token', to: 'api/projects#collaborator_valid'
authenticate :user, ->(u) { u.admin? } do
mount Sidekiq::Web => '/admin/sidekiq'

View File

@ -0,0 +1,14 @@
class CreateOrders < ActiveRecord::Migration[5.2]
def change
create_table :orders do |t|
t.belongs_to :statistic_profile, foreign_key: true
t.integer :operator_id
t.string :token
t.string :reference
t.string :state
t.integer :amount
t.timestamps
end
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal:true
# OrderItem for save article of Order
class CreateOrderItems < ActiveRecord::Migration[5.2]
def change
create_table :order_items do |t|
t.belongs_to :order, foreign_key: true
t.references :orderable, polymorphic: true
t.integer :amount
t.integer :quantity
t.boolean :is_offered
t.timestamps
end
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_08_05_083431) do
ActiveRecord::Schema.define(version: 2022_08_18_160821) do
# These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch"
@ -445,6 +445,31 @@ ActiveRecord::Schema.define(version: 2022_08_05_083431) do
t.datetime "updated_at", null: false
end
create_table "order_items", force: :cascade do |t|
t.bigint "order_id"
t.string "orderable_type"
t.bigint "orderable_id"
t.integer "amount"
t.integer "quantity"
t.boolean "is_offered"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["order_id"], name: "index_order_items_on_order_id"
t.index ["orderable_type", "orderable_id"], name: "index_order_items_on_orderable_type_and_orderable_id"
end
create_table "orders", force: :cascade do |t|
t.bigint "statistic_profile_id"
t.integer "operator_id"
t.string "token"
t.string "reference"
t.string "state"
t.integer "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["statistic_profile_id"], name: "index_orders_on_statistic_profile_id"
end
create_table "organizations", id: :serial, force: :cascade do |t|
t.string "name"
t.datetime "created_at", null: false
@ -1133,6 +1158,8 @@ ActiveRecord::Schema.define(version: 2022_08_05_083431) do
add_foreign_key "invoices", "statistic_profiles"
add_foreign_key "invoices", "wallet_transactions"
add_foreign_key "invoicing_profiles", "users"
add_foreign_key "order_items", "orders"
add_foreign_key "orders", "statistic_profiles"
add_foreign_key "organizations", "invoicing_profiles"
add_foreign_key "payment_gateway_objects", "payment_gateway_objects"
add_foreign_key "payment_schedule_items", "invoices"

View File

@ -121,6 +121,7 @@
"jasny-bootstrap": "3.1",
"jquery": ">=3.5.0",
"jquery-ujs": "^1.2.2",
"js-cookie": "^3.0.1",
"medium-editor": "^5.23.3",
"mini-css-extract-plugin": "^2.6.0",
"moment": "2.29",
@ -137,6 +138,7 @@
"rails-erb-loader": "^5.5.2",
"react": "^17.0.2",
"react-cool-onclickoutside": "^1.7.0",
"react-custom-events": "^1.1.1",
"react-dom": "^17.0.2",
"react-hook-form": "^7.30.0",
"react-i18next": "^11.15.6",

View File

@ -5272,6 +5272,11 @@ jquery@>=3.5.0:
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470"
integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==
js-cookie@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414"
integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -6583,6 +6588,11 @@ react-cool-onclickoutside@^1.7.0:
resolved "https://registry.yarnpkg.com/react-cool-onclickoutside/-/react-cool-onclickoutside-1.7.0.tgz#abc844e14852220fe15f81d7ef44976d15cd9980"
integrity sha512-HVZK2155Unee+enpoHKyYP2UdQK69thw90XAOUCjvJBcgRSgfRPgWWt/W1dYzoGp3+nleAa8SJxF1d4FMA4Qmw==
react-custom-events@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/react-custom-events/-/react-custom-events-1.1.1.tgz#792f126e897043a14b9f27a4c5ab7072ff235ceb"
integrity sha512-71iEu3zHsBn3uvF+Sq4Fu5imtRt+cLZO6nG2zqUhdqGVIpZIfeLcl6yieqPghrE+18KFrS5BaHD0NBPP/EZJNw==
react-dom@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"