1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-18 07:52:23 +01:00

Client side product list + product view

This commit is contained in:
vincent 2022-08-24 18:34:34 +02:00
parent 29993b0ec9
commit e0dc008d4c
13 changed files with 469 additions and 96 deletions

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';

View File

@ -352,7 +352,7 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
</FabAlert>
<FormRichText control={control}
paragraphTools={true}
limit={1000}
limit={6000}
id="description" />
</div>

View File

@ -51,7 +51,7 @@ export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDel
if (product.stock.external === 0 && product.stock.internal === 0) {
return 'out-of-stock';
}
if (product.low_stock_alert) {
if (product.low_stock_threshold && (product.stock.external < product.low_stock_threshold || product.stock.internal < product.low_stock_threshold)) {
return 'low';
}
};
@ -70,11 +70,11 @@ export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDel
: t('app.admin.store.product_item.hidden')
}
</span>
<div className='stock'>
<div className={`stock ${product.stock.internal < product.low_stock_threshold ? 'low' : ''}`}>
<span>{t('app.admin.store.product_item.stock.internal')}</span>
<p>{product.stock.internal}</p>
</div>
<div className='stock'>
<div className={`stock ${product.stock.external < product.low_stock_threshold ? 'low' : ''}`}>
<span>{t('app.admin.store.product_item.stock.external')}</span>
<p>{product.stock.external}</p>
</div>

View File

@ -6,6 +6,7 @@ import { Product } from '../../models/product';
import { Order } from '../../models/order';
import FormatLib from '../../lib/format';
import CartAPI from '../../api/cart';
import noImage from '../../../../images/no_image.png';
interface StoreProductItemProps {
product: Product,
@ -20,27 +21,14 @@ export const StoreProductItem: React.FC<StoreProductItemProps> = ({ product, car
const { t } = useTranslation('public');
/**
* Return main image of Product, if the product has not any image, show default image
* Return main image of Product, if the product has no image, show default image
*/
const productImageUrl = (product: Product) => {
const productImage = _.find(product.product_images_attributes, { is_main: true });
if (productImage) {
return productImage.attachment_url;
}
return 'https://via.placeholder.com/300';
};
/**
* Return product's stock status
*/
const productStockStatus = (product: Product) => {
if (product.stock.external === 0) {
return <span>{t('app.public.store_product_item.out_of_stock')}</span>;
}
if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) {
return <span>{t('app.public.store_product_item.limited_stock')}</span>;
}
return <span>{t('app.public.store_product_item.available')}</span>;
return noImage;
};
/**
@ -59,23 +47,51 @@ export const StoreProductItem: React.FC<StoreProductItemProps> = ({ product, car
window.location.href = `/#!/store/p/${product.slug}`;
};
/**
* 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_alert) {
return 'low';
}
};
/**
* Return product's stock status
*/
const productStockStatus = (product: Product) => {
if (product.stock.external === 0) {
return <span>{t('app.public.store_product_item.out_of_stock')}</span>;
}
if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) {
return <span>{t('app.public.store_product_item.limited_stock')}</span>;
}
return <span>{t('app.public.store_product_item.available')}</span>;
};
return (
<div className="store-product-item" onClick={() => showProduct(product)}>
<div className='itemInfo-image'>
<img src={productImageUrl(product)} alt='' className='itemInfo-thumbnail' />
<div className={`store-product-item ${statusColor(product)}`} onClick={() => showProduct(product)}>
<div className="picture">
<img src={productImageUrl(product)} alt='' />
</div>
<p className="itemInfo-name">{product.name}</p>
<div className=''>
<span>
<div>{FormatLib.price(product.amount)}</div>
{productStockStatus(product)}
</span>
{product.stock.external > 0 &&
<FabButton className="edit-btn" onClick={addProductToCart}>
<i className="fas fa-cart-arrow-down" /> {t('app.public.store_product_item.add')}
</FabButton>
}
<p className="name">{product.name}</p>
{product.amount &&
<div className='price'>
<p>{FormatLib.price(product.amount)}</p>
<span>/ {t('app.public.store_product_item.unit')}</span>
</div>
}
<div className="stock">
{productStockStatus(product)}
</div>
{product.stock.external > 0 &&
<FabButton icon={<i className="fas fa-cart-arrow-down" />} className="main-action-btn" onClick={addProductToCart}>
{t('app.public.store_product_item.add')}
</FabButton>
}
</div>
);
};

