Renamed /src to /frontend.

This commit is contained in:
2020-12-06 21:48:21 +01:00
parent 913268b01c
commit ce7e05fd43
57 changed files with 8 additions and 8 deletions

82
frontend/ui/Attendees.vue Normal file
View File

@@ -0,0 +1,82 @@
<template>
<div class="attendees" v-if="attendees.length > 0">
<h2>Deltakere ({{ attendees.length }})</h2>
<div class="attendees-container" ref="attendees">
<div class="attendee" v-for="(attendee, index) in flipList(attendees)" :key="index">
<span class="attendee-name">{{ attendee.name }}</span>
<div class="red-raffle raffle-element small">{{ attendee.red }}</div>
<div class="blue-raffle raffle-element small">{{ attendee.blue }}</div>
<div class="green-raffle raffle-element small">{{ attendee.green }}</div>
<div class="yellow-raffle raffle-element small">{{ attendee.yellow }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
attendees: {
type: Array
}
},
methods: {
flipList: (list) => list.slice().reverse()
},
watch: {
attendees: {
deep: true,
handler() {
if (this.$refs && this.$refs.history) {
setTimeout(() => {
this.$refs.attendees.scrollTop = this.$refs.attendees.scrollHeight;
}, 50);
}
}
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
.attendee-name {
width: 60%;
}
.raffle-element {
font-size: 0.75rem;
width: 45px;
height: 45px;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
font-size: 0.75rem;
text-align: center;
}
.attendees {
display: flex;
flex-direction: column;
align-items: center;
width: 65%;
height: 100%;
}
.attendees-container {
width: 100%;
height: 100%;
overflow-y: scroll;
}
.attendee {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin: 0 auto;
}
</style>

133
frontend/ui/Banner.vue Normal file
View File

@@ -0,0 +1,133 @@
<template>
<header class="top-banner">
<!-- Mobile -->
<router-link to="/" class="company-logo">
<img src="/public/assets/images/knowit.svg" alt="knowit logo" />
</router-link>
<a class="menu-toggle-container" aria-label="show-menu" @click="toggleMenu" :class="isOpen ? 'open' : 'collapsed'" >
<span class="menu-toggle"></span>
<span class="menu-toggle"></span>
<span class="menu-toggle"></span>
</a>
<nav class="menu" :class="isOpen ? 'open' : 'collapsed'" >
<router-link v-for="(route, index) in routes" :key="index" :to="route.route" class="menu-item-link" >
<a @click="toggleMenu" class="single-route" :class="isOpen ? 'open' : 'collapsed'">{{route.name}}</a>
</router-link>
</nav>
<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>
</header>
</template>
<script>
export default {
data() {
return {
isOpen: false,
nextLottery: null,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
distance: 0,
interval: null,
};
},
props: {
routes: {
required: true,
type: Array
}
},
mounted() {
this.initialize(), this.countdown();
},
computed: {
fiveMinutesLeft: function() {
if (this.days == 0 && this.hours == 0 && this.minutes <= 2) {
return true;
}
return false;
},
tenMinutesOver: function() {
if (this.days == 6 && this.hours >= 23 && this.minutes >= 50) {
return true;
}
return false;
}
},
methods: {
toggleMenu(){
this.isOpen = this.isOpen ? false : true;
},
pad: function(num) {
if (num < 10) {
return `0${num}`;
}
return num;
},
initialize: function() {
let d = new Date();
let dayOfLottery = __DATE__;
let dayDifference = (dayOfLottery + 7 - d.getDay()) % 7;
if (dayDifference == 0) {
dayDifference = 7;
}
let nextDayOfLottery = new Date(d.setDate(d.getDate() + dayDifference));
nextDayOfLottery = new Date(nextDayOfLottery.setHours(__HOURS__));
nextDayOfLottery = new Date(nextDayOfLottery.setMinutes(0));
nextDayOfLottery = new Date(nextDayOfLottery.setSeconds(0));
let nowDate = new Date();
let now = nowDate.getTime();
if (nextDayOfLottery.getTimezoneOffset() != nowDate.getTimezoneOffset()) {
let _diff =
(nextDayOfLottery.getTimezoneOffset() - nowDate.getTimezoneOffset()) *
60 *
-1;
nextDayOfLottery.setSeconds(nextDayOfLottery.getSeconds() + _diff);
}
this.nextLottery = nextDayOfLottery;
this.distance = new Date(this.nextLottery).getTime() - now;
},
countdown: function() {
// Get today's date and time
let now = new Date().getTime();
// Find the distance between now and the count down date
this.distance = new Date(this.nextLottery).getTime() - now;
// Time calculations for days, hours, minutes and seconds
this.days = Math.floor(this.distance / (1000 * 60 * 60 * 24));
this.hours = Math.floor(
(this.distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
);
this.minutes = Math.floor(
(this.distance % (1000 * 60 * 60)) / (1000 * 60)
);
this.seconds = Math.floor((this.distance % (1000 * 60)) / 1000);
if (this.days == 7) {
this.days = 0;
}
if (this.distance < 0) {
this.initialize();
}
this.interval = setTimeout(this.countdown, 500);
},
}
};
</script>
<style lang="scss" scoped>
@import "../styles/banner.scss";
</style>

337
frontend/ui/Chat.vue Normal file
View File

@@ -0,0 +1,337 @@
<template>
<div class="chat-container">
<hr />
<h2>Chat</h2>
<div class="history" ref="history" v-if="chatHistory.length > 0">
<div class="opaque-skirt"></div>
<div v-if="hasMorePages" class="fetch-older-history">
<button @click="loadMoreHistory">Hent eldre meldinger</button>
</div>
<div class="history-message"
v-for="(history, index) in chatHistory"
:key="`${history.username}-${history.timestamp}-${index}`"
>
<div>
<span class="username">{{ history.username }}</span>
<span class="timestamp">{{ getTime(history.timestamp) }}</span>
</div>
<span class="message">{{ history.message }}</span>
</div>
</div>
<div v-if="username" class="input">
<input @keyup.enter="sendMessage" type="text" v-model="message" placeholder="Melding.." />
<button @click="sendMessage">Send</button>
<button @click="removeUsername">Logg ut</button>
</div>
<div v-else class="username-dialog">
<input
type="text"
@keyup.enter="setUsername"
v-model="temporaryUsername"
maxlength="30"
placeholder="Ditt navn.."
/>
<div class="validation-error" v-if="validationError">
{{ validationError }}
</div>
<button @click="setUsername">Lagre brukernavn</button>
</div>
</div>
</template>
<script>
import { getChatHistory } from "@/api";
import io from "socket.io-client";
export default {
data() {
return {
socket: null,
chatHistory: [],
hasMorePages: true,
message: "",
page: 1,
pageSize: 10,
temporaryUsername: null,
username: null,
validationError: undefined
};
},
created() {
getChatHistory(1, this.pageSize)
.then(resp => {
this.chatHistory = resp.messages;
this.hasMorePages = resp.total != resp.messages.length;
});
const username = window.localStorage.getItem('username');
if (username) {
this.username = username;
this.emitUsernameOnConnect = true;
}
},
watch: {
chatHistory: {
handler: function(newVal, oldVal) {
if (oldVal.length == 0) {
this.scrollToBottomOfHistory();
}
else if (newVal && newVal.length == oldVal.length) {
if (this.isScrollPositionAtBottom()) {
this.scrollToBottomOfHistory();
}
} else {
const prevOldestMessage = oldVal[0];
this.scrollToMessageElement(prevOldestMessage);
}
},
deep: true
}
},
beforeDestroy() {
this.socket.disconnect();
this.socket = null;
},
mounted() {
const BASE_URL = __APIURL__ || window.location.origin;
this.socket = io(`${BASE_URL}`);
this.socket.on("chat", msg => {
this.chatHistory.push(msg);
});
this.socket.on("disconnect", msg => {
this.wasDisconnected = true;
});
this.socket.on("connect", msg => {
if (
this.emitUsernameOnConnect ||
(this.wasDisconnected && this.username != null)
) {
this.setUsername(this.username);
}
});
this.socket.on("accept_username", msg => {
const { reason, success, username } = msg;
this.usernameAccepted = success;
if (success !== true) {
this.username = null;
this.validationError = reason;
} else {
this.usernameAllowed = true;
this.username = username;
this.validationError = null;
window.localStorage.setItem("username", username);
}
});
},
methods: {
loadMoreHistory() {
let { page, pageSize } = this;
page = page + 1;
getChatHistory(page, pageSize)
.then(resp => {
this.chatHistory = resp.messages.concat(this.chatHistory);
this.page = page;
this.hasMorePages = resp.total != this.chatHistory.length;
});
},
pad(num) {
if (num > 9) return num;
return `0${num}`;
},
getTime(timestamp) {
let date = new Date(timestamp);
const timeString = `${this.pad(date.getHours())}:${this.pad(
date.getMinutes()
)}:${this.pad(date.getSeconds())}`;
if (date.getDate() == new Date().getDate()) {
return timeString;
}
return `${date.toLocaleDateString()} ${timeString}`;
},
sendMessage() {
const message = { message: this.message };
this.socket.emit("chat", message);
this.message = '';
this.scrollToBottomOfHistory();
},
setUsername(username=undefined) {
if (this.temporaryUsername) {
username = this.temporaryUsername;
}
const message = { username: username };
this.socket.emit("username", message);
},
removeUsername() {
this.username = null;
this.temporaryUsername = null;
window.localStorage.removeItem("username");
},
isScrollPositionAtBottom() {
const { history } = this.$refs;
if (history) {
return history.offsetHeight + history.scrollTop >= history.scrollHeight;
}
return false
},
scrollToBottomOfHistory() {
setTimeout(() => {
const { history } = this.$refs;
history.scrollTop = history.scrollHeight;
}, 1);
},
scrollToMessageElement(message) {
const elemTimestamp = this.getTime(message.timestamp);
const self = this;
const getTimeStamp = (elem) => elem.getElementsByClassName('timestamp')[0].innerText;
const prevOldestMessageInNewList = (elem) => getTimeStamp(elem) == elemTimestamp;
setTimeout(() => {
const { history } = self.$refs;
const childrenElements = Array.from(history.getElementsByClassName('history-message'));
const elemInNewList = childrenElements.find(prevOldestMessageInNewList);
history.scrollTop = elemInNewList.offsetTop - 70
}, 1);
}
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/media-queries.scss";
@import "@/styles/variables.scss";
h2 {
text-align: center;
}
hr {
display: none;
@include mobile {
display: block;
width: 80%;
}
}
.chat-container {
height: 100%;
width: 50%;
position: relative;
@include mobile {
width: 100%;
}
}
input {
width: 80%;
}
.input {
display: flex;
}
.history {
height: 75%;
overflow-y: scroll;
position: relative;
&-message {
display: flex;
flex-direction: column;
margin: 0.35rem 0;
position: relative;
.username {
font-weight: bold;
font-size: 1.05rem;
margin-right: 0.3rem;
}
.timestamp {
font-size: 0.9rem;
top: 2px;
position: absolute;
}
}
&-message:nth-of-type(2) {
margin-top: 1rem;
}
& .opaque-skirt {
width: 100%;
position: absolute;
height: 1rem;
z-index: 1;
background: linear-gradient(
to bottom,
white,
rgba(255, 255, 255, 0)
);
}
& .fetch-older-history {
display: flex;
justify-content: center;
margin: 1rem 0;
}
@include mobile {
height: 300px;
}
}
.username-dialog {
display: flex;
flex-direction: row;
justify-content: center;
position: relative;
.validation-error {
position: absolute;
background-color: $light-red;
color: $red;
top: -3.5rem;
left: 0.5rem;
padding: 0.75rem;
border-radius: 4px;
&::before {
content: '';
position: absolute;
top: 2.1rem;
left: 2rem;
width: 1rem;
height: 1rem;
transform: rotate(45deg);
background-color: $light-red;
}
}
}
button {
position: relative;
display: inline-block;
background: #b7debd;
color: #333;
padding: 10px 30px;
border: 0;
width: fit-content;
font-size: 1rem;
/* height: 1.5rem; */
/* max-height: 1.5rem; */
margin: 0 2px;
cursor: pointer;
font-weight: 500;
transition: transform 0.5s ease;
-webkit-font-smoothing: antialiased;
touch-action: manipulation;
}
</style>

161
frontend/ui/Countdown.vue Normal file
View File

@@ -0,0 +1,161 @@
<template>
<div>
<div class="clock" v-if="enabled">
<h2 cv-if="distance > 0">
<span v-if="days > 0">{{ pad(days) }}:</span>
<span>{{ pad(hours) }}</span
>: <span>{{ pad(minutes) }}</span
>:
<span>{{ pad(seconds) }}</span>
</h2>
<div class="cross" @click="stopClock">X</div>
<h2 v-if="distance <= 0">Lotteriet har begynt!</h2>
</div>
</div>
</template>
<script>
export default {
props: {
hardEnable: {
type: Boolean,
required: false
}
},
data() {
return {
nextLottery: null,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
distance: 0,
enabled: false,
code: "38384040373937396665",
codeDone: "",
interval: null
};
},
watch: {
hardEnable: function(hardEnable) {
if (hardEnable) {
this.enabled = true;
this.initialize();
this.countdown();
}
}
},
mounted() {
window.addEventListener("keydown", this.listenerFunction);
},
methods: {
pad: function(num) {
if (num < 10) {
return `0${num}`;
}
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__;
let dayDifference = (dayOfLottery + 7 - d.getDay()) % 7;
if (dayDifference == 0) {
dayDifference = 7;
}
let nextDayOfLottery = new Date(d.setDate(d.getDate() + dayDifference));
nextDayOfLottery = new Date(nextDayOfLottery.setHours(__HOURS__));
nextDayOfLottery = new Date(nextDayOfLottery.setMinutes(0));
nextDayOfLottery = new Date(nextDayOfLottery.setSeconds(0));
this.nextLottery = nextDayOfLottery;
window.scrollTo(0, 0);
document.querySelector("body").style.overflow = "hidden";
document.querySelector("body").style.height = "100vh";
let now = new Date().getTime();
this.distance = new Date(this.nextLottery).getTime() - now;
},
stopClock: function() {
clearInterval(this.interval);
this.enabled = false;
document.querySelector("body").style.overflow = "auto";
document.querySelector("body").style.height = "initial";
this.$emit("countdown", false);
},
countdown: function() {
// Get today's date and time
let now = new Date().getTime();
// Find the distance between now and the count down date
this.distance = new Date(this.nextLottery).getTime() - now;
// Time calculations for days, hours, minutes and seconds
this.days = Math.floor(this.distance / (1000 * 60 * 60 * 24));
this.hours = Math.floor(
(this.distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
);
this.minutes = Math.floor(
(this.distance % (1000 * 60 * 60)) / (1000 * 60)
);
this.seconds = Math.floor((this.distance % (1000 * 60)) / 1000);
if (this.days == 7) {
this.days = 0;
}
if (this.distance < 0) {
clearTimeout(this.interval);
return;
}
this.interval = setTimeout(this.countdown, 500);
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.scss";
.clock {
width: 100vw;
height: 100vh;
position: absolute;
background: white;
top: 0;
left: 0;
@include mobile {
width: 105vw;
height: 110vh;
}
}
.cross {
top: 15px;
right: 23px;
font-size: 2rem;
position: absolute;
cursor: pointer;
@include mobile {
right: 45px;
}
}
h2 {
margin: auto;
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
font-size: 4rem;
}
</style>

32
frontend/ui/Footer.vue Normal file
View File

@@ -0,0 +1,32 @@
<template>
<footer>
<router-link to="/" class="company-logo">
<img src="/public/assets/images/knowit.svg" alt="knowit logo">
</router-link>
</footer>
</template>
<script>
export default {
name: 'WineFooter'
}
</script>
<style lang="scss" scoped>
footer {
width: 100%;
height: 100px;
display: flex;
justify-content: flex-end;
align-items: center;
background: #f4f4f4;
.company-logo{
padding: 0 5em 0 0;
img{
width: 100px;
}
}
}
</style>

120
frontend/ui/Highscore.vue Normal file
View File

@@ -0,0 +1,120 @@
<template>
<div class="highscores" v-if="highscore.length > 0">
<section class="heading">
<h3>
Topp 5 vinnere
</h3>
<router-link to="highscore" class="">
<span class="vin-link">Se alle vinnere</span>
</router-link>
</section>
<ol class="winner-list-container">
<li v-for="(person, index) in highscore" :key="person._id" class="single-winner">
<span class="placement">{{index + 1}}.</span>
<i class="icon icon--medal"></i>
<p class="winner-name">{{ person.name }}</p>
</li>
</ol>
</div>
</template>
<script>
import { highscoreStatistics } from "@/api";
export default {
data() {
return { highscore: [] };
},
async mounted() {
let response = await highscoreStatistics();
response.sort((a, b) => a.wins.length < b.wins.length ? 1 : -1)
this.highscore = this.generateScoreBoard(response.slice(0, 5));
},
methods: {
generateScoreBoard(highscore=this.highscore) {
let place = 0;
let highestWinCount = -1;
return highscore.map(win => {
const wins = win.wins.length
if (wins != highestWinCount) {
place += 1
highestWinCount = wins
}
const placeString = place.toString().padStart(2, "0");
win.rank = placeString;
return win
})
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/variables.scss";
.heading {
display: flex;
justify-content: space-between;
align-items: center;
}
a {
text-decoration: none;
color: #333333;
&:focus,
&:active,
&:visited {
text-decoration: none;
color: #333333;
}
}
ol {
list-style-type: none;
margin-left: 0;
padding: 0;
}
.winner-list-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12.5em, 1fr));
gap: 5%;
.single-winner {
box-sizing: border-box;
width: 100%;
background: $primary;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
padding: 1em;
i {
font-size: 3em;
width: max-content;
justify-self: end;
}
.placement {
grid-row: 1;
grid-column: 1 / 3;
font-size: 3em;
}
.winner-name {
grid-row: 2;
grid-column: 1 / -1;
}
.winner-icon {
grid-row: 1;
grid-column: 3;
}
}
}
</style>

101
frontend/ui/Modal.vue Normal file
View 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>

View File

@@ -0,0 +1,146 @@
<template>
<div class="chart">
<canvas ref="purchase-chart" width="100" height="50"></canvas>
<div ref="chartjsLegend" class="chartjsLegend"></div>
</div>
</template>
<script>
import Chartjs from "chart.js";
import { chartPurchaseByColor } from "@/api";
export default {
async mounted() {
let canvas = this.$refs["purchase-chart"].getContext("2d");
let response = await chartPurchaseByColor();
let labels = [];
let blue = {
label: "Blå",
borderColor: "#57d2fb",
backgroundColor: "#d4f2fe",
borderWidth: 2,
data: []
};
let yellow = {
label: "Gul",
borderColor: "#ffde5d",
backgroundColor: "#fff6d6",
borderWidth: 2,
data: []
};
let red = {
label: "Rød",
borderColor: "#ef5878",
backgroundColor: "#fbd7de",
borderWidth: 2,
data: []
};
let green = {
label: "Grønn",
borderColor: "#10e783",
backgroundColor: "#c8f9df",
borderWidth: 2,
data: []
};
if (response.length == 1) {
labels.push("");
blue.data.push(0);
yellow.data.push(0);
red.data.push(0);
green.data.push(0);
}
let highestNumber = 0;
for (let i = 0; i < response.length; i++) {
let thisDate = response[i];
let dateObject = new Date(thisDate.date);
labels.push(this.getPrettierDateString(dateObject));
blue.data.push(thisDate.blue);
yellow.data.push(thisDate.yellow);
red.data.push(thisDate.red);
green.data.push(thisDate.green);
if (thisDate.blue > highestNumber) {
highestNumber = thisDate.blue;
}
if (thisDate.yellow > highestNumber) {
highestNumber = thisDate.yellow;
}
if (thisDate.green > highestNumber) {
highestNumber = thisDate.green;
}
if (thisDate.red > highestNumber) {
highestNumber = thisDate.red;
}
}
let datasets = [blue, yellow, green, red];
let chartdata = {
labels: labels,
datasets: datasets
};
let chart = new Chart(canvas, {
type: "line",
data: chartdata,
options: {
maintainAspectRatio: false,
animation: {
duration: 0 // general animation time
},
title: {
display: true,
text: "Antall kjøpt",
fontSize: 20
},
legend: {
display: true,
boxWidth: 3,
usePointStyle: true,
borderRadius: 10,
labels: {
padding: 12,
boxWidth: 20,
usePointStyle: true
}
},
scales: {
yAxes: [
{
ticks: {
beginAtZero: true,
suggestedMax: highestNumber + 5
}
}
]
}
}
});
},
methods: {
getPrettierDateString(date) {
return `${this.pad(date.getDate())}.${this.pad(
date.getMonth() + 1
)}.${this.pad(date.getYear() - 100)}`;
},
pad(num) {
if (num < 10) {
return `0${num}`;
}
return num;
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.scss";
.chart {
height: 40vh;
max-height: 500px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,427 @@
<template>
<div class="container">
<div class="input-line">
<label for="redCheckbox">
<input type="checkbox" id="redCheckbox" v-model="redCheckbox" @click="generateColors"/>
<span class="border">
<span class="checkmark"></span>
</span>
<span class="text">Rød</span>
</label>
<label for="blueCheckbox">
<input type="checkbox" id="blueCheckbox" v-model="blueCheckbox" @click="generateColors"/>
<span class="border">
<span class="checkmark"></span>
</span>
<span class="text">Blå</span>
</label>
<label for="yellowCheckbox">
<input type="checkbox" id="yellowCheckbox" v-model="yellowCheckbox" @click="generateColors"/>
<span class="border">
<span class="checkmark"></span>
</span>
<span class="text">Gul</span>
</label>
<label for="greenCheckbox">
<input type="checkbox" id="greenCheckbox" v-model="greenCheckbox" @click="generateColors"/>
<span class="border">
<span class="checkmark"></span>
</span>
<span class="text">Grønn</span>
</label>
</div>
<div class="input-line">
<input
type="number"
placeholder="Antall lodd"
@keyup.enter="generateColors"
v-model="numberOfRaffles"
/>
<button class="vin-button" @click="generateColors">Generer</button>
</div>
<div class="colors">
<div
v-for="color in colors"
:class="getColorClass(color)"
class="color-box"
:style="{ transform: 'rotate(' + getRotation() + 'deg)' }"
></div>
</div>
<div class="color-count-container" v-if="generated">
<span>Rød: {{ red }}</span>
<span>Blå: {{ blue }}</span>
<span>Gul: {{ yellow }}</span>
<span>Grønn: {{ green }}</span>
</div>
</div>
</template>
<script>
export default {
props: {
generateOnInit: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
numberOfRaffles: 4,
colors: [],
blue: 0,
red: 0,
green: 0,
yellow: 0,
colorTimeout: null,
redCheckbox: true,
greenCheckbox: true,
yellowCheckbox: true,
blueCheckbox: true,
generated: false,
generating: false
};
},
beforeMount() {
this.$emit("numberOfRaffles", this.numberOfRaffles);
if (this.generateOnInit) {
this.generateColors();
}
},
watch: {
numberOfRaffles: function() {
this.$emit("numberOfRaffles", this.numberOfRaffles);
this.generateColors();
}
},
methods: {
generateColors: function(event, time) {
this.generating = true;
if (time == 5) {
this.generating = false;
this.generated = true;
if (this.numberOfRaffles > 1 &&
[this.redCheckbox, this.greenCheckbox, this.yellowCheckbox, this.blueCheckbox].filter(value => value == true).length == 1) {
return
}
if (new Set(this.colors).size == 1) {
alert("BINGO");
}
this.emitColors()
window.ga('send', {
hitType: "event",
eventCategory: "Raffles",
eventAction: "Generate",
eventLabel: JSON.stringify(this.colors)
});
return;
}
if (time == undefined) {
time = 1;
}
this.colors = [];
this.blue = 0;
this.red = 0;
this.green = 0;
this.yellow = 0;
let randomArray = [];
if (this.redCheckbox) {
randomArray.push(1);
}
if (this.greenCheckbox) {
randomArray.push(2);
}
if (this.yellowCheckbox) {
randomArray.push(3);
}
if (this.blueCheckbox) {
randomArray.push(4);
}
if (randomArray.length == 0) {
alert("Du må velge MINST 1 farge");
return;
}
if (this.numberOfRaffles > 0) {
for (let i = 0; i < this.numberOfRaffles; i++) {
let color =
randomArray[Math.floor(Math.random() * randomArray.length)];
this.colors.push(color);
if (color == 1) {
this.red += 1;
}
if (color == 2) {
this.green += 1;
}
if (color == 3) {
this.yellow += 1;
}
if (color == 4) {
this.blue += 1;
}
}
}
clearTimeout(this.colorTimeout);
this.colorTimeout = setTimeout(() => {
this.generateColors(event, time + 1);
}, 50);
},
getRotation: function() {
let num = Math.floor(Math.random() * 15);
let neg = Math.floor(Math.random() * 2);
return neg == 0 ? -num : num;
},
getColorClass: function(number) {
if (number == 1) {
return "red";
}
if (number == 2) {
return "green";
}
if (number == 3) {
return "yellow";
}
if (number == 4) {
return "blue";
}
},
emitColors() {
this.$emit("colors", {
blue: this.blue,
red: this.red,
green: this.green,
yellow: this.yellow
});
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/variables.scss";
@import "../styles/global.scss";
@import "../styles/media-queries.scss";
.container {
margin: auto;
display: flex;
flex-direction: column;
}
.input-line {
margin: auto;
display: flex;
justify-content: space-around;
align-items: center;
margin-top: 2.4rem;
@include mobile {
margin-top: 1.2rem;
}
}
.input-line label {
padding: 0 6px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
font-size: 1.22rem;
margin: 0 0.6rem;
}
input,
button {
font-size: 1.5rem;
}
input {
font-size: 1.5rem;
padding: 7px;
margin: 0;
height: 3rem;
border: 1px solid rgba(#333333, 0.3);
}
input[type="checkbox"] {
display: none;
}
label .border {
border: 1px solid rgba(#333333, 0.3);
border-spacing: 2px;
margin-right: 8px;
display: flex;
justify-content: center;
align-items: center;
padding: 1px;
}
label .checkmark {
background: none;
display: inline-block;
width: 16px;
height: 16px;
padding: 2px;
}
label .text {
margin-left: 0.15rem;
margin-top: auto;
}
.colors {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
max-width: 1400px;
margin: 3rem auto 0;
@include mobile {
margin: 1.8rem auto 0;
}
}
.color-box {
width: 150px;
height: 150px;
margin: 20px;
-webkit-mask-image: url(/public/assets/images/lodd.svg);
background-repeat: no-repeat;
mask-image: url(/public/assets/images/lodd.svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@include mobile {
width: 60px;
height: 60px;
margin: 10px;
}
}
.colors-text {
display: flex;
flex-direction: row;
justify-content: space-around;
}
.color-count-container {
margin: auto;
width: 300px;
justify-content: space-around;
align-items: center;
display: flex;
font-family: Arial;
margin-top: 35px;
@include mobile {
width: 80vw;
}
}
.green {
background-color: $light-green;
}
.blue {
background-color: $light-blue;
}
.yellow {
background-color: $light-yellow;
}
.red {
background-color: $light-red;
}
.input-line label {
& input {
&#greenCheckbox:checked ~ .border .checkmark {
background-color: $light-green;
}
&#redCheckbox:checked ~ .border .checkmark {
background-color: $light-red;
}
&#yellowCheckbox:checked ~ .border .checkmark {
background-color: $light-yellow;
}
&#blueCheckbox:checked ~ .border .checkmark {
background-color: $light-blue;
}
}
@include desktop {
&:hover input {
&#greenCheckbox ~ .border .checkmark {
background-color: $green;
}
&#redCheckbox ~ .border .checkmark {
background-color: $red;
}
&#yellowCheckbox ~ .border .checkmark {
background-color: $yellow;
}
&#blueCheckbox ~ .border .checkmark {
background-color: $blue;
}
}
&:focus input,
&:active input {
&#greenCheckbox ~ .border .checkmark {
background-color: $dark-green;
}
&#redCheckbox ~ .border .checkmark {
background-color: $dark-red;
}
&#yellowCheckbox ~ .border .checkmark {
background-color: $dark-yellow;
}
&#blueCheckbox ~ .border .checkmark {
background-color: $dark-blue;
}
}
}
}
@include mobile {
input,
button {
font-size: 1.4rem;
line-height: 1.4rem;
}
input {
width: 45vw;
height: 3rem;
}
p {
padding: 0 15px;
}
.input-line {
flex-wrap: wrap;
label {
width: 30%;
margin-top: 15px;
justify-content: flex-start;
}
}
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<Wine :wine="wine">
<template v-slot:top>
<div class="flex justify-end">
<div class="requested-count cursor-pointer" @click="request">
<span>{{ requestedElement.count }}</span>
<i class="icon icon--heart" :class="{ 'active': locallyRequested }" />
</div>
</div>
</template>
<template v-slot:default>
<button @click="deleteWine(wine)" v-if="showDeleteButton == true" class="vin-button small danger width-100">
Slett vinen
</button>
</template>
<template v-slot:bottom>
<div class="float-left request">
<i class="icon icon--heart request-icon" :class="{ 'active': locallyRequested }"></i>
<a aria-role="button" tabindex="0" class="link" @click="request"
:class="{ 'active': locallyRequested }">
{{ locallyRequested ? 'Anbefalt' : 'Anbefal' }}
</a>
</div>
</template>
</Wine>
</template>
<script>
import { deleteRequestedWine, requestNewWine } from "@/api";
import Wine from "@/ui/Wine";
export default {
components: {
Wine
},
data(){
return {
wine: this.requestedElement.wine,
locallyRequested: false
}
},
props: {
requestedElement: {
required: true,
type: Object
},
showDeleteButton: {
required: false,
type: Boolean,
default: false
}
},
methods: {
request(){
if (this.locallyRequested)
return
console.log("requesting", this.wine)
this.locallyRequested = true
this.requestedElement.count = this.requestedElement.count +1
requestNewWine(this.wine)
},
async deleteWine() {
const wine = this.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>
@import "@/styles/variables";
.requested-count {
display: flex;
align-items: center;
margin-top: -0.5rem;
background-color: rgb(244,244,244);
border-radius: 1.1rem;
padding: 0.25rem 1rem;
font-size: 1.25em;
span {
padding-right: 0.5rem;
line-height: 1.25em;
}
.icon--heart{
color: grey;
}
}
.active {
&.link {
border-color: $link-color
}
&.icon--heart {
color: $link-color;
}
}
.request {
display: flex;
align-items: center;
&-icon {
font-size: 1.5rem;
color: grey;
}
a {
margin-left: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<div>
<h2 v-if="errorMessage">{{ errorMessage }}</h2>
<video playsinline autoplay class="hidden"></video>
</div>
</template>
<script>
import { BrowserBarcodeReader } from "@zxing/library";
import { barcodeToVinmonopolet } from "@/api";
export default {
name: "Scan to vinnopolet",
data() {
return {
video: undefined,
errorMessage: undefined
};
},
mounted() {
if (navigator.mediaDevices == undefined) {
this.errorMessage = "Feil: Ingen kamera funnet.";
return;
}
setTimeout(() => {
this.video = document.querySelector("video");
this.scrollIntoView();
const constraints = {
audio: false,
video: {
facingMode: "environment"
}
};
navigator.mediaDevices
.getUserMedia(constraints)
.then(this.handleSuccess)
.catch(this.handleError);
}, 10);
},
methods: {
handleSuccess(stream) {
this.video.classList.remove("hidden");
this.video.srcObject = stream;
this.searchVideoForBarcode(this.video);
},
handleError(error) {
console.log(
"navigator.MediaDevices.getUserMedia error: ",
error.message,
error.name
);
this.errorMessage =
"Feil ved oppstart av kamera! Feilmelding: " + error.message;
},
searchVideoForBarcode(video) {
const codeReader = new BrowserBarcodeReader();
codeReader
.decodeOnceFromVideoDevice(undefined, video)
.then(result => {
barcodeToVinmonopolet(result.text)
.then(this.emitWineFromVinmonopolet)
.catch(this.catchVinmonopoletError)
.then(this.searchVideoForBarcode(video));
})
.catch(err => console.error(err));
},
emitWineFromVinmonopolet(response) {
this.errorMessage = "";
this.$emit("wine", {
name: response.name,
vivinoLink: "https://vinmonopolet.no" + response.url,
price: response.price.value,
image: response.images[1].url,
country: response.main_country.name,
id: Number(response.code)
});
},
catchVinmonopoletError(error) {
this.errorMessage = "Feil! " + error.message || error;
},
scrollIntoView() {
window.scrollTo(
0,
document.getElementById("addwine-title").offsetTop - 10
);
}
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/variables";
@import "@/styles/global";
video {
width: 100%;
margin: 1rem 0;
}
.hidden {
height: 0px;
}
h2 {
width: 100%;
margin: 0 auto;
text-align: center;
color: $red;
}
</style>

81
frontend/ui/Tabs.vue Normal file
View File

@@ -0,0 +1,81 @@
<template>
<div>
<div class="tab-container">
<div
class="tab"
v-for="(tab, index) in tabs"
:key="index"
@click="changeTab(index)"
:class="chosenTab == index ? 'active' : null"
>{{ tab.name }}</div>
</div>
<div class="tab-elements">
<component :is="tabs[chosenTab].component" />
</div>
</div>
</template>
<script>
import eventBus from "@/mixins/EventBus";
export default {
props: {
tabs: {
type: Array
},
active: {
type: Number,
default: 0
}
},
beforeMount() {
this.chosenTab = this.active;
},
data() {
return {
chosenTab: 0
};
},
methods: {
changeTab: function(num) {
this.chosenTab = num;
this.$emit("tabChange", num);
eventBus.$emit("tab-change");
}
}
};
</script>
<style lang="scss" scoped>
h1 {
text-align: center;
}
.tab-container {
display: flex;
flex-direction: row;
justify-content: center;
margin-top: 25px;
border-bottom: 1px solid #333333;
}
.tab {
cursor: pointer;
font-size: 1.2rem;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
margin: 0 15px;
border: 1px solid #333333;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
background: #00000008;
border-bottom: 1px solid #333333;
margin-bottom: -1px;
&.active {
border-bottom: 1px solid white;
background: white;
}
}
</style>

99
frontend/ui/TextToast.vue Normal file
View File

@@ -0,0 +1,99 @@
<template>
<div class="update-toast" :class="showClass">
<span v-html="text"></span>
<div class="button-container">
<button @click="closeToast">Lukk</button>
</div>
</div>
</template>
<script>
export default {
props: {
text: { type: String, required: true },
refreshButton: { type: Boolean, required: false }
},
data() {
return { showClass: null };
},
created() {
this.showClass = "show";
},
mounted() {
if (this.refreshButton) {
return;
}
setTimeout(() => {
this.$emit("closeToast");
}, 5000);
},
methods: {
refresh: function() {
location.reload();
},
closeToast: function() {
this.$emit("closeToast");
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.scss";
.update-toast {
position: fixed;
bottom: 1.3rem;
left: 0;
right: 0;
margin: auto;
background: #2d2d2d;
border-radius: 5px;
padding: 15px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 80vw;
opacity: 0;
pointer-events: none;
&.show {
pointer-events: all;
opacity: 1;
}
-webkit-transition: opacity 0.5s ease-in-out;
-moz-transition: opacity 0.5s ease-in-out;
-ms-transition: opacity 0.5s ease-in-out;
-o-transition: opacity 0.5s ease-in-out;
transition: opacity 0.5s ease-in-out;
@include mobile {
width: 85vw;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
& span {
color: white;
}
& .button-container {
& button {
color: #2d2d2d;
background-color: white;
border-radius: 5px;
padding: 10px;
margin: 0 3px;
font-size: 0.8rem;
height: max-content;
&:active {
background: #2d2d2d;
color: white;
}
}
}
}
</style>

216
frontend/ui/TotalBought.vue Normal file
View File

@@ -0,0 +1,216 @@
<template>
<section class="outer-bought">
<h3>Loddstatistikk</h3>
<div class="total-raffles">
Totalt&nbsp;
<span class="total">{{ total }}</span>
&nbsp;kjøpte,&nbsp;
<span>{{ totalWin }}&nbsp;vinn og&nbsp;</span>
<span> {{ stolen }} stjålet </span>
</div>
<div class="bought-container">
<div
v-for="color in colors"
:class="
color.name +
'-container ' +
color.name +
'-raffle raffle-element-local'
"
:key="color.name"
>
<p class="winner-chance">
{{translate(color.name)}} vinnersjanse
</p>
<span class="win-percentage">{{ color.totalPercentage }}% </span>
<p class="total-bought-color">{{ color.total }} kjøpte</p>
<p class="amount-of-wins"> {{ color.win }} vinn </p>
</div>
</div>
</section>
</template>
<script>
import { colorStatistics } from "@/api";
export default {
data() {
return {
colors: [],
red: 0,
blue: 0,
yellow: 0,
green: 0,
total: 0,
totalWin: 0,
stolen: 0,
wins: 0,
redPercentage: 0,
yellowPercentage: 0,
greenPercentage: 0,
bluePercentage: 0
};
},
async mounted() {
let response = await colorStatistics();
this.red = response.red;
this.blue = response.blue;
this.green = response.green;
this.yellow = response.yellow;
this.total = response.total;
this.totalWin =
this.red.win + this.yellow.win + this.blue.win + this.green.win;
this.stolen = response.stolen;
this.redPercentage = this.round(
this.red.win == 0 ? 0 : (this.red.win / this.totalWin) * 100
);
this.greenPercentage = this.round(
this.green.win == 0 ? 0 : (this.green.win / this.totalWin) * 100
);
this.bluePercentage = this.round(
this.blue.win == 0 ? 0 : (this.blue.win / this.totalWin) * 100
);
this.yellowPercentage = this.round(
this.yellow.win == 0 ? 0 : (this.yellow.win / this.totalWin) * 100
);
this.colors.push({
name: "red",
total: this.red.total,
win: this.red.win,
totalPercentage: this.getPercentage(this.red.win, this.totalWin),
percentage: this.getPercentage(this.red.win, this.red.total)
});
this.colors.push({
name: "blue",
total: this.blue.total,
win: this.blue.win,
totalPercentage: this.getPercentage(this.blue.win, this.totalWin),
percentage: this.getPercentage(this.blue.win, this.blue.total)
});
this.colors.push({
name: "yellow",
total: this.yellow.total,
win: this.yellow.win,
totalPercentage: this.getPercentage(this.yellow.win, this.totalWin),
percentage: this.getPercentage(this.yellow.win, this.yellow.total)
});
this.colors.push({
name: "green",
total: this.green.total,
win: this.green.win,
totalPercentage: this.getPercentage(this.green.win, this.totalWin),
percentage: this.getPercentage(this.green.win, this.green.total)
});
this.colors = this.colors.sort((a, b) => (a.win > b.win ? -1 : 1));
},
methods: {
translate(color){
switch(color) {
case "blue":
return "Blå"
break;
case "red":
return "Rød"
break;
case "green":
return "Grønn"
break;
case "yellow":
return "Gul"
break;
break;
}
},
getPercentage: function(win, total) {
return this.round(win == 0 ? 0 : (win / total) * 100);
},
round: function(number) {
//this can make the odds added together more than 100%, maybe rework?
let actualPercentage = Math.round(number * 100) / 100;
let rounded = actualPercentage.toFixed(0);
return rounded;
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
@import "../styles/global.scss";
@include mobile{
section {
margin-top: 5em;
}
}
.total-raffles {
display: flex;
}
.bought-container {
margin-top: 2em;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 50px;
.raffle-element-local {
height: 250px;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
@include raffle;
.win-percentage {
margin-left: 30px;
font-size: 50px;
}
p {
margin-left: 30px;
&.winner-chance {
margin-top: 40px;
}
&.total-bought-color{
font-weight: bold;
margin-top: 25px;
}
&.amount-of-wins {
font-weight: bold;
}
}
h3 {
margin-left: 15px;
}
&.green-raffle {
background-color: $light-green;
}
&.blue-raffle {
background-color: $light-blue;
}
&.yellow-raffle {
background-color: $light-yellow;
}
&.red-raffle {
background-color: $light-red;
}
}
}
</style>

100
frontend/ui/UpdateToast.vue Normal file
View File

@@ -0,0 +1,100 @@
<template>
<div class="update-toast" :class="showClass">
<span>{{text}}</span>
<div class="button-container">
<button v-if="refreshButton" @click="refresh">Refresh</button>
<button @click="closeToast">Lukk</button>
</div>
</div>
</template>
<script>
export default {
props: {
text: { type: String, required: true },
refreshButton: { type: Boolean, required: false }
},
data() {
return { showClass: null };
},
created() {
this.showClass = "show";
},
mounted() {
if (this.refreshButton) {
return;
}
setTimeout(() => {
this.$emit("closeToast");
}, 5000);
},
methods: {
refresh: function() {
location.reload();
},
closeToast: function() {
this.$emit("closeToast");
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.scss";
.update-toast {
position: fixed;
bottom: 10px;
left: 0;
right: 0;
margin: auto;
background: #2d2d2d;
border-radius: 5px;
padding: 15px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 50vw;
opacity: 0;
pointer-events: none;
&.show {
pointer-events: all;
opacity: 1;
}
-webkit-transition: opacity 0.5s ease-in-out;
-moz-transition: opacity 0.5s ease-in-out;
-ms-transition: opacity 0.5s ease-in-out;
-o-transition: opacity 0.5s ease-in-out;
transition: opacity 0.5s ease-in-out;
@include mobile {
width: 85vw;
bottom: 0px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
& span {
color: white;
}
& .button-container {
& button {
color: #2d2d2d;
background-color: white;
border-radius: 5px;
padding: 10px;
margin: 0 3px;
font-size: 0.8rem;
&:active {
background: #2d2d2d;
color: white;
}
}
}
}
</style>

212
frontend/ui/Vipps.vue Normal file
View File

@@ -0,0 +1,212 @@
<template>
<div>
<div
class="vipps-container"
:class="isMobile ? 'clickable' : null"
@click="openVipps"
>
<img
src="/public/assets/images/vipps-logo.svg"
class="vipps-logo"
alt="vipps logo"
/>
<span v-if="amount * price > price">
kr.
<span class="big-money">{{ amount * price }},-</span>
({{ price }},- pr. lodd)
</span>
<span v-if="amount * price == price">
kr.
<span class="big-money">{{ amount * price }},-</span>
pr. lodd
</span>
<ing
src="/public/assets/images/vipps-qr.png"
class="qr-logo"
v-if="qrFailed"
/>
<canvas v-if="!qrFailed" ref="canvas" class="qr-logo"></canvas>
<span class="phone-number">{{ phone }}</span>
<span class="name">{{ name }}</span>
<span class="mark-with">Merk med: {{ message }}</span>
</div>
<p class="click-to-open-text" v-if="isMobile">
<i>Du kan også klikke QR-koden for å åpne i Vipps</i>
</p>
</div>
</template>
<script>
import QRCode from "qrcode";
export default {
props: {
amount: {
type: Number,
default: 1
}
},
data() {
return {
qrImage: null,
qrFailed: false,
phone: __PHONE__,
name: __NAME__,
price: __PRICE__,
message: __MESSAGE__
};
},
watch: {
amount: function(price) {
this.calculateQr();
}
},
mounted() {
this.calculateQr();
},
computed: {
isMobile: function() {
return this.isMobileFunction();
},
priceToPay: function() {
return this.amount * (this.price * 100);
},
vippsUrlBasedOnUserAgent: function() {
if (navigator.userAgent.includes("iPhone")) {
return (
"https://qr.vipps.no/28/2/01/031/47" +
this.phone.replace(/ /g, "") +
"?v=1&m=" +
this.message +
"&a=" +
this.priceToPay
);
}
return (
"https://qr.vipps.no/28/2/01/031/47" +
this.phone.replace(/ /g, "") +
"?v=1&m=" +
this.message
);
}
},
methods: {
calculateQr: function() {
let canvas = this.$refs["canvas"];
QRCode.toCanvas(
canvas,
this.vippsUrlBasedOnUserAgent,
{ errorCorrectionLevel: "Q" },
(err, url) => {
if (err != null) {
this.qrFailed = true;
}
}
);
this.drawLogoOverCanvas(canvas);
},
drawLogoOverCanvas(canvas) {
const context = canvas.getContext("2d");
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
context.font = "30px Arial";
context.textAlign = "center";
context.textBaseline = "middle";
context.arc(centerX, centerY, 25, 0, 2 * Math.PI, false);
context.fillStyle = "white";
context.fill();
context.lineWidth = 3;
context.strokeStyle = "#fe5b23";
context.stroke();
context.fillText("🍾", centerX, centerY);
},
openVipps: function() {
if (!this.isMobileFunction()) {
return;
}
window.location.assign(this.vippsUrlBasedOnUserAgent);
},
isMobileFunction: function() {
if (
navigator.userAgent.match(/Android/i) ||
navigator.userAgent.match(/webOS/i) ||
navigator.userAgent.match(/iPhone/i) ||
navigator.userAgent.match(/iPad/i) ||
navigator.userAgent.match(/iPod/i) ||
navigator.userAgent.match(/BlackBerry/i) ||
navigator.userAgent.match(/Windows Phone/i)
) {
return true;
} else {
return false;
}
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/media-queries.scss";
.vipps-container {
font-family: Arial;
border-radius: 10px;
background-color: #ff5b23;
display: flex;
flex-direction: column;
color: white;
text-align: center;
padding: 25px;
width: 250px;
margin: auto 0;
}
.clickable {
cursor: pointer;
}
.big-money {
font-size: 1.5rem;
font-weight: bold;
}
.vipps-logo {
padding-bottom: 10px;
}
.phone-number {
font-size: 1.75rem;
font-weight: bold;
padding-top: 20px;
padding-bottom: 10px;
}
.qr-logo {
margin-top: 15px;
border-radius: 10px;
width: 220px;
margin: 15px auto auto auto;
}
.name,
.mark-with {
font-weight: 600;
font-size: 1rem;
}
@include mobile {
.vipps-container {
margin-left: 0px;
margin: auto;
}
.click-to-open-text {
width: 65%;
padding-top: 10px;
margin: auto;
text-align: center;
}
}
</style>

112
frontend/ui/WinGraph.vue Normal file
View File

@@ -0,0 +1,112 @@
<template>
<div class="chart">
<canvas ref="win-chart"></canvas>
</div>
</template>
<script>
import { chartWinsByColor } from "@/api";
export default {
async mounted() {
let canvas = this.$refs["win-chart"].getContext("2d");
let response = await chartWinsByColor();
let labels = ["Vunnet"];
let blue = {
label: "Blå",
borderColor: "#57d2fb",
backgroundColor: "#d4f2fe",
borderWidth: 2,
data: []
};
let yellow = {
label: "Gul",
borderColor: "#ffde5d",
backgroundColor: "#fff6d6",
borderWidth: 2,
data: []
};
let red = {
label: "Rød",
borderColor: "#ef5878",
backgroundColor: "#fbd7de",
borderWidth: 2,
data: []
};
let green = {
label: "Grønn",
borderColor: "#10e783",
backgroundColor: "#c8f9df",
borderWidth: 2,
data: []
};
blue.data.push(response.blue.win);
yellow.data.push(response.yellow.win);
red.data.push(response.red.win);
green.data.push(response.green.win);
let highestNumber = 0;
if (response.blue.win > highestNumber) {
highestNumber = response.blue.win;
}
if (response.red.win > highestNumber) {
highestNumber = response.red.win;
}
if (response.green.win > highestNumber) {
highestNumber = response.green.win;
}
if (response.yellow.win > highestNumber) {
highestNumber = response.yellow.win;
}
let datasets = [blue, yellow, green, red];
let chartdata = {
labels: labels,
datasets: datasets
};
let chart = new Chart(canvas, {
type: "bar",
data: chartdata,
options: {
animation: {
duration: 0 // general animation time
},
maintainAspectRatio: false,
title: {
display: true,
text: "Antall vinn",
fontSize: 20
},
legend: {
labels: {
padding: 12,
boxWidth: 20,
usePointStyle: true
}
},
scales: {
yAxes: [
{
ticks: {
beginAtZero: true,
suggestedMax: highestNumber + 5
}
}
]
}
}
});
}
};
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.scss";
.chart {
height: 40vh;
max-height: 500px;
width: 100%;
}
</style>

138
frontend/ui/Wine.vue Normal file
View File

@@ -0,0 +1,138 @@
<template>
<div class="wine">
<slot name="top"></slot>
<div class="wine-image">
<img
v-if="wine.image && loadImage"
:src="wine.image"
/>
<img v-else class="wine-placeholder" alt="Wine image" />
</div>
<div class="wine-details">
<span v-if="wine.name" class="wine-name">{{ wine.name }}</span>
<span v-if="wine.rating"><b>Rating:</b> {{ wine.rating }}</span>
<span v-if="wine.price"><b>Pris:</b> {{ wine.price }} NOK</span>
<span v-if="wine.country"><b>Land:</b> {{ wine.country }}</span>
</div>
<slot></slot>
<div class="bottom-section">
<slot name="bottom"></slot>
<a v-if="wine.vivinoLink" :href="wine.vivinoLink" class="link float-right">
Les mer
</a>
</div>
</div>
</template>
<script>
export default {
props: {
wine: {
type: Object,
required: true
}
},
data() {
return {
loadImage: false
}
},
methods: {
setImage(entries) {
const { target, isIntersecting } = entries[0];
if (!isIntersecting) return;
this.loadImage = true;
this.observer.unobserve(target);
}
},
created() {
this.observer = new IntersectionObserver(this.setImage, {
root: this.$el,
threshold: 0
})
},
mounted() {
this.observer.observe(this.$el);
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/media-queries";
@import "@/styles/variables";
.wine {
padding: 1rem;
box-sizing: border-box;
position: relative;
-webkit-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
-moz-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
@include tablet {
width: 250px;
height: 100%;
}
}
.wine-image {
display: flex;
justify-content: center;
margin-top: 10px;
img {
height: 250px;
@include mobile {
object-fit: cover;
max-width: 90px;
}
}
.wine-placeholder {
height: 250px;
width: 70px;
}
}
.wine-details {
display: flex;
flex-direction: column;
> span {
margin-bottom: 0.5rem;
}
}
.wine-name{
font-size: 20px;
margin: 1em 0;
}
.wine-details {
display: flex;
flex-direction: column;
}
.bottom-section {
width: 100%;
margin-top: 1rem;
.link {
color: $matte-text-color;
font-family: Arial;
font-size: 1.2rem;
cursor: pointer;
font-weight: normal;
border-bottom: 2px solid $matte-text-color;
&:hover {
font-weight: normal;
border-color: $link-color;
}
}
}
</style>

166
frontend/ui/Wines.vue Normal file
View File

@@ -0,0 +1,166 @@
<template>
<div v-if="wines.length > 0" class="wines-main-container">
<div class="info-and-link">
<h3>
Topp 5 viner
</h3>
<router-link to="viner">
<span class="vin-link">Se alle viner </span>
</router-link>
</div>
<div class="wine-container">
<Wine v-for="wine in wines" :key="wine" :wine="wine">
<template v-slot:top>
<div class="flex justify-end">
<div class="requested-count cursor-pointer">
<span> {{ wine.occurences }} </span>
<i class="icon icon--heart" />
</div>
</div>
</template>
</Wine>
</div>
</div>
</template>
<script>
import Wine from "@/ui/Wine";
import { overallWineStatistics } from "@/api";
export default {
components: {
Wine
},
data() {
return {
wines: [],
clickedWine: null,
};
},
async mounted() {
let response = await overallWineStatistics();
response.sort();
response = response
.filter(wine => wine.name != null && wine.name != "")
.sort(
this.predicate(
{
name: "occurences",
reverse: true
},
{
name: "rating",
reverse: true
}
)
);
this.wines = response.slice(0, 5);
},
methods: {
predicate: function() {
var fields = [],
n_fields = arguments.length,
field,
name,
cmp;
var default_cmp = function(a, b) {
if (a == undefined) a = 0;
if (b == undefined) b = 0;
if (a === b) return 0;
return a < b ? -1 : 1;
},
getCmpFunc = function(primer, reverse) {
var dfc = default_cmp,
// closer in scope
cmp = default_cmp;
if (primer) {
cmp = function(a, b) {
return dfc(primer(a), primer(b));
};
}
if (reverse) {
return function(a, b) {
return -1 * cmp(a, b);
};
}
return cmp;
};
// preprocess sorting options
for (var i = 0; i < n_fields; i++) {
field = arguments[i];
if (typeof field === "string") {
name = field;
cmp = default_cmp;
} else {
name = field.name;
cmp = getCmpFunc(field.primer, field.reverse);
}
fields.push({
name: name,
cmp: cmp
});
}
// final comparison function
return function(A, B) {
var name, result;
for (var i = 0; i < n_fields; i++) {
result = 0;
field = fields[i];
name = field.name;
result = field.cmp(A[name], B[name]);
if (result !== 0) break;
}
return result;
};
}
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/variables.scss";
@import "@/styles/global.scss";
@import "../styles/media-queries.scss";
.wines-main-container {
margin-bottom: 10em;
}
.info-and-link{
display: flex;
justify-content: space-between;
}
.wine-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 2rem;
.requested-count {
display: flex;
align-items: center;
margin-top: -0.5rem;
background-color: rgb(244,244,244);
border-radius: 1.1rem;
padding: 0.25rem 1rem;
font-size: 1.25em;
span {
padding-right: 0.5rem;
line-height: 1.25em;
}
.icon--heart{
font-size: 1.5rem;
color: $link-color;
}
}
}
</style>

217
frontend/ui/WinnerDraw.vue Normal file
View File

@@ -0,0 +1,217 @@
<template>
<div class="current-drawn-container">
<div class="current-draw" v-if="drawing">
<h2>TREKKER</h2>
<div
:class="currentColor + '-raffle'"
class="raffle-element center-new-winner"
:style="{ transform: 'rotate(' + getRotation() + 'deg)' }"
>
<span v-if="currentName && colorDone">{{ currentName }}</span>
</div>
<br />
<br />
<br />
<br />
<br />
</div>
<div class="current-draw" v-if="drawingDone">
<h2>VINNER</h2>
<div
:class="currentColor + '-raffle'"
class="raffle-element center-new-winner"
:style="{ transform: 'rotate(' + getRotation() + 'deg)' }"
>
<span v-if="currentName && colorDone">{{ currentName }}</span>
</div>
<br />
<br />
<br />
<br />
<br />
</div>
</div>
</template>
<script>
import confetti from "canvas-confetti";
export default {
props: {
currentWinner: {
type: Object
},
currentWinnerDrawn: {
type: Boolean
},
attendees: {
type: Array
}
},
data() {
return {
currentWinnerLocal: {},
winnerColor: null,
currentColor: null,
winnerName: null,
currentName: null,
colorRounds: 0,
nameRounds: 0,
colorTimeout: null,
nameTimeout: null,
colorDone: false,
drawing: false,
drawingDone: false,
winnerQueue: []
};
},
watch: {
currentWinner: function(currentWinner) {
if (currentWinner == null) {
this.drawingDone = false;
return;
}
if (this.drawing) {
this.winnerQueue.push(currentWinner);
return;
}
this.drawing = true;
this.currentName = null;
this.currentColor = null;
this.nameRounds = 0;
this.colorRounds = 0;
this.colorDone = false;
this.currentWinnerLocal = currentWinner;
this.drawColor(this.currentWinnerLocal.color);
}
},
methods: {
drawName: function(winnerName) {
if (this.nameRounds == 100) {
clearTimeout(this.nameTimeout);
this.currentName = winnerName;
if (this.winnerQueue.length > 0) {
this.currentWinnerLocal = this.winnerQueue.shift();
this.drawing = true;
this.nameRounds = 0;
this.colorRounds = 0;
this.colorDone = false;
this.drawColor(this.currentWinnerLocal.color);
return;
}
this.drawing = false;
this.drawingDone = true;
this.startConfetti(this.currentName);
return;
}
this.currentName = this.attendees[
this.nameRounds % this.attendees.length
].name;
this.nameRounds += 1;
clearTimeout(this.nameTimeout);
this.nameTimeout = setTimeout(() => {
this.drawName(winnerName);
}, 50);
},
drawColor: function(winnerColor) {
this.drawingDone = false;
if (this.colorRounds == 100) {
this.currentColor = winnerColor;
this.colorDone = true;
this.drawName(this.currentWinnerLocal.name);
clearTimeout(this.colorTimeout);
return;
}
this.currentColor = this.getColor(this.colorRounds % 4);
this.colorRounds += 1;
clearTimeout(this.colorTimeout);
this.colorTimeout = setTimeout(() => {
this.drawColor(winnerColor);
}, 50);
},
getRotation: function() {
if (this.colorDone) {
return 0;
}
let num = Math.floor(Math.random() * 15);
let neg = Math.floor(Math.random() * 2);
return neg == 0 ? -num : num;
},
getColor: function(number) {
switch (number) {
case 0:
return "red";
case 1:
return "blue";
case 2:
return "green";
case 3:
return "yellow";
}
},
startConfetti(currentName){
//duration is computed as x * 1000 miliseconds, in this case 7*1000 = 7000 miliseconds ==> 7 seconds.
var duration = 7 * 1000;
var animationEnd = Date.now() + duration;
var defaults = { startVelocity: 50, spread: 160, ticks: 50, zIndex: 0, particleCount: 20};
var uberDefaults = { startVelocity: 65, spread: 75, zIndex: 0, particleCount: 35}
function randomInRange(min, max) {
return Math.random() * (max - min) + min;
}
var interval = setInterval(function() {
var timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
if(currentName == "Amund Brandsrud"){
runCannon(uberDefaults, {x: 1, y: 1 }, {angle: 135});
runCannon(uberDefaults, {x: 0, y: 1 }, {angle: 45});
runCannon(uberDefaults, {y: 1 }, {angle: 90});
runCannon(uberDefaults, {x: 0 }, {angle: 45});
runCannon(uberDefaults, {x: 1 }, {angle: 135});
}else{
runCannon(defaults, {x: 0 }, {angle: 45});
runCannon(defaults, {x: 1 }, {angle: 135});
runCannon(defaults, {y: 1 }, {angle: 90});
}
}, 250);
function runCannon(confettiDefaultValues, originPoint, launchAngle){
confetti(Object.assign({}, confettiDefaultValues, {origin: originPoint }, launchAngle))
}
},
}
};
</script>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
h2 {
text-align: center;
}
.current-drawn-container {
display: flex;
justify-content: center;
align-items: center;
}
.raffle-element {
width: 140px;
height: 140px;
font-size: 1.2rem;
display: flex;
justify-content: center;
align-items: center;
font-size: 0.75rem;
text-align: center;
}
</style>

55
frontend/ui/Winners.vue Normal file
View File

@@ -0,0 +1,55 @@
<template>
<div>
<h2 v-if="winners.length > 0"> {{ title ? title : 'Vinnere' }}</h2>
<div class="winners" v-if="winners.length > 0">
<div v-for="(winner, index) in winners" :key="index">
<router-link :to="`/highscore/${ encodeURIComponent(winner.name) }`">
<div :class="winner.color + '-raffle'" class="raffle-element">{{ winner.name }}</div>
</router-link>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
winners: {
type: Array
},
title: {
type: String,
required: false
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
h2 {
text-align: center;
}
.winners {
display: flex;
flex-flow: wrap;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
}
.raffle-element {
font-size: 1rem;
width: 145px;
height: 145px;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
text-align: center;
}
</style>