Pulled feature branch up-stream.
This commit is contained in:
		| @@ -9,10 +9,17 @@ platform: | ||||
|  | ||||
| steps: | ||||
| - name: frontend_install | ||||
|   image: node:13.6.0 | ||||
|   image: node:14 | ||||
|   commands: | ||||
|     - node -v | ||||
|     - yarn --version | ||||
| - name: backend_build | ||||
|   image: node:14 | ||||
|   commands: | ||||
|     - node -v | ||||
|     - yarn --version | ||||
|     - yarn | ||||
|     - yarn build | ||||
| - name: deploy | ||||
|   image: appleboy/drone-ssh | ||||
|   pull: true | ||||
|   | ||||
							
								
								
									
										69
									
								
								api/request.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								api/request.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| const express = require("express"); | ||||
| const path = require("path"); | ||||
| const RequestedWine = require(path.join( | ||||
|   __dirname + "/../schemas/RequestedWine" | ||||
| )); | ||||
| const Wine = require(path.join( | ||||
|   __dirname + "/../schemas/Wine" | ||||
| )); | ||||
|  | ||||
| const deleteRequestedWineById = async (req, res) => { | ||||
|   const { id } = req.params; | ||||
|   if(id == null){ | ||||
|     return res.json({ | ||||
|       message: "Id er ikke definert", | ||||
|       success: false | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   await RequestedWine.deleteOne({wineId: id}) | ||||
|   return res.json({ | ||||
|     message: `Slettet vin med id: ${id}`, | ||||
|     success: true | ||||
|   });  | ||||
| } | ||||
|  | ||||
| const getAllRequestedWines = async (req, res) => { | ||||
|   const allWines = await RequestedWine.find({}).populate("wine"); | ||||
|    | ||||
|   return res.json(allWines); | ||||
| } | ||||
|  | ||||
| const requestNewWine = async (req, res) => { | ||||
|   const {wine} = req.body | ||||
|    | ||||
|   let thisWineIsLOKO = await Wine.findOne({id: wine.id}) | ||||
|    | ||||
|   if(thisWineIsLOKO == undefined){ | ||||
|     thisWineIsLOKO = new Wine({ | ||||
|       name: wine.name, | ||||
|       vivinoLink: wine.vivinoLink, | ||||
|       rating: null, | ||||
|       occurences: null, | ||||
|       image: wine.image, | ||||
|       id: wine.id | ||||
|     }); | ||||
|     await thisWineIsLOKO.save() | ||||
|   } | ||||
|    | ||||
|   let requestedWine = await RequestedWine.findOne({ "wineId": wine.id}) | ||||
|    | ||||
|   if(requestedWine == undefined){ | ||||
|     requestedWine = new RequestedWine({ | ||||
|       count: 1, | ||||
|       wineId: wine.id, | ||||
|       wine: thisWineIsLOKO | ||||
|     }) | ||||
|   } else { | ||||
|     requestedWine.count += 1; | ||||
|   } | ||||
|   await requestedWine.save() | ||||
|    | ||||
|   return res.send(requestedWine); | ||||
| } | ||||
|  | ||||
| module.exports = { | ||||
|   requestNewWine, | ||||
|   getAllRequestedWines, | ||||
|   deleteRequestedWineById | ||||
| }; | ||||
| @@ -1,6 +1,4 @@ | ||||
| const express = require("express"); | ||||
| const path = require("path"); | ||||
| const router = express.Router(); | ||||
| const mongoose = require("mongoose"); | ||||
| mongoose.connect("mongodb://localhost:27017/vinlottis", { | ||||
|   useNewUrlParser: true | ||||
|   | ||||
| @@ -3,9 +3,11 @@ const path = require("path"); | ||||
|  | ||||
| // Middleware | ||||
| const mustBeAuthenticated = require(__dirname + "/../middleware/mustBeAuthenticated"); | ||||
| const setAdminHeaderIfAuthenticated = require(__dirname + "/../middleware/setAdminHeaderIfAuthenticated"); | ||||
|  | ||||
| const update = require(path.join(__dirname + "/update")); | ||||
| const retrieve = require(path.join(__dirname + "/retrieve")); | ||||
| const request = require(path.join(__dirname + "/request")); | ||||
| const subscriptionApi = require(path.join(__dirname + "/subscriptions")); | ||||
| const loginApi = require(path.join(__dirname + "/login")); | ||||
| const wineinfo = require(path.join(__dirname + "/wineinfo")); | ||||
| @@ -18,6 +20,12 @@ const lottery = require(path.join(__dirname + "/lottery")); | ||||
|  | ||||
| const router = express.Router(); | ||||
|  | ||||
| router.get("/wineinfo/search", wineinfo.wineSearch); | ||||
|  | ||||
| router.get("/request/all", setAdminHeaderIfAuthenticated, request.getAllRequestedWines); | ||||
| router.post("/request/new-wine", request.requestNewWine); | ||||
| router.delete("/request/:id", request.deleteRequestedWineById); | ||||
|  | ||||
| router.get("/wineinfo/schema", mustBeAuthenticated, update.schema); | ||||
| router.get("/wineinfo/:ean", wineinfo.byEAN); | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,51 @@ | ||||
| const fetch = require('node-fetch') | ||||
| const path = require('path') | ||||
| const config = require(path.join(__dirname + "/../config/env/lottery.config")); | ||||
|  | ||||
| const convertToOurWineObject = wine => { | ||||
|   if(wine.basic.ageLimit === "18"){ | ||||
|     return { | ||||
|       name: wine.basic.productShortName, | ||||
|       vivinoLink: "https://www.vinmonopolet.no/p/" + wine.basic.productId, | ||||
|       rating: wine.basic.alcoholContent, | ||||
|       occurences: 0, | ||||
|       id: wine.basic.productId, | ||||
|       image: `https://bilder.vinmonopolet.no/cache/500x500-0/${wine.basic.productId}-1.jpg`, | ||||
|       price: wine.prices[0].salesPrice.toString(), | ||||
|       country: wine.origins.origin.country | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| const wineSearch = async (req, res) => { | ||||
|   const {query} = req.query | ||||
|   let url = new URL(`https://apis.vinmonopolet.no/products/v0/details-normal?productShortNameContains=test&maxResults=15`) | ||||
|   url.searchParams.set('productShortNameContains', query) | ||||
|    | ||||
|   const vinmonopoletResponse = await fetch(url, { | ||||
|     headers: { | ||||
|       "Ocp-Apim-Subscription-Key": config.vinmonopoletToken | ||||
|     } | ||||
|   }) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(err => console.error(err)) | ||||
|    | ||||
|    | ||||
|   if (vinmonopoletResponse.errors != null) { | ||||
|     return vinmonopoletResponse.errors.map(error => { | ||||
|       if (error.type == "UnknownProductError") { | ||||
|         return res.status(404).json({ | ||||
|           message: error.message | ||||
|         }) | ||||
|       } else { | ||||
|         return next() | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
|   const winesConverted = vinmonopoletResponse.map(convertToOurWineObject).filter(Boolean) | ||||
|  | ||||
|   return res.send(winesConverted); | ||||
| } | ||||
|  | ||||
| const byEAN = async (req, res) => { | ||||
|   const vinmonopoletResponse = await fetch("https://app.vinmonopolet.no/vmpws/v2/vmp/products/barCodeSearch/" + req.params.ean) | ||||
| @@ -21,5 +67,6 @@ const byEAN = async (req, res) => { | ||||
| }; | ||||
|  | ||||
| module.exports = { | ||||
|   byEAN | ||||
|   byEAN, | ||||
|   wineSearch | ||||
| }; | ||||
|   | ||||
							
								
								
									
										3
									
								
								config/env/lottery.config.example.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								config/env/lottery.config.example.js
									
									
									
									
										vendored
									
									
								
							| @@ -6,5 +6,6 @@ module.exports = { | ||||
|   date: 5, | ||||
|   hours: 15, | ||||
|   apiUrl: undefined, | ||||
|   gatewayToken: undefined | ||||
|   gatewayToken: undefined, | ||||
|   vinmonopoletToken: undefined | ||||
| }; | ||||
							
								
								
									
										6
									
								
								middleware/setAdminHeaderIfAuthenticated.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								middleware/setAdminHeaderIfAuthenticated.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| const setAdminHeaderIfAuthenticated = (req, res, next) => { | ||||
|   res.set("Vinlottis-Admin", req.isAuthenticated()); | ||||
|   return next(); | ||||
| }; | ||||
|  | ||||
| module.exports = setAdminHeaderIfAuthenticated; | ||||
| @@ -6,7 +6,7 @@ | ||||
|   "scripts": { | ||||
|     "test": "echo \"Error: no test specified\" && exit 1", | ||||
|     "start": "node server.js", | ||||
|     "dev": "cross-env NODE_ENV=development webpack-dev-server --progress", | ||||
|     "dev": "cross-env NODE_ENV=development webpack-dev-server", | ||||
|     "build": "cross-env NODE_ENV=production webpack --progress --hide-modules" | ||||
|   }, | ||||
|   "author": "", | ||||
|   | ||||
							
								
								
									
										13
									
								
								schemas/RequestedWine.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								schemas/RequestedWine.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| const mongoose = require("mongoose"); | ||||
| const Schema = mongoose.Schema; | ||||
|  | ||||
| const RequestedWine = new Schema({ | ||||
|   count: Number, | ||||
|   wineId: String, | ||||
|   wine: { | ||||
|     type: Schema.Types.ObjectId, | ||||
|     ref: "Wine" | ||||
|   } | ||||
| }); | ||||
|  | ||||
| module.exports = mongoose.model("RequestedWine", RequestedWine); | ||||
| @@ -9,7 +9,6 @@ const User = require(path.join(__dirname + "/schemas/User")); | ||||
| const apiRouter = require(path.join(__dirname + "/api/router.js")); | ||||
|  | ||||
| const loginApi = require(path.join(__dirname + "/api/login")); | ||||
| const virtualApi = require(path.join(__dirname + "/api/virtualLottery")); | ||||
| const subscriptionApi = require(path.join(__dirname + "/api/subscriptions")); | ||||
|  | ||||
| //This is required for the chat to work | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <template> | ||||
|   <div class="app-container"> | ||||
|     <banner /> | ||||
|     <banner :routes="routes"/> | ||||
|     <router-view /> | ||||
|     <UpdateToast | ||||
|       v-if="showToast" | ||||
| @@ -24,7 +24,29 @@ export default { | ||||
|     return { | ||||
|       showToast: false, | ||||
|       toastText: null, | ||||
|       refreshToast: false | ||||
|       refreshToast: false, | ||||
|       routes: [ | ||||
|         { | ||||
|           name: "Dagens viner", | ||||
|           route: "/dagens/" | ||||
|         }, | ||||
|         { | ||||
|           name: "Historie", | ||||
|           route: "/history/" | ||||
|         }, | ||||
|         { | ||||
|           name: "Lotteriet", | ||||
|           route: "/lottery/game/" | ||||
|         }, | ||||
|         { | ||||
|           name: "Foreslå vin", | ||||
|           route: "/request" | ||||
|         }, | ||||
|         { | ||||
|           name: "Foreslåtte viner", | ||||
|           route: "/requested-wines" | ||||
|         }, | ||||
|       ] | ||||
|     }; | ||||
|   }, | ||||
|   mounted() { | ||||
| @@ -45,7 +67,7 @@ export default { | ||||
|   methods: { | ||||
|     closeToast: function() { | ||||
|       this.showToast = false; | ||||
|     } | ||||
|     }, | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|   | ||||
							
								
								
									
										63
									
								
								src/api.js
									
									
									
									
									
								
							
							
						
						
									
										63
									
								
								src/api.js
									
									
									
									
									
								
							| @@ -1,3 +1,5 @@ | ||||
| import fetch from "node-fetch"; | ||||
|  | ||||
| const BASE_URL = __APIURL__ || window.location.origin; | ||||
|  | ||||
| const statistics = () => { | ||||
| @@ -24,6 +26,16 @@ const overallWineStatistics = () => { | ||||
|   return fetch(url.href).then(resp => resp.json()); | ||||
| }; | ||||
|  | ||||
| const allRequestedWines = () => { | ||||
|   const url = new URL("/api/request/all", BASE_URL); | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => { | ||||
|       const isAdmin = resp.headers.get("vinlottis-admin") || false; | ||||
|       return Promise.all([resp.json(), Boolean(isAdmin)]); | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| const chartWinsByColor = () => { | ||||
|   const url = new URL("/api/purchase/statistics/color", BASE_URL); | ||||
|  | ||||
| @@ -108,6 +120,21 @@ const winners = () => { | ||||
|   return fetch(url.href).then(resp => resp.json()); | ||||
| }; | ||||
|  | ||||
| const deleteRequestedWine = wineToBeDeleted => { | ||||
|  | ||||
|   const url = new URL("api/request/"+ wineToBeDeleted._id, BASE_URL); | ||||
|  | ||||
|   const options = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json" | ||||
|     }, | ||||
|     method: "DELETE", | ||||
|     body: JSON.stringify(wineToBeDeleted) | ||||
|   }; | ||||
|  | ||||
|   return fetch(url.href, options).then(resp => resp.json()) | ||||
| } | ||||
|  | ||||
| const deleteWinners = () => { | ||||
|   const url = new URL("/api/virtual/winner/all", BASE_URL); | ||||
|  | ||||
| @@ -140,6 +167,23 @@ const attendees = () => { | ||||
|   return fetch(url.href).then(resp => resp.json()); | ||||
| }; | ||||
|  | ||||
| const requestNewWine = (wine) => { | ||||
|   const options = { | ||||
|     body: JSON.stringify({ | ||||
|       wine: wine | ||||
|     }), | ||||
|      headers: { | ||||
|       'Accept': 'application/json', | ||||
|       'Content-Type': 'application/json' | ||||
|     }, | ||||
|     method: "post" | ||||
|   } | ||||
|  | ||||
|   const url = new URL("/api/request/new-wine", BASE_URL) | ||||
|  | ||||
|   return fetch(url.href, options).then(resp => resp.json()) | ||||
| } | ||||
|  | ||||
| const logWines = wines => { | ||||
|   const url = new URL("/api/log/wines", BASE_URL); | ||||
|  | ||||
| @@ -174,6 +218,21 @@ const barcodeToVinmonopolet = id => { | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const searchForWine = searchString => { | ||||
|   const url = new URL("/api/wineinfo/search?query=" + searchString, BASE_URL); | ||||
|  | ||||
|   return fetch(url.href).then(async resp => { | ||||
|     if (!resp.ok) { | ||||
|       if (resp.status == 404) { | ||||
|         throw await resp.json(); | ||||
|       } | ||||
|     } else { | ||||
|       return resp.json(); | ||||
|     } | ||||
|   }); | ||||
| }; | ||||
|  | ||||
|  | ||||
| const handleErrors = async resp => { | ||||
|   if ([400, 409].includes(resp.status)) { | ||||
|     throw await resp.json(); | ||||
| @@ -287,6 +346,9 @@ export { | ||||
|   logWines, | ||||
|   wineSchema, | ||||
|   barcodeToVinmonopolet, | ||||
|   searchForWine, | ||||
|   requestNewWine, | ||||
|   allRequestedWines, | ||||
|   login, | ||||
|   register, | ||||
|   addAttendee, | ||||
| @@ -297,6 +359,7 @@ export { | ||||
|   winnersSecure, | ||||
|   deleteWinners, | ||||
|   deleteAttendees, | ||||
|   deleteRequestedWine, | ||||
|   getChatHistory, | ||||
|   finishedDraw, | ||||
|   getAmIWinner, | ||||
|   | ||||
							
								
								
									
										52
									
								
								src/components/AllRequestedWines.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/components/AllRequestedWines.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| <template> | ||||
|   <main> | ||||
|     <h1> | ||||
|       Alle foreslåtte viner | ||||
|     </h1> | ||||
|     <section class="requested-wines-container"> | ||||
|       <p v-if="wines == undefined || wines.length == 0">Ingen har foreslått noe enda!</p> | ||||
|       <RequestedWineCard v-for="requestedEl in wines" :key="requestedEl.id" :requestedElement="requestedEl" @wineDeleted="filterOutDeletedWine" :showDeleteButton="isAdmin"/> | ||||
|     </section> | ||||
|   </main> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { allRequestedWines } from "@/api"; | ||||
| import RequestedWineCard from "@/ui/RequestedWineCard"; | ||||
| export default { | ||||
|   components: { | ||||
|     RequestedWineCard | ||||
|   }, | ||||
|   data(){ | ||||
|     return{ | ||||
|       wines: undefined, | ||||
|       canRequest: true, | ||||
|       isAdmin: false | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     filterOutDeletedWine(wine){ | ||||
|       this.wines = this.wines.filter(item => item.wine._id !== wine._id) | ||||
|     }, | ||||
|     async refreshData(){ | ||||
|       [this.wines, this.isAdmin] = await allRequestedWines() || [[], false] | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.refreshData() | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| h1{ | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .requested-wines-container{ | ||||
|   display: flex; | ||||
|   justify-content: space-around; | ||||
|   flex-flow: row wrap; | ||||
|   align-items: stretch | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										195
									
								
								src/components/RequestWine.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								src/components/RequestWine.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,195 @@ | ||||
| <template> | ||||
|   <main> | ||||
|     <h1> | ||||
|       Foreslå en vin! | ||||
|     </h1> | ||||
|     <Modal  | ||||
|       v-if="showModal"  | ||||
|       modalText="Ønsket ditt har blitt lagt til"  | ||||
|       :buttons="modalButtons" | ||||
|       @click="emitFromModalButton" | ||||
|     ></Modal> | ||||
|     <section> | ||||
|       <section class="search-section"> | ||||
|         <input type="text" v-model="searchString" @keyup.enter="fetchWineFromVin()" placeholder="Søk etter en vin du liker her!🍷"> | ||||
|         <button :disabled="!searchString" @click="fetchWineFromVin()" class="vin-button">Søk</button> | ||||
|       </section> | ||||
|       <section v-for="(wine, index) in this.wines" :key="index" class="search-results-container"> | ||||
|         <img | ||||
|           v-if="wine.image" | ||||
|           :src="wine.image" | ||||
|           class="wine-image" | ||||
|           :class="{ 'fullscreen': fullscreen }" | ||||
|         /> | ||||
|         <img v-else class="wine-placeholder" alt="Wine image" /> | ||||
|         <section class="wine-info"> | ||||
|           <h2 v-if="wine.name">{{ wine.name }}</h2> | ||||
|           <h2 v-else>(no name)</h2> | ||||
|           <div class="__details"> | ||||
|             <span v-if="wine.rating">{{ wine.rating }}%</span> | ||||
|             <span v-if="wine.price">{{ wine.price }} NOK</span> | ||||
|             <span v-if="wine.country">{{ wine.country }}</span> | ||||
|           </div> | ||||
|         </section> | ||||
|         <section class="buttons"> | ||||
|           <button class="vin-button" @click="request(wine)">Foreslå denne</button> | ||||
|           <a | ||||
|           v-if="wine.vivinoLink" | ||||
|           :href="wine.vivinoLink" | ||||
|           class="wine-link" | ||||
|         >Les mer på polet</a> | ||||
|         </section> | ||||
|       </section> | ||||
|       <p v-if="this.wines && this.wines.length == 0"> | ||||
|         Fant ingen viner med det navnet! | ||||
|       </p> | ||||
|     </section> | ||||
|   </main> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { searchForWine, requestNewWine } from "@/api"; | ||||
| import Wine from "@/ui/Wine"; | ||||
| import Modal from "@/ui/Modal"; | ||||
|  | ||||
| export default { | ||||
|   components: { | ||||
|     Wine, | ||||
|     Modal | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       searchString: undefined, | ||||
|       wines: undefined, | ||||
|       showModal: false, | ||||
|       modalButtons: [ | ||||
|         { | ||||
|           text: "Legg til flere viner", | ||||
|           action: "stay" | ||||
|         }, | ||||
|         { | ||||
|           text: "Se alle viner", | ||||
|           action: "move" | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     fetchWineFromVin(){ | ||||
|       if(this.searchString){ | ||||
|         this.wines = [] | ||||
|         let localSearchString = this.searchString.replace(/ /g,"_"); | ||||
|         searchForWine(localSearchString) | ||||
|           .then(res => this.wines = res) | ||||
|       } | ||||
|     }, | ||||
|     request(wine){ | ||||
|       requestNewWine(wine) | ||||
|         .then(() => this.showModal = true) | ||||
|     }, | ||||
|     emitFromModalButton(action){ | ||||
|       if(action == "stay"){ | ||||
|         this.showModal = false | ||||
|       } else { | ||||
|         this.$router.push("/requested-wines"); | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/styles/media-queries"; | ||||
| @import "./src/styles/global"; | ||||
| @import "./src/styles/variables"; | ||||
|  | ||||
| main{ | ||||
|   margin: auto; | ||||
|   width: 80%; | ||||
|   text-align: center; | ||||
|   z-index: 0; | ||||
| } | ||||
|  | ||||
| input[type="text"] { | ||||
|   width: 100%; | ||||
|   color: black; | ||||
|   border-radius: 4px; | ||||
|   padding: 0.5rem 1rem; | ||||
|   border: 1px solid black; | ||||
|   max-width: 80%; | ||||
| } | ||||
|  | ||||
| .search-section{ | ||||
|   display: flex; | ||||
|   justify-content: space-around; | ||||
|   flex-flow: row; | ||||
| } | ||||
|  | ||||
| .search-results-container{ | ||||
|   display: flex; | ||||
|   padding: 3px; | ||||
|   border-radius: 1px; | ||||
|   box-shadow: 0px 0px 0px 1px rgba(0,0,0,0.3);    | ||||
|   margin: 1rem 0; | ||||
|   justify-content: space-around; | ||||
|   flex-flow: row wrap; | ||||
|   align-items: stretch; | ||||
|  | ||||
|  | ||||
|   .wine-image { | ||||
|     height: 100px; | ||||
|   } | ||||
|  | ||||
|   .wine-placeholder { | ||||
|     height: 100px; | ||||
|     width: 70px; | ||||
|   } | ||||
|  | ||||
|   .wine-info{ | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     .__details{ | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|     } | ||||
|   } | ||||
|   .wine-link { | ||||
|     color: #333333; | ||||
|     font-family: Arial; | ||||
|     text-decoration: none; | ||||
|     font-weight: bold; | ||||
|     border-bottom: 1px solid #ff5fff; | ||||
|     width: fit-content; | ||||
|   } | ||||
|  | ||||
|   .buttons{ | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     order: 2; | ||||
|     justify-content: space-between; | ||||
|     width: 40%; | ||||
|     margin-right: 1rem; | ||||
|   } | ||||
|   @include mobile { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     .wine-image { | ||||
|       height: 100px; | ||||
|       width: 50px; | ||||
|       align-self: center; | ||||
|     } | ||||
|     .buttons{ | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       align-self: center; | ||||
|       margin: 1em; | ||||
|       .wine-link{ | ||||
|         margin-top: 1em; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
| </style> | ||||
| @@ -13,6 +13,9 @@ import LotteryPage from "@/components/LotteryPage"; | ||||
| import HistoryPage from "@/components/HistoryPage"; | ||||
| import HighscorePage from "@/components/HighscorePage"; | ||||
|  | ||||
| import RequestWine from "@/components/RequestWine"; | ||||
| import AllRequestedWines from "@/components/AllRequestedWines"; | ||||
|  | ||||
| const routes = [ | ||||
|   { | ||||
|     path: "*", | ||||
| @@ -57,6 +60,14 @@ const routes = [ | ||||
|   { | ||||
|     path: "/highscore", | ||||
|     component: HighscorePage | ||||
|   }, | ||||
|   { | ||||
|     path: "/request", | ||||
|     component: RequestWine | ||||
|   }, | ||||
|   { | ||||
|     path: "/requested-wines", | ||||
|     component: AllRequestedWines | ||||
|   } | ||||
| ]; | ||||
|  | ||||
|   | ||||
							
								
								
									
										147
									
								
								src/styles/banner.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								src/styles/banner.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | ||||
| @import "./media-queries.scss"; | ||||
|  | ||||
|  | ||||
| // https://codepen.io/erikterwan/pen/EVzeRP | ||||
| @include mobile{ | ||||
|   #menuToggle | ||||
|   { | ||||
|     display: block; | ||||
|     position: relative; | ||||
|     margin: 7px; | ||||
|      | ||||
|     z-index: 1; | ||||
|      | ||||
|     -webkit-user-select: none; | ||||
|     user-select: none; | ||||
|   } | ||||
|  | ||||
|   #menuToggle a | ||||
|   { | ||||
|     text-decoration: none; | ||||
|     color: #333333; | ||||
|      | ||||
|     transition: color 0.3s ease; | ||||
|   } | ||||
|  | ||||
|  | ||||
|   #menuToggle input | ||||
|   { | ||||
|     display: block; | ||||
|     width: 40px; | ||||
|     height: 32px; | ||||
|     position: absolute; | ||||
|     top: -7px; | ||||
|     left: -5px; | ||||
|      | ||||
|     cursor: pointer; | ||||
|      | ||||
|     opacity: 0; /* hide this */ | ||||
|     z-index: 2; /* and place it over the hamburger */ | ||||
|      | ||||
|     -webkit-touch-callout: none; | ||||
|   } | ||||
|  | ||||
|   /* | ||||
|   * Just a quick hamburger | ||||
|   */ | ||||
|   #menuToggle span | ||||
|   { | ||||
|     display: block; | ||||
|     width: 33px; | ||||
|     height: 4px; | ||||
|     margin-bottom: 5px; | ||||
|     position: relative; | ||||
|      | ||||
|     background: #333333; | ||||
|     border-radius: 3px; | ||||
|      | ||||
|     z-index: 1; | ||||
|      | ||||
|     transform-origin: 4px 0px; | ||||
|      | ||||
|     transition: transform 0.5s cubic-bezier(0.77,0.2,0.05,1.0), | ||||
|                 background 0.5s cubic-bezier(0.77,0.2,0.05,1.0), | ||||
|                 opacity 0.55s ease; | ||||
|   } | ||||
|  | ||||
|   #menuToggle span:first-child | ||||
|   { | ||||
|     transform-origin: 0% 0%; | ||||
|   } | ||||
|  | ||||
|   #menuToggle span:nth-last-child(2) | ||||
|   { | ||||
|     transform-origin: 0% 100%; | ||||
|   } | ||||
|  | ||||
|   /*  | ||||
|   * Transform all the slices of hamburger | ||||
|   * into a crossmark. | ||||
|   */ | ||||
|   #menuToggle input:checked ~ span | ||||
|   { | ||||
|     opacity: 1; | ||||
|     transform: rotate(45deg) translate(-2px, -1px); | ||||
|     background: #232323; | ||||
|   } | ||||
|  | ||||
|   /* | ||||
|   * But let's hide the middle one. | ||||
|   */ | ||||
|   #menuToggle input:checked ~ span:nth-last-child(3) | ||||
|   { | ||||
|     opacity: 0; | ||||
|     transform: rotate(0deg) scale(0.2, 0.2); | ||||
|   } | ||||
|  | ||||
|   /* | ||||
|   * Ohyeah and the last one should go the other direction | ||||
|   */ | ||||
|   #menuToggle input:checked ~ span:nth-last-child(2) | ||||
|   { | ||||
|     transform: rotate(-45deg) translate(0, -1px); | ||||
|   } | ||||
|  | ||||
|   /* | ||||
|   * Make this absolute positioned | ||||
|   * at the top left of the screen | ||||
|   */ | ||||
|   #menu | ||||
|   { | ||||
|     position: absolute; | ||||
|     width: 100vw; | ||||
|     margin: -100px 0 0 -50px; | ||||
|     padding-bottom: 10px; | ||||
|     padding-top: 125px; | ||||
|      | ||||
|     background-color: $primary; | ||||
|     list-style-type: none; | ||||
|     -webkit-font-smoothing: antialiased; | ||||
|     /* to stop flickering of text in safari */ | ||||
|      | ||||
|     transform-origin: 0% 0%; | ||||
|     transform: translate(-100%, 0); | ||||
|      | ||||
|     transition: transform 0.5s cubic-bezier(0.77,0.2,0.05,1.0); | ||||
|   } | ||||
|  | ||||
|   #menu li | ||||
|   { | ||||
|     padding: 10px 0; | ||||
|     font-size: 22px; | ||||
|   } | ||||
|  | ||||
|   /* | ||||
|   * And let's slide it in from the left | ||||
|   */ | ||||
|   #menuToggle input:checked ~ ul | ||||
|   { | ||||
|     transform: none; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @include desktop{ | ||||
|   #menuToggle{ | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| @@ -144,13 +144,17 @@ textarea { | ||||
|       0 16px 32px rgba(0, 0, 0, 0.07), 0 32px 64px rgba(0, 0, 0, 0.07); | ||||
|   } | ||||
|  | ||||
|   &:hover { | ||||
|   &:hover:not(:disabled) { | ||||
|     transform: scale(1.02) translateZ(0); | ||||
|  | ||||
|     &::after { | ||||
|       opacity: 1; | ||||
|     } | ||||
|   } | ||||
|   &:disabled{ | ||||
|     opacity: 0.25; | ||||
|     cursor: not-allowed; | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,18 +1,36 @@ | ||||
| <template> | ||||
|   <router-link to="/" class="link"> | ||||
|     <div class="top-banner"> | ||||
|       <img src="/public/assets/images/knowit.svg" alt="knowit logo" /> | ||||
|       <div class="clock"> | ||||
|         <h2 v-if="!fiveMinutesLeft || !tenMinutesOver"> | ||||
|           <span v-if="days > 0">{{ pad(days) }}:</span> | ||||
|           <span>{{ pad(hours) }}</span>: | ||||
|           <span>{{ pad(minutes) }}</span>: | ||||
|           <span>{{ pad(seconds) }}</span> | ||||
|         </h2> | ||||
|         <h2 v-if="twoMinutesLeft || tenMinutesOver">Lotteriet er i gang!</h2> | ||||
|       </div> | ||||
|   <div class="top-banner"> | ||||
|     <!-- Mobile --> | ||||
|     <div id="menuToggle" > | ||||
|       <input type="checkbox" /> | ||||
|       <span></span> | ||||
|       <span></span> | ||||
|       <span></span> | ||||
|       <ul id="menu"> | ||||
|         <router-link v-for="(route, index) in routes" :key="index" :to="route.route"> | ||||
|           <li>{{route.name}}</li> | ||||
|         </router-link> | ||||
|       </ul> | ||||
|     </div> | ||||
|   </router-link> | ||||
|      | ||||
|     <router-link to="/"> | ||||
|       <img src="/public/assets/images/knowit.svg" alt="knowit logo" /> | ||||
|     </router-link> | ||||
|     <div v-for="(route, index) in routes" :key="index" class="desktop"> | ||||
|       <router-link :to="route.route" class="routes"> | ||||
|         {{route.name}} | ||||
|       </router-link> | ||||
|     </div> | ||||
|     <div class="clock"> | ||||
|       <h2 v-if="!fiveMinutesLeft || !tenMinutesOver"> | ||||
|         <span v-if="days > 0">{{ pad(days) }}:</span> | ||||
|         <span>{{ pad(hours) }}</span>: | ||||
|         <span>{{ pad(minutes) }}</span>: | ||||
|         <span>{{ pad(seconds) }}</span> | ||||
|       </h2> | ||||
|       <h2 v-if="twoMinutesLeft || tenMinutesOver">Lotteriet er i gang!</h2> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| @@ -25,12 +43,15 @@ export default { | ||||
|       minutes: 0, | ||||
|       seconds: 0, | ||||
|       distance: 0, | ||||
|       enabled: false, | ||||
|       code: "38384040373937396665", | ||||
|       codeDone: "", | ||||
|       interval: null | ||||
|       interval: null, | ||||
|     }; | ||||
|   }, | ||||
|   props: { | ||||
|     routes: { | ||||
|       required: true, | ||||
|       type: Array | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.initialize(), this.countdown(); | ||||
|   }, | ||||
| @@ -55,19 +76,6 @@ export default { | ||||
|       } | ||||
|       return num; | ||||
|     }, | ||||
|     listenerFunction: function(event) { | ||||
|       this.codeDone += event.keyCode; | ||||
|       if (this.code.substring(0, this.codeDone.length) == this.codeDone) { | ||||
|         if (this.code == this.codeDone && !this.enabled) { | ||||
|           this.enabled = true; | ||||
|           this.initialize(); | ||||
|           this.countdown(); | ||||
|           this.codeDone = ""; | ||||
|         } | ||||
|       } else { | ||||
|         this.codeDone = ""; | ||||
|       } | ||||
|     }, | ||||
|     initialize: function() { | ||||
|       let d = new Date(); | ||||
|       let dayOfLottery = __DATE__; | ||||
| @@ -115,7 +123,7 @@ export default { | ||||
|         this.initialize(); | ||||
|       } | ||||
|       this.interval = setTimeout(this.countdown, 500); | ||||
|     } | ||||
|     }, | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
| @@ -123,41 +131,56 @@ export default { | ||||
| <style lang="scss" scoped> | ||||
| @import "../styles/media-queries.scss"; | ||||
| @import "../styles/variables.scss"; | ||||
| @import "../styles/banner.scss"; | ||||
|  | ||||
| .link { | ||||
|   text-decoration: none; | ||||
| @include mobile { | ||||
|   .desktop { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @include desktop { | ||||
|    | ||||
|   .top-banner{ | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     width: calc(100% - 20px); | ||||
|      | ||||
|     .routes { | ||||
|       text-decoration: none; | ||||
|       color: #333333; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .top-banner { | ||||
|   text-align: center; | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   width: calc(100% - 80px); | ||||
|   margin-top: 0px; | ||||
|   padding: 0px 40px; | ||||
|   width: calc(100% - 20px); | ||||
|   padding: 5px 10px; | ||||
|   background-color: $primary; | ||||
|   -webkit-box-shadow: 0px 0px 22px -8px rgba(0, 0, 0, 0.65); | ||||
|   -moz-box-shadow: 0px 0px 22px -8px rgba(0, 0, 0, 0.65); | ||||
|   box-shadow: 0px 0px 22px -8px rgba(0, 0, 0, 0.65); | ||||
|  | ||||
|   @include mobile { | ||||
|     padding: 0px 40px; | ||||
|  | ||||
|     > img { | ||||
|       height: 23px; | ||||
|   .clock { | ||||
|     text-decoration: none; | ||||
|     color: #333333; | ||||
|     display: flex; | ||||
|     font-family: Arial; | ||||
|     margin-right: 2rem; | ||||
|     @include mobile { | ||||
|       font-size: 0.8em; | ||||
|       margin-right: 1rem; | ||||
|     } | ||||
|     h2 { | ||||
|       display: flex; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .clock { | ||||
|   text-decoration: none; | ||||
|   color: #333333; | ||||
|   display: flex; | ||||
|   font-family: Arial; | ||||
|   h2 { | ||||
|     display: flex; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										101
									
								
								src/ui/Modal.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/ui/Modal.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,101 @@ | ||||
| <template> | ||||
|   <transition name="modal-fade"> | ||||
|     <main class="modal-backdrop"> | ||||
|       <section class="modal"> | ||||
|         <header class="modal-header" v-if="headerText"> | ||||
|           {{headerText}} | ||||
|         </header> | ||||
|         <section class="modal-body"> | ||||
|           <p> | ||||
|             {{modalText}} | ||||
|           </p> | ||||
|           <section class="button-container"> | ||||
|             <button v-for="(button, index) in buttons" :key="index" @click="modalButtonClicked(button.action)" class="vin-button"> | ||||
|               {{button.text}} | ||||
|             </button> | ||||
|           </section> | ||||
|         </section> | ||||
|       </section> | ||||
|     </main> | ||||
|   </transition> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     headerText: { | ||||
|       type: String, | ||||
|       required: false | ||||
|     }, | ||||
|     modalText: { | ||||
|       type: String, | ||||
|       required: true | ||||
|     }, | ||||
|     buttons: { | ||||
|       type: Array, | ||||
|       required: true | ||||
|     }, | ||||
|   }, | ||||
|   methods:{ | ||||
|     modalButtonClicked(action){ | ||||
|       this.$emit('click', action) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "../styles/global.scss"; | ||||
|  | ||||
| .modal-fade-enter, | ||||
| .modal-fade-leave-active { | ||||
|   opacity: 0; | ||||
| } | ||||
|  | ||||
| .modal-fade-enter-active, | ||||
| .modal-fade-leave-active { | ||||
|   transition: opacity .5s ease | ||||
| } | ||||
|  | ||||
| .modal-backdrop { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   bottom: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   background-color: rgba(0, 0, 0, 0.3); | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   z-index: 1; | ||||
|   width: 100vw; | ||||
|   height: 100vh; | ||||
| } | ||||
|  | ||||
| .modal { | ||||
|   background: #FFFFFF; | ||||
|   -webkit-box-shadow: 0px 0px 22px 1px rgba(0, 0, 0, 0.65); | ||||
|   -moz-box-shadow: 0px 0px 22px 1px rgba(0, 0, 0, 0.65); | ||||
|   box-shadow: 0px 0px 22px 1px rgba(0, 0, 0, 0.65); | ||||
|   overflow-x: auto; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| } | ||||
|  | ||||
| .modal-header { | ||||
|   padding: 15px; | ||||
|   display: flex; | ||||
| } | ||||
|  | ||||
| .modal-header { | ||||
|   border-bottom: 1px solid #eeeeee; | ||||
|   color: #4AAE9B; | ||||
|   justify-content: space-between; | ||||
| } | ||||
|  | ||||
| .modal-body { | ||||
|   position: relative; | ||||
|   padding: 20px 10px; | ||||
| } | ||||
|  | ||||
| </style> | ||||
							
								
								
									
										80
									
								
								src/ui/RequestedWineCard.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/ui/RequestedWineCard.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,80 @@ | ||||
| <template> | ||||
|   <div class="requested-wine"> | ||||
|     <img | ||||
|       v-if="wine.image" | ||||
|       :src="wine.image" | ||||
|       class="wine-image" | ||||
|       :class="{ 'fullscreen': fullscreen }" | ||||
|     /> | ||||
|     <img v-else class="wine-placeholder" alt="Wine image" /> | ||||
|     <section class="wine-info"> | ||||
|       <h3 v-if="wine.name">{{ wine.name }}</h3> | ||||
|       <h3 v-else>(no name)</h3> | ||||
|       <p>Antall ganger denne har blitt foreslått: {{requestedElement.count}}</p> | ||||
|       <section class="buttons"> | ||||
|           <button class="vin-button" @click="request(wine)" v-if="!locallyRequested">Foreslå denne</button> | ||||
|           <a | ||||
|           v-if="wine.vivinoLink" | ||||
|           :href="wine.vivinoLink" | ||||
|           class="wine-link" | ||||
|         >Les mer på polet</a> | ||||
|         </section> | ||||
|         <button @click="deleteWine(wine)" v-if="showDeleteButton == true"> | ||||
|           Slett vinen | ||||
|         </button> | ||||
|       </section> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { deleteRequestedWine, requestNewWine } from "@/api"; | ||||
|  | ||||
| export default { | ||||
|   data(){ | ||||
|     return { | ||||
|       wine: this.requestedElement.wine, | ||||
|       locallyRequested: false | ||||
|     } | ||||
|   }, | ||||
|   props: { | ||||
|     requestedElement: { | ||||
|       required: true, | ||||
|       type: Object | ||||
|     }, | ||||
|     showDeleteButton: { | ||||
|       required: false, | ||||
|       type: Boolean, | ||||
|       default: false | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     request(wine){ | ||||
|       this.locallyRequested = true | ||||
|       this.requestedElement.count = this.requestedElement.count +1 | ||||
|       requestNewWine(wine) | ||||
|     }, | ||||
|     async deleteWine(wine) { | ||||
|       if (window.confirm("Er du sikker på at du vil slette vinen?")) { | ||||
|         let response = await deleteRequestedWine(wine); | ||||
|         if (response['success'] == true) { | ||||
|           this.$emit('wineDeleted', wine); | ||||
|         } else { | ||||
|           alert("Klarte ikke slette vinen"); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|   }, | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped>  | ||||
|  | ||||
| .requested-wine{ | ||||
|   padding: 20px; | ||||
|   border-radius: 1px; | ||||
|   margin: 1rem 0; | ||||
|   -webkit-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.65); | ||||
|   -moz-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.65); | ||||
|   box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.65); | ||||
| } | ||||
| </style> | ||||
		Reference in New Issue
	
	Block a user