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

103
frontend/Vinlottis.vue Normal file
View File

@@ -0,0 +1,103 @@
<template>
<div class="app-container">
<banner :routes="routes"/>
<router-view />
<Footer />
<UpdateToast
v-if="showToast"
:text="toastText"
:refreshButton="refreshToast"
v-on:closeToast="closeToast"
/>
</div>
</template>
<script>
import ServiceWorkerMixin from "@/mixins/ServiceWorkerMixin";
import banner from "@/ui/Banner";
import Footer from "@/ui/Footer";
import UpdateToast from "@/ui/UpdateToast";
export default {
name: "vinlottis",
components: { banner, UpdateToast, Footer },
props: {},
data() {
return {
showToast: false,
toastText: null,
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"
},
{
name: "Highscore",
route: "/highscore"
}
]
};
},
mounted() {
console.log("SNEAKY PETE!");
this.$on("service-worker-updated", () => {
this.toastText = "Det er ny oppdatering av siden, vil du oppdatere?";
this.showToast = true;
this.refreshToast = true;
});
this.$on("push-allowed", () => {
this.toastText = "Push-notifications er skrudd på!";
this.refreshToast = false;
this.showToast = true;
});
},
computed: {},
mixins: [ServiceWorkerMixin],
methods: {
closeToast: function() {
this.showToast = false;
},
}
};
</script>
<style lang="scss">
@import "styles/global.scss";
@import "styles/positioning.scss";
@import "styles/vinlottis-icons";
body {
background-color: $primary;
}
</style>
<style lang="scss" scoped>
.app-container {
background-color: white;
min-height: 100vh;
display: grid;
grid-template-rows: 80px auto 100px;
.main-container{
height: 100%;
width: 100%;
}
}
</style>

395
frontend/api.js Normal file
View File

@@ -0,0 +1,395 @@
import fetch from "node-fetch";
const BASE_URL = __APIURL__ || window.location.origin;
const statistics = () => {
const url = new URL("/api/purchase/statistics", BASE_URL);
return fetch(url.href).then(resp => resp.json());
};
const colorStatistics = () => {
const url = new URL("/api/purchase/statistics/color", BASE_URL);
return fetch(url.href).then(resp => resp.json());
};
const highscoreStatistics = () => {
const url = new URL("/api/highscore/statistics", BASE_URL);
return fetch(url.href).then(resp => resp.json());
};
const overallWineStatistics = () => {
const url = new URL("/api/wines/statistics/overall", BASE_URL);
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") == "true";
return Promise.all([resp.json(), isAdmin]);
});
};
const chartWinsByColor = () => {
const url = new URL("/api/purchase/statistics/color", BASE_URL);
return fetch(url.href).then(resp => resp.json());
};
const chartPurchaseByColor = () => {
const url = new URL("/api/purchase/statistics", BASE_URL);
return fetch(url.href).then(resp => resp.json());
};
const prelottery = () => {
const url = new URL("/api/wines/prelottery", BASE_URL);
return fetch(url.href).then(resp => resp.json());
};
const sendLottery = sendObject => {
const url = new URL("/api/lottery", BASE_URL);
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify(sendObject)
};
return fetch(url.href, options).then(resp => resp.json());
};
const sendLotteryWinners = sendObject => {
const url = new URL("/api/lottery/winners", BASE_URL);
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify(sendObject)
};
return fetch(url.href, options).then(resp => resp.json());
};
const addAttendee = sendObject => {
const url = new URL("/api/virtual/attendee/add", BASE_URL);
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify(sendObject)
};
return fetch(url.href, options).then(resp => resp.json());
};
const getVirtualWinner = () => {
const url = new URL("/api/virtual/winner/draw", BASE_URL);
return fetch(url.href).then(resp => resp.json());
};
const attendeesSecure = () => {
const url = new URL("/api/virtual/attendee/all/secure", BASE_URL);
return fetch(url.href).then(resp => resp.json());
};
const winnersSecure = () => {
const url = new URL("/api/virtual/winner/all/secure", BASE_URL);
return fetch(url.href).then(resp => resp.json());
};
const winners = () => {
const url = new URL("/api/virtual/winner/all", BASE_URL);
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);
const options = {
headers: {
"Content-Type": "application/json"
},
method: "DELETE"
};
return fetch(url.href, options).then(resp => resp.json());
};
const deleteAttendees = () => {
const url = new URL("/api/virtual/attendee/all", BASE_URL);
const options = {
headers: {
"Content-Type": "application/json"
},
method: "DELETE"
};
return fetch(url.href, options).then(resp => resp.json());
};
const attendees = () => {
const url = new URL("/api/virtual/attendee/all", BASE_URL);
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);
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify(wines)
};
return fetch(url.href, options).then(resp => resp.json());
};
const wineSchema = () => {
const url = new URL("/api/wineinfo/schema", BASE_URL);
return fetch(url.href).then(resp => resp.json());
};
const barcodeToVinmonopolet = id => {
const url = new URL("/api/wineinfo/" + id, 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 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();
} else {
console.error("Unexpected error occured when login/register user:", resp);
throw await resp.json();
}
};
const login = (username, password) => {
const url = new URL("/api/login", BASE_URL);
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify({ username, password })
};
return fetch(url.href, options).then(resp => {
if (resp.ok) {
return resp.json();
} else {
return handleErrors(resp);
}
});
};
const register = (username, password) => {
const url = new URL("/api/register", BASE_URL);
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify({ username, password })
};
return fetch(url.href, options).then(resp => {
if (resp.ok) {
return resp.json();
} else {
return handleErrors(resp);
}
});
};
const getChatHistory = (page=1, limit=10) => {
const url = new URL("/api/chat/history", BASE_URL);
if (!isNaN(page)) url.searchParams.append("page", page);
if (!isNaN(limit)) url.searchParams.append("limit", limit);
return fetch(url.href).then(resp => resp.json());
};
const finishedDraw = () => {
const url = new URL("/api/virtual/finish", BASE_URL);
const options = {
method: 'POST'
}
return fetch(url.href, options).then(resp => resp.json());
};
const getAmIWinner = id => {
const url = new URL(`/api/winner/${id}`, BASE_URL);
return fetch(url.href).then(resp => resp.json());
};
const postWineChosen = (id, wineName) => {
const url = new URL(`/api/winner/${id}`, BASE_URL);
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify({ wineName: wineName })
};
return fetch(url.href, options).then(resp => {
if (resp.ok) {
return resp.json();
} else {
return handleErrors(resp);
}
});
};
const historyAll = () => {
const url = new URL(`/api/lottery/all`, BASE_URL);
return fetch(url.href).then(resp => {
if (resp.ok) {
return resp.json();
} else {
return handleErrors(resp);
}
});
}
const historyByDate = (date) => {
const url = new URL(`/api/lottery/by-date/${ date }`, BASE_URL);
return fetch(url.href).then(resp => {
if (resp.ok) {
return resp.json();
} else {
return handleErrors(resp);
}
});
}
const getWinnerByName = (name) => {
const encodedName = encodeURIComponent(name)
const url = new URL(`/api/lottery/by-name/${name}`, BASE_URL);
return fetch(url.href).then(resp => {
if (resp.ok) {
return resp.json();
} else {
return handleErrors(resp);
}
})
}
export {
statistics,
colorStatistics,
highscoreStatistics,
overallWineStatistics,
chartWinsByColor,
chartPurchaseByColor,
prelottery,
sendLottery,
sendLotteryWinners,
logWines,
wineSchema,
barcodeToVinmonopolet,
searchForWine,
requestNewWine,
allRequestedWines,
login,
register,
addAttendee,
getVirtualWinner,
attendeesSecure,
attendees,
winners,
winnersSecure,
deleteWinners,
deleteAttendees,
deleteRequestedWine,
getChatHistory,
finishedDraw,
getAmIWinner,
postWineChosen,
historyAll,
historyByDate,
getWinnerByName
};

View File

