diff --git a/cypress/e2e/navbar.cy.js b/cypress/e2e/navbar.cy.js index 2cbd9b1ad9230819d7890632705bb94da06736a4..9e227781986c499d749fd2f66f66d95f56a4ee32 100644 --- a/cypress/e2e/navbar.cy.js +++ b/cypress/e2e/navbar.cy.js @@ -1,6 +1,5 @@ describe('Correct navigation links', () => { - /*TODO*/ }) describe('Navbar on all pages', () => { - /*TODO*/ -}) \ No newline at end of file + +}) diff --git a/package-lock.json b/package-lock.json index 8734150139ece57f5d4421404eef4dbd24eac98a..1a0fb3516a7a9964ae3a16a44de51de879e6b268 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,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" }, @@ -1444,9 +1445,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" @@ -1653,9 +1651,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" } @@ -1691,9 +1686,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" }, @@ -2088,15 +2080,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", @@ -3042,9 +3031,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" }, @@ -3164,7 +3150,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": [ @@ -3308,9 +3293,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" }, @@ -3598,10 +3580,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", @@ -3668,9 +3647,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" }, @@ -3706,9 +3682,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" } @@ -3726,9 +3699,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" }, @@ -3762,9 +3732,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" } @@ -5074,9 +5041,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" } @@ -5329,9 +5293,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" }, @@ -5715,9 +5676,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" }, @@ -5919,9 +5877,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", @@ -6799,9 +6754,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 2c3e2f56a6ab229a407cf87679cc88cbc07e31b7..720c8cb46e52d384a27d5f8ae3248efdc7c1a7c4 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,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/EditAccount.vue b/src/components/EditAccount.vue new file mode 100644 index 0000000000000000000000000000000000000000..564f678621ce2e4e4873b4ea42d473d54184630d --- /dev/null +++ b/src/components/EditAccount.vue @@ -0,0 +1,198 @@ +<template> + <h2>Konto-innstillinger</h2> + <form @submit.prevent="submit"> + <p class="infoText">OBS: Kontakt admin dersom du ønsker å oppdatere epost</p><br> + + <p id="emailField">Epost: {{this.account.email}}</p><br> + + <label for="fname">Endre fornavn</label><br> + <input type="text" id="fname" v-model="updatedAccount.upFirstname"><br> + + <label for="password">Endre passord</label><br> + <input type="password" id="password" v-model="updatedAccount.upPassword"> + + <button class="greenBtn" @click="saveAccountSettings">Lagre profilendringer</button> + <p id="alert">{{alertMsg}}</p> + </form> + + <div id="logout"> + <p class="infoText">Logger deg ut fra din SmartMat-konto</p> + <button class ="redBtn" @click="logOut">Logg ut</button> + </div> + + + <br> + <br> + <hr> + + <form @submit.prevent="submit" id = "dangerZone"> + <h1>🔺FARESONE🔺</h1> + <p class="infoText">Ved å trykke på knappen nedenfor, vil du slette din SmartMat-konto</p> + <input type="checkbox" id="deletionCheckbox" v-model="deletionConfirmation"> + <label for="deletionCheckbox"> Jeg bekrefter jeg skjønner dette, og ønsker å slette kontoen min SmartMat-konto for alltid.</label><br> + <button class="darkRedBtn" id ="delAccount" @click="deleteAccount">SLETT KONTO</button> + <p id="alert">{{delAlertMsg}}</p> + </form> +</template> + +<script> +import {mapState, mapStores} from "pinia"; +import {API} from "@/util/API"; +import { useAuthStore } from "@/stores/authStore"; +import router from "../router"; +export default { + name: "EditAccount", + computed: { + ...mapState(useAuthStore, ['account']), + ...mapStores(useAuthStore), + updatedAccount() { + return { + upFirstname: this.account.firstname, + upPassword:'', + } + }, + }, + data() { + return { + alertMsg:'', //message at the bottom of the form where you change account firstname and password + deletionConfirmation: false, + delAlertMsg:'', //message in the 'dager zone' + } + }, + methods: { + saveAccountSettings(){ //passord + const id = this.account.id; + + let newPassword = null + let newFirstName = null; + + //checks if username and password have been changed + if(this.updatedAccount.upPassword.length!==0) { + newPassword = this.updatedAccount.upPassword; + } + //firstName won't be changed if empty + if(this.updatedAccount.upFirstname!==''){ + newFirstName = this.updatedAccount.upFirstname; + } + + API.updateAccount( + id,{ + firstname:newFirstName, + password:newPassword, + } + ).then((savedAccount)=>{ + useAuthStore().setAccount(savedAccount); + this.alertMsg = "Konto oppdatert." + }).catch(()=> { + this.alertMsg = "‼️Det oppsto en feil.‼️ " + }) + }, + deleteAccount(){ + if(this.deletionConfirmation===false){ + this.delAlertMsg = "‼️Du må bekrefte at du vil slette konto ved å huke av boksen‼️" + } + else { + const id = this.account.id; + API.deleteAccount( + id + ).then(()=>{ + router.push('/login') + }).catch(()=> { + this.delAlertMsg = "‼️Det oppsto en feil ved sletting av bruker‼️" + }) + } + }, + logOut(){ + useAuthStore().logout(); + router.push('/login') + }, + } + } +</script> + +<style scoped lang="scss"> +#dangerZone { + color: darkred; +} + +#logout{ + background-color: base.$grey; + color: black; + padding: 2em; + margin-top: 2em; + margin-bottom: 2em; + display: flex; + flex-direction: column; +} + +input[type="checkbox"] { + width: 2.5em; + height: 2.5em; +} + + + +/*--General--*/ +form { + background-color: base.$grey; + color: black; + align-content: end; + padding: 2em; + margin-top: 2em; + margin-bottom: 2em; +} + +input[type="text"], +input[type="password"]{ + width: 100%; + padding: .5em; +} + +button { + color: black; + border: 1px solid black; + margin: 1em; + padding:.9em; + font-weight: bold; +} +.redBtn { + background-color: base.$red; + border:none; + color:white; +} + +.redBtn:hover { + background-color: base.$red-hover; +} + +.darkRedBtn { + background-color: darkred; + border:none; + color:white; +} +.darkRedBtn:hover { + background-color: base.$darkred-hover; +} + +.greenBtn{ + background-color: base.$green; + border:none; + color:white; +} +.greenBtn:hover{ + background-color: base.$green-hover; +} + +.infoText { + background-color: white; + padding: .5em; + margin: .4em; +} + +#alert { + display: flex; + width:100%; + justify-content: center; + font-weight: bold; + } +</style> \ No newline at end of file diff --git a/src/components/EditProfile.vue b/src/components/EditProfile.vue new file mode 100644 index 0000000000000000000000000000000000000000..8725cfe22726c81985687fb8185ed436f4f12b77 --- /dev/null +++ b/src/components/EditProfile.vue @@ -0,0 +1,262 @@ +<template> + <h2>Profilinnstillinger</h2> + + + <div v-if="hasProfileImage" id = "profilepicture-container"> + <img width="100" :src="this.updatedProfile.upImage" alt="profile picture"> + </div> + <div v-else id="#placeholder"> + <Icon icon="material-symbols:person" :color=iconColor :style="{ fontSize: '500px'}" /> + </div> + + <h3>{{this.profile.name}}</h3> + <button @click="changeProfile" id="changeUserBtn" class="redBtn">Bytt bruker</button> + + <form @submit.prevent="submit"> + <label for="brukernavn">Profilnavn</label><br> + <input type="text" required id="brukernavn" v-model="this.updatedProfile.upName"><br> + <br> + <h3>Brukertype</h3> + <input type="radio" id="normal" value="false" name="restrict" v-model="this.updatedProfile.upRestricted"> + <label for="normal"> Standard</label><br> + + <input type="radio" id="restricted" value="true" name="restrict" v-model="this.updatedProfile.upRestricted"> + <label for="restricted"> Begrenset - Kan ikke redigere ukeplan eller handleliste</label><br><br> + + + <h3>Profilbilde</h3><br> + <div id="changeUserImage"> + <div v-if="hasProfileImage" id = "profilepicture-container"> + <img width="50" :src="this.updatedProfile.upImage" alt="profile picture"> + </div> + <div v-else id = "profilepicture-container"> + <Icon icon="material-symbols:person" :color=iconColor :style="{ fontSize: '30px'}" /> + </div> + + <label for="chooseImageUrl">Bilde-URL:</label><br> + <!--<input type="file" id="chooseImage" v-on:change="updateImage">--> + <input type="text" id="chooseImageUrl" v-model="this.updatedProfile.upImage"> + + </div> + <br><br> + <div id = "submitbuttonBox"> + <button class="greenBtn" @click=" saveUserSettings">Lagre profilendringer</button> + <button class="darkRedBtn" @click="deleteProfile">Slett brukerprofil</button> + </div> + + <p id="alert">{{alertMsg}}</p> + + </form> + +</template> + +<script> +import {mapState, mapStores} from "pinia"; +import {Icon} from "@iconify/vue"; +import {API} from "@/util/API"; +import { useAuthStore } from "@/stores/authStore"; +import router from "../router"; +export default { + name: "EditProfile", + components: {Icon}, + data() { + return { + alertMsg:'', + initialName: '', //used to compare with updated values + initialRestriction: '', + } + }, + computed: { + ...mapState(useAuthStore, ['profile']), + ...mapStores(useAuthStore), + updatedProfile() { + return { + upName: this.profile.name, + upRestricted: this.profile.restricted, + upImage: this.profile.profileImageUrl, + } + }, + iconColor() { + return "#D9D9D9" + }, + hasProfileImage() { + return false; + } + }, + beforeMount() {//used to compare with changed values + this.initialName = this.profile.name; + this.initialRestriction = this.profile.restricted; + }, + methods: { + changeProfile(){ + router.push("/selectProfile"); + }, + async deleteProfile() { + const id = this.profile.id; + API.deleteProfile(id).then(() => { + router.push('/selectProfile') + }).catch((_) => { + this.alertMsg = "‼️Alle kontoer må ha minst en profil (profil ble ikke slettet)‼️" + }) + }, + saveUserSettings(){ + const id = this.profile.id; + + let newName = null; + let newRestricted = null; + + if(this.updatedProfile.upName !== this.initialName){ + newName = this.updatedProfile.upName + } + if(this.updatedProfile.upRestricted !== this.initialRestriction){ + newRestricted = this.updatedProfile.upRestricted + } + + API.updateProfile( + id,{ + name:newName, + profileImageUrl:this.updatedProfile.upImage, + isRestricted: newRestricted, + } + ).then((savedProfile)=>{ + useAuthStore().setProfile(savedProfile); + this.alertMsg = "Profil oppdatert." + }).catch(error=> { + console.log(error) + if (error.message === '400') { + if(newRestricted){ + this.alertMsg = '‼️Det oppsto en feil: Sørg for at det finnes mist en standard profil på kontoen‼️ ' + } else if(this.updatedProfile.name !== this.initialName || this.updatedProfile.name) { + this.alertMsg = '‼️Det oppsto en feil: Det finnes allerede en bruker med samme navn‼️' + } + }else{ + this.alertMsg = "‼️Det oppsto en feil.‼️" + } + }) + }, + + + + + + updateImage(){ + //todo update image preview + }, + chooseProfilePicture(){ + this.alertMsg = "skriv inn bildelenke i feltet, og oppdater innstillinger" + }, + } +} + +</script> + +<style scoped lang="scss"> + +input[type="radio"] { + width: 2em; + height: 2em; + +} + +#changeUserBtn { + border: 1px solid black; +} + +#profilepicture-container { + display:flex; + border-radius:50%; + width:100px; + height: 100px; + background-color: white; + justify-content: center; + align-items: center; + border: 3px solid base.$grey; +} + +#changeUserImage { + display:flex; +} + +img { + border-radius: 50%; +} + +#changeUserImage #profilepicture-container { + width: 50px; + height: 50px; +} + +#submitbuttonBox { + display:flex; + justify-content: space-between; +} + + + + + +/*--General--*/ +form { + background-color: base.$grey; + color: black; + align-content: end; + padding: 2em; + margin-top: 2em; + margin-bottom: 2em; + } + +input[type="text"], +input[type="password"]{ + width: 100%; + padding: .5em; +} + +button { + color: black; + border: 1px solid black; + margin: 1em; + padding:.9em; + font-weight: bold; +} +.redBtn { + background-color: base.$red; + border:none; + color:white; +} + +.redBtn:hover { + background-color: base.$red-hover; +} + +.darkRedBtn { + background-color: darkred; + border:none; + color:white; +} +.darkRedBtn:hover { + background-color: base.$darkred-hover; +} + +.greenBtn{ + background-color: base.$green; + border:none; + color:white; +} +.greenBtn:hover{ + background-color: base.$green-hover; +} + +.infoText { + background-color: white; + padding: .5em; + margin: .4em; +} + +#alert { + display: flex; + width:100%; + justify-content: center; + color: base.$light-green; + font-weight: bold; +} +</style> \ No newline at end of file diff --git a/src/components/FridgeItem.vue b/src/components/FridgeItem.vue index d259d5db29f607aa03b9464483dd6c51132e4164..a23603f03c0e965368cda1ebea78fdf8865bb36f 100644 --- a/src/components/FridgeItem.vue +++ b/src/components/FridgeItem.vue @@ -2,7 +2,9 @@ <div id ="item"> <img :src="getImage" alt=""> <div id="itemInfo"> - <p id="fridgeItemName">{{this.actualItem.item.name }} {{ this.actualItem.amount.quantity }}{{this.actualItem.item.amount.unit}}</p> + <p id="fridgeItemName">{{ this.fridgeItem.item.name }} {{ + this.fridgeItem.amount.quantity + }}{{ this.fridgeItem.item.amount.unit }}</p> <p class="expText" :style="{color:expirationTextColor}">{{expirationText}}</p> </div> <div id = "appleBtn" @click="appleBtnPressed"> @@ -10,7 +12,6 @@ </div> </div> - <hr> </template> <script> @@ -36,7 +37,7 @@ export default { } }, getImage(){ - return this.actualItem.item.image_url; + return this.fridgeItem.item.image_url; }, expirationText() { @@ -66,11 +67,7 @@ export default { } }, props: { - item: { - type:Object, - required: false, - }, - actualItem: { + fridgeItem: { type: Object, required:false, }, @@ -82,7 +79,7 @@ export default { }, methods: { getDateDifference(){ //returns the difference in days between the expiration date and today - let date = this.actualItem.exp_date; + let date = this.fridgeItem.exp_date; const epDate = new Date(date); const parsedDate = Date.parse(epDate) @@ -94,10 +91,10 @@ export default { return numOfDays; }, appleBtnPressed(){ - this.$emit('appleBtnPressed', this.actualItem); + this.$emit('appleBtnPressed', this.fridgeItem); }, formatDate(){ //formats expiration date as dd.mm.yyyy - let fullExpirationDate = new Date(this.actualItem.exp_date); + let fullExpirationDate = new Date(this.fridgeItem.exp_date); let day = fullExpirationDate.getDate(); let month= (fullExpirationDate.getMonth()+1).toString(); let year= fullExpirationDate.getFullYear().toString(); @@ -112,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 4936528217a92b213a2e8391ad76ff6703b9ec0e..b0e13c362fcafcc7002a5b1e5e13fe9ac110ba6a 100644 --- a/src/components/Navbar.vue +++ b/src/components/Navbar.vue @@ -18,13 +18,13 @@ </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> <li> - <RouterLink :to="'/'" :aria-label="'link to settings page'"> - <Icon icon="mdi:cog" :color="iconColor" :style="{ fontSize: iconSize }"/> + <RouterLink :to="'/profileSettings'" :aria-label="'link to settings page'"> + <Icon id="settingsIcon" icon="mdi:cog" :color="iconColor" :style="{ fontSize: iconSize }"/> </RouterLink> </li> </ul> @@ -34,7 +34,6 @@ <script> import { Icon } from '@iconify/vue'; import Logo from "@/components/Logo.vue"; -import { RouterLink } from 'vue-router' export default { name: "Navbar", @@ -46,6 +45,9 @@ export default { iconSize() { return `32px`; }, + logoSize() { + return '52px'; + } } } </script> 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__/EditAccount.spec.js b/src/components/__tests__/EditAccount.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..994d431aa651c86b61e34d0a430cf959a6ea0350 --- /dev/null +++ b/src/components/__tests__/EditAccount.spec.js @@ -0,0 +1,47 @@ +import {describe, it, expect, vi} from 'vitest' +import { mount } from '@vue/test-utils' +import EditAccount from "@/components/EditAccount.vue"; +import {createTestingPinia} from "@pinia/testing"; +import {useAuthStore} from "@/stores/authStore"; +describe('Behaves as expected', () => { + const wrapper = mount(EditAccount, { + global: { + plugins: [createTestingPinia({ + createSpy: vi.fn, + })], + }, + }); + + const store = useAuthStore() + + store.account = { + id: "1", + email:"epost@epost.no", + firstname:"Ola", + password: "Ola123", + fridge: {}, + } + + it('Has email field that contains the account email', async () => { + expect(wrapper.find('#emailField').text()).toContain('epost@epost.no'); + }) + + it('Has firstname field with current firstname', async () => { + expect(wrapper.vm.updatedAccount.upFirstname).to.equal('Ola'); + const fnameInput = wrapper.find('#fname'); + expect(fnameInput.element.value).to.equal('Ola'); + }) + + it('Password field is empty', async () => { + const passwordInput = wrapper.find('#password'); + expect(passwordInput.element.value).to.equal(''); + }) + + it('attempting to delete account without checking box results in error message', async () => { + await wrapper.find('#delAccount').trigger('click'); + const alertMsg = wrapper.vm.delAlertMsg; + expect(alertMsg).to.contain('boks'); + + }) + +}) diff --git a/src/components/__tests__/EditProfile.spec.js b/src/components/__tests__/EditProfile.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f78db12586fa2a935f9067aba276d8dbfa1e8cf3 --- /dev/null +++ b/src/components/__tests__/EditProfile.spec.js @@ -0,0 +1,64 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect, vi } from 'vitest' +import { createTestingPinia } from '@pinia/testing' +import {useAuthStore} from "@/stores/authStore"; +import EditProfile from '@/components/EditProfile.vue' +describe('EditProfile', () => { + const pinia = createTestingPinia({ + initialState: { + profile: { name: 'Ola',restricted:false,profileImageUrl:"some/valid/image.png" }, + }, + createSpy: vi.fn(), + }) + + const wrapper = mount(EditProfile, { + global: { + plugins: [pinia], + }, + }) + + const store = useAuthStore(pinia) + store.profile = { + name:"Ola", + restricted:true, + profileImageUrl:"some/valid/image.png" + } + + it('Profile name is on profile page', () => { + expect(wrapper.vm.updatedProfile.upName).toContain('Ola') + const unameInput = wrapper.find('#brukernavn'); + expect(unameInput.element.value).to.contain('Ola'); + }) + + it('If profile.restricted is true, then radio input with value false is not selected', () => { + const radioInput = wrapper.find('input[type=radio][value="false"]') + expect(radioInput.element.checked).toBe(false) + }) + + it('If profile.restricted is true, then radio input with valuetrue *is* selected', () => { + const radioInput = wrapper.find('input[type=radio][value="true"]') + expect(radioInput.element.checked).toBe(true) + }) + + //update the value from restricted true -> false + it('After changing restricted radio, the values are updated too', async () => { + const notRestrictedRadioInput = wrapper.find('#normal') + const restrictedRadioInput = wrapper.find('#restricted') + + expect(notRestrictedRadioInput.element.checked).toBe(false) + expect(restrictedRadioInput.element.checked).toBe(true) + + await notRestrictedRadioInput.trigger('click') + + expect(notRestrictedRadioInput.element.checked).toBe(true) + expect(restrictedRadioInput.element.checked).toBe(false) + + await wrapper.vm.$nextTick() + + setTimeout(() => { + expect(wrapper.vm.updatedProfile.upRestricted).toBe(false) + }, 1000); + }) + + +}) \ No newline at end of file diff --git a/src/components/__tests__/FridgeItem.spec.js b/src/components/__tests__/FridgeItem.spec.js index 5a7b241b63107426dc1f3aad53e793c412764e74..2760bed18450440eafcbaac7a8dc29823a0235b3 100644 --- a/src/components/__tests__/FridgeItem.spec.js +++ b/src/components/__tests__/FridgeItem.spec.js @@ -28,7 +28,7 @@ describe('Fridge items render correctly', () => { it('displays the name of the item', () => { const wrapper = mount(FridgeItem, { props: { - actualItem: normalItem, + fridgeItem: normalItem, }, }); expect(wrapper.text()).toContain('eple'); @@ -37,7 +37,7 @@ describe('Fridge items render correctly', () => { it('displays the amount of the item in the fridge' , () => { const wrapper = mount(FridgeItem, { props: { - actualItem: normalItem, + fridgeItem: normalItem, }, }); expect(wrapper.text()).toContain('6'); @@ -46,7 +46,7 @@ describe('Fridge items render correctly', () => { it('displays item image', () => { const wrapper = mount(FridgeItem, {props: { - actualItem: normalItem, + fridgeItem: normalItem, }, }); const itemImage = wrapper.find('img'); @@ -58,7 +58,7 @@ describe('Fridge items render correctly', () => { it('displays text of different color when item has expired', () => { const wrapper = mount(FridgeItem, { props: { - actualItem: expiredItem, + fridgeItem: expiredItem, }, }); @@ -71,7 +71,7 @@ describe('Behaves as expected', () => { it('emits when the apple button is pressed', async () => { const wrapper = mount(FridgeItem, { props: { - actualItem: normalItem, + fridgeItem: normalItem, }, }); 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/components/images/w66XcIlw.jpeg b/src/components/images/w66XcIlw.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..f27be0d3372e38997dea9ea9913ad851e4f59048 Binary files /dev/null and b/src/components/images/w66XcIlw.jpeg differ diff --git a/src/router/index.js b/src/router/index.js index 3a40bee87c2f0a16bdc65b2cd7b59ae81b1fe2ad..8d1e93cb3e0897934d204e12c3ee786414fea273 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,13 +1,17 @@ import { createRouter, createWebHistory } from 'vue-router' +import ProfileSettings from "@/views/SettingsView.vue"; +import MissingPage from "@/views/MissingPage.vue"; import HomeView from '../views/HomeView.vue' 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 PlannerView from '../views/PlannerView.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,6 +41,11 @@ const router = createRouter({ name: 'registerAccount', component: RegisterAccountView }, + { + path: '/pincode', + name: 'pincode', + component: PinCodeView + }, { path: '/myFridge', name: 'myFridge', @@ -47,6 +56,26 @@ const router = createRouter({ name: 'planner', component: PlannerView }, + { + path: '/recipe/:id', + name: 'recipe', + component: RecipeView + }, + { + path: '/shoppingList', + name: 'shoppingList', + component: ShoppingListView + }, + { + path: '/profileSettings', + name: 'profileSettings', + component: ProfileSettings + }, + { + path: '/:catchAll(.*)', + name: "404 Page not found" , + component: MissingPage + } ] }) diff --git a/src/stores/authStore.js b/src/stores/authStore.js index 70f611f62d6072269cf375c59fd42826cc0c2ca0..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: { @@ -31,6 +33,23 @@ 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; + this.profile.restricted = isRestricted; } } }); diff --git a/src/stores/useFoodPreferenceStore.js b/src/stores/useFoodPreferenceStore.js new file mode 100644 index 0000000000000000000000000000000000000000..028f2cd981f181f9eeb5b4c6a22403e11e9130e4 --- /dev/null +++ b/src/stores/useFoodPreferenceStore.js @@ -0,0 +1,21 @@ +import {API} from "@/util/API" +import { defineStore} from "pinia"; + +export const useFoodPreferenceStore = defineStore("foodPreference", { + state: () => { + return { + foodPreferences: [], + }; + }, + persist: { + storage: localStorage, + }, + actions: { + fetchAllOptions() { + API.getFoodpreferences() + .then((foodPreferences) => { + this.foodPreferences = foodPreferences; + }) + } + } +}) \ No newline at end of file diff --git a/src/style.scss b/src/style.scss index be108e19e2e37d5a2820357a57bfa434a8d9c681..5ac9953401629264b13437d6b2b770dff15f03a2 100644 --- a/src/style.scss +++ b/src/style.scss @@ -1,14 +1,49 @@ /*APP COLORS*/ $green: #00663C; $light-green: hsla(160, 100%, 37%, 1); -$white: #FFFFFF; -$grey: #D9D9D9; -$red: #EE6D6D; +$white:#FFFFFF; +$grey:#D9D9D9; +$light-grey: #F3F4F9; +$red:#EE6D6D; +$darkred: darkred; -$red-hover: darken($red, 5%); -$green-hover: darken($green, 8%); -$light-green-hover: darken($light-green, 10%); +$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%; +} diff --git a/src/util/API.js b/src/util/API.js index d156d481b691768a0403d48630ed0e1d845b80ba..48507b710f51989fa41da77523087c1abb8abe0f 100644 --- a/src/util/API.js +++ b/src/util/API.js @@ -64,15 +64,23 @@ 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}`, { headers: { Authorization: `Bearer ${authStore.token}` }, }) .then((response) => { - authStore.setProfile(response.data) - router.push("/") + authStore.setProfile(response.data) + if (!response.data.restricted) { + router.push('pincode') + } else { + router.push("/") + } }) .catch(() => { @@ -82,7 +90,7 @@ export const API = { /** * 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 @@ -109,7 +117,7 @@ export const API = { /** * 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 @@ -130,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 }, @@ -148,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}) @@ -171,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"); @@ -183,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: "); @@ -244,19 +257,243 @@ 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)}) + }, + - //returns fridgeItem of specific id - getFridgeItem: async (id) =>{ + /** + * 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)}) + + }, - axios.get(`${import.meta.env.VITE_BACKEND_URL}/fridge/${id}`, { + /** + * 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((error) => { + throw new Error(error); + }) + }, + + + /** + * Deletes account from the + * @param id + * @param token + * @returns {Promise<*>} + */ + deleteAccount: async (id) => { + const authStore = useAuthStore(); + if (!authStore.isLoggedIn) { + throw new Error(); + } + + return axios.delete(`${import.meta.env.VITE_BACKEND_URL}/account/${id}`, { headers: { Authorization: `Bearer ${authStore.token}` }, }) + .then(() => { + authStore.logout() + router.push('/login') + }) + .catch((error) => { + throw new Error(error); + }); + + }, + + /** + * + * @param id account id + * @param request password and firstname + * @returns {Promise<*>} + */ + updateAccount: async (id, request) => { + const authStore = useAuthStore(); + if (!authStore.isLoggedIn) { + throw new Error(); + } + + return axios.put(`${import.meta.env.VITE_BACKEND_URL}/account/${id}`,request, { + headers: { Authorization: `Bearer ${authStore.token}` }, + }) .then((response) => { + authStore.setAccount(response.data) return response.data; + }).catch(() => { + throw new Error("Error when updating account: "); + }); + }, + + /** + * Updates the profile name, restriction and profile image + * If error: "error.message" returns the status code + * @param id profile id + * @param request + * @returns {Promise<*>} + */ + updateProfile: async (id, request) => { + const authStore = useAuthStore(); + if (!authStore.isLoggedIn) { + throw new Error(); + } + + return axios.put(`${import.meta.env.VITE_BACKEND_URL}/profile/${id}`,request, { + headers: { Authorization: `Bearer ${authStore.token}` }, + + }) + .then((response) => { + authStore.setProfile(response.data) + return response.data; + }) + .catch((error) => { + throw new Error(error.response.status); + }); + + }, + + /** + * Deletes a profile from an account + * @param id + * @param request + * @returns {Promise<*>} + */ + deleteProfile: async (id) => { + const authStore = useAuthStore(); + if (!authStore.isLoggedIn) { + throw new Error(); + } + + return axios.delete(`${import.meta.env.VITE_BACKEND_URL}/profile/${id}`, { + headers: { Authorization: `Bearer ${authStore.token}` }, + }) + .then(() => { + router.push('/selectProfile') }) .catch(() => { - throw new Error("Could not fetch fridge item"); + throw new Error("Kan ikke slette profil"); }); + }, + + + + + + + } \ No newline at end of file diff --git a/src/views/FridgeView.vue b/src/views/FridgeView.vue index cd8088e689767f9daa549a4dcaa98a65b30c4b68..6ee345d55cce6d76d913784118d191047ede2ab7 100644 --- a/src/views/FridgeView.vue +++ b/src/views/FridgeView.vue @@ -1,17 +1,17 @@ -<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" > - <FridgeItem v-for="item in fridgeItems" :actualItem = "item" @appleBtnPressed="showModal"></FridgeItem> + <FridgeItem v-for="item in fridgeItems" :fridgeItem = "item" @appleBtnPressed="showModal"></FridgeItem> </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 0f9f671c983e1870a64046c51cca21bd25d04cd7..3cf09945743a39413906a51730ebe43d5644685b 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -1,6 +1,7 @@ <script> import { useAuthStore } from "@/stores/authStore.js"; import { mapState } from 'pinia' + import router from "@/router"; import GroceryOffers from "@/components/GroceryOffers.vue"; export default { @@ -13,8 +14,6 @@ computed: { ...mapState(useAuthStore, ['profile']) } - - } </script> @@ -31,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> @@ -68,8 +70,8 @@ } #tips-img { - width: 40px; - height: 40px; + width: 55px; + height: 55px; margin: auto 0; } @@ -82,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 fe18b47e8ece9dd949df35e5b0517c8c9c22c3a5..2a0b5b48cf99b00c92a1956c4965c477c7c1c63b 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -13,7 +13,6 @@ }, methods: { login() { - //todo: implement when API is up API.login({email: this.email, password: this.password}).then(() => { router.push("/selectProfile"); }) @@ -76,6 +75,12 @@ form { flex-direction: column; } +#error-message { + text-align: center; + width: 80%; + +} + input { height: 40px; font-size: 16px; diff --git a/src/views/MissingPage.vue b/src/views/MissingPage.vue new file mode 100644 index 0000000000000000000000000000000000000000..e110350375e236a41a035386fb3a907cdccc37e9 --- /dev/null +++ b/src/views/MissingPage.vue @@ -0,0 +1,15 @@ +<template> + <h1 id="msg">404</h1> + <h2>Oida, denne siden finnes ikke</h2> + <RouterLink to="/">Gå tilbake til forsiden</RouterLink> +</template> + +<script> +export default { + name: "MissingPage" +} +</script> + +<style scoped> + +</style> \ No newline at end of file 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 3ea4cf01d147be6dbccb64ec9834e4aa2f47af1a..315c9cb5479a8a4d01e7a8820ab6bd6d988fe4fe 100644 --- a/src/views/ProfileCreationView.vue +++ b/src/views/ProfileCreationView.vue @@ -54,7 +54,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/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 8b7130a3a16826fec45442d429cb7458b1a3c7f6..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() { @@ -12,7 +13,7 @@ computed: { ...mapState(useAuthStore, ['profiles']) - }, + }, methods: { // Sends the user into the home page logged in as the profile they clicked on @@ -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/SettingsView.vue b/src/views/SettingsView.vue new file mode 100644 index 0000000000000000000000000000000000000000..8e5e024b1585d6d88f8cd29a97dfb6454e783885 --- /dev/null +++ b/src/views/SettingsView.vue @@ -0,0 +1,138 @@ +<template> + <main> + <EditProfile></EditProfile> + <EditAccount></EditAccount> + <div id = "attribution "> + <h3>Om ikonene brukt i denne appen</h3> + <a href="https://www.flaticon.com/free-icons/leaf" title="leaf icons">Leaf icons created by Freepik - Flaticon</a> + <p>Icons by <a href="https://iconify.design/">Iconify</a></p> + </div> + </main> +</template> +<script setup> + +import EditProfile from "../components/EditProfile.vue"; +import EditAccount from "../components/EditAccount.vue"; +</script> + + +<script> +import {mapStores} from "pinia"; +import {Icon} from "@iconify/vue"; +import {API} from "@/util/API"; +import { useAuthStore } from "@/stores/authStore"; +import router from "../router"; +export default { + name: "ProfileSettings", + components: {Icon}, + computed: { + ...mapStores(useAuthStore), + iconColor() { + return "#000000" + }, + }, + beforeMount() { + if (!useAuthStore().isLoggedIn) { + router.push('/login') + } + }, +} +</script> + +<style scoped lang ="scss"> + + +main { + background-color: white; + color:black; + display:flex; + justify-content: center; + align-items: center; + flex-direction: column; + width: 100%; + text-align: left; + left:0; +} + +#profilepicture-container { + display:flex; + border-radius:50%; + width:100px; + height: 100px; + background-color: white; + justify-content: center; + align-items: center; + border: 3px solid base.$grey; +} + +img { + border-radius: 50%; +} + +#changeUserImage { + display:flex; +} + +#changeUserImage #profilepicture-container { + width: 50px; + height: 50px; +} + +.infoText { + background-color: white; + padding: .5em; + margin: .4em; +} + +form { + background-color: base.$grey; + color: black; + align-content: end; + padding: 2em; + margin-top: 2em; + margin-bottom: 2em; +} + +input[type="text"], +input[type="password"]{ + width: 100%; + padding: .5em; +} +#submitbuttonBox { + display:flex; + justify-content: space-between; +} +button { + background-color: base.$red; + color: black; + + border: 1px solid black; + + margin: 1em; +} + + +#changeUserBtn { + padding:.9em; + +} +button:hover{ + background-color: #282828; + + } + +.saveBtn, .delBtn { + background-color: base.$green; + color: white; + font-weight: bold; + padding:.9em; + border:none; +} +.delBtn { + background-color: darkred; +} + +#dangerZone { + color: darkred; +} +</style> \ No newline at end of file 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') + }) + +})