hedera-web/src/pages/Ecomerce/CatalogView.vue

1041 lines
30 KiB
Vue

<template>
<Teleport v-if="isHeaderMounted" to="#actions">
<div class="q-gutter-x-sm row">
<VnSearchBar :search-term="search" @on-search-error="items = []" />
<QBtn
:icon="viewTypeButtonContent.icon"
:label="viewTypeButtonContent.label"
@click="onViewModeClick()"
rounded
no-caps
>
<QTooltip>
{{ viewTypeButtonContent.label }}
</QTooltip>
</QBtn>
<QBtn
icon="shopping_cart_checkout"
:label="t('shoppingCart')"
:to="{ name: 'basket' }"
rounded
no-caps
>
<QTooltip>
{{ t('shoppingCart') }}
</QTooltip>
</QBtn>
</div>
</Teleport>
<div style="padding-bottom: 5em">
<QDrawer v-model="rightDrawerOpen" side="right" :width="250">
<div class="q-pa-md">
<div class="basket-info q-gutter-y-sm">
<span v-if="order?.nickname">{{ order.nickname }}</span>
<span v-if="order?.sent">
{{
formatDateTitle(order.sent, {
shortDay: true,
shortMonth: true,
includeOfString: true
})
}}
</span>
<QBtn
rounded
no-caps
:to="{
name: 'checkout',
params: { id: appStore.basketOrderId },
query: { continue: 'catalog' }
}"
>
{{ t('modify') }}
</QBtn>
</div>
<div class="q-mt-md">
<div class="q-mb-xs text-grey-7">
{{ t('category') }}
<QIcon
v-if="category"
style="font-size: 1.3em"
name="cancel"
class="cursor-pointer"
:title="t('deleteFilter')"
@click="removeCategory()"
/>
</div>
<div class="categories">
<QBtn
flat
round
class="category q-pa-sm"
v-for="cat in categories"
:class="{ active: category == cat.id }"
:key="cat.id"
@click="selectedCategory = cat.id"
>
<img :src="`statics/category/${cat.code}.svg`" />
<QTooltip>{{ cat.name }}</QTooltip>
</QBtn>
</div>
</div>
<div class="q-mt-md" v-if="selectedCategory">
<div class="q-mb-xs text-grey-7">
{{ t('filterBy') }}
</div>
<VnSelect
v-model="selectedType"
option-value="id"
option-label="name"
:options="itemFamilies"
:disable="!category"
:label="t('family')"
/>
<VnSelect
v-model="selectedColor"
option-value="id"
option-label="name"
:options="itemColors"
:disable="!category"
:label="t('color')"
/>
<VnSelect
v-model="selectedProducer"
option-value="id"
option-label="name"
:options="itemProducers"
:disable="!category"
:label="t('producer')"
/>
<VnSelect
v-model="selectedOrigin"
option-value="id"
option-label="name"
:options="itemOrigins"
:disable="!category"
:label="t('origin')"
/>
<VnSelect
v-model="selectedSubcategory"
option-value="id"
option-label="name"
:options="itemSubcategories"
:disable="!category"
:label="t('category')"
/>
<VnSelect
v-if="isSomeFilterSelected"
v-model="selectedOrderBy"
:options="orderByOptions"
option-value="value"
option-label="label"
:is-clearable="false"
:label="t('orderBy')"
/>
</div>
<span
v-else
class="flex full-width justify-center q-pt-lg text-h6 text-grey-7"
>
Elige una categoria
</span>
</div>
</QDrawer>
<div
:class="
viewMode === 'grid'
? 'q-pa-md row justify-center q-gutter-md'
: 'column items-center'
"
>
<QSpinner
v-if="loading"
color="primary"
size="3em"
:thickness="2"
/>
<div
v-else-if="!items || !items.length || !isSomeFilterSelected"
class="text-subtitle1 text-grey-7 q-pa-md"
>
<QIcon name="refresh" size="sm" class="q-mr-sm"></QIcon>
<span>{{ t('pleaseSetFilter') }}</span>
</div>
<CatalogCard
v-else
v-for="_item in items"
:key="_item.id"
:item="_item"
:view-mode="viewMode"
@click="showItem(_item)"
/>
</div>
<QDialog v-model="showItemDialog">
<QCard style="width: 25em" class="column">
<div class="q-pa-md" style="display: flex">
<VnImg
storage="catalog"
size="200x200"
:id="'asd'"
width="112px"
height="112px"
rounded="bottom"
class="q-mr-md"
/>
<div class="column">
<div class="text-subtitle2">
{{ selectedItem.item }}
</div>
<span class="text-subtitle2 text-grey-7 text-uppercase">
{{ selectedItem.subName }}
</span>
<span class="text-grey-7"> #{{ selectedItem.id }}</span>
</div>
</div>
<div class="tags q-px-md text-caption">
<div
v-for="(tag, index) in selectedItem.tags"
:key="index"
class="full-width row"
>
<span class="text-grey-7 col-5">
{{ tag.name }}
</span>
<span class="col">{{ tag.value }}</span>
</div>
</div>
<div
v-for="(lot, index) in selectedItem.lots"
:key="index"
class="column"
>
<QSeparator />
<div class="row items-center q-px-md q-py-xs">
<span class="col">{{ currency(lot.price) }}</span>
<span class="col"> x{{ lot.grouping }}</span>
<QBtn
icon="add"
round
flat
dense
@click="onAddLotClick(lot)"
>
<QTooltip>{{ t('add') }}</QTooltip>
</QBtn>
</div>
</div>
<div class="row items-center justify-between q-pa-md bg-black">
<QBtn
icon="delete"
round
dense
flat
color="white"
@click="resetAmounts()"
>
<QTooltip>{{ t('delete') }}</QTooltip>
</QBtn>
<span class="text-center text-white">{{ amount }}</span>
<QBtn
icon="check"
round
dense
flat
color="white"
@click="onConfirmClick()"
>
<QTooltip>{{ t('confirm') }}</QTooltip>
</QBtn>
</div>
</QCard>
</QDialog>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n';
import { inject, onBeforeMount, ref, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import VnImg from 'src/components/ui/VnImg.vue';
import VnSelect from 'src/components/common/VnSelect.vue';
import CatalogCard from 'src/pages/Ecomerce/CatalogCard.vue';
import VnSearchBar from 'src/components/ui/VnSearchBar.vue';
import { useAppStore } from 'stores/app';
import { storeToRefs } from 'pinia';
import { formatDateTitle, currency } from 'src/lib/filters.js';
import useNotify from 'src/composables/useNotify.js';
import debounce from 'src/utils/debouncer.js';
const jApi = inject('jApi');
const { t } = useI18n();
const appStore = useAppStore();
const route = useRoute();
const router = useRouter();
const { isHeaderMounted, rightDrawerOpen, basketOrderId } =
storeToRefs(appStore);
const { notify } = useNotify();
const order = ref(null);
const items = ref([]);
const selectedItem = ref(null);
const showItemDialog = ref(false);
const loading = ref(false);
const isGuest = ref(false); // TODO: Integrate isGuest logic
const viewMode = ref('grid');
// Filters options
const categories = ref([]);
const itemColors = ref([]);
const itemFamilies = ref([]);
const itemProducers = ref([]);
const itemOrigins = ref([]);
const itemSubcategories = ref([]);
const search = ref(null);
// Filters values
const category = ref(null);
const type = ref(null);
const color = ref(null);
const producer = ref(null);
const origin = ref(null);
const subcategory = ref(null);
// Order by options
const orderBy = ref('i.relevancy DESC, longName');
const orderByOptions = ref([
{
label: t('relevancy'),
value: 'i.relevancy DESC, longName'
},
{
label: t('name'),
value: 'longName ASC'
},
{
label: t('lowerSize'),
value: 'size ASC'
},
{
label: t('higherSize'),
value: 'size DESC'
},
{
label: t('lowerPrice'),
value: 'price ASC'
},
{
label: t('higherPrice'),
value: 'price DESC'
},
{
label: t('available'),
value: 'available'
},
{ label: t('color'), value: 'ink ASC' },
{
label: t('producer'),
value: 'producer ASC'
},
{
label: t('origin'),
value: 'origin ASC'
},
{
label: t('category'),
value: 'category ASC'
}
]);
const selectedCategory = computed({
get() {
return category.value;
},
async set(value) {
category.value = value;
onCategoryChange();
if (!value) return;
debouncedGetFilters();
debouncedGetItems();
refreshTitle();
}
});
const selectedType = computed({
get() {
return type.value;
},
set(value) {
type.value = value;
router.push({
params: { category: category.value, type: type.value },
query: { ...route.query }
});
if (!selectedCategory.value) return;
debouncedGetFilters();
debouncedGetItems();
refreshTitle();
}
});
const selectedColor = computed({
get() {
return color.value;
},
set(value) {
color.value = value;
if (!selectedCategory.value) return;
debouncedGetFilters();
debouncedGetItems();
}
});
const selectedProducer = computed({
get() {
return producer.value;
},
set(value) {
producer.value = value;
if (!value || !selectedCategory.value) return;
debouncedGetFilters();
debouncedGetItems();
}
});
const selectedOrigin = computed({
get() {
return origin.value;
},
set(value) {
origin.value = value;
if (!value || !selectedCategory.value) return;
debouncedGetFilters();
debouncedGetItems();
}
});
const selectedSubcategory = computed({
get() {
return subcategory.value;
},
set(value) {
subcategory.value = value;
if (!value && !selectedCategory.value) return;
debouncedGetFilters();
debouncedGetItems();
}
});
const selectedOrderBy = computed({
get() {
return orderBy.value;
},
set(value) {
if (value) {
orderBy.value = value;
} else {
orderBy.value = 'i.relevancy DESC, longName';
}
debouncedGetItems();
}
});
const queryFilter = computed(() => {
const filters = [];
// Filtro principal
if (selectedCategory.value) {
filters.push(`(t.categoryFk = ${selectedCategory.value})`);
}
// Filtro búsqueda
if (search.value) {
filters.push(
`((i.longName LIKE '%${search.value}%') OR (i.subname LIKE '%${search.value}%'))`
);
}
const otherFilters = {
'i.typeFk': selectedType.value,
'i.inkFk': selectedColor?.value ? `'${selectedColor.value}'` : null,
'i.producerFk': selectedProducer.value,
'i.originFk': selectedOrigin.value
};
// Añadir solo aquellos filtros que tengan valor
Object.entries(otherFilters).forEach(([key, value]) => {
if (value) {
filters.push(`(${key} = ${value})`);
}
});
// Concatenar todos los filtros
return filters.join(' AND ');
});
const isSomeFilterSelected = computed(() => {
return (
search.value ||
selectedType.value ||
selectedColor.value ||
selectedProducer.value ||
selectedOrigin.value ||
selectedSubcategory.value
);
});
const viewTypeButtonContent = computed(() => {
return {
label: t(viewMode.value === 'list' ? 'gridView' : 'listView'),
icon: viewMode.value === 'list' ? 'grid_on' : 'view_list'
};
});
const getFilters = async () => {
const promises = [
getItemFamilies(),
getItemColors(),
getProducers(),
getOrigins(),
getSubcategories()
];
await Promise.allSettled(promises);
};
const getItems = async () => {
try {
if (!basketOrderId.value || !isSomeFilterSelected.value) return;
loading.value = true;
const res = await jApi.execQuery(
`DROP TEMPORARY TABLE IF EXISTS tmp.item;
CREATE TEMPORARY TABLE tmp.item
(INDEX (itemFk))
ENGINE = MEMORY
SELECT i.id itemFk
FROM vn.item i
JOIN vn.itemType t ON t.id = i.typeFk
WHERE (${queryFilter.value});
CALL myOrder_calcCatalogFull(#orderId);
SELECT i.id, i.longName item, i.subName,
i.tag5, i.value5, i.tag6, i.value6,
i.tag7, i.value7, i.tag8, i.value8,
i.relevancy, i.size, i.category, b.minQuantity,
k.name ink, p.name producer, o.name origin,
b.available, b.price, b.grouping,
i.image, im.updated
FROM tmp.ticketCalculateItem b
JOIN vn.item i ON i.id = b.itemFk
LEFT JOIN vn.ink k ON k.id = i.inkFk
LEFT JOIN vn.producer p ON p.id = i.producerFk
LEFT JOIN vn.origin o ON o.id = i.originFk
LEFT JOIN image im ON im.collectionFk = 'catalog'
AND im.name = i.image
WHERE b.available > 0
ORDER BY ${selectedOrderBy.value}
LIMIT 5000;
DROP TEMPORARY TABLE tmp.item;
CALL vn.ticketCalculatePurge();`,
{ orderId: basketOrderId.value }
);
items.value = res.results[3].data;
await onItemsFetched();
loading.value = false;
} catch (error) {
console.error('Error getting items:', error);
}
};
const debouncedGetItems = debounce(getItems, 400);
const debouncedGetFilters = debounce(getFilters, 400);
const getOrder = async () => {
try {
const [data] = await jApi.query(
`SELECT o.id, o.sent, ad.nickname, o.addressFk, o.agencyModeFk
FROM myOrder o
LEFT JOIN myAddress ad ON ad.id = o.addressFk
WHERE o.id = #orderId`,
{ orderId: basketOrderId.value }
);
order.value = data;
} catch (error) {
console.error('Error getting order:', error);
}
};
const getCategories = async () => {
try {
categories.value = await jApi.query(
`SELECT c.id, l.name, c.color, c.code
FROM vn.itemCategory c
JOIN vn.itemCategoryL10n l ON l.id = c.id
WHERE c.display
ORDER BY display`
);
} catch (error) {
console.error('Error getting categories:', error);
}
};
const getItemFamilies = async () => {
try {
if (!selectedCategory.value || !basketOrderId.value) return;
const res = await jApi.execQuery(
`CALL myOrder_getAvailable(#orderId);
SELECT DISTINCT t.id, l.name
FROM vn.item i
JOIN vn.itemType t ON t.id = i.typeFk
JOIN tmp.itemAvailable a ON a.id = i.id
JOIN vn.itemTypeL10n l ON l.id = t.id
WHERE t.order >= 0
AND t.categoryFk = #category
ORDER BY t.order, l.name;
DROP TEMPORARY TABLE tmp.itemAvailable`,
{
category: selectedCategory.value,
orderId: basketOrderId.value
}
);
itemFamilies.value = res.results[1].data;
} catch (error) {
console.error('Error getting available items:', error);
}
};
const getItemColors = async () => {
try {
if (!selectedCategory.value || !basketOrderId.value) return;
const res = await jApi.execQuery(
`CALL myOrder_getAvailable(#orderId);
SELECT DISTINCT l.id, l.name
FROM vn.item i
JOIN vn.itemType t ON t.id = i.typeFk
JOIN tmp.itemAvailable a ON a.id = i.id
JOIN vn.inkL10n l ON l.id = i.inkFk
WHERE (${queryFilter.value})
ORDER BY name;
DROP TEMPORARY TABLE tmp.itemAvailable;`,
{
orderId: basketOrderId.value
}
);
itemColors.value = res.results[1].data;
} catch (error) {
console.error('Error getting available items:', error);
}
};
const onItemsFetched = async () => {
items.value.forEach(item => {
const previewTags = [
{ name: item.tag5, value: item.value5 },
{ name: item.tag6, value: item.value6 },
{ name: item.tag7, value: item.value7 }
];
item.previewTags = previewTags;
});
};
const getProducers = async () => {
try {
const res = await jApi.execQuery(
`CALL myOrder_getAvailable(#orderId);
SELECT DISTINCT p.id, p.name
FROM vn.item i
JOIN vn.itemType t ON t.id = i.typeFk
JOIN tmp.itemAvailable a ON a.id = i.id
JOIN vn.producer p ON p.id = i.producerFk
WHERE (${queryFilter.value})
ORDER BY name;
DROP TEMPORARY TABLE tmp.itemAvailable;`,
{ orderId: basketOrderId.value }
);
itemProducers.value = res.results[1].data;
} catch (error) {
console.error('Error getting productors:', error);
}
};
const getOrigins = async () => {
try {
const res = await jApi.execQuery(
`CALL myOrder_getAvailable(#orderId);
SELECT DISTINCT o.id, l.name, o.code
FROM vn.item i
JOIN vn.itemType t ON t.id = i.typeFk
JOIN tmp.itemAvailable a ON a.id = i.id
JOIN vn.origin o ON o.id = i.originFk
JOIN vn.originL10n l ON l.id = o.id
WHERE (${queryFilter.value})
ORDER BY name;
DROP TEMPORARY TABLE tmp.itemAvailable;`,
{ orderId: basketOrderId.value }
);
itemOrigins.value = res.results[1].data;
} catch (error) {
console.error('Error getting productors:', error);
}
};
const getSubcategories = async () => {
try {
const res = await jApi.execQuery(
`CALL myOrder_getAvailable(#orderId);
SELECT DISTINCT i.category
FROM vn.item i
JOIN vn.itemType t ON t.id = i.typeFk
JOIN tmp.itemAvailable a ON a.id = i.id
WHERE (${queryFilter.value})
ORDER BY category;
DROP TEMPORARY TABLE tmp.itemAvailable;`,
{ orderId: basketOrderId.value }
);
itemSubcategories.value = res.results[1].data;
} catch (error) {
console.error('Error getting subcategories:', error);
}
};
const showItem = async item => {
if (isGuest.value) return;
const itemLots = await calcItem(item.id);
const tags = await getItemTags(item.id);
item.lots = itemLots;
item.tags = tags;
showItemDialog.value = true;
selectedItem.value = item;
};
const removeCategory = () => {
selectedCategory.value = null;
onCategoryChange();
};
const onCategoryChange = () => {
selectedType.value = null;
selectedColor.value = null;
selectedProducer.value = null;
selectedOrigin.value = null;
selectedSubcategory.value = null;
search.value = '';
items.value = [];
router.push({ params: { category: category.value, type: null } });
};
const getItemTags = async itemFk => {
try {
const tags = await jApi.query(
`SELECT l.name, it.value
FROM vn.itemTag it
JOIN vn.tag t ON t.id = it.tagFk
JOIN vn.tagL10n l ON l.id = t.id
WHERE it.itemFk = #itemFk
AND priority >= 0
ORDER BY it.priority`,
{
itemFk
}
);
return tags;
} catch (error) {
console.error('Error getting available items:', error);
}
};
const calcItem = async itemId => {
try {
const res = await jApi.execQuery(
`CALL myOrder_calcCatalogFromItem(#orderId, #itemId);
SELECT l.warehouseFk, w.name warehouse, p.grouping,
p.price, p.priceKg, p.rate, l.available
FROM tmp.ticketLot l
JOIN tmp.ticketComponentPrice p ON p.warehouseFk = l.warehouseFk
JOIN vn.warehouse w ON w.id = p.warehouseFk
ORDER BY warehouseFk, grouping;
DROP TEMPORARY TABLE
tmp.ticketCalculateItem,
tmp.ticketComponentPrice,
tmp.ticketComponent,
tmp.ticketLot,
tmp.zoneGetShipped;`,
{ orderId: basketOrderId.value, itemId }
);
return res.results[1].data;
} catch (error) {
console.error('Error getting items:', error);
}
};
const addedItemsAmountAcc = ref({});
const amount = ref(0);
const onAddLotClick = async lot => {
try {
const { grouping, warehouseFk, available } = lot;
let lotAmount = addedItemsAmountAcc.value[warehouseFk];
if (lotAmount === undefined) lotAmount = 0;
if (lotAmount < available) {
let newAmount = lotAmount + grouping;
if (newAmount > available) newAmount = available;
addedItemsAmountAcc.value[warehouseFk] = newAmount;
amount.value += newAmount - lotAmount;
} else {
notify(t('amountNotAvailable'), 'negative');
}
} catch (error) {
console.error('Error adding item to basket:', error);
}
};
const resetAmounts = () => {
addedItemsAmountAcc.value = {};
amount.value = 0;
};
const addItemToOrder = async params => {
try {
await jApi.execQuery(
`CALL myOrder_addItem(#orderId, #warehouse, #item, #amount);`,
params
);
} catch (error) {
console.error('Error adding item to basket:', error);
}
};
const onConfirmClick = async params => {
try {
let amountSum = 0;
const addItemPromises = [];
for (const warehouse in addedItemsAmountAcc.value) {
const amount = addedItemsAmountAcc.value[warehouse];
amountSum += amount;
const params = {
orderId: basketOrderId.value,
warehouse,
item: selectedItem.value.id,
amount
};
addItemPromises.push(addItemToOrder(params));
}
if (amountSum > 0) {
await Promise.all(addItemPromises);
notify(
`${t('added')} ${amountSum} ${selectedItem.value.item}`,
'positive'
);
}
} catch (error) {
console.error('Error adding item to basket:', error);
} finally {
showItemDialog.value = false;
resetAmounts();
}
};
const refreshTitle = () => {
const { meta } = route;
let title = t(meta.title);
let subtitle;
if (selectedCategory.value) {
const _category = categories.value.find(
i => i.id === selectedCategory.value
);
if (_category) {
title = _category.name;
if (selectedType.value) {
const _type = itemFamilies.value.find(
i => i.id === selectedType.value
);
if (_type) {
subtitle = title;
title = _type.name;
}
}
}
}
appStore.$patch({ title, subtitle });
};
const onViewModeClick = () => {
viewMode.value = viewMode.value === 'list' ? 'grid' : 'list';
};
watch(
() => route.query.search,
val => {
if (val) {
search.value = val;
debouncedGetItems();
}
}
);
onBeforeMount(async () => {
if (!isGuest.value) {
await appStore.check('catalog');
} else {
// TODO: Implement this logic when isGuest is implemented
// const resultSet = await jApi.execQuery(
// 'CALL myOrder_configureForGuest(@orderId); SELECT @orderId;'
// );
// resultSet.fetchResult();
// this.orderId = resultSet.fetchValue();
}
await getOrder();
await getCategories();
if (route.params.category)
selectedCategory.value = Number(route.params.category);
if (route.params.type) selectedType.value = Number(route.params.type);
});
</script>
<style lang="scss" scoped>
.search {
max-width: 250px;
}
.basket-info {
display: flex;
flex-direction: column;
background-color: #8cc63f;
color: white;
padding: 17px 28px;
border-radius: 7px;
text-align: center;
}
.categories {
display: flex;
flex-wrap: wrap;
justify-content: center;
.category {
width: 54px;
&.active {
background: rgba(0, 0, 0, 0.08);
}
& > img {
height: 40px;
width: 40px;
}
}
}
</style>
<i18n lang="yaml">
en-US:
category: Category
deleteFilter: Delete filter
family: Family
color: Color
producer: Producer
origin: Origin
pleaseSetFilter: Choose a filter from the right menu
orderBy: Order by
relevancy: Relevancy
name: Name
lowerSize: Lower size
higherSize: Higher size
lowerPrice: Lower price
higherPrice: Higher price
add: Add
added: Added
listView: List view
gridView: Grid view
filterBy: Filter by
es-ES:
category: Categoría
deleteFilter: Quitar filtro
family: Familia
search: Buscar
color: Color
producer: Productor
origin: Origen
pleaseSetFilter: Elige un filtro en el menú de la derecha
orderBy: Ordernar por
relevancy: Relevancia
name: Nombre
lowerSize: Medida más pequeña
higherSize: Medida más grande
lowerPrice: Precio más bajo
higherPrice: Precio más alto
add: Añadir
added: Añadido
listView: Vista de lista
gridView: Vista de rejilla
filterBy: Filtrar por
ca-ES:
category: Categoría
deleteFilter: Eliminar filtro
family: Família
color: Color
producer: Productor
origin: Origen
pleaseSetFilter: Tria un filtre en el menú de la dreta
orderBy: Ordenar per
relevancy: Relevància
name: Nom
lowerSize: Mida més petita
higherSize: Mida més gran
lowerPrice: Preu més baix
higherPrice: Preu més alt
add: Afegir
listView: Vista de llista
gridView: Vista de graella
filterBy: Filtrar per
fr-FR:
category: Catégorie
deleteFilter: Supprimer le filtre
family: Famille
color: Couleur
producer: Producteur
origin: Origine
pleaseSetFilter: Choisissez un filtre dans le menu de droite
orderBy: Trier par
relevancy: Pertinence
name: Nom
lowerSize: Taille le plus bas
higherSize: Taille le plus élevé
lowerPrice: Prix le plus bas
higherPrice: Prix le plus élevé
add: Ajouter
listView: Vue en liste
gridView: Vue en grille
filterBy: Filtrer par
pt-PT:
category: Categoria
deleteFilter: Apagar filtro
family: Família
color: Cor
producer: Produtor
origin: Origem
pleaseSetFilter: Escolha um filtro no menu à direita
orderBy: Ordenar por
relevancy: Relevância
name: Nome
lowerSize: Tamanho menor
higherSize: Tamanho maior
lowerPrice: Preço mais baixo
higherPrice: Preço mais alto
add: Adicionar
listView: Vista de lista
gridView: Vista de grade
filterBy: Filtrar por
</i18n>