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

fablab store page

This commit is contained in:
Du Peng 2022-08-16 18:53:11 +02:00
parent 1cef45e3d7
commit 16288ae2bd
22 changed files with 610 additions and 15 deletions

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useForm, useWatch, Path } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import slugify from 'slugify';
import _ from 'lodash';
@ -11,7 +11,7 @@ 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, ImageType } from '../form/form-image-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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -600,6 +600,28 @@ 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'
}
}
})
// --- namespace /admin/... ---
// calendar
.state('app.admin.calendar', {

View File

@ -92,6 +92,7 @@
@import "modules/store/manage-product-category";
@import "modules/store/product-categories";
@import "modules/store/products";
@import "modules/store/store";
@import "modules/subscriptions/free-extend-modal";
@import "modules/subscriptions/renew-modal";
@import "modules/supporting-documents/supporting-documents-files";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,6 +43,7 @@ fr:
projects_gallery: "Galerie de projets"
subscriptions: "Abonnements"
public_calendar: "Agenda"
fablab_store: "Boutique Fablab"
#left menu (admin)
trainings_monitoring: "Formations"
manage_the_calendar: "Agenda"
@ -373,6 +374,16 @@ 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é"
add: "Ajouter"
store_product:
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
tour:
conclusion:
title: "Merci de votre attention"