View File

@ -1,11 +1,16 @@
import React, { useEffect, useState } from 'react';
/* eslint-disable fabmanager/scoped-translation */
import React, { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import FormatLib from '../../lib/format';
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';
import noImage from '../../../../images/no_image.png';
import { FabButton } from '../base/fab-button';
import { FilePdf, Minus, Plus } from 'phosphor-react';
declare const Application: IApplication;
@ -21,32 +26,155 @@ export const StoreProduct: React.FC<StoreProductProps> = ({ productSlug, onError
const { t } = useTranslation('public');
const [product, setProduct] = useState<Product>();
console.log('product: ', product);
const [showImage, setShowImage] = useState<number>(null);
const [toCartCount, setToCartCount] = useState<number>(0);
const [displayToggle, setDisplayToggle] = useState<boolean>(false);
const [collapseDescription, setCollapseDescription] = useState<boolean>(true);
const descContainer = useRef(null);
useEffect(() => {
ProductAPI.get(productSlug).then(data => {
setProduct(data);
const productImage = _.find(data.product_images_attributes, { is_main: true });
setShowImage(productImage.id);
setToCartCount(data.quantity_min ? data.quantity_min : 1);
setDisplayToggle(descContainer.current.offsetHeight < descContainer.current.scrollHeight);
}).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
* Return main image of Product, if the product has no image, show default image
*/
const productImageUrl = (product: Product) => {
const productImage = _.find(product.product_images_attributes, { is_main: true });
const productImageUrl = (id: number) => {
const productImage = _.find(product.product_images_attributes, { id });
if (productImage) {
return productImage.attachment_url;
}
return 'https://via.placeholder.com/300';
return noImage;
};
/**
* 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_alert) {
return 'low';
}
};
/**
* Return product's stock status
*/
const productStockStatus = (product: Product) => {
if (product.stock.external === 0) {
return <span>{t('app.public.store_product_item.out_of_stock')}</span>;
}
if (product.low_stock_threshold && product.stock.external < product.low_stock_threshold) {
return <span>{t('app.public.store_product_item.limited_stock')}</span>;
}
return <span>{t('app.public.store_product_item.available')}</span>;
};
/**
* Update product count
*/
const setCount = (type: 'add' | 'remove') => {
switch (type) {
case 'add':
setToCartCount(toCartCount + 1);
break;
case 'remove':
if (toCartCount > product.quantity_min) {
setToCartCount(toCartCount - 1);
}
break;
}
};
/**
* Update product count
*/
const typeCount = (evt: React.ChangeEvent<HTMLInputElement>) => {
evt.preventDefault();
setToCartCount(Number(evt.target.value));
};
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 className={`store-product ${statusColor(product)}`}>
<span className='ref'>ref: {product.sku}</span>
<h2 className='name'>{product.name}</h2>
<div className='gallery'>
<div className='main'>
<div className='aspect-ratio'>
<img src={productImageUrl(showImage)} alt='' />
</div>
</div>
{product.product_images_attributes.length > 1 &&
<div className='thumbnails'>
{product.product_images_attributes.map(i => (
<div key={i.id} className={`aspect-ratio ${i.id === showImage ? 'is-active' : ''}`}>
<img alt='' onClick={() => setShowImage(i.id)} src={i.attachment_url} />
</div>
))}
</div>
}
</div>
<div className='description'>
<div ref={descContainer} dangerouslySetInnerHTML={{ __html: product.description }} className='description-text' style={{ maxHeight: collapseDescription ? '35rem' : '1000rem' }} />
{displayToggle &&
<button onClick={() => setCollapseDescription(!collapseDescription)} className='description-toggle'>
{collapseDescription
? <span>{t('app.public.store_product.show_more')}</span>
: <span>{t('app.public.store_product.show_less')}</span>
}
</button>
}
{product.product_files_attributes.length > 0 &&
<div className='description-document'>
<p>{t('app.public.store_product.documentation')}</p>
<div className='list'>
{product.product_files_attributes.map(f =>
<a key={f.id} href={f.attachment_url}
target='_blank'
className='fab-button'
rel='noreferrer'>
<FilePdf size={24} />
<span>{f.attachment_name}</span>
</a>
)}
</div>
</div>
}
</div>
<aside>
<div className="stock">
{productStockStatus(product)}
</div>
<div className='price'>
<p>{FormatLib.price(product.amount)} <sup>TTC</sup></p>
<span>/ {t('app.public.store_product_item.unit')}</span>
</div>
{product.stock.external > 0 &&
<div className='to-cart'>
<FabButton onClick={() => setCount('remove')} icon={<Minus size={16} />} className="minus" />
<input type="number"
value={toCartCount}
onChange={evt => typeCount(evt)} />
<FabButton onClick={() => setCount('add')} icon={<Plus size={16} />} className="plus" />
<FabButton onClick={() => console.log('Add', toCartCount, 'to cart')} icon={<i className="fas fa-cart-arrow-down" />}
className="main-action-btn">
{t('app.public.store_product_item.add')}
</FabButton>
</div>
}
</aside>
</div>
);
}

View File

@ -49,7 +49,18 @@ const Store: React.FC<StoreProps> = ({ onError, currentUser }) => {
});
ProductCategoryAPI.index().then(data => {
setProductCategories(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(t('app.public.store.unexpected_error_occurred'));
});

View File

@ -40,7 +40,11 @@
background-color: var(--main);
color: var(--gray-soft-lightest);
border: none;
&:hover { opacity: 0.75; }
&:hover {
background-color: var(--main);
color: var(--gray-soft-lightest);
opacity: 0.75;
}
}
@mixin header {

View File

@ -1,9 +1,85 @@
.products-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 3.2rem;
.store-product-item {
color: tomato;
--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;
position: relative;
width: 100%;
height: 0;
padding-bottom: 50%;
border-radius: var(--border-radius);
overflow: hidden;
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.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 {
grid-area: stock;
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%;
}
}
button {
grid-area: btn;
align-self: flex-end;
margin-left: 1rem;
i { margin-right: 0.8rem; }
}
}
}

View File

@ -5,7 +5,10 @@
.product-item {
--status-color: var(--gray-hard-darkest);
&.low { --status-color: var(--alert-light); }
&.out-of-stock { --status-color: var(--alert); }
&.out-of-stock {
--status-color: var(--alert);
.stock { color: var(--alert) !important; }
}
width: 100%;
display: flex;
@ -74,8 +77,9 @@
.stock {
display: flex;
flex-direction: column;
color: var(--status-color);
color: var(--gray-hard-darkest);
span { @include text-xs; }
&.low { color: var(--alert-light); }
}
.price {
justify-self: flex-end;

View File

@ -1,52 +1,180 @@
.store {
.store,
.store-product {
max-width: 1600px;
@include grid-col(12);
gap: 3.2rem;
margin: 0 auto;
padding-bottom: 6rem;
//&-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) }
// }
// }
//}
}
.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;
.aspect-ratio {
position: relative;
width: 100%;
height: 0;
padding-bottom: 100%;
overflow: hidden;
border-radius: var(--border-radius-sm);
border: 1px solid var(--gray-soft-darkest);
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
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);
.stock {
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%;
}
}
.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";
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;
}
}
}
}

View File

@ -71,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;

View File

@ -19,6 +19,6 @@
</section>
<section class="m-lg">
<section>
<store-product product-slug="productSlug" on-error="onError" on-success="onSuccess" />
</section>

View File

@ -396,6 +396,11 @@ en:
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:
unit: "unit"
cart:
my_cart: "My Cart"
cart_button: