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

store cart

This commit is contained in:
Du Peng 2022-08-19 19:59:13 +02:00
parent 16288ae2bd
commit ab800a519f
31 changed files with 546 additions and 5 deletions

View File

@ -0,0 +1,54 @@
# frozen_string_literal: true
# API Controller for manage user's cart
class API::CartController < API::ApiController
before_action :current_order
before_action :ensure_order, except: %i[create]
def create
authorize :cart, :create?
@order = current_order if current_order.present?
@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 :cart, policy_class: CartPolicy
@order = Cart::RemoveItemService.new.call(@current_order, orderable)
render 'api/orders/show'
end
def set_quantity
authorize :cart, policy_class: CartPolicy
@order = Cart::SetQuantityService.new.call(@current_order, orderable, cart_params[:quantity])
render 'api/orders/show'
end
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
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,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

@ -0,0 +1,65 @@
import React 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';
declare const Application: IApplication;
interface StoreCartProps {
onError: (message: string) => void,
}
/**
* This component shows user's cart
*/
const StoreCart: React.FC<StoreCartProps> = ({ onError }) => {
const { t } = useTranslation('public');
const { loading, cart, setCart } = useCart();
/**
* 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);
});
};
};
return (
<div className="store-cart">
{loading && <p>loading</p>}
{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>
<div>{FormatLib.price(item.quantity * item.amount)}</div>
<FabButton className="delete-btn" onClick={removeProductFromCart(item)}>
<i className="fa fa-trash" /> {t('app.public.store_cart.remove_item')}
</FabButton>
</div>
))}
{cart && <p>{cart.amount}</p>}
</div>
);
};
const StoreCartWrapper: React.FC<StoreCartProps> = ({ onError }) => {
return (
<Loader>
<StoreCart onError={onError} />
</Loader>
);
};
Application.Components.component('storeCart', react2angular(StoreCartWrapper, ['onError']));

View File

@ -3,16 +3,19 @@ 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,
}
/**
* This component shows a product item in store
*/
export const StoreProductItem: React.FC<StoreProductItemProps> = ({ product }) => {
export const StoreProductItem: React.FC<StoreProductItemProps> = ({ product, cart }) => {
const { t } = useTranslation('public');
/**
@ -36,6 +39,15 @@ export const StoreProductItem: React.FC<StoreProductItemProps> = ({ product }) =
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);
};
/**
* Goto show product page
*/
@ -54,7 +66,7 @@ export const StoreProductItem: React.FC<StoreProductItemProps> = ({ product }) =
<div>{FormatLib.price(product.amount)}</div>
{productStockStatus(product)}
</span>
<FabButton className='edit-btn'>
<FabButton className="edit-btn" onClick={addProductToCart}>
<i className="fas fa-cart-arrow-down" /> {t('app.public.store_product_item.add')}
</FabButton>
</div>

View File

@ -7,6 +7,7 @@ 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';
declare const Application: IApplication;
@ -20,6 +21,8 @@ interface StoreProps {
const Store: React.FC<StoreProps> = ({ onError }) => {
const { t } = useTranslation('public');
const { cart } = useCart();
const [products, setProducts] = useState<Array<Product>>([]);
useEffect(() => {
@ -68,7 +71,7 @@ const Store: React.FC<StoreProps> = ({ onError }) => {
<div className="products">
{products.map((product) => (
<StoreProductItem key={product.id} product={product} />
<StoreProductItem key={product.id} product={product} cart={cart}/>
))}
</div>
</div>

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

@ -0,0 +1,29 @@
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);
}
}, []);
return { loading, cart, error, setCart };
}

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

@ -622,6 +622,17 @@ angular.module('application.router', ['ui.router'])
}
})
// cart
.state('app.public.cart', {
url: '/cart',
views: {
'main@': {
templateUrl: '/cart/index.html',
controller: 'CartController'
}
}
})
// --- namespace /admin/... ---
// calendar
.state('app.admin.calendar', {

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 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

@ -0,0 +1,14 @@
# 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
user.privileged? || (record.statistic_profile.user_id == user.id)
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 > orderable.stock['external']
item = order.order_items.find_by(orderable: orderable)
raise ActiveRecord::RecordNotFound if item.nil?
different_quantity = item.quantity - quantiy.to_i
order.amount += (orderable.amount * different_quantity)
ActiveRecord::Base.transaction do
item.update(quantity: quantity)
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

@ -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

@ -384,6 +384,8 @@ en:
add: "Add"
store_product:
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
cart:
my_cart: "My Cart"
tour:
conclusion:
title: "Thank you for your attention"

View File

@ -384,6 +384,8 @@ fr:
add: "Ajouter"
store_product:
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
cart:
my_cart: "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",

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"