Skip to content

Add product filter and sort #1482

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions components/Products/ProductsFilters.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<template>
<div class="w-full md:w-64 flex-shrink-0">
<div class="bg-white px-8 pb-8 sm:px-6 sm:pb-6 rounded-lg shadow-sm">
<div class="mb-8">
<h3 class="font-semibold mb-4">PRODUKT TYPE</h3>
<div class="space-y-2">
<CommonCheckbox
v-for="type in productTypes"
:key="type.id"
:id="type.id"
:label="type.name"
:modelValue="type.checked"
@change="(checked) => toggleProductType(type.id)"
/>
</div>
</div>

<div class="mb-8">
<h3 class="font-semibold mb-4">PRIS</h3>
<CommonRangeSlider
id="price-range"
label="Pris"
:min="0"
:max="1000"
:value="priceRange[1]"
:startValue="priceRange[0]"
@input="(value) => setPriceRange([priceRange[0], value])"
/>
</div>

<div class="mb-8">
<h3 class="font-semibold mb-4">STØRRELSE</h3>
<div class="grid grid-cols-3 gap-2">
<CommonButton
v-for="size in sizes"
:key="size"
:selected="selectedSizes.includes(size)"
variant="filter"
@click="() => toggleSize(size)"
>
{{ size }}
</CommonButton>
</div>
</div>

<div class="mb-8">
<h3 class="font-semibold mb-4">FARGE</h3>
<div class="grid grid-cols-3 gap-2">
<CommonColorSwatch
v-for="color in colors"
:key="color.name"
:color="color.hex"
:title="color.name"
:class="{ 'ring-2 ring-offset-2 ring-gray-900': selectedColors.includes(color.name) }"
@click="() => toggleColor(color.name)"
style="cursor:pointer"
/>
</div>
</div>

<CommonButton variant="reset" class="mt-4 w-full" @click="resetFilters">
Resett filter
</CommonButton>
</div>
</div>
</template>

<script setup>
defineProps({
productTypes: Array,
toggleProductType: Function,
priceRange: Array,
setPriceRange: Function,
sizes: Array,
selectedSizes: Array,
toggleSize: Function,
colors: Array,
selectedColors: Array,
toggleColor: Function,
resetFilters: Function,
});
</script>
95 changes: 63 additions & 32 deletions components/Products/ProductsShowAll.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div id="product-container" class="flex flex-wrap items-center">
<template v-for="product in products" :key="product.id">
<template v-for="product in filteredProducts" :key="product.id">
<div class="flex flex-col mt-6 sm:w-1/2 md:w-1/3 lg:w-1/4 lg:mr-4">
<NuxtLink
class="text-black cursor-pointer hover:underline"
Expand All @@ -24,34 +24,80 @@
</template>

<script setup>
import FETCH_ALL_PRODUCTS_QUERY from "@/apollo/queries/FETCH_ALL_PRODUCTS_QUERY.gql";
import GET_PRODUCTS_FROM_CATEGORY_QUERY from "@/apollo/queries/GET_PRODUCTS_FROM_CATEGORY_QUERY.gql";

import ProductImage from "@/components/Products/ProductImage.vue";
import ProductPrice from "@/components/Products/ProductPrice.vue";

const props = defineProps({
categoryId: { type: String, required: false },
categorySlug: { type: String, required: false },
sortBy: { type: String, required: false, default: "popular" },
selectedSizes: { type: Array, required: false, default: () => [] },
selectedColors: { type: Array, required: false, default: () => [] },
priceRange: { type: Array, required: false, default: () => [0, 1000] },
productTypes: { type: Array, required: false, default: () => [] },
});

const config = useRuntimeConfig();

