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:
commit
857261ba62
49
app/controllers/api/cart_controller.rb
Normal file
49
app/controllers/api/cart_controller.rb
Normal 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
|
6
app/controllers/api/checkout_controller.rb
Normal file
6
app/controllers/api/checkout_controller.rb
Normal file
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# API Controller for cart checkout
|
||||
class API::CheckoutController < API::ApiController
|
||||
include ::API::OrderConcern
|
||||
end
|
@ -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
|
||||
|
@ -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
|
||||
|
18
app/controllers/concerns/api/order_concern.rb
Normal file
18
app/controllers/concerns/api/order_concern.rb
Normal 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
|
5
app/exceptions/cart/inactive_product_error.rb
Normal file
5
app/exceptions/cart/inactive_product_error.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when the product is out of stock
|
||||
class Cart::InactiveProductError < StandardError
|
||||
end
|
5
app/exceptions/cart/out_stock_error.rb
Normal file
5
app/exceptions/cart/out_stock_error.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when the product is out of stock
|
||||
class Cart::OutStockError < StandardError
|
||||
end
|
25
app/frontend/src/javascript/api/cart.ts
Normal file
25
app/frontend/src/javascript/api/cart.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
48
app/frontend/src/javascript/components/cart/cart-button.tsx
Normal file
48
app/frontend/src/javascript/components/cart/cart-button.tsx
Normal 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));
|
98
app/frontend/src/javascript/components/cart/store-cart.tsx
Normal file
98
app/frontend/src/javascript/components/cart/store-cart.tsx
Normal 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']));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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']));
|
104
app/frontend/src/javascript/components/store/store.tsx
Normal file
104
app/frontend/src/javascript/components/store/store.tsx
Normal 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']));
|
@ -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}`);
|
||||
|
41
app/frontend/src/javascript/controllers/cart.js
Normal file
41
app/frontend/src/javascript/controllers/cart.js
Normal 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();
|
||||
}
|
||||
|
||||
]);
|
@ -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',
|
||||
|
42
app/frontend/src/javascript/controllers/products.js
Normal file
42
app/frontend/src/javascript/controllers/products.js
Normal 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();
|
||||
}
|
||||
|
||||
]);
|
41
app/frontend/src/javascript/controllers/store.js
Normal file
41
app/frontend/src/javascript/controllers/store.js
Normal 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();
|
||||
}
|
||||
|
||||
]);
|
37
app/frontend/src/javascript/hooks/use-cart.ts
Normal file
37
app/frontend/src/javascript/hooks/use-cart.ts
Normal 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 };
|
||||
}
|
23
app/frontend/src/javascript/lib/cart-token.ts
Normal file
23
app/frontend/src/javascript/lib/cart-token.ts
Normal 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);
|
||||
};
|
21
app/frontend/src/javascript/models/order.ts
Normal file
21
app/frontend/src/javascript/models/order.ts
Normal 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
|
||||
}>,
|
||||
}
|
@ -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',
|
||||
|
@ -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', {
|
||||
|
@ -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";
|
||||
|
170
app/frontend/src/stylesheets/modules/store/store.scss
Normal file
170
app/frontend/src/stylesheets/modules/store/store.scss
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
19
app/frontend/templates/cart/index.html
Normal file
19
app/frontend/templates/cart/index.html
Normal 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>
|
24
app/frontend/templates/products/show.html
Normal file
24
app/frontend/templates/products/show.html
Normal 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>
|
25
app/frontend/templates/store/index.html
Normal file
25
app/frontend/templates/store/index.html
Normal 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
12
app/models/order.rb
Normal 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
9
app/models/order_item.rb
Normal 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
|
@ -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) }
|
||||
|
@ -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'
|
||||
|
16
app/policies/cart_policy.rb
Normal file
16
app/policies/cart_policy.rb
Normal 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
|
25
app/services/cart/add_item_service.rb
Normal file
25
app/services/cart/add_item_service.rb
Normal 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
|
19
app/services/cart/create_service.rb
Normal file
19
app/services/cart/create_service.rb
Normal 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
|
17
app/services/cart/remove_item_service.rb
Normal file
17
app/services/cart/remove_item_service.rb
Normal 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
|
22
app/services/cart/set_quantity_service.rb
Normal file
22
app/services/cart/set_quantity_service.rb
Normal 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
|
21
app/services/generate_token_service.rb
Normal file
21
app/services/generate_token_service.rb
Normal 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
|
@ -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
|
||||
|
14
app/views/api/orders/_order.json.jbuilder
Normal file
14
app/views/api/orders/_order.json.jbuilder
Normal 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
|
3
app/views/api/orders/show.json.jbuilder
Normal file
3
app/views/api/orders/show.json.jbuilder
Normal file
@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! 'api/orders/order', order: @order
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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'
|
||||
|
14
db/migrate/20220808161314_create_orders.rb
Normal file
14
db/migrate/20220808161314_create_orders.rb
Normal 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
|
16
db/migrate/20220818160821_create_order_items.rb
Normal file
16
db/migrate/20220818160821_create_order_items.rb
Normal 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
|
29
db/schema.rb
29
db/schema.rb
@ -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"
|
||||
|
@ -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",
|
||||
|
10
yarn.lock
10
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user