Merge pull request #67 from KevinMidboe/refactor/project-structure

Refactor/Project structure
This commit is contained in:
2020-12-10 23:20:38 +01:00
committed by GitHub
102 changed files with 4237 additions and 4682 deletions

15
.babelrc Normal file
View File

@@ -0,0 +1,15 @@
{
presets: [
[
"@babel/preset-env",
{
modules: false,
targets: {
browsers: ["IE 11", "> 5%"]
},
useBuiltIns: "usage",
corejs: "3"
}
]
]
}

View File

@@ -1,22 +1,46 @@
const path = require("path");
const { addMessage } = require(path.join(__dirname + "/redis.js"));
const validateUsername = (username) => {
let error = undefined;
const illegalChars = /\W/;
const minLength = 3;
const maxLength = 15;
if (typeof username !== 'string') {
error = 'Ugyldig brukernavn.';
} else if (username.length === 0) {
error = 'Vennligst oppgi brukernavn.';
} else if (username.length < minLength || username.length > maxLength) {
error = `Brukernavn må være mellom ${minLength}-${maxLength} karaktere.`
} else if (illegalChars.test(username)) {
error = 'Brukernavn kan bare inneholde tall og bokstaver.'
}
return error;
}
const io = (io) => {
io.on("connection", socket => {
let username = null;
socket.on("username", msg => {
if (msg.username == null) {
const usernameValidationError = validateUsername(msg.username);
if (usernameValidationError) {
username = null;
socket.emit("accept_username", false);
return;
}
if (msg.username.length > 3 && msg.username.length < 30) {
socket.emit("accept_username", {
reason: usernameValidationError,
success: false,
username: undefined
});
} else {
username = msg.username;
socket.emit("accept_username", true);
return;
socket.emit("accept_username", {
reason: undefined,
success: true,
username: msg.username
});
}
socket.emit("accept_username", false);
});
socket.on("chat", msg => {

View File

@@ -1,33 +1,29 @@
const express = require("express");
const path = require("path");
const router = express.Router();
const { history, clearHistory } = require(path.join(__dirname + "/../api/redis"));
router.use((req, res, next) => {
next();
});
const getAllHistory = (req, res) => {
let { page, limit } = req.query;
page = !isNaN(page) ? Number(page) : undefined;
limit = !isNaN(limit) ? Number(limit) : undefined;
router.route("/chat/history").get(async (req, res) => {
let { skip, take } = req.query;
skip = !isNaN(skip) ? Number(skip) : undefined;
take = !isNaN(take) ? Number(take) : undefined;
return history(page, limit)
.then(messages => res.json(messages))
.catch(error => res.status(500).json({
message: error.message,
success: false
}));
};
try {
const messages = await history(skip, take);
res.json(messages)
} catch(error) {
res.status(500).send(error);
}
});
const deleteHistory = (req, res) => {
return clearHistory()
.then(message => res.json(message))
.catch(error => res.status(500).json({
message: error.message,
success: false
}));
};
router.route("/chat/history").delete(async (req, res) => {
try {
const messages = await clearHistory();
res.json(messages)
} catch(error) {
res.status(500).send(error);
}
});
module.exports = router;
module.exports = {
getAllHistory,
deleteHistory
};

View File

@@ -1,59 +0,0 @@
const passport = require("passport");
const path = require("path");
const User = require(path.join(__dirname + "/../schemas/User"));
const router = require("express").Router();
router.get("/", function(req, res) {
res.sendFile(path.join(__dirname + "/../public/index.html"));
});
router.get("/register", function(req, res) {
res.sendFile(path.join(__dirname + "/../public/index.html"));
});
// router.post("/register", function(req, res, next) {
// User.register(
// new User({ username: req.body.username }),
// req.body.password,
// function(err) {
// if (err) {
// if (err.name == "UserExistsError")
// res.status(409).send({ success: false, message: err.message })
// else if (err.name == "MissingUsernameError" || err.name == "MissingPasswordError")
// res.status(400).send({ success: false, message: err.message })
// return next(err);
// }
// return res.status(200).send({ message: "Bruker registrert. Velkommen " + req.body.username, success: true })
// }
// );
// });
router.get("/login", function(req, res) {
res.sendFile(path.join(__dirname + "/../public/index.html"));
});
router.post("/login", function(req, res, next) {
passport.authenticate("local", function(err, user, info) {
if (err) {
if (err.name == "MissingUsernameError" || err.name == "MissingPasswordError")
return res.status(400).send({ message: err.message, success: false })
return next(err);
}
if (!user) return res.status(404).send({ message: "Incorrect username or password", success: false })
req.logIn(user, (err) => {
if (err) { return next(err) }
return res.status(200).send({ message: "Velkommen " + user.username, success: true })
})
})(req, res, next);
});
router.get("/logout", function(req, res) {
req.logout();
res.redirect("/");
});
module.exports = router;

View File

@@ -1,7 +1,7 @@
const path = require('path');
const Highscore = require(path.join(__dirname + '/../schemas/Highscore'));
const Wine = require(path.join(__dirname + '/../schemas/Wine'));
const Highscore = require(path.join(__dirname, '/schemas/Highscore'));
const Wine = require(path.join(__dirname, '/schemas/Wine'));
// Utils
const epochToDateString = date => new Date(parseInt(date)).toDateString();

View File

@@ -1,5 +1,10 @@
const mustBeAuthenticated = (req, res, next) => {
console.log(req.isAuthenticated());
if (process.env.NODE_ENV == "development") {
console.info(`Restricted endpoint ${req.originalUrl}, allowing with environment development.`)
req.isAuthenticated = () => true;
return next();
}
if (!req.isAuthenticated()) {
return res.status(401).send({
success: false,

View File

@@ -0,0 +1,6 @@
const openCORS = (req, res, next) => {
res.set("Access-Control-Allow-Origin", "*")
return next();
};
module.exports = openCORS;

View File

@@ -0,0 +1,37 @@
const camelToKebabCase = str => str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
const mapFeaturePolicyToString = (features) => {
return Object.entries(features).map(([key, value]) => {
key = camelToKebabCase(key)
value = value == "*" ? value : `'${ value }'`
return `${key} ${value}`
}).join("; ")
}
const setupHeaders = (req, res, next) => {
res.set("Access-Control-Allow-Headers", "Content-Type")
// Security
res.set("X-Content-Type-Options", "nosniff");
res.set("X-XSS-Protection", "1; mode=block");
res.set("X-Frame-Options", "SAMEORIGIN");
res.set("X-DNS-Prefetch-Control", "off");
res.set("X-Download-Options", "noopen");
res.set("Strict-Transport-Security", "max-age=15552000; includeSubDomains")
// Feature policy
const features = {
fullscreen: "*",
payment: "none",
microphone: "none",
camera: "self",
speaker: "*",
syncXhr: "self"
}
const featureString = mapFeaturePolicyToString(features);
res.set("Feature-Policy", featureString)
return next();
}
module.exports = setupHeaders;

View File

@@ -1,5 +1,5 @@
const path = require("path");
const Highscore = require(path.join(__dirname + "/../schemas/Highscore"));
const Highscore = require(path.join(__dirname, "/schemas/Highscore"));
async function findSavePerson(foundWinner, wonWine, date) {
let person = await Highscore.findOne({

View File

@@ -1,29 +1,40 @@
const { promisify } = require("util"); // from node
let client;
let llenAsync;
let lrangeAsync;
try {
const redis = require("redis");
console.log("trying to create redis");
console.log("Trying to connect with redis..");
client = redis.createClient();
client.zcount = promisify(client.zcount).bind(client);
client.zadd = promisify(client.zadd).bind(client);
client.zrevrange = promisify(client.zrevrange).bind(client);
client.del = promisify(client.del).bind(client);
client.on("connect", () => console.log("Redis connection established!"));
client.on("error", function(err) {
client.quit();
console.error("Missing redis-configurations..");
console.error("Unable to connect to redis, setting up redis-mock.");
client = {
rpush: function() {
console.log("redis-dummy lpush", arguments);
if (typeof arguments[arguments.length - 1] == "function") {
arguments[arguments.length - 1](null);
}
zcount: function() {
console.log("redis-dummy zcount", arguments);
return Promise.resolve()
},
lrange: function() {
console.log("redis-dummy lrange", arguments);
if (typeof arguments[arguments.length - 1] == "function") {
arguments[arguments.length - 1](null);
}
zadd: function() {
console.log("redis-dummy zadd", arguments);
return Promise.resolve();
},
zrevrange: function() {
console.log("redis-dummy zrevrange", arguments);
return Promise.resolve(null);
},
del: function() {
console.log("redis-dummy del", arguments);
if (typeof arguments[arguments.length - 1] == "function") {
arguments[arguments.length - 1](null);
}
return Promise.resolve();
}
};
});
@@ -31,36 +42,46 @@ try {
const addMessage = message => {
const json = JSON.stringify(message);
client.rpush("messages", json);
return message;
return client.zadd("messages", message.timestamp, json)
.then(position => {
return {
success: true
}
})
};
const history = (skip = 0, take = 20) => {
skip = (1 + skip) * -1; // negate to get FIFO
return new Promise((resolve, reject) =>
client.lrange("messages", skip * take, skip, (err, data) => {
if (err) {
console.log(err);
reject(err);
}
const history = (page=1, limit=10) => {
const start = (page - 1) * limit;
const stop = (limit * page) - 1;
data = data.map(data => JSON.parse(data));
resolve(data);
const getTotalCount = client.zcount("messages", '-inf', '+inf');
const getMessages = client.zrevrange("messages", start, stop);
return Promise.all([getTotalCount, getMessages])
.then(([totalCount, messages]) => {
if (messages) {
return {
messages: messages.map(entry => JSON.parse(entry)).reverse(),
count: messages.length,
total: totalCount
}
} else {
return {
messages: [],
count: 0,
total: 0
}
}
})
);
};
const clearHistory = () => {
return new Promise((resolve, reject) =>
client.del("messages", (err, success) => {
if (err) {
console.log(err);
reject(err);
return client.del("messages")
.then(success => {
return {
success: success == 1 ? true : false
}
resolve(success == 1 ? true : false);
})
);
};
module.exports = {

View File

@@ -1,10 +1,10 @@
const express = require("express");
const path = require("path");
const RequestedWine = require(path.join(
__dirname + "/../schemas/RequestedWine"
__dirname, "/schemas/RequestedWine"
));
const Wine = require(path.join(
__dirname + "/../schemas/Wine"
__dirname, "/schemas/Wine"
));
const deleteRequestedWineById = async (req, res) => {

View File

@@ -1,10 +1,10 @@
const path = require("path");
const Purchase = require(path.join(__dirname + "/../schemas/Purchase"));
const Wine = require(path.join(__dirname + "/../schemas/Wine"));
const Highscore = require(path.join(__dirname + "/../schemas/Highscore"));
const Purchase = require(path.join(__dirname, "/schemas/Purchase"));
const Wine = require(path.join(__dirname, "/schemas/Wine"));
const Highscore = require(path.join(__dirname, "/schemas/Highscore"));
const PreLotteryWine = require(path.join(
__dirname + "/../schemas/PreLotteryWine"
__dirname, "/schemas/PreLotteryWine"
));
const prelotteryWines = async (req, res) => {

View File

@@ -1,22 +1,21 @@
const express = require("express");
const path = require("path");
// Middleware
const mustBeAuthenticated = require(__dirname + "/../middleware/mustBeAuthenticated");
const setAdminHeaderIfAuthenticated = require(__dirname + "/../middleware/setAdminHeaderIfAuthenticated");
const mustBeAuthenticated = require(path.join(__dirname, "/middleware/mustBeAuthenticated"));
const setAdminHeaderIfAuthenticated = require(path.join(__dirname, "/middleware/setAdminHeaderIfAuthenticated"));
const update = require(path.join(__dirname + "/update"));
const retrieve = require(path.join(__dirname + "/retrieve"));
const request = require(path.join(__dirname + "/request"));
const subscriptionApi = require(path.join(__dirname + "/subscriptions"));
const loginApi = require(path.join(__dirname + "/login"));
const wineinfo = require(path.join(__dirname + "/wineinfo"));
const virtualApi = require(path.join(__dirname + "/virtualLottery"));
const update = require(path.join(__dirname, "/update"));
const retrieve = require(path.join(__dirname, "/retrieve"));
const request = require(path.join(__dirname, "/request"));
const subscriptionApi = require(path.join(__dirname, "/subscriptions"));
const userApi = require(path.join(__dirname, "/user"));
const wineinfo = require(path.join(__dirname, "/wineinfo"));
const virtualApi = require(path.join(__dirname, "/virtualLottery"));
const virtualRegistrationApi = require(path.join(
__dirname + "/virtualRegistration"
__dirname, "/virtualRegistration"
));
const lottery = require(path.join(__dirname + "/lottery"));
const lottery = require(path.join(__dirname, "/lottery"));
const chatHistoryApi = require(path.join(__dirname, "/chatHistory"));
const router = express.Router();
@@ -61,10 +60,11 @@ router.post('/winner/notify/:id', virtualRegistrationApi.sendNotificationToWinne
router.get('/winner/:id', virtualRegistrationApi.getWinesToWinnerById);
router.post('/winner/:id', virtualRegistrationApi.registerWinnerSelection);
// router.use("/api/", updateApi);
// router.use("/api/", retrieveApi);
// router.use("/api/", wineinfoApi);
// router.use("/api/lottery", lottery);
// router.use("/virtual-registration/", virtualRegistrationApi);
router.get('/chat/history', chatHistoryApi.getAllHistory)
router.delete('/chat/history', mustBeAuthenticated, chatHistoryApi.deleteHistory)
router.post('/login', userApi.login);
router.post('/register', mustBeAuthenticated, userApi.register);
router.get('/logout', userApi.logout);
module.exports = router;

View File

@@ -5,11 +5,11 @@ const webpush = require("web-push"); //requiring the web-push module
const schedule = require("node-schedule");
const mustBeAuthenticated = require(path.join(
__dirname + "/../middleware/mustBeAuthenticated"
__dirname, "/middleware/mustBeAuthenticated"
));
const config = require(path.join(__dirname + "/../config/defaults/push"));
const Subscription = require(path.join(__dirname + "/../schemas/Subscription"));
const Subscription = require(path.join(__dirname, "/schemas/Subscription"));
const lotteryConfig = require(path.join(
__dirname + "/../config/defaults/lottery"
));

View File

@@ -1,14 +1,14 @@
const express = require("express");
const path = require("path");
const sub = require(path.join(__dirname + "/../api/subscriptions"));
const sub = require(path.join(__dirname, "/subscriptions"));
const _wineFunctions = require(path.join(__dirname + "/../api/wine"));
const _personFunctions = require(path.join(__dirname + "/../api/person"));
const Subscription = require(path.join(__dirname + "/../schemas/Subscription"));
const Lottery = require(path.join(__dirname + "/../schemas/Purchase"));
const _wineFunctions = require(path.join(__dirname, "/wine"));
const _personFunctions = require(path.join(__dirname, "/person"));
const Subscription = require(path.join(__dirname, "/schemas/Subscription"));
const Lottery = require(path.join(__dirname, "/schemas/Purchase"));
const PreLotteryWine = require(path.join(
__dirname + "/../schemas/PreLotteryWine"
__dirname, "/schemas/PreLotteryWine"
));
const submitWines = async (req, res) => {

51
api/user.js Normal file
View File

@@ -0,0 +1,51 @@
const passport = require("passport");
const path = require("path");
const User = require(path.join(__dirname, "/schemas/User"));
const router = require("express").Router();
const register = (req, res, next) => {
User.register(
new User({ username: req.body.username }),
req.body.password,
function(err) {
if (err) {
if (err.name == "UserExistsError")
res.status(409).send({ success: false, message: err.message })
else if (err.name == "MissingUsernameError" || err.name == "MissingPasswordError")
res.status(400).send({ success: false, message: err.message })
return next(err);
}
return res.status(200).send({ message: "Bruker registrert. Velkommen " + req.body.username, success: true })
}
);
};
const login = (req, res, next) => {
passport.authenticate("local", function(err, user, info) {
if (err) {
if (err.name == "MissingUsernameError" || err.name == "MissingPasswordError")
return res.status(400).send({ message: err.message, success: false })
return next(err);
}
if (!user) return res.status(404).send({ message: "Incorrect username or password", success: false })
req.logIn(user, (err) => {
if (err) { return next(err) }
return res.status(200).send({ message: "Velkommen " + user.username, success: true })
})
})(req, res, next);
};
const logout = (req, res) => {
req.logout();
res.redirect("/");
};
module.exports = {
register,
login,
logout
};

View File

@@ -1,13 +1,13 @@
const path = require("path");
const crypto = require("crypto");
const config = require(path.join(__dirname + "/../config/defaults/lottery"));
const Message = require(path.join(__dirname + "/message"));
const { findAndNotifyNextWinner } = require(path.join(__dirname + "/virtualRegistration"));
const config = require(path.join(__dirname, "/../config/defaults/lottery"));
const Message = require(path.join(__dirname, "/message"));
const { findAndNotifyNextWinner } = require(path.join(__dirname, "/virtualRegistration"));
const Attendee = require(path.join(__dirname + "/../schemas/Attendee"));
const VirtualWinner = require(path.join(__dirname + "/../schemas/VirtualWinner"));
const PreLotteryWine = require(path.join(__dirname + "/../schemas/PreLotteryWine"));
const Attendee = require(path.join(__dirname, "/schemas/Attendee"));
const VirtualWinner = require(path.join(__dirname, "/schemas/VirtualWinner"));
const PreLotteryWine = require(path.join(__dirname, "/schemas/PreLotteryWine"));
const winners = async (req, res) => {

View File

@@ -1,13 +1,13 @@
const path = require("path");
const _wineFunctions = require(path.join(__dirname + "/../api/wine"));
const _personFunctions = require(path.join(__dirname + "/../api/person"));
const Message = require(path.join(__dirname + "/../api/message"));
const _wineFunctions = require(path.join(__dirname, "/wine"));
const _personFunctions = require(path.join(__dirname, "/person"));
const Message = require(path.join(__dirname, "/message"));
const VirtualWinner = require(path.join(
__dirname + "/../schemas/VirtualWinner"
__dirname, "/schemas/VirtualWinner"
));
const PreLotteryWine = require(path.join(
__dirname + "/../schemas/PreLotteryWine"
__dirname, "/schemas/PreLotteryWine"
));

View File

@@ -1,5 +1,5 @@
const path = require("path");
const Wine = require(path.join(__dirname + "/../schemas/Wine"));
const Wine = require(path.join(__dirname, "/schemas/Wine"));
async function findSaveWine(prelotteryWine) {
let wonWine = await Wine.findOne({ name: prelotteryWine.name });

View File

@@ -2,7 +2,7 @@ try {
module.exports = require("../env/lottery.config");
} catch (e) {
console.error(
"You haven't defined lottery-configs, you sure you want to continue without them?"
"⚠️ You haven't defined lottery-configs, you sure you want to continue without them?\n"
);
module.exports = {
name: "NAME MISSING",
@@ -11,7 +11,6 @@ try {
message: "INSERT MESSAGE",
date: 5,
hours: 15,
apiUrl: "http://localhost:30030",
gatewayToken: "asd"
};
}

View File

@@ -2,7 +2,7 @@ try {
module.exports = require("../env/push.config");
} catch (e) {
console.error(
"You haven't defined push-parameters, you sure you want to continue without them?"
"⚠️ You haven't defined push-parameters, you sure you want to continue without them?\n"
);
module.exports = { publicKey: false, privateKey: false, mailto: false };
}

View File

@@ -5,7 +5,8 @@ module.exports = {
message: "VINLOTTERI",
date: 5,
hours: 15,
apiUrl: undefined,
gatewayToken: undefined,
vinmonopoletToken: undefined
vinmonopoletToken: undefined,
googleanalytics_trackingId: undefined,
googleanalytics_cookieLifetime: 60 * 60 * 24 * 14
};

View File

@@ -2,14 +2,14 @@
const webpack = require("webpack");
const helpers = require("./helpers");
const UglifyJSPlugin = require("uglifyjs-webpack-plugin");
const TerserPlugin = require("terser-webpack-plugin");
const ServiceWorkerConfig = {
resolve: {
extensions: [".js", ".vue"]
},
entry: {
serviceWorker: [helpers.root("src/service-worker", "service-worker")]
serviceWorker: [helpers.root("frontend/service-worker", "service-worker")]
},
optimization: {
minimizer: []
@@ -19,7 +19,7 @@ const ServiceWorkerConfig = {
{
test: /\.js$/,
loader: "babel-loader",
include: [helpers.root("src/service-worker", "service-worker")]
include: [helpers.root("frontend/service-worker", "service-worker")]
}
]
},
@@ -31,11 +31,10 @@ const ServiceWorkerConfig = {
//filename: "js/[name].bundle.js"
},
optimization: {
minimize: true,
minimizer: [
new UglifyJSPlugin({
cache: true,
parallel: false,
sourceMap: false
new TerserPlugin({
test: /\.js(\?.*)?$/i,
})
]
},

View File

@@ -1,28 +0,0 @@
"use strict";
const HtmlWebpackPlugin = require("html-webpack-plugin");
const helpers = require("./helpers");
const VinlottisConfig = {
entry: {
vinlottis: ["@babel/polyfill", helpers.root("src", "vinlottis-init")]
},
optimization: {
minimizer: [
new HtmlWebpackPlugin({
chunks: ["vinlottis"],
filename: "../index.html",
template: "./src/templates/Create.html",
inject: true,
minify: {
removeComments: true,
collapseWhitespace: false,
preserveLineBreaks: true,
removeAttributeQuotes: true
}
})
]
}
};
module.exports = VinlottisConfig;

View File

@@ -11,10 +11,16 @@ const webpackConfig = function(isDev) {
resolve: {
extensions: [".js", ".vue"],
alias: {
vue$: isDev ? "vue/dist/vue.min.js" : "vue/dist/vue.min.js",
"@": helpers.root("src")
vue$: "vue/dist/vue.min.js",
"@": helpers.root("frontend")
}
},
entry: {
vinlottis: helpers.root("frontend", "vinlottis-init")
},
externals: {
moment: 'moment' // comes with chart.js
},
module: {
rules: [
{
@@ -33,35 +39,31 @@ const webpackConfig = function(isDev) {
},
{
test: /\.js$/,
loader: "babel-loader",
include: [helpers.root("src")]
use: [ "babel-loader" ],
include: [helpers.root("frontend")]
},
{
test: /\.css$/,
use: [
isDev ? "vue-style-loader" : MiniCSSExtractPlugin.loader,
MiniCSSExtractPlugin.loader,
{ loader: "css-loader", options: { sourceMap: isDev } }
]
},
{
test: /\.scss$/,
use: [
isDev ? "vue-style-loader" : MiniCSSExtractPlugin.loader,
{ loader: "css-loader", options: { sourceMap: isDev } },
{ loader: "sass-loader", options: { sourceMap: isDev } }
]
},
{
test: /\.sass$/,
use: [
isDev ? "vue-style-loader" : MiniCSSExtractPlugin.loader,
MiniCSSExtractPlugin.loader,
{ loader: "css-loader", options: { sourceMap: isDev } },
{ loader: "sass-loader", options: { sourceMap: isDev } }
]
},
{
test: /\.woff(2)?(\?[a-z0-9]+)?$/,
loader: "url-loader?limit=10000&mimetype=application/font-woff"
loader: "url-loader",
options: {
limit: 10000,
mimetype: "application/font-woff"
}
},
{
test: /\.(ttf|eot|svg)(\?[a-z0-9]+)?$/,
@@ -72,14 +74,16 @@ const webpackConfig = function(isDev) {
plugins: [
new VueLoaderPlugin(),
new webpack.DefinePlugin({
__ENV__: JSON.stringify(process.env.NODE_ENV),
__NAME__: JSON.stringify(env.name),
__PHONE__: JSON.stringify(env.phone),
__PRICE__: env.price,
__MESSAGE__: JSON.stringify(env.message),
__DATE__: env.date,
__HOURS__: env.hours,
__APIURL__: JSON.stringify(env.apiUrl),
__PUSHENABLED__: JSON.stringify(require("./defaults/push") != false)
__PUSHENABLED__: JSON.stringify(require("./defaults/push") != false),
__GA_TRACKINGID__: JSON.stringify(env.googleanalytics_trackingId),
__GA_COOKIELIFETIME__: env.googleanalytics_cookieLifetime
})
]
};

View File

@@ -1,52 +1,63 @@
"use strict";
const webpack = require("webpack");
const merge = require("webpack-merge");
const { merge } = require("webpack-merge");
const FriendlyErrorsPlugin = require("friendly-errors-webpack-plugin");
const HtmlPlugin = require("html-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const helpers = require("./helpers");
const commonConfig = require("./webpack.config.common");
const environment = require("./env/dev.env");
const MiniCSSExtractPlugin = require("mini-css-extract-plugin");
let webpackConfig = merge(commonConfig(true), {
mode: "development",
devtool: "cheap-module-eval-source-map",
devtool: "eval-cheap-module-source-map",
output: {
path: helpers.root("dist"),
publicPath: "/",
filename: "js/[name].bundle.js",
chunkFilename: "js/[id].chunk.js"
filename: "js/[name].bundle.js"
},
optimization: {
runtimeChunk: "single",
concatenateModules: true,
splitChunks: {
chunks: "all"
chunks: "initial"
}
},
plugins: [
new webpack.EnvironmentPlugin(environment),
new webpack.HotModuleReplacementPlugin(),
new FriendlyErrorsPlugin()
new FriendlyErrorsPlugin(),
new MiniCSSExtractPlugin({
filename: "css/[name].css"
})
],
devServer: {
compress: true,
historyApiFallback: true,
host: "0.0.0.0",
hot: true,
overlay: true,
stats: {
normal: true
}
},
proxy: {
"/api": {
target: "http://localhost:30030",
changeOrigin: true
},
"/socket.io": {
target: "ws://localhost:30030",
changeOrigin: false,
ws: true
}
},
writeToDisk: false
}
});
webpackConfig = merge(webpackConfig, {
entry: {
main: ["@babel/polyfill", helpers.root("src", "vinlottis-init")]
},
plugins: [
new HtmlPlugin({
template: "src/templates/Create.html",
chunksSortMode: "dependency"
new HtmlWebpackPlugin({
template: "frontend/templates/Index.html"
})
]
});

View File

@@ -3,12 +3,15 @@
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const path = require("path");
const webpack = require("webpack");
const merge = require("webpack-merge");
const { merge } = require("webpack-merge");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const MiniCSSExtractPlugin = require("mini-css-extract-plugin");
const UglifyJSPlugin = require("uglifyjs-webpack-plugin");
const TerserPlugin = require("terser-webpack-plugin");
const helpers = require("./helpers");
const commonConfig = require("./webpack.config.common");
const isProd = process.env.NODE_ENV === "production";
const environment = isProd
? require("./env/prod.env")
@@ -16,11 +19,11 @@ const environment = isProd
const webpackConfig = merge(commonConfig(false), {
mode: "production",
stats: { children: false },
output: {
path: helpers.root("public/dist"),
publicPath: "/dist/",
filename: "js/[name].bundle.[hash:7].js"
//filename: "js/[name].bundle.js"
publicPath: "/public/dist/",
filename: "js/[name].bundle.[fullhash:7].js"
},
optimization: {
splitChunks: {
@@ -33,37 +36,47 @@ const webpackConfig = merge(commonConfig(false), {
}
}
},
minimize: true,
minimizer: [
new HtmlWebpackPlugin({
chunks: ["vinlottis"],
filename: "index.html",
template: "./frontend/templates/Index.html",
inject: true,
minify: {
removeComments: true,
collapseWhitespace: false,
preserveLineBreaks: true,
removeAttributeQuotes: true
}
}),
new OptimizeCSSAssetsPlugin({
cssProcessorPluginOptions: {
preset: ["default", { discardComments: { removeAll: true } }]
}
}),
new UglifyJSPlugin({
cache: true,
parallel: false,
sourceMap: !isProd
new TerserPlugin({
test: /\.js(\?.*)?$/i,
})
]
},
plugins: [
new CleanWebpackPlugin(),
new CleanWebpackPlugin(), // clean output folder
new webpack.EnvironmentPlugin(environment),
new MiniCSSExtractPlugin({
filename: "css/[name].[hash:7].css"
//filename: "css/[name].css"
filename: "css/[name].[fullhash:7].css"
})
]
});
if (!isProd) {
webpackConfig.devtool = "source-map";
}
if (process.env.npm_config_report) {
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
.BundleAnalyzerPlugin;
webpackConfig.plugins.push(new BundleAnalyzerPlugin());
}
if (process.env.BUILD_REPORT) {
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
.BundleAnalyzerPlugin;
webpackConfig.plugins.push(new BundleAnalyzerPlugin());
}
module.exports = webpackConfig;

View File

@@ -83,22 +83,6 @@ export default {
@import "styles/positioning.scss";
@import "styles/vinlottis-icons";
@font-face {
font-family: "knowit";
font-weight: 600;
src: url("/../public/assets/fonts/bold.woff"),
url("/../public/assets/fonts/bold.woff") format("woff"), local("Arial");
font-display: swap;
}
@font-face {
font-family: "knowit";
font-weight: 300;
src: url("/../public/assets/fonts/regular.eot"),
url("/../public/assets/fonts/regular.woff") format("woff"), local("Arial");
font-display: swap;
}
body {
background-color: $primary;
}

369
frontend/api.js Normal file
View File

@@ -0,0 +1,369 @@
import fetch from "node-fetch";
const BASE_URL = window.location.origin;
const statistics = () => {
return fetch("/api/purchase/statistics")
.then(resp => resp.json());
};
const colorStatistics = () => {
return fetch("/api/purchase/statistics/color")
.then(resp => resp.json());
};
const highscoreStatistics = () => {
return fetch("/api/highscore/statistics")
.then(resp => resp.json());
};
const overallWineStatistics = () => {
return fetch("/api/wines/statistics/overall")
.then(resp => resp.json());
};
const allRequestedWines = () => {;
return fetch("/api/request/all")
.then(resp => {
const isAdmin = resp.headers.get("vinlottis-admin") == "true";
return Promise.all([resp.json(), isAdmin]);
});
};
const chartWinsByColor = () => {
return fetch("/api/purchase/statistics/color")
.then(resp => resp.json());
};
const chartPurchaseByColor = () => {
return fetch("/api/purchase/statistics")
.then(resp => resp.json());
};
const prelottery = () => {
return fetch("/api/wines/prelottery")
.then(resp => resp.json());
};
const sendLottery = sendObject => {
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify(sendObject)
};
return fetch("/api/lottery", options)
.then(resp => resp.json());
};
const sendLotteryWinners = sendObject => {
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify(sendObject)
};
return fetch("/api/lottery/winners", options)
.then(resp => resp.json());
};
const addAttendee = sendObject => {
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify(sendObject)
};
return fetch("/api/virtual/attendee/add", options)
.then(resp => resp.json());
};
const getVirtualWinner = () => {
return fetch("/api/virtual/winner/draw")
.then(resp => resp.json());
};
const attendeesSecure = () => {
return fetch("/api/virtual/attendee/all/secure")
.then(resp => resp.json());
};
const winnersSecure = () => {
return fetch("/api/virtual/winner/all/secure")
.then(resp => resp.json());
};
const winners = () => {
return fetch("/api/virtual/winner/all")
.then(resp => resp.json());
};
const deleteRequestedWine = wineToBeDeleted => {
const options = {
headers: {
"Content-Type": "application/json"
},
method: "DELETE",
body: JSON.stringify(wineToBeDeleted)
};
return fetch("api/request/" + wineToBeDeleted.id, options)
.then(resp => resp.json());
}
const deleteWinners = () => {
const options = {
headers: {
"Content-Type": "application/json"
},
method: "DELETE"
};
return fetch("/api/virtual/winner/all", options)
.then(resp => resp.json());
};
const deleteAttendees = () => {
const options = {
headers: {
"Content-Type": "application/json"
},
method: "DELETE"
};
return fetch("/api/virtual/attendee/all", options)
.then(resp => resp.json());
};
const attendees = () => {
return fetch("/api/virtual/attendee/all")
.then(resp => resp.json());
};
const requestNewWine = (wine) => {
const options = {
body: JSON.stringify({
wine: wine
}),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
method: "post"
}
return fetch("/api/request/new-wine", options)
.then(resp => resp.json())
}
const logWines = wines => {
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify(wines)
};
return fetch("/api/log/wines", 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 => {
return fetch("/api/wineinfo/")
.then(async resp => {
if (!resp.ok) {
if (resp.status == 404) {
throw await resp.json();
}
} else {
return resp.json();
}
});
};
const searchForWine = searchString => {
return fetch("/api/wineinfo/search?query=" + searchString)
.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 options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify({ username, password })
};
return fetch("/api/login", options)
.then(resp => {
if (resp.ok) {
return resp.json();
} else {
return handleErrors(resp);
}
});
};
const register = (username, password) => {
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify({ username, password })
};
return fetch("/api/register", 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 options = {
method: 'POST'
}
return fetch("/api/virtual/finish", options)
.then(resp => resp.json());
};
const getAmIWinner = id => {
return fetch(`/api/winner/${id}`)
.then(resp => resp.json());
};
const postWineChosen = (id, wineName) => {
const options = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify({ wineName: wineName })
};
return fetch(`/api/winner/${id}`, options)
.then(resp => {
if (resp.ok) {
return resp.json();
} else {
return handleErrors(resp);
}
});
};
const historyAll = () => {
return fetch(`/api/lottery/all`)
.then(resp => {
if (resp.ok) {
return resp.json();
} else {
return handleErrors(resp);
}
});
}
const historyByDate = (date) => {
return fetch(`/api/lottery/by-date/${ date }`)
.then(resp => {
if (resp.ok) {
return resp.json();
} else {
return handleErrors(resp);
}
});
}
const getWinnerByName = (name) => {
const encodedName = encodeURIComponent(name)
return fetch(`/api/lottery/by-name/${name}`)
.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

@@ -39,8 +39,8 @@ export default {
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.scss";
@import "./src/styles/variables.scss";
@import "@/styles/media-queries.scss";
@import "@/styles/variables.scss";
.container {
width: 90vw;

View File

@@ -32,7 +32,6 @@
</template>
<script>
import { page, event } from "vue-analytics";
import Banner from "@/ui/Banner";
import Wine from "@/ui/Wine";
import { overallWineStatistics } from "@/api";
@@ -62,8 +61,8 @@ export default {
</script>
<style lang="scss" scoped>
@import "./src/styles/media-queries";
@import "./src/styles/variables";
@import "@/styles/media-queries";
@import "@/styles/variables";
.container {
width: 90vw;

View File

@@ -13,7 +13,6 @@
</template>
<script>
import { page, event } from "vue-analytics";
import RaffleGenerator from "@/ui/RaffleGenerator";
import Vipps from "@/ui/Vipps";
import Countdown from "@/ui/Countdown";
@@ -44,7 +43,7 @@ export default {
this.hardStart = true;
},
track() {
this.$ga.page("/lottery/generate");
window.ga('send', 'pageview', '/lottery/generate');
}
}
};

View File

@@ -93,8 +93,8 @@ export default {
</script>
<style lang="scss" scoped>
@import "./src/styles/media-queries.scss";
@import "./src/styles/variables.scss";
@import "@/styles/media-queries.scss";
@import "@/styles/variables.scss";
$elementSpacing: 3.5rem;
.el-spacing {

View File

@@ -125,8 +125,8 @@ export default {
</script>
<style lang="scss" scoped>
@import "./src/styles/variables";
@import "./src/styles/media-queries";
@import "@/styles/variables";
@import "@/styles/media-queries";
$elementSpacing: 3rem;

View File

@@ -654,9 +654,9 @@ hr {
width: 150px;
height: 150px;
margin: 20px;
-webkit-mask-image: url(/../../public/assets/images/lodd.svg);
-webkit-mask-image: url(/public/assets/images/lodd.svg);
background-repeat: no-repeat;
mask-image: url(/../../public/assets/images/lodd.svg);
mask-image: url(/public/assets/images/lodd.svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;

View File

@@ -97,9 +97,9 @@ export default {
</script>
<style lang="scss" scoped>
@import "./src/styles/media-queries";
@import "./src/styles/global";
@import "./src/styles/variables";
@import "@/styles/media-queries";
@import "@/styles/global";
@import "@/styles/variables";
h1{

View File

@@ -8,7 +8,6 @@
</template>
<script>
import { page, event } from "vue-analytics";
import { prelottery } from "@/api";
import Banner from "@/ui/Banner";
import Wine from "@/ui/Wine";
@@ -30,8 +29,8 @@ export default {
</script>
<style lang="scss" scoped>
@import "./src/styles/media-queries";
@import "./src/styles/variables";
@import "@/styles/media-queries";
@import "@/styles/variables";
.wine-image {
height: 250px;

View File

@@ -2,7 +2,7 @@
<main class="main-container">
<section class="top-container">
<div class="want-to-win">
<h1>
Vil du også vinne?
@@ -40,33 +40,32 @@
</div>
</section>
<section class="content-container">
<div class="scroll-info">
<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 { page, event } from "vue-analytics";
import PurchaseGraph from "@/ui/PurchaseGraph";
import TotalBought from "@/ui/TotalBought";
import Highscore from "@/ui/Highscore";
@@ -121,7 +120,7 @@ export default {
this.hardStart = way;
},
track() {
this.$ga.page("/");
window.ga('send', 'pageview', '/');
},
startCountdown() {
this.hardStart = true;
@@ -161,7 +160,7 @@ export default {
font-size: 2em;
font-weight: 400;
}
@include tablet {
h1{
font-size: 3em;
@@ -187,7 +186,7 @@ export default {
align-items: center;
text-decoration: none;
color: black;
i {
color: $link-color;
margin-left: 5px;
@@ -207,7 +206,7 @@ export default {
.see-details-link {
grid-row: 6 / 8;
grid-column: 2 / -1;
@include tablet {
grid-row: 6 / 8;
grid-column: 2 / 10;
@@ -325,7 +324,7 @@ h1 {
display: grid;
grid-template-columns: repeat(12, 1fr);
row-gap: 5em;
.scroll-info {
display: flex;
align-items: center;

View File

@@ -40,23 +40,14 @@
<hr />
<div class="middle-elements">
<Attendees :attendees="attendees" class="outer-attendees" />
<Chat
class="outer-chat"
:chatHistory="chatHistory"
:historyPageSize="historyPageSize"
:usernameAllowed="usernameAllowed"
@loadMoreHistory="loadMoreHistory"
@message="sendMessage"
@username="setUsername"
/>
<Chat class="outer-chat" />
</div>
<Vipps class="vipps" :amount="1" />
</div>
</template>
<script>
import { page, event } from "vue-analytics";
import { attendees, winners, getChatHistory, prelottery } from "@/api";
import { attendees, winners, prelottery } from "@/api";
import Chat from "@/ui/Chat";
import Vipps from "@/ui/Vipps";
import Attendees from "@/ui/Attendees";
@@ -75,44 +66,21 @@ export default {
socket: null,
attendeesFetched: false,
winnersFetched: false,
chatHistory: [],
historyPage: 0,
historyPageSize: 100,
lastHistoryPage: false,
usernameAccepted: false,
username: null,
wasDisconnected: false,
emitUsernameOnConnect: false,
ticketsBought: {}
};
},
created() {
getChatHistory(0, 100).then(messages => (this.chatHistory = messages));
},
mounted() {
this.track();
this.getAttendees();
this.getWinners();
this.socket = io(`${window.location.hostname}:${window.location.port}`);
this.socket = io(window.location.origin);
this.socket.on("color_winner", msg => {});
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("winner", async msg => {
this.currentWinnerDrawn = true;
this.currentWinner = { name: msg.name, color: msg.color };
@@ -131,14 +99,6 @@ export default {
this.socket.on("new_attendee", async msg => {
this.getAttendees();
});
this.socket.on("accept_username", accepted => {
this.usernameAccepted = accepted;
if (!accepted) {
this.username = null;
} else {
window.localStorage.setItem("username", this.username);
}
});
},
beforeDestroy() {
this.socket.disconnect();
@@ -152,26 +112,6 @@ export default {
}
},
methods: {
setUsername: function(username) {
this.username = username;
if (!this.socket || !this.socket.emit) {
this.emitUsernameOnConnect = true;
return;
}
this.socket.emit("username", { username });
},
sendMessage: function(msg) {
this.socket.emit("chat", { message: msg });
},
loadMoreHistory: function() {
const { historyPage, historyPageSize } = this;
const page = historyPage + 1;
getChatHistory(page * historyPageSize, historyPageSize).then(messages => {
this.chatHistory = messages.concat(this.chatHistory);
this.historyPage = page;
});
},
getWinners: async function() {
let response = await winners();
if (response) {
@@ -200,7 +140,7 @@ export default {
this.attendeesFetched = true;
},
track() {
this.$ga.page("/lottery/game");
window.ga('send', 'pageview', '/lottery/game');
}
}
};
@@ -323,9 +263,9 @@ export default {
width: 150px;
height: 150px;
margin: 20px;
-webkit-mask-image: url(/../../public/assets/images/lodd.svg);
-webkit-mask-image: url(/public/assets/images/lodd.svg);
background-repeat: no-repeat;
mask-image: url(/../../public/assets/images/lodd.svg);
mask-image: url(/public/assets/images/lodd.svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;

View File

@@ -359,9 +359,9 @@ hr {
width: 140px;
height: 150px;
margin: 20px 0;
-webkit-mask-image: url(/../../public/assets/images/lodd.svg);
-webkit-mask-image: url(/public/assets/images/lodd.svg);
background-repeat: no-repeat;
mask-image: url(/../../public/assets/images/lodd.svg);
mask-image: url(/public/assets/images/lodd.svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
color: #333333;

View File

@@ -70,7 +70,7 @@ export default {
</script>
<style lang="scss" scoped>
@import "./src/styles/global";
@import "@/styles/global";
.container {
display: flex;
justify-content: center;

View File

@@ -9,8 +9,12 @@ var serviceWorkerRegistrationMixin = {
localStorage.removeItem("push");
}
}
this.registerPushListener();
this.registerServiceWorker();
if (window.location.href.includes('localhost')) {
console.info("Service worker manually disabled while on localhost.")
} else {
this.registerPushListener();
this.registerServiceWorker();
}
},
methods: {
registerPushListener: function() {
@@ -92,4 +96,4 @@ var serviceWorkerRegistrationMixin = {
}
};
module.exports = serviceWorkerRegistrationMixin;
export default serviceWorkerRegistrationMixin;

124
frontend/router.js Normal file
View File

@@ -0,0 +1,124 @@
const VinlottisPage = () => import(
/* webpackChunkName: "landing-page" */
"@/components/VinlottisPage");
const LotteryPage = () => import(
/* webpackChunkName: "landing-page" */
"@/components/LotteryPage");
const GeneratePage = () => import(
/* webpackChunkName: "landing-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

@@ -4,13 +4,13 @@
@font-face {
font-family: "knowit";
font-weight: 600;
src: url("/../../public/assets/fonts/bold.woff");
src: url("/public/assets/fonts/bold.woff");
}
@font-face {
font-family: "knowit";
font-weight: 300;
src: url("/../../public/assets/fonts/regular.eot");
src: url("/public/assets/fonts/regular.woff");
}
body {
@@ -272,9 +272,9 @@ textarea {
.raffle-element {
margin: 20px 0;
-webkit-mask-image: url(/../../public/assets/images/lodd.svg);
-webkit-mask-image: url(/public/assets/images/lodd.svg);
background-repeat: no-repeat;
mask-image: url(/../../public/assets/images/lodd.svg);
mask-image: url(/public/assets/images/lodd.svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
color: #333333;

View File

@@ -55,6 +55,7 @@
<div id="app"></div>
<noscript>Du trenger vin, jeg trenger javascript!</noscript>
<script src="/public/analytics.js" async></script>
</body>
</html>

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

@@ -0,0 +1,336 @@
<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() {
this.socket = io(window.location.origin);
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>

View File

@@ -112,13 +112,11 @@ export default {
this.emitColors()
if (window.location.hostname == "localhost") {
return;
}
this.$ga.event({
window.ga('send', {
hitType: "event",
eventCategory: "Raffles",
eventAction: "Generate",
eventValue: JSON.stringify(this.colors)
eventLabel: JSON.stringify(this.colors)
});
return;
}
@@ -292,9 +290,9 @@ label .text {
width: 150px;
height: 150px;
margin: 20px;
-webkit-mask-image: url(/../../public/assets/images/lodd.svg);
-webkit-mask-image: url(/public/assets/images/lodd.svg);
background-repeat: no-repeat;
mask-image: url(/../../public/assets/images/lodd.svg);
mask-image: url(/public/assets/images/lodd.svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;

View File

@@ -77,7 +77,7 @@ export default {
</script>
<style lang="scss" scoped>
@import "./src/styles/variables";
@import "@/styles/variables";
.requested-count {
display: flex;

View File

@@ -94,8 +94,8 @@ export default {
</script>
<style lang="scss" scoped>
@import "./src/styles/variables";
@import "./src/styles/global";
@import "@/styles/variables";
@import "@/styles/global";
video {
width: 100%;

View File

@@ -62,8 +62,8 @@ export default {
</script>
<style lang="scss" scoped>
@import "./src/styles/media-queries";
@import "./src/styles/variables";
@import "@/styles/media-queries";
@import "@/styles/variables";
.wine {
padding: 1rem;

View File

@@ -24,7 +24,6 @@
</template>
<script>
import { event } from "vue-analytics";
import Wine from "@/ui/Wine";
import { overallWineStatistics } from "@/api";
@@ -124,8 +123,8 @@ export default {
</script>
<style lang="scss" scoped>
@import "./src/styles/variables.scss";
@import "./src/styles/global.scss";
@import "@/styles/variables.scss";
@import "@/styles/global.scss";
@import "../styles/media-queries.scss";
.wines-main-container {

View File

@@ -1,4 +1,4 @@
const dateString = (date) => {
if (typeof(date) == "string") {
date = new Date(date);
@@ -20,7 +20,7 @@ function daysAgo(date) {
return Math.round(Math.abs((new Date() - new Date(date)) / day));
}
module.exports = {
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)
});

View File

@@ -4,80 +4,64 @@
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "cross-env NODE_ENV=production webpack --progress",
"build-report": "cross-env NODE_ENV=production BUILD_REPORT=true webpack --progress",
"dev": "yarn webpack serve --mode development --env development",
"start": "node server.js",
"dev": "cross-env NODE_ENV=development webpack-dev-server",
"build": "cross-env NODE_ENV=production webpack --hide-modules"
"start-noauth": "cross-env NODE_ENV=development node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"@babel/polyfill": "~7.2",
"@sentry/browser": "^5.27.4",
"@sentry/integrations": "^5.27.4",
"@sentry/tracing": "^5.27.4",
"@zxing/library": "^0.15.2",
"body-parser": "^1.19.0",
"@sentry/browser": "^5.28.0",
"@sentry/integrations": "^5.28.0",
"@zxing/library": "^0.18.3",
"canvas-confetti": "^1.2.0",
"cross-env": "^7.0.3",
"chart.js": "^2.9.3",
"clean-webpack-plugin": "^3.0.0",
"compression": "^1.7.4",
"connect-mongo": "^3.2.0",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-session": "^1.17.0",
"extract-text-webpack-plugin": "^3.0.2",
"feature-policy": "^0.4.0",
"helmet": "^3.21.2",
"moment": "^2.24.0",
"mongoose": "^5.8.7",
"mongoose": "^5.11.4",
"node-fetch": "^2.6.0",
"node-sass": "^4.13.0",
"node-sass": "^5.0.0",
"node-schedule": "^1.3.2",
"passport": "^0.4.1",
"passport-local": "^1.0.0",
"passport-local-mongoose": "^6.0.1",
"qrcode": "^1.4.4",
"referrer-policy": "^1.2.0",
"socket.io": "^2.3.0",
"socket.io-client": "^2.3.0",
"socket.io": "^3.0.3",
"socket.io-client": "^3.0.3",
"vue": "~2.6",
"vue-analytics": "^5.22.1",
"vue-router": "~3.0",
"vuex": "^3.1.1",
"vue-router": "~3.4.9",
"vuex": "^3.6.0",
"web-push": "^3.4.3"
},
"devDependencies": {
"@babel/core": "~7.2",
"@babel/plugin-proposal-class-properties": "~7.3",
"@babel/plugin-proposal-decorators": "~7.3",
"@babel/plugin-proposal-json-strings": "~7.2",
"@babel/plugin-syntax-dynamic-import": "~7.2",
"@babel/plugin-syntax-import-meta": "~7.2",
"@babel/preset-env": "~7.3",
"babel-loader": "~8.0",
"compression-webpack-plugin": "^3.1.0",
"cross-env": "^6.0.3",
"css-loader": "^3.2.0",
"file-loader": "^4.2.0",
"@babel/core": "~7.12",
"@babel/preset-env": "~7.12",
"babel-loader": "~8.2.2",
"clean-webpack-plugin": "^3.0.0",
"core-js": "3.8.1",
"css-loader": "^5.0.1",
"file-loader": "^6.2.0",
"friendly-errors-webpack-plugin": "~1.7",
"google-maps-api-loader": "^1.1.1",
"html-webpack-plugin": "~3.2",
"mini-css-extract-plugin": "~0.5",
"optimize-css-assets-webpack-plugin": "~3.2",
"pm2": "^4.2.3",
"html-webpack-plugin": "5.0.0-alpha.15",
"mini-css-extract-plugin": "~1.3.2",
"optimize-css-assets-webpack-plugin": "~5.0.4",
"redis": "^3.0.2",
"sass-loader": "~7.1",
"uglifyjs-webpack-plugin": "~1.2",
"url-loader": "^2.2.0",
"vue-loader": "~15.6",
"sass-loader": "~10.1.0",
"url-loader": "^4.1.1",
"vue-loader": "~15.9.5",
"vue-style-loader": "~4.1",
"vue-template-compiler": "~2.6",
"webpack": "~4.41.5",
"webpack-bundle-analyzer": "^3.6.0",
"webpack-cli": "~3.2",
"webpack-dev-server": "~3.1",
"webpack-hot-middleware": "~2.24",
"webpack-merge": "~4.2"
"vue-template-compiler": "^2.6.12",
"webpack": "~5.10.0",
"webpack-bundle-analyzer": "^4.2.0",
"webpack-cli": "~4.2.0",
"webpack-dev-server": "~3.11",
"webpack-merge": "~5.4"
}
}

View File

@@ -1,12 +0,0 @@
{
"apps": [
{
"name": "vinlottis",
"script": "./server.js",
"watch": true,
"instances": "max",
"exec_mode": "cluster",
"ignore_watch": ["./node_modules", "./public/assets/"]
}
]
}

88
public/analytics.js Normal file
View File

@@ -0,0 +1,88 @@
// https://www.google-analytics.com/analytics.js - 24.11.2020
(function(){/*
Copyright The Closure Library Authors.
SPDX-License-Identifier: Apache-2.0
*/
var l=this||self,m=function(a,b){a=a.split(".");var c=l;a[0]in c||"undefined"==typeof c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)a.length||void 0===b?c=c[d]&&c[d]!==Object.prototype[d]?c[d]:c[d]={}:c[d]=b};var q=function(a,b){for(var c in b)b.hasOwnProperty(c)&&(a[c]=b[c])},r=function(a){for(var b in a)if(a.hasOwnProperty(b))return!0;return!1};var t=/^(?:(?:https?|mailto|ftp):|[^:/?#]*(?:[/?#]|$))/i;var u=window,v=document,w=function(a,b){v.addEventListener?v.addEventListener(a,b,!1):v.attachEvent&&v.attachEvent("on"+a,b)};var x={},y=function(){x.TAGGING=x.TAGGING||[];x.TAGGING[1]=!0};var z=/:[0-9]+$/,A=function(a,b,c){a=a.split("&");for(var d=0;d<a.length;d++){var e=a[d].split("=");if(decodeURIComponent(e[0]).replace(/\+/g," ")===b)return b=e.slice(1).join("="),c?b:decodeURIComponent(b).replace(/\+/g," ")}},D=function(a,b){b&&(b=String(b).toLowerCase());if("protocol"===b||"port"===b)a.protocol=B(a.protocol)||B(u.location.protocol);"port"===b?a.port=String(Number(a.hostname?a.port:u.location.port)||("http"==a.protocol?80:"https"==a.protocol?443:"")):"host"===b&&(a.hostname=(a.hostname||
u.location.hostname).replace(z,"").toLowerCase());return C(a,b,void 0,void 0,void 0)},C=function(a,b,c,d,e){var f=B(a.protocol);b&&(b=String(b).toLowerCase());switch(b){case "url_no_fragment":d="";a&&a.href&&(d=a.href.indexOf("#"),d=0>d?a.href:a.href.substr(0,d));a=d;break;case "protocol":a=f;break;case "host":a=a.hostname.replace(z,"").toLowerCase();c&&(d=/^www\d*\./.exec(a))&&d[0]&&(a=a.substr(d[0].length));break;case "port":a=String(Number(a.port)||("http"==f?80:"https"==f?443:""));break;case "path":a.pathname||
a.hostname||y();a="/"==a.pathname.substr(0,1)?a.pathname:"/"+a.pathname;a=a.split("/");a:if(d=d||[],c=a[a.length-1],Array.prototype.indexOf)d=d.indexOf(c),d="number"==typeof d?d:-1;else{for(e=0;e<d.length;e++)if(d[e]===c){d=e;break a}d=-1}0<=d&&(a[a.length-1]="");a=a.join("/");break;case "query":a=a.search.replace("?","");e&&(a=A(a,e,void 0));break;case "extension":a=a.pathname.split(".");a=1<a.length?a[a.length-1]:"";a=a.split("/")[0];break;case "fragment":a=a.hash.replace("#","");break;default:a=
a&&a.href}return a},B=function(a){return a?a.replace(":","").toLowerCase():""},E=function(a){var b=v.createElement("a");a&&(b.href=a);var c=b.pathname;"/"!==c[0]&&(a||y(),c="/"+c);a=b.hostname.replace(z,"");return{href:b.href,protocol:b.protocol,host:b.host,hostname:a,pathname:c,search:b.search,hash:b.hash,port:b.port}};function F(){for(var a=G,b={},c=0;c<a.length;++c)b[a[c]]=c;return b}function H(){var a="ABCDEFGHIJKLMNOPQRSTUVWXYZ";a+=a.toLowerCase()+"0123456789-_";return a+"."}var G,I;function J(a){G=G||H();I=I||F();for(var b=[],c=0;c<a.length;c+=3){var d=c+1<a.length,e=c+2<a.length,f=a.charCodeAt(c),g=d?a.charCodeAt(c+1):0,h=e?a.charCodeAt(c+2):0,k=f>>2;f=(f&3)<<4|g>>4;g=(g&15)<<2|h>>6;h&=63;e||(h=64,d||(g=64));b.push(G[k],G[f],G[g],G[h])}return b.join("")}
function K(a){function b(k){for(;d<a.length;){var n=a.charAt(d++),p=I[n];if(null!=p)return p;if(!/^[\s\xa0]*$/.test(n))throw Error("Unknown base64 encoding at char: "+n);}return k}G=G||H();I=I||F();for(var c="",d=0;;){var e=b(-1),f=b(0),g=b(64),h=b(64);if(64===h&&-1===e)return c;c+=String.fromCharCode(e<<2|f>>4);64!=g&&(c+=String.fromCharCode(f<<4&240|g>>2),64!=h&&(c+=String.fromCharCode(g<<6&192|h)))}};var L;var N=function(){var a=aa,b=ba,c=M(),d=function(g){a(g.target||g.srcElement||{})},e=function(g){b(g.target||g.srcElement||{})};if(!c.init){w("mousedown",d);w("keyup",d);w("submit",e);var f=HTMLFormElement.prototype.submit;HTMLFormElement.prototype.submit=function(){b(this);f.call(this)};c.init=!0}},O=function(a,b,c,d,e){a={callback:a,domains:b,fragment:2===c,placement:c,forms:d,sameHost:e};M().decorators.push(a)},P=function(a,b,c){for(var d=M().decorators,e={},f=0;f<d.length;++f){var g=d[f],h;if(h=
!c||g.forms)a:{h=g.domains;var k=a,n=!!g.sameHost;if(h&&(n||k!==v.location.hostname))for(var p=0;p<h.length;p++)if(h[p]instanceof RegExp){if(h[p].test(k)){h=!0;break a}}else if(0<=k.indexOf(h[p])||n&&0<=h[p].indexOf(k)){h=!0;break a}h=!1}h&&(h=g.placement,void 0==h&&(h=g.fragment?2:1),h===b&&q(e,g.callback()))}return e},M=function(){var a={};var b=u.google_tag_data;u.google_tag_data=void 0===b?a:b;a=u.google_tag_data;b=a.gl;b&&b.decorators||(b={decorators:[]},a.gl=b);return b};var ca=/(.*?)\*(.*?)\*(.*)/,da=/([^?#]+)(\?[^#]*)?(#.*)?/;function Q(a){return new RegExp("(.*?)(^|&)"+a+"=([^&]*)&?(.*)")}
var S=function(a){var b=[],c;for(c in a)if(a.hasOwnProperty(c)){var d=a[c];void 0!==d&&d===d&&null!==d&&"[object Object]"!==d.toString()&&(b.push(c),b.push(J(String(d))))}a=b.join("*");return["1",R(a),a].join("*")},R=function(a,b){a=[window.navigator.userAgent,(new Date).getTimezoneOffset(),window.navigator.userLanguage||window.navigator.language,Math.floor((new Date).getTime()/60/1E3)-(void 0===b?0:b),a].join("*");if(!(b=L)){b=Array(256);for(var c=0;256>c;c++){for(var d=c,e=0;8>e;e++)d=d&1?d>>>1^
3988292384:d>>>1;b[c]=d}}L=b;b=4294967295;for(c=0;c<a.length;c++)b=b>>>8^L[(b^a.charCodeAt(c))&255];return((b^-1)>>>0).toString(36)},fa=function(a){return function(b){var c=E(u.location.href),d=c.search.replace("?","");var e=A(d,"_gl",!0);b.query=T(e||"")||{};e=D(c,"fragment");var f=e.match(Q("_gl"));b.fragment=T(f&&f[3]||"")||{};a&&ea(c,d,e)}};function U(a,b){if(a=Q(a).exec(b)){var c=a[2],d=a[4];b=a[1];d&&(b=b+c+d)}return b}
var ea=function(a,b,c){function d(f,g){f=U("_gl",f);f.length&&(f=g+f);return f}if(u.history&&u.history.replaceState){var e=Q("_gl");if(e.test(b)||e.test(c))a=D(a,"path"),b=d(b,"?"),c=d(c,"#"),u.history.replaceState({},void 0,""+a+b+c)}},T=function(a){var b=void 0===b?3:b;try{if(a){a:{for(var c=0;3>c;++c){var d=ca.exec(a);if(d){var e=d;break a}a=decodeURIComponent(a)}e=void 0}if(e&&"1"===e[1]){var f=e[2],g=e[3];a:{for(e=0;e<b;++e)if(f===R(g,e)){var h=!0;break a}h=!1}if(h){b={};var k=g?g.split("*"):
[];for(g=0;g<k.length;g+=2)b[k[g]]=K(k[g+1]);return b}}}}catch(n){}};function V(a,b,c,d){function e(k){k=U(a,k);var n=k.charAt(k.length-1);k&&"&"!==n&&(k+="&");return k+h}d=void 0===d?!1:d;var f=da.exec(c);if(!f)return"";c=f[1];var g=f[2]||"";f=f[3]||"";var h=a+"="+b;d?f="#"+e(f.substring(1)):g="?"+e(g.substring(1));return""+c+g+f}
function W(a,b){var c="FORM"===(a.tagName||"").toUpperCase(),d=P(b,1,c),e=P(b,2,c);b=P(b,3,c);r(d)&&(d=S(d),c?X("_gl",d,a):Y("_gl",d,a,!1));!c&&r(e)&&(c=S(e),Y("_gl",c,a,!0));for(var f in b)b.hasOwnProperty(f)&&Z(f,b[f],a)}function Z(a,b,c,d){if(c.tagName){if("a"===c.tagName.toLowerCase())return Y(a,b,c,d);if("form"===c.tagName.toLowerCase())return X(a,b,c)}if("string"==typeof c)return V(a,b,c,d)}function Y(a,b,c,d){c.href&&(a=V(a,b,c.href,void 0===d?!1:d),t.test(a)&&(c.href=a))}
function X(a,b,c){if(c&&c.action){var d=(c.method||"").toLowerCase();if("get"===d){d=c.childNodes||[];for(var e=!1,f=0;f<d.length;f++){var g=d[f];if(g.name===a){g.setAttribute("value",b);e=!0;break}}e||(d=v.createElement("input"),d.setAttribute("type","hidden"),d.setAttribute("name",a),d.setAttribute("value",b),c.appendChild(d))}else"post"===d&&(a=V(a,b,c.action),t.test(a)&&(c.action=a))}}
var aa=function(a){try{a:{for(var b=100;a&&0<b;){if(a.href&&a.nodeName.match(/^a(?:rea)?$/i)){var c=a;break a}a=a.parentNode;b--}c=null}if(c){var d=c.protocol;"http:"!==d&&"https:"!==d||W(c,c.hostname)}}catch(e){}},ba=function(a){try{if(a.action){var b=D(E(a.action),"host");W(a,b)}}catch(c){}};m("google_tag_data.glBridge.auto",function(a,b,c,d){N();O(a,b,"fragment"===c?2:1,!!d,!1)});m("google_tag_data.glBridge.passthrough",function(a,b,c){N();O(a,[C(u.location,"host",!0)],b,!!c,!0)});m("google_tag_data.glBridge.decorate",function(a,b,c){a=S(a);return Z("_gl",a,b,!!c)});m("google_tag_data.glBridge.generate",S);m("google_tag_data.glBridge.get",function(a,b){var c=fa(!!b);b=M();b.data||(b.data={query:{},fragment:{}},c(b.data));c={};if(b=b.data)q(c,b.query),a&&q(c,b.fragment);return c});})(window);
(function(){function La(a){var b=1,c;if(a)for(b=0,c=a.length-1;0<=c;c--){var d=a.charCodeAt(c);b=(b<<6&268435455)+d+(d<<14);d=b&266338304;b=0!=d?b^d>>21:b}return b};/*
Copyright The Closure Library Authors.
SPDX-License-Identifier: Apache-2.0
*/
var $c=function(a){this.C=a||[]};$c.prototype.set=function(a){this.C[a]=!0};$c.prototype.encode=function(){for(var a=[],b=0;b<this.C.length;b++)this.C[b]&&(a[Math.floor(b/6)]^=1<<b%6);for(b=0;b<a.length;b++)a[b]="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".charAt(a[b]||0);return a.join("")+"~"};var ha=window.GoogleAnalyticsObject,wa;if(wa=void 0!=ha)wa=-1<(ha.constructor+"").indexOf("String");var ne;if(ne=wa){var Ee=window.GoogleAnalyticsObject;ne=Ee?Ee.replace(/^[\s\xa0]+|[\s\xa0]+$/g,""):""}var gb=ne||"ga",jd=/^(?:utma\.)?\d+\.\d+$/,kd=/^amp-[\w.-]{22,64}$/,Ba=!1;var vd=new $c;function J(a){vd.set(a)}var Td=function(a){a=Dd(a);a=new $c(a);for(var b=vd.C.slice(),c=0;c<a.C.length;c++)b[c]=b[c]||a.C[c];return(new $c(b)).encode()},Dd=function(a){a=a.get(Gd);ka(a)||(a=[]);return a};var ea=function(a){return"function"==typeof a},ka=function(a){return"[object Array]"==Object.prototype.toString.call(Object(a))},qa=function(a){return void 0!=a&&-1<(a.constructor+"").indexOf("String")},D=function(a,b){return 0==a.indexOf(b)},sa=function(a){return a?a.replace(/^[\s\xa0]+|[\s\xa0]+$/g,""):""},ra=function(){for(var a=O.navigator.userAgent+(M.cookie?M.cookie:"")+(M.referrer?M.referrer:""),b=a.length,c=O.history.length;0<c;)a+=c--^b++;return[hd()^La(a)&2147483647,Math.round((new Date).getTime()/
1E3)].join(".")},ta=function(a){var b=M.createElement("img");b.width=1;b.height=1;b.src=a;return b},ua=function(){},K=function(a){if(encodeURIComponent instanceof Function)return encodeURIComponent(a);J(28);return a},L=function(a,b,c,d){try{a.addEventListener?a.addEventListener(b,c,!!d):a.attachEvent&&a.attachEvent("on"+b,c)}catch(e){J(27)}},f=/^[\w\-:/.?=&%!\[\]]+$/,Nd=/^[\w+/_-]+[=]{0,2}$/,Id=function(a,b,c){if(a){var d=M.querySelector&&M.querySelector("script[nonce]")||null;d=d?d.nonce||d.getAttribute&&
d.getAttribute("nonce")||"":"";if(c){var e=c="";b&&f.test(b)&&(c=' id="'+b+'"');d&&Nd.test(d)&&(e=' nonce="'+d+'"');f.test(a)&&M.write("<script"+c+e+' src="'+a+'">\x3c/script>')}else c=M.createElement("script"),c.type="text/javascript",c.async=!0,c.src=a,b&&(c.id=b),d&&c.setAttribute("nonce",d),a=M.getElementsByTagName("script")[0],a.parentNode.insertBefore(c,a)}},be=function(a,b){return E(M.location[b?"href":"search"],a)},E=function(a,b){return(a=a.match("(?:&|#|\\?)"+K(b).replace(/([.*+?^=!:${}()|\[\]\/\\])/g,
"\\$1")+"=([^&#]*)"))&&2==a.length?a[1]:""},xa=function(){var a=""+M.location.hostname;return 0==a.indexOf("www.")?a.substring(4):a},de=function(a,b){var c=a.indexOf(b);if(5==c||6==c)if(a=a.charAt(c+b.length),"/"==a||"?"==a||""==a||":"==a)return!0;return!1},ya=function(a,b){var c=M.referrer;if(/^(https?|android-app):\/\//i.test(c)){if(a)return c;a="//"+M.location.hostname;if(!de(c,a))return b&&(b=a.replace(/\./g,"-")+".cdn.ampproject.org",de(c,b))?void 0:c}},za=function(a,b){if(1==b.length&&null!=
b[0]&&"object"===typeof b[0])return b[0];for(var c={},d=Math.min(a.length+1,b.length),e=0;e<d;e++)if("object"===typeof b[e]){for(var g in b[e])b[e].hasOwnProperty(g)&&(c[g]=b[e][g]);break}else e<a.length&&(c[a[e]]=b[e]);return c};var ee=function(){this.b=[];this.ea={};this.m={}};ee.prototype.set=function(a,b,c){this.b.push(a);c?this.m[":"+a]=b:this.ea[":"+a]=b};ee.prototype.get=function(a){return this.m.hasOwnProperty(":"+a)?this.m[":"+a]:this.ea[":"+a]};ee.prototype.map=function(a){for(var b=0;b<this.b.length;b++){var c=this.b[b],d=this.get(c);d&&a(c,d)}};var O=window,M=document,va=function(a,b){return setTimeout(a,b)};var Qa=window,Za=document,G=function(a){var b=Qa._gaUserPrefs;if(b&&b.ioo&&b.ioo()||a&&!0===Qa["ga-disable-"+a])return!0;try{var c=Qa.external;if(c&&c._gaUserPrefs&&"oo"==c._gaUserPrefs)return!0}catch(g){}a=[];b=String(Za.cookie).split(";");for(c=0;c<b.length;c++){var d=b[c].split("="),e=d[0].replace(/^\s*|\s*$/g,"");e&&"AMP_TOKEN"==e&&((d=d.slice(1).join("=").replace(/^\s*|\s*$/g,""))&&(d=decodeURIComponent(d)),a.push(d))}for(b=0;b<a.length;b++)if("$OPT_OUT"==a[b])return!0;return Za.getElementById("__gaOptOutExtension")?
!0:!1};var Ca=function(a){var b=[],c=M.cookie.split(";");a=new RegExp("^\\s*"+a+"=\\s*(.*?)\\s*$");for(var d=0;d<c.length;d++){var e=c[d].match(a);e&&b.push(e[1])}return b},zc=function(a,b,c,d,e,g,ca){e=G(e)?!1:eb.test(M.location.hostname)||"/"==c&&vc.test(d)?!1:!0;if(!e)return!1;b&&1200<b.length&&(b=b.substring(0,1200));c=a+"="+b+"; path="+c+"; ";g&&(c+="expires="+(new Date((new Date).getTime()+g)).toGMTString()+"; ");d&&"none"!==d&&(c+="domain="+d+";");ca&&(c+=ca+";");d=M.cookie;M.cookie=c;if(!(d=d!=M.cookie))a:{a=
Ca(a);for(d=0;d<a.length;d++)if(b==a[d]){d=!0;break a}d=!1}return d},Cc=function(a){return encodeURIComponent?encodeURIComponent(a).replace(/\(/g,"%28").replace(/\)/g,"%29"):a},vc=/^(www\.)?google(\.com?)?(\.[a-z]{2})?$/,eb=/(^|\.)doubleclick\.net$/i;var Fa,Ga,fb,Ab,ja=/^https?:\/\/[^/]*cdn\.ampproject\.org\//,Ue=/^(?:www\.|m\.|amp\.)+/,Ub=[],da=function(a){if(ye(a[Kd])){if(void 0===Ab){var b;if(b=(b=De.get())&&b._ga||void 0)Ab=b,J(81)}if(void 0!==Ab)return a[Q]||(a[Q]=Ab),!1}if(a[Kd]){J(67);if(a[ac]&&"cookie"!=a[ac])return!1;if(void 0!==Ab)a[Q]||(a[Q]=Ab);else{a:{b=String(a[W]||xa());var c=String(a[Yb]||"/"),d=Ca(String(a[U]||"_ga"));b=na(d,b,c);if(!b||jd.test(b))b=!0;else if(b=Ca("AMP_TOKEN"),0==b.length)b=!0;else{if(1==b.length&&(b=decodeURIComponent(b[0]),
"$RETRIEVING"==b||"$OPT_OUT"==b||"$ERROR"==b||"$NOT_FOUND"==b)){b=!0;break a}b=!1}}if(b&&tc(ic,String(a[Na])))return!0}}return!1},ic=function(){Z.D([ua])},tc=function(a,b){var c=Ca("AMP_TOKEN");if(1<c.length)return J(55),!1;c=decodeURIComponent(c[0]||"");if("$OPT_OUT"==c||"$ERROR"==c||G(b))return J(62),!1;if(!ja.test(M.referrer)&&"$NOT_FOUND"==c)return J(68),!1;if(void 0!==Ab)return J(56),va(function(){a(Ab)},0),!0;if(Fa)return Ub.push(a),!0;if("$RETRIEVING"==c)return J(57),va(function(){tc(a,b)},
1E4),!0;Fa=!0;c&&"$"!=c[0]||(xc("$RETRIEVING",3E4),setTimeout(Mc,3E4),c="");return Pc(c,b)?(Ub.push(a),!0):!1},Pc=function(a,b,c){if(!window.JSON)return J(58),!1;var d=O.XMLHttpRequest;if(!d)return J(59),!1;var e=new d;if(!("withCredentials"in e))return J(60),!1;e.open("POST",(c||"https://ampcid.google.com/v1/publisher:getClientId")+"?key=AIzaSyA65lEHUEizIsNtlbNo-l2K18dT680nsaM",!0);e.withCredentials=!0;e.setRequestHeader("Content-Type","text/plain");e.onload=function(){Fa=!1;if(4==e.readyState){try{200!=
e.status&&(J(61),Qc("","$ERROR",3E4));var g=JSON.parse(e.responseText);g.optOut?(J(63),Qc("","$OPT_OUT",31536E6)):g.clientId?Qc(g.clientId,g.securityToken,31536E6):!c&&g.alternateUrl?(Ga&&clearTimeout(Ga),Fa=!0,Pc(a,b,g.alternateUrl)):(J(64),Qc("","$NOT_FOUND",36E5))}catch(ca){J(65),Qc("","$ERROR",3E4)}e=null}};d={originScope:"AMP_ECID_GOOGLE"};a&&(d.securityToken=a);e.send(JSON.stringify(d));Ga=va(function(){J(66);Qc("","$ERROR",3E4)},1E4);return!0},Mc=function(){Fa=!1},xc=function(a,b){if(void 0===
fb){fb="";for(var c=id(),d=0;d<c.length;d++){var e=c[d];if(zc("AMP_TOKEN",encodeURIComponent(a),"/",e,"",b)){fb=e;return}}}zc("AMP_TOKEN",encodeURIComponent(a),"/",fb,"",b)},Qc=function(a,b,c){Ga&&clearTimeout(Ga);b&&xc(b,c);Ab=a;b=Ub;Ub=[];for(c=0;c<b.length;c++)b[c](a)},ye=function(a){a:{if(ja.test(M.referrer)){var b=M.location.hostname.replace(Ue,"");b:{var c=M.referrer;c=c.replace(/^https?:\/\//,"");var d=c.replace(/^[^/]+/,"").split("/"),e=d[2];d=(d="s"==e?d[3]:e)?decodeURIComponent(d):d;if(!d){if(0==
c.indexOf("xn--")){c="";break b}(c=c.match(/(.*)\.cdn\.ampproject\.org\/?$/))&&2==c.length&&(d=c[1].replace(/-/g,".").replace(/\.\./g,"-"))}c=d?d.replace(Ue,""):""}(d=b===c)||(c="."+c,d=b.substring(b.length-c.length,b.length)===c);if(d){b=!0;break a}else J(78)}b=!1}return b&&!1!==a};var bd=function(a){return(a?"https:":Ba||"https:"==M.location.protocol?"https:":"http:")+"//www.google-analytics.com"},Ge=function(a){switch(a){default:case 1:return"https://www.google-analytics.com/gtm/js?id=";case 2:return"https://www.googletagmanager.com/gtag/js?id="}},Da=function(a){this.name="len";this.message=a+"-8192"},ba=function(a,b,c){c=c||ua;if(2036>=b.length)wc(a,b,c);else if(8192>=b.length)x(a,b,c)||wd(a,b,c)||wc(a,b,c);else throw ge("len",b.length),new Da(b.length);},pe=function(a,b,
c,d){d=d||ua;wd(a+"?"+b,"",d,c)},wc=function(a,b,c){var d=ta(a+"?"+b);d.onload=d.onerror=function(){d.onload=null;d.onerror=null;c()}},wd=function(a,b,c,d){var e=O.XMLHttpRequest;if(!e)return!1;var g=new e;if(!("withCredentials"in g))return!1;a=a.replace(/^http:/,"https:");g.open("POST",a,!0);g.withCredentials=!0;g.setRequestHeader("Content-Type","text/plain");g.onreadystatechange=function(){if(4==g.readyState){if(d&&"text/plain"===g.getResponseHeader("Content-Type"))try{Ea(d,g.responseText,c)}catch(ca){ge("xhr",
"rsp"),c()}else c();g=null}};g.send(b);return!0},Ea=function(a,b,c){if(1>b.length)ge("xhr","ver","0"),c();else if(3<a.count++)ge("xhr","tmr",""+a.count),c();else{var d=b.charAt(0);if("1"===d)oc(a,b.substring(1),c);else if(a.V&&"2"===d){var e=b.substring(1).split(","),g=0;b=function(){++g===e.length&&c()};for(d=0;d<e.length;d++)oc(a,e[d],b)}else ge("xhr","ver",String(b.length)),c()}},oc=function(a,b,c){if(0===b.length)c();else{var d=b.charAt(0);switch(d){case "d":pe("https://stats.g.doubleclick.net/j/collect",
a.U,a,c);break;case "g":wc("https://www.google.%/ads/ga-audiences".replace("%","com"),a.google,c);(b=b.substring(1))&&(/^[a-z.]{1,6}$/.test(b)?wc("https://www.google.%/ads/ga-audiences".replace("%",b),a.google,ua):ge("tld","bcc",b));break;case "G":if(a.V){a.V("G-"+b.substring(1));c();break}case "x":if(a.V){a.V();c();break}default:ge("xhr","brc",d),c()}}},x=function(a,b,c){return O.navigator.sendBeacon?O.navigator.sendBeacon(a,b)?(c(),!0):!1:!1},ge=function(a,b,c){1<=100*Math.random()||G("?")||(a=
["t=error","_e="+a,"_v=j87","sr=1"],b&&a.push("_f="+b),c&&a.push("_m="+K(c.substring(0,100))),a.push("aip=1"),a.push("z="+hd()),wc(bd(!0)+"/u/d",a.join("&"),ua))};var qc=function(){return O.gaData=O.gaData||{}},h=function(a){var b=qc();return b[a]=b[a]||{}};var Ha=function(){this.M=[]};Ha.prototype.add=function(a){this.M.push(a)};Ha.prototype.D=function(a){try{for(var b=0;b<this.M.length;b++){var c=a.get(this.M[b]);c&&ea(c)&&c.call(O,a)}}catch(d){}b=a.get(Ia);b!=ua&&ea(b)&&(a.set(Ia,ua,!0),setTimeout(b,10))};function Ja(a){if(100!=a.get(Ka)&&La(P(a,Q))%1E4>=100*R(a,Ka))throw"abort";}function Ma(a){if(G(P(a,Na)))throw"abort";}function Oa(){var a=M.location.protocol;if("http:"!=a&&"https:"!=a)throw"abort";}
function Pa(a){try{O.navigator.sendBeacon?J(42):O.XMLHttpRequest&&"withCredentials"in new O.XMLHttpRequest&&J(40)}catch(c){}a.set(ld,Td(a),!0);a.set(Ac,R(a,Ac)+1);var b=[];ue.map(function(c,d){d.F&&(c=a.get(c),void 0!=c&&c!=d.defaultValue&&("boolean"==typeof c&&(c*=1),b.push(d.F+"="+K(""+c))))});!1===a.get(xe)&&b.push("npa=1");b.push("z="+Bd());a.set(Ra,b.join("&"),!0)}
function Sa(a){var b=P(a,fa);!b&&a.get(Vd)&&(b="beacon");var c=P(a,gd),d=P(a,oe),e=c||(d||bd(!1)+"")+"/collect";switch(P(a,ad)){case "d":e=c||(d||bd(!1)+"")+"/j/collect";b=a.get(qe)||void 0;pe(e,P(a,Ra),b,a.Z(Ia));break;default:b?(c=P(a,Ra),d=(d=a.Z(Ia))||ua,"image"==b?wc(e,c,d):"xhr"==b&&wd(e,c,d)||"beacon"==b&&x(e,c,d)||ba(e,c,d)):ba(e,P(a,Ra),a.Z(Ia))}e=P(a,Na);e=h(e);b=e.hitcount;e.hitcount=b?b+1:1;e.first_hit||(e.first_hit=(new Date).getTime());e=P(a,Na);delete h(e).pending_experiments;a.set(Ia,
ua,!0)}function Hc(a){qc().expId&&a.set(Nc,qc().expId);qc().expVar&&a.set(Oc,qc().expVar);var b=P(a,Na);if(b=h(b).pending_experiments){var c=[];for(d in b)b.hasOwnProperty(d)&&b[d]&&c.push(encodeURIComponent(d)+"."+encodeURIComponent(b[d]));var d=c.join("!")}else d=void 0;d&&((b=a.get(m))&&(d=b+"!"+d),a.set(m,d,!0))}function cd(){if(O.navigator&&"preview"==O.navigator.loadPurpose)throw"abort";}
function yd(a){var b=O.gaDevIds||[];if(ka(b)){var c=a.get("&did");qa(c)&&0<c.length&&(b=b.concat(c.split(",")));c=[];for(var d=0;d<b.length;d++){var e;a:{for(e=0;e<c.length;e++)if(b[d]==c[e]){e=!0;break a}e=!1}e||c.push(b[d])}0!=c.length&&a.set("&did",c.join(","),!0)}}function vb(a){if(!a.get(Na))throw"abort";};var hd=function(){return Math.round(2147483647*Math.random())},Bd=function(){try{var a=new Uint32Array(1);O.crypto.getRandomValues(a);return a[0]&2147483647}catch(b){return hd()}};function Ta(a){var b=R(a,Ua);500<=b&&J(15);var c=P(a,Va);if("transaction"!=c&&"item"!=c){c=R(a,Wa);var d=(new Date).getTime(),e=R(a,Xa);0==e&&a.set(Xa,d);e=Math.round(2*(d-e)/1E3);0<e&&(c=Math.min(c+e,20),a.set(Xa,d));if(0>=c)throw"abort";a.set(Wa,--c)}a.set(Ua,++b)};var Ya=function(){this.data=new ee};Ya.prototype.get=function(a){var b=$a(a),c=this.data.get(a);b&&void 0==c&&(c=ea(b.defaultValue)?b.defaultValue():b.defaultValue);return b&&b.Z?b.Z(this,a,c):c};var P=function(a,b){a=a.get(b);return void 0==a?"":""+a},R=function(a,b){a=a.get(b);return void 0==a||""===a?0:Number(a)};Ya.prototype.Z=function(a){return(a=this.get(a))&&ea(a)?a:ua};
Ya.prototype.set=function(a,b,c){if(a)if("object"==typeof a)for(var d in a)a.hasOwnProperty(d)&&ab(this,d,a[d],c);else ab(this,a,b,c)};var ab=function(a,b,c,d){if(void 0!=c)switch(b){case Na:wb.test(c)}var e=$a(b);e&&e.o?e.o(a,b,c,d):a.data.set(b,c,d)};var ue=new ee,ve=[],bb=function(a,b,c,d,e){this.name=a;this.F=b;this.Z=d;this.o=e;this.defaultValue=c},$a=function(a){var b=ue.get(a);if(!b)for(var c=0;c<ve.length;c++){var d=ve[c],e=d[0].exec(a);if(e){b=d[1](e);ue.set(b.name,b);break}}return b},yc=function(a){var b;ue.map(function(c,d){d.F==a&&(b=d)});return b&&b.name},S=function(a,b,c,d,e){a=new bb(a,b,c,d,e);ue.set(a.name,a);return a.name},cb=function(a,b){ve.push([new RegExp("^"+a+"$"),b])},T=function(a,b,c){return S(a,b,c,void 0,db)},db=function(){};var hb=T("apiVersion","v"),ib=T("clientVersion","_v");S("anonymizeIp","aip");var jb=S("adSenseId","a"),Va=S("hitType","t"),Ia=S("hitCallback"),Ra=S("hitPayload");S("nonInteraction","ni");S("currencyCode","cu");S("dataSource","ds");var Vd=S("useBeacon",void 0,!1),fa=S("transport");S("sessionControl","sc","");S("sessionGroup","sg");S("queueTime","qt");var Ac=S("_s","_s");S("screenName","cd");var kb=S("location","dl",""),lb=S("referrer","dr"),mb=S("page","dp","");S("hostname","dh");
var nb=S("language","ul"),ob=S("encoding","de");S("title","dt",function(){return M.title||void 0});cb("contentGroup([0-9]+)",function(a){return new bb(a[0],"cg"+a[1])});var pb=S("screenColors","sd"),qb=S("screenResolution","sr"),rb=S("viewportSize","vp"),sb=S("javaEnabled","je"),tb=S("flashVersion","fl");S("campaignId","ci");S("campaignName","cn");S("campaignSource","cs");S("campaignMedium","cm");S("campaignKeyword","ck");S("campaignContent","cc");
var ub=S("eventCategory","ec"),xb=S("eventAction","ea"),yb=S("eventLabel","el"),zb=S("eventValue","ev"),Bb=S("socialNetwork","sn"),Cb=S("socialAction","sa"),Db=S("socialTarget","st"),Eb=S("l1","plt"),Fb=S("l2","pdt"),Gb=S("l3","dns"),Hb=S("l4","rrt"),Ib=S("l5","srt"),Jb=S("l6","tcp"),Kb=S("l7","dit"),Lb=S("l8","clt"),Ve=S("l9","_gst"),We=S("l10","_gbt"),Xe=S("l11","_cst"),Ye=S("l12","_cbt"),Mb=S("timingCategory","utc"),Nb=S("timingVar","utv"),Ob=S("timingLabel","utl"),Pb=S("timingValue","utt");
S("appName","an");S("appVersion","av","");S("appId","aid","");S("appInstallerId","aiid","");S("exDescription","exd");S("exFatal","exf");var Nc=S("expId","xid"),Oc=S("expVar","xvar"),m=S("exp","exp"),Rc=S("_utma","_utma"),Sc=S("_utmz","_utmz"),Tc=S("_utmht","_utmht"),Ua=S("_hc",void 0,0),Xa=S("_ti",void 0,0),Wa=S("_to",void 0,20);cb("dimension([0-9]+)",function(a){return new bb(a[0],"cd"+a[1])});cb("metric([0-9]+)",function(a){return new bb(a[0],"cm"+a[1])});S("linkerParam",void 0,void 0,Bc,db);
var Ze=T("_cd2l",void 0,!1),ld=S("usage","_u"),Gd=S("_um");S("forceSSL",void 0,void 0,function(){return Ba},function(a,b,c){J(34);Ba=!!c});var ed=S("_j1","jid"),ia=S("_j2","gjid");cb("\\&(.*)",function(a){var b=new bb(a[0],a[1]),c=yc(a[0].substring(1));c&&(b.Z=function(d){return d.get(c)},b.o=function(d,e,g,ca){d.set(c,g,ca)},b.F=void 0);return b});
var Qb=T("_oot"),dd=S("previewTask"),Rb=S("checkProtocolTask"),md=S("validationTask"),Sb=S("checkStorageTask"),Uc=S("historyImportTask"),Tb=S("samplerTask"),Vb=S("_rlt"),Wb=S("buildHitTask"),Xb=S("sendHitTask"),Vc=S("ceTask"),zd=S("devIdTask"),Cd=S("timingTask"),Ld=S("displayFeaturesTask"),oa=S("customTask"),ze=S("fpsCrossDomainTask"),V=T("name"),Q=T("clientId","cid"),n=T("clientIdTime"),xd=T("storedClientId"),Ad=S("userId","uid"),Na=T("trackingId","tid"),U=T("cookieName",void 0,"_ga"),W=T("cookieDomain"),
Yb=T("cookiePath",void 0,"/"),Zb=T("cookieExpires",void 0,63072E3),Hd=T("cookieUpdate",void 0,!0),Be=T("cookieFlags",void 0,""),$b=T("legacyCookieDomain"),Wc=T("legacyHistoryImport",void 0,!0),ac=T("storage",void 0,"cookie"),bc=T("allowLinker",void 0,!1),cc=T("allowAnchor",void 0,!0),Ka=T("sampleRate","sf",100),dc=T("siteSpeedSampleRate",void 0,1),ec=T("alwaysSendReferrer",void 0,!1),I=T("_gid","_gid"),la=T("_gcn"),Kd=T("useAmpClientId"),ce=T("_gclid"),fe=T("_gt"),he=T("_ge",void 0,7776E6),ie=T("_gclsrc"),
je=T("storeGac",void 0,!0),oe=S("_x_19"),Ae=S("_fplc","_fplc"),F=T("_cs"),Je=T("_useUp",void 0,!1),Le=S("up","up"),gd=S("transportUrl"),Md=S("_r","_r"),Od=S("_slc","_slc"),qe=S("_dp"),ad=S("_jt",void 0,"n"),Ud=S("allowAdFeatures",void 0,!0),xe=S("allowAdPersonalizationSignals",void 0,!0);function X(a,b,c,d){b[a]=function(){try{return d&&J(d),c.apply(this,arguments)}catch(e){throw ge("exc",a,e&&e.name),e;}}};function fc(){var a,b;if((b=(b=O.navigator)?b.plugins:null)&&b.length)for(var c=0;c<b.length&&!a;c++){var d=b[c];-1<d.name.indexOf("Shockwave Flash")&&(a=d.description)}if(!a)try{var e=new ActiveXObject("ShockwaveFlash.ShockwaveFlash.7");a=e.GetVariable("$version")}catch(g){}if(!a)try{e=new ActiveXObject("ShockwaveFlash.ShockwaveFlash.6"),a="WIN 6,0,21,0",e.AllowScriptAccess="always",a=e.GetVariable("$version")}catch(g){}if(!a)try{e=new ActiveXObject("ShockwaveFlash.ShockwaveFlash"),a=e.GetVariable("$version")}catch(g){}a&&
(e=a.match(/[\d]+/g))&&3<=e.length&&(a=e[0]+"."+e[1]+" r"+e[2]);return a||void 0};var Ed=function(a){if("cookie"==a.get(ac))return a=Ca("FPLC"),0<a.length?a[0]:void 0},Fe=function(a){var b;if(b=P(a,oe)&&a.get(Ze))b=De.get(a.get(cc)),b=!(b&&b._fplc);b&&a.set(Ae,Ed(a)||"0")};var aa=function(a){var b=Math.min(R(a,dc),100);return La(P(a,Q))%100>=b?!1:!0},gc=function(a){var b={};if(Ec(b)||Fc(b)){var c=b[Eb];void 0==c||Infinity==c||isNaN(c)||(0<c?(Y(b,Gb),Y(b,Jb),Y(b,Ib),Y(b,Fb),Y(b,Hb),Y(b,Kb),Y(b,Lb),Y(b,Ve),Y(b,We),Y(b,Xe),Y(b,Ye),va(function(){a(b)},10)):L(O,"load",function(){gc(a)},!1))}},Ec=function(a){var b=O.performance||O.webkitPerformance;b=b&&b.timing;if(!b)return!1;var c=b.navigationStart;if(0==c)return!1;a[Eb]=b.loadEventStart-c;a[Gb]=b.domainLookupEnd-b.domainLookupStart;
a[Jb]=b.connectEnd-b.connectStart;a[Ib]=b.responseStart-b.requestStart;a[Fb]=b.responseEnd-b.responseStart;a[Hb]=b.fetchStart-c;a[Kb]=b.domInteractive-c;a[Lb]=b.domContentLoadedEventStart-c;a[Ve]=N.L-c;a[We]=N.ya-c;O.google_tag_manager&&O.google_tag_manager._li&&(b=O.google_tag_manager._li,a[Xe]=b.cst,a[Ye]=b.cbt);return!0},Fc=function(a){if(O.top!=O)return!1;var b=O.external,c=b&&b.onloadT;b&&!b.isValidLoadTime&&(c=void 0);2147483648<c&&(c=void 0);0<c&&b.setPageReadyTime();if(void 0==c)return!1;
a[Eb]=c;return!0},Y=function(a,b){var c=a[b];if(isNaN(c)||Infinity==c||0>c)a[b]=void 0},Fd=function(a){return function(b){if("pageview"==b.get(Va)&&!a.I){a.I=!0;var c=aa(b),d=0<E(P(b,kb),"gclid").length;(c||d)&&gc(function(e){c&&a.send("timing",e);d&&a.send("adtiming",e)})}}};var hc=!1,mc=function(a){if("cookie"==P(a,ac)){if(a.get(Hd)||P(a,xd)!=P(a,Q)){var b=1E3*R(a,Zb);ma(a,Q,U,b);a.data.set(xd,P(a,Q))}(a.get(Hd)||uc(a)!=P(a,I))&&ma(a,I,la,864E5);if(a.get(je)){var c=P(a,ce);if(c){var d=Math.min(R(a,he),1E3*R(a,Zb));d=Math.min(d,1E3*R(a,fe)+d-(new Date).getTime());a.data.set(he,d);b={};var e=P(a,fe),g=P(a,ie),ca=kc(P(a,Yb)),l=lc(P(a,W)),k=P(a,Na);a=P(a,Be);g&&"aw.ds"!=g?b&&(b.ua=!0):(c=["1",e,Cc(c)].join("."),0<d&&(b&&(b.ta=!0),zc("_gac_"+Cc(k),c,ca,l,k,d,a)));le(b)}}else J(75)}},
ma=function(a,b,c,d){var e=nd(a,b);if(e){c=P(a,c);var g=kc(P(a,Yb)),ca=lc(P(a,W)),l=P(a,Be),k=P(a,Na);if("auto"!=ca)zc(c,e,g,ca,k,d,l)&&(hc=!0);else{J(32);for(var w=id(),Ce=0;Ce<w.length;Ce++)if(ca=w[Ce],a.data.set(W,ca),e=nd(a,b),zc(c,e,g,ca,k,d,l)){hc=!0;return}a.data.set(W,"auto")}}},uc=function(a){var b=Ca(P(a,la));return Xd(a,b)},nc=function(a){if("cookie"==P(a,ac)&&!hc&&(mc(a),!hc))throw"abort";},Yc=function(a){if(a.get(Wc)){var b=P(a,W),c=P(a,$b)||xa(),d=Xc("__utma",c,b);d&&(J(19),a.set(Tc,
(new Date).getTime(),!0),a.set(Rc,d.R),(b=Xc("__utmz",c,b))&&d.hash==b.hash&&a.set(Sc,b.R))}},nd=function(a,b){b=Cc(P(a,b));var c=lc(P(a,W)).split(".").length;a=jc(P(a,Yb));1<a&&(c+="-"+a);return b?["GA1",c,b].join("."):""},Xd=function(a,b){return na(b,P(a,W),P(a,Yb))},na=function(a,b,c){if(!a||1>a.length)J(12);else{for(var d=[],e=0;e<a.length;e++){var g=a[e];var ca=g.split(".");var l=ca.shift();("GA1"==l||"1"==l)&&1<ca.length?(g=ca.shift().split("-"),1==g.length&&(g[1]="1"),g[0]*=1,g[1]*=1,ca={H:g,
s:ca.join(".")}):ca=kd.test(g)?{H:[0,0],s:g}:void 0;ca&&d.push(ca)}if(1==d.length)return J(13),d[0].s;if(0==d.length)J(12);else{J(14);d=Gc(d,lc(b).split(".").length,0);if(1==d.length)return d[0].s;d=Gc(d,jc(c),1);1<d.length&&J(41);return d[0]&&d[0].s}}},Gc=function(a,b,c){for(var d=[],e=[],g,ca=0;ca<a.length;ca++){var l=a[ca];l.H[c]==b?d.push(l):void 0==g||l.H[c]<g?(e=[l],g=l.H[c]):l.H[c]==g&&e.push(l)}return 0<d.length?d:e},lc=function(a){return 0==a.indexOf(".")?a.substr(1):a},id=function(){var a=
[],b=xa().split(".");if(4==b.length){var c=b[b.length-1];if(parseInt(c,10)==c)return["none"]}for(c=b.length-2;0<=c;c--)a.push(b.slice(c).join("."));b=M.location.hostname;eb.test(b)||vc.test(b)||a.push("none");return a},kc=function(a){if(!a)return"/";1<a.length&&a.lastIndexOf("/")==a.length-1&&(a=a.substr(0,a.length-1));0!=a.indexOf("/")&&(a="/"+a);return a},jc=function(a){a=kc(a);return"/"==a?1:a.split("/").length},le=function(a){a.ta&&J(77);a.na&&J(74);a.pa&&J(73);a.ua&&J(69)};function Xc(a,b,c){"none"==b&&(b="");var d=[],e=Ca(a);a="__utma"==a?6:2;for(var g=0;g<e.length;g++){var ca=(""+e[g]).split(".");ca.length>=a&&d.push({hash:ca[0],R:e[g],O:ca})}if(0!=d.length)return 1==d.length?d[0]:Zc(b,d)||Zc(c,d)||Zc(null,d)||d[0]}function Zc(a,b){if(null==a)var c=a=1;else c=La(a),a=La(D(a,".")?a.substring(1):"."+a);for(var d=0;d<b.length;d++)if(b[d].hash==c||b[d].hash==a)return b[d]};var Jc=new RegExp(/^https?:\/\/([^\/:]+)/),De=O.google_tag_data.glBridge,Kc=/(.*)([?&#])(?:_ga=[^&#]*)(?:&?)(.*)/,od=/(.*)([?&#])(?:_gac=[^&#]*)(?:&?)(.*)/;function Bc(a){if(a.get(Ze))return J(35),De.generate($e(a));var b=P(a,Q),c=P(a,I)||"";b="_ga=2."+K(pa(c+b,0)+"."+c+"-"+b);(a=af(a))?(J(44),a="&_gac=1."+K([pa(a.qa,0),a.timestamp,a.qa].join("."))):a="";return b+a}
function Ic(a,b){var c=new Date,d=O.navigator,e=d.plugins||[];a=[a,d.userAgent,c.getTimezoneOffset(),c.getYear(),c.getDate(),c.getHours(),c.getMinutes()+b];for(b=0;b<e.length;++b)a.push(e[b].description);return La(a.join("."))}function pa(a,b){var c=new Date,d=O.navigator,e=c.getHours()+Math.floor((c.getMinutes()+b)/60);return La([a,d.userAgent,d.language||"",c.getTimezoneOffset(),c.getYear(),c.getDate()+Math.floor(e/24),(24+e)%24,(60+c.getMinutes()+b)%60].join("."))}
var Dc=function(a){J(48);this.target=a;this.T=!1};Dc.prototype.ca=function(a,b){if(a){if(this.target.get(Ze))return De.decorate($e(this.target),a,b);if(a.tagName){if("a"==a.tagName.toLowerCase()){a.href&&(a.href=qd(this,a.href,b));return}if("form"==a.tagName.toLowerCase())return rd(this,a)}if("string"==typeof a)return qd(this,a,b)}};
var qd=function(a,b,c){var d=Kc.exec(b);d&&3<=d.length&&(b=d[1]+(d[3]?d[2]+d[3]:""));(d=od.exec(b))&&3<=d.length&&(b=d[1]+(d[3]?d[2]+d[3]:""));a=a.target.get("linkerParam");var e=b.indexOf("?");d=b.indexOf("#");c?b+=(-1==d?"#":"&")+a:(c=-1==e?"?":"&",b=-1==d?b+(c+a):b.substring(0,d)+c+a+b.substring(d));b=b.replace(/&+_ga=/,"&_ga=");return b=b.replace(/&+_gac=/,"&_gac=")},rd=function(a,b){if(b&&b.action)if("get"==b.method.toLowerCase()){a=a.target.get("linkerParam").split("&");for(var c=0;c<a.length;c++){var d=
a[c].split("="),e=d[1];d=d[0];for(var g=b.childNodes||[],ca=!1,l=0;l<g.length;l++)if(g[l].name==d){g[l].setAttribute("value",e);ca=!0;break}ca||(g=M.createElement("input"),g.setAttribute("type","hidden"),g.setAttribute("name",d),g.setAttribute("value",e),b.appendChild(g))}}else"post"==b.method.toLowerCase()&&(b.action=qd(a,b.action))};
Dc.prototype.S=function(a,b,c){function d(g){try{g=g||O.event;a:{var ca=g.target||g.srcElement;for(g=100;ca&&0<g;){if(ca.href&&ca.nodeName.match(/^a(?:rea)?$/i)){var l=ca;break a}ca=ca.parentNode;g--}l={}}("http:"==l.protocol||"https:"==l.protocol)&&sd(a,l.hostname||"")&&l.href&&(l.href=qd(e,l.href,b))}catch(k){J(26)}}var e=this;this.target.get(Ze)?De.auto(function(){return $e(e.target)},a,b?"fragment":"",c):(this.T||(this.T=!0,L(M,"mousedown",d,!1),L(M,"keyup",d,!1)),c&&L(M,"submit",function(g){g=
g||O.event;if((g=g.target||g.srcElement)&&g.action){var ca=g.action.match(Jc);ca&&sd(a,ca[1])&&rd(e,g)}}))};Dc.prototype.$=function(a){if(a){var b=this,c=b.target.get(F);void 0!==c&&De.passthrough(function(){if(c("analytics_storage"))return{};var d={};return d._ga=b.target.get(Q),d._up="1",d},1,!0)}};function sd(a,b){if(b==M.location.hostname)return!1;for(var c=0;c<a.length;c++)if(a[c]instanceof RegExp){if(a[c].test(b))return!0}else if(0<=b.indexOf(a[c]))return!0;return!1}
function ke(a,b){return b!=Ic(a,0)&&b!=Ic(a,-1)&&b!=Ic(a,-2)&&b!=pa(a,0)&&b!=pa(a,-1)&&b!=pa(a,-2)}function $e(a){var b=af(a),c={};c._ga=a.get(Q);c._gid=a.get(I)||void 0;c._gac=b?[b.qa,b.timestamp].join("."):void 0;b=a.get(Ae);a=Ed(a);return c._fplc=b&&"0"!==b?b:a,c}function af(a){function b(e){return void 0==e||""===e?0:Number(e)}var c=a.get(ce);if(c&&a.get(je)){var d=b(a.get(fe));if(1E3*d+b(a.get(he))<=(new Date).getTime())J(76);else return{timestamp:d,qa:c}}};var p=/^(GTM|OPT)-[A-Z0-9]+$/,Ie=/^G-[A-Z0-9]+$/,q=/;_gaexp=[^;]*/g,r=/;((__utma=)|([^;=]+=GAX?\d+\.))[^;]*/g,Aa=/^https?:\/\/[\w\-.]+\.google.com(:\d+)?\/optimize\/opt-launch\.html\?.*$/,t=function(a){function b(d,e){e&&(c+="&"+d+"="+K(e))}var c=Ge(a.type)+K(a.id);"dataLayer"!=a.B&&b("l",a.B);b("cx",a.context);b("t",a.target);b("cid",a.clientId);b("cidt",a.ka);b("gac",a.la);b("aip",a.ia);a.sync&&b("m","sync");b("cycle",a.G);a.qa&&b("gclid",a.qa);Aa.test(M.referrer)&&b("cb",String(hd()));return c},
He=function(a,b){var c=(new Date).getTime();O[a.B]=O[a.B]||[];c={"gtm.start":c};a.sync||(c.event="gtm.js");O[a.B].push(c);2===a.type&&function(d,e,g){O[a.B].push(arguments)}("config",a.id,b)},Ke=function(a,b,c,d){c=c||{};var e=1;Ie.test(b)&&(e=2);var g={id:b,type:e,B:c.dataLayer||"dataLayer",G:!1},ca=void 0;a.get("&gtm")==b&&(g.G=!0);1===e?(g.ia=!!a.get("anonymizeIp"),g.sync=d,b=String(a.get("name")),"t0"!=b&&(g.target=b),G(String(a.get("trackingId")))||(g.clientId=String(a.get(Q)),g.ka=Number(a.get(n)),
c=c.palindrome?r:q,c=(c=M.cookie.replace(/^|(; +)/g,";").match(c))?c.sort().join("").substring(1):void 0,g.la=c,g.qa=E(P(a,kb),"gclid"))):2===e&&(g.context="c",ca={allow_google_signals:a.get(Ud),allow_ad_personalization_signals:a.get(xe)});He(g,ca);return t(g)};var H={},Jd=function(a,b){b||(b=(b=P(a,V))&&"t0"!=b?Wd.test(b)?"_gat_"+Cc(P(a,Na)):"_gat_"+Cc(b):"_gat");this.Y=b},Rd=function(a,b){var c=b.get(Wb);b.set(Wb,function(e){Pd(a,e,ed);Pd(a,e,ia);var g=c(e);Qd(a,e);return g});var d=b.get(Xb);b.set(Xb,function(e){var g=d(e);if(se(e)){J(80);var ca={U:re(e,1),google:re(e,2),count:0};pe("https://stats.g.doubleclick.net/j/collect",ca.U,ca);e.set(ed,"",!0)}return g})},Pd=function(a,b,c){!1===b.get(Ud)||b.get(c)||("1"==Ca(a.Y)[0]?b.set(c,"",!0):b.set(c,""+hd(),
!0))},Qd=function(a,b){se(b)&&zc(a.Y,"1",P(b,Yb),P(b,W),P(b,Na),6E4,P(b,Be))},se=function(a){return!!a.get(ed)&&!1!==a.get(Ud)},Ne=function(a){return!H[P(a,Na)]&&void 0===a.get("&gtm")&&void 0===a.get(fa)&&void 0===a.get(gd)&&void 0===a.get(oe)},re=function(a,b){var c=new ee,d=function(g){$a(g).F&&c.set($a(g).F,a.get(g))};d(hb);d(ib);d(Na);d(Q);d(ed);1==b&&(d(Ad),d(ia),d(I));!1===a.get(xe)&&c.set("npa","1");c.set($a(ld).F,Td(a));var e="";c.map(function(g,ca){e+=K(g)+"=";e+=K(""+ca)+"&"});e+="z="+
hd();1==b?e="t=dc&aip=1&_r=3&"+e:2==b&&(e="t=sr&aip=1&_r=4&slf_rd=1&"+e);return e},Me=function(a){if(Ne(a))return H[P(a,Na)]=!0,function(b){if(b&&!H[b]){var c=Ke(a,b);Id(c);H[b]=!0}}},Wd=/^gtm\d+$/;var fd=function(a,b){a=a.model;if(!a.get("dcLoaded")){var c=new $c(Dd(a));c.set(29);a.set(Gd,c.C);b=b||{};var d;b[U]&&(d=Cc(b[U]));b=new Jd(a,d);Rd(b,a);a.set("dcLoaded",!0)}};var Sd=function(a){if(!a.get("dcLoaded")&&"cookie"==a.get(ac)){var b=new Jd(a);Pd(b,a,ed);Pd(b,a,ia);Qd(b,a);b=se(a);var c=Ne(a);b&&a.set(Md,1,!0);c&&a.set(Od,1,!0);if(b||c)a.set(ad,"d",!0),J(79),a.set(qe,{U:re(a,1),google:re(a,2),V:Me(a),count:0},!0)}};var Lc=function(){var a=O.gaGlobal=O.gaGlobal||{};return a.hid=a.hid||hd()};var wb=/^(UA|YT|MO|GP)-(\d+)-(\d+)$/,pc=function(a){function b(e,g){d.model.data.set(e,g)}function c(e,g){b(e,g);d.filters.add(e)}var d=this;this.model=new Ya;this.filters=new Ha;b(V,a[V]);b(Na,sa(a[Na]));b(U,a[U]);b(W,a[W]||xa());b(Yb,a[Yb]);b(Zb,a[Zb]);b(Hd,a[Hd]);b(Be,a[Be]);b($b,a[$b]);b(Wc,a[Wc]);b(bc,a[bc]);b(cc,a[cc]);b(Ka,a[Ka]);b(dc,a[dc]);b(ec,a[ec]);b(ac,a[ac]);b(Ad,a[Ad]);b(n,a[n]);b(Kd,a[Kd]);b(je,a[je]);b(Ze,a[Ze]);b(oe,a[oe]);b(Je,a[Je]);b(F,a[F]);b(hb,1);b(ib,"j87");c(Qb,Ma);c(oa,
ua);c(dd,cd);c(Rb,Oa);c(md,vb);c(Sb,nc);c(Uc,Yc);c(Tb,Ja);c(Vb,Ta);c(Vc,Hc);c(zd,yd);c(Ld,Sd);c(ze,Fe);c(Wb,Pa);c(Xb,Sa);c(Cd,Fd(this));pd(this.model);td(this.model,a[Q]);this.model.set(jb,Lc())};pc.prototype.get=function(a){return this.model.get(a)};pc.prototype.set=function(a,b){this.model.set(a,b)};
pc.prototype.send=function(a){if(!(1>arguments.length)){if("string"===typeof arguments[0]){var b=arguments[0];var c=[].slice.call(arguments,1)}else b=arguments[0]&&arguments[0][Va],c=arguments;b&&(c=za(me[b]||[],c),c[Va]=b,this.model.set(c,void 0,!0),this.filters.D(this.model),this.model.data.m={})}};pc.prototype.ma=function(a,b){var c=this;u(a,c,b)||(v(a,function(){u(a,c,b)}),y(String(c.get(V)),a,void 0,b,!0))};
var td=function(a,b){var c=P(a,U);a.data.set(la,"_ga"==c?"_gid":c+"_gid");if("cookie"==P(a,ac)){hc=!1;c=Ca(P(a,U));c=Xd(a,c);if(!c){c=P(a,W);var d=P(a,$b)||xa();c=Xc("__utma",d,c);void 0!=c?(J(10),c=c.O[1]+"."+c.O[2]):c=void 0}c&&(hc=!0);if(d=c&&!a.get(Hd))if(d=c.split("."),2!=d.length)d=!1;else if(d=Number(d[1])){var e=R(a,Zb);d=d+e<(new Date).getTime()/1E3}else d=!1;d&&(c=void 0);c&&(a.data.set(xd,c),a.data.set(Q,c),(c=uc(a))&&a.data.set(I,c));if(a.get(je)&&(c=a.get(ce),d=a.get(ie),!c||d&&"aw.ds"!=
d)){c={};if(M){d=[];e=M.cookie.split(";");for(var g=/^\s*_gac_(UA-\d+-\d+)=\s*(.+?)\s*$/,ca=0;ca<e.length;ca++){var l=e[ca].match(g);l&&d.push({ja:l[1],value:l[2]})}e={};if(d&&d.length)for(g=0;g<d.length;g++)(ca=d[g].value.split("."),"1"!=ca[0]||3!=ca.length)?c&&(c.na=!0):ca[1]&&(e[d[g].ja]?c&&(c.pa=!0):e[d[g].ja]=[],e[d[g].ja].push({timestamp:ca[1],qa:ca[2]}));d=e}else d={};d=d[P(a,Na)];le(c);d&&0!=d.length&&(c=d[0],a.data.set(fe,c.timestamp),a.data.set(ce,c.qa))}}if(a.get(Hd)){c=be("_ga",!!a.get(cc));
g=be("_gl",!!a.get(cc));d=De.get(a.get(cc));e=d._ga;g&&0<g.indexOf("_ga*")&&!e&&J(30);if(b||!a.get(Je))g=!1;else if(g=a.get(F),void 0===g||g("analytics_storage"))g=!1;else{J(84);a.data.set(Le,1);if(g=d._up)(g=Jc.exec(M.referrer))?(g=g[1],ca=M.location.hostname,g=ca===g||0<=ca.indexOf("."+g)||0<=g.indexOf("."+ca)?!0:!1):g=!1;g=g?!0:!1}ca=d.gclid;l=d._gac;if(c||e||ca||l)if(c&&e&&J(36),a.get(bc)||ye(a.get(Kd))||g){e&&(J(38),a.data.set(Q,e),d._gid&&(J(51),a.data.set(I,d._gid)));ca?(J(82),a.data.set(ce,
ca),d.gclsrc&&a.data.set(ie,d.gclsrc)):l&&(e=l.split("."))&&2===e.length&&(J(37),a.data.set(ce,e[0]),a.data.set(fe,e[1]));if(d=d._fplc)J(83),a.data.set(Ae,d);if(c)b:if(d=c.indexOf("."),-1==d)J(22);else{e=c.substring(0,d);g=c.substring(d+1);d=g.indexOf(".");c=g.substring(0,d);g=g.substring(d+1);if("1"==e){if(d=g,ke(d,c)){J(23);break b}}else if("2"==e){d=g.indexOf("-");e="";0<d?(e=g.substring(0,d),d=g.substring(d+1)):d=g.substring(1);if(ke(e+d,c)){J(53);break b}e&&(J(2),a.data.set(I,e))}else{J(22);
break b}J(11);a.data.set(Q,d);if(c=be("_gac",!!a.get(cc)))c=c.split("."),"1"!=c[0]||4!=c.length?J(72):ke(c[3],c[1])?J(71):(a.data.set(ce,c[3]),a.data.set(fe,c[2]),J(70))}}else J(21)}b&&(J(9),a.data.set(Q,K(b)));a.get(Q)||(b=(b=O.gaGlobal)&&b.from_cookie&&"cookie"!==P(a,ac)?void 0:(b=b&&b.vid)&&-1!==b.search(jd)?b:void 0,b?(J(17),a.data.set(Q,b)):(J(8),a.data.set(Q,ra())));a.get(I)||(J(3),a.data.set(I,ra()));mc(a);b=O.gaGlobal=O.gaGlobal||{};c=P(a,Q);a=c===P(a,xd);if(void 0==b.vid||a&&!b.from_cookie)b.vid=
c,b.from_cookie=a},pd=function(a){var b=O.navigator,c=O.screen,d=M.location;a.set(lb,ya(!!a.get(ec),!!a.get(Kd)));if(d){var e=d.pathname||"";"/"!=e.charAt(0)&&(J(31),e="/"+e);a.set(kb,d.protocol+"//"+d.hostname+e+d.search)}c&&a.set(qb,c.width+"x"+c.height);c&&a.set(pb,c.colorDepth+"-bit");c=M.documentElement;var g=(e=M.body)&&e.clientWidth&&e.clientHeight,ca=[];c&&c.clientWidth&&c.clientHeight&&("CSS1Compat"===M.compatMode||!g)?ca=[c.clientWidth,c.clientHeight]:g&&(ca=[e.clientWidth,e.clientHeight]);
c=0>=ca[0]||0>=ca[1]?"":ca.join("x");a.set(rb,c);a.set(tb,fc());a.set(ob,M.characterSet||M.charset);a.set(sb,b&&"function"===typeof b.javaEnabled&&b.javaEnabled()||!1);a.set(nb,(b&&(b.language||b.browserLanguage)||"").toLowerCase());a.data.set(ce,be("gclid",!0));a.data.set(ie,be("gclsrc",!0));a.data.set(fe,Math.round((new Date).getTime()/1E3));if(d&&a.get(cc)&&(b=M.location.hash)){b=b.split(/[?&#]+/);d=[];for(c=0;c<b.length;++c)(D(b[c],"utm_id")||D(b[c],"utm_campaign")||D(b[c],"utm_source")||D(b[c],
"utm_medium")||D(b[c],"utm_term")||D(b[c],"utm_content")||D(b[c],"gclid")||D(b[c],"dclid")||D(b[c],"gclsrc"))&&d.push(b[c]);0<d.length&&(b="#"+d.join("&"),a.set(kb,a.get(kb)+b))}},me={pageview:[mb],event:[ub,xb,yb,zb],social:[Bb,Cb,Db],timing:[Mb,Nb,Pb,Ob]};var rc=function(a){if("prerender"==M.visibilityState)return!1;a();return!0},z=function(a){if(!rc(a)){J(16);var b=!1,c=function(){if(!b&&rc(a)){b=!0;var d=c,e=M;e.removeEventListener?e.removeEventListener("visibilitychange",d,!1):e.detachEvent&&e.detachEvent("onvisibilitychange",d)}};L(M,"visibilitychange",c)}};var te=/^(?:(\w+)\.)?(?:(\w+):)?(\w+)$/,sc=function(a){if(ea(a[0]))this.u=a[0];else{var b=te.exec(a[0]);null!=b&&4==b.length&&(this.c=b[1]||"t0",this.K=b[2]||"",this.methodName=b[3],this.a=[].slice.call(a,1),this.K||(this.A="create"==this.methodName,this.i="require"==this.methodName,this.g="provide"==this.methodName,this.ba="remove"==this.methodName),this.i&&(3<=this.a.length?(this.X=this.a[1],this.W=this.a[2]):this.a[1]&&(qa(this.a[1])?this.X=this.a[1]:this.W=this.a[1])));b=a[1];a=a[2];if(!this.methodName)throw"abort";
if(this.i&&(!qa(b)||""==b))throw"abort";if(this.g&&(!qa(b)||""==b||!ea(a)))throw"abort";if(ud(this.c)||ud(this.K))throw"abort";if(this.g&&"t0"!=this.c)throw"abort";}};function ud(a){return 0<=a.indexOf(".")||0<=a.indexOf(":")};var Yd,Zd,$d,A;Yd=new ee;$d=new ee;A=new ee;Zd={ec:45,ecommerce:46,linkid:47};
var u=function(a,b,c){b==N||b.get(V);var d=Yd.get(a);if(!ea(d))return!1;b.plugins_=b.plugins_||new ee;if(b.plugins_.get(a))return!0;b.plugins_.set(a,new d(b,c||{}));return!0},y=function(a,b,c,d,e){if(!ea(Yd.get(b))&&!$d.get(b)){Zd.hasOwnProperty(b)&&J(Zd[b]);a=N.j(a);if(p.test(b)){J(52);if(!a)return!0;c=Ke(a.model,b,d,e)}!c&&Zd.hasOwnProperty(b)?(J(39),c=b+".js"):J(43);if(c){if(a){var g=a.get(oe);qa(g)||(g=void 0)}c&&0<=c.indexOf("/")||(c=(g||bd(!1))+"/plugins/ua/"+c);d=ae(c);g=d.protocol;c=M.location.protocol;
("https:"==g||g==c||("http:"!=g?0:"http:"==c))&&B(d)&&(Id(d.url,void 0,e),$d.set(b,!0))}}},v=function(a,b){var c=A.get(a)||[];c.push(b);A.set(a,c)},C=function(a,b){Yd.set(a,b);b=A.get(a)||[];for(var c=0;c<b.length;c++)b[c]();A.set(a,[])},B=function(a){var b=ae(M.location.href);if(D(a.url,Ge(1))||D(a.url,Ge(2)))return!0;if(a.query||0<=a.url.indexOf("?")||0<=a.path.indexOf("://"))return!1;if(a.host==b.host&&a.port==b.port)return!0;b="http:"==a.protocol?80:443;return"www.google-analytics.com"==a.host&&
(a.port||b)==b&&D(a.path,"/plugins/")?!0:!1},ae=function(a){function b(l){var k=l.hostname||"",w=0<=k.indexOf("]");k=k.split(w?"]":":")[0].toLowerCase();w&&(k+="]");w=(l.protocol||"").toLowerCase();w=1*l.port||("http:"==w?80:"https:"==w?443:"");l=l.pathname||"";D(l,"/")||(l="/"+l);return[k,""+w,l]}var c=M.createElement("a");c.href=M.location.href;var d=(c.protocol||"").toLowerCase(),e=b(c),g=c.search||"",ca=d+"//"+e[0]+(e[1]?":"+e[1]:"");D(a,"//")?a=d+a:D(a,"/")?a=ca+a:!a||D(a,"?")?a=ca+e[2]+(a||
g):0>a.split("/")[0].indexOf(":")&&(a=ca+e[2].substring(0,e[2].lastIndexOf("/"))+"/"+a);c.href=a;d=b(c);return{protocol:(c.protocol||"").toLowerCase(),host:d[0],port:d[1],path:d[2],query:c.search||"",url:a||""}};var Z={ga:function(){Z.f=[]}};Z.ga();Z.D=function(a){var b=Z.J.apply(Z,arguments);b=Z.f.concat(b);for(Z.f=[];0<b.length&&!Z.v(b[0])&&!(b.shift(),0<Z.f.length););Z.f=Z.f.concat(b)};Z.J=function(a){for(var b=[],c=0;c<arguments.length;c++)try{var d=new sc(arguments[c]);d.g?C(d.a[0],d.a[1]):(d.i&&(d.ha=y(d.c,d.a[0],d.X,d.W)),b.push(d))}catch(e){}return b};
Z.v=function(a){try{if(a.u)a.u.call(O,N.j("t0"));else{var b=a.c==gb?N:N.j(a.c);if(a.A){if("t0"==a.c&&(b=N.create.apply(N,a.a),null===b))return!0}else if(a.ba)N.remove(a.c);else if(b)if(a.i){if(a.ha&&(a.ha=y(a.c,a.a[0],a.X,a.W)),!u(a.a[0],b,a.W))return!0}else if(a.K){var c=a.methodName,d=a.a,e=b.plugins_.get(a.K);e[c].apply(e,d)}else b[a.methodName].apply(b,a.a)}}catch(g){}};var N=function(a){J(1);Z.D.apply(Z,[arguments])};N.h={};N.P=[];N.L=0;N.ya=0;N.answer=42;var we=[Na,W,V];N.create=function(a){var b=za(we,[].slice.call(arguments));b[V]||(b[V]="t0");var c=""+b[V];if(N.h[c])return N.h[c];if(da(b))return null;b=new pc(b);N.h[c]=b;N.P.push(b);c=qc().tracker_created;if(ea(c))try{c(b)}catch(d){}return b};N.remove=function(a){for(var b=0;b<N.P.length;b++)if(N.P[b].get(V)==a){N.P.splice(b,1);N.h[a]=null;break}};N.j=function(a){return N.h[a]};N.getAll=function(){return N.P.slice(0)};
N.N=function(){"ga"!=gb&&J(49);var a=O[gb];if(!a||42!=a.answer){N.L=a&&a.l;N.ya=1*new Date;N.loaded=!0;var b=O[gb]=N;X("create",b,b.create);X("remove",b,b.remove);X("getByName",b,b.j,5);X("getAll",b,b.getAll,6);b=pc.prototype;X("get",b,b.get,7);X("set",b,b.set,4);X("send",b,b.send);X("requireSync",b,b.ma);b=Ya.prototype;X("get",b,b.get);X("set",b,b.set);if("https:"!=M.location.protocol&&!Ba){a:{b=M.getElementsByTagName("script");for(var c=0;c<b.length&&100>c;c++){var d=b[c].src;if(d&&0==d.indexOf(bd(!0)+
"/analytics")){b=!0;break a}}b=!1}b&&(Ba=!0)}(O.gaplugins=O.gaplugins||{}).Linker=Dc;b=Dc.prototype;C("linker",Dc);X("decorate",b,b.ca,20);X("autoLink",b,b.S,25);X("passthrough",b,b.$,25);C("displayfeatures",fd);C("adfeatures",fd);a=a&&a.q;ka(a)?Z.D.apply(N,a):J(50)}};var Oe=N.N,Pe=O[gb];Pe&&Pe.r?Oe():z(Oe);z(function(){Z.D(["provide","render",ua])});})(window);

View File

@@ -4,59 +4,41 @@ const server = require("http").Server(app);
const io = require("socket.io")(server);
const path = require("path");
const session = require("express-session");
const User = require(path.join(__dirname + "/schemas/User"));
const User = require(path.join(__dirname + "/api/schemas/User"));
const apiRouter = require(path.join(__dirname + "/api/router.js"));
const loginApi = require(path.join(__dirname + "/api/login"));
const subscriptionApi = require(path.join(__dirname + "/api/subscriptions"));
//This is required for the chat to work
const chat = require(path.join(__dirname + "/api/chat"))(io);
const chatHistory = require(path.join(__dirname + "/api/chatHistory"));
const bodyParser = require("body-parser");
const mongoose = require("mongoose");
const MongoStore = require("connect-mongo")(session);
const cors = require("cors");
const referrerPolicy = require("referrer-policy");
const helmet = require("helmet");
const featurePolicy = require("feature-policy");
const compression = require("compression");
app.use(compression());
app.use(
featurePolicy({
features: {
fullscreen: ["*"],
//vibrate: ["'none'"],
payment: ["'none'"],
microphone: ["'none'"],
camera: ["'self'"],
speaker: ["*"],
syncXhr: ["'self'"]
//notifications: ["'self'"]
}
})
);
app.use(helmet());
app.use(helmet.frameguard({ action: "sameorigin" }));
app.use(referrerPolicy({ policy: "origin" }));
app.use(cors());
// mongoose / database
console.log("Trying to connect with mongodb..");
mongoose.promise = global.Promise;
mongoose.connect("mongodb://localhost/vinlottis");
mongoose.set("debug", true);
mongoose.connect("mongodb://localhost/vinlottis", {
useCreateIndex: true,
useNewUrlParser: true,
useUnifiedTopology: true,
serverSelectionTimeoutMS: 10000 // initial connection timeout
}).then(_ => console.log("Mongodb connection established!"))
.catch(err => {
console.log(err);
console.error("ERROR! Mongodb required to run.");
process.exit(1);
})
mongoose.set("debug", false);
app.use(
bodyParser.urlencoded({
extended: true
})
);
app.use(bodyParser.json());
// middleware
const setupCORS = require(path.join(__dirname, "/api/middleware/setupCORS"));
const setupHeaders = require(path.join(__dirname, "/api/middleware/setupHeaders"));
app.use(setupCORS)
app.use(setupHeaders)
// parse application/json
app.use(express.json());
app.use(
session({
@@ -70,36 +52,34 @@ app.use(
})
);
app.set('socketio', io);
app.set('socketio', io); // set io instance to key "socketio"
const passport = require("passport");
const LocalStrategy = require("passport-local");
app.use(passport.initialize());
app.use(passport.session());
// use static authenticate method of model in LocalStrategy
passport.use(new LocalStrategy(User.authenticate()));
// use static serialize and deserialize of model for passport session support
passport.serializeUser(User.serializeUser());
passport.deserializeUser(User.deserializeUser());
// files
app.use("/public", express.static(path.join(__dirname, "public")));
app.use("/dist", express.static(path.join(__dirname, "public/dist")));
app.use("/", loginApi);
app.use("/api/", chatHistory);
app.use("/service-worker.js", express.static(path.join(__dirname, "public/sw/serviceWorker.js")));
// api endpoints
app.use("/api/", apiRouter);
// redirects
app.get("/dagens", (req, res) => res.redirect("/#/dagens"));
app.get("/winner/:id", (req, res) => res.redirect("/#/winner/" + req.params.id));
// push-notifications
app.use("/subscription", subscriptionApi);
app.get("/dagens", function(req, res) {
res.redirect("/#/dagens");
});
app.get("/winner/:id", function(req, res) {
res.redirect("/#/winner/" + req.params.id);
});
app.use("/service-worker.js", function(req, res) {
res.sendFile(path.join(__dirname, "public/sw/serviceWorker.js"));
});
// No other route defined, return index file
app.use("/", (req, res) => res.sendFile(path.join(__dirname + "/public/dist/index.html")));
server.listen(30030);

View File

@@ -1,395 +0,0 @@
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("/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("/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 = (skip = null, take = null) => {
const url = new URL("/api/chat/history", BASE_URL);
if (!isNaN(skip)) url.searchParams.append("skip", skip);
if (!isNaN(take)) url.searchParams.append("take", take);
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

@@ -1,96 +0,0 @@
import VinlottisPage from "@/components/VinlottisPage";
import GeneratePage from "@/components/GeneratePage";
import TodaysPage from "@/components/TodaysPage";
import AllWinesPage from "@/components/AllWinesPage";
import LoginPage from "@/components/LoginPage";
import CreatePage from "@/components/CreatePage";
import AdminPage from "@/components/AdminPage";
import WinnerPage from "@/components/WinnerPage";
import LotteryPage from "@/components/LotteryPage";
import HistoryPage from "@/components/HistoryPage";
import HighscorePage from "@/components/HighscorePage";
import PersonalHighscorePage from "@/components/PersonalHighscorePage";
import RequestWine from "@/components/RequestWine";
import AllRequestedWines from "@/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

@@ -1 +0,0 @@
<div id="app"></div>

View File

@@ -1,252 +0,0 @@
<template>
<div class="chat-container">
<hr />
<h2>Chat</h2>
<div class="history" ref="history">
<div class="opaque-skirt"></div>
<div v-if="existsMore" class="fetch-older-history">
<button @click="$emit('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="user-name">{{ history.username }}</span>
<span class="timestamp">{{ getTime(history.timestamp) }}</span>
</div>
<span class="message">{{ history.message }}</span>
</div>
</div>
<div v-if="usernameSet" 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.."
/>
<button @click="setUsername">Lagre brukernavn</button>
</div>
</div>
</template>
<script>
export default {
props: {
usernameAllowed: {
type: Boolean
},
chatHistory: {
type: Array
},
historyPageSize: {
type: Number
}
},
data() {
return {
message: "",
temporaryUsername: null,
username: null,
usernameSet: false,
existsMore: true
};
},
watch: {
chatHistory: {
handler: function(newVal, oldVal) {
if (this.$refs && this.$refs.history) {
const firstMessages = oldVal.length == 0;
const diffLargerThanOne = newVal.length - oldVal.length > 1;
setTimeout(() => {
if (firstMessages || diffLargerThanOne == false) {
this.scrollToBottomOfHistory();
} else {
this.scrollToStartOfNewMessages();
// what shows the load more button - if we scroll page and less than page size
// come back we have reached a limit
this.existsMore = newVal.length - oldVal.length == this.historyPageSize
}
}, 100);
}
},
deep: true
}
},
mounted() {
let username = window.localStorage.getItem("username");
if (username) {
this.username = username;
this.usernameSet = true;
this.$emit("username", username);
}
},
methods: {
pad: function(num) {
if (num > 9) return num;
return `0${num}`;
},
getTime: function(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: function() {
this.$emit("message", this.message);
this.message = "";
},
removeUsername: function() {
this.username = null;
this.temporaryUsername = null;
this.usernameSet = false;
window.localStorage.removeItem("username");
this.$emit("username", null);
},
setUsername: function() {
if (
this.temporaryUsername.length > 3 &&
this.temporaryUsername.length < 30
) {
this.username = this.temporaryUsername;
this.usernameSet = true;
this.$emit("username", this.username);
}
},
scrollToBottomOfHistory() {
if (this.$refs && this.$refs.history) {
const { history } = this.$refs;
history.scrollTop = history.scrollHeight;
}
},
scrollToStartOfNewMessages() {
const { history } = this.$refs;
const histLength = history.children.length;
const pages = Math.floor(histLength / 100);
const messageToScrollTo = history.children[histLength - ((pages * 100) + 3)]
history.scrollTop = messageToScrollTo.offsetTop;
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.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;
&-message {
display: flex;
flex-direction: column;
margin: 0.35rem 0;
position: relative;
.user-name {
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: 0.2rem 0 0.5rem;
}
@include mobile {
height: 300px;
}
}
.username-dialog {
display: flex;
flex-direction: row;
justify-content: center;
}
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>

View File

@@ -1,38 +0,0 @@
import Vue from "vue";
import VueRouter from "vue-router";
import { routes } from "@/router.js";
import Vinlottis from "@/Vinlottis";
import VueAnalytics from "vue-analytics";
import * as Sentry from "@sentry/browser";
import { Vue as VueIntegration } from "@sentry/integrations";
import { Integrations } from "@sentry/tracing";
Vue.use(VueRouter);
Vue.use(VueAnalytics, {
id: "UA-156846886-1"
});
Sentry.init({
dsn: "https://7debc951f0074fb68d7a76a1e3ace6fa@o364834.ingest.sentry.io/4905091",
integrations: [
new VueIntegration({
Vue,
tracing: true
}),
new Integrations.BrowserTracing(),
]
})
const router = new VueRouter({
routes: routes
});
new Vue({
el: "#app",
router,
components: { Vinlottis },
template: "<Vinlottis/>",
render: h => h(Vinlottis)
});

Some files were not shown because too many files have changed in this diff Show More