const products = computed(() => {
return (
allCategoryProducts.value?.productCategory?.products?.nodes ||
allProducts.value?.products?.nodes ||
[]
);
import FETCH_ALL_PRODUCTS_QUERY from "@/apollo/queries/FETCH_ALL_PRODUCTS_QUERY.gql";
import GET_PRODUCTS_FROM_CATEGORY_QUERY from "@/apollo/queries/GET_PRODUCTS_FROM_CATEGORY_QUERY.gql";

const productVariables = { limit: 99 };
const { data: allProducts } = await useAsyncQuery(
FETCH_ALL_PRODUCTS_QUERY,
productVariables,
);

const products = computed(() => allProducts.value?.products?.nodes || []);

// --- Filtering/Sorting ---
const filteredProducts = computed(() => {
let filtered = products.value.filter((product) => {
// Price
const productPrice = parseFloat((product.price || "0").replace(/[^0-9.]/g, ""));
if (productPrice < props.priceRange[0] || productPrice > props.priceRange[1]) return false;

// Product Type
const selectedTypes = props.productTypes.filter((t) => t.checked).map((t) => t.name.toLowerCase());
if (selectedTypes.length > 0) {
const productCategories = (product.productCategories?.nodes || []).map((cat) => cat.name.toLowerCase());
if (!selectedTypes.some((type) => productCategories.includes(type))) return false;
}

// Size
if (props.selectedSizes.length > 0) {
const productSizes = product.allPaSizes?.nodes?.map((node) => node.name) || [];
if (!props.selectedSizes.some((size) => productSizes.includes(size))) return false;
}

// Color
if (props.selectedColors.length > 0) {
const productColors = product.allPaColors?.nodes?.map((node) => node.name) || [];
if (!props.selectedColors.some((color) => productColors.includes(color))) return false;
}

return true;
});

// Sorting
filtered = [...filtered].sort((a, b) => {
const priceA = parseFloat((a.price || "0").replace(/[^0-9.]/g, ""));
const priceB = parseFloat((b.price || "0").replace(/[^0-9.]/g, ""));
switch (props.sortBy) {
case "price-low":
return priceA - priceB;
case "price-high":
return priceB - priceA;
case "newest":
return (b.databaseId || 0) - (a.databaseId || 0);
default:
return 0;
}
});

return filtered;
});

/**
* Returns the path and query parameters for a product link.
*
* @param {Object} product - Object containing product information.
* @param {string} product.slug - The product's URL slug.
* @param {number} product.databaseId - The product's database ID.
* @return {Object} An object containing the product's path and query parameters.
*/
const productLink = (product) => {
return {
Expand All @@ -62,24 +108,9 @@ const productLink = (product) => {

/**
* Returns the source URL of a product image or a placeholder image if the product does not have an image.
*
* @param {Object} product - The product object containing the image source URL.
* @return {string} The source URL of the product image or a placeholder image if the product does not have an image.
*/
const productImage = (product) =>
product.image ? product.image.sourceUrl : config.public.placeholderImage;

const productVariables = { limit: 99 };
const { data: allProducts } = await useAsyncQuery(
FETCH_ALL_PRODUCTS_QUERY,
productVariables,
);

const categoryVariables = { id: props.categoryId };
const { data: allCategoryProducts } = await useAsyncQuery(
GET_PRODUCTS_FROM_CATEGORY_QUERY,
categoryVariables,
);
</script>

<style scoped>
Expand Down
14 changes: 14 additions & 0 deletions components/Products/ProductsSort.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<template>
<div class="flex items-center gap-2">
<label for="sort-select" class="text-base text-gray-700">Sortering:</label>
<select
id="sort-select"
class="min-w-[140px] border border-gray-200 rounded-lg px-3 py-2 text-base bg-gray-50 text-gray-700 focus:outline-none"
>
<option value="popular">Populær</option>
<option value="price-low">Pris: Lav til Høy</option>
<option value="price-high">Pris: Høy til Lav</option>
<option value="newest">Nyeste</option>
</select>
</div>
</template>
25 changes: 25 additions & 0 deletions components/common/Checkbox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<template>
<label class="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
class="form-checkbox h-4 w-4 text-blue-600 rounded"
:checked="modelValue"
@change="$emit('change', $event.target.checked)"
/>
<span class="text-sm">{{ label }}</span>
</label>
</template>

<script setup>
defineProps({
label: {
type: String,
default: '',
},
modelValue: {
type: Boolean,
default: false,
},
});
defineEmits(['change']);
</script>
20 changes: 20 additions & 0 deletions components/common/ColorSwatch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<span
class="inline-block w-8 h-8 rounded-full border-2 border-white shadow"
:style="{ backgroundColor: color }"
:title="title"
></span>
</template>

<script setup>
defineProps({
color: {
type: String,
default: '#3b82f6', // Tailwind blue-500
},
title: {
type: String,
default: '',
},
});
</script>
14 changes: 14 additions & 0 deletions components/common/RangeSlider.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<template>
<div class="w-full">
<input
type="range"
min="0"
max="1000"
class="w-full accent-blue-600"
/>
<div class="flex justify-between text-sm mt-1">
<span>kr 0</span>
<span>kr 1000</span>
</div>
</div>
</template>
88 changes: 74 additions & 14 deletions pages/products.vue
Original file line number Diff line number Diff line change
@@ -1,19 +1,79 @@
<template>
<ProductsShowAll />
<div class="container mx-auto px-4 py-8">
<div class="flex flex-col md:flex-row gap-8">
<ProductsFilters
:productTypes="productTypes"
:toggleProductType="toggleProductType"
:priceRange="priceRange"
:setPriceRange="setPriceRange"
:sizes="sizes"
:selectedSizes="selectedSizes"
:toggleSize="toggleSize"
:colors="colors"
:selectedColors="selectedColors"
:toggleColor="toggleColor"
:resetFilters="resetFilters"
/>
<div class="flex-1">
<div
class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-8"
>
<h1 class="text-xl sm:text-2xl font-medium text-center sm:text-left">
Produkter
</h1>
<ProductsSort v-model="sortBy" />
</div>
<ProductsShowAll
:sortBy="sortBy"
:selectedSizes="selectedSizes"
:selectedColors="selectedColors"
:priceRange="priceRange"
:productTypes="productTypes"
/>
</div>
</div>
</div>
</template>

<script setup>
useHead({
title: "Products",
titleTemplate: "%s - Nuxt 3 Woocommerce",
meta: [
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{
hid: "description",
name: "description",
content: "Nuxt 3 Woocommerce",
},
],
link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" }],
});
const sortBy = ref("popular");
const selectedSizes = ref([]);
const selectedColors = ref([]);
const priceRange = ref([0, 1000]);
const productTypes = ref([
{ id: "clothing", name: "Clothing", checked: false },
{ id: "tshirts", name: "Tshirts", checked: false },
{ id: "uncategorized", name: "Uncategorized", checked: false },
]);
const sizes = ref(["Large"]);
const colors = ref([{ name: "Blue", hex: "#3b82f6" }]);

function toggleProductType(id) {
productTypes.value = productTypes.value.map((type) =>
type.id === id ? { ...type, checked: !type.checked } : type
);
}
function setPriceRange(newRange) {
priceRange.value = newRange;
}
function toggleSize(size) {
if (selectedSizes.value.includes(size)) {
selectedSizes.value = selectedSizes.value.filter((s) => s !== size);
} else {
selectedSizes.value = [...selectedSizes.value, size];
}
}
function toggleColor(color) {
if (selectedColors.value.includes(color)) {
selectedColors.value = selectedColors.value.filter((c) => c !== color);
} else {
selectedColors.value = [...selectedColors.value, color];
}
}
function resetFilters() {
selectedSizes.value = [];
selectedColors.value = [];
priceRange.value = [0, 1000];
productTypes.value = productTypes.value.map((type) => ({ ...type, checked: false }));
}
</script>