diff --git a/Gemfile b/Gemfile index aef064099..70c41bdce 100644 --- a/Gemfile +++ b/Gemfile @@ -145,3 +145,5 @@ gem 'tzinfo-data' gem 'sassc', '= 2.1.0' gem 'redis-session-store' + +gem 'acts_as_list' diff --git a/Gemfile.lock b/Gemfile.lock index a5358f468..6b4877130 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -48,6 +48,8 @@ GEM i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) + acts_as_list (1.0.4) + activerecord (>= 4.2) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) aes_key_wrap (1.1.0) @@ -500,6 +502,7 @@ DEPENDENCIES aasm actionpack-page_caching (= 1.2.2) active_record_query_trace + acts_as_list api-pagination apipie-rails awesome_print diff --git a/app/controllers/api/cart_controller.rb b/app/controllers/api/cart_controller.rb new file mode 100644 index 000000000..c9de2170d --- /dev/null +++ b/app/controllers/api/cart_controller.rb @@ -0,0 +1,39 @@ +# 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 ||= Cart::FindOrCreateService.new(current_user).call(order_token) + 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 +end diff --git a/app/controllers/api/checkout_controller.rb b/app/controllers/api/checkout_controller.rb new file mode 100644 index 000000000..05e5faa84 --- /dev/null +++ b/app/controllers/api/checkout_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# API Controller for cart checkout +class API::CheckoutController < API::ApiController + include ::API::OrderConcern + before_action :authenticate_user! + before_action :current_order + before_action :ensure_order + + def payment + authorize @current_order, policy_class: CheckoutPolicy + if @current_order.statistic_profile_id.nil? && current_user.privileged? + user = User.find(params[:customer_id]) + @current_order.statistic_profile = user.statistic_profile + end + res = Checkout::PaymentService.new.payment(@current_order, current_user, params[:coupon_code], + params[:payment_id]) + render json: res + rescue StandardError => e + render json: e, status: :unprocessable_entity + end + + def confirm_payment + authorize @current_order, policy_class: CheckoutPolicy + res = Checkout::PaymentService.new.confirm_payment(@current_order, current_user, params[:coupon_code], params[:payment_id]) + render json: res + rescue StandardError => e + render json: e, status: :unprocessable_entity + end +end diff --git a/app/controllers/api/product_categories_controller.rb b/app/controllers/api/product_categories_controller.rb new file mode 100644 index 000000000..0a60e477d --- /dev/null +++ b/app/controllers/api/product_categories_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# API Controller for resources of type ProductCategory +# ProductCategories are used to group Products +class API::ProductCategoriesController < API::ApiController + before_action :authenticate_user!, except: :index + before_action :set_product_category, only: %i[update destroy position] + + def index + @product_categories = ProductCategoryService.list + end + + def show + @product_category = ProductCategory.friendly.find(params[:id]) + end + + def create + authorize ProductCategory + @product_category = ProductCategory.new(product_category_params) + if @product_category.save + render status: :created + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @product_category + + if @product_category.update(product_category_params) + render status: :ok + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def position + authorize @product_category + + if @product_category.insert_at(params[:position]) + render :show + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @product_category + ProductCategoryService.destroy(@product_category) + head :no_content + end + + private + + def set_product_category + @product_category = ProductCategory.find(params[:id]) + end + + def product_category_params + params.require(:product_category).permit(:name, :parent_id, :slug) + end +end diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb new file mode 100644 index 000000000..ff022b3eb --- /dev/null +++ b/app/controllers/api/products_controller.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# API Controller for resources of type Product +# Products are used in store +class API::ProductsController < API::ApiController + before_action :authenticate_user!, except: %i[index show] + before_action :set_product, only: %i[update destroy] + + def index + @products = ProductService.list(params) + end + + def show + @product = Product.includes(:product_images, :product_files).friendly.find(params[:id]) + end + + def create + authorize Product + @product = Product.new(product_params) + @product.amount = ProductService.amount_multiplied_by_hundred(@product.amount) + if @product.save + render status: :created + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @product + + product_parameters = product_params + product_parameters[:amount] = ProductService.amount_multiplied_by_hundred(product_parameters[:amount]) + if @product.update(product_parameters) + render status: :ok + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @product + @product.destroy + head :no_content + end + + private + + def set_product + @product = Product.find(params[:id]) + end + + def product_params + params.require(:product).permit(:name, :slug, :sku, :description, :is_active, + :product_category_id, :amount, :quantity_min, + :low_stock_alert, :low_stock_threshold, + machine_ids: [], + product_files_attributes: %i[id attachment _destroy], + product_images_attributes: %i[id attachment is_main _destroy], + product_stock_movements_attributes: %i[id quantity reason stock_type _destroy]) + end +end diff --git a/app/controllers/concerns/api/order_concern.rb b/app/controllers/concerns/api/order_concern.rb new file mode 100644 index 000000000..9e14854dd --- /dev/null +++ b/app/controllers/concerns/api/order_concern.rb @@ -0,0 +1,22 @@ +# 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, state: 'cart') + end + + def ensure_order + raise ActiveRecord::RecordNotFound if @current_order.nil? + end + + def cart_params + params.permit(:order_token, :orderable_id, :quantity, :user_id) + end +end diff --git a/app/exceptions/cart/inactive_product_error.rb b/app/exceptions/cart/inactive_product_error.rb new file mode 100644 index 000000000..dff476462 --- /dev/null +++ b/app/exceptions/cart/inactive_product_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when the product is out of stock +class Cart::InactiveProductError < StandardError +end diff --git a/app/exceptions/cart/out_stock_error.rb b/app/exceptions/cart/out_stock_error.rb new file mode 100644 index 000000000..effaaad54 --- /dev/null +++ b/app/exceptions/cart/out_stock_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when the product is out of stock +class Cart::OutStockError < StandardError +end diff --git a/app/exceptions/cart/zero_price_error.rb b/app/exceptions/cart/zero_price_error.rb new file mode 100644 index 000000000..7ac80e19f --- /dev/null +++ b/app/exceptions/cart/zero_price_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when order amount = 0 +class Cart::ZeroPriceError < StandardError +end diff --git a/app/frontend/images/no_avatar.png b/app/frontend/images/no_avatar.png old mode 100755 new mode 100644 index dbbb16222..bdde27828 Binary files a/app/frontend/images/no_avatar.png and b/app/frontend/images/no_avatar.png differ diff --git a/app/frontend/images/no_image.png b/app/frontend/images/no_image.png new file mode 100644 index 000000000..9d77d405f Binary files /dev/null and b/app/frontend/images/no_image.png differ diff --git a/app/frontend/src/javascript/api/cart.ts b/app/frontend/src/javascript/api/cart.ts new file mode 100644 index 000000000..4601de322 --- /dev/null +++ b/app/frontend/src/javascript/api/cart.ts @@ -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 { + const res: AxiosResponse = await apiClient.post('/api/cart', { order_token: token }); + return res?.data; + } + + static async addItem (order: Order, orderableId: number, quantity: number): Promise { + const res: AxiosResponse = 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 { + const res: AxiosResponse = 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 { + const res: AxiosResponse = await apiClient.put('/api/cart/set_quantity', { order_token: order.token, orderable_id: orderableId, quantity }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/checkout.ts b/app/frontend/src/javascript/api/checkout.ts new file mode 100644 index 000000000..0a7e8f34a --- /dev/null +++ b/app/frontend/src/javascript/api/checkout.ts @@ -0,0 +1,25 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { OrderPayment, Order } from '../models/order'; + +export default class CheckoutAPI { + static async payment (order: Order, paymentId?: string): Promise { + const res: AxiosResponse = await apiClient.post('/api/checkout/payment', { + order_token: order.token, + coupon_code: order.coupon?.code, + payment_id: paymentId, + customer_id: order.user.id + }); + return res?.data; + } + + static async confirmPayment (order: Order, paymentId: string): Promise { + const res: AxiosResponse = await apiClient.post('/api/checkout/confirm_payment', { + order_token: order.token, + coupon_code: order.coupon?.code, + payment_id: paymentId, + customer_id: order.user.id + }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/coupon.ts b/app/frontend/src/javascript/api/coupon.ts new file mode 100644 index 000000000..5f0dd4fd8 --- /dev/null +++ b/app/frontend/src/javascript/api/coupon.ts @@ -0,0 +1,10 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { Coupon } from '../models/coupon'; + +export default class CouponAPI { + static async validate (code: string, amount: number, userId?: number): Promise { + const res: AxiosResponse = await apiClient.post('/api/coupons/validate', { code, amount, user_id: userId }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/member.ts b/app/frontend/src/javascript/api/member.ts index 0c3697c18..e22262a68 100644 --- a/app/frontend/src/javascript/api/member.ts +++ b/app/frontend/src/javascript/api/member.ts @@ -9,6 +9,16 @@ export default class MemberAPI { return res?.data; } + static async search (name: string): Promise> { + const res: AxiosResponse> = await apiClient.get(`/api/members/search/${name}`); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/members/${id}`); + return res?.data; + } + static async create (user: User): Promise { const data = serialize({ user }); if (user.profile_attributes?.user_avatar_attributes?.attachment_files[0]) { diff --git a/app/frontend/src/javascript/api/product-category.ts b/app/frontend/src/javascript/api/product-category.ts new file mode 100644 index 000000000..2870e35a6 --- /dev/null +++ b/app/frontend/src/javascript/api/product-category.ts @@ -0,0 +1,35 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { ProductCategory } from '../models/product-category'; + +export default class ProductCategoryAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/product_categories'); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/product_categories/${id}`); + return res?.data; + } + + static async create (productCategory: ProductCategory): Promise { + const res: AxiosResponse = await apiClient.post('/api/product_categories', { product_category: productCategory }); + return res?.data; + } + + static async update (productCategory: ProductCategory): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/product_categories/${productCategory.id}`, { product_category: productCategory }); + return res?.data; + } + + static async destroy (productCategoryId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/product_categories/${productCategoryId}`); + return res?.data; + } + + static async updatePosition (productCategory: ProductCategory, position: number): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/product_categories/${productCategory.id}/position`, { position }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts new file mode 100644 index 000000000..6abf55f4a --- /dev/null +++ b/app/frontend/src/javascript/api/product.ts @@ -0,0 +1,92 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { serialize } from 'object-to-formdata'; +import { Product, ProductIndexFilter } from '../models/product'; +import ApiLib from '../lib/api'; + +export default class ProductAPI { + static async index (filters?: ProductIndexFilter): Promise> { + const res: AxiosResponse> = await apiClient.get(`/api/products${ApiLib.filtersToQuery(filters)}`); + return res?.data; + } + + static async get (id: number | string): Promise { + const res: AxiosResponse = await apiClient.get(`/api/products/${id}`); + return res?.data; + } + + static async create (product: Product): Promise { + const data = serialize({ + product: { + ...product, + product_files_attributes: null, + product_images_attributes: null + } + }); + data.delete('product[product_files_attributes]'); + data.delete('product[product_images_attributes]'); + product.product_files_attributes?.forEach((file, i) => { + if (file?.attachment_files && file?.attachment_files[0]) { + data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]); + } + }); + product.product_images_attributes?.forEach((image, i) => { + if (image?.attachment_files && image?.attachment_files[0]) { + data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]); + data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString()); + } + }); + const res: AxiosResponse = await apiClient.post('/api/products', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return res?.data; + } + + static async update (product: Product): Promise { + const data = serialize({ + product: { + ...product, + product_files_attributes: null, + product_images_attributes: null + } + }); + data.delete('product[product_files_attributes]'); + data.delete('product[product_images_attributes]'); + product.product_files_attributes?.forEach((file, i) => { + if (file?.attachment_files && file?.attachment_files[0]) { + data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]); + } + if (file?.id) { + data.set(`product[product_files_attributes][${i}][id]`, file.id.toString()); + } + if (file?._destroy) { + data.set(`product[product_files_attributes][${i}][_destroy]`, file._destroy.toString()); + } + }); + product.product_images_attributes?.forEach((image, i) => { + if (image?.attachment_files && image?.attachment_files[0]) { + data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]); + } + if (image?.id) { + data.set(`product[product_images_attributes][${i}][id]`, image.id.toString()); + } + if (image?._destroy) { + data.set(`product[product_images_attributes][${i}][_destroy]`, image._destroy.toString()); + } + data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString()); + }); + const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return res?.data; + } + + static async destroy (productId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/products/${productId}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/base/fab-input.tsx b/app/frontend/src/javascript/components/base/fab-input.tsx index b5500ff84..f7e57dc5c 100644 --- a/app/frontend/src/javascript/components/base/fab-input.tsx +++ b/app/frontend/src/javascript/components/base/fab-input.tsx @@ -36,11 +36,9 @@ export const FabInput: React.FC = ({ id, onChange, defaultValue, * If the default value changes, update the value of the input until there's no content in it. */ useEffect(() => { - if (!inputValue) { - setInputValue(defaultValue); - if (typeof onChange === 'function') { - onChange(defaultValue); - } + setInputValue(defaultValue); + if (typeof onChange === 'function') { + onChange(defaultValue); } }, [defaultValue]); diff --git a/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx b/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx index 9a1076349..c08d5eef7 100644 --- a/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx +++ b/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx @@ -12,11 +12,14 @@ import { MenuBar } from './menu-bar'; import { WarningOctagon } from 'phosphor-react'; interface FabTextEditorProps { - paragraphTools?: boolean, - content?: string, - limit?: number, + heading?: boolean, + bulletList?: boolean, + blockquote?: boolean, + link?: boolean, video?: boolean, image?: boolean, + content?: string, + limit?: number, onChange?: (content: string) => void, placeholder?: string, error?: string, @@ -30,7 +33,7 @@ export interface FabTextEditorRef { /** * This component is a WYSIWYG text editor */ -export const FabTextEditor: React.ForwardRefRenderFunction = ({ paragraphTools, content, limit = 400, video, image, onChange, placeholder, error, disabled = false }, ref: RefObject) => { +export const FabTextEditor: React.ForwardRefRenderFunction = ({ heading, bulletList, blockquote, content, limit = 400, video, image, link, onChange, placeholder, error, disabled = false }, ref: RefObject) => { const { t } = useTranslation('shared'); const placeholderText = placeholder || t('app.shared.text_editor.fab_text_editor.text_placeholder'); // TODO: Add ctrl+click on link to visit @@ -86,7 +89,7 @@ export const FabTextEditor: React.ForwardRefRenderFunction - +
{editor?.storage.characterCount.characters()} / {limit} diff --git a/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx b/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx index 1a580d949..d2fca119b 100644 --- a/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx +++ b/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx @@ -6,7 +6,10 @@ import { TextAa, TextBolder, TextItalic, TextUnderline, LinkSimpleHorizontal, Li interface MenuBarProps { editor?: Editor, - paragraphTools?: boolean, + heading?: boolean, + bulletList?: boolean, + blockquote?: boolean, + link?: boolean, video?: boolean, image?: boolean, disabled?: boolean, @@ -15,7 +18,7 @@ interface MenuBarProps { /** * This component is the menu bar for the WYSIWYG text editor */ -export const MenuBar: React.FC = ({ editor, paragraphTools, video, image, disabled = false }) => { +export const MenuBar: React.FC = ({ editor, heading, bulletList, blockquote, link, video, image, disabled = false }) => { const { t } = useTranslation('shared'); const [submenu, setSubmenu] = useState(''); @@ -142,8 +145,7 @@ export const MenuBar: React.FC = ({ editor, paragraphTools, video, return ( <>
- { paragraphTools && - (<> + {heading && + } + {bulletList && + } + {blockquote && - - ) } + { (heading || bulletList || blockquote) && } - + {link && + + } { (video || image) && } { video && (<> diff --git a/app/frontend/src/javascript/components/cart/cart-button.tsx b/app/frontend/src/javascript/components/cart/cart-button.tsx new file mode 100644 index 000000000..959342044 --- /dev/null +++ b/app/frontend/src/javascript/components/cart/cart-button.tsx @@ -0,0 +1,45 @@ +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(); + useCustomEventListener('CartUpdate', (data) => { + setCart(data); + }); + + /** + * Goto cart page + */ + const showCart = () => { + window.location.href = '/#!/cart'; + }; + + return ( +
+ + {cart?.order_items_attributes?.length} +

{t('app.public.cart_button.my_cart')}

+
+ ); +}; + +const CartButtonWrapper: React.FC = () => { + return ( + + + + ); +}; + +Application.Components.component('cartButton', react2angular(CartButtonWrapper)); diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx new file mode 100644 index 000000000..a345a8b2d --- /dev/null +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -0,0 +1,253 @@ +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 { 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'; +import { PaymentModal } from '../payment/stripe/payment-modal'; +import { PaymentMethod } from '../../models/payment'; +import { Order } from '../../models/order'; +import { MemberSelect } from '../user/member-select'; +import { CouponInput } from '../coupon/coupon-input'; +import { Coupon } from '../../models/coupon'; +import { computePriceWithCoupon } from '../../lib/coupon'; +import noImage from '../../../../images/no_image.png'; +import Switch from 'react-switch'; + +declare const Application: IApplication; + +interface StoreCartProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, + userLogin: () => void, + currentUser?: User +} + +/** + * This component shows user's cart + */ +const StoreCart: React.FC = ({ onSuccess, onError, currentUser, userLogin }) => { + const { t } = useTranslation('public'); + + const { cart, setCart } = useCart(currentUser); + const [paymentModal, setPaymentModal] = useState(false); + + /** + * 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); + }).catch(onError); + }; + }; + + /** + * Change product quantity + */ + const changeProductQuantity = (item) => { + return (e: React.BaseSyntheticEvent) => { + CartAPI.setQuantity(cart, item.orderable_id, e.target.value).then(data => { + setCart(data); + }).catch(onError); + }; + }; + + /** + * Checkout cart + */ + const checkout = () => { + if (!currentUser) { + userLogin(); + } else { + setPaymentModal(true); + } + }; + + /** + * Open/closes the payment modal + */ + const togglePaymentModal = (): void => { + setPaymentModal(!paymentModal); + }; + + /** + * Handle payment + */ + const handlePaymentSuccess = (data: Order): void => { + if (data.payment_state === 'paid') { + setPaymentModal(false); + window.location.href = '/#!/store'; + onSuccess(t('app.public.store_cart.checkout_success')); + } else { + onError(t('app.public.store_cart.checkout_error')); + } + }; + + /** + * Change cart's customer by admin/manger + */ + const handleChangeMember = (userId: number): void => { + setCart({ ...cart, user: { id: userId, role: 'member' } }); + }; + + /** + * Check if the current operator has administrative rights or is a normal member + */ + const isPrivileged = (): boolean => { + return (currentUser?.role === 'admin' || currentUser?.role === 'manager'); + }; + + /** + * Check if the current cart is empty ? + */ + const cartIsEmpty = (): boolean => { + return cart && cart.order_items_attributes.length === 0; + }; + + /** + * Toggle product offer + */ + const onSwitch = (product, checked: boolean) => { + console.log('Offer ', product.orderable_name, ': ', checked); + }; + + /** + * Apply coupon to current cart + */ + const applyCoupon = (coupon?: Coupon): void => { + if (coupon !== cart.coupon) { + setCart({ ...cart, coupon }); + } + }; + + /** + * Get the offered item total + */ + const offeredAmount = (): number => { + return cart.order_items_attributes + .filter(i => i.is_offered) + .map(i => i.amount) + .reduce((acc, curr) => acc + curr, 0); + }; + + return ( +
+
+ {cart && cartIsEmpty() &&

{t('app.public.store_cart.cart_is_empty')}

} + {cart && cart.order_items_attributes.map(item => ( +
+
+ +
+
+ {t('app.public.store_cart.reference_short')} +

{item.orderable_name}

+
+
+
+

{FormatLib.price(item.amount)}

+ / {t('app.public.store_cart.unit')} +
+ +
+ {t('app.public.store_cart.total')} +

{FormatLib.price(item.quantity * item.amount)}

+
+ + + +
+ {isPrivileged() && +
+ +
+ } +
+ ))} +
+ +
+
+

{t('app.public.store_cart.pickup')}

+

[TODO: texte venant des paramètres de la boutique…]

+
+ + {cart && !cartIsEmpty() && cart.user && +
+ +
+ } +
+ + + + {cart && !cartIsEmpty() && cart.user &&
+ 'dont need update shopping cart'} /> +
} +
+ ); +}; + +const StoreCartWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('storeCart', react2angular(StoreCartWrapper, ['onSuccess', 'onError', 'currentUser', 'userLogin'])); diff --git a/app/frontend/src/javascript/components/coupon/coupon-input.tsx b/app/frontend/src/javascript/components/coupon/coupon-input.tsx new file mode 100644 index 000000000..d3fb917a2 --- /dev/null +++ b/app/frontend/src/javascript/components/coupon/coupon-input.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FabInput } from '../base/fab-input'; +import { FabAlert } from '../base/fab-alert'; +import CouponAPI from '../../api/coupon'; +import { Coupon } from '../../models/coupon'; +import { User } from '../../models/user'; +import FormatLib from '../../lib/format'; + +interface CouponInputProps { + amount: number, + user?: User, + onChange?: (coupon?: Coupon) => void +} + +interface Message { + type: 'info' | 'warning' | 'danger', + message: string +} + +/** + * This component renders an input of coupon + */ +export const CouponInput: React.FC = ({ user, amount, onChange }) => { + const { t } = useTranslation('shared'); + const [messages, setMessages] = useState>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [coupon, setCoupon] = useState(); + + /** + * callback for validate the code + */ + const handleChange = (value: string) => { + const mgs = []; + setMessages([]); + setError(false); + setCoupon(null); + if (value) { + setLoading(true); + CouponAPI.validate(value, amount, user?.id).then((res) => { + setCoupon(res); + if (res.type === 'percent_off') { + mgs.push({ type: 'info', message: t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_PERCENT_discount', { PERCENT: res.percent_off }) }); + } else { + mgs.push({ type: 'info', message: t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_AMOUNT_CURRENCY', { AMOUNT: res.amount_off, CURRENCY: FormatLib.currencySymbol() }) }); + } + if (res.validity_per_user === 'once') { + mgs.push({ type: 'warning', message: t('app.shared.coupon_input.coupon_validity_once') }); + } + setMessages(mgs); + setLoading(false); + if (typeof onChange === 'function') { + onChange(res); + } + }).catch((err) => { + const state = err.split(':')[1].trim(); + setError(true); + setCoupon(null); + setLoading(false); + setMessages([{ type: 'danger', message: t(`app.shared.coupon_input.unable_to_apply_the_coupon_because_${state}`) }]); + onChange(null); + }); + } else { + onChange(null); + } + }; + + // input addon + const inputAddOn = () => { + if (error) { + return ; + } else { + if (loading) { + return ; + } + if (coupon) { + return ; + } + } + }; + + return ( +
+ + + {messages.map((m, i) => { + return ( + + {m.message} + + ); + })} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-checklist.tsx b/app/frontend/src/javascript/components/form/form-checklist.tsx new file mode 100644 index 000000000..ac457a248 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-checklist.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { Controller, Path, FieldPathValue } from 'react-hook-form'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FieldPath } from 'react-hook-form/dist/types/path'; +import { useTranslation } from 'react-i18next'; +import { UnpackNestedValue } from 'react-hook-form/dist/types'; +import { FormControlledComponent } from '../../models/form-component'; +import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item'; +import { FabButton } from '../base/fab-button'; + +/** + * Checklist Option format + */ +export type ChecklistOption = { value: TOptionValue, label: string }; + +interface FormChecklistProps extends FormControlledComponent, AbstractFormItemProps { + defaultValue?: Array, + options: Array>, + onChange?: (values: Array) => void, +} + +/** + * This component is a template for a checklist component to use within React Hook Form + */ +export const FormChecklist = ({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormChecklistProps) => { + const { t } = useTranslation('shared'); + + /** + * Verify if the provided option is currently ticked + */ + const isChecked = (values: Array, option: ChecklistOption): boolean => { + return !!values?.includes(option.value); + }; + + /** + * Callback triggered when a checkbox is ticked or unticked. + */ + const toggleCheckbox = (option: ChecklistOption, rhfValues: Array = [], rhfCallback: (value: Array) => void) => { + return (event: React.ChangeEvent) => { + let newValues: Array = []; + if (event.target.checked) { + newValues = rhfValues.concat(option.value); + } else { + newValues = rhfValues.filter(v => v !== option.value); + } + rhfCallback(newValues); + if (typeof onChange === 'function') { + onChange(newValues); + } + }; + }; + + /** + * Mark all options as selected + */ + const selectAll = (rhfCallback: (value: Array) => void) => { + return () => { + const newValues: Array = options.map(o => o.value); + rhfCallback(newValues); + if (typeof onChange === 'function') { + onChange(newValues); + } + }; + }; + + /** + * Mark all options as non-selected + */ + const unselectAll = (rhfCallback: (value: Array) => void) => { + return () => { + rhfCallback([]); + if (typeof onChange === 'function') { + onChange([]); + } + }; + }; + + return ( + + } + control={control} + defaultValue={defaultValue as UnpackNestedValue>>} + rules={rules} + render={({ field: { onChange, value } }) => { + return ( + <> +
+ {options.map((option, k) => { + return ( +
+ + +
+ ); + })} +
+
+ {t('app.shared.form_checklist.select_all')} + {t('app.shared.form_checklist.unselect_all')} +
+ + ); + }} /> +
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-file-upload.tsx b/app/frontend/src/javascript/components/form/form-file-upload.tsx new file mode 100644 index 000000000..4fbe89f27 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-file-upload.tsx @@ -0,0 +1,123 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Path } from 'react-hook-form'; +import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { FieldPathValue } from 'react-hook-form/dist/types/path'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormInput } from '../form/form-input'; +import { FormComponent } from '../../models/form-component'; +import { AbstractFormItemProps } from './abstract-form-item'; +import { FabButton } from '../base/fab-button'; +import { FilePdf, Trash } from 'phosphor-react'; + +export interface FileType { + id?: number, + attachment_name?: string, + attachment_url?: string +} + +interface FormFileUploadProps extends FormComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + defaultFile?: FileType, + accept?: string, + onFileChange?: (value: FileType) => void, + onFileRemove?: () => void, +} + +/** + * This component allows to upload file, in forms managed by react-hook-form. + */ +export const FormFileUpload = ({ id, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps) => { + const { t } = useTranslation('shared'); + + const [file, setFile] = useState(defaultFile); + + /** + * Check if file is selected + */ + const hasFile = (): boolean => { + return !!file?.attachment_name; + }; + + /** + * Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled). + */ + function onFileSelected (event: React.ChangeEvent) { + const f = event.target?.files[0]; + if (f) { + setFile({ + attachment_name: f.name + }); + setValue( + `${id}[_destroy]` as Path, + false as UnpackNestedValue>> + ); + if (typeof onFileChange === 'function') { + onFileChange({ attachment_name: f.name }); + } + } + } + + /** + * Callback triggered when the user clicks on the delete button. + */ + function onRemoveFile () { + if (file?.id) { + setValue( + `${id}[_destroy]` as Path, + true as UnpackNestedValue>> + ); + } + setValue( + `${id}[attachment_files]` as Path, + null as UnpackNestedValue>> + ); + setFile(null); + if (typeof onFileRemove === 'function') { + onFileRemove(); + } + } + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + /** + * Returns placeholder text + */ + const placeholder = (): string => hasFile() ? t('app.shared.form_file_upload.edit') : t('app.shared.form_file_upload.browse'); + + return ( +
+ {hasFile() && ( + {file.attachment_name} + )} +
+ {file?.id && file?.attachment_url && ( + + + + )} + + {hasFile() && + } className="is-main" /> + } +
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx new file mode 100644 index 000000000..dd3d5435d --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -0,0 +1,150 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Path } from 'react-hook-form'; +import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { FieldPathValue } from 'react-hook-form/dist/types/path'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormInput } from '../form/form-input'; +import { FormComponent } from '../../models/form-component'; +import { AbstractFormItemProps } from './abstract-form-item'; +import { FabButton } from '../base/fab-button'; +import noImage from '../../../../images/no_image.png'; +import { Trash } from 'phosphor-react'; + +export interface ImageType { + id?: number, + attachment_name?: string, + attachment_url?: string, + is_main?: boolean +} + +interface FormImageUploadProps extends FormComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + defaultImage?: ImageType, + accept?: string, + size?: 'small' | 'medium' | 'large', + mainOption?: boolean, + onFileChange?: (value: ImageType) => void, + onFileRemove?: () => void, + onFileIsMain?: () => void, +} + +/** + * This component allows to upload image, in forms managed by react-hook-form. + */ +export const FormImageUpload = ({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size, onFileIsMain, mainOption = false }: FormImageUploadProps) => { + const { t } = useTranslation('shared'); + + const [file, setFile] = useState(defaultImage); + const [image, setImage] = useState(defaultImage.attachment_url); + + useEffect(() => { + setFile(defaultImage); + }, [defaultImage]); + + /** + * Check if image is selected + */ + const hasImage = (): boolean => { + return !!file?.attachment_name; + }; + + /** + * Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled). + */ + function onFileSelected (event: React.ChangeEvent) { + const f = event.target?.files[0]; + if (f) { + const reader = new FileReader(); + reader.onload = (): void => { + setImage(reader.result); + }; + reader.readAsDataURL(f); + setFile({ + ...file, + attachment_name: f.name + }); + setValue( + `${id}[attachment_name]` as Path, + f.name as UnpackNestedValue>> + ); + setValue( + `${id}[_destroy]` as Path, + false as UnpackNestedValue>> + ); + if (typeof onFileChange === 'function') { + onFileChange({ attachment_name: f.name }); + } + } + } + + /** + * Callback triggered when the user clicks on the delete button. + */ + function onRemoveFile () { + if (file?.id) { + setValue( + `${id}[_destroy]` as Path, + true as UnpackNestedValue>> + ); + } + setValue( + `${id}[attachment_files]` as Path, + null as UnpackNestedValue>> + ); + setFile(null); + if (typeof onFileRemove === 'function') { + onFileRemove(); + } + } + + /** + * Returns placeholder text + */ + const placeholder = (): string => hasImage() ? t('app.shared.form_image_upload.edit') : t('app.shared.form_image_upload.browse'); + + /** + * Callback triggered when the user set the image is main + */ + function setMainImage () { + setValue( + `${id}[is_main]` as Path, + true as UnpackNestedValue>> + ); + onFileIsMain(); + } + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + return ( +
+
+ +
+
+ {mainOption && + + } + + {hasImage() && } className="is-main" />} +
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-input.tsx b/app/frontend/src/javascript/components/form/form-input.tsx index 7d07b6950..8f103f943 100644 --- a/app/frontend/src/javascript/components/form/form-input.tsx +++ b/app/frontend/src/javascript/components/form/form-input.tsx @@ -67,6 +67,7 @@ export const FormInput = ({ id, re disabled={typeof disabled === 'function' ? disabled(id) : disabled} placeholder={placeholder} accept={accept} /> + {(type === 'file' && placeholder) && {placeholder}} {addOn && {addOn}} ); diff --git a/app/frontend/src/javascript/components/form/form-rich-text.tsx b/app/frontend/src/javascript/components/form/form-rich-text.tsx index b5c93f986..7cef3a8c3 100644 --- a/app/frontend/src/javascript/components/form/form-rich-text.tsx +++ b/app/frontend/src/javascript/components/form/form-rich-text.tsx @@ -10,15 +10,18 @@ import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types'; interface FormRichTextProps extends FormControlledComponent, AbstractFormItemProps { valueDefault?: string, limit?: number, - paragraphTools?: boolean, + heading?: boolean, + bulletList?: boolean, + blockquote?: boolean, + link?: boolean, video?: boolean, - image?: boolean, + image?: boolean } /** * This component is a rich-text editor to use with react-hook-form. */ -export const FormRichText = ({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, paragraphTools, video, image }: FormRichTextProps) => { +export const FormRichText = ({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, heading, bulletList, blockquote, video, image, link }: FormRichTextProps) => { const textEditorRef = React.useRef(); const [isDisabled, setIsDisabled] = React.useState(false); @@ -54,9 +57,12 @@ export const FormRichText = } /> diff --git a/app/frontend/src/javascript/components/form/form-switch.tsx b/app/frontend/src/javascript/components/form/form-switch.tsx index 2deda376c..0d6979cec 100644 --- a/app/frontend/src/javascript/components/form/form-switch.tsx +++ b/app/frontend/src/javascript/components/form/form-switch.tsx @@ -41,8 +41,11 @@ export const FormSwitch = ({ id, label, t onChangeCb(val); }} checked={value as boolean || false} - height={19} width={40} + height={19} + uncheckedIcon={false} + checkedIcon={false} + handleDiameter={15} ref={ref} disabled={typeof disabled === 'function' ? disabled(id) : disabled} /> } /> diff --git a/app/frontend/src/javascript/components/machines/machines-list.tsx b/app/frontend/src/javascript/components/machines/machines-list.tsx index 9f54083cf..59c602919 100644 --- a/app/frontend/src/javascript/components/machines/machines-list.tsx +++ b/app/frontend/src/javascript/components/machines/machines-list.tsx @@ -7,6 +7,8 @@ import MachineAPI from '../../api/machine'; import { MachineCard } from './machine-card'; import { MachinesFilters } from './machines-filters'; import { User } from '../../models/user'; +import { useTranslation } from 'react-i18next'; +import { FabButton } from '../base/fab-button'; declare const Application: IApplication; @@ -25,6 +27,7 @@ interface MachinesListProps { * This component shows a list of all machines and allows filtering on that list. */ export const MachinesList: React.FC = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user, canProposePacks }) => { + const { t } = useTranslation('public'); // shown machines const [machines, setMachines] = useState>(null); // we keep the full list of machines, for filtering @@ -56,10 +59,30 @@ export const MachinesList: React.FC = ({ onError, onSuccess, setMachines(allMachines.filter(m => !!m.disabled === !status)); }; + /** + * Go to store + */ + const linkToStore = (): void => { + window.location.href = '/#!/store'; + }; + + // TODO: Conditionally display the store ad return (
+ {false && +
linkToStore}> +
+

{t('app.public.machines_list.store_ad.title')}

+

{t('app.public.machines_list.store_ad.buy')}

+

{t('app.public.machines_list.store_ad.sell')}

+
+ } className="cta" onClick={linkToStore}> + {t('app.public.machines_list.store_ad.link')} + +
+ } {machines && machines.map(machine => { return void, - onSuccess: (result: Invoice|PaymentSchedule) => void, + onSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, customer: User, operator: User, className?: string, paymentSchedule?: PaymentSchedule, cart?: ShoppingCart, + order?: Order, updateCart?: (cart: ShoppingCart) => void, formId: string, } @@ -34,9 +37,10 @@ export interface GatewayFormProps { interface AbstractPaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, updateCart?: (cart: ShoppingCart) => void, currentUser: User, schedule?: PaymentSchedule, @@ -60,7 +64,7 @@ declare const GTM: GoogleTagManager; * This component must not be called directly but must be extended for each implemented payment gateway. * @see https://reactjs.org/docs/composition-vs-inheritance.html */ -export const AbstractPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => { +export const AbstractPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize, order }) => { // customer's wallet const [wallet, setWallet] = useState(null); // server-computed price with all details @@ -107,16 +111,25 @@ export const AbstractPaymentModal: React.FC = ({ isOp * - Refresh the remaining price */ useEffect(() => { - if (!cart) return; - WalletAPI.getByUser(cart.customer_id).then((wallet) => { - setWallet(wallet); - PriceAPI.compute(cart).then((res) => { - setPrice(res); - setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price)); + if (order && order?.user?.id) { + WalletAPI.getByUser(order.user.id).then((wallet) => { + setWallet(wallet); + const p = { price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total }; + setPrice(p); + setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(p.price)); setReady(true); }); - }); - }, [cart]); + } else if (cart && cart.customer_id) { + WalletAPI.getByUser(cart.customer_id).then((wallet) => { + setWallet(wallet); + PriceAPI.compute(cart).then((res) => { + setPrice(res); + setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price)); + setReady(true); + }); + }); + } + }, [cart, order]); /** * Check if there is currently an error to display @@ -156,7 +169,7 @@ export const AbstractPaymentModal: React.FC = ({ isOp /** * After sending the form with success, process the resulting payment method */ - const handleFormSuccess = async (result: Invoice|PaymentSchedule): Promise => { + const handleFormSuccess = async (result: Invoice|PaymentSchedule|Order): Promise => { setSubmitState(false); GTM.trackPurchase(result.id, result.total); afterSuccess(result); @@ -212,6 +225,7 @@ export const AbstractPaymentModal: React.FC = ({ isOp className={`gateway-form ${formClassName || ''}`} formId={formId} cart={cart} + order={order} updateCart={updateCart} customer={customer} paymentSchedule={schedule}> diff --git a/app/frontend/src/javascript/components/payment/card-payment-modal.tsx b/app/frontend/src/javascript/components/payment/card-payment-modal.tsx index 525dd4d4d..eb6fba8e9 100644 --- a/app/frontend/src/javascript/components/payment/card-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/card-payment-modal.tsx @@ -11,15 +11,17 @@ import { Setting } from '../../models/setting'; import { Invoice } from '../../models/invoice'; import SettingAPI from '../../api/setting'; import { useTranslation } from 'react-i18next'; +import { Order } from '../../models/order'; declare const Application: IApplication; interface CardPaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, currentUser: User, schedule?: PaymentSchedule, customer: User @@ -29,7 +31,7 @@ interface CardPaymentModalProps { * This component open a modal dialog for the configured payment gateway, allowing the user to input his card data * to process an online payment. */ -const CardPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer }) => { +const CardPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer, order }) => { const { t } = useTranslation('shared'); const [gateway, setGateway] = useState(null); @@ -49,6 +51,7 @@ const CardPaymentModal: React.FC = ({ isOpen, toggleModal afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={currentUser} schedule={schedule} customer={customer} />; @@ -63,6 +66,7 @@ const CardPaymentModal: React.FC = ({ isOpen, toggleModal afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={currentUser} schedule={schedule} customer={customer} />; @@ -99,4 +103,4 @@ const CardPaymentModalWrapper: React.FC = (props) => { export { CardPaymentModalWrapper as CardPaymentModal }; -Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer'])); +Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer', 'order'])); diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx index e8f5f5799..1a78166fa 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx @@ -8,6 +8,7 @@ import SettingAPI from '../../../api/setting'; import { CardPaymentModal } from '../card-payment-modal'; import { PaymentSchedule } from '../../../models/payment-schedule'; import { HtmlTranslate } from '../../base/html-translate'; +import CheckoutAPI from '../../../api/checkout'; const ALL_SCHEDULE_METHODS = ['card', 'check', 'transfer'] as const; type scheduleMethod = typeof ALL_SCHEDULE_METHODS[number]; @@ -23,7 +24,7 @@ type selectOption = { value: scheduleMethod, label: string }; * This is intended for use by privileged users. * The form validation button must be created elsewhere, using the attribute form={formId}. */ -export const LocalPaymentForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId }) => { +export const LocalPaymentForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId, order }) => { const { t } = useTranslation('admin'); const [method, setMethod] = useState('check'); @@ -85,8 +86,14 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce } try { - const document = await LocalPaymentAPI.confirmPayment(cart); - onSuccess(document); + let res; + if (order) { + res = await CheckoutAPI.payment(order); + res = res.order; + } else { + res = await LocalPaymentAPI.confirmPayment(cart); + } + onSuccess(res); } catch (e) { onError(e); } @@ -113,6 +120,9 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce * Get the type of the main item in the cart compile */ const mainItemType = (): string => { + if (order) { + return ''; + } return Object.keys(cart.items[0])[0]; }; diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx index 9d0ee2032..ad04b105b 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx @@ -10,15 +10,17 @@ import { ModalSize } from '../../base/fab-modal'; import { Loader } from '../../base/loader'; import { react2angular } from 'react2angular'; import { IApplication } from '../../../models/application'; +import { Order } from '../../../models/order'; declare const Application: IApplication; interface LocalPaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, updateCart: (cart: ShoppingCart) => void, currentUser: User, schedule?: PaymentSchedule, @@ -28,7 +30,7 @@ interface LocalPaymentModalProps { /** * This component enables a privileged user to confirm a local payments. */ -const LocalPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer }) => { +const LocalPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, order }) => { const { t } = useTranslation('admin'); /** @@ -54,7 +56,7 @@ const LocalPaymentModal: React.FC = ({ isOpen, toggleMod /** * Integrates the LocalPaymentForm into the parent AbstractPaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children, order }) => { return ( = ({ isOpen, toggleMod className={className} formId={formId} cart={cart} + order={order} updateCart={updateCart} customer={customer} paymentSchedule={paymentSchedule}> @@ -81,6 +84,7 @@ const LocalPaymentModal: React.FC = ({ isOpen, toggleMod formClassName="local-payment-form" currentUser={currentUser} cart={cart} + order={order} updateCart={updateCart} customer={customer} afterSuccess={afterSuccess} diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx index 0c5bf8e86..61904af66 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx @@ -11,6 +11,8 @@ import { } from '../../../models/payzen'; import { PaymentSchedule } from '../../../models/payment-schedule'; import { Invoice } from '../../../models/invoice'; +import CheckoutAPI from '../../../api/checkout'; +import { Order } from '../../../models/order'; // we use these two additional parameters to update the card, if provided interface PayzenFormProps extends GatewayFormProps { @@ -21,7 +23,7 @@ interface PayzenFormProps extends GatewayFormProps { * A form component to collect the credit card details and to create the payment method on Stripe. * The form validation button must be created elsewhere, using the attribute form={formId}. */ -export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId }) => { +export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId, order }) => { const PayZenKR = useRef(null); const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader'); @@ -43,7 +45,7 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE .catch(error => onError(error)); }).catch(error => onError(error)); }); - }, [cart, paymentSchedule, customer]); + }, [cart, paymentSchedule, customer, order]); /** * Ask the API to create the form token. @@ -54,6 +56,9 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE return await PayzenAPI.updateToken(paymentSchedule?.id); } else if (paymentSchedule) { return await PayzenAPI.chargeCreateToken(cart, customer); + } else if (order) { + const res = await CheckoutAPI.payment(order); + return res.payment as CreateTokenResponse; } else { return await PayzenAPI.chargeCreatePayment(cart, customer); } @@ -87,9 +92,12 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE /** * Confirm the payment, depending on the current type of payment (single shot or recurring) */ - const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise => { + const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise => { if (paymentSchedule) { return await PayzenAPI.confirmPaymentSchedule(event.clientAnswer.orderDetails.orderId, transaction.uuid, cart); + } else if (order) { + const res = await CheckoutAPI.confirmPayment(order, event.clientAnswer.orderDetails.orderId); + return res.order; } else { return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart); } @@ -131,7 +139,9 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE try { const { result } = await PayZenKR.current.validateForm(); if (result === null) { - await PayzenAPI.checkCart(cart, customer); + if (!order) { + await PayzenAPI.checkCart(cart, customer); + } await PayZenKR.current.onSubmit(onPaid); await PayZenKR.current.onError(handleError); await PayZenKR.current.submit(); diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx index 0b6a70bf2..ea117b12f 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx @@ -9,13 +9,15 @@ import payzenLogo from '../../../../../images/payzen-secure.png'; import mastercardLogo from '../../../../../images/mastercard.png'; import visaLogo from '../../../../../images/visa.png'; import { PayzenForm } from './payzen-form'; +import { Order } from '../../../models/order'; interface PayzenModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, currentUser: User, schedule?: PaymentSchedule, customer: User @@ -28,7 +30,7 @@ interface PayzenModalProps { * This component should not be called directly. Prefer using which can handle the configuration * of a different payment gateway. */ -export const PayzenModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => { +export const PayzenModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => { /** * Return the logos, shown in the modal footer. */ @@ -45,7 +47,7 @@ export const PayzenModal: React.FC = ({ isOpen, toggleModal, a /** * Integrates the PayzenForm into the parent PaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => { return ( = ({ isOpen, toggleModal, a operator={operator} formId={formId} cart={cart} + order={order} className={className} paymentSchedule={paymentSchedule}> {children} @@ -70,6 +73,7 @@ export const PayzenModal: React.FC = ({ isOpen, toggleModal, a className="payzen-modal" currentUser={currentUser} cart={cart} + order={order} customer={customer} afterSuccess={afterSuccess} onError={onError} diff --git a/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx index 0acd1b28d..17c625343 100644 --- a/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx @@ -11,13 +11,16 @@ import { LocalPaymentModal } from '../local-payment/local-payment-modal'; import { CardPaymentModal } from '../card-payment-modal'; import PriceAPI from '../../../api/price'; import { ComputePriceResult } from '../../../models/price'; +import { Order } from '../../../models/order'; +import { computePriceWithCoupon } from '../../../lib/coupon'; interface PaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, updateCart: (cart: ShoppingCart) => void, operator: User, schedule?: PaymentSchedule, @@ -27,7 +30,7 @@ interface PaymentModalProps { /** * This component is responsible for rendering the payment modal. */ -export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer }) => { +export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer, order }) => { // the user's wallet const [wallet, setWallet] = useState(null); // the price of the cart @@ -44,10 +47,14 @@ export const PaymentModal: React.FC = ({ isOpen, toggleModal, // refresh the price when the cart changes useEffect(() => { - PriceAPI.compute(cart).then(price => { - setPrice(price); - }); - }, [cart]); + if (order) { + setPrice({ price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total }); + } else { + PriceAPI.compute(cart).then(price => { + setPrice(price); + }); + } + }, [cart, order]); // refresh the remaining price when the cart price was computed and the wallet was retrieved useEffect(() => { @@ -73,6 +80,7 @@ export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} updateCart={updateCart} currentUser={operator} customer={customer} @@ -86,6 +94,7 @@ export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={operator} customer={customer} schedule={schedule} diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx index 0343e4bb7..826246764 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx @@ -6,12 +6,14 @@ import { PaymentConfirmation } from '../../../models/payment'; import StripeAPI from '../../../api/stripe'; import { Invoice } from '../../../models/invoice'; import { PaymentSchedule } from '../../../models/payment-schedule'; +import CheckoutAPI from '../../../api/checkout'; +import { Order } from '../../../models/order'; /** * A form component to collect the credit card details and to create the payment method on Stripe. * The form validation button must be created elsewhere, using the attribute form={formId}. */ -export const StripeForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId }) => { +export const StripeForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId, order }) => { const { t } = useTranslation('shared'); const stripe = useStripe(); @@ -41,9 +43,18 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, on } else { try { if (!paymentSchedule) { - // process the normal payment pipeline, including SCA validation - const res = await StripeAPI.confirmMethod(paymentMethod.id, cart); - await handleServerConfirmation(res); + if (order) { + const res = await CheckoutAPI.payment(order, paymentMethod.id); + if (res.payment) { + await handleServerConfirmation(res.payment as PaymentConfirmation); + } else { + await handleServerConfirmation(res.order); + } + } else { + // process the normal payment pipeline, including SCA validation + const res = await StripeAPI.confirmMethod(paymentMethod.id, cart); + await handleServerConfirmation(res); + } } else { const res = await StripeAPI.setupSubscription(paymentMethod.id, cart); await handleServerConfirmation(res, paymentMethod.id); @@ -61,7 +72,7 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, on * @param paymentMethodId ID of the payment method, required only when confirming a payment schedule * @see app/controllers/api/stripe_controller.rb#confirm_payment */ - const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule, paymentMethodId?: string) => { + const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule|Order, paymentMethodId?: string) => { if ('error' in response) { if (response.error.statusText) { onError(response.error.statusText); @@ -78,8 +89,13 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, on // The card action has been handled // The PaymentIntent can be confirmed again on the server try { - const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart); - await handleServerConfirmation(confirmation); + if (order) { + const confirmation = await CheckoutAPI.confirmPayment(order, result.paymentIntent.id); + await handleServerConfirmation(confirmation.order); + } else { + const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart); + await handleServerConfirmation(confirmation); + } } catch (e) { onError(e); } diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx index f4974615f..69fcb46cf 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx @@ -10,13 +10,15 @@ import stripeLogo from '../../../../../images/powered_by_stripe.png'; import mastercardLogo from '../../../../../images/mastercard.png'; import visaLogo from '../../../../../images/visa.png'; import { Invoice } from '../../../models/invoice'; +import { Order } from '../../../models/order'; interface StripeModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, currentUser: User, schedule?: PaymentSchedule, customer: User @@ -29,7 +31,7 @@ interface StripeModalProps { * This component should not be called directly. Prefer using which can handle the configuration * of a different payment gateway. */ -export const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => { +export const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => { /** * Return the logos, shown in the modal footer. */ @@ -47,7 +49,7 @@ export const StripeModal: React.FC = ({ isOpen, toggleModal, a /** * Integrates the StripeForm into the parent PaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => { return ( = ({ isOpen, toggleModal, a className={className} formId={formId} cart={cart} + order={order} customer={customer} paymentSchedule={paymentSchedule}> {children} @@ -74,6 +77,7 @@ export const StripeModal: React.FC = ({ isOpen, toggleModal, a formClassName="stripe-form" currentUser={currentUser} cart={cart} + order={order} customer={customer} afterSuccess={afterSuccess} onError={onError} diff --git a/app/frontend/src/javascript/components/store/accordion-item.tsx b/app/frontend/src/javascript/components/store/accordion-item.tsx new file mode 100644 index 000000000..711933500 --- /dev/null +++ b/app/frontend/src/javascript/components/store/accordion-item.tsx @@ -0,0 +1,30 @@ +/* eslint-disable fabmanager/scoped-translation */ +import React, { useState, useEffect } from 'react'; +import { CaretDown } from 'phosphor-react'; + +interface AccordionItemProps { + isOpen: boolean, + onChange: (id: number, isOpen: boolean) => void, + id: number, + label: string +} + +/** + * Renders an accordion item + */ +export const AccordionItem: React.FC = ({ isOpen, onChange, id, label, children }) => { + const [state, setState] = useState(isOpen); + useEffect(() => { + onChange(id, state); + }, [state]); + + return ( +
+
setState(!state)}> + {label} + +
+ {children} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx new file mode 100644 index 000000000..71df0ce11 --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx @@ -0,0 +1,85 @@ +import { PencilSimple, Trash } from 'phosphor-react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import { FabModal, ModalSize } from '../../base/fab-modal'; +import { ProductCategoryForm } from './product-category-form'; + +interface ManageProductCategoryProps { + action: 'create' | 'update' | 'delete', + productCategories: Array, + productCategory?: ProductCategory, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows a button. + * When clicked, we show a modal dialog allowing to fill the parameters of a product category. + */ +export const ManageProductCategory: React.FC = ({ productCategories, productCategory, action, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + // is the modal open? + const [isOpen, setIsOpen] = useState(false); + + /** + * Opens/closes the product category modal + */ + const toggleModal = (): void => { + setIsOpen(!isOpen); + }; + + /** + * Close the modal if the form submission was successful + */ + const handleSuccess = (message) => { + setIsOpen(false); + onSuccess(message); + }; + + /** + * Render the appropriate button depending on the action type + */ + const toggleBtn = () => { + switch (action) { + case 'create': + return ( + + {t('app.admin.store.manage_product_category.create')} + + ); + case 'update': + return (} + className="edit-btn" + onClick={toggleModal} />); + case 'delete': + return (} + className="delete-btn" + onClick={toggleModal} />); + } + }; + + return ( +
+ { toggleBtn() } + + { (action === 'update' || action === 'delete') &&

{productCategory.name}

} + +
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx new file mode 100644 index 000000000..76e603246 --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -0,0 +1,80 @@ +// TODO: Remove next eslint-disable +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React from 'react'; +import { ProductCategory } from '../../../models/product-category'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { ManageProductCategory } from './manage-product-category'; +import { ArrowElbowDownRight, ArrowElbowLeftUp, CaretDown, DotsSixVertical } from 'phosphor-react'; + +interface ProductCategoriesItemProps { + productCategories: Array, + category: ProductCategory, + offset: 'up' | 'down' | null, + collapsed?: boolean, + handleCollapse?: (id: number) => void, + status: 'child' | 'single' | 'parent', + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * Renders a draggable category item + */ +export const ProductCategoriesItem: React.FC = ({ productCategories, category, offset, collapsed, handleCollapse, status, onSuccess, onError }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id: category.id }); + + const style = { + transition, + transform: CSS.Transform.toString(transform) + }; + + return ( +
+ {((isDragging && offset) || status === 'child') && +
+ {(offset === 'down') && } + {(offset === 'up') && } +
+ } +
+
+ {status === 'parent' &&
+ +
} +

{category.name}

+ [count] +
+
+ {!isDragging && +
+ + +
+ } +
+ +
+
+
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx new file mode 100644 index 000000000..5bd94fa7e --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -0,0 +1,343 @@ +// TODO: Remove next eslint-disable +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { useEffect, useState } from 'react'; +import { useImmer } from 'use-immer'; +import { ProductCategory } from '../../../models/product-category'; +import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { restrictToWindowEdges } from '@dnd-kit/modifiers'; +import { ProductCategoriesItem } from './product-categories-item'; + +interface ProductCategoriesTreeProps { + productCategories: Array, + onDnd: (list: Array) => void, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows a tree list of all Product's Categories + */ +export const ProductCategoriesTree: React.FC = ({ productCategories, onDnd, onSuccess, onError }) => { + const [categoriesList, setCategoriesList] = useImmer(productCategories); + const [activeData, setActiveData] = useImmer(initActiveData); + const [extractedChildren, setExtractedChildren] = useImmer({}); + const [collapsed, setCollapsed] = useImmer([]); + + // Initialize state from props + useEffect(() => { + setCategoriesList(productCategories); + }, [productCategories]); + + // @dnd-kit config + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + /** + * On drag start + * Collect dragged items' data + * Extract children from list + */ + const handleDragStart = ({ active }: DragMoveEvent) => { + const activeIndex = active.data.current.sortable.index; + const children = getChildren(active.id); + + setActiveData(draft => { + draft.index = activeIndex; + draft.category = getCategory(active.id); + draft.status = getStatus(active.id); + draft.children = children?.length ? children : null; + }); + + setExtractedChildren(draft => { draft[active.id] = children; }); + hideChildren(active.id, activeIndex); + }; + + /** + * On drag move + */ + const handleDragMove = ({ delta, active, over }: DragMoveEvent) => { + const activeStatus = getStatus(active.id); + if (activeStatus === 'single') { + if (Math.ceil(delta.x) > 32 && getStatus(over.id) !== 'child') { + setActiveData(draft => { + return { ...draft, offset: 'down' }; + }); + } else { + setActiveData(draft => { + return { ...draft, offset: null }; + }); + } + } + if (activeStatus === 'child') { + if (Math.ceil(delta.x) > 32 && getStatus(over.id) !== 'child') { + setActiveData(draft => { + return { ...draft, offset: 'down' }; + }); + } else if (Math.ceil(delta.x) < -32 && getStatus(over.id) === 'child') { + setActiveData(draft => { + return { ...draft, offset: 'up' }; + }); + } else { + setActiveData(draft => { + return { ...draft, offset: null }; + }); + } + } + }; + + /** + * On drag End + * Insert children back in list + */ + const handleDragEnd = ({ active, over }: DragMoveEvent) => { + let newOrder = [...categoriesList]; + const currentIdsOrder = over?.data.current.sortable.items; + let newIndex = over.data.current.sortable.index; + + // [A] Single |> [B] Single + if (getStatus(active.id) === 'single' && getStatus(over.id) === 'single') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (activeData.offset === 'down' && sortedId === active.id && activeData.index < newIndex && active.id !== over.id) { + category = { ...category, parent_id: Number(over.id) }; + } else if (activeData.offset === 'down' && sortedId === active.id && (activeData.index > newIndex || active.id === over.id)) { + category = { ...category, parent_id: getPreviousAdopter(over.id) }; + } + return category; + }); + } + + // [A] Child |> [B] Single + if ((getStatus(active.id) === 'child') && getStatus(over.id) === 'single') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (activeData.offset === 'down' && sortedId === active.id && activeData.index < newIndex) { + category = { ...category, parent_id: Number(over.id) }; + } else if (activeData.offset === 'down' && sortedId === active.id && activeData.index > newIndex) { + category = { ...category, parent_id: getPreviousAdopter(over.id) }; + } else if (sortedId === active.id) { + category = { ...category, parent_id: null }; + } + return category; + }); + } + + // [A] Single || Child |>… + if (getStatus(active.id) === 'single' || getStatus(active.id) === 'child') { + // [B] Parent + if (getStatus(over.id) === 'parent') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + if (activeData.index < newIndex) { + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: Number(over.id) }; + } + return category; + }); + } else if (activeData.index > newIndex) { + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id && !activeData.offset) { + category = { ...category, parent_id: null }; + } else if (sortedId === active.id && activeData.offset === 'down') { + category = { ...category, parent_id: getPreviousAdopter(over.id) }; + } + return category; + }); + } + } + // [B] Child + if (getStatus(over.id) === 'child') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: getCategory(over.id).parent_id }; + } + return category; + }); + } + } + + // [A] Parent |>… + if (getStatus(active.id) === 'parent') { + // [B] Single + if (getStatus(over.id) === 'single') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + // [B] Parent + if (getStatus(over.id) === 'parent') { + if (activeData.index < newIndex) { + const lastOverChildIndex = newOrder.findIndex(c => c.id === getChildren(over.id).pop().id); + newIndex = lastOverChildIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } else { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + } + // [B] Child + if (getStatus(over.id) === 'child') { + if (activeData.index < newIndex) { + const parent = newOrder.find(c => c.id === getCategory(over.id).parent_id); + const lastSiblingIndex = newOrder.findIndex(c => c.id === getChildren(parent.id).pop().id); + newIndex = lastSiblingIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } else { + const parentIndex = currentIdsOrder.indexOf(getCategory(over.id).parent_id); + newIndex = parentIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + } + // insert children back + newOrder = showChildren(active.id, newOrder, newIndex); + } + setActiveData(initActiveData); + onDnd(newOrder); + }; + + /** + * On drag cancel + * Reset states + */ + const handleDragCancel = ({ active }: DragMoveEvent) => { + setCategoriesList(productCategories); + setActiveData(initActiveData); + setExtractedChildren({ ...extractedChildren, [active.id]: null }); + }; + + /** + * Get a category by its id + */ + const getCategory = (id) => { + return categoriesList.find(c => c.id === id); + }; + + /** + * Get the children categories of a parent category by its id + */ + const getChildren = (id) => { + const displayedChildren = categoriesList.filter(c => c.parent_id === id); + if (displayedChildren.length) { + return displayedChildren; + } + return extractedChildren[id]; + }; + + /** + * Get previous category that can have children + */ + const getPreviousAdopter = (overId) => { + const reversedList = [...categoriesList].reverse(); + const dropIndex = reversedList.findIndex(c => c.id === overId); + const adopter = reversedList.find((c, index) => index > dropIndex && !c.parent_id)?.id; + return adopter || null; + }; + + /** + * Get category's status by its id + * child | single | parent + */ + const getStatus = (id) => { + const c = getCategory(id); + return !c.parent_id + ? getChildren(id)?.length + ? 'parent' + : 'single' + : 'child'; + }; + + /** + * Extract children from the list by their parent's id + */ + const hideChildren = (parentId, parentIndex) => { + const children = getChildren(parentId); + if (children?.length) { + const shortenList = [...categoriesList]; + shortenList.splice(parentIndex + 1, children.length); + setCategoriesList(shortenList); + } + }; + + /** + * Insert children back in the list by their parent's id + */ + const showChildren = (parentId, currentList, insertIndex) => { + if (extractedChildren[parentId]?.length) { + currentList.splice(insertIndex + 1, 0, ...extractedChildren[parentId]); + setExtractedChildren({ ...extractedChildren, [parentId]: null }); + } + return currentList; + }; + + /** + * Toggle parent category by hidding/showing its children + */ + const handleCollapse = (id) => { + const i = collapsed.findIndex(el => el === id); + if (i === -1) { + setCollapsed([...collapsed, id]); + } else { + const copy = [...collapsed]; + copy.splice(i, 1); + setCollapsed(copy); + } + }; + + return ( + + +
+ {categoriesList + .map((category) => ( + + ))} +
+
+
+ ); +}; + +interface ActiveData { + index: number, + category: ProductCategory, + status: 'child' | 'single' | 'parent', + children: ProductCategory[], + offset: 'up' | 'down' | null +} +const initActiveData: ActiveData = { + index: null, + category: null, + status: null, + children: [], + offset: null +}; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx new file mode 100644 index 000000000..c0f50f2e8 --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ProductCategory } from '../../../models/product-category'; +import ProductCategoryAPI from '../../../api/product-category'; +import { ManageProductCategory } from './manage-product-category'; +import { ProductCategoriesTree } from './product-categories-tree'; +import { FabAlert } from '../../base/fab-alert'; +import { FabButton } from '../../base/fab-button'; +import { HtmlTranslate } from '../../base/html-translate'; +import { IApplication } from '../../../models/application'; +import { Loader } from '../../base/loader'; +import { react2angular } from 'react2angular'; + +declare const Application: IApplication; + +interface ProductCategoriesProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows a tree list of all product categories and offer to manager them + * by creating, deleting, modifying and reordering each product categories. + */ +const ProductCategories: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + // List of all products' categories + const [productCategories, setProductCategories] = useState>([]); + + // load the categories list on component mount + useEffect(() => { + refreshCategories(); + }, []); + + /** + * The creation/edition/deletion was successful. + * Show the provided message and refresh the list + */ + const handleSuccess = (message: string): void => { + onSuccess(message); + refreshCategories(); + }; + + /** + * Update state after drop + */ + const handleDnd = (data: ProductCategory[]) => { + setProductCategories(data); + }; + + /** + * Refresh the list of categories + */ + const refreshCategories = () => { + ProductCategoryAPI.index().then(data => { + // Map product categories by position + const sortedCategories = data + .filter(c => !c.parent_id) + .sort((a, b) => a.position - b.position); + const childrenCategories = data + .filter(c => typeof c.parent_id === 'number') + .sort((a, b) => b.position - a.position); + childrenCategories.forEach(c => { + const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id); + sortedCategories.splice(parentIndex + 1, 0, c); + }); + setProductCategories(sortedCategories); + }).catch((error) => onError(error)); + }; + + /** + * Save list's new order + */ + const handleSave = () => { + // TODO: index to position -> send to API + console.log('save order:', productCategories); + }; + + return ( +
+
+

{t('app.admin.store.product_categories.title')}

+
+ + Save +
+
+ + + + +
+ ); +}; + +const ProductCategoriesWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('productCategories', react2angular(ProductCategoriesWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx new file mode 100644 index 000000000..2729d966e --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx @@ -0,0 +1,131 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import slugify from 'slugify'; +import { FormInput } from '../../form/form-input'; +import { FormSelect } from '../../form/form-select'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import { FabAlert } from '../../base/fab-alert'; +import ProductCategoryAPI from '../../../api/product-category'; + +interface ProductCategoryFormProps { + action: 'create' | 'update' | 'delete', + productCategories: Array, + productCategory?: ProductCategory, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ + type selectOption = { value: number, label: string }; + +/** + * Form to create/edit/delete a product category + */ +export const ProductCategoryForm: React.FC = ({ action, productCategories, productCategory, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const { register, watch, setValue, control, handleSubmit, formState } = useForm({ defaultValues: { ...productCategory } }); + + // filter all first level product categorie + let parents = productCategories.filter(c => !c.parent_id); + if (action === 'update') { + parents = parents.filter(c => c.id !== productCategory.id); + } + + /** + * Convert all parents to the react-select format + */ + const buildOptions = (): Array => { + const options = parents.map(t => { + return { value: t.id, label: t.name }; + }); + if (action === 'update') { + options.unshift({ value: null, label: t('app.admin.store.product_category_form.no_parent') }); + } + return options; + }; + + // Create slug from category's name + useEffect(() => { + const subscription = watch((value, { name }) => { + if (name === 'name') { + const _slug = slugify(value.name, { lower: true }); + setValue('slug', _slug); + } + }); + return () => subscription.unsubscribe(); + }, [watch]); + // Check slug pattern + // Only lowercase alphanumeric groups of characters separated by an hyphen + const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/g; + + // Form submit + const onSubmit: SubmitHandler = (category: ProductCategory) => { + switch (action) { + case 'create': + ProductCategoryAPI.create(category).then(() => { + onSuccess(t('app.admin.store.product_category_form.create.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.create.error') + error); + }); + break; + case 'update': + ProductCategoryAPI.update(category).then(() => { + onSuccess(t('app.admin.store.product_category_form.update.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.update.error') + error); + }); + break; + case 'delete': + ProductCategoryAPI.destroy(category.id).then(() => { + onSuccess(t('app.admin.store.product_category_form.delete.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.delete.error') + error); + }); + break; + } + }; + + return ( +
+ { action === 'delete' + ? <> + + {t('app.admin.store.product_category_form.delete.confirm')} + + {t('app.admin.store.product_category_form.save')} + + : <> + + + + {t('app.admin.store.product_category_form.save')} + + } + + ); +}; diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx new file mode 100644 index 000000000..27ef76e1f --- /dev/null +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -0,0 +1,58 @@ +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 { ProductForm } from './product-form'; +import { Product } from '../../models/product'; +import ProductAPI from '../../api/product'; + +declare const Application: IApplication; + +interface EditProductProps { + productId: number, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component show edit product form + */ +const EditProduct: React.FC = ({ productId, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [product, setProduct] = useState(); + + useEffect(() => { + ProductAPI.get(productId).then(data => { + setProduct(data); + }).catch(onError); + }, []); + + /** + * Success to save product and return to product list + */ + const saveProductSuccess = () => { + onSuccess(t('app.admin.store.edit_product.successfully_updated')); + window.location.href = '/#!/admin/store/products'; + }; + + if (product) { + return ( +
+ +
+ ); + } + return null; +}; + +const EditProductWrapper: React.FC = ({ productId, onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx new file mode 100644 index 000000000..5747e50ed --- /dev/null +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { ProductForm } from './product-form'; + +declare const Application: IApplication; + +interface NewProductProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component show new product form + */ +const NewProduct: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const product = { + id: undefined, + name: '', + slug: '', + sku: '', + description: '', + is_active: false, + quantity_min: 1, + stock: { + internal: 0, + external: 0 + }, + low_stock_alert: false, + machine_ids: [], + product_files_attributes: [], + product_images_attributes: [] + }; + + /** + * Success to save product and return to product list + */ + const saveProductSuccess = () => { + onSuccess(t('app.admin.store.new_product.successfully_created')); + window.location.href = '/#!/admin/store/products'; + }; + + return ( +
+ +
+ ); +}; + +const NewProductWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/order-item.tsx b/app/frontend/src/javascript/components/store/order-item.tsx new file mode 100644 index 000000000..ecf50222b --- /dev/null +++ b/app/frontend/src/javascript/components/store/order-item.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Order } from '../../models/order'; +import FormatLib from '../../lib/format'; +import { FabButton } from '../base/fab-button'; + +interface OrderItemProps { + order?: Order + statusColor: string +} + +/** + * List item for an order + */ +export const OrderItem: React.FC = ({ order, statusColor }) => { + const { t } = useTranslation('admin'); + /** + * Go to order page + */ + const showOrder = (token: string) => { + window.location.href = `/#!/admin/store/o/${token}`; + }; + + return ( +
+

order.token

+ order.state +
+ {t('app.admin.store.order_item.client')} +

order.user.name

+
+

order.created_at

+
+ {t('app.admin.store.order_item.total')} +

{FormatLib.price(order?.total)}

+
+ showOrder('orderToken')} icon={} className="is-black" /> +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/orders-dashboard.tsx b/app/frontend/src/javascript/components/store/orders-dashboard.tsx new file mode 100644 index 000000000..8f0c26bb2 --- /dev/null +++ b/app/frontend/src/javascript/components/store/orders-dashboard.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { StoreListHeader } from './store-list-header'; + +declare const Application: IApplication; + +interface OrdersDashboardProps { + onError: (message: string) => void +} +/** +* Option format, expected by react-select +* @see https://github.com/JedWatson/react-select +*/ +type selectOption = { value: number, label: string }; + +/** + * This component shows a list of all orders from the store for the current user + */ +// TODO: delete next eslint disable +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const OrdersDashboard: React.FC = ({ onError }) => { + const { t } = useTranslation('public'); + + /** + * Creates sorting options to the react-select format + */ + const buildOptions = (): Array => { + return [ + { value: 0, label: t('app.public.store.orders_dashboard.sort.newest') }, + { value: 1, label: t('app.public.store.orders_dashboard.sort.oldest') } + ]; + }; + /** + * Display option: sorting + */ + const handleSorting = (option: selectOption) => { + console.log('Sort option:', option); + }; + + return ( +
+
+

{t('app.public.store.orders_dashboard.heading')}

+
+ +
+ +
+
+ ); +}; + +const OrdersDashboardWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('ordersDashboard', react2angular(OrdersDashboardWrapper, ['onError'])); diff --git a/app/frontend/src/javascript/components/store/orders.tsx b/app/frontend/src/javascript/components/store/orders.tsx new file mode 100644 index 000000000..6781cc120 --- /dev/null +++ b/app/frontend/src/javascript/components/store/orders.tsx @@ -0,0 +1,242 @@ +import React, { useState, useEffect } from 'react'; +import { useImmer } from 'use-immer'; +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 { StoreListHeader } from './store-list-header'; +import { AccordionItem } from './accordion-item'; +import { OrderItem } from './order-item'; +import { MemberSelect } from '../user/member-select'; + +declare const Application: IApplication; + +interface OrdersProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} +/** +* Option format, expected by react-select +* @see https://github.com/JedWatson/react-select +*/ +type selectOption = { value: number, label: string }; + +/** +* Option format, expected by checklist +*/ +type checklistOption = { value: number, label: string }; + +/** + * Admin list of orders + */ +// TODO: delete next eslint disable +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const Orders: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [filters, setFilters] = useImmer(initFilters); + const [clearFilters, setClearFilters] = useState(false); + const [accordion, setAccordion] = useState({}); + + useEffect(() => { + applyFilters(); + setClearFilters(false); + }, [clearFilters]); + + /** + * Create a new order + */ + const newOrder = () => { + console.log('Create new order'); + }; + + const statusOptions: checklistOption[] = [ + { value: 0, label: t('app.admin.store.orders.status.error') }, + { value: 1, label: t('app.admin.store.orders.status.canceled') }, + { value: 2, label: t('app.admin.store.orders.status.pending') }, + { value: 3, label: t('app.admin.store.orders.status.under_preparation') }, + { value: 4, label: t('app.admin.store.orders.status.paid') }, + { value: 5, label: t('app.admin.store.orders.status.ready') }, + { value: 6, label: t('app.admin.store.orders.status.collected') }, + { value: 7, label: t('app.admin.store.orders.status.refunded') } + ]; + + /** + * Apply filters + */ + const applyFilters = () => { + console.log('Apply filters:', filters); + }; + + /** + * Clear filters + */ + const clearAllFilters = () => { + setFilters(initFilters); + setClearFilters(true); + console.log('Clear all filters'); + }; + + /** + * Creates sorting options to the react-select format + */ + const buildOptions = (): Array => { + return [ + { value: 0, label: t('app.admin.store.orders.sort.newest') }, + { value: 1, label: t('app.admin.store.orders.sort.oldest') } + ]; + }; + /** + * Display option: sorting + */ + const handleSorting = (option: selectOption) => { + console.log('Sort option:', option); + }; + + /** + * Filter: by status + */ + const handleSelectStatus = (s: checklistOption, checked) => { + const list = [...filters.status]; + checked + ? list.push(s) + : list.splice(list.indexOf(s), 1); + setFilters(draft => { + return { ...draft, status: list }; + }); + }; + + /** + * Filter: by member + */ + const handleSelectMember = (userId: number) => { + setFilters(draft => { + return { ...draft, memberId: userId }; + }); + }; + + /** + * Open/close accordion items + */ + const handleAccordion = (id, state) => { + setAccordion({ ...accordion, [id]: state }); + }; + + /** + * Returns a className according to the status + */ + const statusColor = (status: string) => { + switch (status) { + case 'error': + return 'error'; + case 'canceled': + return 'canceled'; + case 'pending' || 'under_preparation': + return 'pending'; + default: + return 'normal'; + } + }; + + return ( +
+
+

{t('app.admin.store.orders.heading')}

+ {false && +
+ {t('app.admin.store.orders.create_order')} +
+ } +
+ +
+
+

{t('app.admin.store.orders.filter')}

+
+ {t('app.admin.store.orders.filter_clear')} +
+
+
+ +
+
+ + {t('app.admin.store.orders.filter_apply')} +
+
+
+ +
+
+ {statusOptions.map(s => ( + + ))} +
+ {t('app.admin.store.orders.filter_apply')} +
+
+ +
+
+ + {t('app.admin.store.orders.filter_apply')} +
+
+
+
+
+ +
+ +
+ + + + +
+
+
+ ); +}; + +const OrdersWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('orders', react2angular(OrdersWrapper, ['onSuccess', 'onError'])); + +interface Filters { + reference: string, + status: checklistOption[], + memberId: number +} + +const initFilters: Filters = { + reference: '', + status: [], + memberId: null +}; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx new file mode 100644 index 000000000..7839ed8b2 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -0,0 +1,419 @@ +import React, { useEffect, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import slugify from 'slugify'; +import _ from 'lodash'; +import { HtmlTranslate } from '../base/html-translate'; +import { Product } from '../../models/product'; +import { FormInput } from '../form/form-input'; +import { FormSwitch } from '../form/form-switch'; +import { FormSelect } from '../form/form-select'; +import { FormChecklist } from '../form/form-checklist'; +import { FormRichText } from '../form/form-rich-text'; +import { FormFileUpload } from '../form/form-file-upload'; +import { FormImageUpload } from '../form/form-image-upload'; +import { FabButton } from '../base/fab-button'; +import { FabAlert } from '../base/fab-alert'; +import ProductCategoryAPI from '../../api/product-category'; +import MachineAPI from '../../api/machine'; +import ProductAPI from '../../api/product'; +import { Plus } from 'phosphor-react'; +import { ProductStockForm } from './product-stock-form'; + +interface ProductFormProps { + product: Product, + title: string, + onSuccess: (product: Product) => void, + onError: (message: string) => void, +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: number, label: string }; + +/** + * Option format, expected by checklist + */ +type checklistOption = { value: number, label: string }; + +/** + * Form component to create or update a product + */ +export const ProductForm: React.FC = ({ product, title, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const { handleSubmit, register, control, formState, setValue, reset } = useForm({ defaultValues: { ...product } }); + const output = useWatch({ control }); + const [isActivePrice, setIsActivePrice] = useState(product.id && _.isFinite(product.amount) && product.amount > 0); + const [productCategories, setProductCategories] = useState([]); + const [machines, setMachines] = useState([]); + const [stockTab, setStockTab] = useState(false); + + useEffect(() => { + ProductCategoryAPI.index().then(data => { + // Map product categories by position + const sortedCategories = data + .filter(c => !c.parent_id) + .sort((a, b) => a.position - b.position); + const childrenCategories = data + .filter(c => typeof c.parent_id === 'number') + .sort((a, b) => b.position - a.position); + childrenCategories.forEach(c => { + const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id); + sortedCategories.splice(parentIndex + 1, 0, c); + }); + setProductCategories(buildSelectOptions(sortedCategories)); + }).catch(onError); + MachineAPI.index({ disabled: false }).then(data => { + setMachines(buildChecklistOptions(data)); + }).catch(onError); + }, []); + + /** + * Convert the provided array of items to the react-select format + */ + const buildSelectOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Convert the provided array of items to the checklist format + */ + const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Callback triggered when the name has changed. + */ + const handleNameChange = (event: React.ChangeEvent): void => { + const name = event.target.value; + const slug = slugify(name, { lower: true, strict: true }); + setValue('slug', slug); + }; + + /** + * Callback triggered when is active price has changed. + */ + const toggleIsActivePrice = (value: boolean) => { + if (!value) { + setValue('amount', null); + } + setIsActivePrice(value); + }; + + /** + * Callback triggered when the form is submitted: process with the product creation or update. + */ + const onSubmit = (event: React.FormEvent) => { + return handleSubmit((data: Product) => { + saveProduct(data); + })(event); + }; + + /** + * Call product creation or update api + */ + const saveProduct = (data: Product) => { + if (product.id) { + ProductAPI.update(data).then((res) => { + reset(res); + onSuccess(res); + }).catch(onError); + } else { + ProductAPI.create(data).then((res) => { + reset(res); + onSuccess(res); + }).catch(onError); + } + }; + + /** + * Add new product file + */ + const addProductFile = () => { + setValue('product_files_attributes', output.product_files_attributes.concat({})); + }; + + /** + * Remove a product file + */ + const handleRemoveProductFile = (i: number) => { + return () => { + const productFile = output.product_files_attributes[i]; + if (!productFile.id) { + output.product_files_attributes.splice(i, 1); + setValue('product_files_attributes', output.product_files_attributes); + } + }; + }; + + /** + * Add new product image + */ + const addProductImage = () => { + setValue('product_images_attributes', output.product_images_attributes.concat({ + is_main: output.product_images_attributes.length === 0 + })); + }; + + /** + * Remove a product image + */ + const handleRemoveProductImage = (i: number) => { + return () => { + const productImage = output.product_images_attributes[i]; + if (!productImage.id) { + output.product_images_attributes.splice(i, 1); + if (productImage.is_main) { + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (k === 0) { + return { + ...image, + is_main: true + }; + } + return image; + })); + } else { + setValue('product_images_attributes', output.product_images_attributes); + } + } else { + if (productImage.is_main) { + let mainImage = false; + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (i !== k && !mainImage) { + mainImage = true; + return { + ...image, + _destroy: i === k, + is_main: true + }; + } + return { + ...image, + _destroy: i === k + }; + })); + } + } + }; + }; + + /** + * Remove main image in others product images + */ + const handleSetMainImage = (i: number) => { + return () => { + if (output.product_images_attributes.length > 1) { + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (i !== k) { + return { + ...image, + is_main: false + }; + } + return { + ...image, + is_main: true + }; + })); + } + }; + }; + + return ( + <> +
+

{title}

+
+ {t('app.admin.store.product_form.save')} +
+
+
+
+

setStockTab(false)}>{t('app.admin.store.product_form.product_parameters')}

+

setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}

+
+ {stockTab + ? + :
+
+ + +
+
+ + +
+ +
+ +
+
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

+ +
+ {isActivePrice &&
+ + +
} +
+ +
+ +
+

{t('app.admin.store.product_form.product_images')}

+ + + +
+
+ {output.product_images_attributes.map((image, i) => ( + + ))} +
+ }> + {t('app.admin.store.product_form.add_product_image')} + +
+
+ +
+ +
+

{t('app.admin.store.product_form.assigning_category')}

+ + + + +
+ +
+ +
+

{t('app.admin.store.product_form.assigning_machines')}

+ + + + +
+ +
+ +
+

{t('app.admin.store.product_form.product_description')}

+ + + + +
+ +
+ +
+

{t('app.admin.store.product_form.product_files')}

+ + + +
+
+ {output.product_files_attributes.map((file, i) => ( + + ))} +
+ }> + {t('app.admin.store.product_form.add_product_file')} + +
+
+ +
+ {t('app.admin.store.product_form.save')} +
+
+ } + + + ); +}; diff --git a/app/frontend/src/javascript/components/store/product-item.tsx b/app/frontend/src/javascript/components/store/product-item.tsx new file mode 100644 index 000000000..47022bb61 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-item.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import FormatLib from '../../lib/format'; +import { FabButton } from '../base/fab-button'; +import { Product } from '../../models/product'; +import { PencilSimple, Trash } from 'phosphor-react'; +import noImage from '../../../../images/no_image.png'; + +interface ProductItemProps { + product: Product, + onEdit: (product: Product) => void, + onDelete: (productId: number) => void, +} + +/** + * This component shows a product item in the admin view + */ +export const ProductItem: React.FC = ({ product, onEdit, onDelete }) => { + const { t } = useTranslation('admin'); + + /** + * Get the main image + */ + const thumbnail = () => { + const image = product.product_images_attributes + .find(att => att.is_main); + return image; + }; + /** + * Init the process of editing the given product + */ + const editProduct = (product: Product): () => void => { + return (): void => { + onEdit(product); + }; + }; + + /** + * Init the process of delete the given product + */ + const deleteProduct = (productId: number): () => void => { + return (): void => { + onDelete(productId); + }; + }; + + /** + * Returns CSS class from stock status + */ + const statusColor = (product: Product) => { + if (product.stock.external === 0 && product.stock.internal === 0) { + return 'out-of-stock'; + } + if (product.low_stock_threshold && (product.stock.external < product.low_stock_threshold || product.stock.internal < product.low_stock_threshold)) { + return 'low'; + } + }; + + return ( +
+
+ {/* TODO: image size version ? */} + +

{product.name}

+
+
+ + {product.is_active + ? t('app.admin.store.product_item.visible') + : t('app.admin.store.product_item.hidden') + } + +
+ {t('app.admin.store.product_item.stock.internal')} +

{product.stock.internal}

+
+
+ {t('app.admin.store.product_item.stock.external')} +

{product.stock.external}

+
+ {product.amount && +
+

{FormatLib.price(product.amount)}

+ / {t('app.admin.store.product_item.unit')} +
+ } +
+
+
+ + + + + + +
+
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/product-stock-form.tsx b/app/frontend/src/javascript/components/store/product-stock-form.tsx new file mode 100644 index 000000000..f18cea823 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-stock-form.tsx @@ -0,0 +1,208 @@ +import React, { useState } from 'react'; +import { Product } from '../../models/product'; +import { UseFormRegister } from 'react-hook-form'; +import { Control, FormState } from 'react-hook-form/dist/types/form'; +import { useTranslation } from 'react-i18next'; +import { HtmlTranslate } from '../base/html-translate'; +import { FormSwitch } from '../form/form-switch'; +import { FormInput } from '../form/form-input'; +import Select from 'react-select'; +import { FabAlert } from '../base/fab-alert'; +import { FabButton } from '../base/fab-button'; +import { PencilSimple } from 'phosphor-react'; +import { FabModal, ModalSize } from '../base/fab-modal'; +import { ProductStockModal } from './product-stock-modal'; + +interface ProductStockFormProps { + product: Product, + register: UseFormRegister, + control: Control, + formState: FormState, + onSuccess: (product: Product) => void, + onError: (message: string) => void, +} + +/** + * Form tab to manage a product's stock + */ +export const ProductStockForm = ({ product, register, control, formState, onError, onSuccess }: ProductStockFormProps) => { + const { t } = useTranslation('admin'); + + const [activeThreshold, setActiveThreshold] = useState(false); + // is the modal open? + const [isOpen, setIsOpen] = useState(false); + + // Styles the React-select component + const customStyles = { + control: base => ({ + ...base, + width: '20ch', + border: 'none', + backgroundColor: 'transparent' + }), + indicatorSeparator: () => ({ + display: 'none' + }) + }; + + type selectOption = { value: number, label: string }; + /** + * Creates sorting options to the react-select format + */ + const buildEventsOptions = (): Array => { + return [ + { value: 0, label: t('app.admin.store.product_stock_form.events.inward_stock') }, + { value: 1, label: t('app.admin.store.product_stock_form.events.returned') }, + { value: 2, label: t('app.admin.store.product_stock_form.events.canceled') }, + { value: 3, label: t('app.admin.store.product_stock_form.events.sold') }, + { value: 4, label: t('app.admin.store.product_stock_form.events.missing') }, + { value: 5, label: t('app.admin.store.product_stock_form.events.damaged') } + ]; + }; + /** + * Creates sorting options to the react-select format + */ + const buildStocksOptions = (): Array => { + return [ + { value: 0, label: t('app.admin.store.product_stock_form.internal') }, + { value: 1, label: t('app.admin.store.product_stock_form.external') }, + { value: 2, label: t('app.admin.store.product_stock_form.all') } + ]; + }; + + /** + * On events option change + */ + const eventsOptionsChange = (evt: selectOption) => { + console.log('Event option:', evt); + }; + /** + * On stocks option change + */ + const stocksOptionsChange = (evt: selectOption) => { + console.log('Stocks option:', evt); + }; + + /** + * Toggle stock threshold + */ + const toggleStockThreshold = (checked: boolean) => { + setActiveThreshold(checked); + }; + + /** + * Opens/closes the product category modal + */ + const toggleModal = (): void => { + setIsOpen(!isOpen); + }; + + /** + * Toggle stock threshold alert + */ + const toggleStockThresholdAlert = (checked: boolean) => { + console.log('Low stock notification:', checked); + }; + + return ( +
+

Stock à jour 00/00/0000 - 00H30

+
+
+

Product name

+
+ {t('app.admin.store.product_stock_form.internal')} +

00

+
+
+ {t('app.admin.store.product_stock_form.external')} +

000

+
+ } className="is-black">Modifier +
+
+ +
+
+

{t('app.admin.store.product_stock_form.low_stock_threshold')}

+ +
+ + + + {activeThreshold && <> + {t('app.admin.store.product_stock_form.low_stock')} +
+ + +
+ } +
+
+ +
+

{t('app.admin.store.product_stock_form.events_history')}

+
+
+

{t('app.admin.store.product_stock_form.event_type')}

+ stocksOptionsChange(evt)} + styles={customStyles} + /> +
+
+
+
+

Product name

+

00/00/0000

+
+ [stock type] +

00

+
+
+ {t('app.admin.store.product_stock_form.event_type')} +

[event type]

+
+
+ {t('app.admin.store.product_stock_form.stock_level')} +

000

+
+
+
+
+ + + + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/product-stock-modal.tsx b/app/frontend/src/javascript/components/store/product-stock-modal.tsx new file mode 100644 index 000000000..132f52a87 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-stock-modal.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Product } from '../../models/product'; +import { UseFormRegister } from 'react-hook-form'; +import { Control, FormState } from 'react-hook-form/dist/types/form'; +import { FormSelect } from '../form/form-select'; +import { FormInput } from '../form/form-input'; +import { FabButton } from '../base/fab-button'; + +type selectOption = { value: number, label: string }; + +interface ProductStockModalProps { + product: Product, + register: UseFormRegister, + control: Control, + formState: FormState, + onSuccess: (product: Product) => void, + onError: (message: string) => void +} + +/** + * Form to manage a product's stock movement and quantity + */ +// TODO: delete next eslint disable +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const ProductStockModal = ({ product, register, control, formState, onError, onSuccess }: ProductStockModalProps) => { + const { t } = useTranslation('admin'); + + const [movement, setMovement] = useState<'in' | 'out'>('in'); + + /** + * Toggle between adding or removing product from stock + */ + const toggleMovementType = (evt: React.MouseEvent, type: 'in' | 'out') => { + evt.preventDefault(); + setMovement(type); + }; + + /** + * Creates sorting options to the react-select format + */ + const buildEventsOptions = (): Array => { + let options = []; + movement === 'in' + ? options = [ + { value: 0, label: t('app.admin.store.product_stock_modal.events.inward_stock') }, + { value: 1, label: t('app.admin.store.product_stock_modal.events.returned') }, + { value: 2, label: t('app.admin.store.product_stock_modal.events.canceled') } + ] + : options = [ + { value: 0, label: t('app.admin.store.product_stock_modal.events.sold') }, + { value: 1, label: t('app.admin.store.product_stock_modal.events.missing') }, + { value: 2, label: t('app.admin.store.product_stock_modal.events.damaged') } + ]; + return options; + }; + /** + * Creates sorting options to the react-select format + */ + const buildStocksOptions = (): Array => { + return [ + { value: 0, label: t('app.admin.store.product_stock_modal.internal') }, + { value: 1, label: t('app.admin.store.product_stock_modal.external') } + ]; + }; + + return ( +
+

{t('app.admin.store.product_stock_modal.new_event')}

+
+ + +
+ + + + {t('app.admin.store.product_stock_modal.update_stock')} + + ); +}; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx new file mode 100644 index 000000000..c1ab15dad --- /dev/null +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -0,0 +1,366 @@ +import React, { useState, useEffect } from 'react'; +import { useImmer } from 'use-immer'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { Product } from '../../models/product'; +import { ProductCategory } from '../../models/product-category'; +import { FabButton } from '../base/fab-button'; +import { ProductItem } from './product-item'; +import ProductAPI from '../../api/product'; +import ProductCategoryAPI from '../../api/product-category'; +import MachineAPI from '../../api/machine'; +import { AccordionItem } from './accordion-item'; +import { X } from 'phosphor-react'; +import { StoreListHeader } from './store-list-header'; + +declare const Application: IApplication; + +interface ProductsProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ + type selectOption = { value: number, label: string }; + +/** + * This component shows the admin view of the store + */ +const Products: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [products, setProducts] = useState>([]); + const [filteredProductsList, setFilteredProductList] = useImmer>([]); + const [features, setFeatures] = useImmer(initFilters); + const [filterVisible, setFilterVisible] = useState(false); + const [filters, setFilters] = useImmer(initFilters); + const [clearFilters, setClearFilters] = useState(false); + const [productCategories, setProductCategories] = useState([]); + const [machines, setMachines] = useState([]); + const [update, setUpdate] = useState(false); + const [accordion, setAccordion] = useState({}); + + useEffect(() => { + ProductAPI.index().then(data => { + setProducts(data); + setFilteredProductList(data); + }); + + ProductCategoryAPI.index().then(data => { + // Map product categories by position + const sortedCategories = data + .filter(c => !c.parent_id) + .sort((a, b) => a.position - b.position); + const childrenCategories = data + .filter(c => typeof c.parent_id === 'number') + .sort((a, b) => b.position - a.position); + childrenCategories.forEach(c => { + const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id); + sortedCategories.splice(parentIndex + 1, 0, c); + }); + setProductCategories(sortedCategories); + }).catch(onError); + + MachineAPI.index({ disabled: false }).then(data => { + setMachines(buildChecklistOptions(data)); + }).catch(onError); + }, []); + + useEffect(() => { + applyFilters(); + setClearFilters(false); + setUpdate(false); + }, [filterVisible, clearFilters, update === true]); + + /** + * Goto edit product page + */ + const editProduct = (product: Product) => { + window.location.href = `/#!/admin/store/products/${product.id}/edit`; + }; + + /** + * Delete a product + */ + const deleteProduct = async (productId: number): Promise => { + try { + await ProductAPI.destroy(productId); + const data = await ProductAPI.index(); + setProducts(data); + onSuccess(t('app.admin.store.products.successfully_deleted')); + } catch (e) { + onError(t('app.admin.store.products.unable_to_delete') + e); + } + }; + + /** + * Goto new product page + */ + const newProduct = (): void => { + window.location.href = '/#!/admin/store/products/new'; + }; + + /** + * Filter: toggle non-available products visibility + */ + const toggleVisible = (checked: boolean) => { + setFilterVisible(!filterVisible); + console.log('Display on the shelf product only:', checked); + }; + + /** + * Filter: by categories + */ + const handleSelectCategory = (c: ProductCategory, checked: boolean, instantUpdate?: boolean) => { + let list = [...filters.categories]; + const children = productCategories + .filter(el => el.parent_id === c.id); + const siblings = productCategories + .filter(el => el.parent_id === c.parent_id && el.parent_id !== null); + + if (checked) { + list.push(c); + if (children.length) { + const unique = Array.from(new Set([...list, ...children])); + list = [...unique]; + } + if (siblings.length && siblings.every(el => list.includes(el))) { + list.push(productCategories.find(p => p.id === siblings[0].parent_id)); + } + } else { + list.splice(list.indexOf(c), 1); + const parent = productCategories.find(p => p.id === c.parent_id); + if (c.parent_id && list.includes(parent)) { + list.splice(list.indexOf(parent), 1); + } + if (children.length) { + children.forEach(child => { + list.splice(list.indexOf(child), 1); + }); + } + } + setFilters(draft => { + return { ...draft, categories: list }; + }); + if (instantUpdate) { + setUpdate(true); + } + }; + + /** + * Filter: by machines + */ + const handleSelectMachine = (m: checklistOption, checked, instantUpdate?) => { + const list = [...filters.machines]; + checked + ? list.push(m) + : list.splice(list.indexOf(m), 1); + setFilters(draft => { + return { ...draft, machines: list }; + }); + if (instantUpdate) { + setUpdate(true); + } + }; + + /** + * Display option: sorting + */ + const handleSorting = (option: selectOption) => { + console.log('Sort option:', option); + }; + + /** + * Apply filters + */ + const applyFilters = () => { + let tags = initFilters; + + if (filters.categories.length) { + tags = { ...tags, categories: [...filters.categories] }; + } + + if (filters.machines.length) { + tags = { ...tags, machines: [...filters.machines] }; + } + + setFeatures(tags); + console.log('Apply filters:', filters); + }; + + /** + * Clear filters + */ + const clearAllFilters = () => { + setFilters(initFilters); + setClearFilters(true); + console.log('Clear all filters'); + }; + + /** + * Creates sorting options to the react-select format + */ + const buildOptions = (): Array => { + return [ + { value: 0, label: t('app.admin.store.products.sort.name_az') }, + { value: 1, label: t('app.admin.store.products.sort.name_za') }, + { value: 2, label: t('app.admin.store.products.sort.price_low') }, + { value: 3, label: t('app.admin.store.products.sort.price_high') } + ]; + }; + + /** + * Open/close accordion items + */ + const handleAccordion = (id, state) => { + setAccordion({ ...accordion, [id]: state }); + }; + + return ( +
+
+

{t('app.admin.store.products.all_products')}

+
+ {t('app.admin.store.products.create_a_product')} +
+
+
+
+

{t('app.admin.store.products.filter')}

+
+ {t('app.admin.store.products.filter_clear')} +
+
+
+ +
+
+ {productCategories.map(pc => ( + + ))} +
+ setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')} +
+
+ + +
+
+ {machines.map(m => ( + + ))} +
+ setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')} +
+
+
+
+
+ +
+ {features.categories.map(c => ( +
+

{c.name}

+ +
+ ))} + {features.machines.map(m => ( +
+

{m.label}

+ +
+ ))} +
+ +
+ {filteredProductsList.map((product) => ( + + ))} +
+
+
+ ); +}; + +const ProductsWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError'])); + +/** + * Option format, expected by checklist + */ +type checklistOption = { value: number, label: string }; + +/** + * Convert the provided array of items to the checklist format + */ +const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); +}; + +interface Stock { + from: number, + to: number +} + +interface Filters { + instant: boolean, + categories: ProductCategory[], + machines: checklistOption[], + keywords: string[], + internalStock: Stock, + externalStock: Stock +} + +const initFilters: Filters = { + instant: false, + categories: [], + machines: [], + keywords: [], + internalStock: { + from: 0, + to: null + }, + externalStock: { + from: 0, + to: null + } +}; diff --git a/app/frontend/src/javascript/components/store/show-order.tsx b/app/frontend/src/javascript/components/store/show-order.tsx new file mode 100644 index 000000000..ff36c85ee --- /dev/null +++ b/app/frontend/src/javascript/components/store/show-order.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { IApplication } from '../../models/application'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import noImage from '../../../../images/no_image.png'; + +declare const Application: IApplication; + +interface ShowOrderProps { + orderRef: string, + onError: (message: string) => void, + onSuccess: (message: string) => void +} + +/** + * This component shows an order details + */ +// TODO: delete next eslint disable +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const ShowOrder: React.FC = ({ orderRef, onError, onSuccess }) => { + const { t } = useTranslation('admin'); + + /** + * Returns a className according to the status + */ + const statusColor = (status: string) => { + switch (status) { + case 'error': + return 'error'; + case 'canceled': + return 'canceled'; + case 'pending' || 'under_preparation': + return 'pending'; + default: + return 'normal'; + } + }; + + return ( +
+
+

[order.ref]

+ +
+ +
+ +
+
+ {t('app.admin.store.show_order.client')} +

order.user.name

+
+
+ {t('app.admin.store.show_order.created_at')} +

order.created_at

+
+
+ {t('app.admin.store.show_order.last_update')} +

order.???

+
+ order.state +
+
+ +
+ +
+ {/* loop sur les articles du panier */} +
+
+ +
+
+ {t('app.admin.store.show_order.reference_short')} orderable_id? +

o.orderable_name

+
+
+
+

o.amount

+ / {t('app.admin.store.show_order.unit')} +
+ + o.quantity + +
+ {t('app.admin.store.show_order.item_total')} +

o.quantity * o.amount

+
+
+
+
+
+ +
+
+ +

Lorem ipsum dolor sit amet consectetur adipisicing elit. Ipsum rerum commodi quaerat possimus! Odit, harum.

+
+
+ +

{t('app.admin.store.show_order.products_total')}order.amount

+

{t('app.admin.store.show_order.gift_total')}-order.amount

+

{t('app.admin.store.show_order.coupon')}order.amount

+

{t('app.admin.store.show_order.total')} order.total

+
+
+
+ ); +}; + +const ShowOrderWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('showOrder', react2angular(ShowOrderWrapper, ['orderRef', 'onError', 'onSuccess'])); diff --git a/app/frontend/src/javascript/components/store/store-list-header.tsx b/app/frontend/src/javascript/components/store/store-list-header.tsx new file mode 100644 index 000000000..78b9c55e1 --- /dev/null +++ b/app/frontend/src/javascript/components/store/store-list-header.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import Select from 'react-select'; +import Switch from 'react-switch'; + +interface StoreListHeaderProps { + productsCount: number, + selectOptions: selectOption[], + onSelectOptionsChange: (option: selectOption) => void, + switchLabel?: string, + switchChecked?: boolean, + onSwitch?: (boolean) => void +} +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ + type selectOption = { value: number, label: string }; + +/** + * Renders an accordion item + */ +export const StoreListHeader: React.FC = ({ productsCount, selectOptions, onSelectOptionsChange, switchLabel, switchChecked, onSwitch }) => { + const { t } = useTranslation('admin'); + + // Styles the React-select component + const customStyles = { + control: base => ({ + ...base, + width: '20ch', + border: 'none', + backgroundColor: 'transparent' + }), + indicatorSeparator: () => ({ + display: 'none' + }) + }; + + return ( +
+
+

{t('app.admin.store.store_list_header.result_count')}{productsCount}

+
+
+
+

{t('app.admin.store.store_list_header.display_options')}

+ typeCount(evt)} /> + setCount('add')} icon={} className="plus" /> + addToCart()} icon={} + className="main-action-btn"> + {t('app.public.store_product_item.add_to_cart')} + +
+ } + +
+ ); + } + return null; +}; + +const StoreProductWrapper: React.FC = ({ productSlug, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('storeProduct', react2angular(StoreProductWrapper, ['productSlug', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/store-settings.tsx b/app/frontend/src/javascript/components/store/store-settings.tsx new file mode 100644 index 000000000..c09617fec --- /dev/null +++ b/app/frontend/src/javascript/components/store/store-settings.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { useTranslation } from 'react-i18next'; +import { HtmlTranslate } from '../base/html-translate'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import { FabAlert } from '../base/fab-alert'; +import { FormRichText } from '../form/form-rich-text'; +import { FabButton } from '../base/fab-button'; + +declare const Application: IApplication; + +interface StoreSettingsProps { + onError: (message: string) => void, + onSuccess: (message: string) => void +} +interface Settings { + withdrawal: string +} + +/** + * Shows store settings + */ +// TODO: delete next eslint disable +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const StoreSettings: React.FC = (onError, onSuccess) => { + const { t } = useTranslation('admin'); + + const { control, handleSubmit } = useForm(); + + /** + * Callback triggered when the form is submitted: process with the product creation or update. + */ + const onSubmit: SubmitHandler = (data) => { + console.log(data); + }; + + return ( +
+
+

{t('app.admin.store_settings.title')}

+
+
+

{t('app.admin.store_settings.withdrawal_instructions')}

+ + + + + {t('app.admin.store_settings.save')} + +
+ ); +}; + +const StoreSettingsWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('storeSettings', react2angular(StoreSettingsWrapper, ['onError', 'onSuccess'])); diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx new file mode 100644 index 000000000..a5d140d29 --- /dev/null +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -0,0 +1,275 @@ +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 { ProductCategory } from '../../models/product-category'; +import ProductAPI from '../../api/product'; +import ProductCategoryAPI from '../../api/product-category'; +import MachineAPI from '../../api/machine'; +import { StoreProductItem } from './store-product-item'; +import useCart from '../../hooks/use-cart'; +import { emitCustomEvent } from 'react-custom-events'; +import { User } from '../../models/user'; +import { Order } from '../../models/order'; +import { AccordionItem } from './accordion-item'; +import { StoreListHeader } from './store-list-header'; + +declare const Application: IApplication; + +interface StoreProps { + onError: (message: string) => void, + onSuccess: (message: string) => void, + currentUser: User, +} +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ + type selectOption = { value: number, label: string }; + +/** + * This component shows public store + */ +const Store: React.FC = ({ onError, onSuccess, currentUser }) => { + const { t } = useTranslation('public'); + + const { cart, setCart } = useCart(currentUser); + + const [products, setProducts] = useState>([]); + const [productCategories, setProductCategories] = useState([]); + const [categoriesTree, setCategoriesTree] = useState([]); + const [activeCategory, setActiveCategory] = useState(); + const [filterVisible, setFilterVisible] = useState(false); + const [machines, setMachines] = useState([]); + const [accordion, setAccordion] = useState({}); + + useEffect(() => { + ProductAPI.index({ is_active: true }).then(data => { + setProducts(data); + }).catch(() => { + onError(t('app.public.store.unexpected_error_occurred')); + }); + + ProductCategoryAPI.index().then(data => { + setProductCategories(data); + formatCategories(data); + }).catch(() => { + onError(t('app.public.store.unexpected_error_occurred')); + }); + + MachineAPI.index({ disabled: false }).then(data => { + setMachines(buildChecklistOptions(data)); + }).catch(() => { + onError(t('app.public.store.unexpected_error_occurred')); + }); + }, []); + + useEffect(() => { + emitCustomEvent('CartUpdate', cart); + }, [cart]); + + /** + * Create categories tree (parent/children) + */ + const formatCategories = (list: ProductCategory[]) => { + const tree = []; + const parents = list.filter(c => !c.parent_id); + const getChildren = (id) => { + return list.filter(c => c.parent_id === id); + }; + parents.forEach(p => { + tree.push({ parent: p, children: getChildren(p.id) }); + }); + setCategoriesTree(tree); + }; + + /** + * Filter by category + */ + const filterCategory = (id: number, parent?: number) => { + setActiveCategory({ id, parent }); + console.log('Filter by category:', productCategories.find(c => c.id === id).name); + }; + + /** + * Apply filters + */ + const applyFilters = () => { + console.log('Filter products'); + }; + /** + * Clear filters + */ + const clearAllFilters = () => { + console.log('Clear filters'); + }; + + /** + * Open/close accordion items + */ + const handleAccordion = (id, state) => { + setAccordion({ ...accordion, [id]: state }); + }; + + /** + * Creates sorting options to the react-select format + */ + const buildOptions = (): Array => { + return [ + { value: 0, label: t('app.public.store.products.sort.name_az') }, + { value: 1, label: t('app.public.store.products.sort.name_za') }, + { value: 2, label: t('app.public.store.products.sort.price_low') }, + { value: 3, label: t('app.public.store.products.sort.price_high') } + ]; + }; + /** + * Display option: sorting + */ + const handleSorting = (option: selectOption) => { + console.log('Sort option:', option); + }; + + /** + * Filter: toggle non-available products visibility + */ + const toggleVisible = (checked: boolean) => { + setFilterVisible(!filterVisible); + console.log('Display in stock only:', checked); + }; + + /** + * Add product to the cart + */ + const addToCart = (cart: Order) => { + setCart(cart); + onSuccess(t('app.public.store.add_to_cart_success')); + }; + + return ( +
+
    +
  • + setActiveCategory(null)}>{t('app.public.store.products.all_products')} +
  • + {activeCategory?.parent && +
  • + filterCategory(activeCategory?.parent)}> + {productCategories.find(c => c.id === activeCategory.parent).name} + +
  • + } + {activeCategory?.id && +
  • + filterCategory(activeCategory?.id, activeCategory?.parent)}> + {productCategories.find(c => c.id === activeCategory.id).name} + +
  • + } +
+ +
+ +
+ {products.map((product) => ( + + ))} +
+
+
+ ); +}; + +/** + * Option format, expected by checklist + */ + type checklistOption = { value: number, label: string }; +/** + * Convert the provided array of items to the checklist format + */ +const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); +}; + +const StoreWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('store', react2angular(StoreWrapper, ['onError', 'onSuccess', 'currentUser'])); + +interface ActiveCategory { + id: number, + parent: number +} +interface ParentCategory { + parent: ProductCategory, + children: ProductCategory[] +} diff --git a/app/frontend/src/javascript/components/user/member-select.tsx b/app/frontend/src/javascript/components/user/member-select.tsx new file mode 100644 index 000000000..98c7a114c --- /dev/null +++ b/app/frontend/src/javascript/components/user/member-select.tsx @@ -0,0 +1,69 @@ +import React, { useState, useEffect } from 'react'; +import AsyncSelect from 'react-select/async'; +import { useTranslation } from 'react-i18next'; +import MemberAPI from '../../api/member'; +import { User } from '../../models/user'; + +interface MemberSelectProps { + defaultUser?: User, + onSelected?: (userId: number) => void, + noHeader?: boolean +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: number, label: string }; + +/** + * This component renders the member select for manager. + */ +export const MemberSelect: React.FC = ({ defaultUser, onSelected, noHeader }) => { + const { t } = useTranslation('public'); + const [value, setValue] = useState(); + + useEffect(() => { + if (defaultUser) { + setValue({ value: defaultUser.id, label: defaultUser.name }); + } + }, []); + + /** + * search members by name + */ + const loadMembers = async (inputValue: string): Promise> => { + if (!inputValue) { + return []; + } + const data = await MemberAPI.search(inputValue); + return data.map(u => { + return { value: u.id, label: u.name }; + }); + }; + + /** + * callback for handle select changed + */ + const onChange = (v: selectOption) => { + setValue(v); + onSelected(v.value); + }; + + return ( +
+ {!noHeader && +
+

{t('app.public.member_select.select_a_member')}

+
+ } + +
+ ); +}; diff --git a/app/frontend/src/javascript/controllers/admin/store.js b/app/frontend/src/javascript/controllers/admin/store.js new file mode 100644 index 000000000..f46365752 --- /dev/null +++ b/app/frontend/src/javascript/controllers/admin/store.js @@ -0,0 +1,64 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'growl', '$state', + function ($scope, CSRF, growl, $state) { + /* PRIVATE SCOPE */ + // Map of tab state and index + const TABS = { + 'app.admin.store.settings': 0, + 'app.admin.store.products': 1, + 'app.admin.store.categories': 2, + 'app.admin.store.orders': 3 + }; + + /* PUBLIC SCOPE */ + // default tab: products + $scope.tabs = { + active: TABS[$state.current.name] + }; + + /** + * Callback triggered in click tab + */ + $scope.selectTab = () => { + setTimeout(function () { + const currentTab = _.keys(TABS)[$scope.tabs.active]; + if (currentTab !== $state.current.name) { + $state.go(currentTab, { location: true, notify: false, reload: false }); + } + }); + }; + + /** + * 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(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/admin/store_products.js b/app/frontend/src/javascript/controllers/admin/store_products.js new file mode 100644 index 000000000..d9a066f8c --- /dev/null +++ b/app/frontend/src/javascript/controllers/admin/store_products.js @@ -0,0 +1,47 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('AdminStoreProductController', ['$scope', 'CSRF', 'growl', '$state', '$transition$', + function ($scope, CSRF, growl, $state, $transition$) { + /* PUBLIC SCOPE */ + $scope.productId = $transition$.params().id; + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /** + * Click Callback triggered in case of back products list + */ + $scope.backProductsList = () => { + $state.go('app.admin.store.products'); + }; + + /* 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(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/application.js b/app/frontend/src/javascript/controllers/application.js index 93d9e6c1e..d2ae5b337 100644 --- a/app/frontend/src/javascript/controllers/application.js +++ b/app/frontend/src/javascript/controllers/application.js @@ -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}`); diff --git a/app/frontend/src/javascript/controllers/cart.js b/app/frontend/src/javascript/controllers/cart.js new file mode 100644 index 000000000..e69162c33 --- /dev/null +++ b/app/frontend/src/javascript/controllers/cart.js @@ -0,0 +1,53 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('CartController', ['$scope', 'CSRF', 'growl', + function ($scope, CSRF, growl) { + /* PRIVATE SCOPE */ + + /* PUBLIC SCOPE */ + + /** + * Open the modal dialog allowing the user to log into the system + */ + $scope.userLogin = function () { + setTimeout(() => { + if (!$scope.isAuthenticated()) { + $scope.login(); + $scope.$apply(); + } + }, 50); + }; + + /** + * 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(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/main_nav.js b/app/frontend/src/javascript/controllers/main_nav.js index 898e9ee60..44e50c473 100644 --- a/app/frontend/src/javascript/controllers/main_nav.js +++ b/app/frontend/src/javascript/controllers/main_nav.js @@ -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', @@ -83,6 +89,12 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm linkIcon: 'cogs', authorizedRoles: ['admin', 'manager'] }, + { + state: 'app.admin.store.products', + linkText: 'app.public.common.manage_the_store', + linkIcon: 'cart-plus', + authorizedRoles: ['admin', 'manager'] + }, $scope.$root.modules.trainings && { state: 'app.admin.trainings', linkText: 'app.public.common.trainings_monitoring', diff --git a/app/frontend/src/javascript/controllers/orders.js b/app/frontend/src/javascript/controllers/orders.js new file mode 100644 index 000000000..b6778948a --- /dev/null +++ b/app/frontend/src/javascript/controllers/orders.js @@ -0,0 +1,49 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('ShowOrdersController', ['$scope', 'CSRF', 'growl', '$state', '$transition$', + function ($scope, CSRF, growl, $state, $transition$) { + /* PRIVATE SCOPE */ + + /* PUBLIC SCOPE */ + $scope.orderToken = $transition$.params().token; + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /** + * Click Callback triggered in case of back products list + */ + $scope.backProductsList = () => { + $state.go('app.admin.store.orders'); + }; + + /* 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(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/products.js b/app/frontend/src/javascript/controllers/products.js new file mode 100644 index 000000000..90a4c6473 --- /dev/null +++ b/app/frontend/src/javascript/controllers/products.js @@ -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(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/store.js b/app/frontend/src/javascript/controllers/store.js new file mode 100644 index 000000000..fef6b91c2 --- /dev/null +++ b/app/frontend/src/javascript/controllers/store.js @@ -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(); + } + +]); diff --git a/app/frontend/src/javascript/hooks/use-cart.ts b/app/frontend/src/javascript/hooks/use-cart.ts new file mode 100644 index 000000000..a1617b667 --- /dev/null +++ b/app/frontend/src/javascript/hooks/use-cart.ts @@ -0,0 +1,44 @@ +import { useState, useEffect } from 'react'; +import { Order } from '../models/order'; +import CartAPI from '../api/cart'; +import { getCartToken, setCartToken } from '../lib/cart-token'; +import { User } from '../models/user'; + +export default function useCart (user?: User) { + const [cart, setCart] = useState(null); + const [loading, setLoading] = useState(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); + }; + + useEffect(() => { + if (user && cart && (!cart.statistic_profile_id || !cart.operator_id)) { + reloadCart(); + } + }, [user]); + + return { loading, cart, error, setCart, reloadCart }; +} diff --git a/app/frontend/src/javascript/lib/cart-token.ts b/app/frontend/src/javascript/lib/cart-token.ts new file mode 100644 index 000000000..9d7973faf --- /dev/null +++ b/app/frontend/src/javascript/lib/cart-token.ts @@ -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); +}; diff --git a/app/frontend/src/javascript/lib/coupon.ts b/app/frontend/src/javascript/lib/coupon.ts new file mode 100644 index 000000000..7dd8da184 --- /dev/null +++ b/app/frontend/src/javascript/lib/coupon.ts @@ -0,0 +1,13 @@ +import { Coupon } from '../models/coupon'; + +export const computePriceWithCoupon = (price: number, coupon?: Coupon): number => { + if (!coupon) { + return price; + } + if (coupon.type === 'percent_off') { + return price - (price * coupon.percent_off / 100.00); + } else if (coupon.type === 'amount_off' && price > coupon.amount_off) { + return price - coupon.amount_off; + } + return price; +}; diff --git a/app/frontend/src/javascript/lib/format.ts b/app/frontend/src/javascript/lib/format.ts index 16337d780..3b6b608e6 100644 --- a/app/frontend/src/javascript/lib/format.ts +++ b/app/frontend/src/javascript/lib/format.ts @@ -32,4 +32,11 @@ export default class FormatLib { static price = (price: number): string => { return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(price); }; + + /** + * Return currency symbol for currency setting + */ + static currencySymbol = (): string => { + return new Intl.NumberFormat('fr', { style: 'currency', currency: Fablab.intl_currency }).formatToParts()[2].value; + }; } diff --git a/app/frontend/src/javascript/models/coupon.ts b/app/frontend/src/javascript/models/coupon.ts new file mode 100644 index 000000000..ad3ee624a --- /dev/null +++ b/app/frontend/src/javascript/models/coupon.ts @@ -0,0 +1,8 @@ +export interface Coupon { + id: number, + code: string, + type: string, + amount_off: number, + percent_off: number, + validity_per_user: string +} diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts new file mode 100644 index 000000000..cd2a9d991 --- /dev/null +++ b/app/frontend/src/javascript/models/order.ts @@ -0,0 +1,37 @@ +import { TDateISO } from '../typings/date-iso'; +import { PaymentConfirmation } from './payment'; +import { CreateTokenResponse } from './payzen'; +import { UserRole } from './user'; +import { Coupon } from './coupon'; + +export interface Order { + id: number, + token: string, + statistic_profile_id?: number, + user?: { + id: number, + role: UserRole + name?: string, + }, + operator_profile_id?: number, + reference?: string, + state?: string, + payment_state?: string, + total?: number, + coupon?: Coupon, + 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 + }>, +} + +export interface OrderPayment { + order: Order, + payment?: PaymentConfirmation|CreateTokenResponse +} diff --git a/app/frontend/src/javascript/models/product-category.ts b/app/frontend/src/javascript/models/product-category.ts new file mode 100644 index 000000000..59acf07fa --- /dev/null +++ b/app/frontend/src/javascript/models/product-category.ts @@ -0,0 +1,7 @@ +export interface ProductCategory { + id: number, + name: string, + slug: string, + parent_id?: number, + position: number, +} diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts new file mode 100644 index 000000000..27665b281 --- /dev/null +++ b/app/frontend/src/javascript/models/product.ts @@ -0,0 +1,58 @@ +import { TDateISO } from '../typings/date-iso'; +import { ApiFilter } from './api'; + +export interface ProductIndexFilter extends ApiFilter { + is_active: boolean, +} + +export enum StockType { + internal = 'internal', + external = 'external' +} + +export interface Stock { + internal: number, + external: number, +} + +export interface Product { + id: number, + name: string, + slug: string, + sku: string, + description: string, + is_active: boolean, + product_category_id?: number, + amount?: number, + quantity_min?: number, + stock: Stock, + low_stock_alert: boolean, + low_stock_threshold?: number, + machine_ids: number[], + product_files_attributes: Array<{ + id?: number, + attachment?: File, + attachment_files?: FileList, + attachment_name?: string, + attachment_url?: string, + _destroy?: boolean + }>, + product_images_attributes: Array<{ + id?: number, + attachment?: File, + attachment_files?: FileList, + attachment_name?: string, + attachment_url?: string, + _destroy?: boolean, + is_main?: boolean + }>, + product_stock_movements_attributes: Array<{ + id?: number, + quantity?: number, + reason?: string, + stock_type?: string, + remaining_stock?: number, + date?: TDateISO, + _destroy?: boolean + }>, +} diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index 1ca1d2752..f149be320 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -227,6 +227,15 @@ angular.module('application.router', ['ui.router']) } } }) + .state('app.logged.dashboard.orders', { + url: '/orders', + views: { + 'main@': { + templateUrl: '/dashboard/orders.html', + controller: 'DashboardController' + } + } + }) .state('app.logged.dashboard.wallet', { url: '/wallet', abstract: !Fablab.walletModule, @@ -600,6 +609,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', { @@ -871,6 +913,17 @@ angular.module('application.router', ['ui.router']) } }) + // show order + .state('app.admin.order_show', { + url: '/admin/store/o/:token', + views: { + 'main@': { + templateUrl: '/admin/orders/show.html', + controller: 'ShowOrdersController' + } + } + }) + // invoices .state('app.admin.invoices', { url: '/admin/invoices', @@ -1104,6 +1157,71 @@ angular.module('application.router', ['ui.router']) } }) + .state('app.admin.store', { + abstract: true, + url: '/admin/store' + }) + + .state('app.admin.store.settings', { + url: '/settings', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } + }) + + .state('app.admin.store.products', { + url: '/products', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } + }) + + .state('app.admin.store.products_new', { + url: '/products/new', + views: { + 'main@': { + templateUrl: '/admin/store/product_new.html', + controller: 'AdminStoreProductController' + } + } + }) + + .state('app.admin.store.products_edit', { + url: '/products/:id/edit', + views: { + 'main@': { + templateUrl: '/admin/store/product_edit.html', + controller: 'AdminStoreProductController' + } + } + }) + + .state('app.admin.store.categories', { + url: '/categories', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } + }) + + .state('app.admin.store.orders', { + url: '/orders', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } + }) + // OpenAPI Clients .state('app.admin.open_api_clients', { url: '/open_api_clients', diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 14723c579..56dd9e962 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -30,6 +30,8 @@ @import "modules/base/fab-text-editor"; @import "modules/base/labelled-input"; @import "modules/calendar/calendar"; +@import "modules/cart/cart-button"; +@import "modules/cart/store-cart"; @import "modules/dashboard/reservations/credits-panel"; @import "modules/dashboard/reservations/reservations-dashboard"; @import "modules/dashboard/reservations/reservations-panel"; @@ -38,7 +40,11 @@ @import "modules/form/form-input"; @import "modules/form/form-rich-text"; @import "modules/form/form-switch"; +@import "modules/form/form-checklist"; +@import "modules/form/form-file-upload"; +@import "modules/form/form-image-upload"; @import "modules/group/change-group"; +@import "modules/layout/header-page"; @import "modules/machines/machine-card"; @import "modules/machines/machines-filters"; @import "modules/machines/machines-list"; @@ -85,6 +91,21 @@ @import "modules/settings/check-list-setting"; @import "modules/settings/user-validation-setting"; @import "modules/socials/fab-socials"; +@import "modules/store/_utilities"; +@import "modules/store/orders-dashboard"; +@import "modules/store/orders"; +@import "modules/store/product-categories"; +@import "modules/store/product-form"; +@import "modules/store/product-stock-form"; +@import "modules/store/product-stock-modal"; +@import "modules/store/products-grid"; +@import "modules/store/products-list"; +@import "modules/store/products"; +@import "modules/store/store-filters"; +@import "modules/store/store-list-header"; +@import "modules/store/store-list"; +@import "modules/store/store-settings"; +@import "modules/store/store"; @import "modules/subscriptions/free-extend-modal"; @import "modules/subscriptions/renew-modal"; @import "modules/supporting-documents/supporting-documents-files"; diff --git a/app/frontend/src/stylesheets/modules/base/fab-button.scss b/app/frontend/src/stylesheets/modules/base/fab-button.scss index 5f70a2d3a..4f825d1eb 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-button.scss @@ -1,23 +1,23 @@ .fab-button { - color: black; - background-color: #fbfbfb; - display: inline-block; + height: 38px; margin-bottom: 0; - font-weight: normal; - text-align: center; - white-space: nowrap; - vertical-align: middle; - touch-action: manipulation; - cursor: pointer; - background-image: none; - border: 1px solid #c9c9c9; padding: 6px 12px; + display: inline-flex; + align-items: center; + border: 1px solid #c9c9c9; + border-radius: 4px; + background-color: #fbfbfb; + background-image: none; font-size: 16px; line-height: 1.5; - border-radius: 4px; - user-select: none; + text-align: center; + font-weight: normal; text-decoration: none; - height: 38px; + color: black; + white-space: nowrap; + touch-action: manipulation; + cursor: pointer; + user-select: none; &:hover { background-color: #f2f2f2; @@ -45,5 +45,31 @@ &--icon { margin-right: 0.5em; + display: flex; + } + &--icon-only { + display: flex; + } + + // color variants + @mixin colorVariant($color, $textColor) { + border-color: $color; + background-color: $color; + color: $textColor; + &:hover { + border-color: $color; + background-color: $color; + color: $textColor; + opacity: 0.75; + } + } + &.is-info { + @include colorVariant(var(--information), var(--gray-soft-lightest)); + } + &.is-black { + @include colorVariant(var(--gray-hard-darkest), var(--gray-soft-lightest)); + } + &.is-main { + @include colorVariant(var(--main), var(--gray-soft-lightest)); } } diff --git a/app/frontend/src/stylesheets/modules/base/fab-modal.scss b/app/frontend/src/stylesheets/modules/base/fab-modal.scss index 4e5d538b3..890c98c3b 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-modal.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-modal.scss @@ -81,6 +81,12 @@ position: relative; padding: 15px; + .subtitle { + margin-bottom: 3.2rem; + @include title-base; + color: var(--gray-hard-darkest); + } + form { display: flex; flex-direction: column; diff --git a/app/frontend/src/stylesheets/modules/base/fab-output-copy.scss b/app/frontend/src/stylesheets/modules/base/fab-output-copy.scss index 24c3a9941..30eebc19c 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-output-copy.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-output-copy.scss @@ -1,7 +1,7 @@ .fab-output-copy { .form-item-field { & > input { - background-color: var(--gray-soft); + background-color: var(--gray-soft-dark); border-top-right-radius: 0; border-bottom-right-radius: 0; } diff --git a/app/frontend/src/stylesheets/modules/cart/cart-button.scss b/app/frontend/src/stylesheets/modules/cart/cart-button.scss new file mode 100644 index 000000000..2f8c491ac --- /dev/null +++ b/app/frontend/src/stylesheets/modules/cart/cart-button.scss @@ -0,0 +1,41 @@ +.cart-button { + position: relative; + width: 100%; + height: 100%; + padding: 0.8rem 0.6rem; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + background-color: var(--secondary); + + &:hover { + cursor: pointer; + } + + span { + position: absolute; + top: 1rem; + right: 1rem; + min-width: 2rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--secondary-text-color); + border-radius: 10rem; + color: var(--secondary); + @include text-sm(600); + } + i { + margin-bottom: 0.8rem; + font-size: 2.6rem; + columns: var(--secondary-text-color); + } + p { + margin: 0; + @include text-sm; + text-align: center; + color: var(--secondary-text-color); + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/cart/store-cart.scss b/app/frontend/src/stylesheets/modules/cart/store-cart.scss new file mode 100644 index 000000000..add2e1e7c --- /dev/null +++ b/app/frontend/src/stylesheets/modules/cart/store-cart.scss @@ -0,0 +1,186 @@ +.store-cart { + width: 100%; + max-width: 1600px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-template-rows: minmax(0, min-content); + gap: 3.2rem; + align-items: flex-start; + + &-list { + grid-area: 1 / 1 / 2 / 10; + display: grid; + gap: 1.6rem; + + &-item { + padding: 0.8rem; + display: grid; + grid-auto-flow: column; + grid-template-columns: min-content 1fr; + gap: 1.6rem; + justify-content: space-between; + align-items: center; + background-color: var(--gray-soft-lightest); + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + + .picture { + width: 10rem !important; + @include imageRatio(76%); + border-radius: var(--border-radius); + } + .ref { + display: flex; + flex-direction: column; + span { + @include text-sm; + color: var(--gray-hard-lightest); + text-transform: uppercase; + } + p { + max-width: 60ch; + margin: 0; + @include text-base(600); + } + } + .actions { + align-self: stretch; + padding: 0.8rem; + display: grid; + grid-auto-flow: column; + justify-content: space-between; + align-items: center; + gap: 2.4rem; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius); + } + .offer { + align-self: stretch; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.8rem 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + label { + display: flex; + justify-content: space-between; + align-items: center; + margin: 0; + @include text-base; + cursor: pointer; + span { margin-right: 0.8rem; } + } + } + .price, + .total { + min-width: 10rem; + p { + margin: 0; + display: flex; + @include title-base; + } + span { @include text-sm; } + } + .count { + padding: 0.8rem 1.6rem; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--gray-soft); + border-radius: var(--border-radius-sm); + } + .total { + span { + @include text-sm; + color: var(--main); + text-transform: uppercase; + } + } + } + } + .group { + grid-area: 2 / 1 / 3 / 10; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 2.4rem; + } + &-info, + &-coupon { + padding: 2.4rem; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius); + h3, label { + margin: 0 0 1.6rem; + @include text-base(500); + color: var(--gray-hard-darkest) !important; + } + .fab-input .input-wrapper { + width: 100%; + .fab-input--input { + border-radius: var(--border-radius); + } + } + } + &-info { + p { @include text-sm; } + } + + aside { + grid-area: 1 / 10 / 3 / 13; + & > div { + margin-bottom: 3.2rem; + padding: 1.6rem; + background-color: var(--gray-soft-lightest); + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + h3, + .member-select-title { + margin: 0 0 2.4rem; + padding-bottom: 1.2rem; + border-bottom: 1px solid var(--gray-hard); + @include title-base; + color: var(--gray-hard-dark) !important; + } + } + .checkout { + .list { + margin: 0.8rem 0 2.4rem; + padding: 2.4rem 0; + border-top: 1px solid var(--main); + border-bottom: 1px solid var(--main); + p { + display: flex; + justify-content: space-between; + align-items: center; + span { @include title-base; } + } + .gift { color: var(--information); } + } + .total { + display: flex; + justify-content: space-between; + align-items: flex-start; + @include text-base(600); + span { @include title-lg; } + } + + &-btn { + width: 100%; + height: auto; + padding: 1.6rem 0.8rem; + background-color: var(--information); + border: none; + color: var(--gray-soft-lightest); + justify-content: center; + text-transform: uppercase; + &:hover { + color: var(--gray-soft-lightest); + opacity: 0.75; + cursor: pointer; + } + } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss index 59ac7a110..55abc566a 100644 --- a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss +++ b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss @@ -32,7 +32,7 @@ background-color: var(--information-lightest); color: var(--information); border: 1px solid var(--information); - border-radius: 8px; + border-radius: var(--border-radius); font-size: 14px; font-weight: normal; line-height: 1.2em; @@ -52,7 +52,7 @@ &.is-required &-header p::after { content: "*"; margin-left: 0.5ch; - color: var(--error); + color: var(--alert); } &-field { @@ -64,6 +64,7 @@ border: 1px solid var(--gray-soft-dark); border-radius: var(--border-radius); transition: border-color ease-in-out 0.15s; + font-weight: 400; .icon, .addon { @@ -151,19 +152,19 @@ } } &.is-incorrect &-field { - border-color: var(--error); + border-color: var(--alert); .icon { - color: var(--error); - border-color: var(--error); - background-color: var(--error-lightest); + color: var(--alert); + border-color: var(--alert); + background-color: var(--alert-lightest); } } &.is-warned &-field { - border-color: var(--warning); + border-color: var(--notification); .icon { - color: var(--warning); - border-color: var(--warning); - background-color: var(--warning-lightest); + color: var(--notification); + border-color: var(--notification); + background-color: var(--notification-lightest); } } &.is-disabled &-field input, @@ -173,10 +174,21 @@ &-error { margin-top: 0.4rem; - color: var(--error); + color: var(--alert); } &-warning { margin-top: 0.4rem; - color: var(--warning); + color: var(--notification); + } + + input[type='file'] { + opacity: 0; + width: 0; + height: 0; + margin: 0; + padding: 0; + } + .file-placeholder { + border: none; } } diff --git a/app/frontend/src/stylesheets/modules/form/form-checklist.scss b/app/frontend/src/stylesheets/modules/form/form-checklist.scss new file mode 100644 index 000000000..20721b0c5 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-checklist.scss @@ -0,0 +1,28 @@ +.form-checklist { + .form-item-field { + display: flex; + flex-direction: column; + border: none; + } + + .checklist { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.6rem 3.2rem; + + .checklist-item input { + margin-right: 1em; + } + } + + .actions { + align-self: flex-end; + margin: 2.4rem 0; + display: flex; + justify-content: flex-end; + align-items: center; + & > *:not(:first-child) { + margin-left: 1.6rem; + } + } +} diff --git a/app/frontend/src/stylesheets/modules/form/form-file-upload.scss b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss new file mode 100644 index 000000000..773f763d8 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss @@ -0,0 +1,25 @@ +.form-file-upload { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + + .actions { + margin-left: auto; + display: flex; + align-items: center; + & > *:not(:first-child) { + margin-left: 1rem; + } + a { + display: flex; + } + + .image-file-input { + margin-bottom: 0; + } + } +} diff --git a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss new file mode 100644 index 000000000..68e7680fa --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss @@ -0,0 +1,54 @@ +@mixin base { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); +} +.form-image-upload { + &--small, + &--medium, + &--large { + @include base; + } + + .image { + flex-shrink: 0; + display: flex; + object-fit: cover; + border-radius: var(--border-radius-sm); + overflow: hidden; + &--small { + width: 8rem; + height: 8rem; + } + &--medium { + width: 20rem; + height: 20rem; + } + &--large { + width: 40rem; + height: 40rem; + } + + img { + width: 100%; + } + } + + .actions { + display: flex; + align-items: center; + & > *:not(:first-child) { + margin-left: 1rem; + } + + input[type="radio"] { margin-left: 0.5rem; } + + .image-file-input { + margin-bottom: 0; + } + } +} diff --git a/app/frontend/src/stylesheets/modules/layout/header-page.scss b/app/frontend/src/stylesheets/modules/layout/header-page.scss new file mode 100644 index 000000000..ccace1adf --- /dev/null +++ b/app/frontend/src/stylesheets/modules/layout/header-page.scss @@ -0,0 +1,37 @@ +.header-page { + width: 100%; + min-height: 9rem; + display: grid; + grid-template-columns: min-content 1fr min-content; + background-color: var(--gray-soft-lightest); + border-bottom: 1px solid var(--gray-soft-dark); + + .back { + width: 9rem; + border-right: 1px solid var(--gray-soft-dark); + a { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + color: var(--gray-hard-darkest) !important; + &:hover { + cursor: pointer; + background-color: var(--secondary); + } + } + } + + .center { + padding: 3.2rem; + h1 { + margin: 0; + } + } + + .right { + min-width: 9rem; + border-left: 1px solid var(--gray-soft-dark); + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/machines/machine-card.scss b/app/frontend/src/stylesheets/modules/machines/machine-card.scss index cb26def75..8db1ead72 100644 --- a/app/frontend/src/stylesheets/modules/machines/machine-card.scss +++ b/app/frontend/src/stylesheets/modules/machines/machine-card.scss @@ -1,11 +1,9 @@ .machine-card { background-color: #fff; border: 1px solid #ddd; - border-radius: 6px; - margin: 0 15px 30px; - width: 30%; - min-width: 263px; + border-radius: var(--border-radius); position: relative; + overflow: hidden; &.loading::before { content: ''; @@ -37,18 +35,6 @@ 100% { transform: rotate(360deg);} } - @media screen and (max-width: 1219px) { - width: 45%; - min-width: 195px; - margin: 0 auto 30px; - } - - @media screen and (max-width: 674px) { - width: 95%; - max-width: 400px; - margin: 0 auto 30px; - } - .machine-picture { height: 250px; background-size: cover; diff --git a/app/frontend/src/stylesheets/modules/machines/machines-list.scss b/app/frontend/src/stylesheets/modules/machines/machines-list.scss index 35b5d9c7c..3d93c80ca 100644 --- a/app/frontend/src/stylesheets/modules/machines/machines-list.scss +++ b/app/frontend/src/stylesheets/modules/machines/machines-list.scss @@ -1,6 +1,44 @@ -.machines-list { +.machines-list { .all-machines { - display: flex; - flex-wrap: wrap; + max-width: 1600px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 3.2rem; + + .store-ad { + display: flex; + flex-direction: column; + background-color: var(--main); + border-radius: var(--border-radius); + overflow: hidden; + color: var(--main-text-color); + .content { + flex: 1; + padding: 3.2rem; + display: flex; + flex-direction: column; + h3 { + margin: 0 0 2.4rem; + @include title-lg; + color: var(--main-text-color) !important; + } + p { margin: 0; } + .sell { + margin-top: auto; + @include text-lg(500); + } + } + .cta { + margin-top: auto; + width: 100%; + height: 5.4rem; + justify-content: center; + border: none; + border-radius: 0; + background-color: var(--gray-hard-darkest); + color: var(--main-text-color); + } + } } } diff --git a/app/frontend/src/stylesheets/modules/plan-categories/manage-plan-category.scss b/app/frontend/src/stylesheets/modules/plan-categories/manage-plan-category.scss index 507e62268..6cb8cf6a3 100644 --- a/app/frontend/src/stylesheets/modules/plan-categories/manage-plan-category.scss +++ b/app/frontend/src/stylesheets/modules/plan-categories/manage-plan-category.scss @@ -3,8 +3,12 @@ margin-right: 5px; .create-button { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); + background-color: var(--secondary); + border-color: var(--secondary); color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss b/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss index bf8d86c96..04f032429 100644 --- a/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss +++ b/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss @@ -10,9 +10,13 @@ vertical-align: middle; } .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); margin-left: 15px; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss b/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss index ec368ce3d..75b1c5f3f 100644 --- a/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss +++ b/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss @@ -1,8 +1,12 @@ .user-validation-setting { .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); margin-top: 15px; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/socials/fab-socials.scss b/app/frontend/src/stylesheets/modules/socials/fab-socials.scss index 4404ae008..44005e290 100644 --- a/app/frontend/src/stylesheets/modules/socials/fab-socials.scss +++ b/app/frontend/src/stylesheets/modules/socials/fab-socials.scss @@ -1,7 +1,11 @@ .fab-socials { .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); + background-color: var(--secondary); + border-color: var(--secondary); color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/store/_utilities.scss b/app/frontend/src/stylesheets/modules/store/_utilities.scss new file mode 100644 index 000000000..d348bc22d --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/_utilities.scss @@ -0,0 +1,106 @@ +@mixin btn { + width: 4rem; + height: 4rem; + display: inline-flex; + justify-content: center; + align-items: center; + padding: 0; + background: none; + border: none; + &:active { + color: currentColor; + box-shadow: none; + } +} + +@mixin grid-col($col-count) { + width: 100%; + display: grid; + grid-template-columns: repeat($col-count, minmax(0, 1fr)); +} + +.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 { + color: var(--gray-soft-lightest); + background-color: var(--gray-hard-lightest); + cursor: pointer; + } +} + +.main-action-btn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { + background-color: var(--main); + color: var(--gray-soft-lightest); + opacity: 0.75; + } +} + +@mixin 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; + } +} + +.stock-label { + display: flex; + align-items: center; + @include text-sm; + color: var(--status-color); + &::before { + content: ""; + margin-right: 0.8rem; + width: 1rem; + height: 1rem; + background-color: var(--status-color); + border-radius: 50%; + } +} + +// Custom scrollbar +.u-scrollbar { + &::-webkit-scrollbar-track + { + border-radius: 6px; + background-color: #d9d9d9; + } + + &::-webkit-scrollbar + { + width: 12px; + background-color: #ffffff; + } + + &::-webkit-scrollbar-thumb + { + border-radius: 6px; + background-color: #2d2d2d; + border: 2px solid #d9d9d9 + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/orders-dashboard.scss b/app/frontend/src/stylesheets/modules/store/orders-dashboard.scss new file mode 100644 index 000000000..f3d378499 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/orders-dashboard.scss @@ -0,0 +1,14 @@ +.orders-dashboard { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + @include grid-col(12); + gap: 3.2rem; + align-items: flex-start; + + header { + @include header(); + padding-bottom: 0; + grid-column: 2 / -2; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/orders.scss b/app/frontend/src/stylesheets/modules/store/orders.scss new file mode 100644 index 000000000..16443137d --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/orders.scss @@ -0,0 +1,158 @@ +.orders, +.show-order { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + @include grid-col(12); + gap: 3.2rem; + align-items: flex-start; + + header { + @include header(); + padding-bottom: 0; + grid-column: 1 / -1; + } + + &-list { + & > *:not(:first-child) { + margin-top: 1.6rem; + } + + .order-item { + width: 100%; + display: grid; + grid-auto-flow: column; + grid-template-columns: 1fr 15rem 15rem 10ch 12rem; + gap: 2.4rem; + justify-items: flex-start; + align-items: center; + padding: 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + + p { margin: 0; } + .ref { @include text-base(600); } + .client { + span { + @include text-xs; + color: var(--gray-hard-light); + } + p { @include text-sm; } + } + .date { @include text-sm; } + .price { + justify-self: flex-end; + span { + @include text-xs; + color: var(--gray-hard-light); + } + p { @include text-base(600); } + } + } + } +} + +.show-order { + &-nav { + max-width: 1600px; + margin: 0 auto; + @include grid-col(12); + gap: 3.2rem; + justify-items: flex-start; + & > * { + grid-column: 2 / -2; + } + } + header { grid-column: 2 / -2; } + .client-info, + .cart { + grid-column: 2 / -2; + label { + margin-bottom: 1.6rem; + @include title-base; + } + .content { + display: flex; + align-items: center; + & > *:not(:last-child) { + margin-right: 2.4rem; + padding-right: 2.4rem; + border-right: 1px solid var(--gray-hard-dark); + } + } + p { + margin: 0; + line-height: 1.18; + } + .group { + span { + @include text-xs; + color: var(--gray-hard-light); + } + } + } + + & > .group { + grid-column: 2 / -2; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 2.4rem; + align-items: flex-start; + + .payment-info, + .amount { + padding: 2.4rem; + border: 1px solid var(--gray-soft); + border-radius: var(--border-radius); + label { + margin: 0 0 2.4rem; + padding: 0 0 0.8rem; + width: 100%; + border-bottom: 1px solid var(--gray-hard); + @include title-base; + } + } + .amount { + p { + display: flex; + justify-content: space-between; + align-items: center; + span { @include title-base; } + } + .gift { color: var(--information); } + .total { + padding: 1.6rem 0 0; + align-items: flex-start; + border-top: 1px solid var(--main); + @include text-base(600); + span { @include title-lg; } + } + } + } +} + +.order-status { + --status-color: var(--success); + &.error { --status-color: var(--alert); } + &.canceled { --status-color: var(--alert-light); } + &.pending { --status-color: var(--information); } + &.normal { --status-color: var(--success); } + padding: 0.4rem 0.8rem; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius); + @include text-sm(500); + line-height: 1.714; + + &::before { + content: ""; + margin-right: 0.8rem; + width: 1rem; + height: 1rem; + background-color: var(--status-color); + border-radius: 50%; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss new file mode 100644 index 000000000..1098f1e99 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -0,0 +1,113 @@ +.product-categories { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + @include grid-col(12); + gap: 0 3.2rem; + + header { + @include header(); + grid-column: 2 / -2; + + .create-button { + background-color: var(--gray-hard-darkest); + border-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + &:hover { + background-color: var(--gray-hard-light); + border-color: var(--gray-hard-light); + } + } + } + .fab-alert { + grid-column: 2 / -2; + } + + &-tree { + grid-column: 2 / -2; + & > *:not(:first-child) { + margin-top: 1.6rem; + } + } + &-item { + display: flex; + pointer-events: all; + + &.is-collapsed { + height: 0; + margin: 0; + padding: 0; + border: none; + overflow: hidden; + pointer-events: none; + } + .offset { + width: 4.8rem; + } + + .wrap { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + &.is-child { margin-left: 4.8rem; } + .itemInfo { + display: flex; + align-items: center; + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + &-count { + margin-left: 2.4rem; + @include text-sm; + font-weight: 500; + color: var(--information); + } + } + + .actions { + display: flex; + justify-content: flex-end; + 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(--main); } + } + } + } + + .collapse-handle { + width: 4rem; + margin: 0 0 0 -1rem; + button { + @include btn; + background: none; + border-radius: 0; + transition: transform 250ms ease-in-out; + &.rotate { + transform: rotateZ(-180deg); + } + } + } + .drag-handle button { + @include btn; + cursor: grab; + } + } +} diff --git a/app/frontend/src/stylesheets/modules/store/product-form.scss b/app/frontend/src/stylesheets/modules/store/product-form.scss new file mode 100644 index 000000000..b72819217 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-form.scss @@ -0,0 +1,82 @@ +.product-form { + grid-column: 2 / -2; + + .tabs { + display: flex; + justify-content: space-between; + p { + flex: 1; + margin-bottom: 4rem; + padding: 0.8rem; + text-align: center; + color: var(--main); + border-bottom: 1px solid var(--gray-soft-dark); + &.is-active { + color: var(--gray-hard-dark); + border: 1px solid var(--gray-soft-dark); + border-bottom: none; + border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0; + } + &:hover { cursor: pointer; } + } + } + + h4 { + margin: 0 0 2.4rem; + @include title-base; + } + hr { + margin: 4.8rem 0; + } + + .subgrid { + @include grid-col(10); + gap: 3.2rem; + align-items: flex-end; + } + .span-3 { grid-column: span 3; } + .span-7 { grid-column: span 7; } + + & > div { + grid-column: 2 / -2; + } + + .flex { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0 3.2rem; + & > * { + flex: 1 1 320px; + } + } + + .header-switch { + display: flex; + flex-direction: row; + gap: 3.2rem; + justify-content: space-between; + align-items: center; + label { flex: 0 1 fit-content; } + } + .price-data-content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 0 3.2rem; + align-items: flex-end; + } + + .product-images, + .product-documents { + display: flex; + flex-direction: column; + .list { + margin-bottom: 2.4rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); + gap: 2.4rem; + } + button { margin-left: auto; } + } + +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-stock-form.scss b/app/frontend/src/stylesheets/modules/store/product-stock-form.scss new file mode 100644 index 000000000..1778e3ad1 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-stock-form.scss @@ -0,0 +1,74 @@ +.product-stock-form { + h4 span { @include text-sm; } + + .store-list { + h4 { margin: 0; } + } + .store-list-header { + & > *:not(:first-child) { + &::before { + content: ""; + margin: 0 2rem; + width: 1px; + height: 2rem; + background-color: var(--gray-hard-darkest); + } + } + .sort-events { + margin-left: auto; + display: flex; + align-items: center; + } + .sort-stocks { + display: flex; + align-items: center; + } + } + + .threshold-data-content { + margin-top: 1.6rem; + padding: 1.6rem; + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 3.2rem; + border: 1px solid var(--gray-soft); + border-radius: var(--border-radius); + label { flex: 0 1 fit-content; } + + } + .stock-label { + --status-color: var(--alert-light); + } + + .stock-item { + width: 100%; + display: flex; + gap: 4.8rem; + justify-items: flex-start; + align-items: center; + padding: 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + + & > * { flex: 1 1 45%; } + button { flex: 0;} + + p { + margin: 0; + @include text-base; + } + .title { + @include text-base(600); + flex: 1 1 100%; + } + .group { + span { + @include text-xs; + color: var(--gray-hard-light); + } + p { @include text-base(600); } + } + } +} diff --git a/app/frontend/src/stylesheets/modules/store/product-stock-modal.scss b/app/frontend/src/stylesheets/modules/store/product-stock-modal.scss new file mode 100644 index 000000000..a61ad683e --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-stock-modal.scss @@ -0,0 +1,30 @@ +.product-stock-modal { + .movement { + margin-bottom: 3.2rem; + display: flex; + justify-content: space-between; + align-items: center; + button { + flex: 1; + padding: 1.6rem; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--gray-soft-lightest); + border: 1px solid var(--gray-soft-dark); + color: var(--gray-soft-darkest); + @include text-base; + &.is-active { + border: 1px solid var(--gray-soft-darkest); + background-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + } + } + button:first-of-type { + border-radius: var(--border-radius) 0 0 var(--border-radius); + } + button:last-of-type { + border-radius: 0 var(--border-radius) var(--border-radius) 0; + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/products-grid.scss b/app/frontend/src/stylesheets/modules/store/products-grid.scss new file mode 100644 index 000000000..107ee46b1 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products-grid.scss @@ -0,0 +1,60 @@ +.products-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 3.2rem; + + .store-product-item { + --status-color: var(--success); + &.low { --status-color: var(--alert-light); } + &.out-of-stock { + --status-color: var(--alert); + background-color: var(--gray-soft-light); + border: none; + } + + padding: 1.6rem 2.4rem; + display: grid; + grid-template-areas: "image image" + "name name" + "price btn" + "stock btn"; + grid-template-columns: auto min-content; + grid-template-rows: min-content auto min-content min-content; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + cursor: pointer; + + .picture { + grid-area: image; + @include imageRatio(50%); + border-radius: var(--border-radius); + } + .name { + margin: 2.4rem 0 1.6rem; + grid-area: name; + align-self: flex-start; + @include text-base(600); + } + .price { + grid-area: price; + display: flex; + align-items: baseline; + p { + margin: 0; + @include title-base; + } + span { + margin-left: 0.8rem; + @include text-sm; + word-break: break-all; + } + } + .stock-label { grid-area: stock; } + button { + grid-area: btn; + align-self: flex-end; + margin-left: 1rem; + i { margin-right: 0.8rem; } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/products-list.scss b/app/frontend/src/stylesheets/modules/store/products-list.scss new file mode 100644 index 000000000..88b49d64e --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products-list.scss @@ -0,0 +1,108 @@ +.products-list { + & > *:not(:first-child) { + margin-top: 1.6rem; + } + .product-item { + --status-color: var(--gray-hard-darkest); + &.low { --status-color: var(--alert-light); } + &.out-of-stock { + --status-color: var(--alert); + .stock { color: var(--alert) !important; } + } + + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.6rem 0.8rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + &.out-of-stock { border-color: var(--status-color); } + + .itemInfo { + min-width: 20ch; + flex: 1; + display: flex; + align-items: center; + + &-thumbnail { + width: 4.8rem; + height: 4.8rem; + margin-right: 1.6rem; + object-fit: cover; + border-radius: var(--border-radius); + background-color: var(--gray-soft); + } + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + } + .details { + display: grid; + grid-template-columns: 120px repeat(2, minmax(min-content, 120px)) 120px; + justify-items: center; + align-items: center; + gap: 1.6rem; + margin-left: auto; + margin-right: 4rem; + p { + margin: 0; + @include text-base(600); + } + + .visibility { + justify-self: center; + padding: 0.4rem 0.8rem; + display: flex; + align-items: center; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius); + &::before { + flex-shrink: 0; + margin-right: 1rem; + content: ""; + width: 1rem; + height: 1rem; + background-color: var(--gray-hard); + border-radius: 50%; + } + &.is-active::before { + background-color: var(--success); + } + } + .stock { + display: flex; + flex-direction: column; + color: var(--gray-hard-darkest); + span { @include text-xs; } + &.low { color: var(--alert-light); } + } + .price { + justify-self: flex-end; + } + } + + .actions { + display: flex; + justify-content: flex-end; + 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(--main) } + } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/products.scss b/app/frontend/src/stylesheets/modules/store/products.scss new file mode 100644 index 000000000..d5cff3ce1 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products.scss @@ -0,0 +1,33 @@ +.products, +.new-product, +.edit-product { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + @include grid-col(12); + gap: 3.2rem; + align-items: flex-start; + + header { + @include header(); + padding-bottom: 0; + grid-column: 1 / -1; + } +} + +.new-product, +.edit-product { + + &-nav { + max-width: 1600px; + margin: 0 auto; + @include grid-col(12); + gap: 3.2rem; + justify-items: flex-start; + & > * { + grid-column: 2 / -2; + } + } + + header { grid-column: 2 / -2; } +} diff --git a/app/frontend/src/stylesheets/modules/store/store-filters.scss b/app/frontend/src/stylesheets/modules/store/store-filters.scss new file mode 100644 index 000000000..6dbd308aa --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store-filters.scss @@ -0,0 +1,146 @@ +.store-filters { + grid-column: 1 / 4; + + .categories { + margin-bottom: 3.2rem; + .list { + max-height: 30vh; + overflow: auto; + } + p { + display: flex; + align-items: baseline; + cursor: pointer; + span { + margin-left: 0.8rem; + @include text-xs; + color: var(--information); + } + } + .parent { + & > p { + margin-bottom: 2.4rem; + @include text-base(500); + color: var(--gray-hard); + } + &.is-active > p { + @include text-base(600); + color: var(--information); + .children { + max-height: 1000px; + } + } + &.is-active .children { + max-height: 1000px; + margin: -0.8rem 0 1.6rem; + transition: max-height 500ms ease-in-out; + } + } + + .children { + max-height: 0; + overflow: hidden; + p { + margin-bottom: 1.6rem; + @include text-base(400); + color: var(--gray-hard-light); + &.is-active { + background-color: var(--gray-soft-light); + } + } + } + } + + .filters { + padding-top: 1.6rem; + border-top: 1px solid var(--gray-soft-dark); + } + + header { + @include header(); + padding: 0 0 2.4rem 0; + } + + .accordion { + &-item:not(:last-of-type) { + margin-bottom: 1.6rem; + border-bottom: 1px solid var(--gray-soft-darkest); + } + &-item { + position: relative; + padding-bottom: 1.6rem; + &.collapsed { + header svg { transform: rotateZ(180deg); } + .content { + max-height: 0; + overflow: hidden; + * { opacity: 0; } + } + } + + header { + width: 100%; + padding: 0; + display: flex; + justify-content: space-between; + align-items: center; + background: none; + border: none; + @include text-base(600); + cursor: pointer; + svg { transition: transform 250ms ease-in-out; } + } + .content { + max-height: 24rem; + padding-top: 1.6rem; + display: flex; + flex-direction: column; + align-items: stretch; + transition: max-height 500ms ease-in-out; + * { transition: opacity 250ms ease-in-out 300ms; } + + .group { + display: flex; + flex-direction: column; + opacity: 1; + &.u-scrollbar { overflow: hidden auto; } + + label { + margin: 0 0.8rem 0 0; + padding: 0.6rem; + display: flex; + align-items: center; + &:hover { + background-color: var(--gray-soft-light); + cursor: pointer; + } + input[type=checkbox] { margin: 0 0.8rem 0 0; } + p { + margin: 0; + @include text-base; + } + &.offset { margin-left: 1.6rem; } + } + } + + input[type="text"] { + width: 100%; + min-height: 4rem; + padding: 0 0.8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius-sm); + @include text-base; + } + + button { + opacity: 100; + margin-top: 0.8rem; + justify-content: center; + } + } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/store-list-header.scss b/app/frontend/src/stylesheets/modules/store/store-list-header.scss new file mode 100644 index 000000000..ad0c54387 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store-list-header.scss @@ -0,0 +1,55 @@ +.store-list-header { + padding: 0.8rem 2.4rem; + display: flex; + justify-content: space-between; + background-color: var(--gray-soft); + border-radius: var(--border-radius); + p { margin: 0; } + + .count { + display: flex; + align-items: center; + p { + @include text-sm; + span { + margin-left: 1.6rem; + @include text-lg(600); + } + } + } + + .display { + display: flex; + align-items: center; + & > *:not(:first-child) { + &::before { + content: ""; + margin: 0 2rem; + width: 1px; + height: 2rem; + background-color: var(--gray-hard-darkest); + } + } + + .sort { + display: flex; + align-items: center; + p { margin-right: 0.8rem; } + } + + .visibility { + display: flex; + align-items: center; + label { + margin: 0; + display: flex; + align-items: center; + font-weight: 400; + cursor: pointer; + span { + margin-right: 1rem; + } + } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/store-list.scss b/app/frontend/src/stylesheets/modules/store/store-list.scss new file mode 100644 index 000000000..e29de16e4 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store-list.scss @@ -0,0 +1,32 @@ +.store-list { + grid-column: 4 / -1; + display: grid; + grid-template-columns: 1fr; + gap: 2.4rem 0; + + .features { + 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); + overflow: hidden; + p { margin: 0; } + button { + width: 3.2rem; + height: 3.2rem; + margin-left: 0.8rem; + display: flex; + align-items: center; + background: none; + border: none; + } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/store-settings.scss b/app/frontend/src/stylesheets/modules/store/store-settings.scss new file mode 100644 index 000000000..6df84056c --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store-settings.scss @@ -0,0 +1,27 @@ +.store-settings { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + @include grid-col(12); + gap: 3.2rem; + align-items: flex-start; + header { + @include header(); + padding-bottom: 0; + grid-column: 2 / -2; + } + form { + grid-column: 2 / 7; + p { @include title-base; } + .save-btn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { + background-color: var(--main); + color: var(--gray-soft-lightest); + opacity: 0.75; + } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/store.scss b/app/frontend/src/stylesheets/modules/store/store.scss new file mode 100644 index 000000000..17ddbde79 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store.scss @@ -0,0 +1,186 @@ +.store, +.store-product { + max-width: 1600px; + @include grid-col(12); + gap: 2.4rem 3.2rem; + align-items: flex-start; + margin: 0 auto; + padding-bottom: 6rem; +} + +.store { + .breadcrumbs { + grid-column: 1 / -1; + padding: 0.8rem 1.6rem; + display: flex; + list-style: none; + border-radius: var(--border-radius-sm); + background-color: var(--gray-soft-light); + li:not(:last-of-type)::after { + margin: 0 2.4rem; + content: "\f054"; + font-family: 'Font Awesome 5 Free'; + font-size: 1.2rem; + font-weight: 900; + color: var(--gray-hard-darkest); + } + li:last-of-type:not(:first-of-type) span { + color: var(--information); + } + span { + color: var(--gray-hard-light); + cursor: pointer; + } + } +} + +.store-product { + --status-color: var(--success); + &.low { --status-color: var(--alert-light); } + &.out-of-stock { --status-color: var(--alert); } + + padding-top: 4rem; + gap: 0 3.2rem; + align-items: flex-start; + + .ref { + grid-area: 1 / 1 / 2 / 9; + @include text-sm; + color: var(--gray-hard-lightest); + text-transform: uppercase; + } + .name { + grid-area: 2 / 1 / 3 / 9; + margin: 0.8rem 0 3.2rem; + @include title-lg; + color: var(--gray-hard-darkest) !important; + } + .gallery { + grid-area: 3 / 1 / 4 / 4; + .picture{ + @include imageRatio; + border-radius: var(--border-radius-sm); + border: 1px solid var(--gray-soft-darkest); + img { + object-fit: contain; + cursor: pointer; + } + } + .thumbnails { + margin-top: 1.6rem; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.8rem; + .is-active { + border-color: transparent; + box-shadow: 0 0 0 2px var(--gray-hard-darkest); + } + } + } + .description { + grid-area: 3 / 4 / 4 / 9; + &-text { + padding-bottom: 4rem; + overflow: hidden; + @include editor; + transition: max-height 0.5s ease-in-out; + h3 { + @include text-base(600); + } + p { + @include text-sm; + color: var(--gray-hard-lightest); + } + } + &-toggle { + position: relative; + width: 100%; + height: 6rem; + display: flex; + justify-content: center; + align-items: flex-end; + background: linear-gradient(0deg, white 0%, transparent 100%); + border: none; + transform: translateY(-4rem); + &::before { + position: absolute; + bottom: 1.2rem; + left: 0; + content: ''; + width: 100%; + height: 1px; + background-color: var(--information); + z-index: -1; + } + span { + padding: 0 1.6rem; + color: var(--information); + background-color: var(--gray-soft-lightest); + } + } + &-document { + padding: 2.4rem; + background-color: var(--gray-soft-light); + p { @include text-sm(500); } + .list { + display: flex; + flex-wrap: wrap; + gap: 0.8rem 1.6rem; + a { + display: flex; + align-items: center; + svg { margin-right: 0.8rem; } + } + } + } + } + aside { + grid-area: 1 / -4 / 4 / -1; + position: sticky; + top: 4rem; + padding: 4rem; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius-sm); + + .price { + p { + margin: 0; + display: flex; + @include title-xl; + sup { + margin: 0.8rem 0 0 0.8rem; + @include title-sm; + } + } + span { + @include text-sm; + } + } + .to-cart { + margin-top: 1.6rem; + padding-top: 3.2rem; + display: grid; + grid-template-areas: "minus input plus" + "btn btn btn"; + grid-template-columns: repeat(3, minmax(0, min-content)); + gap: 1.6rem; + border-top: 1px solid var(--gray-soft-dark); + .minus { + grid-area: minus; + color: var(--gray-hard-darkest); + } + .plus { + grid-area: plus; + color: var(--gray-hard-darkest); + } + input { + grid-area: input; + text-align: center; + } + .main-action-btn { + grid-area: btn; + justify-content: center; + } + } + } +} diff --git a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss index 3572dd43d..bae4cfa58 100644 --- a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss +++ b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss @@ -12,7 +12,7 @@ .file-item { &.has-error { - color: var(--error); + color: var(--alert); } label { @@ -113,11 +113,15 @@ } } .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); + float: right; margin-bottom: 15px; margin-top: 15px; - float: right; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss index a886005e0..e762d10d1 100644 --- a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss +++ b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss @@ -16,7 +16,7 @@ } .missing-file { - color: var(--error); + color: var(--alert); } } diff --git a/app/frontend/src/stylesheets/modules/user/avatar-input.scss b/app/frontend/src/stylesheets/modules/user/avatar-input.scss index 47242dae1..66be087b2 100644 --- a/app/frontend/src/stylesheets/modules/user/avatar-input.scss +++ b/app/frontend/src/stylesheets/modules/user/avatar-input.scss @@ -23,7 +23,7 @@ } } .delete-avatar { - background-color: var(--error); + background-color: var(--alert); color: white; } } diff --git a/app/frontend/src/stylesheets/variables/colors.scss b/app/frontend/src/stylesheets/variables/colors.scss index 50ba2ad5f..fff231c5c 100644 --- a/app/frontend/src/stylesheets/variables/colors.scss +++ b/app/frontend/src/stylesheets/variables/colors.scss @@ -28,11 +28,11 @@ --success-dark: #229051; --success-darkest: #155239; - --error-lightest: #FDF1F1; - --error-light: #EA8585; - --error: #DA3030; - --error-dark: #9F1D1D; - --error-darkest: #611818; + --alert-lightest: #FDF1F1; + --alert-light: #EA8585; + --alert: #DA3030; + --alert-dark: #9F1D1D; + --alert-darkest: #611818; --information-lightest: #EFF6FF; --information-light: #93C5FD; @@ -40,11 +40,11 @@ --information-dark: #1E3A8A; --information-darkest: #122354; - --warning-lightest: #FFFCF4; - --warning-light: #FAE29F; - --warning: #D6AE47; - --warning-dark: #8C6D1F; - --warning-darkest: #5C4813; + --notification-lightest: #FFFCF4; + --notification-light: #FAE29F; + --notification: #D6AE47; + --notification-dark: #8C6D1F; + --notification-darkest: #5C4813; --main-text-color: black; --secondary-text-color: black; diff --git a/app/frontend/src/stylesheets/variables/decoration.scss b/app/frontend/src/stylesheets/variables/decoration.scss index 29a009f81..e1b2f447b 100644 --- a/app/frontend/src/stylesheets/variables/decoration.scss +++ b/app/frontend/src/stylesheets/variables/decoration.scss @@ -1,4 +1,20 @@ :root { --border-radius: 8px; + --border-radius-sm: 4px; --shadow: 0 0 10px rgba(39, 32, 32, 0.25); +} + +@mixin imageRatio($ratio: 100%) { + position: relative; + width: 100%; + height: 0; + padding-bottom: $ratio; + overflow: hidden; + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } } \ No newline at end of file diff --git a/app/frontend/src/stylesheets/variables/typography.scss b/app/frontend/src/stylesheets/variables/typography.scss index 156b8144b..49eea5746 100644 --- a/app/frontend/src/stylesheets/variables/typography.scss +++ b/app/frontend/src/stylesheets/variables/typography.scss @@ -56,6 +56,12 @@ font-size: 1.4rem; line-height: normal; } +@mixin text-xs($weight: normal) { + font-family: var(--font-text); + font-weight: $weight; + font-size: 1.1rem; + line-height: 1.18; +} // Text Editor @mixin editor { @@ -65,7 +71,7 @@ h3 { @include text-lg(600); margin: 0 0 1rem; - color: var(--gray-hard-darkest); + color: var(--gray-hard-darkest) !important; } ul { padding-inline-start: 2.2rem; diff --git a/app/frontend/templates/admin/calendar/icalendar.html b/app/frontend/templates/admin/calendar/icalendar.html index ab91191d3..edd60ecfe 100644 --- a/app/frontend/templates/admin/calendar/icalendar.html +++ b/app/frontend/templates/admin/calendar/icalendar.html @@ -38,7 +38,7 @@ - {{calendar.name}} plop + {{calendar.name}} {{calendar.url}} {{ calendar.text_hidden ? '' : 'app.admin.icalendar.example' }} diff --git a/app/frontend/templates/admin/orders/show.html b/app/frontend/templates/admin/orders/show.html new file mode 100644 index 000000000..eaf7f0feb --- /dev/null +++ b/app/frontend/templates/admin/orders/show.html @@ -0,0 +1,19 @@ +
+
+ +
+ +
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+ + +
\ No newline at end of file diff --git a/app/frontend/templates/admin/store/categories.html b/app/frontend/templates/admin/store/categories.html new file mode 100644 index 000000000..636dbdc36 --- /dev/null +++ b/app/frontend/templates/admin/store/categories.html @@ -0,0 +1 @@ + diff --git a/app/frontend/templates/admin/store/index.html b/app/frontend/templates/admin/store/index.html new file mode 100644 index 000000000..d13802c80 --- /dev/null +++ b/app/frontend/templates/admin/store/index.html @@ -0,0 +1,36 @@ +
+
+ +
+ +
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + +
+ +
+
diff --git a/app/frontend/templates/admin/store/orders.html b/app/frontend/templates/admin/store/orders.html new file mode 100644 index 000000000..202768686 --- /dev/null +++ b/app/frontend/templates/admin/store/orders.html @@ -0,0 +1 @@ + diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html new file mode 100644 index 000000000..02e9df374 --- /dev/null +++ b/app/frontend/templates/admin/store/product_edit.html @@ -0,0 +1,19 @@ +
+
+ +
+ +
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+ + +
\ No newline at end of file diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html new file mode 100644 index 000000000..1af8e51f6 --- /dev/null +++ b/app/frontend/templates/admin/store/product_new.html @@ -0,0 +1,19 @@ +
+
+ +
+ +
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+ + +
\ No newline at end of file diff --git a/app/frontend/templates/admin/store/products.html b/app/frontend/templates/admin/store/products.html new file mode 100644 index 000000000..e37bcce4f --- /dev/null +++ b/app/frontend/templates/admin/store/products.html @@ -0,0 +1 @@ + diff --git a/app/frontend/templates/admin/store/settings.html b/app/frontend/templates/admin/store/settings.html new file mode 100644 index 000000000..35dfdb99f --- /dev/null +++ b/app/frontend/templates/admin/store/settings.html @@ -0,0 +1 @@ + diff --git a/app/frontend/templates/cart/index.html b/app/frontend/templates/cart/index.html new file mode 100644 index 000000000..ff038fd9d --- /dev/null +++ b/app/frontend/templates/cart/index.html @@ -0,0 +1,13 @@ +
+
+ +
+ +
+

{{ 'app.public.cart.my_cart' }}

+
+
+ +
+ +
diff --git a/app/frontend/templates/dashboard/nav.html b/app/frontend/templates/dashboard/nav.html index 9f40297d0..9dedf71ae 100644 --- a/app/frontend/templates/dashboard/nav.html +++ b/app/frontend/templates/dashboard/nav.html @@ -19,6 +19,7 @@
  • {{ 'app.public.common.my_events' }}
  • {{ 'app.public.common.my_invoices' }}
  • {{ 'app.public.common.my_payment_schedules' }}
  • +
  • {{ 'app.public.common.my_orders' }}
  • {{ 'app.public.common.my_wallet' }}
  • diff --git a/app/frontend/templates/dashboard/orders.html b/app/frontend/templates/dashboard/orders.html new file mode 100644 index 000000000..3534473a2 --- /dev/null +++ b/app/frontend/templates/dashboard/orders.html @@ -0,0 +1,9 @@ +
    +
    +
    + +
    +
    + + +
    \ No newline at end of file diff --git a/app/frontend/templates/products/show.html b/app/frontend/templates/products/show.html new file mode 100644 index 000000000..7123b0a30 --- /dev/null +++ b/app/frontend/templates/products/show.html @@ -0,0 +1,17 @@ +
    +
    + +
    + +
    +

    {{ 'app.public.store.fablab_store' }}

    +
    + +
    + +
    +
    + +
    + +
    diff --git a/app/frontend/templates/shared/header.html.erb b/app/frontend/templates/shared/header.html.erb index d43802728..8f7d37845 100644 --- a/app/frontend/templates/shared/header.html.erb +++ b/app/frontend/templates/shared/header.html.erb @@ -48,6 +48,7 @@
  • {{ 'app.public.common.my_events' }}
  • {{ 'app.public.common.my_invoices' }}
  • {{ 'app.public.common.my_payment_schedules' }}
  • +
  • {{ 'app.public.common.my_orders' }}
  • {{ 'app.public.common.my_wallet' }}
  • {{ 'app.public.common.help' }}
  • diff --git a/app/frontend/templates/store/index.html b/app/frontend/templates/store/index.html new file mode 100644 index 000000000..56d615e78 --- /dev/null +++ b/app/frontend/templates/store/index.html @@ -0,0 +1,17 @@ +
    +
    + +
    + +
    +

    {{ 'app.public.store.fablab_store' }}

    +
    + +
    + +
    +
    + +
    + +
    diff --git a/app/models/coupon.rb b/app/models/coupon.rb index 744ad35ba..623c8623e 100644 --- a/app/models/coupon.rb +++ b/app/models/coupon.rb @@ -4,6 +4,7 @@ class Coupon < ApplicationRecord has_many :invoices has_many :payment_schedule + has_many :orders after_create :create_gateway_coupon before_destroy :delete_gateway_coupon @@ -82,7 +83,7 @@ class Coupon < ApplicationRecord end def users - invoices.map(&:user) + invoices.map(&:user).concat(orders.map(&:user)).uniq(&:id) end def users_ids @@ -104,5 +105,4 @@ class Coupon < ApplicationRecord def delete_gateway_coupon PaymentGatewayService.new.delete_coupon(id) end - end diff --git a/app/models/machine.rb b/app/models/machine.rb index e7d0eadde..da118eddf 100644 --- a/app/models/machine.rb +++ b/app/models/machine.rb @@ -29,6 +29,7 @@ class Machine < ApplicationRecord has_one :payment_gateway_object, as: :item + has_and_belongs_to_many :products after_create :create_statistic_subtype after_create :create_machine_prices diff --git a/app/models/order.rb b/app/models/order.rb new file mode 100644 index 000000000..4bcee9766 --- /dev/null +++ b/app/models/order.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Order is a model for the user hold information of order +class Order < PaymentDocument + belongs_to :statistic_profile + belongs_to :operator_profile, class_name: 'InvoicingProfile' + belongs_to :coupon + has_many :order_items, dependent: :destroy + has_one :payment_gateway_object, as: :item + + ALL_STATES = %w[cart in_progress ready canceled return].freeze + enum state: ALL_STATES.zip(ALL_STATES).to_h + + PAYMENT_STATES = %w[paid failed refunded].freeze + enum payment_state: PAYMENT_STATES.zip(PAYMENT_STATES).to_h + + validates :token, :state, presence: true + + before_create :add_environment + after_create :update_reference + + delegate :user, to: :statistic_profile + + def footprint_children + order_items + end + + def paid_by_card? + !payment_gateway_object.nil? && payment_method == 'card' + end + + def self.columns_out_of_footprint + %w[payment_method] + end +end diff --git a/app/models/order_item.rb b/app/models/order_item.rb new file mode 100644 index 000000000..f6e76ec5c --- /dev/null +++ b/app/models/order_item.rb @@ -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 diff --git a/app/models/payment_document.rb b/app/models/payment_document.rb index 1f2ef946c..40aa03420 100644 --- a/app/models/payment_document.rb +++ b/app/models/payment_document.rb @@ -4,8 +4,8 @@ class PaymentDocument < Footprintable self.abstract_class = true - def generate_reference(date = DateTime.current) - self.reference = PaymentDocumentService.generate_reference(self, date: date) + def generate_reference + self.reference = PaymentDocumentService.generate_order_number(self) end def update_reference diff --git a/app/models/payment_gateway_object.rb b/app/models/payment_gateway_object.rb index c5b976ac1..fa3ef7f58 100644 --- a/app/models/payment_gateway_object.rb +++ b/app/models/payment_gateway_object.rb @@ -15,6 +15,7 @@ class PaymentGatewayObject < ApplicationRecord belongs_to :machine, foreign_type: 'Machine', foreign_key: 'item_id' belongs_to :space, foreign_type: 'Space', foreign_key: 'item_id' belongs_to :training, foreign_type: 'Training', foreign_key: 'item_id' + belongs_to :order, foreign_type: 'Order', foreign_key: 'item_id' belongs_to :payment_gateway_object # some objects may require a reference to another object for remote recovery diff --git a/app/models/payment_schedule.rb b/app/models/payment_schedule.rb index 11646e8ff..c6b0c7587 100644 --- a/app/models/payment_schedule.rb +++ b/app/models/payment_schedule.rb @@ -9,7 +9,7 @@ class PaymentSchedule < PaymentDocument belongs_to :coupon belongs_to :invoicing_profile belongs_to :statistic_profile - belongs_to :operator_profile, foreign_key: :operator_profile_id, class_name: 'InvoicingProfile' + belongs_to :operator_profile, class_name: 'InvoicingProfile' has_many :payment_schedule_items has_many :payment_gateway_objects, as: :item @@ -61,9 +61,7 @@ class PaymentSchedule < PaymentDocument payment_schedule_objects.find_by(main: true) end - def user - invoicing_profile.user - end + delegate :user, to: :invoicing_profile # for debug & used by rake task "fablab:maintenance:regenerate_schedules" def regenerate_pdf diff --git a/app/models/product.rb b/app/models/product.rb new file mode 100644 index 000000000..68531e3dd --- /dev/null +++ b/app/models/product.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Product is a model for the merchandise. +# It holds data of products in the store +class Product < ApplicationRecord + extend FriendlyId + friendly_id :name, use: :slugged + + belongs_to :product_category + + has_and_belongs_to_many :machines + + has_many :product_files, as: :viewable, dependent: :destroy + accepts_nested_attributes_for :product_files, allow_destroy: true, reject_if: :all_blank + + has_many :product_images, as: :viewable, dependent: :destroy + accepts_nested_attributes_for :product_images, allow_destroy: true, reject_if: :all_blank + + 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) } +end diff --git a/app/models/product_category.rb b/app/models/product_category.rb new file mode 100644 index 000000000..40af30a1a --- /dev/null +++ b/app/models/product_category.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# 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' + has_many :children, class_name: 'ProductCategory', foreign_key: :parent_id + + has_many :products + + acts_as_list scope: :parent, top_of_list: 0 +end diff --git a/app/models/product_file.rb b/app/models/product_file.rb new file mode 100644 index 000000000..fd23a9b3f --- /dev/null +++ b/app/models/product_file.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# ProductFile is a file stored on the file system, associated with a Product. +class ProductFile < Asset + mount_uploader :attachment, ProductFileUploader +end diff --git a/app/models/product_image.rb b/app/models/product_image.rb new file mode 100644 index 000000000..9a5da4a85 --- /dev/null +++ b/app/models/product_image.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# ProductImage is an image stored on the file system, associated with a Product. +class ProductImage < Asset + mount_uploader :attachment, ProductImageUploader +end diff --git a/app/models/product_stock_movement.rb b/app/models/product_stock_movement.rb new file mode 100644 index 000000000..92cabe2fb --- /dev/null +++ b/app/models/product_stock_movement.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# A ProductStockMovement records a movement of a product's stock. +# Eg. 10 units of item X are added to the stock +class ProductStockMovement < ApplicationRecord + belongs_to :product + + ALL_STOCK_TYPES = %w[internal external].freeze + enum stock_type: ALL_STOCK_TYPES.zip(ALL_STOCK_TYPES).to_h + + ALL_REASONS = %w[incoming_stock returned_by_customer cancelled_by_customer sold missing_from_inventory damaged].freeze + enum reason: ALL_REASONS.zip(ALL_REASONS).to_h + + validates :stock_type, presence: true + validates :stock_type, inclusion: { in: ALL_STOCK_TYPES } + + validates :reason, presence: true + validates :reason, inclusion: { in: ALL_REASONS } +end diff --git a/app/policies/cart_policy.rb b/app/policies/cart_policy.rb new file mode 100644 index 000000000..4bacdda1e --- /dev/null +++ b/app/policies/cart_policy.rb @@ -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? && record.operator_profile_id.nil? + end + end +end diff --git a/app/policies/checkout_policy.rb b/app/policies/checkout_policy.rb new file mode 100644 index 000000000..045361caf --- /dev/null +++ b/app/policies/checkout_policy.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Check the access policies for API::CheckoutController +class CheckoutPolicy < ApplicationPolicy + %w[payment confirm_payment].each do |action| + define_method "#{action}?" do + return user.privileged? || (record.statistic_profile_id == user.statistic_profile.id) + end + end +end diff --git a/app/policies/product_category_policy.rb b/app/policies/product_category_policy.rb new file mode 100644 index 000000000..5f6a0fb74 --- /dev/null +++ b/app/policies/product_category_policy.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Check the access policies for API::ProductCategoriesController +class ProductCategoryPolicy < ApplicationPolicy + def create? + user.privileged? + end + + def update? + user.privileged? + end + + def destroy? + user.privileged? + end + + def position? + user.privileged? + end +end diff --git a/app/policies/product_policy.rb b/app/policies/product_policy.rb new file mode 100644 index 000000000..f64026b79 --- /dev/null +++ b/app/policies/product_policy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Check the access policies for API::ProductsController +class ProductPolicy < ApplicationPolicy + def create? + user.privileged? + end + + def update? + user.privileged? + end + + def destroy? + user.privileged? + end +end diff --git a/app/services/cart/add_item_service.rb b/app/services/cart/add_item_service.rb new file mode 100644 index 000000000..298aafbc8 --- /dev/null +++ b/app/services/cart/add_item_service.rb @@ -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.total += (orderable.amount * quantity.to_i) + ActiveRecord::Base.transaction do + item.save + order.save + end + order.reload + end +end diff --git a/app/services/cart/find_or_create_service.rb b/app/services/cart/find_or_create_service.rb new file mode 100644 index 000000000..ec506c0e2 --- /dev/null +++ b/app/services/cart/find_or_create_service.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +# Provides methods for find or create a cart +class Cart::FindOrCreateService + def initialize(user) + @user = user + end + + def call(order_token) + @order = Order.find_by(token: order_token, state: 'cart') + check_order_authorization + set_last_cart_if_user_login if @order.nil? + + if @order + if @order.order_items.count.zero? && @user && ((@user.member? && @order.statistic_profile_id.nil?) || (@user.privileged? && @order.operator_profile_id.nil?)) + set_last_order_if_anonymous_order_s_items_is_empty_after_user_login + end + clean_old_cart if @user + @order.update(statistic_profile_id: @user.statistic_profile.id) if @order.statistic_profile_id.nil? && @user&.member? + @order.update(operator_profile_id: @user.invoicing_profile.id) if @order.operator_profile_id.nil? && @user&.privileged? + return @order + end + + token = GenerateTokenService.new.call(Order) + order_param = { + token: token, + state: 'cart', + total: 0 + } + if @user + order_param[:statistic_profile_id] = @user.statistic_profile.id if @user.member? + + order_param[:operator_profile_id] = @user.invoicing_profile.id if @user.privileged? + end + Order.create!(order_param) + end + + # This function check current order that + # 1. belongs current user + # 2. has belonged an user but this user dont login + # 3. created date > last paid order of user + # if not, set current order = nil + def check_order_authorization + if @order && @user && ((@user.member? && @order.statistic_profile_id.present? && @order.statistic_profile_id != @user.statistic_profile.id) || + (@user.privileged? && @order.operator_profile_id.present? && @order.operator_profile_id != @user.invoicing_profile.id)) + @order = nil + end + @order = nil if @order && !@user && (@order.statistic_profile_id.present? || @order.operator_profile_id.present?) + if @order && @order.statistic_profile_id.present? && Order.where(statistic_profile_id: @order.statistic_profile_id, + payment_state: 'paid').where('created_at > ?', @order.created_at).last.present? + @order = nil + end + if @order && @order.operator_profile_id.present? && Order.where(operator_profile_id: @order.operator_profile_id, + payment_state: 'paid').where('created_at > ?', @order.created_at).last.present? + @order = nil + end + end + + # set user last cart of user when login + def set_last_cart_if_user_login + if @user&.member? + last_paid_order = Order.where(statistic_profile_id: @user.statistic_profile.id, + payment_state: 'paid').last + @order = if last_paid_order + Order.where(statistic_profile_id: @user.statistic_profile.id, + state: 'cart').where('created_at > ?', last_paid_order.created_at).last + else + Order.where(statistic_profile_id: @user.statistic_profile.id, state: 'cart').last + end + end + if @user&.privileged? + last_paid_order = Order.where(operator_profile_id: @user.invoicing_profile.id, + payment_state: 'paid').last + @order = if last_paid_order + Order.where(operator_profile_id: @user.invoicing_profile.id, + state: 'cart').where('created_at > ?', last_paid_order.created_at).last + else + Order.where(operator_profile_id: @user.invoicing_profile.id, state: 'cart').last + end + end + end + + # set last order if current cart is anoymous and user is login + def set_last_order_if_anonymous_order_s_items_is_empty_after_user_login + last_unpaid_order = nil + if @user&.member? + last_paid_order = Order.where(statistic_profile_id: @user.statistic_profile.id, + payment_state: 'paid').last + last_unpaid_order = if last_paid_order + Order.where(statistic_profile_id: @user.statistic_profile.id, + state: 'cart').where('created_at > ?', last_paid_order.created_at).last + else + Order.where(statistic_profile_id: @user.statistic_profile.id, state: 'cart').last + end + end + if @user&.privileged? + last_paid_order = Order.where(operator_profile_id: @user.invoicing_profile.id, + payment_state: 'paid').last + last_unpaid_order = if last_paid_order + Order.where(operator_profile_id: @user.invoicing_profile.id, + state: 'cart').where('created_at > ?', last_paid_order.created_at).last + else + Order.where(operator_profile_id: @user.invoicing_profile.id, state: 'cart').last + end + end + if last_unpaid_order && last_unpaid_order.id != @order.id + @order.destroy + @order = last_unpaid_order + end + end + + # delete all old cart if last cart of user isnt empty + # keep every user only one cart + def clean_old_cart + if @user&.member? + Order.where(statistic_profile_id: @user.statistic_profile.id, state: 'cart') + .where.not(id: @order.id) + .destroy_all + end + if @user&.privileged? + Order.where(operator_profile_id: @user.invoicing_profile.id, state: 'cart') + .where.not(id: @order.id) + .destroy_all + end + end + + # delete all empty cart if last cart of user isnt empty + def clean_empty_cart + if @user&.member? + Order.where(statistic_profile_id: @user.statistic_profile.id, state: 'cart') + .where('(SELECT COUNT(*) FROM order_items WHERE order_items.order_id = orders.id) = 0') + .destroy_all + end + if @user&.privileged? + Order.where(operator_profile_id: @user.invoicing_profile.id, state: 'cart') + .where('(SELECT COUNT(*) FROM order_items WHERE order_items.order_id = orders.id) = 0') + .destroy_all + end + end +end diff --git a/app/services/cart/remove_item_service.rb b/app/services/cart/remove_item_service.rb new file mode 100644 index 000000000..cfa43c1ef --- /dev/null +++ b/app/services/cart/remove_item_service.rb @@ -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.total -= (item.amount * item.quantity.to_i) + ActiveRecord::Base.transaction do + item.destroy! + order.save + end + order.reload + end +end diff --git a/app/services/cart/set_quantity_service.rb b/app/services/cart/set_quantity_service.rb new file mode 100644 index 000000000..396716de5 --- /dev/null +++ b/app/services/cart/set_quantity_service.rb @@ -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.total += (orderable.amount * different_quantity) + ActiveRecord::Base.transaction do + item.update(quantity: quantity.to_i) + order.save + end + order.reload + end +end diff --git a/app/services/checkout/payment_service.rb b/app/services/checkout/payment_service.rb new file mode 100644 index 000000000..6f6e94216 --- /dev/null +++ b/app/services/checkout/payment_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Provides methods for pay cart +class Checkout::PaymentService + require 'pay_zen/helper' + require 'stripe/helper' + include Payments::PaymentConcern + + def payment(order, operator, coupon_code, payment_id = '') + raise Cart::OutStockError unless Orders::OrderService.new.in_stock?(order, 'external') + + raise Cart::InactiveProductError unless Orders::OrderService.new.all_products_is_active?(order) + + CouponService.new.validate(coupon_code, order.statistic_profile.user) + + amount = debit_amount(order) + if operator.privileged? || amount.zero? + Payments::LocalService.new.payment(order, coupon_code) + elsif operator.member? + if Stripe::Helper.enabled? + Payments::StripeService.new.payment(order, coupon_code, payment_id) + elsif PayZen::Helper.enabled? + Payments::PayzenService.new.payment(order, coupon_code) + else + raise Error('Bad gateway or online payment is disabled') + end + end + end + + def confirm_payment(order, operator, coupon_code, payment_id = '') + if operator.member? + if Stripe::Helper.enabled? + Payments::StripeService.new.confirm_payment(order, coupon_code, payment_id) + elsif PayZen::Helper.enabled? + Payments::PayzenService.new.confirm_payment(order, coupon_code, payment_id) + else + raise Error('Bad gateway or online payment is disabled') + end + end + end +end diff --git a/app/services/generate_token_service.rb b/app/services/generate_token_service.rb new file mode 100644 index 000000000..d0ed74972 --- /dev/null +++ b/app/services/generate_token_service.rb @@ -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 diff --git a/app/services/orders/order_service.rb b/app/services/orders/order_service.rb new file mode 100644 index 000000000..b4febade9 --- /dev/null +++ b/app/services/orders/order_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Provides methods for Order +class Orders::OrderService + def in_stock?(order, stock_type = 'external') + order.order_items.each do |item| + return false if item.orderable.stock[stock_type] < item.quantity + end + true + end + + def all_products_is_active?(order) + order.order_items.each do |item| + return false unless item.orderable.is_active + end + true + end +end diff --git a/app/services/payment_document_service.rb b/app/services/payment_document_service.rb index 1590eff3c..4d099ed84 100644 --- a/app/services/payment_document_service.rb +++ b/app/services/payment_document_service.rb @@ -43,16 +43,16 @@ class PaymentDocumentService reference end - def generate_order_number(invoice) + def generate_order_number(document) pattern = Setting.get('invoice_order-nb') # global document number (nn..nn) reference = pattern.gsub(/n+(?![^\[]*\])/) do |match| - pad_and_truncate(number_of_invoices('global'), match.to_s.length) + pad_and_truncate(number_of_invoices(document.is_a?(Order) ? 'order' : 'global'), match.to_s.length) end - reference = replace_invoice_number_pattern(reference, invoice.created_at) - replace_date_pattern(reference, invoice.created_at) + reference = replace_invoice_number_pattern(reference, document.created_at) + replace_date_pattern(reference, document.created_at) end private @@ -83,13 +83,14 @@ class PaymentDocumentService when 'year' start = date.beginning_of_year else - return get_max_id(Invoice) + get_max_id(PaymentSchedule) + return get_max_id(Invoice) + get_max_id(PaymentSchedule) + get_max_id(Order) end ending = date - return Invoice.count + PaymentSchedule.count unless defined? start + return Invoice.count + PaymentSchedule.count + Order.count unless defined? start Invoice.where('created_at >= :start_date AND created_at <= :end_date', start_date: start, end_date: ending).length + - PaymentSchedule.where('created_at >= :start_date AND created_at <= :end_date', start_date: start, end_date: ending).length + PaymentSchedule.where('created_at >= :start_date AND created_at <= :end_date', start_date: start, end_date: ending).length + + Order.where('created_at >= :start_date AND created_at <= :end_date', start_date: start, end_date: ending).length end ## @@ -101,21 +102,21 @@ class PaymentDocumentService copy = reference.dup # full year (YYYY) - copy.gsub!(/YYYY(?![^\[]*\])/, date.strftime('%Y')) + copy.gsub!(/(?![^\[]*\])YYYY(?![^\[]*\])/, date.strftime('%Y')) # year without century (YY) - copy.gsub!(/YY(?![^\[]*\])/, date.strftime('%y')) + copy.gsub!(/(?![^\[]*\])YY(?![^\[]*\])/, date.strftime('%y')) # abbreviated month name (MMM) - copy.gsub!(/MMM(?![^\[]*\])/, date.strftime('%^b')) + copy.gsub!(/(?![^\[]*\])MMM(?![^\[]*\])/, date.strftime('%^b')) # month of the year, zero-padded (MM) - copy.gsub!(/MM(?![^\[]*\])/, date.strftime('%m')) + copy.gsub!(/(?![^\[]*\])MM(?![^\[]*\])/, date.strftime('%m')) # month of the year, non zero-padded (M) - copy.gsub!(/M(?![^\[]*\])/, date.strftime('%-m')) + copy.gsub!(/(?![^\[]*\])M(?![^\[]*\])/, date.strftime('%-m')) # day of the month, zero-padded (DD) - copy.gsub!(/DD(?![^\[]*\])/, date.strftime('%d')) + copy.gsub!(/(?![^\[]*\])DD(?![^\[]*\])/, date.strftime('%d')) # day of the month, non zero-padded (DD) - copy.gsub!(/DD(?![^\[]*\])/, date.strftime('%-d')) + copy.gsub!(/(?![^\[]*\])DD(?![^\[]*\])/, date.strftime('%-d')) copy end diff --git a/app/services/payments/local_service.rb b/app/services/payments/local_service.rb new file mode 100644 index 000000000..43bd07d08 --- /dev/null +++ b/app/services/payments/local_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Provides methods for pay cart by Local +class Payments::LocalService + include Payments::PaymentConcern + + def payment(order, coupon_code) + o = payment_success(order, coupon_code, 'local') + { order: o } + end +end diff --git a/app/services/payments/payment_concern.rb b/app/services/payments/payment_concern.rb new file mode 100644 index 000000000..5e2686ff9 --- /dev/null +++ b/app/services/payments/payment_concern.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Concern for Payment +module Payments::PaymentConcern + private + + def get_wallet_debit(user, total_amount) + wallet_amount = (user.wallet.amount * 100).to_i + wallet_amount >= total_amount ? total_amount : wallet_amount + end + + def debit_amount(order, coupon_code = nil) + total = CouponService.new.apply(order.total, coupon_code, order.statistic_profile.user) + wallet_debit = get_wallet_debit(order.statistic_profile.user, total) + total - wallet_debit + end + + def payment_success(order, coupon_code, payment_method = '', payment_id = nil, payment_type = nil) + ActiveRecord::Base.transaction do + order.paid_total = debit_amount(order, coupon_code) + coupon = Coupon.find_by(code: coupon_code) + order.coupon_id = coupon.id if coupon + WalletService.debit_user_wallet(order, order.statistic_profile.user) + order.operator_profile_id = order.statistic_profile.user.invoicing_profile.id if order.operator_profile.nil? + order.payment_method = if order.total == order.wallet_amount + 'wallet' + else + payment_method + end + order.state = 'in_progress' + order.payment_state = 'paid' + if payment_id && payment_type + order.payment_gateway_object = PaymentGatewayObject.new(gateway_object_id: payment_id, gateway_object_type: payment_type) + end + order.order_items.each do |item| + ProductService.update_stock(item.orderable, 'external', 'sold', -item.quantity, item.id) + end + order.save + order.reload + end + end +end diff --git a/app/services/payments/payzen_service.rb b/app/services/payments/payzen_service.rb new file mode 100644 index 000000000..30e1adeac --- /dev/null +++ b/app/services/payments/payzen_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Provides methods for pay cart by PayZen +class Payments::PayzenService + require 'pay_zen/helper' + require 'pay_zen/order' + require 'pay_zen/charge' + require 'pay_zen/service' + include Payments::PaymentConcern + + def payment(order, coupon_code) + amount = debit_amount(order, coupon_code) + + raise Cart::ZeroPriceError if amount.zero? + + id = PayZen::Helper.generate_ref(order, order.statistic_profile.user.id) + + client = PayZen::Charge.new + result = client.create_payment(amount: PayZen::Service.new.payzen_amount(amount), + order_id: id, + customer: PayZen::Helper.generate_customer(order.statistic_profile.user.id, + order.statistic_profile.user.id, order)) + { order: order, payment: { formToken: result['answer']['formToken'], orderId: id } } + end + + def confirm_payment(order, coupon_code, payment_id) + client = PayZen::Order.new + payzen_order = client.get(payment_id, operation_type: 'DEBIT') + + if payzen_order['answer']['transactions'].any? { |transaction| transaction['status'] == 'PAID' } + o = payment_success(order, coupon_code, 'card', payment_id, 'PayZen::Order') + { order: o } + else + order.update(payment_state: 'failed') + { order: order, payment: { error: { statusText: payzen_order['answer'] } } } + end + end +end diff --git a/app/services/payments/stripe_service.rb b/app/services/payments/stripe_service.rb new file mode 100644 index 000000000..c4c8a09b5 --- /dev/null +++ b/app/services/payments/stripe_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# Provides methods for pay cart by Stripe +class Payments::StripeService + require 'stripe/service' + include Payments::PaymentConcern + + def payment(order, coupon_code, payment_id) + amount = debit_amount(order, coupon_code) + + raise Cart::ZeroPriceError if amount.zero? + + # Create the PaymentIntent + intent = Stripe::PaymentIntent.create( + { + payment_method: payment_id, + amount: Stripe::Service.new.stripe_amount(amount), + currency: Setting.get('stripe_currency'), + confirmation_method: 'manual', + confirm: true, + customer: order.statistic_profile.user.payment_gateway_object.gateway_object_id + }, { api_key: Setting.get('stripe_secret_key') } + ) + + if intent&.status == 'succeeded' + o = payment_success(order, coupon_code, 'card', intent.id, intent.class.name) + return { order: o } + end + + if intent&.status == 'requires_action' && intent&.next_action&.type == 'use_stripe_sdk' + { order: order, payment: { requires_action: true, payment_intent_client_secret: intent.client_secret, + type: 'payment' } } + end + end + + def confirm_payment(order, coupon_code, payment_id) + intent = Stripe::PaymentIntent.confirm(payment_id, {}, { api_key: Setting.get('stripe_secret_key') }) + if intent&.status == 'succeeded' + o = payment_success(order, coupon_code, 'card', intent.id, intent.class.name) + { order: o } + else + order.update(payment_state: 'failed') + { order: order, payment: { error: { statusText: 'payment failed' } } } + end + end +end diff --git a/app/services/product_category_service.rb b/app/services/product_category_service.rb new file mode 100644 index 000000000..fbcc72cf8 --- /dev/null +++ b/app/services/product_category_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Provides methods for ProductCategory +class ProductCategoryService + def self.list + ProductCategory.all.order(parent_id: :asc, position: :asc) + end + + def self.destroy(product_category) + ActiveRecord::Base.transaction do + sub_categories = ProductCategory.where(parent_id: product_category.id) + # remove product_category and sub-categories related id in product + Product.where(product_category_id: sub_categories.map(&:id).push(product_category.id)).update(product_category_id: nil) + # remove all sub-categories + sub_categories.destroy_all + product_category.destroy + end + end +end diff --git a/app/services/product_service.rb b/app/services/product_service.rb new file mode 100644 index 000000000..811a48322 --- /dev/null +++ b/app/services/product_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Provides methods for Product +class ProductService + 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 + def self.amount_multiplied_by_hundred(amount) + if amount.present? + v = amount.to_f + + return nil if v.zero? + + return v * 100 + end + nil + end + + def self.update_stock(product, stock_type, reason, quantity, order_item_id = nil) + remaining_stock = product.stock[stock_type] + quantity + product.product_stock_movements.create(stock_type: stock_type, reason: reason, quantity: quantity, remaining_stock: remaining_stock, + date: DateTime.current, + order_item_id: order_item_id) + product.stock[stock_type] = remaining_stock + product.save + end +end diff --git a/app/services/wallet_service.rb b/app/services/wallet_service.rb index 73f2b31ee..2d23ad77a 100644 --- a/app/services/wallet_service.rb +++ b/app/services/wallet_service.rb @@ -75,7 +75,7 @@ class WalletService ## # Compute the amount decreased from the user's wallet, if applicable - # @param payment {Invoice|PaymentSchedule} + # @param payment {Invoice|PaymentSchedule|Order} # @param user {User} the customer # @param coupon {Coupon|String} Coupon object or code ## @@ -93,7 +93,7 @@ class WalletService end ## - # Subtract the amount of the payment document (Invoice|PaymentSchedule) from the customer's wallet + # Subtract the amount of the payment document (Invoice|PaymentSchedule|Order) from the customer's wallet # @param transaction, if false: the wallet is not debited, the transaction is only simulated on the payment document ## def self.debit_user_wallet(payment, user, transaction: true) @@ -111,5 +111,4 @@ class WalletService payment.set_wallet_transaction(wallet_amount, nil) end end - end diff --git a/app/uploaders/product_file_uploader.rb b/app/uploaders/product_file_uploader.rb new file mode 100644 index 000000000..1cee5d75c --- /dev/null +++ b/app/uploaders/product_file_uploader.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# CarrierWave uploader for file of product +# This file defines the parameters for these uploads. +class ProductFileUploader < CarrierWave::Uploader::Base + # Include RMagick or MiniMagick support: + # include CarrierWave::RMagick + # include CarrierWave::MiniMagick + include UploadHelper + + # Choose what kind of storage to use for this uploader: + storage :file + # storage :fog + + after :remove, :delete_empty_dirs + + # Override the directory where uploaded files will be stored. + # This is a sensible default for uploaders that are meant to be mounted: + def store_dir + "#{base_store_dir}/#{model.id}" + end + + def base_store_dir + "uploads/#{model.class.to_s.underscore}" + end + + # Provide a default URL as a default if there hasn't been a file uploaded: + # def default_url + # # For Rails 3.1+ asset pipeline compatibility: + # # ActionController::Base.helpers.asset_pack_path("fallback/" + [version_name, "default.png"].compact.join('_')) + # + # "/images/fallback/" + [version_name, "default.png"].compact.join('_') + # end + + # Process files as they are uploaded: + # process :scale => [200, 300] + # + # def scale(width, height) + # # do something + # end + + # Create different versions of your uploaded files: + # version :thumb do + # process :resize_to_fit => [50, 50] + # end + + # Add a white list of extensions which are allowed to be uploaded. + # For images you might use something like this: + def extension_whitelist + %w[pdf] + end + + def content_type_whitelist + ['application/pdf'] + end + + # Override the filename of the uploaded files: + # Avoid using model.id or version_name here, see uploader/store.rb for details. + def filename + if original_filename + original_filename.split('.').map do |s| + ActiveSupport::Inflector.transliterate(s).to_s + end.join('.') + end + end +end diff --git a/app/uploaders/product_image_uploader.rb b/app/uploaders/product_image_uploader.rb new file mode 100644 index 000000000..8b591a84b --- /dev/null +++ b/app/uploaders/product_image_uploader.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# CarrierWave uploader for image of product +# This file defines the parameters for these uploads. +class ProductImageUploader < CarrierWave::Uploader::Base + include CarrierWave::MiniMagick + include UploadHelper + + # Choose what kind of storage to use for this uploader: + storage :file + after :remove, :delete_empty_dirs + + # Override the directory where uploaded files will be stored. + # This is a sensible default for uploaders that are meant to be mounted: + def store_dir + "#{base_store_dir}/#{model.id}" + end + + def base_store_dir + "uploads/#{model.class.to_s.underscore}" + end + + # Provide a default URL as a default if there hasn't been a file uploaded: + # def default_url + # # For Rails 3.1+ asset pipeline compatibility: + # # ActionController::Base.helpers.asset_pack_path("fallback/" + [version_name, "default.png"].compact.join('_')) + # + # "/images/fallback/" + [version_name, "default.png"].compact.join('_') + # end + + # Process files as they are uploaded: + # process :scale => [200, 300] + # + # def scale(width, height) + # # do something + # end + + # Create different versions of your uploaded files: + # version :thumb do + # process :resize_to_fit => [50, 50] + # end + + # Create different versions of your uploaded files: + version :large do + process resize_to_fit: [1000, 700] + end + + version :medium do + process resize_to_fit: [700, 400] + end + + version :small do + process resize_to_fit: [400, 250] + end + + # Add a white list of extensions which are allowed to be uploaded. + # For images you might use something like this: + def extension_whitelist + %w[jpg jpeg gif png] + end + + def content_type_whitelist + [%r{image/}] + end + + # Override the filename of the uploaded files: + # Avoid using model.id or version_name here, see uploader/store.rb for details. + def filename + if original_filename + original_filename.split('.').map do |s| + ActiveSupport::Inflector.transliterate(s).to_s + end.join('.') + end + end + + # return an array like [width, height] + def dimensions + ::MiniMagick::Image.open(file.file)[:dimensions] + end +end diff --git a/app/views/api/orders/_order.json.jbuilder b/app/views/api/orders/_order.json.jbuilder new file mode 100644 index 000000000..a002b1483 --- /dev/null +++ b/app/views/api/orders/_order.json.jbuilder @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +json.extract! order, :id, :token, :statistic_profile_id, :operator_profile_id, :reference, :state, :created_at +json.total order.total / 100.0 if order.total.present? +if order&.statistic_profile&.user + json.user do + json.id order.statistic_profile.user.id + json.role order.statistic_profile.user.roles.first.name + json.name order.statistic_profile.user.profile.full_name + end +end + +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 diff --git a/app/views/api/orders/show.json.jbuilder b/app/views/api/orders/show.json.jbuilder new file mode 100644 index 000000000..d3b6da6f5 --- /dev/null +++ b/app/views/api/orders/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/orders/order', order: @order diff --git a/app/views/api/product_categories/_product_category.json.jbuilder b/app/views/api/product_categories/_product_category.json.jbuilder new file mode 100644 index 000000000..e691d8276 --- /dev/null +++ b/app/views/api/product_categories/_product_category.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.extract! product_category, :id, :name, :slug, :parent_id, :position diff --git a/app/views/api/product_categories/create.json.jbuilder b/app/views/api/product_categories/create.json.jbuilder new file mode 100644 index 000000000..061c999fe --- /dev/null +++ b/app/views/api/product_categories/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/product_categories/product_category', product_category: @product_category diff --git a/app/views/api/product_categories/index.json.jbuilder b/app/views/api/product_categories/index.json.jbuilder new file mode 100644 index 000000000..b97ceff09 --- /dev/null +++ b/app/views/api/product_categories/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.array! @product_categories do |product_category| + json.partial! 'api/product_categories/product_category', product_category: product_category +end diff --git a/app/views/api/product_categories/show.json.jbuilder b/app/views/api/product_categories/show.json.jbuilder new file mode 100644 index 000000000..061c999fe --- /dev/null +++ b/app/views/api/product_categories/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/product_categories/product_category', product_category: @product_category diff --git a/app/views/api/product_categories/update.json.jbuilder b/app/views/api/product_categories/update.json.jbuilder new file mode 100644 index 000000000..061c999fe --- /dev/null +++ b/app/views/api/product_categories/update.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/product_categories/product_category', product_category: @product_category diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder new file mode 100644 index 000000000..8e70bb0be --- /dev/null +++ b/app/views/api/products/_product.json.jbuilder @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +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 + json.attachment_name f.attachment_identifier + json.attachment_url f.attachment_url +end +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 +json.product_stock_movements_attributes product.product_stock_movements do |s| + json.id s.id + json.quantity s.quantity + json.reason s.reason + json.stock_type s.stock_type + json.remaining_stock s.remaining_stock + json.date s.date +end diff --git a/app/views/api/products/create.json.jbuilder b/app/views/api/products/create.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/api/products/index.json.jbuilder b/app/views/api/products/index.json.jbuilder new file mode 100644 index 000000000..10be62f81 --- /dev/null +++ b/app/views/api/products/index.json.jbuilder @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +json.array! @products do |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 diff --git a/app/views/api/products/show.json.jbuilder b/app/views/api/products/show.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/api/products/update.json.jbuilder b/app/views/api/products/update.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/update.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index d2687bec2..655671261 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -100,7 +100,7 @@ en: delete_this_and_next: "This slot and the following" delete_all: "All slots" event_in_the_past: "Create a slot in the past" - confirm_create_event_in_the_past: "You are about to create a slot in the past. Are you sure you want to do this? Members will not be able to book this slot." + confirm_create_event_in_the_past: "You are about to create a slot in the past. Are you sure you want to do this? Members on the store? will not be able to book this slot." edit_event: "Edit the event" view_reservations: "View reservations" legend: "Legend" @@ -1893,3 +1893,182 @@ en: doc: title: "Documentation" content: "Click here to access the API online documentation." + store: + manage_the_store: "Manage the Store" + settings: "Settings" + all_products: "All products" + categories_of_store: "Store's categories" + the_orders: "Orders" + back_products_list: "Back to products list" + product_categories: + title: "Categories" + info: "Information:
    Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted. Make sure that your categories are well arranged and save your choice." + manage_product_category: + create: "Create a product category" + update: "Modify the product category" + delete: "Delete the product category" + product_category_modal: + new_product_category: "Create a category" + edit_product_category: "Modify a category" + product_category_form: + name: "Name of category" + slug: "Name of URL" + select_parent_product_category: "Choose a parent category (N1)" + no_parent: "No parent" + create: + error: "Unable to create the category: " + success: "The new category has been created." + update: + error: "Unable to modify the category: " + success: "The category has been modified." + delete: + confirm: "Do you really want to delete this product category?" + error: "Unable to delete the category: " + success: "The category has been successfully deleted" + save: "Save" + required: "This field is required" + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + products: + all_products: "All products" + create_a_product: "Create a product" + successfully_deleted: "The product has been successfully deleted" + unable_to_delete: "Unable to delete the product: " + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "By categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + filter_stock: "By stock status" + filter_stock_from: "From" + filter_stock_to: "to" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_list_header: + result_count: "Result count:" + display_options: "Display options:" + visible_only: "Visible products only" + product_item: + visible: "visible" + hidden: "hidden" + stock: + internal: "Private stock" + external: "Public stock" + unit: "unit" + new_product: + add_a_new_product: "Add a new product" + successfully_created: "The new product has been created." + edit_product: + successfully_updated: "The product has been updated." + product_form: + product_parameters: "Product parameters" + stock_management: "Stock management" + name: "Name of product" + sku: "Reference product (SKU)" + slug: "Name of URL" + is_show_in_store: "Available in the store" + is_active_price: "Activate the price" + active_price_info: "Is this product visible by the members on the store?" + price_and_rule_of_selling_product: "Price and rule for selling the product" + price: "Price of product" + quantity_min: "Minimum number of items for the shopping cart" + linking_product_to_category: "Linking this product to an existing category" + assigning_category: "Assigning a category" + assigning_category_info: "Information
    You can only declare one category per product. If you assign this product to a sub-category, it will automatically be assigned to its parent category as well." + assigning_machines: "Assigning machines" + assigning_machines_info: "Information
    You can link one or more machines from your fablab to your product, this product will then be subject to the filters on the catalogue view.
    The machines selected below will be linked to the product." + product_description: "Product description" + product_description_info: "Information
    This product description will be present in the product sheet. You have a few editorial styles at your disposal to create the product sheet." + product_files: "Document" + product_files_info: "Information
    Add documents related to this product, the uploaded documents will be presented in the product sheet, in a separate block. You can only upload pdf documents." + add_product_file: "Add a document" + product_images: "Images of product" + product_images_info: "Advice
    We advise you to use a square format, jpg or png, for jpgs, please use white for the background colour. The main visual will be the visual presented first in the product sheet." + add_product_image: "Add an image" + save: "Save" + product_stock_form: + low_stock_threshold: "Define a low stock threshold" + stock_threshold_toggle: "Activate stock threshold" + stock_threshold_information: "Information
    Define a low stock threshold and receive a notification when it's reached.
    Above the threshold, the product is available in the store. When the threshold is reached, the product quantity is labeled as low." + low_stock: "Low stock" + threshold_level: "Minimum threshold level" + threshold_alert: "Notify me when the threshold is reached" + event_type: "Events:" + stocks: "Stocks:" + internal: "Private stock" + external: "Public stock" + all: "All types" + stock_level: "Stock level" + events: + inward_stock: "Inward stock" + returned: "Returned by client" + canceled: "Canceled by client" + sold: "Sold" + missing: "Missing in stock" + damaged: "Damaged product" + events_history: "Events history" + modal_title: "Manage stock" + product_stock_modal: + internal: "Private stock" + external: "Public stock" + new_event: "New stock event" + addition: "Addition" + withdrawal: "Withdrawal" + update_stock: "Update stock" + event_type: "Events:" + stocks: "Stocks:" + quantity: "Quantity" + events: + inward_stock: "Inward stock" + returned: "Returned by client" + canceled: "Canceled by client" + sold: "Sold" + missing: "Missing in stock" + damaged: "Damaged product" + orders: + heading: "Orders" + create_order: "Create an order" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_ref: "By reference" + filter_status: "By status" + filter_client: "By client" + status: + error: "Payment error" + canceled: "Canceled" + pending: "Pending payment" + under_preparation: "Under preparation" + paid: "Paid" + ready: "Ready" + collected: "Collected" + refunded: "Refunded" + sort: + newest: "Newest first" + oldest: "Oldest first" + order_item: + total: "Total" + client: "Client" + show_order: + see_invoice: "See invoice" + client: "Client" + created_at: "Creation date" + last_update: "Last update" + cart: "Cart" + reference_short: "ref:" + unit: "Unit" + item_total: "Total" + payment_informations : "Payment informations" + amount: "Amount" + products_total: "Products total" + gift_total: "Discount total" + coupon: "Coupon" + cart_total: "Cart total" + store_settings: + title: 'Settings' + withdrawal_instructions: 'Product withdrawal instructions' + withdrawal_info: "This text is displayed on the checkout page to inform the client about the products withdrawal method" + save: "Save" \ No newline at end of file diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 4009232af..67b74d79a 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1893,3 +1893,70 @@ fr: doc: title: "Documentation" content: "Cliquez ici pour accéder à la documentation en ligne de l'API." + store: + manage_the_store: "Gestion de la Boutique Fablab" + settings: "Paramètres" + all_products: "Tous les produits" + categories_of_store: "Les catégories de la boutique" + the_orders: "Les commandes" + back_products_list: "Retrounez à la liste" + product_categories: + title: "Les catégories" + info: "Information:
    Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveaux maximum, vous pouvez les agencer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." + manage_product_category: + create: "Créer une catégorie" + update: "Modifier la catégorie" + delete: "Supprimer la catégorie" + product_category_modal: + new_product_category: "Créer une catégorie" + edit_product_category: "Modifier la catégorie" + product_category_form: + name: "Nom de la catégorie" + slug: "Nom de l'URL" + select_parent_product_category: "Choisir une catégorie parent (N1)" + create: + error: "Impossible de créer la catégorie : " + success: "La catégorie a bien été créée." + update: + error: "Impossible de modifier la catégorie : " + success: "La nouvelle catégorie a bien été mise à jour." + delete: + confirm: "Voulez-vous vraiment supprimer cette catégorie de produits ?" + error: "Impossible de supprimer the catégorie : " + success: "La catégorie a bien été supprimée" + save: "Enregistrer" + required: "Le champ est requise" + slug_pattern: "Uniquement des groupes de caractères alphanumériques minuscules séparés par un trait d'union." + products: + all_products: "Tous les produits" + create_a_product: "Créer un produit" + successfully_deleted: "Le produit a bien été supprimé" + unable_to_delete: "Impossible de supprimer le produit: " + new_product: + add_a_new_product: "Ajouter un nouveau produit" + successfully_created: "Le produit a bien été créée." + edit_product: + successfully_updated: "Le produit a bien été mise à jour." + product_form: + name: "Nom de produit" + sku: "Référence produit (SKU)" + slug: "Nom de l'URL" + is_show_in_store: "Visible dans la boutique" + is_active_price: "Activer le prix" + price_and_rule_of_selling_product: "Prix et règle de vente du produit" + price: "Prix du produit" + quantity_min: "Nombre d'article minimum pour la mise au panier" + linking_product_to_category: "Lier ce product à une catégorie exisante" + assigning_category: "Attribuer à une catégorie" + assigning_category_info: "Information
    Vous ne pouvez déclarer qu'une catégorie par produit. Si vous attribuez ce produit à une sous catégorie, il sera attribué automatiquement aussi à sa catégorie parent." + assigning_machines: "Attribuer aux machines" + assigning_machines_info: "Information
    Vous pouvez lier une ou plusieurs machines de votre fablab à votre produit, Ce produit sera alors assujetti aux filtres sur la vue catalogue.
    Les machines sélectionnées ci-dessous seront liées au produit." + product_description: "Description du produit" + product_description_info: "Information
    Cette description du produit sera présente dans la fiche du produit. Vous avez à disposition quelques styles rédactionnels pour créer la fiche du produit." + product_files: "Documentation" + product_files_info: "Information
    Ajouter des documents liés à ce produit, les document uploadés seront présentés dans la fiche produit, dans un bloc distinct. Vous pouvez uploadé des pdf uniquement." + add_product_file: "Ajouter un document" + product_images: "Visuel(s) du produit" + product_images_info: "Conseils
    Nous vous conseillons d'utiliser un format carré, jpg ou png, pour les jpgs, merci de privilégier le blanc pour la couleur de fond. Le visuel principal sera le visuel présenté en premier dans la fiche produit." + add_product_image: "Ajouter un visuel" + save: "Enregistrer" diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index fc53c41f3..94c20e24d 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -22,6 +22,7 @@ en: my_events: "My Events" my_invoices: "My Invoices" my_payment_schedules: "My payment schedules" + my_orders: "My orders" my_wallet: "My Wallet" #contextual help help: "Help" @@ -43,6 +44,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" @@ -51,6 +53,7 @@ en: subscriptions_and_prices: "Subscriptions and Prices" manage_the_events: "Events" manage_the_machines: "Machines" + manage_the_store: "Store" manage_the_spaces: "Spaces" projects: "Projects" statistics: "Statistics" @@ -217,6 +220,11 @@ en: new_availability: "Open reservations" book: "Book" _or_the_: " or the " + store_ad: + title: "Discover our store" + buy: "Check out products from members' projects along with consumable related to the different machines and tools of the workshop." + sell: "If you also want to sell your creations, please let us know." + link: "To the store" machines_filters: show_machines: "Show machines" status_enabled: "Enabled" @@ -372,6 +380,59 @@ 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." + add_to_cart_success: "Product added to the cart." + products: + all_products: "All the products" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "Categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + in_stock_only: "Available products only" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_product: + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + show_more: "Display more" + show_less: "Display less" + documentation: "Documentation" + store_product_item: + available: "Available" + limited_stock: "Limited stock" + out_of_stock: "Out of stock" + add: "Add" + add_to_cart: "Add to cart" + unit: "unit" + cart: + my_cart: "My Cart" + cart_button: + my_cart: "My Cart" + store_cart: + checkout: "Checkout" + cart_is_empty: "Your cart is empty" + pickup: "Pickup your products" + reference_short: "ref:" + unit: "Unit" + total: "Total" + checkout_header: "Total amount for your cart" + checkout_products_COUNT: "Your cart contains {COUNT} {COUNT, plural, =1{product} other{products}}" + checkout_products_total: "Products total" + checkout_gift_total: "Discount total" + checkout_coupon: "Coupon" + checkout_total: "Cart total" + checkout_error: "An unexpected error occurred. Please contact the administrator." + checkout_success: "Purchase confirmed. Thanks!" + member_select: + select_a_member: "Select a member" + start_typing: "Start typing..." tour: conclusion: title: "Thank you for your attention" diff --git a/config/locales/app.public.fr.yml b/config/locales/app.public.fr.yml index 881334a0e..0df4d3e45 100644 --- a/config/locales/app.public.fr.yml +++ b/config/locales/app.public.fr.yml @@ -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" @@ -51,6 +52,7 @@ fr: subscriptions_and_prices: "Abonnements & Tarifs" manage_the_events: "Événements" manage_the_machines: "Machines" + manage_the_store: "Gestion de la boutique" manage_the_spaces: "Espaces" projects: "Projets" statistics: "Statistiques" @@ -372,6 +374,27 @@ 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" + cart_is_empty: "Votre panier est vide" + member_select: + select_a_member: "Sélectionnez un membre" + start_typing: "Commencez à écrire..." tour: conclusion: title: "Merci de votre attention" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 34724b6d0..f5c3abaca 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -550,3 +550,13 @@ en: validate_button: "Validate the new card" form_multi_select: create_label: "Add {VALUE}" + form_checklist: + select_all: "Select all" + unselect_all: "Unselect all" + form_file_upload: + browse: "Browse" + edit: "Edit" + form_image_upload: + browse: "Browse" + edit: "Edit" + main_image: "Main image" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index d39a36486..6f2cfa3d8 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -550,3 +550,13 @@ fr: validate_button: "Valider la nouvelle carte" form_multi_select: create_label: "Ajouter {VALUE}" + form_checklist: + select_all: "Tout sélectionner" + unselect_all: "Tout désélectionner" + form_file_upload: + browse: "Parcourir" + edit: "Modifier" + form_image_upload: + browse: "Parcourir" + edit: "Modifier" + main_image: "Visuel principal" diff --git a/config/routes.rb b/config/routes.rb index 2c8c4adab..59fe26096 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -150,6 +150,21 @@ Rails.application.routes.draw do resources :profile_custom_fields + resources :product_categories do + patch 'position', on: :member + 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 + resources :checkout, only: %i[] do + post 'payment', on: :collection + post 'confirm_payment', on: :collection + end + # for admin resources :trainings do get :availabilities, on: :member diff --git a/db/migrate/20220620072750_create_product_categories.rb b/db/migrate/20220620072750_create_product_categories.rb new file mode 100644 index 000000000..eb04b9704 --- /dev/null +++ b/db/migrate/20220620072750_create_product_categories.rb @@ -0,0 +1,12 @@ +class CreateProductCategories < ActiveRecord::Migration[5.2] + def change + create_table :product_categories do |t| + t.string :name + t.string :slug + t.integer :parent_id, index: true + t.integer :position + + t.timestamps + end + end +end diff --git a/db/migrate/20220712153708_create_products.rb b/db/migrate/20220712153708_create_products.rb new file mode 100644 index 000000000..3876ca037 --- /dev/null +++ b/db/migrate/20220712153708_create_products.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateProducts < ActiveRecord::Migration[5.2] + def change + create_table :products do |t| + t.string :name + t.string :slug + t.string :sku + t.text :description + t.boolean :is_active, default: false + t.belongs_to :product_category, foreign_key: true + t.integer :amount + t.integer :quantity_min + t.jsonb :stock, default: { internal: 0, external: 0 } + t.boolean :low_stock_alert, default: false + t.integer :low_stock_threshold + + t.timestamps + end + end +end diff --git a/db/migrate/20220712160137_create_join_table_product_machine.rb b/db/migrate/20220712160137_create_join_table_product_machine.rb new file mode 100644 index 000000000..874005fd0 --- /dev/null +++ b/db/migrate/20220712160137_create_join_table_product_machine.rb @@ -0,0 +1,8 @@ +class CreateJoinTableProductMachine < ActiveRecord::Migration[5.2] + def change + create_join_table :products, :machines do |t| + # t.index [:product_id, :machine_id] + # t.index [:machine_id, :product_id] + end + end +end diff --git a/db/migrate/20220803091913_add_is_main_to_assets.rb b/db/migrate/20220803091913_add_is_main_to_assets.rb new file mode 100644 index 000000000..3201a80a5 --- /dev/null +++ b/db/migrate/20220803091913_add_is_main_to_assets.rb @@ -0,0 +1,5 @@ +class AddIsMainToAssets < ActiveRecord::Migration[5.2] + def change + add_column :assets, :is_main, :boolean + end +end diff --git a/db/migrate/20220805083431_create_product_stock_movements.rb b/db/migrate/20220805083431_create_product_stock_movements.rb new file mode 100644 index 000000000..d7c7af564 --- /dev/null +++ b/db/migrate/20220805083431_create_product_stock_movements.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateProductStockMovements < ActiveRecord::Migration[5.2] + def change + create_table :product_stock_movements do |t| + t.belongs_to :product, foreign_key: true + t.integer :quantity + t.string :reason + t.string :stock_type + t.integer :remaining_stock + t.datetime :date + + t.timestamps + end + end +end diff --git a/db/migrate/20220808161314_create_orders.rb b/db/migrate/20220808161314_create_orders.rb new file mode 100644 index 000000000..180236fee --- /dev/null +++ b/db/migrate/20220808161314_create_orders.rb @@ -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 diff --git a/db/migrate/20220818160821_create_order_items.rb b/db/migrate/20220818160821_create_order_items.rb new file mode 100644 index 000000000..456d52173 --- /dev/null +++ b/db/migrate/20220818160821_create_order_items.rb @@ -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 diff --git a/db/migrate/20220822081222_add_payment_state_to_order.rb b/db/migrate/20220822081222_add_payment_state_to_order.rb new file mode 100644 index 000000000..f803a40ee --- /dev/null +++ b/db/migrate/20220822081222_add_payment_state_to_order.rb @@ -0,0 +1,5 @@ +class AddPaymentStateToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :payment_state, :string + end +end diff --git a/db/migrate/20220826074619_rename_amount_to_total_in_order.rb b/db/migrate/20220826074619_rename_amount_to_total_in_order.rb new file mode 100644 index 000000000..bf583b032 --- /dev/null +++ b/db/migrate/20220826074619_rename_amount_to_total_in_order.rb @@ -0,0 +1,5 @@ +class RenameAmountToTotalInOrder < ActiveRecord::Migration[5.2] + def change + rename_column :orders, :amount, :total + end +end diff --git a/db/migrate/20220826085923_add_order_item_id_to_product_stock_movement.rb b/db/migrate/20220826085923_add_order_item_id_to_product_stock_movement.rb new file mode 100644 index 000000000..57eb279f5 --- /dev/null +++ b/db/migrate/20220826085923_add_order_item_id_to_product_stock_movement.rb @@ -0,0 +1,5 @@ +class AddOrderItemIdToProductStockMovement < ActiveRecord::Migration[5.2] + def change + add_column :product_stock_movements, :order_item_id, :integer + end +end diff --git a/db/migrate/20220826090821_add_wallet_amount_and_wallet_transaction_id_to_order.rb b/db/migrate/20220826090821_add_wallet_amount_and_wallet_transaction_id_to_order.rb new file mode 100644 index 000000000..d07d7bd14 --- /dev/null +++ b/db/migrate/20220826090821_add_wallet_amount_and_wallet_transaction_id_to_order.rb @@ -0,0 +1,6 @@ +class AddWalletAmountAndWalletTransactionIdToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :wallet_amount, :integer + add_column :orders, :wallet_transaction_id, :integer + end +end diff --git a/db/migrate/20220826091819_add_payment_method_to_order.rb b/db/migrate/20220826091819_add_payment_method_to_order.rb new file mode 100644 index 000000000..3be3c2740 --- /dev/null +++ b/db/migrate/20220826091819_add_payment_method_to_order.rb @@ -0,0 +1,5 @@ +class AddPaymentMethodToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :payment_method, :string + end +end diff --git a/db/migrate/20220826093503_rename_operator_id_to_operator_profile_id_in_order.rb b/db/migrate/20220826093503_rename_operator_id_to_operator_profile_id_in_order.rb new file mode 100644 index 000000000..c199208f0 --- /dev/null +++ b/db/migrate/20220826093503_rename_operator_id_to_operator_profile_id_in_order.rb @@ -0,0 +1,7 @@ +class RenameOperatorIdToOperatorProfileIdInOrder < ActiveRecord::Migration[5.2] + def change + rename_column :orders, :operator_id, :operator_profile_id + add_index :orders, :operator_profile_id + add_foreign_key :orders, :invoicing_profiles, column: :operator_profile_id, primary_key: :id + end +end diff --git a/db/migrate/20220826133518_add_footprint_and_environment_to_order.rb b/db/migrate/20220826133518_add_footprint_and_environment_to_order.rb new file mode 100644 index 000000000..966fdc47c --- /dev/null +++ b/db/migrate/20220826133518_add_footprint_and_environment_to_order.rb @@ -0,0 +1,6 @@ +class AddFootprintAndEnvironmentToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :footprint, :string + add_column :orders, :environment, :string + end +end diff --git a/db/migrate/20220826140921_add_coupon_id_to_order.rb b/db/migrate/20220826140921_add_coupon_id_to_order.rb new file mode 100644 index 000000000..cca23c0bd --- /dev/null +++ b/db/migrate/20220826140921_add_coupon_id_to_order.rb @@ -0,0 +1,5 @@ +class AddCouponIdToOrder < ActiveRecord::Migration[5.2] + def change + add_reference :orders, :coupon, index: true, foreign_key: true + end +end diff --git a/db/migrate/20220826175129_add_paid_total_to_order.rb b/db/migrate/20220826175129_add_paid_total_to_order.rb new file mode 100644 index 000000000..5681a3ed5 --- /dev/null +++ b/db/migrate/20220826175129_add_paid_total_to_order.rb @@ -0,0 +1,5 @@ +class AddPaidTotalToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :paid_total, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 6ea5b3b4d..e9373c7c0 100644 --- a/db/schema.rb +++ b/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_07_20_135828) do +ActiveRecord::Schema.define(version: 2022_08_26_175129) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do enable_extension "unaccent" create_table "abuses", id: :serial, force: :cascade do |t| - t.integer "signaled_id" t.string "signaled_type" + t.integer "signaled_id" t.string "first_name" t.string "last_name" t.string "email" @@ -49,8 +49,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.string "locality" t.string "country" t.string "postal_code" - t.integer "placeable_id" t.string "placeable_type" + t.integer "placeable_id" t.datetime "created_at" t.datetime "updated_at" end @@ -64,12 +64,13 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do end create_table "assets", id: :serial, force: :cascade do |t| - t.integer "viewable_id" t.string "viewable_type" + t.integer "viewable_id" t.string "attachment" t.string "type" t.datetime "created_at" t.datetime "updated_at" + t.boolean "is_main" end create_table "auth_provider_mappings", id: :serial, force: :cascade do |t| @@ -146,8 +147,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do end create_table "credits", id: :serial, force: :cascade do |t| - t.integer "creditable_id" t.string "creditable_type" + t.integer "creditable_id" t.integer "plan_id" t.integer "hours" t.datetime "created_at" @@ -367,17 +368,22 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.index ["machine_id"], name: "index_machines_availabilities_on_machine_id" end + create_table "machines_products", id: false, force: :cascade do |t| + t.bigint "product_id", null: false + t.bigint "machine_id", null: false + end + create_table "notifications", id: :serial, force: :cascade do |t| t.integer "receiver_id" - t.integer "attached_object_id" t.string "attached_object_type" + t.integer "attached_object_id" t.integer "notification_type_id" t.boolean "is_read", default: false t.datetime "created_at" t.datetime "updated_at" t.string "receiver_type" t.boolean "is_send", default: false - t.jsonb "meta_data", default: {} + t.jsonb "meta_data", default: "{}" t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id" t.index ["receiver_id"], name: "index_notifications_on_receiver_id" end @@ -439,6 +445,41 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) 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_profile_id" + t.string "token" + t.string "reference" + t.string "state" + t.integer "total" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "payment_state" + t.integer "wallet_amount" + t.integer "wallet_transaction_id" + t.string "payment_method" + t.string "footprint" + t.string "environment" + t.bigint "coupon_id" + t.integer "paid_total" + t.index ["coupon_id"], name: "index_orders_on_coupon_id" + t.index ["operator_profile_id"], name: "index_orders_on_operator_profile_id" + 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 @@ -570,8 +611,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do create_table "prices", id: :serial, force: :cascade do |t| t.integer "group_id" t.integer "plan_id" - t.integer "priceable_id" t.string "priceable_type" + t.integer "priceable_id" t.integer "amount" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -581,6 +622,46 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.index ["priceable_type", "priceable_id"], name: "index_prices_on_priceable_type_and_priceable_id" end + create_table "product_categories", force: :cascade do |t| + t.string "name" + t.string "slug" + t.integer "parent_id" + t.integer "position" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["parent_id"], name: "index_product_categories_on_parent_id" + end + + create_table "product_stock_movements", force: :cascade do |t| + t.bigint "product_id" + t.integer "quantity" + t.string "reason" + t.string "stock_type" + t.integer "remaining_stock" + t.datetime "date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "order_item_id" + t.index ["product_id"], name: "index_product_stock_movements_on_product_id" + end + + create_table "products", force: :cascade do |t| + t.string "name" + t.string "slug" + t.string "sku" + t.text "description" + t.boolean "is_active", default: false + t.bigint "product_category_id" + t.integer "amount" + t.integer "quantity_min" + t.jsonb "stock", default: {"external"=>0, "internal"=>0} + t.boolean "low_stock_alert", default: false + t.integer "low_stock_threshold" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["product_category_id"], name: "index_products_on_product_category_id" + end + create_table "profile_custom_fields", force: :cascade do |t| t.string "label" t.boolean "required", default: false @@ -729,8 +810,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.text "message" t.datetime "created_at" t.datetime "updated_at" - t.integer "reservable_id" t.string "reservable_type" + t.integer "reservable_id" t.integer "nb_reserve_places" t.integer "statistic_profile_id" t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id" @@ -739,8 +820,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do create_table "roles", id: :serial, force: :cascade do |t| t.string "name" - t.integer "resource_id" t.string "resource_type" + t.integer "resource_id" t.datetime "created_at" t.datetime "updated_at" t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id" @@ -1020,8 +1101,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.boolean "is_allow_newsletter" t.inet "current_sign_in_ip" t.inet "last_sign_in_ip" - t.string "mapped_from_sso" t.datetime "validated_at" + t.string "mapped_from_sso" t.index ["auth_token"], name: "index_users_on_auth_token" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true @@ -1088,6 +1169,10 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) 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", "coupons" + add_foreign_key "orders", "invoicing_profiles", column: "operator_profile_id" + 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" @@ -1102,6 +1187,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do add_foreign_key "prepaid_packs", "groups" add_foreign_key "prices", "groups" add_foreign_key "prices", "plans" + add_foreign_key "product_stock_movements", "products" + add_foreign_key "products", "product_categories" add_foreign_key "project_steps", "projects" add_foreign_key "project_users", "projects" add_foreign_key "project_users", "users" diff --git a/lib/pay_zen/helper.rb b/lib/pay_zen/helper.rb index ea98c972b..05a377ca2 100644 --- a/lib/pay_zen/helper.rb +++ b/lib/pay_zen/helper.rb @@ -59,10 +59,24 @@ class PayZen::Helper def generate_shopping_cart(cart_items, customer, operator) cart = if cart_items.is_a? ShoppingCart cart_items + elsif cart_items.is_a? Order + cart_items else cs = CartService.new(operator) cs.from_hash(cart_items) end + if cart.is_a? Order + return { + cartItemInfo: cart.order_items.map do |item| + { + productAmount: item.amount.to_i.to_s, + productLabel: item.orderable_id, + productQty: item.quantity.to_s, + productType: customer.organization? ? 'SERVICE_FOR_BUSINESS' : 'SERVICE_FOR_INDIVIDUAL' + } + end + } + end { cartItemInfo: cart.items.map do |item| { diff --git a/package.json b/package.json index dc778a1f6..6e3662c83 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "@babel/preset-typescript": "^7.16.7", "@babel/runtime": "^7.17.2", "@claviska/jquery-minicolors": "^2.3.5", + "@dnd-kit/core": "^6.0.5", + "@dnd-kit/modifiers": "^6.0.0", + "@dnd-kit/sortable": "^7.0.1", "@fortawesome/fontawesome-free": "5.14.0", "@lyracom/embedded-form-glue": "^0.3.3", "@stripe/react-stripe-js": "^1.4.0", @@ -118,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", @@ -134,17 +138,21 @@ "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", "react-modal": "^3.11.2", "react-select": "^5.3.2", + "react-sortablejs": "^6.1.4", "react-switch": "^6.0.0", "react2angular": "^4.0.6", "resolve-url-loader": "^4.0.0", "sass": "^1.49.9", "sass-loader": "^12.6.0", "shakapacker": "6.2.0", + "slugify": "^1.6.5", + "sortablejs": "^1.15.0", "style-loader": "^3.3.1", "summernote": "0.8.18", "terser-webpack-plugin": "5", diff --git a/yarn.lock b/yarn.lock index 7a0ee5cfa..c96b1815c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1488,6 +1488,45 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f" integrity sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA== +"@dnd-kit/accessibility@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c" + integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^6.0.5": + version "6.0.5" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.5.tgz#5670ad0dcc83cd51dbf2fa8c6a5c8af4ac0c1989" + integrity sha512-3nL+Zy5cT+1XwsWdlXIvGIFvbuocMyB4NBxTN74DeBaBqeWdH9JsnKwQv7buZQgAHmAH+eIENfS1ginkvW6bCw== + dependencies: + "@dnd-kit/accessibility" "^3.0.0" + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/modifiers@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/modifiers/-/modifiers-6.0.0.tgz#61d8834132f791a68e9e93be5426becbcd45c078" + integrity sha512-V3+JSo6/BTcgPRHiNUTSKgqVv/doKXg+T4Z0QvKiiXp+uIyJTUtPkQOBRQApUWi3ApBhnoWljyt/3xxY4fTd0Q== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/sortable@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.1.tgz#99c6012bbab4d8bb726c0eef7b921a338c404fdb" + integrity sha512-n77qAzJQtMMywu25sJzhz3gsHnDOUlEjTtnRl8A87rWIhnu32zuP+7zmFjwGgvqfXmRufqiHOSlH7JPC/tnJ8Q== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.0.tgz#b3e956ea63a1347c9d0e1316b037ddcc6140acda" + integrity sha512-h65/pn2IPCCIWwdlR2BMLqRkDxpTEONA+HQW3n765HBijLYGyrnTCLa2YQt8VVjjSQD6EfFlTE6aS2Q/b6nb2g== + dependencies: + tslib "^2.0.0" + "@emotion/babel-plugin@^11.7.1": version "11.9.2" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz#723b6d394c89fb2ef782229d92ba95a740576e95" @@ -3317,6 +3356,11 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== +classnames@2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" + integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== + clean-css@^4.2.3: version "4.2.4" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178" @@ -5228,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" @@ -6539,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" @@ -6600,6 +6654,14 @@ react-select@^5.3.2: prop-types "^15.6.0" react-transition-group "^4.3.0" +react-sortablejs@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/react-sortablejs/-/react-sortablejs-6.1.4.tgz#420ebfab602bbd935035dec24a04c8b3b836dbbf" + integrity sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ== + dependencies: + classnames "2.3.1" + tiny-invariant "1.2.0" + react-switch@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/react-switch/-/react-switch-6.0.0.tgz#bd4a2dea08f211b8a32e55e8314fd44bc1ec947e" @@ -7098,6 +7160,11 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +slugify@^1.6.5: + version "1.6.5" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.5.tgz#c8f5c072bf2135b80703589b39a3d41451fbe8c8" + integrity sha512-8mo9bslnBO3tr5PEVFzMPIWwWnipGS0xVbYf65zxDqfNwmzYn1LpiKNrR6DlClusuvo+hDHd1zKpmfAe83NQSQ== + sockjs@^0.3.21: version "0.3.21" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.21.tgz#b34ffb98e796930b60a0cfa11904d6a339a7d417" @@ -7107,6 +7174,11 @@ sockjs@^0.3.21: uuid "^3.4.0" websocket-driver "^0.7.4" +sortablejs@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.15.0.tgz#53230b8aa3502bb77a29e2005808ffdb4a5f7e2a" + integrity sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w== + "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" @@ -7380,6 +7452,11 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tiny-invariant@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9" + integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg== + tippy.js@^6.3.7: version "6.3.7" resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c" @@ -7424,6 +7501,11 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tslib@^2.0.3, tslib@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"