@@ -0,0 +1,34 @@
<template>
<div>
<h1>Admin-side</h1>
<Tabs :tabs="tabs" />
</div>
</template>
<script>
import Tabs from "@/ui/Tabs";
import RegisterPage from "@/components/RegisterPage";
import VirtualLotteryRegistrationPage from "@/components/VirtualLotteryRegistrationPage";
export default {
components: {
Tabs,
RegisterPage,
VirtualLotteryRegistrationPage
},
data() {
return {
tabs: [
{ name: "Registrering", component: RegisterPage },
{ name: "Virtuelt lotteri", component: VirtualLotteryRegistrationPage }
]
};
}
};
</script>
<style lang="scss" scoped>
h1 {
text-align: center;
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<main class="container">
<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.wine._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>
@import "@/styles/media-queries.scss";
@import "@/styles/variables.scss";
.container {
width: 90vw;
margin: 3rem auto;
margin-bottom: 0;
padding-bottom: 3rem;
}
h1 {
font-size: 3rem;
font-family: "knowit";
color: $matte-text-color;
font-weight: normal;
}
.requested-wines-container{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 2rem;
}
</style>

View File

@@ -0,0 +1,130 @@
<template>
<div class="container">
<h1 class="">Alle viner</h1>
<div id="wines-container">
<Wine :wine="wine" v-for="(wine, _, index) in wines" :key="wine._id">
<div class="winners-container">
<span class="label">Vinnende lodd:</span>
<div class="flex row">
<span class="raffle-element blue-raffle">{{ wine.blue == null ? 0 : wine.blue }}</span>
<span class="raffle-element red-raffle">{{ wine.red == null ? 0 : wine.red }}</span>
<span class="raffle-element green-raffle">{{ wine.green == null ? 0 : wine.green }}</span>
<span class="raffle-element yellow-raffle">{{ wine.yellow == null ? 0 : wine.yellow }}</span>
</div>
<div class="name-wins">
<span class="label">Vunnet av:</span>
<ul class="names">
<li v-for="(winner, index) in wine.winners">
<router-link class="vin-link" :to="`/highscore/` + winner">{{ winner }}</router-link>
-&nbsp;
<router-link class="vin-link" :to="winDateUrl(wine.dates[index])">{{ dateString(wine.dates[index]) }}</router-link>
</li>
</ul>
</div>
</div>
</Wine>
</div>
</div>
</template>
<script>
import Banner from "@/ui/Banner";
import Wine from "@/ui/Wine";
import { overallWineStatistics } from "@/api";
import { dateString } from "@/utils";
export default {
components: {
Banner,
Wine
},
data() {
return {
wines: []
};
},
methods: {
winDateUrl(date) {
const timestamp = new Date(date).getTime();
return `/history/${timestamp}`
},
dateString: dateString
},
async mounted() {
this.wines = await overallWineStatistics();
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/media-queries";
@import "@/styles/variables";
.container {
width: 90vw;
margin: 3rem auto;
margin-bottom: 0;
padding-bottom: 4rem;
}
h1 {
font-size: 3rem;
font-family: "knowit";
font-weight: normal;
font-family: knowit, Arial;
margin-bottom: 25px;
}
.label {
font-weight: 600;
}
#wines-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 2rem;
> div {
justify-content: flex-start;
margin-bottom: 2rem;
}
}
.name-wins {
display: flex;
flex-direction: column;
a {
font-weight: normal;
&:not(:hover) {
border-color: transparent;
}
}
ul {
padding-left: 0;
li {
padding-left: 1.5rem;
list-style: none;
&:before {
content: "- ";
margin-left: -0.5rem;
}
}
}
}
.raffle-element {
padding: 1rem;
font-size: 1.3rem;
margin: 3px;
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div class="outer">
<h2>Vinlottis brukerregistering</h2>
<form aria-label="User registration" @submit.prevent>
<div class="label-div">
<label>Brukernavn</label>
<input type="text"
v-model="username"
placeholder="Brukernavn"
autocapitalize="none"
@keyup.enter="submit" />
</div>
<div class="label-div row">
<label>Passord</label>
<input type="password" v-model="password" placeholder="Passord" @keyup.enter="submit" />
</div>
<button class="vin-button" @click="submit">Registrer bruker</button>
<div v-if="error" class="error">{{ error }}</div>
</form>
</div>
</template>
<script>
import { register } from "@/api";
export default {
data() {
return {
username: undefined,
password: undefined,
error: undefined
}
},
methods: {
submit() {
register(this.username, this.password)
.then(resp => this.$router.push("/"))
.catch(error => this.error = error.message || error)
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/loginAndRegister";
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="container">
<h1 class="title" @click="startCountdown">Loddgenerator</h1>
<p class="subtext">
Velg hvilke farger du vil ha, fyll inn antall lodd og klikk 'generer'
</p>
<RaffleGenerator @numberOfRaffles="val => this.numberOfRaffles = val" />
<Vipps class="vipps" :amount="numberOfRaffles" />
<Countdown :hardEnable="hardStart" @countdown="changeEnabled" />
</div>
</template>
<script>
import RaffleGenerator from "@/ui/RaffleGenerator";
import Vipps from "@/ui/Vipps";
import Countdown from "@/ui/Countdown";
export default {
components: {
RaffleGenerator,
Vipps,
Countdown
},
data() {
return {
hardStart: false,
numberOfRaffles: null
};
},
mounted() {
if (window.location.hostname == "localhost") {
return;
}
this.track();
},
methods: {
changeEnabled: function(way) {
this.hardStart = way;
},
startCountdown: function() {
this.hardStart = true;
},
track() {
window.ga('send', 'pageview', '/lottery/generate');
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/variables.scss";
@import "../styles/global.scss";
@import "../styles/media-queries.scss";
h1 {
cursor: pointer;
}
.header-link {
color: #333333;
text-decoration: none;
}
p {
text-align: center;
margin: 0;
}
.vipps {
margin: 5rem auto 2.5rem auto;
@include mobile {
margin-top: 2rem;
}
}
.container {
margin: auto;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<div class="container">
<h1>Vinlottis highscore</h1>
<div class="filter flex el-spacing">
<input type="text" v-model="filterInput" placeholder="Filtrer på navn" />
<button v-if="filterInput" @click="resetFilter" class="vin-button auto-height margin-left-sm">
Reset
</button>
</div>
<p class="highscore-header margin-bottom-md"><b>Plassering.</b> Navn - Antall vinn</p>
<ol v-if="highscore.length > 0" class="highscore-list">
<li v-for="person in filteredResults" @click="selectWinner(person)" @keydown.enter="selectWinner(person)" tabindex="0">
<b>{{ person.rank }}.</b>&nbsp;&nbsp;&nbsp;{{ person.name }} - {{ person.wins.length }}
</li>
</ol>
<div class="center desktop-only">
👈 Se dine vin(n), trykk navnet ditt
</div>
</div>
</template>
<script>
import { highscoreStatistics } from "@/api";
import { humanReadableDate, daysAgo } from "@/utils";
import Wine from "@/ui/Wine";
export default {
components: { Wine },
data() {
return {
highscore: [],
filterInput: ''
}
},
async mounted() {
let response = await highscoreStatistics();
response.sort((a, b) => {
return a.wins.length > b.wins.length ? -1 : 1;
});
response = response.filter(
person => person.name != null && person.name != ""
);
this.highscore = this.generateScoreBoard(response);
},
computed: {
filteredResults() {
let highscore = this.highscore;
let val = this.filterInput;
if (val.length) {
val = val.toLowerCase()
const nameIncludesString = (person) => person.name.toLowerCase().includes(val);
highscore = highscore.filter(nameIncludesString)
}
return highscore
}
},
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
})
},
resetFilter() {
this.filterInput = '';
document.getElementsByTagName('input')[0].focus();
},
selectWinner(winner) {
const path = "/highscore/" + encodeURIComponent(winner.name)
this.$router.push(path);
},
humanReadableDate: humanReadableDate,
daysAgo: daysAgo
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/media-queries.scss";
@import "@/styles/variables.scss";
$elementSpacing: 3.5rem;
.el-spacing {
margin-bottom: $elementSpacing;
}
.container {
width: 90vw;
margin: 3rem auto;
max-width: 1200px;
margin-bottom: 0;
padding-bottom: 3rem;
@include desktop {
width: 80vw;
}
}
h1 {
font-size: 3rem;
font-family: "knowit";
color: $matte-text-color;
font-weight: normal;
}
.filter input {
font-size: 1rem;
width: 100%;
border-color: black;
border-width: 1.5px;
padding: 0.75rem 1rem;
@include desktop {
width: 30%;
}
}
.highscore-header {
margin-bottom: 2rem;
font-size: 1.3rem;
color: $matte-text-color;
}
.highscore-list {
display: flex;
flex-direction: column;
padding-left: 0;
li {
width: fit-content;
display: inline-block;
margin-bottom: calc(1rem - 2px);
font-size: 1.25rem;
color: $matte-text-color;
cursor: pointer;
border-bottom: 2px solid transparent;
&:hover, &:focus {
border-color: $link-color;
}
}
}
.center {
position: absolute;
top: 40%;
right: 10vw;
max-width: 50vw;
font-size: 2.5rem;
background-color: $primary;
padding: 1rem 1rem;
border-radius: 8px;
font-style: italic;
@include widescreen {
right: 20vw;
}
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div>
<h1>Historie fra tidligere lotteri</h1>
<div v-if="lotteries.length || lotteries != null" v-for="lottery in lotteries">
<Winners :winners="lottery.winners" :title="`Vinnere fra ${ humanReadableDate(lottery.date) }`" />
</div>
</div>
</template>
<script>
import { historyByDate, historyAll } from '@/api'
import { humanReadableDate } from "@/utils";
import Winners from '@/ui/Winners'
export default {
name: 'History page of prev lotteries',
components: { Winners },
data() {
return {
lotteries: [],
}
},
methods: {
humanReadableDate: humanReadableDate
},
created() {
const dateFromUrl = this.$route.params.date;
if (dateFromUrl !== undefined)
historyByDate(dateFromUrl)
.then(history => this.lotteries = { "lottery": history })
else
historyAll()
.then(history => this.lotteries = history.lotteries)
}
}
</script>
<style lang="scss" scoped>
h1 {
text-align: center;
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<div class="outer">
<h2>Vinlottis brukerinnlogging</h2>
<form aria-label="User signin" @submit.prevent>
<div class="label-div">
<label>Brukernavn</label>
<input
type="text"
v-model="username"
placeholder="Brukernavn"
autocapitalize="none"
@keyup.enter="submit"
/>
</div>
<div class="label-div row">
<label>Passord</label>
<input type="password" v-model="password" placeholder="Passord" @keyup.enter="submit" />
</div>
<button class="vin-button" @click="submit">Logg inn</button>
<div v-if="error" class="error">{{ error }}</div>
</form>
</div>
</template>
<script>
import { login } from "@/api";
export default {
data() {
return {
username: undefined,
password: undefined,
error: undefined
};
},
methods: {
submit() {
login(this.username, this.password)
.then(resp => this.$router.push("admin"))
.catch(error => (this.error = error.message || error));
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/loginAndRegister";
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div>
<Tabs :tabs="tabs" :active="startRoute" v-on:tabChange="tabChanged" />
</div>
</template>
<script>
import Tabs from "@/ui/Tabs";
import GeneratePage from "@/components/GeneratePage";
import VirtualLotteryPage from "@/components/VirtualLotteryPage";
export default {
components: {
Tabs
},
data() {
return {
startRoute: 0
};
},
beforeMount() {
switch (this.$router.currentRoute.params.tab) {
case "generate":
this.startRoute = 0;
break;
case "game":
this.startRoute = 1;
break;
default:
this.startRoute = 0;
}
},
data() {
return {
tabs: [
{ name: "Loddgenerator", component: GeneratePage },
{ name: "Virtuelt lotteri", component: VirtualLotteryPage }
]
};
},
methods: {
tabChanged: function(num) {
if (num == 0) {
this.$router.push("/lottery/generate");
} else if (num == 1) {
this.$router.push("/lottery/game");
}
}
}
};
</script>
<style lang="scss" scoped>
h1 {
text-align: center;
}
</style>

View File

@@ -0,0 +1,273 @@
<template>
<div class="container">
<h1>Vinlottis highscore</h1>
<div class="backdrop">
<a @click="navigateBack" @keydown.enter="navigateBack" tabindex="0">
<span class="vin-link navigate-back">Tilbake til {{ previousRoute.name }}</span>
</a>
<section v-if="winner">
<h2 class="name">{{ winner.name }}</h2>
<p class="win-count el-spacing">{{ numberOfWins }} vinn</p>
<h4 class="margin-bottom-0">Vinnende farger:</h4>
<div class="raffle-container el-spacing">
<div class="raffle-element" :class="color + `-raffle`" v-for="[color, occurences] in Object.entries(winningColors)" :key="color">
{{ occurences }}
</div>
</div>
<h4 class="el-spacing">Flasker vunnet:</h4>
<div v-for="win in winner.highscore" :key="win._id">
<router-link :to="winDateUrl(win.date)" class="days-ago">
{{ humanReadableDate(win.date) }} - {{ daysAgo(win.date) }} dager siden
</router-link>
<div class="won-wine">
<img :src="smallerWineImage(win.wine.image)">
<div class="won-wine-details">
<h3>{{ win.wine.name }}</h3>
<a :href="win.wine.vivinoLink" class="vin-link no-margin">
Les mer vinmonopolet.no
</a>
</div>
<div class="raffle-element small" :class="win.color + `-raffle`"></div>
</div>
</div>
</section>
<h2 v-else-if="error" class="error">
{{ error }}
</h2>
</div>
</div>
</template>
<script>
import { getWinnerByName } from "@/api";
import { humanReadableDate, daysAgo } from "@/utils";
export default {
data() {
return {
winner: undefined,
error: undefined,
previousRoute: {
default: true,
name: "topplisten",
path: "/highscore"
}
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
if (from.name != null)
vm.previousRoute = from
})
},
computed: {
numberOfWins() {
return this.winner.highscore.length
}
},
created() {
const nameFromURL = this.$route.params.name;
getWinnerByName(nameFromURL)
.then(winner => this.setWinner(winner))
.catch(err => this.error = `Ingen med navn: "${nameFromURL}" funnet.`)
},
methods: {
setWinner(winner) {
this.winner = {
name: winner.name,
highscore: [],
...winner
}
this.winningColors = this.findWinningColors()
},
smallerWineImage(image) {
if (image && image.includes(`515x515`))
return image.replace(`515x515`, `175x175`)
return image
},
findWinningColors() {
const colors = this.winner.highscore.map(win => win.color)
const colorOccurences = {}
colors.forEach(color => {
if (colorOccurences[color] == undefined) {
colorOccurences[color] = 1
} else {
colorOccurences[color] += 1
}
})
return colorOccurences
},
winDateUrl(date) {
const timestamp = new Date(date).getTime();
return `/history/${timestamp}`
},
navigateBack() {
if (this.previousRoute.default) {
this.$router.push({ path: this.previousRoute.path });
} else {
this.$router.go(-1);
}
},
humanReadableDate: humanReadableDate,
daysAgo: daysAgo
}
}
</script>
<style lang="scss" scoped>
@import "@/styles/variables";
@import "@/styles/media-queries";
$elementSpacing: 3rem;
.el-spacing {
margin-bottom: $elementSpacing;
}
.navigate-back {
font-weight: normal;
font-size: 1.2rem;
border-width: 2px;
border-color: gray;
}
.container {
width: 90vw;
margin: 3rem auto;
margin-bottom: 0;
padding-bottom: 3rem;
max-width: 1200px;
@include desktop {
width: 80vw;
}
}
h1 {
font-size: 3rem;
font-family: "knowit";
font-weight: normal;
}
.name {
text-transform: capitalize;
font-size: 3.5rem;
font-family: "knowit";
font-weight: normal;
margin: 2rem 0 1rem 0;
}
.error {
font-size: 2.5rem;
font-weight: normal;
}
.win-count {
font-size: 1.5rem;
margin-top: 0;
}
.raffle-container {
display: flex;
margin-top: 1rem;
div:not(:last-of-type) {
margin-right: 1.5rem;
}
}
.raffle-element {
width: 5rem;
height: 4rem;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5rem;
margin-top: 0;
&.small {
height: 40px;
width: 40px;
}
}
.days-ago {
color: $matte-text-color;
border-bottom: 2px solid transparent;
&:hover {
border-color: $link-color;
}
}
.won-wine {
--spacing: 1rem;
background-color: white;
margin: var(--spacing) 0 3rem 0;
padding: var(--spacing);
position: relative;
@include desktop {
flex-direction: row;
}
img {
margin: 0 3rem;
height: 160px;
}
&-details {
vertical-align: top;
display: inline-block;
@include tablet {
width: calc(100% - 160px - 80px);
}
& > * {
width: 100%;
}
h3 {
font-size: 1.5rem;
font-weight: normal;
color: $matte-text-color;
}
a {
font-size: 1.2rem;
border-width: 2px;
font-weight: normal;
}
}
.raffle-element {
position: absolute;
top: calc(var(--spacing) * 2);
right: calc(var(--spacing) * 2);
margin: 0;
}
}
.backdrop {
$background: rgb(244,244,244);
--padding: 2rem;
@include desktop {
--padding: 5rem;
}
background-color: $background;
padding: var(--padding);
}
</style>

View File

@@ -0,0 +1,723 @@
<template>
<div class="page-container">
<h1>Registrering</h1>
<br />
<br />
<div class="notification-element">
<div class="label-div">
<label for="notification">Push-melding</label>
<textarea
id="notification"
type="text"
rows="3"
v-model="pushMessage"
placeholder="Push meldingtekst"
/>
<input id="notification-link" type="text" v-model="pushLink" placeholder="Push-click link" />
</div>
</div>
<div class="button-container">
<button class="vin-button" @click="sendPush">Send push</button>
</div>
<hr />
<h2 id="addwine-title">Prelottery</h2>
<ScanToVinmonopolet @wine="wineFromVinmonopoletScan" v-if="showCamera" />
<div class="button-container">
<button
class="vin-button"
@click="showCamera = !showCamera"
>{{ showCamera ? "Skjul camera" : "Legg til vin med camera" }}</button>
<button class="vin-button" @click="addWine">Legg til en vin manuelt</button>
</div>
<div v-if="wines.length > 0" class="edit-container">
<wine v-for="wine in wines" :key="key" :wine="wine">
<div class="edit">
<div class="button-container row">
<button
class="vin-button"
@click="editWine = amIBeingEdited(wine) ? false : wine"
>{{ amIBeingEdited(wine) ? "Lukk" : "Rediger" }}</button>
<button class="red vin-button" @click="deleteWine(wine)">Slett</button>
</div>
<div v-if="amIBeingEdited(wine)" class="wine-edit">
<div class="label-div" v-for="key in Object.keys(wine)" :key="key">
<label>{{ key }}</label>
<input type="text" v-model="wine[key]" :placeholder="key" />
</div>
</div>
</div>
</wine>
</div>
<div class="button-container" v-if="wines.length > 0">
<button class="vin-button" @click="sendWines">Send inn viner</button>
</div>
<hr />
<h2>Lottery</h2>
<h3>Legg til lodd kjøpt</h3>
<div class="colors">
<div v-for="color in lotteryColors" :class="color.css + ' colors-box'" :key="color">
<div class="colors-overlay">
<p>{{ color.name }} kjøpt</p>
<input v-model="color.value" min="0" :placeholder="0" type="number" />
</div>
</div>
<div class="label-div">
<label>Totalt kjøpt for:</label>
<input v-model="payed" placeholder="NOK" type="number" :step="price || 1" min="0" />
</div>
</div>
<div class="button-container">
<button class="vin-button" @click="submitLottery">Send inn lotteri</button>
</div>
<h3>Vinnere</h3>
<a class="wine-link" @click="fetchColorsAndWinners()">Refresh data fra virtuelt lotteri</a>
<div class="winner-container" v-if="winners.length > 0">
<wine v-for="winner in winners" :key="winner" :wine="winner.wine">
<div class="winner-element">
<div class="color-selector">
<div class="label-div">
<label>Farge vunnet</label>
</div>
<button
class="blue"
:class="{ active: winner.color == 'blue' }"
@click="winner.color = 'blue'"
></button>
<button
class="red"
:class="{ active: winner.color == 'red' }"
@click="winner.color = 'red'"
></button>
<button
class="green"
:class="{ active: winner.color == 'green' }"
@click="winner.color = 'green'"
></button>
<button
class="yellow"
:class="{ active: winner.color == 'yellow' }"
@click="winner.color = 'yellow'"
></button>
</div>
<div class="label-div">
<label for="winner-name">Navn vinner</label>
<input id="winner-name" type="text" placeholder="Navn" v-model="winner.name" />
</div>
</div>
<div class="label-div">
<label for="potential-winner-name">Virtuelle vinnere</label>
<select
id="potential-winner-name"
type="text"
placeholder="Navn"
v-model="winner.potentialWinner"
@change="potentialChange($event, winner)"
>
<option
v-for="fetchedWinner in fetchedWinners"
:value="stringify(fetchedWinner)"
>{{fetchedWinner.name}}</option>
</select>
</div>
</wine>
<div class="button-container column">
<button class="vin-button" @click="submitLotteryWinners">Send inn vinnere</button>
<button class="vin-button" @click="resetWinnerDataInStorage">Reset local wines</button>
</div>
</div>
<TextToast v-if="showToast" :text="toastText" v-on:closeToast="showToast = false" />
</div>
</template>
<script>
import eventBus from "@/mixins/EventBus";
import { dateString } from '@/utils'
import {
prelottery,
sendLotteryWinners,
sendLottery,
logWines,
wineSchema,
winnersSecure,
attendees
} from "@/api";
import TextToast from "@/ui/TextToast";
import Wine from "@/ui/Wine";
import ScanToVinmonopolet from "@/ui/ScanToVinmonopolet";
export default {
components: { TextToast, Wine, ScanToVinmonopolet },
data() {
return {
payed: undefined,
winners: [],
fetchedWinners: [],
wines: [],
pushMessage: "",
pushLink: "/",
toastText: undefined,
showToast: false,
showCamera: false,
editWine: false,
lotteryColors: [
{ value: null, name: "Blå", css: "blue" },
{ value: null, name: "Rød", css: "red" },
{ value: null, name: "Grønn", css: "green" },
{ value: null, name: "Gul", css: "yellow" }
],
price: __PRICE__
};
},
created() {
this.fetchAndAddPrelotteryWines().then(this.getWinnerdataFromStorage);
window.addEventListener("unload", this.setWinnerdataToStorage);
},
beforeDestroy() {
this.setWinnerdataToStorage();
eventBus.$off("tab-change", () => {
this.fetchColorsAndWinners();
});
},
mounted() {
this.fetchColorsAndWinners();
eventBus.$on("tab-change", () => {
this.fetchColorsAndWinners();
});
},
methods: {
stringify(json) {
return JSON.stringify(json);
},
potentialChange(event, winner) {
let data = JSON.parse(event.target.value);
winner.name = data.name;
winner.color = data.color;
},
async fetchColorsAndWinners() {
let winners = await winnersSecure();
let _attendees = await attendees();
let colors = {
red: 0,
blue: 0,
green: 0,
yellow: 0
};
this.payed = 0;
for (let i = 0; i < _attendees.length; i++) {
let attendee = _attendees[i];
colors.red += attendee.red;
colors.blue += attendee.blue;
colors.green += attendee.green;
colors.yellow += attendee.yellow;
this.payed +=
(attendee.red + attendee.blue + attendee.green + attendee.yellow) *
10;
}
for (let i = 0; i < this.lotteryColors.length; i++) {
let currentColor = this.lotteryColors[i];
switch (currentColor.css) {
case "red":
currentColor.value = colors.red;
break;
case "blue":
currentColor.value = colors.blue;
break;
a;
case "green":
currentColor.value = colors.green;
break;
case "yellow":
currentColor.value = colors.yellow;
break;
}
}
this.fetchedWinners = winners;
},
amIBeingEdited(wine) {
return this.editWine.id == wine.id && this.editWine.name == wine.name;
},
async fetchAndAddPrelotteryWines() {
const wines = await prelottery();
for (let i = 0; i < wines.length; i++) {
let wine = wines[i];
this.winners.push({
name: "",
color: "",
potentialWinner: "",
wine: {
name: wine.name,
vivinoLink: wine.vivinoLink,
rating: wine.rating,
image: wine.image,
id: wine.id
}
});
}
},
wineFromVinmonopoletScan(wineResponse) {
if (this.wines.map(wine => wine.name).includes(wineResponse.name)) {
this.toastText = "Vinen er allerede lagt til.";
this.showToast = true;
return;
}
this.toastText = "Fant og la til vin:<br>" + wineResponse.name;
this.showToast = true;
this.wines.unshift(wineResponse);
},
sendPush: async function() {
let _response = await fetch("/subscription/send-notification", {
headers: {
"Content-Type": "application/json"
// 'Content-Type': 'application/x-www-form-urlencoded',
},
method: "POST",
body: JSON.stringify({ message: this.pushMessage, link: this.pushLink })
});
let response = await _response.json();
if (response) {
alert("Sendt!");
} else {
alert("Noe gikk galt!");
}
},
addWine: async function(event) {
const wine = await wineSchema();
this.editWine = wine;
this.wines.unshift(wine);
},
deleteWine(deletedWine) {
this.wines = this.wines.filter(wine => wine.name != deletedWine.name);
},
sendWines: async function() {
let response = await logWines(this.wines);
if (response.success == true) {
alert("Sendt!");
window.location.reload();
} else {
alert("Noe gikk galt under innsending");
}
},
addWinner: function(event) {
this.winners.push({
name: "",
color: "",
wine: {
name: "",
vivinoLink: "",
rating: ""
}
});
},
submitLottery: async function(event) {
const colors = {
red: this.lotteryColors.filter(c => c.css == "red")[0].value,
green: this.lotteryColors.filter(c => c.css == "green")[0].value,
blue: this.lotteryColors.filter(c => c.css == "blue")[0].value,
yellow: this.lotteryColors.filter(c => c.css == "yellow")[0].value
};
let sendObject = {
lottery: {
date: dateString(new Date()),
...colors
}
};
if (sendObject.lottery.red == undefined) {
alert("Rød må defineres");
return;
}
if (sendObject.lottery.green == undefined) {
alert("Grønn må defineres");
return;
}
if (sendObject.lottery.yellow == undefined) {
alert("Gul må defineres");
return;
}
if (sendObject.lottery.blue == undefined) {
alert("Blå må defineres");
return;
}
sendObject.lottery.bought =
parseInt(colors.blue) +
parseInt(colors.red) +
parseInt(colors.green) +
parseInt(colors.yellow);
const stolen = sendObject.lottery.bought - parseInt(this.payed) / 10;
if (isNaN(stolen) || stolen == undefined) {
alert("Betalt må registreres");
return;
}
sendObject.lottery.stolen = stolen;
let response = await sendLottery(sendObject);
if (response == true) {
alert("Sendt!");
window.location.reload();
} else {
alert(response.message || "Noe gikk galt under innsending");
}
},
submitLotteryWinners: async function(event) {
let sendObject = {
lottery: {
date: dateString(new Date()),
winners: this.winners
}
}
if (sendObject.lottery.winners.length == 0) {
alert("Det må være med vinnere");
return;
}
for (let i = 0; i < sendObject.lottery.winners.length; i++) {
let currentWinner = sendObject.lottery.winners[i];
if (currentWinner.name == undefined || currentWinner.name == "") {
alert("Navn må defineres");
return;
}
if (currentWinner.color == undefined || currentWinner.color == "") {
alert("Farge må defineres");
return;
}
}
let response = await sendLotteryWinners(sendObject);
if (response == true) {
alert("Sendt!");
window.location.reload();
} else {
alert(response.message || "Noe gikk galt under innsending");
}
},
getWinnerdataFromStorage() {
let localWinners = localStorage.getItem("winners");
if (localWinners && this.winners.length) {
localWinners = JSON.parse(localWinners);
this.winners = this.winners.map(winner => {
const localWinnerMatch = localWinners.filter(
localWinner =>
localWinner.wine.name == winner.wine.name ||
localWinner.wine.id == winner.wine.id
);
if (localWinnerMatch.length > 0) {
winner.name = localWinnerMatch[0].name || winner.name;
winner.color = localWinnerMatch[0].color || winner.name;
}
return winner;
});
}
let localColors = localStorage.getItem("colorValues");
if (localColors) {
localColors = localColors.split(",");
this.lotteryColors.forEach((color, i) => {
const localColorValue = Number(localColors[i]);
color.value = localColorValue == 0 ? null : localColorValue;
});
}
},
setWinnerdataToStorage() {
localStorage.setItem("winners", JSON.stringify(this.winners));
localStorage.setItem(
"colorValues",
this.lotteryColors.map(color => Number(color.value))
);
window.removeEventListener("unload", this.setWinnerdataToStorage);
},
resetWinnerDataInStorage() {
this.winners = [];
this.fetchAndAddPrelotteryWines().then(resp => (this.winners = resp));
this.lotteryColors.map(color => (color.value = null));
window.location.reload();
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/media-queries.scss";
select {
margin: 0 0 auto;
height: 2rem;
min-width: 0;
width: 98%;
padding: 1%;
}
h1 {
width: 100%;
text-align: center;
font-family: knowit, Arial;
}
h2 {
width: 100%;
text-align: center;
font-size: 1.6rem;
font-family: knowit, Arial;
}
.wine-link {
color: #333333;
text-decoration: none;
font-weight: bold;
cursor: pointer;
border-bottom: 1px solid $link-color;
}
hr {
width: 90%;
margin: 2rem auto;
color: grey;
}
.button-container {
margin-top: 1rem;
}
.page-container {
padding: 0 1.5rem 3rem;
@include desktop {
max-width: 60vw;
margin: 0 auto;
}
}
.winner-container {
width: max-content;
max-width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.button-container {
width: 100%;
}
}
.edit-container {
margin-top: 2rem;
display: flex;
justify-content: center;
flex-direction: column;
}
.edit {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.notification-element {
margin-bottom: 2rem;
}
.winner-element {
display: flex;
flex-direction: column;
> div {
margin-bottom: 1rem;
}
@include mobile {
width: 100%;
}
}
.wine-element {
align-items: flex-start;
}
.generate-link {
color: #333333;
text-decoration: none;
display: block;
text-align: center;
margin-bottom: 0px;
}
.wine-edit {
width: 100%;
margin-top: 1.5rem;
label {
margin-top: 0.75rem;
margin-bottom: 0;
}
}
.color-selector {
margin-bottom: 0.65rem;
margin-right: 1rem;
@include desktop {
min-width: 175px;
}
@include mobile {
max-width: 25vw;
}
.active {
border: 2px solid unset;
&.green {
border-color: $green;
}
&.blue {
border-color: $dark-blue;
}
&.red {
border-color: $red;
}
&.yellow {
border-color: $dark-yellow;
}
}
button {
border: 2px solid transparent;
display: inline-flex;
flex-wrap: wrap;
flex-direction: row;
height: 2.5rem;
width: 2.5rem;
// disable-dbl-tap-zoom
touch-action: manipulation;
@include mobile {
margin: 2px;
}
&.green {
background: #c8f9df;
}
&.blue {
background: #d4f2fe;
}
&.red {
background: #fbd7de;
}
&.yellow {
background: #fff6d6;
}
}
}
.colors {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
max-width: 1400px;
margin: 3rem auto 1rem;
@include mobile {
margin: 1.8rem auto 0;
}
.label-div {
margin-top: 0.5rem;
width: 100%;
}
}
.colors-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: 120px;
height: 120px;
margin: 10px;
}
}
.colors-overlay {
display: flex;
justify-content: center;
height: 100%;
padding: 0 0.5rem;
position: relative;
p {
margin: 0;
font-size: 0.8rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
font-weight: 600;
position: absolute;
top: 0.4rem;
left: 0.5rem;
}
input {
width: 70%;
border: 0;
padding: 0;
font-size: 3rem;
height: unset;
max-height: unset;
position: absolute;
bottom: 1.5rem;
}
}
.green,
.green .colors-overlay > input {
background-color: $light-green;
color: $green;
}
.blue,
.blue .colors-overlay > input {
background-color: $light-blue;
color: $blue;
}
.yellow,
.yellow .colors-overlay > input {
background-color: $light-yellow;
color: $yellow;
}
.red,
.red .colors-overlay > input {
background-color: $light-red;
color: $red;
}
</style>

View File

@@ -0,0 +1,237 @@
<template>
<section class="main-container">
<Modal
v-if="showModal"
modalText="Ønsket ditt har blitt lagt til"
:buttons="modalButtons"
@click="emitFromModalButton"
></Modal>
<h1>
Foreslå en vin!
</h1>
<section class="search-container">
<section class="search-section">
<input type="text" v-model="searchString" @keyup.enter="fetchWineFromVin()" placeholder="Søk etter en vin du liker her!🍷" class="search-input-field">
<button :disabled="!searchString" @click="fetchWineFromVin()" class="vin-button">Søk</button>
</section>
<section v-for="(wine, index) in this.wines" :key="index" class="single-result">
<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>
<button class="vin-button" @click="request(wine)">Foreslå denne</button>
<a
v-if="wine.vivinoLink"
:href="wine.vivinoLink"
class="wine-link"
>Les mer</a>
</section>
<p v-if="this.wines && this.wines.length == 0">
Fant ingen viner med det navnet!
</p>
</section>
</section>
</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 "@/styles/media-queries";
@import "@/styles/global";
@import "@/styles/variables";
h1{
text-align: center;
}
.main-container{
margin: auto;
max-width: 1200px;
}
input[type="text"] {
width: 90%;
color: black;
border-radius: 4px;
padding: 1rem 1rem;
border: 1px solid black;
max-width: 90%;
}
.search-container{
margin: 1rem;
}
.search-section{
display: grid;
grid: 1fr / 1fr .2fr;
@include mobile{
.vin-button{
display: none;
}
.search-input-field{
grid-column: 1 / -1;
}
}
}
.single-result{
margin-top: 1rem;
display: grid;
grid: 1fr / .5fr 2fr .5fr .5fr;
grid-template-areas: "picture details button-left button-right";
justify-items: center;
align-items: center;
grid-gap: 1em;
padding-bottom: 1em;
margin-bottom: 1em;
box-shadow: 0 1px 0 0 rgba(0,0,0,0.2);
@include mobile{
grid: 1fr .5fr / .5fr 1fr;
grid-template-areas: "picture details"
"button-left button-right";
grid-gap: .5em;
.vin-button{
grid-area: button-right;
padding: .5em;
font-size: 1em;
line-height: 1em;
height: 2em;
}
.wine-link{
grid-area: button-left;
}
h2{
font-size: 1em;
max-width: 80%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis
}
}
.wine-image {
height: 100px;
grid-area: picture;
}
.wine-placeholder {
height: 100px;
width: 70px;
grid-area: picture;
}
.wine-info{
grid-area: details;
width: 100%;
h2{
margin: 0;
}
.details{
top: 0;
display: flex;
flex-direction: column;
}
}
.wine-link {
grid-area: button-left;
color: #333333;
font-family: Arial;
text-decoration: none;
font-weight: bold;
border-bottom: 1px solid $link-color;
height: 1.2em;
width: max-content;
}
.vin-button{
grid-area: button-right;
}
@include tablet{
h2{
font-size: 1.2em;
}
}
@include desktop{
h2{
font-size: 1.6em;
}
}
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div class="container">
<h1 class="title">Dagens viner</h1>
<div class="wines-container">
<Wine :wine="wine" v-for="wine in wines" :key="wine" />
</div>
</div>
</template>
<script>
import { prelottery } from "@/api";
import Banner from "@/ui/Banner";
import Wine from "@/ui/Wine";
export default {
components: {
Banner,
Wine
},
data() {
return {
wines: []
};
},
async mounted() {
prelottery().then(wines => this.wines = wines);
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/media-queries";
@import "@/styles/variables";
.wine-image {
height: 250px;
}
h1 {
font-family: knowit, Arial;
margin-bottom: 25px;
}
.wines-container {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
margin: 0 2rem;
@media (min-width: 1500px) {
max-width: 1500px;
margin: 0 auto;
}
@include mobile {
flex-direction: column;
}
}
h3 {
max-width: 30vw;
@include mobile {
max-width: 50vw;
}
}
.inner-wine-container {
display: flex;
flex-direction: row;
margin: auto;
width: 500px;
font-family: Arial;
margin-bottom: 30px;
@include desktop {
justify-content: center;
}
@include mobile {
width: auto;
}
}
.right {
display: flex;
flex-direction: column;
margin-bottom: 150px;
margin-left: 50px;
@include mobile {
margin-left: 2rem;
}
}
a,
a:focus,
a:hover,
a:visited {
color: #333333;
font-family: Arial;
text-decoration: none;
font-weight: bold;
}
.wine-link {
color: #333333;
font-family: Arial;
text-decoration: none;
font-weight: bold;
border-bottom: 1px solid $link-color;
width: fit-content;
}
</style>

View File

@@ -0,0 +1,382 @@
<template>
<main class="main-container">
<section class="top-container">
<div class="want-to-win">
<h1>
Vil du også vinne?
</h1>
<img
src="/public/assets/images/notification.svg"
alt="Notification-bell"
@click="requestNotificationAccess"
class="notification-request-button"
role="button"
v-if="notificationAllowed"
/>
</div>
<router-link to="/lottery/game" class="participate-button">
<i class="icon icon--arrow-right"></i>
<p>Trykk her for å delta</p>
</router-link>
<router-link to="/lottery/generate" class="see-details-link">
Se vipps detaljer og QR-kode
</router-link>
<div class="icons-container">
<i class="icon icon--heart-sparks"></i>
<i class="icon icon--face-1"></i>
<i class="icon icon--face-3"></i>
<i class="icon icon--ballon"></i>
<i class="icon icon--bottle"></i>
<i class="icon icon--bottle"></i>
<i class="icon icon--bottle"></i>
<i class="icon icon--bottle"></i>
<i class="icon icon--bottle"></i>
</div>
</section>
<section class="content-container">
<div class="scroll-info">
<i class ="icon icon--arrow-long-right"></i>
<p>Scroll for å se vinnere og annen gøy statistikk</p>
</div>
<Highscore class="highscore"/>
<TotalBought class="total-bought" />
<section class="chart-container">
<PurchaseGraph class="purchase" />
<WinGraph class="win" />
</section>
<Wines class="wines-container" />
</section>
<Countdown :hardEnable="hardStart" @countdown="changeEnabled" />
</main>
</template>
<script>
import PurchaseGraph from "@/ui/PurchaseGraph";
import TotalBought from "@/ui/TotalBought";
import Highscore from "@/ui/Highscore";
import WinGraph from "@/ui/WinGraph";
import Wines from "@/ui/Wines";
import Vipps from "@/ui/Vipps";
import Countdown from "@/ui/Countdown";
import { prelottery } from "@/api";
export default {
components: {
PurchaseGraph,
TotalBought,
Highscore,
WinGraph,
Wines,
Vipps,
Countdown
},
data() {
return {
hardStart: false,
pushAllowed: false
};
},
computed: {
notificationAllowed: function() {
if (!("PushManager" in window)) {
return false;
}
return (
Notification.permission !== "granted" ||
!this.pushAllowed ||
localStorage.getItem("push") == null
);
}
},
async mounted() {
this.$on("push-allowed", () => {
this.pushAllowed = true;
});
if (window.location.hostname == "localhost") {
return;
}
this.track();
},
methods: {
requestNotificationAccess() {
this.$root.$children[0].registerServiceWorkerPushNotification();
},
changeEnabled(way) {
this.hardStart = way;
},
track() {
window.ga('send', 'pageview', '/');
},
startCountdown() {
this.hardStart = true;
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.scss";
@import "../styles/variables.scss";
.top-container {
height: 30em;
background-color: $primary;
width: 100vw;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-template-rows: repeat(12, 1fr);
align-items: center;
justify-items: start;
@include mobile{
padding-bottom: 2em;
height: 15em;
grid-template-rows: repeat(7, 1fr);
}
.want-to-win {
grid-row: 2 / 4;
grid-column: 2 / -1;
display: flex;
h1{
font-size: 2em;
font-weight: 400;
}
@include tablet {
h1{
font-size: 3em;
}
grid-row: 2 / 4;
grid-column: 3 / -3;
}
}
.notification-request-button{
cursor: pointer;
}
.participate-button {
grid-row: 4 / 6;
grid-column: 2 / -1;
background: inherit;
border: 4px solid black;
padding: 0 1em 0 1em;
display: flex;
width: 12.5em;
align-items: center;
text-decoration: none;
color: black;
i {
color: $link-color;
margin-left: 5px;
}
p {
font-size: 16px;
margin-left: 15px;
}
@include tablet {
grid-row: 4 / 6;
grid-column: 3 / -3;
}
}
.see-details-link {
grid-row: 6 / 8;
grid-column: 2 / -1;
@include tablet {
grid-row: 6 / 8;
grid-column: 2 / 10;
}
@include tablet {
grid-column: 3 / -3;
}
font-weight: bold;
color: black;
font-weight: 200;
font-size: 1.3em;
text-decoration: underline;
text-decoration-color: $link-color;
text-underline-position: under;
}
.icons-container {
grid-column: 1 / -1;
grid-row: 7 / -1;
@include mobile{
margin-top: 2em;
display: none;
}
@include tablet {
grid-row: 6 / -1;
grid-column: 7 / -1;
}
@include desktop{
grid-row: 4 / -3;
grid-column: 7 / 11;
}
@include widescreen {
grid-column: 6 / 10;
}
width: 100%;
min-width: 375px;
height: 100%;
display: grid;
grid: repeat(6, 1fr) / repeat(12, 1fr);
i {
font-size: 5em;
&.icon--heart-sparks{
grid-column: 2 / 4;
grid-row: 2 / 4;
align-self: center;
justify-self: center;
}
&.icon--face-1{
grid-column: 4 / 7;
grid-row: 2 / 4;
justify-self: center;
}
&.icon--face-3{
grid-column: 7 / 10;
grid-row: 1 / 4;
align-self: center;
}
&.icon--ballon{
grid-column: 9 / 11;
grid-row: 3 / 5;
}
&.icon--bottle{
grid-row: 4 / -1;
&:nth-of-type(5) {
grid-column: 4 / 5;
align-self: center;
}
&:nth-of-type(6) {
grid-column: 5 / 6;
}
&:nth-of-type(7) {
grid-column: 6 / 7;
align-self: center;
}
&:nth-of-type(8) {
grid-column: 7 / 8;
}
&:nth-of-type(9){
grid-column: 8 / 9;
align-self: center;
}
}
}
}
}
h1 {
text-align: center;
font-family: "knowit";
}
.to-lottery{
color: #333;
text-decoration: none;
display: block;
text-align: center;
margin-bottom: 0;
}
.content-container {
display: grid;
grid-template-columns: repeat(12, 1fr);
row-gap: 5em;
.scroll-info {
display: flex;
align-items: center;
column-gap: 10px;
grid-column: 2 / -2;
}
.chart-container {
display: flex;
width: 100%;
flex-direction: column;
grid-column: 2 / -2;
}
.total-bought {
grid-column: 2 / -2;
}
.highscore {
grid-column: 2 / -2;
}
.wines-container {
grid-column: 2 / -2;
}
.icon--arrow-long-right {
transform: rotate(90deg);
color: $link-color;
}
@include tablet {
.scroll-info{
grid-column: 3 / -3;
}
.chart-container {
grid-column: 3 / -3;
flex-direction: row;
}
.total-bought {
grid-column: 3 / -3;
}
.highscore {
grid-column: 3 / -3;
}
.wines-container {
grid-column: 3 / -3;
}
}
}
</style>

View File

@@ -0,0 +1,387 @@
<template>
<div>
<h1 class="title">Virtuelt lotteri</h1>
<h2
v-if="
attendees.length <= 0 &&
winners.length <= 0 &&
attendeesFetched &&
winnersFetched
"
>Her var det lite.. Sikker på at det er en virtuell trekning nå?</h2>
<div class="title-info">
<h2>Send vipps med melding "Vinlotteri" for å bli registrert til virtuelt lotteri</h2>
<p>Send gjerne melding om fargeønsker også</p>
</div>
<router-link to="/dagens" class="generate-link" v-if="todayExists">
Lurer du på dagens fangst?
<span class="subtext generator-link">Se her</span>
</router-link>
<hr />
<h2>Live oversikt av lodd kjøp i dag</h2>
<div class="colors">
<div v-for="color in Object.keys(ticketsBought)" :class="color + ' colors-box'" :key="color">
<div class="colors-overlay">
<p>{{ ticketsBought[color] }} kjøpt</p>
</div>
</div>
</div>
<WinnerDraw
:currentWinnerDrawn="currentWinnerDrawn"
:currentWinner="currentWinner"
:attendees="attendees"
/>
<Winners :winners="winners" />
<hr />
<div class="middle-elements">
<Attendees :attendees="attendees" class="outer-attendees" />
<Chat class="outer-chat" />
</div>
<Vipps class="vipps" :amount="1" />
</div>
</template>
<script>
import { attendees, winners, prelottery } from "@/api";
import Chat from "@/ui/Chat";
import Vipps from "@/ui/Vipps";
import Attendees from "@/ui/Attendees";
import Winners from "@/ui/Winners";
import WinnerDraw from "@/ui/WinnerDraw";
import io from "socket.io-client";
export default {
components: { Chat, Attendees, Winners, WinnerDraw, Vipps },
data() {
return {
attendees: [],
winners: [],
currentWinnerDrawn: false,
currentWinner: {},
socket: null,
attendeesFetched: false,
winnersFetched: false,
wasDisconnected: false,
ticketsBought: {}
};
},
mounted() {
this.track();
this.getAttendees();
this.getWinners();
const BASE_URL = __APIURL__ || window.location.origin;
this.socket = io(`${BASE_URL}`);
this.socket.on("color_winner", msg => {});
this.socket.on("disconnect", msg => {
this.wasDisconnected = true;
});
this.socket.on("winner", async msg => {
this.currentWinnerDrawn = true;
this.currentWinner = { name: msg.name, color: msg.color };
setTimeout(() => {
this.getWinners();
this.getAttendees();
this.currentWinner = null;
this.currentWinnerDrawn = false;
}, 12000);
});
this.socket.on("refresh_data", async msg => {
this.getAttendees();
this.getWinners();
});
this.socket.on("new_attendee", async msg => {
this.getAttendees();
});
},
beforeDestroy() {
this.socket.disconnect();
this.socket = null;
},
computed: {
todayExists: () => {
return prelottery()
.then(wines => wines.length > 0)
.catch(() => false);
}
},
methods: {
getWinners: async function() {
let response = await winners();
if (response) {
this.winners = response;
}
this.winnersFetched = true;
},
getAttendees: async function() {
let response = await attendees();
if (response) {
this.attendees = response;
if (this.attendees == undefined || this.attendees.length == 0) {
this.attendeesFetched = true;
return;
}
const addValueOfListObjectByKey = (list, key) =>
list.map(object => object[key]).reduce((a, b) => a + b);
this.ticketsBought = {
red: addValueOfListObjectByKey(response, "red"),
blue: addValueOfListObjectByKey(response, "blue"),
green: addValueOfListObjectByKey(response, "green"),
yellow: addValueOfListObjectByKey(response, "yellow")
};
}
this.attendeesFetched = true;
},
track() {
window.ga('send', 'pageview', '/lottery/game');
}
}
};
</script>
<!-- TODO move link styling to global with more generic name -->
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
.generate-link {
color: #333333;
text-decoration: none;
display: block;
width: 100vw;
text-align: center;
margin-bottom: 0px;
@include mobile {
width: 60vw;
margin: auto;
}
}
.vipps-image {
width: 250px;
margin: auto;
display: block;
margin-top: 30px;
}
.generator-link {
font-weight: bold;
border-bottom: 1px solid $link-color;
}
</style>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
.color-selector {
margin-bottom: 0.65rem;
margin-right: 1rem;
@include desktop {
min-width: 175px;
}
@include mobile {
max-width: 25vw;
}
.active {
border: 2px solid unset;
&.green {
border-color: $green;
}
&.blue {
border-color: $dark-blue;
}
&.red {
border-color: $red;
}
&.yellow {
border-color: $dark-yellow;
}
}
button {
border: 2px solid transparent;
display: inline-flex;
flex-wrap: wrap;
flex-direction: row;
height: 2.5rem;
width: 2.5rem;
// disable-dbl-tap-zoom
touch-action: manipulation;
@include mobile {
margin: 2px;
}
&.green {
background: #c8f9df;
}
&.blue {
background: #d4f2fe;
}
&.red {
background: #fbd7de;
}
&.yellow {
background: #fff6d6;
}
}
}
.colors {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
max-width: 1400px;
margin: 0 auto;
@include mobile {
margin: 1.8rem auto 0;
}
.label-div {
margin-top: 0.5rem;
width: 100%;
}
}
.colors-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: 120px;
height: 120px;
margin: 10px;
}
}
.colors-overlay {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
padding: 0 0.25rem;
position: relative;
p {
width: 70%;
border: 0;
padding: 0;
font-size: 1.5rem;
height: unset;
max-height: unset;
@include mobile {
font-size: 1.3rem;
}
}
}
.green,
.green .colors-overlay > input {
background-color: $light-green;
color: $green;
}
.blue,
.blue .colors-overlay > input {
background-color: $light-blue;
color: $blue;
}
.yellow,
.yellow .colors-overlay > input {
background-color: $light-yellow;
color: $yellow;
}
.red,
.red .colors-overlay > input {
background-color: $light-red;
color: $red;
}
</style>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
hr {
width: 80%;
}
h1,
h2 {
text-align: center;
}
.current-draw {
margin: auto;
}
.title-info {
width: 100%;
text-align: center;
}
.outer-chat {
margin: 0 60px 0 10px;
@include mobile {
margin: 0;
}
}
.outer-attendees {
margin: 0 10px 0 45px;
@include mobile {
margin: 0;
}
}
.center-new-winner {
margin: auto !important;
}
.middle-elements {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 400px;
@include mobile {
height: auto;
flex-direction: column;
}
}
.vipps {
margin-top: 70px;
display: flex;
padding-bottom: 50px;
justify-content: center;
@include mobile {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,439 @@
<template>
<div class="page-container">
<h1 class="title">Virtuelt lotteri registrering</h1>
<br />
<div class="draw-winner-container" v-if="attendees.length > 0">
<div v-if="drawingWinner">
<span>
Trekker {{ currentWinners }} av {{ numberOfWinners }} vinnere.
{{ secondsLeft }} sekunder av {{ drawTime }} igjen
</span>
<button class="vin-button no-margin" @click="stopDraw">Stopp trekning</button>
</div>
<div class="draw-container" v-if="!drawingWinner">
<button class="vin-button no-margin" @click="drawWinner">Trekk vinnere</button>
<input type="number" v-model="numberOfWinners" />
</div>
</div>
<h2 v-if="winners.length > 0">Vinnere</h2>
<div class="winners" v-if="winners.length > 0">
<div class="winner" v-for="(winner, index) in winners" :key="index">
<div :class="winner.color + '-raffle'" class="raffle-element">
<span>{{ winner.name }}</span>
<span>{{ winner.phoneNumber }}</span>
<span>Rød: {{ winner.red }}</span>
<span>Blå: {{ winner.blue }}</span>
<span>Grønn: {{ winner.green }}</span>
<span>Gul: {{ winner.yellow }}</span>
</div>
</div>
</div>
<div class="delete-buttons" v-if="attendees.length > 0 || winners.length > 0">
<button
class="vin-button"
v-if="winners.length > 0"
@click="deleteAllWinners"
>Slett virtuelle vinnere</button>
<button
class="vin-button"
v-if="attendees.length > 0"
@click="deleteAllAttendees"
>Slett virtuelle deltakere</button>
</div>
<div class="attendees" v-if="attendees.length > 0">
<h2>Deltakere ({{ attendees.length }})</h2>
<div class="attendee" v-for="(attendee, index) in attendees" :key="index">
<div class="name-and-phone">
<span class="name">{{ attendee.name }}</span>
<span class="phoneNumber">{{ attendee.phoneNumber }}</span>
</div>
<div class="raffles-container">
<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>
<div class="attendee-registration-container">
<h2>Legg til deltaker</h2>
<div class="label-div">
<label for="name">Navn</label>
<input id="name" type="text" placeholder="Navn" v-model="name" />
</div>
<br />
<div class="label-div">
<label for="phoneNumber">Telefonnummer</label>
<input id="phoneNumber" type="text" placeholder="Telefonnummer" v-model="phoneNumber" />
</div>
<br />
<br />
<div class="label-div">
<label for="randomColors">Tilfeldig farger?</label>
<input
id="randomColors"
type="checkbox"
placeholder="Tilfeldig farger"
v-model="randomColors"
/>
</div>
<div v-if="!randomColors">
<br />
<br />
<div class="label-div">
<label for="red">Rød</label>
<input id="red" type="number" placeholder="Rød" v-model="red" />
</div>
<br />
<div class="label-div">
<label for="blue">Blå</label>
<input id="blue" type="number" placeholder="Blå" v-model="blue" />
</div>
<br />
<div class="label-div">
<label for="green">Grønn</label>
<input id="green" type="number" placeholder="Grønn" v-model="green" />
</div>
<br />
<div class="label-div">
<label for="yellow">Gul</label>
<input id="yellow" type="number" placeholder="Gul" v-model="yellow" />
</div>
</div>
<div v-else>
<RaffleGenerator @colors="setWithRandomColors" :generateOnInit="true" />
</div>
</div>
<br />
<button class="vin-button" @click="sendAttendee">Send deltaker</button>
<TextToast v-if="showToast" :text="toastText" v-on:closeToast="showToast = false" />
</div>
</template>
<script>
import io from "socket.io-client";
import {
addAttendee,
getVirtualWinner,
attendeesSecure,
attendees,
winnersSecure,
deleteWinners,
deleteAttendees,
finishedDraw,
prelottery
} from "@/api";
import TextToast from "@/ui/TextToast";
import RaffleGenerator from "@/ui/RaffleGenerator";
export default {
components: {
RaffleGenerator,
TextToast
},
data() {
return {
name: null,
phoneNumber: null,
red: 0,
blue: 0,
green: 0,
yellow: 0,
raffles: 0,
randomColors: false,
attendees: [],
winners: [],
drawingWinner: false,
secondsLeft: 20,
drawTime: 20,
currentWinners: 1,
numberOfWinners: 4,
socket: null,
toastText: undefined,
showToast: false
};
},
mounted() {
this.getAttendees();
this.getWinners();
this.socket = io(`${window.location.hostname}:${window.location.port}`);
this.socket.on("winner", async msg => {
this.getWinners();
this.getAttendees();
});
this.socket.on("refresh_data", async msg => {
this.getAttendees();
this.getWinners();
});
this.socket.on("new_attendee", async msg => {
this.getAttendees();
});
window.finishedDraw = finishedDraw;
},
methods: {
setWithRandomColors(colors) {
Object.keys(colors).forEach(color => (this[color] = colors[color]));
},
sendAttendee: async function() {
if (this.red == 0 && this.blue == 0 && this.green == 0 && this.yellow == 0) {
alert('Ingen farger valgt!')
return;
}
if (this.name == 0 && this.phoneNumber) {
alert('Ingen navn eller tlf satt!')
return;
}
let response = await addAttendee({
name: this.name,
phoneNumber: this.phoneNumber,
red: this.red,
blue: this.blue,
green: this.green,
yellow: this.yellow,
raffles: this.raffles
});
if (response == true) {
this.toastText = `Sendt inn deltaker: ${this.name}`;
this.showToast = true;
this.name = null;
this.phoneNumber = null;
this.yellow = 0;
this.green = 0;
this.red = 0;
this.blue = 0;
this.getAttendees();
} else {
alert("Klarte ikke sende inn.. Er du logget inn?");
}
},
getAttendees: async function() {
let response = await attendeesSecure();
this.attendees = response;
},
stopDraw: function() {
this.drawingWinner = false;
this.secondsLeft = this.drawTime;
},
drawWinner: async function() {
if (window.confirm("Er du sikker på at du vil trekke vinnere?")) {
this.drawingWinner = true;
let response = await getVirtualWinner();
if (response.success) {
console.log("Winner:", response.winner);
if (this.currentWinners < this.numberOfWinners) {
this.countdown();
} else {
this.drawingWinner = false;
let finished = await finishedDraw();
if(finished) {
alert("SMS'er er sendt ut!");
} else {
alert("Noe gikk galt under SMS utsendelser.. Sjekk logg og database for id'er.");
}
}
this.getWinners();
this.getAttendees();
} else {
this.drawingWinner = false;
alert("Noe gikk galt under trekningen..! " + response["message"]);
}
}
},
countdown: function() {
this.secondsLeft -= 1;
if (!this.drawingWinner) {
return;
}
if (this.secondsLeft <= 0) {
this.secondsLeft = this.drawTime;
this.currentWinners += 1;
if (this.currentWinners <= this.numberOfWinners) {
this.drawWinner();
} else {
this.drawingWinner = false;
}
return;
}
setTimeout(() => {
this.countdown();
}, 1000);
},
deleteAllWinners: async function() {
if (window.confirm("Er du sikker på at du vil slette vinnere?")) {
let response = await deleteWinners();
if (response) {
this.getWinners();
} else {
alert("Klarte ikke hente ut vinnere");
}
}
},
deleteAllAttendees: async function() {
if (window.confirm("Er du sikker på at du vil slette alle deltakere?")) {
let response = await deleteAttendees();
if (response) {
this.getAttendees();
} else {
alert("Klarte ikke hente ut vinnere");
}
}
},
getWinners: async function() {
let response = await winnersSecure();
if (response) {
this.winners = response;
} else {
alert("Klarte ikke hente ut vinnere");
}
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/media-queries.scss";
.draw-container {
display: flex;
justify-content: space-around;
}
.draw-winner-container,
.delete-buttons {
margin-bottom: 20px;
}
.delete-buttons {
display: flex;
}
h1 {
width: 100%;
text-align: center;
font-family: knowit, Arial;
}
h2 {
width: 100%;
text-align: center;
font-size: 1.6rem;
font-family: knowit, Arial;
}
hr {
width: 90%;
margin: 2rem auto;
color: grey;
}
.page-container {
padding: 0 1.5rem 3rem;
@include desktop {
max-width: 60vw;
margin: 0 auto;
}
}
#randomColors {
width: 40px;
height: 40px;
&:checked {
background: green;
}
}
.raffle-element {
width: 140px;
height: 150px;
margin: 20px 0;
-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;
color: #333333;
font-size: 0.75rem;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
flex-direction: column;
&.small {
width: 45px;
height: 45px;
font-size: 1rem;
}
&.green-raffle {
background-color: $light-green;
}
&.blue-raffle {
background-color: $light-blue;
}
&.yellow-raffle {
background-color: $light-yellow;
}
&.red-raffle {
background-color: $light-red;
}
}
button {
display: flex !important;
margin: auto !important;
}
.winners {
display: flex;
justify-content: space-around;
align-items: center;
}
.attendees {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.attendee {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 50%;
margin: 0 auto;
& .name-and-phone,
& .raffles-container {
display: flex;
justify-content: center;
}
& .name-and-phone {
flex-direction: column;
}
& .raffles-container {
flex-direction: row;
}
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="container">
<div v-if="!posted">
<h1 v-if="name">Gratulerer {{name}}!</h1>
<p v-if="name">
Her er valgene for dagens lotteri, du har 10 minutter å velge etter du fikk SMS-en.
</p>
<h1 v-else-if="!turn && !existing" class="sent-container">Finner ikke noen vinner her..</h1>
<h1 v-else-if="!turn" class="sent-container">Du vente tur..</h1>
<div class="wines-container" v-if="name">
<Wine :wine="wine" v-for="wine in wines" :key="wine">
<button
@click="chooseWine(wine.name)"
class="vin-button select-wine"
>Velg denne vinnen</button>
</Wine>
</div>
</div>
<div v-else-if="posted" class="sent-container">
<h1>Valget ditt er sendt inn!</h1>
<p>Du får mer info om henting snarest!</p>
</div>
</div>
</template>
<script>
import { getAmIWinner, postWineChosen, prelottery } from "@/api";
import Wine from "@/ui/Wine";
export default {
components: { Wine },
data() {
return {
id: null,
existing: false,
fetched: false,
turn: false,
name: null,
wines: [],
posted: false
};
},
async mounted() {
this.id = this.$router.currentRoute.params.id;
let winnerObject = await getAmIWinner(this.id);
this.fetched = true;
if (!winnerObject || !winnerObject.existing) {
console.error("non existing", winnerObject);
return;
}
this.existing = true;
if (winnerObject.existing && !winnerObject.turn) {
console.error("not your turn yet", winnerObject);
return;
}
this.turn = true;
this.name = winnerObject.name;
this.wines = await prelottery();
},
methods: {
chooseWine: async function(name) {
let posted = await postWineChosen(this.id, name);
console.log("response", posted);
if (posted.success) {
this.posted = true;
}
}
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/global";
.container {
display: flex;
justify-content: center;
margin-top: 2rem;
padding: 2rem;
}
.sent-container {
width: 100%;
height: 90vh;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
flex-direction: column;
}
.select-wine {
margin-top: 1rem;
}
.wines-container {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
align-items: flex-start;
}
</style>

View File

@@ -0,0 +1,2 @@
import Vue from "vue";
export default new Vue();

View File

@@ -0,0 +1,99 @@
var serviceWorkerRegistrationMixin = {
created: function() {
if (!("serviceWorker" in navigator)) {
console.log("Nettleseren din støtter ikke service-workers.");
return;
}
if ("PushManager" in window && __PUSHENABLED__) {
if (Notification.permission !== "granted") {
localStorage.removeItem("push");
}
}
if (window.location.href.includes('localhost')) {
console.info("Service worker manually disabled while on localhost.")
} else {
this.registerPushListener();
this.registerServiceWorker();
}
},
methods: {
registerPushListener: function() {
try {
const channel = new BroadcastChannel("updatePush");
channel.addEventListener("message", event => {
if (event.data.success) {
localStorage.setItem("push", true);
this.$emit("push-allowed");
}
});
} catch (e) {
console.log("Using safari 'eh? No notifications for you.");
}
},
sendMessage: function(message) {
return new Promise(function(resolve, reject) {
var messageChannel = new MessageChannel();
messageChannel.port1.onmessage = function(event) {
if (event.data.error) {
reject(event.data.error);
} else {
resolve(event.data);
}
};
if (navigator.serviceWorker.controller == null) {
resolve();
} else {
navigator.serviceWorker.controller.postMessage(message, [
messageChannel.port2
]);
}
});
},
serviceWorkerUpdateFoundListener: function(serviceWorker) {
const installingWorker = serviceWorker.installing;
installingWorker.onstatechange = () => {
if (
installingWorker.state === "installed" &&
navigator.serviceWorker.controller
) {
this.$emit("service-worker-updated");
}
};
},
registerServiceWorkerPushNotification: function() {
if (!("PushManager" in window)) {
throw new Error("No Push API Support!");
}
window.Notification.requestPermission().then(permission => {
if (permission !== "granted") {
console.log(
"Du valgte å ikke ha arbeids-arbeideren til å sende deg dytte-meldinger :'('"
);
return;
}
if (localStorage.getItem("push") == null) {
this.sendMessage("updatePush");
}
});
},
registerServiceWorker: function() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/service-worker.js")
.then(serviceWorker => {
console.log(
"Arbeids arbeideren din er installert. Du kan nå gå offline frem til neste trekning."
);
serviceWorker.onupdatefound = () => {
this.serviceWorkerUpdateFoundListener(serviceWorker);
};
})
.catch(error => {
console.error("Arbeids arbeideren klarer ikke arbeide.", error);
});
}
}
}
};
export default serviceWorkerRegistrationMixin;

124
frontend/router.js Normal file
View File

@@ -0,0 +1,124 @@
const VinlottisPage = () => import(
/* webpackChunkName: "lading-page" */
"@/components/VinlottisPage");
const LotteryPage = () => import(
/* webpackChunkName: "lading-page" */
"@/components/LotteryPage");
const GeneratePage = () => import(
/* webpackChunkName: "lading-page" */
"@/components/GeneratePage");
const TodaysPage = () => import(
/* webpackChunkName: "sub-pages" */
"@/components/TodaysPage");
const AllWinesPage = () => import(
/* webpackChunkName: "sub-pages" */
"@/components/AllWinesPage");
const HistoryPage = () => import(
/* webpackChunkName: "sub-pages" */
"@/components/HistoryPage");
const WinnerPage = () => import(
/* webpackChunkName: "sub-pages" */
"@/components/WinnerPage");
const LoginPage = () => import(
/* webpackChunkName: "user" */
"@/components/LoginPage");
const CreatePage = () => import(
/* webpackChunkName: "user" */
"@/components/CreatePage");
const AdminPage = () => import(
/* webpackChunkName: "admin" */
"@/components/AdminPage");
const PersonalHighscorePage = () => import(
/* webpackChunkName: "highscore" */
"@/components/PersonalHighscorePage");
const HighscorePage = () => import(
/* webpackChunkName: "highscore" */
"@/components/HighscorePage");
const RequestWine = () => import(
/* webpackChunkName: "request" */
"@/components/RequestWine");
const AllRequestedWines = () => import(
/* webpackChunkName: "request" */
"@/components/AllRequestedWines");
const routes = [
{
path: "*",
name: "Hjem",
component: VinlottisPage
},
{
path: "/lottery",
name: "Lotteri",
component: LotteryPage
},
{
path: "/dagens",
name: "Dagens vin",
component: TodaysPage
},
{
path: "/viner",
name: "All viner",
component: AllWinesPage
},
{
path: "/login",
name: "Login",
component: LoginPage
},
{
path: "/create",
name: "Registrer",
component: CreatePage
},
{
path: "/admin",
name: "Admin side",
component: AdminPage
},
{
path: "/lottery/:tab",
component: LotteryPage
},
{
path: "/winner/:id",
component: WinnerPage
},
{
path: "/history/:date",
name: "Historie for dato",
component: HistoryPage
},
{
path: "/history",
name: "Historie",
component: HistoryPage
},
{
path: "/highscore/:name",
name: "Personlig topplisten",
component: PersonalHighscorePage
},
{
path: "/highscore",
name: "Topplisten",
component: HighscorePage
},
{
path: "/request",
name: "Etterspør vin",
component: RequestWine
},
{
path: "/requested-wines",
name: "Etterspurte vin",
component: AllRequestedWines
}
];
export { routes };

View File

@@ -0,0 +1,205 @@
var version = "v1.0" + __DATE__;
var cacheName = "vinlottis";
var CACHE_NAME = cacheName;
var CACHE_NAME_API = cacheName + "::api";
var STATIC_CACHE_URLS = ["/"];
console.log("Nåværende versjon:", version);
self.addEventListener("activate", event => {
console.log("Aktiverer");
event.waitUntil(self.clients.claim());
event.waitUntil(removeCache(CACHE_NAME));
event.waitUntil(removeCache(CACHE_NAME_API));
event.waitUntil(addCache(CACHE_NAME, STATIC_CACHE_URLS));
});
self.addEventListener("notificationclick", function(event) {
event.notification.close();
if (
event.notification.data != undefined &&
event.notification.data.link != undefined
) {
event.waitUntil(clients.openWindow(event.notification.data.link));
} else {
event.waitUntil(clients.openWindow("/"));
}
});
self.addEventListener("message", event => {
if (!__PUBLICKEY__) {
return;
}
if (event.data === "updatePush") {
event.waitUntil(
new Promise((resolve, reject) => {
const applicationServerKey = urlB64ToUint8Array(__PUBLICKEY__);
const options = { applicationServerKey, userVisibleOnly: true };
self.registration.pushManager
.subscribe(options)
.then(subscription =>
saveSubscription(subscription)
.then(() => {
try {
const channel = new BroadcastChannel("updatePush");
channel.postMessage({ success: true });
} catch (e) {
console.log("Using safari 'eh? No notifications for you.");
}
resolve();
})
.catch(() => {
resolve();
})
)
.catch(() => {
console.log("Kunne ikke legge til pushnotifications");
reject();
});
})
);
}
});
self.addEventListener("push", function(event) {
if (event.data) {
var message = JSON.parse(event.data.text());
var link = "/";
if (message.link != undefined) {
link = message.link;
}
showLocalNotification(
message.title,
message.message,
link,
self.registration
);
} else {
}
});
self.addEventListener("install", event => {
console.log("Arbeids arbeideren installerer seg.");
self.skipWaiting();
event.waitUntil(addCache(CACHE_NAME, STATIC_CACHE_URLS));
});
self.addEventListener("fetch", event => {
if (
event.request.url.includes("/login") ||
event.request.url.includes("/update") ||
event.request.url.includes("/register") ||
event.request.method == "POST" ||
event.request.url.includes("/api/wines/prelottery") ||
event.request.url.includes("/api/virtual") ||
event.request.url.includes("/socket.io")
) {
event.respondWith(fetch(event.request));
return;
}
if (
event.request.cache === "only-if-cached" &&
event.request.mode !== "same-origin"
)
return;
if (event.request.url.includes("/api/")) {
event.respondWith(
fetch(event.request)
.then(response => cache(event.request, response))
.catch(function() {
return caches.match(event.request);
})
);
} else {
event.respondWith(
caches
.match(event.request) // check if the request has already been cached
.then(cached => cached || fetch(event.request)) // otherwise request network
.then(
response =>
staticCache(event.request, response) // put response in cache
.then(() => response) // resolve promise with the network response
)
);
}
});
function showLocalNotification(title, body, link, swRegistration) {
const options = {
body,
icon: "https://lottis.vin/public/assets/images/favicon.png",
image: "https://lottis.vin/public/assets/images/favicon.png",
vibrate: [300],
data: { link: link }
};
swRegistration.showNotification(title, options);
}
async function saveSubscription(subscription) {
const SERVER_URL = "/subscription/save-subscription";
const response = await fetch(SERVER_URL, {
method: "post",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(subscription)
});
return response.json();
}
const urlB64ToUint8Array = base64String => {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, "+")
.replace(/_/g, "/");
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
};
function addCache(cacheKey, cacheUrls) {
return caches.open(cacheKey).then(cache => {
console.log("Legger til cache", cache);
return cache.addAll(cacheUrls);
});
}
function removeCache(cacheKey) {
return caches
.keys()
.then(keys => keys.filter(key => key !== cacheKey))
.then(keys =>
Promise.all(
keys.map(key => {
console.log(`Sletter mellom-lager på nøkkel ${key}`);
return caches.delete(key);
})
)
);
}
function staticCache(request, response) {
if (response.type === "error" || response.type === "opaque") {
return Promise.resolve(); // do not put in cache network errors
}
return caches
.open(CACHE_NAME)
.then(cache => cache.put(request, response.clone()));
}
function cache(request, response) {
if (response.type === "error" || response.type === "opaque") {
return response;
}
return caches.open(CACHE_NAME_API).then(cache => {
cache.put(request, response.clone());
return response;
});
}

222
frontend/styles/banner.scss Normal file
View File

@@ -0,0 +1,222 @@
@import "./media-queries.scss";
@import "./variables.scss";
.top-banner{
position: sticky;
top: 0;
z-index: 1;
display: grid;
grid-template-columns: 0.5fr 1fr 0.5fr;
grid-template-areas: "menu logo clock";
grid-gap: 1em;
align-items: center;
justify-items: center;
background-color: $primary;
// ios homescreen app whitespace above header fix.
&::before {
content: '';
width: 100%;
height: 3rem;
position: absolute;
top: -3rem;
background-color: inherit;
}
}
.company-logo{
grid-area: logo;
}
.menu-toggle-container{
grid-area: menu;
color: #1e1e1e;
border-radius: 50% 50%;
z-index: 3;
width: 42px;
height: 42px;
background: white;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
&:hover{
cursor: pointer;
}
span{
display: block;
position: relative;
border-radius: 3px;
height: 3px;
width: 18px;
background: #111;
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;
}
span:first-child{
transform-origin: 0% 0%;
margin-bottom: 4px;
}
span:nth-last-child(2){
transform-origin: 0% 100%;
margin-bottom: 4px;
}
&.open{
span{
opacity: 1;
transform: rotate(-45deg) translate(2px, -2px);
background: #232323;
}
span:nth-last-child(2){
opacity: 0;
transform: rotate(0deg) scale(0.2, 0.2);
}
span:nth-last-child(3){
transform: rotate(45deg) translate(3.5px, -2px);
}
}
&.open{
background: #fff;
}
}
.menu{
position: fixed;
top: 0;
background-color: $primary;
width: 100%;
z-index: 2;
overflow: hidden;
transition: max-height 0.5s ease-out;
height: 100vh;
max-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
row-gap: 3em;
&.collapsed{
max-height: 0%;
}
a{
text-decoration: none;
}
.single-route{
font-size: 3em;
outline: 0;
text-decoration: none;
color: #1e1e1e;
border-bottom: 4px solid transparent;
display: block;
&.open{
-webkit-animation: fadeInFromNone 3s ease-out;
-moz-animation: fadeInFromNone 3s ease-out;
-o-animation: fadeInFromNone 3s ease-out;
animation: fadeInFromNone 3s ease-out;
}
&:hover{
cursor: pointer;
border-color: $link-color;
}
}
}
@-webkit-keyframes fadeInFromNone {
0% {
display: none;
opacity: 0;
}
10% {
display: block;
opacity: 0;
}
100% {
display: block;
opacity: 1;
}
}
@-moz-keyframes fadeInFromNone {
0% {
display: none;
opacity: 0;
}
10% {
display: block;
opacity: 0;
}
100% {
display: block;
opacity: 1;
}
}
@-o-keyframes fadeInFromNone {
0% {
display: none;
opacity: 0;
}
10% {
display: block;
opacity: 0;
}
100% {
display: block;
opacity: 1;
}
}
@keyframes fadeInFromNone {
0% {
display: none;
opacity: 0;
}
10% {
display: block;
opacity: 0;
}
100% {
display: block;
opacity: 1;
}
}
.clock {
grid-area: clock;
text-decoration: none;
color: #333333;
display: flex;
font-family: Arial;
@include mobile {
font-size: 0.8em;
margin-right: 1rem;
}
h2 {
display: flex;
}
}

331
frontend/styles/global.scss Normal file
View File

@@ -0,0 +1,331 @@
@import "./media-queries.scss";
@import "./variables.scss";
@font-face {
font-family: "knowit";
font-weight: 600;
src: url("/public/assets/fonts/bold.woff");
}
@font-face {
font-family: "knowit";
font-weight: 300;
src: url("/public/assets/fonts/regular.woff");
}
body {
font-family: Arial;
margin: 0;
}
a {
text-decoration: none;
}
.title {
text-align: center;
width: fit-content;
margin: 2rem auto;
text-align: center;
font-family: knowit, Arial;
margin-top: 3.8rem;
font-weight: 600;
@include mobile {
margin-top: 1.5rem;
font-size: 1.6rem;
}
}
.subtext {
margin-top: 0.5rem;
font-size: 1.22rem;
@include mobile {
margin-top: 0;
font-size: 1.15rem;
}
}
.label-div {
display: flex;
flex-direction: column;
justify-content: space-between;
label {
margin-bottom: 0.25rem;
font-weight: 600;
text-transform: uppercase;
}
input {
margin: 0;
margin-bottom: auto;
height: 2rem;
padding: 0.5rem;
min-width: 0;
width: 98%;
padding: 1%;
}
}
.button-container {
display: flex;
justify-content: center;
flex-direction: row;
> *:not(:last-child) {
margin-right: 2rem;
}
&.column {
flex-direction: column;
align-items: center;
> * {
margin-right: unset;
margin-bottom: 1rem;
}
}
@include mobile {
&:not(.row) {
flex-direction: column;
align-items: center;
> *:not(:last-child) {
margin-right: unset;
margin-bottom: .75rem;
}
}
}
}
input,
textarea {
border-radius: 0;
box-shadow: none;
-webkit-appearance: none;
font-size: 1.1rem;
border: 1px solid rgba(#333333, 0.3);
}
.vin-button {
font-family: Arial;
$color: #b7debd;
position: relative;
display: inline-block;
background: $color;
color: #333;
padding: 10px 30px;
margin: 0;
border: 0;
width: fit-content;
font-size: 1.3rem;
line-height: 1.3rem;
height: 4rem;
max-height: 4rem;
cursor: pointer;
font-weight: 500;
transition: transform 0.5s ease;
-webkit-font-smoothing: antialiased;
// disable-dbl-tap-zoom
touch-action: manipulation;
&.auto-height {
height: auto;
}
&.danger {
background-color: $red;
color: white;
}
&::after {
content: "";
position: absolute;
transition: opacity 0.3s ease-in-out;
z-index: -1;
width: 100%;
height: 100%;
top: 0;
left: 0;
opacity: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.07),
0 4px 8px rgba(0, 0, 0, 0.07), 0 8px 16px rgba(0, 0, 0, 0.07),
0 16px 32px rgba(0, 0, 0, 0.07), 0 32px 64px rgba(0, 0, 0, 0.07);
}
&:hover:not(:disabled) {
transform: scale(1.02) translateZ(0);
&::after {
opacity: 1;
}
}
&:disabled{
opacity: 0.25;
cursor: not-allowed;
}
&.small {
height: min-content;
}
}
.cursor {
&-pointer {
cursor: pointer;
}
}
.text-center {
text-align: center;
}
.vin-link {
font-weight: bold;
border-bottom: 1px solid $link-color;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
color: $matte-text-color;
&:focus, &:hover {
border-color: $link-color;
}
}
.margin-top {
&-md {
margin-top: 3rem;
}
&-sm {
margin-top: 1rem;
}
&-0 {
margin-top: 0;
}
}
.margin-left {
&-md {
margin-left: 3rem;
}
&-sm {
margin-left: 1rem;
}
&-0 {
margin-left: 0;
}
}
.margin-right {
&-md {
margin-right: 3rem;
}
&-sm {
margin-right: 1rem;
}
&-0 {
margin-right: 0;
}
}
.margin-bottom {
&-md {
margin-bottom: 3rem;
}
&-sm {
margin-bottom: 1rem;
}
&-0 {
margin-bottom: 0;
}
}
.width {
&-100 {
width: 100%;
}
&-75 {
width: 75%;
}
&-50 {
width: 50%;
}
&-25 {
width: 25%;
}
}
.cursor {
&-pointer {
cursor: pointer;
}
}
.no-margin {
margin: 0 !important;
}
.raffle-element {
margin: 20px 0;
-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;
color: #333333;
&.green-raffle {
background-color: $light-green;
}
&.blue-raffle {
background-color: $light-blue;
}
&.yellow-raffle {
background-color: $light-yellow;
}
&.red-raffle {
background-color: $light-red;
}
}
@mixin raffle {
padding-bottom: 50px;
&::before, &::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 25px;
height: 50px;
background: radial-gradient(closest-side, #fff, #fff 50%, transparent 50%);
background-size: 50px 50px;
background-position: 0 25px;
background-repeat: repeat-x;
}
&::after{
background: radial-gradient(closest-side, transparent, transparent 50%, #fff 50%);
background-size: 50px 50px;
background-position: 25px -25px;
bottom: -25px
}
}
.desktop-only {
@include mobile {
display: none;
}
}
.mobile-only {
@include desktop {
display: none;
}
}

View File

@@ -0,0 +1,71 @@
@import "./media-queries.scss";
@import "./variables.scss";
.outer {
display: flex;
flex-direction: column;
@include desktop {
margin-top: 10vh;
}
}
h2 {
font-family: knowit, Arial;
text-align: center;
font-size: 3rem;
width: 100vw;
@include mobile {
font-size: 2rem;
}
}
form {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
margin: 2rem auto 0;
width: 80vw;
max-width: 1100px;
@include mobile {
display: flex;
flex-direction: column;
justify-content: center;
width: 90vw;
}
.label-div input {
font-size: 2rem;
min-height: 2rem;
line-height: 2rem;
border: none;
border-bottom: 1px solid black;
max-width: 30vw;
@include mobile {
max-width: unset;
font-size: 2rem;
min-height: 2rem;
line-height: 2rem;
margin-bottom: 1.5rem;
}
}
}
.vin-button {
margin-bottom: 0;
margin-top: auto;
}
.error {
padding: 1.25rem;
margin: 1.25rem;
width: calc(100% - 5rem);
background-color: $light-red;
color: $red;
}

View File

@@ -0,0 +1,28 @@
$mobile-width: 768px;
$tablet-max: 1200px;
$desktop-max: 2004px;
@mixin mobile {
@media (max-width: #{$mobile-width}) {
@content;
}
}
@mixin tablet {
@media (min-width: #{$mobile-width + 1px}) {
@content;
}
}
@mixin desktop {
@media (min-width: #{$tablet-max + 1px}) {
@content;
}
}
@mixin widescreen {
@media (min-width: #{$desktop-max + 1px}){
@content;
}
}

View File

@@ -0,0 +1,46 @@
.flex {
display: flex;
&.column {
flex-direction: column;
}
&.row {
flex-direction: row;
}
&.wrap {
flex-wrap: wrap;
}
&.justify-center {
justify-content: center;
}
&.justify-space-between {
justify-content: space-between;
}
&.justify-end {
justify-content: flex-end;
}
&.justify-start {
justify-content: flex-start;
}
&.align-center {
align-items: center;
}
}
.inline-block {
display: inline-block;
}
.float {
&-left {
float: left;
}
&-right {
float: right;
}
}

View File

@@ -0,0 +1,21 @@
$primary: #b7debd;
$light-green: #c8f9df;
$green: #0be881;
$dark-green: #0ed277;
$light-blue: #d4f2fe;
$blue: #4bcffa;
$dark-blue: #24acda;
$light-yellow: #fff6d6;
$yellow: #ffde5d;
$dark-yellow: #ecc31d;
$light-red: #fbd7de;
$red: #ef5878;
$dark-red: #ec3b61;
$link-color: #ff5fff;
$matte-text-color: #333333;

View File

@@ -0,0 +1,122 @@
@font-face {
font-family: 'vinlottis-icons';
src:
url('/public/assets/fonts/vinlottis-icons/vinlottis-icons.ttf?95xu5r') format('truetype'),
url('/public/assets/fonts/vinlottis-icons/vinlottis-icons.woff?95xu5r') format('woff'),
url('/public/assets/fonts/vinlottis-icons/vinlottis-icons.svg?95xu5r#vinlottis-icons') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
.icon {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'vinlottis-icons' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon--arrow-long-right:before {
content: "\e907";
}
.icon--arrow-long-left:before {
content: "\e908";
}
.icon--arrow-right:before {
content: "\e909";
}
.icon--arrow-left:before {
content: "\e900";
}
.icon--ballon:before {
content: "\e90b";
}
.icon--bars:before {
content: "\e90c";
}
.icon--bottle:before {
content: "\e90d";
}
.icon--cake-chart:before {
content: "\e90f";
}
.icon--stopwatch:before {
content: "\e911";
}
.icon--cloud:before {
content: "\e912";
}
.icon--dart:before {
content: "\e914";
}
.icon--eye-1:before {
content: "\e919";
}
.icon--eye-2:before {
content: "\e91a";
}
.icon--eye-3:before {
content: "\e91b";
}
.icon--eye-4:before {
content: "\e91c";
}
.icon--eye-5:before {
content: "\e91d";
}
.icon--eye-6:before {
content: "\e91e";
}
.icon--eye-7:before {
content: "\e91f";
}
.icon--eye-8:before {
content: "\e920";
}
.icon--face-1:before {
content: "\e922";
}
.icon--face-2:before {
content: "\e923";
}
.icon--face-3:before {
content: "\e924";
}
.icon--heart-sparks:before {
content: "\e928";
}
.icon--heart:before {
content: "\e929";
}
.icon--medal:before {
content: "\e936";
}
.icon--megaphone:before {
content: "\e937";
}
.icon--phone:before {
content: "\e93a";
}
.icon--plus:before {
content: "\e93e";
}
.icon--spark:before {
content: "\e946";
}
.icon--tag:before {
content: "\e949";
}
.icon--talk:before {
content: "\e94b";
}
.icon--cross:before {
content: "\e952";
}

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="nb">
<head>
<meta charset="UTF-8" />
<title>Vinlottis</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Knowits ukentlige vinlotteri-statistikk-side."
/>
<meta name="keywords" content="vin,vinlotteri,knowit,statistikk" />
<meta name="author" content="Kasper Rynning-Tønnesen & Kevin Midbøe" />
<link
rel="preload"
href="/public/assets/fonts/bold.woff"
as="font"
crossorigin
/>
<meta
name="description"
content="Knowits ukentlige vinlotteri-statistikk-side."
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="/public/assets/images/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/public/assets/images/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/public/assets/images/favicon-16x16.png"
/>
<link rel="manifest" href="/public/assets/manifest.json" />
<link
rel="mask-icon"
href="/public/assets/images/safari-pinned-tab.svg"
color="#23101f"
/>
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#b7debd" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
</head>
<body>
<div id="app"></div>
<noscript>Du trenger vin, jeg trenger javascript!</noscript>
<script src="/public/analytics.js" async></script>
</body>
</html>
<style>
noscript {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
height: 100vh;
background-color: #b7debd;
font-size: 1.5rem;
font-family: Arial, Helvetica, sans-serif;
}
</style>

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>

27
frontend/utils.js Normal file
View File

@@ -0,0 +1,27 @@
const dateString = (date) => {
if (typeof(date) == "string") {
date = new Date(date);
}
const ye = new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date)
const mo = new Intl.DateTimeFormat('en', { month: '2-digit' }).format(date)
const da = new Intl.DateTimeFormat('en', { day: '2-digit' }).format(date)
return `${ye}-${mo}-${da}`
}
function humanReadableDate(date) {
const options = { year: 'numeric', month: 'long', day: 'numeric' };
return new Date(date).toLocaleDateString(undefined, options);
}
function daysAgo(date) {
const day = 24 * 60 * 60 * 1000;
return Math.round(Math.abs((new Date() - new Date(date)) / day));
}
export {
dateString,
humanReadableDate,
daysAgo
}

View File

@@ -0,0 +1,54 @@
import Vue from "vue";
import VueRouter from "vue-router";
import { routes } from "@/router.js";
import Vinlottis from "@/Vinlottis";
import * as Sentry from "@sentry/browser";
import { Vue as VueIntegration } from "@sentry/integrations";
Vue.use(VueRouter);
const ENV = window.location.href.includes("localhost") ? "development" : "production";
if (ENV !== "development") {
Sentry.init({
dsn: "https://7debc951f0074fb68d7a76a1e3ace6fa@o364834.ingest.sentry.io/4905091",
integrations: [
new VueIntegration({ Vue })
],
beforeSend: event => {
console.error(event);
return event;
}
})
}
// Add global GA variables
window.ga = window.ga || function(){
window.ga.q = window.ga.q || [];
window.ga.q.push(arguments);
};
ga.l = 1 * new Date();
// Initiate
ga('create', __GA_TRACKINGID__, {
'allowAnchor': false,
'cookieExpires': __GA_COOKIELIFETIME__, // Time in seconds
'cookieFlags': 'SameSite=Strict; Secure'
});
ga('set', 'anonymizeIp', true); // Enable IP Anonymization/IP masking
ga('send', 'pageview');
if (ENV == 'development')
window[`ga-disable-${__GA_TRACKINGID__}`] = true;
const router = new VueRouter({
routes: routes
});
new Vue({
el: "#app",
router,
components: { Vinlottis },
template: "<Vinlottis/>",
render: h => h(Vinlottis)
});