diff --git a/package-lock.json b/package-lock.json index 325009f43a1e9ac5e917359abdc88e3764ea8973..0e41c4d52d0928c850db5bcb9ea7a342a14d6a2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@iconify/vue": "^4.1.1", "@pinia/testing": "^0.0.16", "jwt-decode": "^3.1.2", + "lodash": "^4.17.21", "pinia": "^2.0.35", "pinia-plugin-persistedstate": "^3.1.0", "vue": "^3.2.45", @@ -3836,8 +3837,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.once": { "version": "4.1.1", diff --git a/package.json b/package.json index 0eb0aa1f4754b48a7823428f190f1a111aca44af..7a96571b84bc49ee76db855c7ade3f24b03d71ca 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "jwt-decode": "^3.1.2", "pinia": "^2.0.35", "pinia-plugin-persistedstate": "^3.1.0", + "sass": "^1.62.0", "vue": "^3.2.45", "vue-router": "^4.1.6" }, diff --git a/src/App.vue b/src/App.vue index 1358b7e3e414d1466e1288bbfed8cdcabdafb3fc..d9988e4a1832293fff956f9976fd7782c97b76ec 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,19 +1,15 @@ <script setup> -import { RouterLink, RouterView } from 'vue-router' -import Navbar from "@/components/Navbar.vue"; - + import { RouterView } from 'vue-router' + import Navbar from "@/components/Navbar.vue"; </script> <template> - <Navbar></Navbar> - <body> - <RouterView /> - </body> - + <Navbar v-if="$route.name !== 'login' && $route.name !== 'selectProfile'" /> + <RouterView /> </template> -<style scoped> +<style lang=scss scoped> header { line-height: 1.5; max-height: 100vh; @@ -52,7 +48,7 @@ nav a:first-of-type { border: 0; } -@media (min-width: 1024px) { +@media (min-width: base.$desktop-min) { header { display: flex; place-items: center; diff --git a/src/assets/main.css b/src/assets/main.css index 2d624bc8d97520029335b1976f166e1a46c461b1..c417db8ebe95a78dcdf6bb8c362b0a6cb4dc518b 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -25,10 +25,14 @@ a, body { display: flex; place-items: center; + justify-content: center; + align-items: center; } #app { display: grid; - padding: 0 2rem; + padding: 2rem 2rem; + + } } diff --git a/src/components/FridgeItem.vue b/src/components/FridgeItem.vue index d259d5db29f607aa03b9464483dd6c51132e4164..1d58f426274b49c89a218ce4b94c9431aab99ba4 100644 --- a/src/components/FridgeItem.vue +++ b/src/components/FridgeItem.vue @@ -10,7 +10,6 @@ </div> </div> - <hr> </template> <script> @@ -112,13 +111,17 @@ export default { #item { background-color: base.$white; color: black; - qborder-radius: 10px; padding: 1em; padding-left: 2em; padding-right:2em; display:flex; align-items: center; justify-content: space-between; + border-bottom: solid 1px base.$grey; +} + +#item:last-child { + border-bottom: none; } #fridgeItemName { diff --git a/src/components/GroceryOffers.vue b/src/components/GroceryOffers.vue index c970238dc31d8463b342f7f404db08c9988d6014..cbc668647d78b1a3fae6125cd7f1c68dceb2c945 100644 --- a/src/components/GroceryOffers.vue +++ b/src/components/GroceryOffers.vue @@ -1,6 +1,6 @@ <template> <div> - <h2>Tilbudaviser</h2> + <h2>Lenker til tilbudsaviser</h2> </div> <div id="list"> diff --git a/src/components/ItemSearch.vue b/src/components/ItemSearch.vue index cfa6e6c5d551e11c2a17317003dafdd9055ef909..12298cc97c69a64911f856fa1887a3864ca006ca 100644 --- a/src/components/ItemSearch.vue +++ b/src/components/ItemSearch.vue @@ -1,9 +1,9 @@ <template> <div v-if="showSearch" id="wrapper"> - <h3>SØK ETTER VARE</h3> + <h3>Søk etter vare</h3> <div id="searchBoxDiv"> <input type="text" id="searchBox" v-model="itemSearch"> - <button @click="search">Søk</button> + <button id="search-button" @click="search">Søk</button> </div> <p>Resultater: ({{searchResult.length}})</p> @@ -12,9 +12,10 @@ <option v-for ="item in searchResult" :value="item" :key="item.ean">{{item.name}} ({{item.amount.quantity}}{{item.amount.unit}})</option> </select> <p>Antall varer: <span v-if="numOfItemsToAdd>1 && selectedItem!=null">(totalt: {{this.totalNumOfItems}} {{selectedItem.amount.unit}})</span></p> - <input type="number" min='1' v-model="numOfItemsToAdd"><br> + <input id="items-input" type="number" min='1' v-model="numOfItemsToAdd"><br> - <button id = "addToFridgeBtn" @click="addToFridge">Legg i kjøleskap</button> + <button v-if="addsToFridge" id = "addToFridgeBtn" @click="addToFridge">Legg i kjøleskap</button> + <button v-else id = "addToFridgeBtn" @click="addToShoppingList">Legg i handleliste</button> </div> @@ -22,10 +23,17 @@ <script> import {API} from "@/util/API"; +import { useAuthStore } from "@/stores/authStore.js"; export default { name: "itemSearch", + props: { + addsToFridge: { + type: Boolean, + default: false + } + }, data(){ return{ itemSearch:'', @@ -56,6 +64,22 @@ export default { "quantity": this.selectedItem.amount.quantity*num, "unit": this.selectedItem.amount.unit}} ).then(() => this.$emit('itemsAdded',this.selectedItem)).catch((_)=> console.log("No items were added to the fridge")) + }, + async addToShoppingList() { + const num = this.numOfItemsToAdd; + const authStore = useAuthStore(); + + if (authStore.profile.restricted) { + await API.addSuggestion(this.selectedItem.id, num) + .then((ingredient) => {this.$emit('itemsAdded', ingredient)}) + .catch(err => console.log(err)); + } else { + await API.addItemToShoppingList(this.selectedItem.id, num) + .then((ingredient) => { + console.log(ingredient) + this.$emit('itemsAdded', ingredient) + }).catch(err => console.log(err)); + } } } } @@ -74,6 +98,14 @@ select { } +#items-input { + margin-bottom: 1em; +} + +#search-button { + margin-left: 0.5em; +} + #searchBoxDiv { display:flex; width:100%; @@ -91,14 +123,17 @@ button { padding: .5em; background-color: base.$light-green; border-radius: 5%; - + background-color: base.$green; + color: white; + font-weight: bold; border: 1px solid base.$green; + height: 30px; } button:hover { border: 1px solid base.$grey; - background-color: base.$light-green-hover; + background-color: base.$light-green; cursor: pointer; diff --git a/src/components/Navbar.vue b/src/components/Navbar.vue index 788592a89408f9515e0cedc16804a5f890e546db..6e2e12e21b564f1fa9ec4ec8c75ee96072e5b47c 100644 --- a/src/components/Navbar.vue +++ b/src/components/Navbar.vue @@ -18,7 +18,7 @@ </RouterLink> </li> <li> - <RouterLink :to="'/'" :aria-label="'link to shopping list'"> + <RouterLink :to="'/shoppingList'" :aria-label="'link to shopping list'"> <Icon icon="material-symbols:event-list-outline" :color="iconColor" :style="{ fontSize: iconSize }" /> </RouterLink> </li> diff --git a/src/components/ShoppingListItem.vue b/src/components/ShoppingListItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..e69e730e4f3910a5c67711a2320c95eca2a82b84 --- /dev/null +++ b/src/components/ShoppingListItem.vue @@ -0,0 +1,166 @@ +<template> + <div class="content"> + <div class="item"> + <div class="check"> + <input v-model="isChecked" @click="this.updateChecked()" class="checkbox" type="checkbox"> + </div> + + <p> {{ this.index }}</p> + + <div class="item-label"> + <label for="checkbox" class="checkbox-label">{{ this.amount }}x {{ this.itemName }}</label> + </div> + </div> + + <div class="delete"> + <img @click="deleteItem" src="./icons/trash.svg" alt="delete"> + </div> + </div> +</template> + +<script> + let uuid = 0; + + export default { + beforeCreate() { + this.uuid = uuid.toString(); + console.log(this.itemName + " + " + this.uuid) + + uuid += 1; + }, + + + name: "ShoppingListItem", + props: { + itemName: { + type: String, + default: "", + required: true + }, + amount: { + type: Number, + default: 1, + required: false + }, + propValue: { + type: Boolean, + default: false, + required: false + }, + index: { + type: Number, + required: true + + } + }, + data() { + return { + isChecked: this.propValue, + uuid: "" + } + }, + methods: { + updateChecked() { + this.$emit('updateItem', {id: this.uuid, isChecked: !this.isChecked}) + } + } + + + } +</script> + +<style lang="scss" scoped> + + .content { + display: flex; + flex-direction: row; + gap: 10px; + justify-content: space-between; + background-color: white; + padding: 5px; + border-top-right-radius: 10px; + border-top-left-radius: 10px; + //border-bottom: base.$grey solid 1px; + + } + + .content:not(:first-child) { + border-radius: 0px; + } + + .content:last-child { + border-radius: 0px; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; + } + + .content:not(:last-child)::after { + content: ''; + height: 1px; /* this works like a border-width */ + width: 70%; /* percentage of border shown */ + background: base.$grey; /* the color of border */ + position: absolute; + bottom: 0; + margin: 0 auto; left: 0; right: 0; /* horizontal centering */ + } + + .item { + display: flex; + flex-direction: row; + gap: 10px; + } + + input[type="checkbox"] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 35px; + height: 35px; + border-radius: 50%; + border: 2px solid #ccc; + + } + + input[type="checkbox"]:checked { + background-color: base.$light-green; + } + + input[type="checkbox"]:checked:after { + content: "\2713"; /* Unicode code for checkmark symbol */ + font-size: 24px; + font-weight: bold; + color: white; + text-align: center; + line-height: 35px; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + } + + label { + font-size: 20px; + } + + img { + width: 30px; + height: 30px; + padding: 5px; + + max-height: 100%; + max-width: 100%; + + + } + + .check { + display: flex; + justify-content: center; + align-items: center; + } + + .delete { + align-self: flex-end; + } + +</style> \ No newline at end of file diff --git a/src/components/__tests__/ShoppingListItem.spec.js b/src/components/__tests__/ShoppingListItem.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..8d61f2b68f7ed420666faf8e071de41c9191fd22 --- /dev/null +++ b/src/components/__tests__/ShoppingListItem.spec.js @@ -0,0 +1,23 @@ +import { describe, it, expect, vi} from 'vitest' +import { createTestingPinia } from '@pinia/testing' +import { mount } from '@vue/test-utils' +import ShoppingListItem from "@/components/ShoppingListItem.vue"; + + + +describe('ShoppingListItem', () => { + it('mounts correctly', () => { + const wrapper = mount(ShoppingListItem, { + global: { + plugins: [createTestingPinia({ + createSpy: vi.fn, + })], + }, + props: { + itemName: "Test", + amount: 99 + } + }) + expect(wrapper.text()).toMatch("99x Test") + }) +}) diff --git a/src/components/icons/tips.png b/src/components/icons/tips.png new file mode 100644 index 0000000000000000000000000000000000000000..7547b26b1bcbb2f138729714bd68dd5b20c5be29 Binary files /dev/null and b/src/components/icons/tips.png differ diff --git a/src/components/icons/trash.svg b/src/components/icons/trash.svg new file mode 100644 index 0000000000000000000000000000000000000000..d6a7d41dcee741cd4596a2738a0e4fa5b7b70440 --- /dev/null +++ b/src/components/icons/trash.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" id="Outline" viewBox="0 0 24 24" width="512" height="512"><path d="M21,4H17.9A5.009,5.009,0,0,0,13,0H11A5.009,5.009,0,0,0,6.1,4H3A1,1,0,0,0,3,6H4V19a5.006,5.006,0,0,0,5,5h6a5.006,5.006,0,0,0,5-5V6h1a1,1,0,0,0,0-2ZM11,2h2a3.006,3.006,0,0,1,2.829,2H8.171A3.006,3.006,0,0,1,11,2Zm7,17a3,3,0,0,1-3,3H9a3,3,0,0,1-3-3V6H18Z"/><path d="M10,18a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,10,18Z"/><path d="M14,18a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,14,18Z"/></svg> diff --git a/src/router/index.js b/src/router/index.js index 010ff3cc25d1fb1dd8dafae01bc7ed655878acf9..cf14f5330cb9f4cca178d0d7db316441fe80727f 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -8,6 +8,7 @@ import PinCodeView from "@/views/PinCodeView.vue"; import FridgeView from "@/views/FridgeView.vue"; import RecipeView from "@/views/RecipeView.vue"; +import ShoppingListView from '../views/ShoppingListView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -51,7 +52,12 @@ const router = createRouter({ path: '/recipe/:id', name: 'recipe', component: RecipeView - } + }, + { + path: '/shoppingList', + name: 'shoppingList', + component: ShoppingListView + }, ] }) diff --git a/src/stores/authStore.js b/src/stores/authStore.js index 70f611f62d6072269cf375c59fd42826cc0c2ca0..1aaf2007015cfaf17202ac2026888ecadfe05e96 100644 --- a/src/stores/authStore.js +++ b/src/stores/authStore.js @@ -5,7 +5,8 @@ export const useAuthStore = defineStore("auth", { token: "", account: {}, profile: {}, - profiles: [] + profiles: [], + items: {ingredientList: [], suggestionList: []} }; }, persist: { @@ -31,6 +32,12 @@ export const useAuthStore = defineStore("auth", { }, setProfiles(profiles) { this.profiles = profiles; + }, + setItems(items) { + this.items = items; + }, + setItem(item) { + this.items.push(item); } } }); diff --git a/src/style.scss b/src/style.scss index c7158435ec70a7af663f63d364e5946679272560..9a2b66fdb2767791e8529c854f69a6df196f92ea 100644 --- a/src/style.scss +++ b/src/style.scss @@ -3,13 +3,45 @@ $green: #00663C; $light-green: hsla(160, 100%, 37%, 1); $white:#FFFFFF; $grey:#D9D9D9; +$light-grey: #F3F4F9; $red:#EE6D6D; $red-hover: darken( $red, 5% ); $green-hover: darken( $green, 8% ); $light-green-hover: darken( $light-green, 10% ); +$indigo: #2c3e50; - -$desktop-min: 800px; +$desktop-min: 1024px; $phone-min : 360px; +.add-button { + border-radius: 50%; + border-style: none; + width: 70px; + height: 70px; + font-size: 60px; + display: flex; + align-items: center; + justify-content: center; + background-color: $light-green; + color: white; + transition: .25s ease-in-out; + line-height: 1; + padding: 0; +} + +.add-button:hover { + background-color: $green; +} + +.plus-sign:before { + content: '\FF0B'; +} +.plus-sign { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + margin: 0; + height: 100%; +} \ No newline at end of file diff --git a/src/util/API.js b/src/util/API.js index 97cde9c4e9c91048b5be41b0b7d448cf5395b118..db0615097ee0bac3af900ac26c3114e21f3d5e4a 100644 --- a/src/util/API.js +++ b/src/util/API.js @@ -64,7 +64,11 @@ export const API = { }); }, - // Sends the user into the home page logged in as the profile they clicked on + + /** + * Sends the user into the home page logged in as the profile specified + * @param id ID of the profile that the user will log in as + */ selectProfile: async (id) => { const authStore = useAuthStore() return axios.get(`${import.meta.env.VITE_BACKEND_URL}/profile/${id}`, { @@ -134,12 +138,14 @@ export const API = { }) }, - // Returns all profiles to the logged in user - getProfiles: async () => { - const authStore = useAuthStore(); - if (!authStore.isLoggedIn) { - throw new Error(); - } + /** + * @returns all profiles to the logged in user + */ + getProfiles: async () => { + const authStore = useAuthStore(); + if (!authStore.isLoggedIn) { + throw new Error(); + } return axios.get(import.meta.env.VITE_BACKEND_URL + '/profile', { headers: { Authorization: "Bearer " + authStore.token }, @@ -152,10 +158,12 @@ export const API = { }); }, - // Registers a new account and logs into it - addAccount: async (request) => { - const authStore = useAuthStore(); - + /** + * Registers a new account and logs into it + * @param email the email of the new account + * @param password the password of the new account + */ + addAccount: async (request) => { axios.post(import.meta.env.VITE_BACKEND_URL + '/account', request) .then(() => { API.login({email: request.email, password: request.password}) @@ -264,6 +272,138 @@ export const API = { }); }, + /** + * Getter for the shopping list of a logged in account + * @returns a promise that resolves to an array of Ingredient objects representing the shopping list, or an error if it fails + */ + getShoppingList: async () => { + const authStore = useAuthStore(); + + return axios.get(import.meta.env.VITE_BACKEND_URL + '/shoppinglist', + { + headers: { Authorization: "Bearer " + authStore.token }, + }) + .then((response) => { + authStore.setItems(response.data) + return response.data + }) + .catch(() => {throw new Error()}) + }, + + + /** + * Adds an item to the logged in account's shopping list + * @param itemId the id of the item that will be added + * @param amount the amount of the specified item that will be added + */ + addItemToShoppingList: async (itemId, amount) => { + const authStore = useAuthStore(); + return axios.put(import.meta.env.VITE_BACKEND_URL + '/shoppinglist/items/' + itemId + '/' + amount, "", + { + headers: { Authorization: "Bearer " + authStore.token } + }, + ) + .then(response => { + return response.data + }) + .catch(err => {console.log(err)}) + }, + + /** + * Deletes an ingredient(item with amount) from the logged in account's shopping list + * @param ingredientId the id of the ingredient that will be removed + */ + deleteItemFromShoppingList: async (ingredientId) => { + const authStore = useAuthStore(); + return axios.delete(import.meta.env.VITE_BACKEND_URL + '/shoppinglist/items/' + ingredientId, + { + headers: {Authorization: "Bearer " + authStore.token } + }) + .then(response => { return response.data; }) + .catch(err => {console.log(err)}) + }, + + /** + * Adds a suggestion to the logged in account's shopping list + * @param itemId the id of the item that will be suggested + * @param amount the amount of the specified item that will be suggested + */ + addSuggestion: async (itemId, amount) => { + const authStore = useAuthStore(); + return axios.put(import.meta.env.VITE_BACKEND_URL + '/shoppinglist/suggestions/' + itemId + '/' + amount, "", + { + headers: { Authorization: "Bearer " + authStore.token } + }) + .then(response => { return response.data; }) + .catch(err => {console.log(err)}) + }, + + + /** + * Accepts a suggestion and adds it to the shopping list, removing it from the suggestions list + * @param id the id of the ingredient that will be added to the list + */ + acceptSuggestion: async (id) => { + const authStore = useAuthStore(); + return axios.put(import.meta.env.VITE_BACKEND_URL + '/shoppinglist/suggestions/accept/' + id, "", + { + headers: { Authorization: "Bearer " + authStore.token } + }) + .then(response => { return response.data; }) + .catch(err => {console.log(err)}) + }, + + /** + * Declines a suggestion and removes it from the suggestions list + * @param id the id of the ingredient that will be declined + */ + declineSuggestion: async (id) => { + const authStore = useAuthStore(); + return axios.delete(import.meta.env.VITE_BACKEND_URL + '/shoppinglist/suggestions/' + id, + { + headers: { Authorization: "Bearer " + authStore.token } + }) + .then(response => { return response.data; }) + .catch(err => {console.log(err)}) + }, + + /** + * Searches for registered items. + * @param searchPhrase Name of the item that one is looking for (e.g: "purre") + * @returns {Promise<*>} list containing items that match the search phrase + */ + searchItems: async(searchPhrase)=> { + return axios.get(`${import.meta.env.VITE_BACKEND_URL}/item/search?name=${searchPhrase}`, { + }).then((response) => { + return response.data.content; + }).catch(()=> { + throw new Error("Error when searching for item "); + }) + }, + + // addIngredientsToFridge: async (ingredientIds) => { + // const authStore = useAuthStore(); + // return axios.delete(`${import.meta.env.VITE_BACKEND_URL}/shoppinglist/purchased`, { + // headers: { Authorization: `Bearer ${authStore.token}`}, + // data: { ingredientIds } + // }) + // .then((response) => {return response.data}) + // .catch(err => {console.log(err)}) + + // }, + + addIngredientsToFridge: async (ingredients) => { + const authStore = useAuthStore(); + return axios.put(`${import.meta.env.VITE_BACKEND_URL}/shoppinglist/purchased`, ingredients, { + headers: { Authorization: `Bearer ${authStore.token}`} + }) + .then((response) => {return response.data}) + .catch(err => {console.log(err)}) + + }, + + + /** * Get recipe based on id * @param id diff --git a/src/views/FridgeView.vue b/src/views/FridgeView.vue index cd8088e689767f9daa549a4dcaa98a65b30c4b68..a52a9ddb21d8bb8156bbf32b788e9e9785c165d9 100644 --- a/src/views/FridgeView.vue +++ b/src/views/FridgeView.vue @@ -1,8 +1,8 @@ -<template><h1>Kjøleskap</h1><br><br> +<template><h1>Kjøleskap</h1> <main> <ItemSearch v-if="searchVisible" @itemsAdded="updateFridge"></ItemSearch> - <div id = "fridgeMsg"><p>Melding fra kjøleskapet:</p><span>{{this.fridgeMsg}}</span></div> + <div id = "fridgeMsg"><p v-if="this.fridgeMsg != ''">Melding fra kjøleskapet:</p><span>{{this.fridgeMsg}}</span></div> <eat-fridge-item-modal @closeModal="hideModal" v-if="visible" :fridge-item="selectedItem"></eat-fridge-item-modal> <div id = "itemContainer" > @@ -10,8 +10,8 @@ </div> <div id="addItemBtn-container"> - <button @click="showItemSearch" id="addItemBtn"> - <span class="plus">+</span> + <button :class="{ rotate: this.searchVisible }" @click="showItemSearch" class="add-button"> + <span class="plus-sign"></span> </button> </div> </main> @@ -72,7 +72,7 @@ export default { async mounted() { this.fridgeItems = await API.getFridgeItems(); if(this.fridgeItems.length===0){ - this.fridgeMsg="Kjøleskapet ditt er tomt. Legg inn varer ved å trykke på pluss-knappen nederst i høyre hjørne" + this.fridgeMsg="Kjøleskapet ditt er tomt." } } } @@ -82,21 +82,24 @@ export default { main { color:black; - background-color: base.$grey; - padding: 2em; + //background-color: base.$grey; + padding: 1em 1em; min-height: 600px; } h1 { width:100%; - padding-left: 1em; + padding-left: 0.5em; padding-top: 2em; - font-weight: bold; } #itemContainer { - background-color: base.$grey; - padding-bottom: 5em; + padding-bottom: 170px; + +} + +.rotate { + -webkit-transform: rotate(45deg); } #addItemBtn { @@ -118,16 +121,16 @@ h1 { } #addItemBtn-container { - padding-bottom: 3em; - z-index: 9999; position: fixed; - bottom: 15px; - right: 1em; + bottom: 75px; + right: 10px; + float: right; } #fridgeMsg { background-color: base.$light-green; padding: 1em; + min-height: 3em; } #fridgeMsg span, #fridgeMsg p{ @@ -135,4 +138,8 @@ h1 { font-weight: bolder; font-size: 1.2em; } + +p, span { + color: white; +} </style> \ No newline at end of file diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 0f9f671c983e1870a64046c51cca21bd25d04cd7..af7052980cac33be13f5f373d144934daaa8f204 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -31,12 +31,15 @@ </div> <div class="tips"> - <img id="tips-img" src="../components/icons/logo.png" alt="Logo"> - <p id="tips-text">Her kommer tips du kanskje kan ha nytte av, trykk på meg for å gå til neste tips!</p> + <img id="tips-img" src="../components/icons/tips.png" alt="Logo"> + <p id="tips-text">Frys ned rester, så kan du ta opp en porsjon senere når du trenger et raskt måltid, i stedet for å kaste restene.</p> </div> + <div class="grocery"> <grocery-offers></grocery-offers> + </div> + </div> </main> @@ -68,8 +71,8 @@ } #tips-img { - width: 40px; - height: 40px; + width: 55px; + height: 55px; margin: auto 0; } @@ -82,6 +85,7 @@ background-color: rgb(232, 232, 232); margin-left: 10px; margin-right: 10px; + margin-bottom: 1.5em; } diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index fe18b47e8ece9dd949df35e5b0517c8c9c22c3a5..17694f15d73d1c73d27affc3f75fcc27bfa6a233 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -76,6 +76,12 @@ form { flex-direction: column; } +#error-message { + text-align: center; + width: 80%; + +} + input { height: 40px; font-size: 16px; diff --git a/src/views/ProfileCreationView.vue b/src/views/ProfileCreationView.vue index 1f0d2c8e3c0076dd8e98c873b0f3c60aa1ea04f0..560f3c5d29264e61c614ce618b6cf56f8e162220 100644 --- a/src/views/ProfileCreationView.vue +++ b/src/views/ProfileCreationView.vue @@ -50,7 +50,7 @@ export default { <main> <h1>Ny profil</h1> - <form action="todo" method="post"> + <form @submit.prevent="submit"> <label for="profile_img" id="profile_img_label"> <div class="img_hover"> <img :src="image" alt="fjes" id="profile_img_preview"> diff --git a/src/views/SelectProfileView.vue b/src/views/SelectProfileView.vue index 8b7130a3a16826fec45442d429cb7458b1a3c7f6..d8bd68c631b574e531cf4a7e7381c9c254d2435b 100644 --- a/src/views/SelectProfileView.vue +++ b/src/views/SelectProfileView.vue @@ -2,6 +2,7 @@ import { API } from '@/util/API.js'; import { useAuthStore } from "@/stores/authStore.js"; import { mapState } from 'pinia' + import router from '../router'; export default { data() { @@ -22,7 +23,7 @@ // Sends the user into the "register profile" view addProfile() { - API.addProfile(); + router.push('/newProfile') }, // Receives all profiles from this user @@ -55,7 +56,7 @@ </div> <div class="add"> - <button @click="addProfile">+</button> + <button class="add-button" @click="addProfile"><span class="plus-sign"></span></button> </div> </div> </template> @@ -104,19 +105,6 @@ padding: 10px; } - - button { - border-radius: 50%; - border-style: none; - width: 50px; - height: 50px; - font-size: 50px; - display: flex; - align-items: center; - justify-content: center; - padding-bottom: 10px; - } - #selectProfileBtn { background-color: transparent; border:none; diff --git a/src/views/ShoppingListView.vue b/src/views/ShoppingListView.vue new file mode 100644 index 0000000000000000000000000000000000000000..7501e1ad2c5feab9e15f678a8c07acfacbe99a87 --- /dev/null +++ b/src/views/ShoppingListView.vue @@ -0,0 +1,372 @@ +<template> + <div class="container"> + <h1 v-if="this.items.suggestionList.length === 0">Handleliste</h1> + + <ItemSearch v-if="this.showAddMenu && this.profile.restricted" + @itemsAdded="(ingredient) => updateSuggestionList(ingredient)" :addsToFridge="false"></ItemSearch> + <ItemSearch v-else-if="this.showAddMenu && !this.profile.restricted" + @itemsAdded="(ingredient) => updateShoppingList(ingredient)" :addsToFridge="false"></ItemSearch> + + <div class="list"> + <div class="items"> + + <div v-if="items.suggestionList.length > 0" class="suggestions-wrapper"> + <h2>Forslag</h2> + <div v-for="(item, index) in items.suggestionList" :key="index" :item="item" class="item"> + + <div class="item-wrapper"> + <div class="item-label"> + <label v-if="item.amount.quantity === 1" for="checkbox" class="checkbox-label">{{ + item.item.name }}</label> + <label v-else for="checkbox" class="checkbox-label">{{ item.amount.quantity }}x {{ + item.item.name }}</label> + </div> + </div> + + <div class="buttons"> + <button v-if="!this.profile.restricted" class="accept-button" + @click="acceptItem(item.ingredient_id)">Godta</button> + <button class="decline-button" @click="declineItem(item.ingredient_id)">Avslå</button> + </div> + + </div> + </div> + + + <h2 v-if="this.items.ingredientList.length > 0 && this.items.suggestionList > 0">Handleliste</h2> + <div v-for="(item, index) in items.ingredientList" :key="index" :item="item" class="item"> + + <div class="item-wrapper"> + <div class="check"> + <input v-model="item.isChecked" @change="sortList" class="checkbox" type="checkbox"> + </div> + + <div class="item-label"> + <label v-if="item.amount.quantity === 1" for="checkbox" class="checkbox-label">{{ item.item.name + }}</label> + <label v-else for="checkbox" class="checkbox-label">{{ item.amount.quantity }}x {{ + item.item.name }}</label> + </div> + </div> + + <div class="buttons"> + <img id="delete-button" @click="deleteItem(item.ingredient_id)" src="../components/icons/trash.svg" alt="delete"> + </div> + + </div> + </div> + + </div> + <div class="add"> + <button :class="{ rotate: this.showAddMenu }" class="add-button" @click="addItem"><span class="plus-sign"></span></button> + </div> + </div> +</template> + +<script> +import { API } from '../util/API.js'; +import { mapState } from 'pinia'; +import { useAuthStore } from "@/stores/authStore.js"; +import ItemSearch from '../components/ItemSearch.vue' + + +export default { + data() { + return { + testItems: [{ itemName: "banan", amount: 2, isChecked: false }, { itemName: "ost", amount: 1, isChecked: false }, { itemName: "eple", amount: 4, isChecked: false }, { itemName: "brus", amount: 1, isChecked: false }], + ingredients: [], + showAddMenu: false, + } + }, + components: { + ItemSearch + }, + methods: { + sortList() { + this.items.ingredientList.sort((a, b) => a.isChecked - b.isChecked) + }, + async getShoppingList() { + await API.getShoppingList() + .catch(err => { console.log(err) }) + }, + async addItem() { + this.showAddMenu = !this.showAddMenu; + window.scrollTo({ top: 0, behavior: 'smooth' }); + + }, + async acceptItem(id) { + await API.acceptSuggestion(id) + .then((response) => { + this.items.suggestionList.forEach((ingredient, index) => { + if (ingredient.ingredient_id === response.ingredient_id) { + this.items.suggestionList.splice(index, 1); + } + }) + this.updateShoppingList(response) + }) + + }, + async declineItem(id) { + await API.declineSuggestion(id) + .then((response) => { + this.items.suggestionList.forEach((ingredient, index) => { + if (ingredient.ingredient_id === response.ingredient_id) { + this.items.suggestionList.splice(index, 1); + } + }) + }) + }, + async deleteItem(id) { + await API.deleteItemFromShoppingList(id) + .then(() => { + this.items.ingredientList.forEach((ingredient, index) => { + if (ingredient.ingredient_id === id) { + this.items.ingredientList.splice(index, 1); + } + }) + }) + + }, + addIsCheckedToIngredient() { + this.items.ingredientList.forEach(ingredient => { + if (!ingredient.hasOwnProperty('isChecked')) { + ingredient.isChecked = false; + } + }) + }, + updateShoppingList(ingredient) { + ingredient.isChecked = false; + this.items.ingredientList.push(ingredient); + this.showAddMenu = false; + }, + updateSuggestionList(ingredient) { + ingredient.isChecked = false; + this.items.suggestionList.push(ingredient) + this.showAddMenu = false; + }, + sendIngredientsToFridge() { + const boughtIngredients = [] + + this.items.ingredientList.forEach(ingredient => { + if (ingredient.isChecked) { + boughtIngredients.push(ingredient) + } + }) + console.log(boughtIngredients) + API.addIngredientsToFridge(boughtIngredients) + } + + }, + computed: { + ...mapState(useAuthStore, ['items', 'profile']) + }, + async mounted() { + await this.getShoppingList() + .then(() => { + this.items.ingredientList.forEach(item => { + item.isChecked = false + }) + }) + }, + beforeRouteLeave (to, from) { + this.sendIngredientsToFridge() + } +} +</script> + +<style lang="scss" scoped> +.container { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + margin: auto; + + max-width: 550px; + margin-top: 4em; + height: 100%; +} + +h1 { + + width:100%; + padding-left: 0.5em; + +} + + + +.rotate { + -webkit-transform: rotate(45deg); +} + +.accept-button { + border-radius: 10px; + background-color: base.$light-green; + color: white; + font-weight: bold; + border-style: none; + height: 27px; + padding: 0 10px; +} + +.decline-button { + border-radius: 10px; + background-color: base.$grey; + color: base.$indigo; + font-weight: bold; + border-style: none; + height: 27px; + padding: 0 10px; + margin: 0 auto; + +} + +.buttons { + display: flex; + gap: 5px; + align-self: center +} + + +h2 { + padding: 5px 15px +} + + + +.add { + position: fixed; + bottom: 75px; + right: 10px; + float: right; +} + +.header { + display: flex; + align-items: left; + justify-content: left; + padding-left: 15px; + width: 100%; + border-bottom: 1px solid base.$grey; +} + +.list { + min-width: 300px; + min-height: 100vh; + height: 100%; + /* background-color: #F3F4F9; */ + width: 100%; + // padding: 15px; + // border-radius: 10px; +} + +.items { + padding-bottom: 170px; +} + +.suggestions-wrapper { + border-bottom: 1px solid base.$grey; + border-width: 100%; + padding-bottom: 15px; + + +} + +.item { + display: flex; + flex-direction: row; + gap: 10px; + justify-content: space-between; + background-color: white; + padding: 5px 15px; + padding: 0.5em 1em; + border-top-right-radius: 10px; + border-top-left-radius: 10px; + +} + +.item:not(:first-child) { + border-radius: 0px; +} + +.item:last-child { + border-radius: 0px; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; +} + +// .item:not(:last-child)::after { +// content: ''; +// /* this works like a border-width */ +// height: 1px; +// /* percentage of border shown */ +// width: 70%; +// /* the color of border */ +// background: base.$grey; +// position: absolute; +// bottom: 0; +// margin: 0 auto; +// left: 0; +// /* horizontal centering */ +// right: 0; +// } + +.item-wrapper { + display: flex; + flex-direction: row; + gap: 10px; + +} + +input[type="checkbox"] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 35px; + height: 35px; + border-radius: 50%; + border: 2px solid #ccc; + +} + +input[type="checkbox"]:checked { + background-color: base.$light-green; +} + +input[type="checkbox"]:checked:after { + content: "\2713"; + /* Unicode code for checkmark symbol */ + font-size: 24px; + font-weight: bold; + color: white; + text-align: center; + line-height: 35px; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} + +label { + font-size: 20px; +} + +img { + width: 30px; + height: 30px; + padding: 5px; + + max-height: 100%; + max-width: 100%; +} + +.check { + display: flex; + justify-content: center; + align-items: center; +} + +.delete { + align-self: flex-end; +}</style> \ No newline at end of file diff --git a/src/views/__tests__/FridgeView.spec.js b/src/views/__tests__/FridgeView.spec.js index 4790f4ad2639935f3da4a5694dafcb3a27738181..4312e6a5f91ac7d53812647c27bb300461c284b0 100644 --- a/src/views/__tests__/FridgeView.spec.js +++ b/src/views/__tests__/FridgeView.spec.js @@ -38,7 +38,7 @@ describe('Fridge', () => { }) expect(wrapper.find('ItemSearch').exists()).toBe(false); - await wrapper.find('#addItemBtn').trigger('click'); + await wrapper.find('.add-button').trigger('click'); setTimeout(() => { expect(wrapper.find('ItemSearch').exists()).toBe(true); diff --git a/src/views/__tests__/SelectProfileView.spec.js b/src/views/__tests__/SelectProfileView.spec.js index 858b394a2c36720176fd2f1cf9e157e4b6673907..c0b9d55f0f528f4c4f3d4cbe65a125804b500fad 100644 --- a/src/views/__tests__/SelectProfileView.spec.js +++ b/src/views/__tests__/SelectProfileView.spec.js @@ -13,7 +13,6 @@ describe('Select profile', () => { }, }) expect(wrapper.text()).toContain('Hvem bruker appen?') - expect(wrapper.text()).toContain('+') }) it('loads with one profile', () => { diff --git a/src/views/__tests__/ShoppingListView.spec.js b/src/views/__tests__/ShoppingListView.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..97988ad4314e26355b1a35af1ea4c27b25874b62 --- /dev/null +++ b/src/views/__tests__/ShoppingListView.spec.js @@ -0,0 +1,211 @@ +import { describe, it, expect, vi} from 'vitest' +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import ShoppingListView from '../ShoppingListView.vue' + + + + describe('Shopping list', () => { + const wrapper = mount(ShoppingListView, + { computed: { + items() { + return { + id: 1, + ingredientList: [ + { + ingredient_id: 1, + amount: { + quantity: 1, + unit: "stk" + }, + item: { + id: 1, + name: "Vegansk Krone-Is Jordbær", + ean: "7041012550001", + shelfLife: 30, + image_url: "https://bilder.ngdata.no/7041012550001/meny/large.jpg", + amount: { + quantity: 4, + unit: "stk" + }, + store: null, + allergens: [ + { + name: "Nøtter (kan inneholde spor)" + }, + { + name: "Peanøtter (kan inneholde spor)" + }, + { + name: "Soya (kan inneholde spor)" + } + ], + nutrition: [ + { + name: "Kalorier", + amount: { + quantity: 264, + unit: "stk" + } + }, + { + name: "Energi", + amount: { + quantity: 1106, + unit: "stk" + } + }, + { + name: "Fett", + amount: { + quantity: 11.2, + unit: "g" + } + }, + { + name: "Karbohydrater", + amount: { + quantity: 38.7, + unit: "g" + } + }, + { + name: "Mettet fett", + amount: { + quantity: 9.5, + unit: "g" + } + }, + { + name: "Protein", + amount: { + quantity: 1.5, + unit: "g" + } + }, + { + name: "Salt", + amount: { + quantity: 0.11, + unit: "g" + } + }, + { + name: "Sukkerarter", + amount: { + quantity: 23.6, + unit: "g" + } + } + ] + }, + exp_date: null + }, + { + ingredient_id: 2, + amount: { + quantity: 1, + unit: "stk" + }, + item: { + id: 2, + name: "Jarlsberg Gulost", + ean: "7038010053368", + shelfLife: 14, + image_url: "https://bilder.ngdata.no/7038010053368/kmh/large.jpg", + amount: { + quantity: 700, + unit: "g" + }, + store: null, + allergens: [ + { + name: "Melk" + } + ], + nutrition: [ + { + name: "Kalorier", + amount: { + quantity: 351, + unit: "kcal" + } + }, + { + name: "Energi", + amount: { + quantity: 1458, + unit: "kj" + } + }, + { + name: "Fett", + amount: { + quantity: 27, + unit: "g" + } + }, + { + name: "Karbohydrater", + amount: { + quantity: 0, + unit: "g" + } + }, + { + name: "Mettet fett", + amount: { + quantity: 17, + unit: "g" + } + }, + { + name: "Protein", + amount: { + quantity: 27, + unit: "g" + } + }, + { + name: "Salt", + amount: { + quantity: 1.1, + unit: "g" + } + }, + { + name: "Sukkerarter", + amount: { + quantity: 0, + unit: "g" + } + } + ] + }, + exp_date: null + } + ], + suggestionList: [] + } + } + }, + global: { + plugins: [createTestingPinia({ + createSpy: vi.fn, + })], + }, + + }) + + it('renders properly', () => { + + expect(wrapper.text()).toContain('Handleliste') + expect(wrapper.text()).not.toContain('Forslag') + }) + + it('items gets shown', () => { + expect(wrapper.text()).toContain('Vegansk Krone-Is Jordbær') + expect(wrapper.text()).toContain('Jarlsberg Gulost') + }) + +})