diff --git a/package-lock.json b/package-lock.json index 4d84981ffc0ea20655b4480a4eaf37d1585b272a..6545ddf362172e977f30659857ffaa00ac1d6809 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,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" }, @@ -1330,9 +1331,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "optional": true, - "peer": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -1539,9 +1537,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -1571,9 +1566,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "optional": true, - "peer": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -1831,15 +1823,12 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, "funding": [ { "type": "individual", "url": "https://paulmillr.com/funding/" } ], - "optional": true, - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -2742,9 +2731,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "optional": true, - "peer": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2851,7 +2837,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -2995,9 +2980,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "optional": true, - "peer": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -3279,10 +3261,7 @@ "node_modules/immutable": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.0.tgz", - "integrity": "sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==", - "dev": true, - "optional": true, - "peer": true + "integrity": "sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==" }, "node_modules/imurmurhash": { "version": "0.1.4", @@ -3349,9 +3328,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "optional": true, - "peer": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -3387,9 +3363,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3407,9 +3380,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "optional": true, - "peer": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -3443,9 +3413,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=0.12.0" } @@ -4644,9 +4611,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4893,9 +4857,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=8.6" }, @@ -5236,9 +5197,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "optional": true, - "peer": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -5440,9 +5398,6 @@ "version": "1.62.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.62.0.tgz", "integrity": "sha512-Q4USplo4pLYgCi+XlipZCWUQz5pkg/ruSSgJ0WRDSb/+3z9tXUOkQ7QPYn4XrhZKYAK4HlpaQecRwKLJX6+DBg==", - "dev": true, - "optional": true, - "peer": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -6237,9 +6192,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "optional": true, - "peer": true, "dependencies": { "is-number": "^7.0.0" }, diff --git a/package.json b/package.json index 07c86518e9c5cca96054dc4a5190ccb9e2a4807d..3763423fcce1bfff961a04258f6ad2607f61d14a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,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 46f03ed381f06adc678daada431ffab509f8d376..a23603f03c0e965368cda1ebea78fdf8865bb36f 100644 --- a/src/components/FridgeItem.vue +++ b/src/components/FridgeItem.vue @@ -12,7 +12,6 @@ </div> </div> - <hr> </template> <script> @@ -110,13 +109,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..10805ce1e1e4a2edb9f9eeda83e3e4b11650ac73 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:'', @@ -55,7 +63,25 @@ 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")) + ).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 +100,14 @@ select { } +#items-input { + margin-bottom: 1em; +} + +#search-button { + margin-left: 0.5em; +} + #searchBoxDiv { display:flex; width:100%; @@ -91,14 +125,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 b3f9d0433378866d59f82cebe2d65d336405d630..10d7e10ab9c318905ccc11056c64963d6d86ca50 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 5ebfef204d1472a8a26f5a4be3f7739a4256cd11..aa8efdeb78b72d4e113c4613dbd546c9c8632f8a 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -6,9 +6,11 @@ import LoginView from '../views/LoginView.vue' import SelectProfileView from '../views/SelectProfileView.vue' import ProfileCreationView from '../views/ProfileCreationView.vue' import RegisterAccountView from '../views/RegisterAccountView.vue' - +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), @@ -37,11 +39,27 @@ const router = createRouter({ path: '/registerAccount', name: 'registerAccount', component: RegisterAccountView - },{ + }, + { + path: '/pincode', + name: 'pincode', + component: PinCodeView + }, + { path: '/myFridge', name: 'myFridge', component: FridgeView }, + { + path: '/recipe/:id', + name: 'recipe', + component: RecipeView + }, + { + path: '/shoppingList', + name: 'shoppingList', + component: ShoppingListView + }, { path: '/profileSettings', name: 'profileSettings', diff --git a/src/stores/authStore.js b/src/stores/authStore.js index 1c7e561ca6628fa4b8d4275d22186cf98201afd0..db029beb454bad9b49dcfc0c1ac658915888f9fa 100644 --- a/src/stores/authStore.js +++ b/src/stores/authStore.js @@ -5,7 +5,9 @@ export const useAuthStore = defineStore("auth", { token: "", account: {}, profile: {}, - profiles: [] + profiles: [], + items: {ingredientList: [], suggestionList: []}, + fridgeItems: [], }; }, persist: { @@ -32,6 +34,18 @@ export const useAuthStore = defineStore("auth", { setProfiles(profiles) { this.profiles = profiles; }, + setItems(items) { + this.items = items; + }, + setItem(item) { + this.items.push(item); + }, + setFridge(fridgeItems){ + this.fridgeItems = fridgeItems; + }, + addItemToFridge(fridgeItem){ + this.fridgeItems.push(fridgeItem); + }, updateProfile(name, image, isRestricted){ this.profile.name = name; this.profile.profileImageUrl = image; diff --git a/src/style.scss b/src/style.scss index 3142bce63de8907d0511e5f6fb2b243594c15834..9cc6bdcfdd7f3e718539c048fd98eaf01b6bffb0 100644 --- a/src/style.scss +++ b/src/style.scss @@ -3,6 +3,7 @@ $green: #00663C; $light-green: hsla(160, 100%, 37%, 1); $white:#FFFFFF; $grey:#D9D9D9; +$light-grey: #F3F4F9; $red:#EE6D6D; $darkred: darkred; @@ -10,8 +11,39 @@ $red-hover: darken( $red, 5% ); $green-hover: darken( $green, 8% ); $light-green-hover: darken( $light-green, 10% ); $darkred-hover: darken(darkred, 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 c4653e1603597569628fd5224faa1b3db643fd09..48507b710f51989fa41da77523087c1abb8abe0f 100644 --- a/src/util/API.js +++ b/src/util/API.js @@ -5,129 +5,165 @@ import router from "@/router/index"; export const API = { - /** - * API method to send a login request. - * If login succeeds, the logged in User and their token - * is saved to the Pinia AuthStore - * - * @param email email address of the user to log in as - * @param password password to log in with - * @returns a Result with whether the login attempt succeeded - */ - login: async (request) => { - const authStore = useAuthStore(); - let token; - authStore.logout(); //in case someone for some reason is logged in - - return axios.post( - `${import.meta.env.VITE_BACKEND_URL}/login`, - request, - ) - .then(async (response) => { - token = response.data; - const id = (jwt_decode(token)).id; - - return API.getAccount(id, token) - .then((user) => { - authStore.setAccount(user); - authStore.setToken(token); - API.getProfiles() - .then(response => {authStore.setProfiles(response)}) - .catch(() => {throw new Error()}) - return; - }) - .catch(() => { - throw new Error(); - }); - }) - .catch(() => { - throw new Error(); - }); - }, - - - /** - * API method to get a account by their ID - * @param id ID number of the account to retrieve - * @returns A promise that resolves to a User if the API call succeeds, - * or is rejected if the API call fails - */ - getAccount: async (id, token) => { - return axios.get(`${import.meta.env.VITE_BACKEND_URL}/account/${id}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - .then((response) => { - return response.data; + /** + * API method to send a login request. + * If login succeeds, the logged in User and their token + * is saved to the Pinia AuthStore + * + * @param email email address of the user to log in as + * @param password password to log in with + * @returns a Result with whether the login attempt succeeded + */ + login: async (request) => { + const authStore = useAuthStore(); + let token; + authStore.logout(); //in case someone for some reason is logged in + + return axios.post( + `${import.meta.env.VITE_BACKEND_URL}/login`, + request, + ) + .then(async (response) => { + token = response.data; + const id = (jwt_decode(token)).id; + + return API.getAccount(id, token) + .then((user) => { + authStore.setAccount(user); + authStore.setToken(token); + API.getProfiles() + .then(response => { authStore.setProfiles(response) }) + .catch(() => { throw new Error() }) + return; }) .catch(() => { - throw new Error("Account not found or not accessible"); + throw new Error(); }); - }, - - // Sends the user into the home page logged in as the profile they clicked on - selectProfile: async (id) => { - const authStore = useAuthStore() - return axios.get(`${import.meta.env.VITE_BACKEND_URL}/profile/${id}`, { - headers: { Authorization: `Bearer ${authStore.token}` }, - }) - .then((response) => { - authStore.setProfile(response.data) - router.push("/") - - }) - .catch(() => { - throw new Error("Profile not found or not accessible") - }) - + }) + .catch(() => { + throw new Error(); + }); + }, + + + /** + * API method to get a account by their ID + * @param id ID number of the account to retrieve + * @returns A promise that resolves to a User if the API call succeeds, + * or is rejected if the API call fails + */ + getAccount: async (id, token) => { + return axios.get(`${import.meta.env.VITE_BACKEND_URL}/account/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((response) => { + return response.data; + }) + .catch(() => { + throw new Error("Account not found or not accessible"); + }); + }, - }, - /** - * Sends a request to create a new profile on the currently logged in account - * - * @typedef {{name: string, profileImageUrl: string, isRestricted: boolean}} ProfileType - * @param {ProfileType} profile - * @returns + /** + * 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 */ - addProfile: async (profile) => { - const authStore = useAuthStore(); - if (!authStore.isLoggedIn) { - throw new Error(); - } + selectProfile: async (id) => { + const authStore = useAuthStore() + return axios.get(`${import.meta.env.VITE_BACKEND_URL}/profile/${id}`, { + headers: { Authorization: `Bearer ${authStore.token}` }, + }) + .then((response) => { + authStore.setProfile(response.data) + if (!response.data.restricted) { + router.push('pincode') + } else { + router.push("/") + } - return axios.post(import.meta.env.VITE_BACKEND_URL + '/profile', { - headers: { Authorization: "Bearer " + authStore.token }, - body: profile }) + .catch(() => { + throw new Error("Profile not found or not accessible") + }) + }, + + /** + * Upload profile image + * + * @param {Blob} image - the image file contents to upload. Must be a JPEG no bigger than 512kB + * @param {Number} profileId - the ID of the profile to upload this image to + * @returns {Promise<String>} A Promise that resolves to the URL of the uploaded image + */ + uploadProfileImage: async (image, profileId) => { + const authStore = useAuthStore(); + + let fd = new FormData(); + fd.append("file", image); + fd.append("profileId", profileId); + + return axios.post(`${import.meta.env.VITE_BACKEND_URL}/img`, fd, { + headers: { + Authorization: `Bearer ${authStore.token}`, + } + }) + .then((response) => { + return response.data; + }) + .catch(() => { + throw new Error(); + }) + }, + + /** + * Sends a request to create a new profile on the currently logged in account + * + * @typedef {{name: string, id?: number, accountId?: number, profileImageUrl: string, isRestricted: boolean}} ProfileType + * @param {ProfileType} profile - the partial data of profile to create + * @returns {Promise<ProfileType>} the full profile after saving, with id and account ID set + */ + addProfile: async (profile) => { + const authStore = useAuthStore(); + if (!authStore.isLoggedIn) { + throw new Error(); + } + + return axios.post(import.meta.env.VITE_BACKEND_URL + '/profile', profile, { + headers: { Authorization: "Bearer " + authStore.token }, + }) .then((response) => { return response.data; }).catch(() => { throw new Error(); }) - }, + }, - // Returns all profiles to the logged in user + /** + * @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 }, - }, - ) - .then(response => { - return response.data - }).catch(() => { - throw new Error(); - }); + return axios.get(import.meta.env.VITE_BACKEND_URL + '/profile', { + headers: { Authorization: "Bearer " + authStore.token }, }, + ) + .then(response => { + return response.data + }).catch(() => { + throw new Error(); + }); + }, - // Registers a new account and logs into it + /** + * 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) => { - const authStore = useAuthStore(); - axios.post(import.meta.env.VITE_BACKEND_URL + '/account', request) .then(() => { API.login({email: request.email, password: request.password}) @@ -147,6 +183,7 @@ export const API = { headers: { Authorization: `Bearer ${authStore.token}` }, }) .then((response) => { + authStore.setFridge(response.data) return response.data; }).catch(() => { throw new Error("Could not fetch fridge items"); @@ -159,11 +196,11 @@ export const API = { * @returns {Promise<void>} */ addToFridge: async(request) =>{ - const authStore = useAuthStore(); axios.post(`${import.meta.env.VITE_BACKEND_URL}/fridge/items`, request,{ headers: { Authorization: `Bearer ${authStore.token}` }, }).then((response) => { + authStore.setFridge(response.data) return response.data; }).catch(()=> { throw new Error("Could not add item to fridge: "); @@ -220,20 +257,137 @@ 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()}) + }, + - //returns fridgeItem of specific id - getFridgeItem: async (id) =>{ - const authStore = useAuthStore(); + /** + * 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)}) + }, - axios.get(`${import.meta.env.VITE_BACKEND_URL}/fridge/${id}`, { - headers: { Authorization: `Bearer ${authStore.token}` }, + /** + * 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)}) + }, + + + // 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 + * @returns {Promise<*>} + */ + getRecipe: async (id) => { + return axios.get(`${import.meta.env.VITE_BACKEND_URL}/recipe/${id}`) .then((response) => { + console.log(response.data); return response.data; }) - .catch(() => { - throw new Error("Could not fetch fridge item"); - }); + .catch((error) => { + throw new Error(error); + }) }, diff --git a/src/views/FridgeView.vue b/src/views/FridgeView.vue index a9af9a5f70f906ee75717baaedbf3527b94c56ff..6ee345d55cce6d76d913784118d191047ede2ab7 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> + <ItemSearch :adds-to-fridge="true" 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> @@ -31,14 +31,13 @@ export default { name: "FridgeView", components: {ItemSearch, EatFridgeItemModal, FridgeItem}, computed:{ - ...mapState(useAuthStore, ['account']), - ...mapStores(useFridgeStore), + ...mapState(useAuthStore, ['account', 'fridgeItems']), }, data() { return { visible: false, //is the useitemModal visible selectedItem: null, - fridgeItems: [], + //fridgeItems: [], searchVisible: false, fridgeMsg: "" } @@ -62,17 +61,15 @@ export default { this.fridgeMsg=msg; }, async updateFridge(addedItem) { - this.fridgeItems = await API.getFridgeItems(); - this.fridgeItems = await API.getFridgeItems(); this.hideItemSearch(); this.updateFridgeMessage(addedItem.name + " ble lagt i kjøleskapet.") }, }, async mounted() { - this.fridgeItems = await API.getFridgeItems(); + 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 +79,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 +118,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 +135,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 1bd7463494c91b036111d7a97d4983d6e3395c12..3cf09945743a39413906a51730ebe43d5644685b 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -30,12 +30,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> @@ -67,8 +70,8 @@ } #tips-img { - width: 40px; - height: 40px; + width: 55px; + height: 55px; margin: auto 0; } @@ -81,6 +84,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 77618971030bf7cf0ff025bf08d5e7e49e20fcef..7572c9a1d41d64d7e86a5b262ee8c4a3f3f2fc27 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -80,6 +80,12 @@ form { flex-direction: column; } +#error-message { + text-align: center; + width: 80%; + +} + input { height: 40px; font-size: 16px; diff --git a/src/views/PinCodeView.vue b/src/views/PinCodeView.vue new file mode 100644 index 0000000000000000000000000000000000000000..3fc4116508005935b5be9317ead74fd0d38d32bd --- /dev/null +++ b/src/views/PinCodeView.vue @@ -0,0 +1,54 @@ +<script> +import router from '@/router/index.js'; + +export default { + methods: { + sendToHomePage() { + router.push("/"); + } + } +} +</script> + +<template> + <main> + <h1>PIN</h1> + <div class="pincode-container"> + <input id="pincode-field" type="tel" maxlength="4" placeholder="0000"/> + <button id="pincode-button" type="button" @click="sendToHomePage">OK</button> + </div> + </main> +</template> + +<style scoped lang="scss"> +h1 { + font-size: 50px; +} +main { + padding: 50px 10px; + text-align: center; +} + +.pincode-container { + width: 100%; + display: flex; + justify-content: center; + padding: 10px; + input { + border-radius: 0; + border: 1px solid black; + height: 50px; + width: 100px; + margin: 0 5px; + font-size: 38px; + padding: 5px; + } +} + + +button { + border-radius: 0; + border: 1px solid black; + width: 50px; +} +</style> \ No newline at end of file diff --git a/src/views/ProfileCreationView.vue b/src/views/ProfileCreationView.vue index 22cb341b164554dd4416834a341b895504de2bde..560f3c5d29264e61c614ce618b6cf56f8e162220 100644 --- a/src/views/ProfileCreationView.vue +++ b/src/views/ProfileCreationView.vue @@ -1,6 +1,8 @@ <script> -import Toggle from '../components/Toggle.vue'; +import { mapStores } from 'pinia'; +import router from '../router' import { API } from '../util/API'; +import { useAuthStore } from '../stores/authStore' export default { data: () => { @@ -14,20 +16,33 @@ export default { }; }, methods: { - submit() { - this.profile.isRestricted = this.$refs.toggle.state; - API.addProfile(this.profile); + async submit() { + await API.addProfile(this.profile) + .then((profile) => { + let id = profile.id; + + let image = document.getElementById("profile_img").files[0]; + + API.uploadProfileImage(image, id) + .then((updatedProfile) => { + this.authStore.profile = updatedProfile; + router.push("/"); + }); + + }) }, updateImg() { - let file = document.getElementById("avatar").files[0]; + let file = document.getElementById("profile_img").files[0]; let reader = new FileReader(); reader.onload = function (ev) { - document.getElementById("avatar_preview").src = ev.target.result; + document.getElementById("profile_img_preview").src = ev.target.result; }; reader.readAsDataURL(file); } }, - components: { Toggle } + computed: { + ...mapStores(useAuthStore) + } } </script> @@ -35,21 +50,22 @@ export default { <main> <h1>Ny profil</h1> - <form action="todo" method="post"> - <label for="avatar" id="avatar_label"> + <form @submit.prevent="submit"> + <label for="profile_img" id="profile_img_label"> <div class="img_hover"> - <img :src="image" alt="fjes" id="avatar_preview"> + <img :src="image" alt="fjes" id="profile_img_preview"> <p class="hover_text"> Klikk for å endre </p> </div> </label> - <input type="file" name="avatar" id="avatar" @change="updateImg"> + <input @change="updateImg" type="file" + accept=".jpeg, .jpg" id="profile_img" name="profile_img"> <label for="name">Navn</label> <input name="name" type="text" v-model="profile.name"> <div class="check_container"> - <label for="child">Begrenset:</label> - <Toggle ref="toggle" /> + <label for="limited">Begrenset:</label> + <input type="checkbox" name="limited" id="limited"> </div> </form> <button @click="submit">Opprett</button> @@ -84,7 +100,7 @@ main { } } -#avatar_label { +#profile_img_label { display: flex; justify-content: center; width: 100%; diff --git a/src/views/RecipeView.vue b/src/views/RecipeView.vue new file mode 100644 index 0000000000000000000000000000000000000000..88fd6572f4de41dae6c0a44719f0a0255f2b3dc1 --- /dev/null +++ b/src/views/RecipeView.vue @@ -0,0 +1,86 @@ +<script> + +import {API} from "@/util/API"; + +export default { + name: "RecipeView", + data() { + return { + recipe: {}, + id: this.$route.params.id, + title: "", + description: "", + time: "", + ingredients: [], + instructions: "", + } + }, + methods: { + async loadData() { + await API.getRecipe(this.id) + .then((recipe) => { + this.title = recipe.title; + this.description = recipe.description; + this.time = recipe.time; + this.ingredients = recipe.ingredient; + this.instructions = recipe.instructions; + }) + }, + addIngredientsShoppingList() { + //TODO add ingredients to shopping list + }, + removeIngredientsFromFridge() { + //TODO remove used ingredients from fridge + } + }, + async mounted() { + await this.loadData(); + } +} + +</script> + +<template> + <main> + <h1>{{this.title}}</h1><br> + <p>{{this.description}}</p><br> + <div class="ingredients"> + <h2>Ingredienser</h2> + <ul> + <li v-for="ingredient in this.ingredients">{{ ingredient.item.name }} {{ ingredient.amount.quantity }} {{ingredient.amount.unit}}</li> + </ul> + <button @click="addIngredientsShoppingList">Legg til ingrediensene i handlekurven</button> + </div> + <div class="instructions"> + <h2>Instruksjoner</h2> + {{this.instructions}} + </div> + <button @click="removeIngredientsFromFridge">Fjern varene fra kjøleskapet</button> + + </main> + +</template> + +<style scoped lang="scss"> +@media(min-width: base.$desktop-min) { + main { + padding-top: 45px !important; + } +} + +main { + padding: 20px 10px; + .ingredients { + margin-bottom: 20px; + } + button { + min-height: 40px; + border-radius: 0; + border: 1px solid; + cursor: pointer; + padding: 5px; + } +} + + +</style> \ No newline at end of file diff --git a/src/views/SelectProfileView.vue b/src/views/SelectProfileView.vue index 01ed39912f2343e22898718b05ac76d21a551b00..8dd7991f2720672dfb0ca46f75a8cb1bdd0fab1b 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') + }) + +})