Compare commits
563 Commits
refactor/w
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c7a69294c | ||
| 168aad21e9 | |||
| 836a06537a | |||
| a44a2f5e2a | |||
| 560fb81a31 | |||
| ddd497f074 | |||
| 45d1df92a1 | |||
| b15c9cecb6 | |||
| 6c708532ac | |||
| d4f059945d | |||
| b94ea75941 | |||
| 019e763341 | |||
| 19c1f18ef6 | |||
| 5a69001efd | |||
| 9f3b3777d9 | |||
| 783373da22 | |||
| 105820cdbb | |||
| d21a33ab42 | |||
| 3188e83aeb | |||
| f91466f1bf | |||
| 7092fb1959 | |||
| 210f9ddbc9 | |||
| c96b52c935 | |||
| 8c964a4815 | |||
| 127fc6741f | |||
| 66253f7bfe | |||
| 1d2a443915 | |||
| ab15e24574 | |||
| 908b61f5bb | |||
| fb4e7d4506 | |||
| 753e4eb422 | |||
| d71a3a4c5a | |||
| a700de6e2e | |||
| c0d98af6e1 | |||
| 947d8958a0 | |||
| 9503967e7e | |||
| 57ddd77493 | |||
| d44cc3cd39 | |||
| 7b406ef432 | |||
|
|
e498dad860 | ||
| e637455059 | |||
|
|
56072ff282 | ||
|
|
0e3c4d98e6 | ||
|
|
64377b7cc0 | ||
|
|
59b4366ed5 | ||
| ea10f95a22 | |||
| bd4d833533 | |||
| 4ed912df46 | |||
| 5af082784c | |||
| b0424a519c | |||
| 33fa7c14c6 | |||
| 4a270f05b8 | |||
| b0000293a6 | |||
| dbcd56a98b | |||
| 8df082dd48 | |||
| 80e6c91045 | |||
| 7e2b5a5bb0 | |||
| ac6a5195a5 | |||
| 113f286f03 | |||
| b260991116 | |||
| 7402fb7a7c | |||
| e89952d965 | |||
| 60044859eb | |||
| d108f331ca | |||
| 3388bed400 | |||
| 2fce0e66ab | |||
| 704ed51db5 | |||
| 48c1842b8b | |||
| 7bd2e9d931 | |||
| d337329765 | |||
| 83d9b30048 | |||
| a37c08880c | |||
| 6b33e03aae | |||
| 9e25b374e0 | |||
| 9913b5984a | |||
| 1d4b74b56b | |||
| f1a0f2a0f2 | |||
| 9b2d0f2d31 | |||
| e20e952573 | |||
| 8d3a21825d | |||
| b02472ef75 | |||
| 3f77722f4f | |||
| 324ca5d9bc | |||
| b493fa2bea | |||
| 1e6ec3d4c8 | |||
| 1b12453df0 | |||
| 2eb933f03e | |||
| de664b3a29 | |||
| 07dd0d43f5 | |||
| cff64999b3 | |||
| 710f276a9b | |||
| 6e0b2b76fe | |||
| 7234c2fbba | |||
| d9de155174 | |||
| 7267c5f5bd | |||
| be70fa6ddf | |||
| 30a9d30b1e | |||
| 2734e9a840 | |||
| 3886313351 | |||
| fc261b9274 | |||
| 442e0ffbfd | |||
| 20dc2b8e38 | |||
| 2477f36f96 | |||
| 4ab67877b9 | |||
| 6968ccf389 | |||
| 8bd41cc691 | |||
| eaf57115e8 | |||
| cded690fba | |||
| eb9e7d4b43 | |||
| b2755add12 | |||
| b5cca00ed4 | |||
| 2cf4095b97 | |||
| 72c1896747 | |||
| 011aec3dea | |||
| b57fb5f1f8 | |||
| 9823197a48 | |||
| d0fa89b92b | |||
| fc029f80df | |||
| 824bd60c02 | |||
| 6003151e3b | |||
| ab58a45da5 | |||
| dcaaeae51f | |||
| 9fd67a6bc3 | |||
| 70c80849df | |||
| a28a8ccacb | |||
| 4bd3b688e9 | |||
| 930c458d9c | |||
| 787882e753 | |||
| 68b4e96ad0 | |||
| 56d2513a9c | |||
| 1c40fae69d | |||
| bca4558d59 | |||
| 38eb98e68b | |||
| c98ccbc3f0 | |||
| 3d99a3e5f2 | |||
| 7292cf7983 | |||
| 57fe7d444b | |||
| cb4a30b5e9 | |||
| 56095cb3e2 | |||
| b321f2cfdd | |||
| ce480e790a | |||
| ba86bf3ada | |||
| f4a16bc417 | |||
| 4c33708ff4 | |||
| 87257fd5b2 | |||
| ac829052b6 | |||
| 1b1a99ccc3 | |||
| 5e018f071d | |||
| 939e7e34df | |||
| 33070ae31a | |||
| 6e02c5e393 | |||
| b596dc28e8 | |||
| 03c0513da3 | |||
| afab4387cc | |||
| 1c1f52308f | |||
| f5d3b16f27 | |||
| b5b61784cc | |||
| 2f3a6aeba7 | |||
| 84fa1ff925 | |||
| 53135acc05 | |||
| fac50805bd | |||
| 6d5f0e824f | |||
| 4d822ccb64 | |||
| 7aa5f7e9ce | |||
| 18d8c2c7ca | |||
| edc4d26647 | |||
| e07e6ae09a | |||
| 93854bc131 | |||
| 872f1f5fa3 | |||
| ccba3e5f10 | |||
| 8d320e73c0 | |||
| bfa13892d5 | |||
|
|
c430d07703 | ||
|
|
3ed0ce7dac | ||
| e36c6b42eb | |||
| 11b988ba19 | |||
| e9ece6963e | |||
| 53780878af | |||
| 5e06a3fc28 | |||
| 54c6c0eb97 | |||
| e754f0a909 | |||
| a010641a8e | |||
| 89389ddc59 | |||
| c03f5aa0cf | |||
| ca6f6cb2ba | |||
| 4043954f95 | |||
| fc69accea3 | |||
| 3906816b80 | |||
| 6c4b6588d2 | |||
| 8044759264 | |||
| 242aa28847 | |||
| f34857f5a8 | |||
| 593de53073 | |||
| 938b92cf0d | |||
| 342651a90c | |||
| f115ee79e6 | |||
| 326101f7d2 | |||
| fea81dcd63 | |||
| dfe89160b1 | |||
| 9ab3c8c83d | |||
| 7a2b5600c4 | |||
| 30b63a8e61 | |||
| d59d6fbd6c | |||
| 814ee4fa7d | |||
| 18079ae312 | |||
| 8275292526 | |||
| e61a6c0432 | |||
| d0cf99e8f8 | |||
| 50ea05cad3 | |||
| 59328de496 | |||
| 3c0b8d4c06 | |||
| 0144780bb1 | |||
| baa348bc95 | |||
| 1839810e66 | |||
| 9265572e45 | |||
| ce7e05fd43 | |||
| 913268b01c | |||
| 37f41ff894 | |||
| a1a14b5361 | |||
| 466e21aa0e | |||
| 9e7be82f57 | |||
| 2fa0c1085c | |||
| c4c74ca3ef | |||
| ced7ebfcac | |||
| 539386664c | |||
| 6503b309c5 | |||
| da8251c733 | |||
| e9eada0002 | |||
| 8f844dbf85 | |||
| ccc72997c0 | |||
| 055d13af35 | |||
| d58f6dd210 | |||
| edd09c012c | |||
| ea1237464d | |||
| 7c0d7c14ec | |||
| cf06140f60 | |||
| 81ce466318 | |||
| 7bebe07db6 | |||
| ad27ba8b33 | |||
| 236c07f3d0 | |||
| 036f6ea499 | |||
| 51e11d0df0 | |||
| d36aad3f9e | |||
| cc0bef927f | |||
| 594c4cc482 | |||
| a2a81e488e | |||
| 0e8f73ebd5 | |||
| 30fdbb5e2f | |||
| 1f17429c8f | |||
| 7096fbed86 | |||
| 054ff69b27 | |||
| 7eb69aa3f7 | |||
| c85e6ca56a | |||
| 50c79abaf7 | |||
| 2504dc55d6 | |||
| e12e5cafb0 | |||
| ee5f817248 | |||
| f7ac5a96ee | |||
| 9816642362 | |||
| d20acadf87 | |||
| 3b8d6f22ca | |||
|
|
c1c11f9782 | ||
|
|
90f026cfe7 | ||
|
|
03347e6ac9 | ||
| 607d47aee5 | |||
| dc0b9859fc | |||
|
|
0b04e9294f | ||
|
|
8296474538 | ||
|
|
f8b58eb64c | ||
|
|
82d708cf82 | ||
| f690124871 | |||
|
|
3ab9f2d7ab | ||
|
|
d5a7ce1b1e | ||
|
|
c2c3ae153a | ||
|
|
425975b4b9 | ||
|
|
7de2530b9b | ||
| 627f96b420 | |||
| da29e3a8ce | |||
| 8b498f3606 | |||
| 7b05c0da7c | |||
| 003f0d1c4d | |||
| 3aa989d2c1 | |||
|
|
c93671d14e | ||
|
|
58b91e67f9 | ||
| 2fc5fd29b9 | |||
|
|
8758752b26 | ||
|
|
0d0022f420 | ||
|
|
2ee071e2b2 | ||
|
|
c96356127c | ||
|
|
9f2adb54e0 | ||
| 554948d67c | |||
| f01b58c1b6 | |||
| 12d0137987 | |||
| e13b4125be | |||
| 13a9c00b50 | |||
| 87c309d094 | |||
| dda526eb5c | |||
| f1d500936b | |||
| b6e2bde4d1 | |||
| 5cbb3cbe87 | |||
| d49303a42a | |||
| d2242d4b9b | |||
| efbad63fcd | |||
| bce28c5eb9 | |||
| 794a2b06e4 | |||
| d5f3a7a1f0 | |||
| abf673faeb | |||
| 526be378d3 | |||
|
|
61851e4935 | ||
|
|
daeae25e93 | ||
|
|
315af4d50e | ||
|
|
7cf9e41b6f | ||
|
|
f8f5cd519a | ||
|
|
eee7a85cba | ||
|
|
cee0e1c8a6 | ||
|
|
397c635551 | ||
|
|
1f4f4c224e | ||
|
|
a0a9a7205e | ||
| 95105582e7 | |||
| 82512fa4ba | |||
| 56edf7e4d6 | |||
| 72ba0fb398 | |||
| 73d15dcdff | |||
| 46fb5dc6c1 | |||
|
|
74d15bbfc2 | ||
| a7fc12038c | |||
| 859a018c87 | |||
| 53ef555822 | |||
| 82068f22a9 | |||
| 8cdd9cb2c6 | |||
| 2c574020d0 | |||
| da00c7735e | |||
| 40d5062d2f | |||
| 401b9b8ac9 | |||
| cb368ee6a3 | |||
| 3344af6e90 | |||
| ba522c350a | |||
|
|
0ad3d4cafd | ||
| 6603fc489c | |||
| a90b332e27 | |||
| 7e0d3cd75e | |||
| e76e814877 | |||
| b3b5e87ab5 | |||
| 439191008a | |||
| 795f110e1b | |||
| 432d9211b2 | |||
| 25823540ff | |||
| 2013675802 | |||
| d6839dd10b | |||
| f868e03e7b | |||
| a50c94f77c | |||
| 99a94a6bc2 | |||
| 685db1f8f5 | |||
| f44402cf03 | |||
| 98e04ecd63 | |||
| 61fad22ef4 | |||
| d3bfdb632b | |||
| eaf3dbb586 | |||
| 292cdab3dd | |||
| 139b80ccff | |||
| 335d834cd4 | |||
| d68cd17ba1 | |||
| afff3fcdee | |||
| feed7774ce | |||
| 21904f4bb6 | |||
| f2989d2534 | |||
| 9c1b290219 | |||
| a7d673026e | |||
| 587239b799 | |||
| 6670f43b11 | |||
| dc7ffbae7a | |||
| 5f2b29324d | |||
| 64a1a8a93a | |||
| 5f973b199d | |||
| 52dedd1e7c | |||
| 823bbb7437 | |||
| 03c13d9558 | |||
| c0f26f9061 | |||
| 20be3cc5ca | |||
| 90eb557f0b | |||
| 0e74534cde | |||
| 0923965544 | |||
| 2062f64999 | |||
| 9d9947d7dc | |||
| d5558863e4 | |||
| 27f4c8faef | |||
| 0702507963 | |||
| 8ef1a2dd88 | |||
| a59f1e2b17 | |||
| 1383a310b3 | |||
| 584d497c6a | |||
| 98c2707cb0 | |||
|
|
4ce8ca1a99 | ||
| cb286b6894 | |||
| 73f1614ed4 | |||
| bd96a19faa | |||
| c01692bf1e | |||
| 59792f9aae | |||
| e87a59b1e6 | |||
| 4c02c81cc3 | |||
| 0e8bf9546a | |||
| 9b2de75910 | |||
| 05b07ca465 | |||
|
|
5ffb10468c | ||
|
|
2b44a6454f | ||
| 53257a16ff | |||
| 8ce7c254fb | |||
| f4be12d00b | |||
| 8d8550a835 | |||
| b0b99ed921 | |||
|
|
89557c89b7 | ||
|
|
67cd06444f | ||
|
|
53156e2330 | ||
|
|
c1c4b414c3 | ||
|
|
ce67ecc23d | ||
|
|
06a50a6662 | ||
|
|
b31bee8731 | ||
|
|
a5abdfd002 | ||
|
|
3f6b362d5a | ||
|
|
9f7a4df61b | ||
|
|
0f24afd07c | ||
|
|
48e43edcf4 | ||
|
|
122eaad1d1 | ||
|
|
6aa535bade | ||
|
|
1e8a77ec3f | ||
|
|
833cf649f3 | ||
|
|
2c9821d753 | ||
|
|
d5cf6d31ca | ||
|
|
00cbd3c871 | ||
| 06c2d35580 | |||
| 1f44b7bf8e | |||
|
|
c0171412c1 | ||
|
|
4089ad9050 | ||
|
|
80f935db26 | ||
|
|
934eb2e9ee | ||
|
|
4f270a9ca0 | ||
|
|
c405cfef54 | ||
|
|
86e7b5a56d | ||
|
|
3ab4a17d5a | ||
|
|
1327650492 | ||
|
|
036958b279 | ||
|
|
9ab84fffda | ||
|
|
e9e7b60f22 | ||
|
|
ef367bd1db | ||
|
|
d2ad209d13 | ||
|
|
861568069c | ||
|
|
fa6e5afa9c | ||
|
|
4e6121e618 | ||
|
|
45d47924e9 | ||
|
|
2eb664fc79 | ||
|
|
13e0c26eef | ||
|
|
d1044ee6c8 | ||
|
|
3b490e9995 | ||
|
|
21603dc856 | ||
|
|
07d751700f | ||
| a2fbbf0715 | |||
| 83f69ff007 | |||
| aa19c3b3a7 | |||
| 23165c0b3f | |||
| 3ebd3d3dce | |||
| 8c0a002020 | |||
|
|
5cfec8bb9d | ||
|
|
f17931a014 | ||
|
|
ee15c65988 | ||
|
|
68f20268b8 | ||
|
|
563c45bc27 | ||
|
|
2764972abd | ||
|
|
2406d2b81a | ||
|
|
052b5007c2 | ||
|
|
e060ff3aaa | ||
|
|
ea278a4892 | ||
|
|
4a98e79414 | ||
|
|
6d26da1817 | ||
| 34216c2708 | |||
| 9174338621 | |||
| 2cc5c81df6 | |||
| ace222749a | |||
| 1aa266ad71 | |||
| 20dd638133 | |||
| f6c1e35180 | |||
| bf0670c285 | |||
| 7e08e2d628 | |||
| 4b926c9ece | |||
| a7f4f9af27 | |||
| ec1685dfa8 | |||
| f171853c22 | |||
| c1f0a1b7f3 | |||
| 55b3552786 | |||
| 4f9b4ad3cf | |||
| ef035d3c44 | |||
| 7684fde8e5 | |||
| 6dc4c9080e | |||
| 8268efe625 | |||
| a30dc2a419 | |||
| 1d714d1e5a | |||
| 4f054a0437 | |||
| dde8fe1cbe | |||
|
|
ed6ac2ac01 | ||
|
|
fe5f9d2124 | ||
|
|
58b424a873 | ||
|
|
e481b6a812 | ||
|
|
f5768044e8 | ||
|
|
1e5c1faa5b | ||
|
|
e86f956b03 | ||
|
|
f31823803d | ||
|
|
88e42cb87a | ||
|
|
99df5bb8c1 | ||
|
|
c38f5c8819 | ||
|
|
a5ae46f5c3 | ||
|
|
1c95244850 | ||
|
|
dd9edf160e | ||
|
|
d67c1e77bd | ||
|
|
c6a2bfe4b2 | ||
|
|
b25e3a38f8 | ||
|
|
543a7a6eb3 | ||
| fd17e11e87 | |||
| 51a7107802 | |||
|
|
aea808dae1 | ||
|
|
7b7895728b | ||
|
|
f785a111d8 | ||
|
|
15b84a9b18 | ||
|
|
09f0436f98 | ||
| 3256c62a39 | |||
| 827eb716d7 | |||
| a6a84e4b29 | |||
| ec80aa8bcc | |||
| 262efa0380 | |||
| 5fc3bca01a | |||
|
|
8431e52e0a | ||
| 171529497d | |||
|
|
eeb14786e8 | ||
| 9b9d5bd528 | |||
| 6633994940 | |||
| b4b6643581 | |||
| f059d6f662 | |||
| ab8fd9dd83 | |||
| efb5dfcd3e | |||
| a84441078c | |||
| 1e0a76757a | |||
| 267b2df2d7 | |||
| 368b6a29aa | |||
| fa59d71717 | |||
|
|
6e380c5f5c | ||
|
|
37cb4a8f56 | ||
|
|
7aa47b08ad | ||
|
|
cab623b3f7 | ||
|
|
4051c04c91 | ||
|
|
55e1a00e67 | ||
|
|
39c4d8f134 | ||
|
|
096dbdb2e6 | ||
|
|
e6582983f2 | ||
|
|
062b01f784 | ||
|
|
40927ac286 | ||
| 131ffe42e7 | |||
| 706ef44491 | |||
|
|
09648f0b2d | ||
|
|
a7ab191ed1 | ||
|
|
6e5f99391f | ||
|
|
14fbf40ac8 | ||
|
|
52e773bc7e | ||
|
|
3b91f9693e | ||
|
|
0c08672dcd | ||
|
|
0dff0c91e1 |
15
.babelrc
Normal file
15
.babelrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
presets: [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
modules: false,
|
||||
targets: {
|
||||
browsers: ["IE 11", "> 5%"]
|
||||
},
|
||||
useBuiltIns: "usage",
|
||||
corejs: "3"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
13
.drone.yml
13
.drone.yml
@@ -9,10 +9,17 @@ platform:
|
||||
|
||||
steps:
|
||||
- name: frontend_install
|
||||
image: node:13.6.0
|
||||
image: node:14
|
||||
commands:
|
||||
- node -v
|
||||
- yarn --version
|
||||
- name: backend_build
|
||||
image: node:14
|
||||
commands:
|
||||
- node -v
|
||||
- yarn --version
|
||||
- yarn
|
||||
- yarn build
|
||||
- name: deploy
|
||||
image: appleboy/drone-ssh
|
||||
pull: true
|
||||
@@ -26,13 +33,13 @@ steps:
|
||||
- drone-test
|
||||
status: success
|
||||
settings:
|
||||
host: 10.0.0.114
|
||||
host: vinlottis.schleppe
|
||||
username: root
|
||||
key:
|
||||
from_secret: ssh_key
|
||||
command_timeout: 600s
|
||||
script:
|
||||
- /home/kevin/deploy/vinlottis.sh
|
||||
- /home/kevin/deploy.sh
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
|
||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"quoteProps": "consistent",
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": true
|
||||
}
|
||||
17
README.md
17
README.md
@@ -1,9 +1,19 @@
|
||||
# vinlattis
|
||||
<h1 align="center">
|
||||
Vinlottis 🍾
|
||||
</h1>
|
||||
|
||||
[](https://drone.kevinmidboe.com/KevinMidboe/vinlottis)
|
||||
<div align="center">
|
||||
|
||||
[](https://drone.schleppe.cloud/KevinMidboe/vinlottis)
|
||||
|
||||
Prerequisits
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
[**Vinlottis**](https://vinlottis.no) is the unofficial website for Knowit's wine-lottery, usually happening every friday at around 15:00.
|
||||
|
||||
|
||||
### Prerequisites
|
||||
```
|
||||
mongodb
|
||||
nodejs
|
||||
@@ -12,7 +22,6 @@ npm
|
||||
|
||||
|
||||
### Run dev
|
||||
|
||||
Since the backend and API runs separate from the Vue-on-save-compiler, when running the dev-server, the backend needs to be run separate
|
||||
|
||||
```
|
||||
|
||||
81
api/attendee.js
Normal file
81
api/attendee.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const path = require("path");
|
||||
|
||||
const Attendee = require(path.join(__dirname, "/schemas/Attendee"));
|
||||
const { UserNotFound } = require(path.join(__dirname, "/vinlottisErrors"));
|
||||
|
||||
const redactAttendeeInfoMapper = attendee => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
raffles: attendee.red + attendee.blue + attendee.yellow + attendee.green,
|
||||
red: attendee.red,
|
||||
blue: attendee.blue,
|
||||
green: attendee.green,
|
||||
yellow: attendee.yellow
|
||||
};
|
||||
};
|
||||
|
||||
const allAttendees = (isAdmin = false) => {
|
||||
if (!isAdmin) {
|
||||
return Attendee.find().then(attendees => attendees.map(redactAttendeeInfoMapper));
|
||||
} else {
|
||||
return Attendee.find();
|
||||
}
|
||||
};
|
||||
|
||||
const addAttendee = attendee => {
|
||||
const { name, red, blue, green, yellow, phoneNumber } = attendee;
|
||||
|
||||
let newAttendee = new Attendee({
|
||||
name,
|
||||
red,
|
||||
blue,
|
||||
green,
|
||||
yellow,
|
||||
phoneNumber,
|
||||
winner: false
|
||||
});
|
||||
|
||||
return newAttendee.save().then(_ => newAttendee);
|
||||
};
|
||||
|
||||
const updateAttendeeById = (id, updateModel) => {
|
||||
return Attendee.findOne({ _id: id }).then(attendee => {
|
||||
if (attendee == null) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
const updatedAttendee = {
|
||||
name: updateModel.name != null ? updateModel.name : attendee.name,
|
||||
green: updateModel.green != null ? updateModel.green : attendee.green,
|
||||
red: updateModel.red != null ? updateModel.red : attendee.red,
|
||||
blue: updateModel.blue != null ? updateModel.blue : attendee.blue,
|
||||
yellow: updateModel.yellow != null ? updateModel.yellow : attendee.yellow,
|
||||
phoneNumber: updateModel.phoneNumber != null ? updateModel.phoneNumber : attendee.phoneNumber,
|
||||
winner: updateModel.winner != null ? updateModel.winner : attendee.winner
|
||||
};
|
||||
|
||||
return Attendee.updateOne({ _id: id }, updatedAttendee).then(_ => updatedAttendee);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAttendeeById = id => {
|
||||
return Attendee.findOne({ _id: id }).then(attendee => {
|
||||
if (attendee == null) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
return Attendee.deleteOne({ _id: id }).then(_ => attendee);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAttendees = () => {
|
||||
return Attendee.deleteMany();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
allAttendees,
|
||||
addAttendee,
|
||||
updateAttendeeById,
|
||||
deleteAttendeeById,
|
||||
deleteAttendees
|
||||
};
|
||||
40
api/chat.js
40
api/chat.js
@@ -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 => {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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();
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const messages = await history(skip, take);
|
||||
res.json(messages)
|
||||
} catch(error) {
|
||||
res.status(500).send(error);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
34
api/controllers/chatController.js
Normal file
34
api/controllers/chatController.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const path = require("path");
|
||||
const { history, clearHistory } = require(path.join(__dirname + "/../redis"));
|
||||
console.log("loading chat");
|
||||
|
||||
const getAllHistory = (req, res) => {
|
||||
let { page, limit } = req.query;
|
||||
page = !isNaN(page) ? Number(page) : undefined;
|
||||
limit = !isNaN(limit) ? Number(limit) : undefined;
|
||||
|
||||
return history(page, limit)
|
||||
.then(messages => res.json(messages))
|
||||
.catch(error =>
|
||||
res.status(500).json({
|
||||
message: error.message,
|
||||
success: false
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const deleteHistory = (req, res) => {
|
||||
return clearHistory()
|
||||
.then(message => res.json(message))
|
||||
.catch(error =>
|
||||
res.status(500).json({
|
||||
message: error.message,
|
||||
success: false
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllHistory,
|
||||
deleteHistory
|
||||
};
|
||||
261
api/controllers/historyController.js
Normal file
261
api/controllers/historyController.js
Normal file
@@ -0,0 +1,261 @@
|
||||
const path = require("path");
|
||||
const historyRepository = require(path.join(__dirname, "../history"));
|
||||
|
||||
const sortOptions = ["desc", "asc"];
|
||||
const includeWinesOptions = ["true", "false"];
|
||||
|
||||
const all = (req, res) => {
|
||||
const { sort, includeWines } = req.query;
|
||||
|
||||
if (sort !== undefined && !sortOptions.includes(sort)) {
|
||||
return res.status(400).send({
|
||||
message: `Sort option must be: '${sortOptions.join(", ")}'`,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
if (includeWines !== undefined && !includeWinesOptions.includes(includeWines)) {
|
||||
return res.status(400).send({
|
||||
message: `includeWines option must be: '${includeWinesOptions.join(", ")}'`,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
return historyRepository
|
||||
.all(includeWines == "true")
|
||||
.then(winners =>
|
||||
res.send({
|
||||
winners: sort !== "asc" ? winners : winners.reverse(),
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch winners."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const byDate = (req, res) => {
|
||||
let { date } = req.params;
|
||||
|
||||
const regexDate = new RegExp("^\\d{4}-\\d{2}-\\d{2}$");
|
||||
if (!isNaN(date)) {
|
||||
date = new Date(new Date(parseInt(date * 1000)).setHours(0, 0, 0, 0));
|
||||
} else if (regexDate.test(date)) {
|
||||
date = new Date(date);
|
||||
} else if (date !== undefined) {
|
||||
return res.status(400).send({
|
||||
message: "Invalid date parameter, allowed epoch seconds or YYYY-MM-DD.",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
return historyRepository
|
||||
.byDate(date)
|
||||
.then(winners =>
|
||||
res.send({
|
||||
date: date,
|
||||
winners: winners,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch winner by date."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const groupByDate = (req, res) => {
|
||||
const { sort, includeWines } = req.query;
|
||||
|
||||
if (sort !== undefined && !sortOptions.includes(sort)) {
|
||||
return res.status(400).send({
|
||||
message: `Sort option must be: '${sortOptions.join(", ")}'`,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
if (includeWines !== undefined && !includeWinesOptions.includes(includeWines)) {
|
||||
return res.status(400).send({
|
||||
message: `includeWines option must be: '${includeWinesOptions.join(", ")}'`,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
return historyRepository
|
||||
.groupByDate(includeWines == "true", sort)
|
||||
.then(lotteries =>
|
||||
res.send({
|
||||
lotteries: lotteries,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch winner by date."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const latest = (req, res) => {
|
||||
return historyRepository
|
||||
.latest()
|
||||
.then(winners =>
|
||||
res.send({
|
||||
...winners,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch winner by date."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const byName = (req, res) => {
|
||||
const { name } = req.params;
|
||||
const { sort } = req.query;
|
||||
|
||||
if (sort !== undefined && !sortOptions.includes(sort)) {
|
||||
return res.status(400).send({
|
||||
message: `Sort option must be: '${sortOptions.join(", ")}'`,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
return historyRepository
|
||||
.byName(name, sort)
|
||||
.then(winner =>
|
||||
res.send({
|
||||
winner: winner,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch winner by name."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const search = (req, res) => {
|
||||
const { name, sort } = req.query;
|
||||
|
||||
if (sort !== undefined && !sortOptions.includes(sort)) {
|
||||
return res.status(400).send({
|
||||
message: `Sort option must be: '${sortOptions.join(", ")}'`,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
return historyRepository
|
||||
.search(name, sort)
|
||||
.then(winners =>
|
||||
res.send({
|
||||
winners: winners || [],
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch winner by name."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const groupByColor = (req, res) => {
|
||||
const { includeWines } = req.query;
|
||||
|
||||
if (includeWines !== undefined && !includeWinesOptions.includes(includeWines)) {
|
||||
return res.status(400).send({
|
||||
message: `includeWines option must be: '${includeWinesOptions.join(", ")}'`,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
return historyRepository
|
||||
.groupByColor(includeWines == "true")
|
||||
.then(colors =>
|
||||
res.send({
|
||||
colors: colors,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch winners by color."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const orderByWins = (req, res) => {
|
||||
let { includeWines, limit } = req.query;
|
||||
|
||||
if (includeWines !== undefined && !includeWinesOptions.includes(includeWines)) {
|
||||
return res.status(400).send({
|
||||
message: `includeWines option must be: '${includeWinesOptions.join(", ")}'`,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
if (limit && isNaN(limit)) {
|
||||
return res.status(400).send({
|
||||
message: "If limit query parameter is provided it must be a number",
|
||||
success: false
|
||||
});
|
||||
} else if (!!!isNaN(limit)) {
|
||||
limit = Number(limit);
|
||||
}
|
||||
|
||||
return historyRepository
|
||||
.orderByWins(includeWines == "true", limit)
|
||||
.then(winners =>
|
||||
res.send({
|
||||
winners: winners,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch winners by color."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
all,
|
||||
byDate,
|
||||
groupByDate,
|
||||
latest,
|
||||
byName,
|
||||
search,
|
||||
groupByColor,
|
||||
orderByWins
|
||||
};
|
||||
135
api/controllers/lotteryAttendeeController.js
Normal file
135
api/controllers/lotteryAttendeeController.js
Normal file
@@ -0,0 +1,135 @@
|
||||
const path = require("path");
|
||||
const attendeeRepository = require(path.join(__dirname, "../attendee"));
|
||||
|
||||
const allAttendees = (req, res) => {
|
||||
const isAdmin = req.isAuthenticated();
|
||||
|
||||
return attendeeRepository
|
||||
.allAttendees(isAdmin)
|
||||
.then(attendees =>
|
||||
res.send({
|
||||
attendees: attendees,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch lottery attendees."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const addAttendee = (req, res) => {
|
||||
const { attendee } = req.body;
|
||||
|
||||
const requiredColors = [attendee["red"], attendee["blue"], attendee["green"], attendee["yellow"]];
|
||||
const correctColorsTypes = requiredColors.filter(color => typeof color === "number");
|
||||
if (requiredColors.length !== correctColorsTypes.length) {
|
||||
return res.status(400).send({
|
||||
message: "Incorrect or missing color, required type Number for keys: 'blue', 'red', 'green' & 'yellow'.",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof attendee["name"] !== "string" || typeof attendee["phoneNumber"] !== "number") {
|
||||
return res.status(400).send({
|
||||
message: "Incorrect or missing attendee keys 'name' or 'phoneNumber'.",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
return attendeeRepository
|
||||
.addAttendee(attendee)
|
||||
.then(savedAttendee => {
|
||||
var io = req.app.get("socketio");
|
||||
io.emit("new_attendee", {});
|
||||
return savedAttendee;
|
||||
})
|
||||
.then(savedAttendee =>
|
||||
res.send({
|
||||
attendee: savedAttendee,
|
||||
message: `Successfully added attendee ${attendee.name} to lottery.`,
|
||||
success: true
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const updateAttendeeById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { attendee } = req.body;
|
||||
|
||||
return attendeeRepository
|
||||
.updateAttendeeById(id, attendee)
|
||||
.then(updatedAttendee => {
|
||||
var io = req.app.get("socketio");
|
||||
io.emit("refresh_data", {});
|
||||
return updatedAttendee;
|
||||
})
|
||||
.then(attendee =>
|
||||
res.send({
|
||||
attendee,
|
||||
message: `Updated attendee: ${attendee.name}`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while deleteing attendee by id.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAttendeeById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
return attendeeRepository
|
||||
.deleteAttendeeById(id)
|
||||
.then(removedAttendee => {
|
||||
var io = req.app.get("socketio");
|
||||
io.emit("refresh_data", {});
|
||||
return removedAttendee;
|
||||
})
|
||||
.then(attendee =>
|
||||
res.send({
|
||||
message: `Removed attendee: ${attendee.name}`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while deleteing attendee by id.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAttendees = (req, res) => {
|
||||
return attendeeRepository
|
||||
.deleteAttendees()
|
||||
.then(removedAttendee => {
|
||||
var io = req.app.get("socketio");
|
||||
io.emit("refresh_data", {});
|
||||
})
|
||||
.then(_ =>
|
||||
res.send({
|
||||
message: "Removed all attendees",
|
||||
success: true
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
allAttendees,
|
||||
addAttendee,
|
||||
updateAttendeeById,
|
||||
deleteAttendeeById,
|
||||
deleteAttendees
|
||||
};
|
||||
213
api/controllers/lotteryController.js
Normal file
213
api/controllers/lotteryController.js
Normal file
@@ -0,0 +1,213 @@
|
||||
const path = require("path");
|
||||
const lotteryRepository = require(path.join(__dirname, "../lottery"));
|
||||
|
||||
const drawWinner = (req, res) => {
|
||||
return lotteryRepository
|
||||
.drawWinner()
|
||||
.then(({ winner, color, winners }) => {
|
||||
var io = req.app.get("socketio");
|
||||
io.emit("winner", {
|
||||
color: color,
|
||||
name: winner.name,
|
||||
winner_count: winners.length
|
||||
});
|
||||
|
||||
return { winner, color, winners };
|
||||
})
|
||||
.then(({ winner, color, winners }) =>
|
||||
res.send({
|
||||
color: color,
|
||||
winner: winner,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while drawing winner.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const archiveLottery = (req, res) => {
|
||||
const { lottery } = req.body;
|
||||
if (lottery == undefined || !lottery instanceof Object) {
|
||||
return res.status(400).send({
|
||||
message: "Missing lottery object.",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
let { stolen, date, raffles, wines } = lottery;
|
||||
stolen = stolen !== undefined ? stolen : 0; // default = 0
|
||||
|
||||
const validDateFormat = new RegExp("d{4}-d{2}-d{2}");
|
||||
if (date != undefined && (!validDateFormat.test(date) || isNaN(date))) {
|
||||
return res.status(400).send({
|
||||
message: "Date must be defined as 'yyyy-mm-dd'.",
|
||||
success: false
|
||||
});
|
||||
} else if (date != undefined) {
|
||||
date = Date.parse(date, "yyyy-MM-dd");
|
||||
} else {
|
||||
date = new Date();
|
||||
}
|
||||
|
||||
return verifyLotteryPayload(raffles, stolen, wines)
|
||||
.then(_ => lotteryRepository.archive(date, raffles, stolen, wines))
|
||||
.then(_ =>
|
||||
res.send({
|
||||
message: "Successfully archive lottery",
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while submitting lottery.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const lotteryByDate = (req, res) => {
|
||||
const { epoch } = req.params;
|
||||
|
||||
if (!/^\d+$/.test(epoch)) {
|
||||
return res.status(400).send({
|
||||
message: "Last parameter must be epoch (in seconds).",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
const date = new Date(Number(epoch) * 1000);
|
||||
|
||||
return lotteryRepository
|
||||
.lotteryByDate(date)
|
||||
.then(lottery =>
|
||||
res.send({
|
||||
lottery,
|
||||
message: `Lottery for date: ${dateToDateString(date)}/${epoch}.`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while fetching lottery by date.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const sortOptions = ["desc", "asc"];
|
||||
const allLotteries = (req, res) => {
|
||||
let { includeWinners, year, sort } = req.query;
|
||||
|
||||
if (sort !== undefined && !sortOptions.includes(sort)) {
|
||||
return res.status(400).send({
|
||||
message: `Sort option must be: '${sortOptions.join(", ")}'`,
|
||||
success: false
|
||||
});
|
||||
} else if (sort === undefined) {
|
||||
sort = "asc";
|
||||
}
|
||||
|
||||
let allLotteriesFunction = lotteryRepository.allLotteries;
|
||||
if (includeWinners === "true") {
|
||||
allLotteriesFunction = lotteryRepository.allLotteriesIncludingWinners;
|
||||
}
|
||||
|
||||
return allLotteriesFunction(sort, year)
|
||||
.then(lotteries =>
|
||||
res.send({
|
||||
lotteries,
|
||||
message: "All lotteries.",
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while fetching all lotteries.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const latestLottery = (req, res) => {
|
||||
return lotteryRepository
|
||||
.latestLottery()
|
||||
.then(lottery =>
|
||||
res.send({
|
||||
lottery,
|
||||
message: "Latest lottery.",
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while fetching all lotteries.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function verifyLotteryPayload(raffles, stolen, wines) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (raffles == undefined || !raffles instanceof Array) {
|
||||
reject({
|
||||
message: "Raffles must be array.",
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
const requiredColors = [raffles["red"], raffles["blue"], raffles["green"], raffles["yellow"]];
|
||||
const correctColorsTypes = requiredColors.filter(color => typeof color === "number");
|
||||
if (requiredColors.length !== correctColorsTypes.length) {
|
||||
reject({
|
||||
message:
|
||||
"Incorrect or missing raffle colors, required type Number for keys: 'blue', 'red', 'green' & 'yellow'.",
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
if (stolen == undefined || (isNaN(stolen) && stolen >= 0)) {
|
||||
reject({
|
||||
message: "Number of stolen raffles must be positive integer or 0.",
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
if (wines == undefined || !wines instanceof Array) {
|
||||
reject({
|
||||
message: "Wines must be array.",
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function dateToDateString(date) {
|
||||
const ye = new Intl.DateTimeFormat("en", { year: "numeric" }).format(date);
|
||||
const mo = new Intl.DateTimeFormat("en", { month: "2-digit" }).format(date);
|
||||
const da = new Intl.DateTimeFormat("en", { day: "2-digit" }).format(date);
|
||||
|
||||
return `${ye}-${mo}-${da}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
drawWinner,
|
||||
archiveLottery,
|
||||
lotteryByDate,
|
||||
allLotteries,
|
||||
latestLottery
|
||||
};
|
||||
207
api/controllers/lotteryWineController.js
Normal file
207
api/controllers/lotteryWineController.js
Normal file
@@ -0,0 +1,207 @@
|
||||
const path = require("path");
|
||||
const prelotteryWineRepository = require(path.join(__dirname, "../prelotteryWine"));
|
||||
|
||||
const allWines = (req, res) => {
|
||||
return prelotteryWineRepository
|
||||
.allWines()
|
||||
.then(wines =>
|
||||
res.send({
|
||||
wines: wines,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch lottery wines."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const addWines = (req, res) => {
|
||||
let { wines } = req.body;
|
||||
|
||||
if (!(wines instanceof Array)) {
|
||||
return res.status(400).send({
|
||||
message: "Wines must be array.",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
const validateAllWines = wines =>
|
||||
wines.map(wine => {
|
||||
const requiredAttributes = ["name", "vivinoLink", "image", "id", "price"];
|
||||
|
||||
return Promise.all(
|
||||
requiredAttributes.map(attr => {
|
||||
if (typeof wine[attr] === "undefined" || wine[attr] == "") {
|
||||
return Promise.reject({
|
||||
message: `Incorrect or missing attribute: ${attr}.`,
|
||||
statusCode: 400,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
})
|
||||
).then(_ => Promise.resolve(wine));
|
||||
});
|
||||
|
||||
return Promise.all(validateAllWines(wines))
|
||||
.then(wines => prelotteryWineRepository.addWines(wines))
|
||||
.then(savedWines => {
|
||||
var io = req.app.get("socketio");
|
||||
io.emit("new_wine", {});
|
||||
return true;
|
||||
})
|
||||
.then(success =>
|
||||
res.send({
|
||||
message: `Successfully added wines to lottery.`,
|
||||
success: success
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured adding wines.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const wineById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
return prelotteryWineRepository
|
||||
.wineById(id)
|
||||
.then(wine =>
|
||||
res.send({
|
||||
wine,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while fetching wine by id.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const updateWineById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { wine } = req.body;
|
||||
|
||||
if (id == null || id == "undefined") {
|
||||
return res.status(400).send({
|
||||
message: "Unable to update without id.",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
return prelotteryWineRepository
|
||||
.updateWineById(id, wine)
|
||||
.then(updatedWine => {
|
||||
var io = req.app.get("socketio");
|
||||
io.emit("refresh_data", {});
|
||||
return updatedWine;
|
||||
})
|
||||
.then(wine =>
|
||||
res.send({
|
||||
wine,
|
||||
message: `Updated wine: ${wine.name}`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while deleteing wine by id.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const deleteWineById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
return prelotteryWineRepository
|
||||
.deleteWineById(id)
|
||||
.then(removedWine => {
|
||||
var io = req.app.get("socketio");
|
||||
io.emit("refresh_data", {});
|
||||
return removedWine;
|
||||
})
|
||||
.then(wine =>
|
||||
res.send({
|
||||
message: `Removed wine: ${wine.name}`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while deleteing wine by id.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const deleteWines = (req, res) => {
|
||||
return prelotteryWineRepository
|
||||
.deleteWines()
|
||||
.then(_ => {
|
||||
var io = req.app.get("socketio");
|
||||
io.emit("refresh_data", {});
|
||||
})
|
||||
.then(_ =>
|
||||
res.send({
|
||||
message: "Removed all wines.",
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while deleting wines",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const wineSchema = (req, res) => {
|
||||
return prelotteryWineRepository
|
||||
.wineSchema()
|
||||
.then(schema =>
|
||||
res.send({
|
||||
schema: schema,
|
||||
message: `Wine schema template.`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch wine schema template."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
allWines,
|
||||
addWines,
|
||||
wineById,
|
||||
updateWineById,
|
||||
deleteWineById,
|
||||
deleteWines,
|
||||
wineSchema
|
||||
};
|
||||
195
api/controllers/lotteryWinnerController.js
Normal file
195
api/controllers/lotteryWinnerController.js
Normal file
@@ -0,0 +1,195 @@
|
||||
const path = require("path");
|
||||
const winnerRepository = require(path.join(__dirname, "../winner"));
|
||||
const { WinnerNotFound } = require(path.join(__dirname, "../vinlottisErrors"));
|
||||
const prizeDistributionRepository = require(path.join(__dirname, "../prizeDistribution"));
|
||||
|
||||
// should not be used, is done through POST /lottery/prize-distribution/prize/:id - claimPrize.
|
||||
const addWinners = (req, res) => {
|
||||
const { winners } = req.body;
|
||||
|
||||
if (!(winners instanceof Array)) {
|
||||
return res.status(400).send({
|
||||
message: "Winners must be array.",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
const requiredAttributes = ["name", "color", "wine"];
|
||||
const validColors = ["red", "blue", "green", "yellow"];
|
||||
const validateAllWinners = winners =>
|
||||
winners.map(winner => {
|
||||
return Promise.all(
|
||||
requiredAttributes.map(attr => {
|
||||
if (typeof winner[attr] === "undefined") {
|
||||
return Promise.reject({
|
||||
message: `Incorrect or missing attribute: ${attr}.`,
|
||||
statusCode: 400
|
||||
});
|
||||
}
|
||||
|
||||
if (!validColors.includes(winner.color)) {
|
||||
return Promise.reject({
|
||||
message: `Missing or incorrect color value, must have one of values: ${validColors.join(", ")}.`,
|
||||
statusCode: 400
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
})
|
||||
).then(_ => Promise.resolve(winner));
|
||||
});
|
||||
|
||||
return Promise.all(validateAllWinners(winners))
|
||||
.then(winners =>
|
||||
winners.map(winner => {
|
||||
return prizeDistributionRepository.claimPrize(winner, winner.wine);
|
||||
})
|
||||
)
|
||||
.then(winners =>
|
||||
res.send({
|
||||
winners: winners,
|
||||
message: `Successfully added winners to lottery.`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured adding winners.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const allWinners = (req, res) => {
|
||||
const isAdmin = req.isAuthenticated();
|
||||
|
||||
return winnerRepository
|
||||
.allWinners(isAdmin)
|
||||
.then(winners =>
|
||||
res.send({
|
||||
winners: winners,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch lottery winners."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const winnerById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const isAdmin = req.isAuthenticated();
|
||||
|
||||
return winnerRepository
|
||||
.winnerById(id, isAdmin)
|
||||
.then(winner =>
|
||||
res.send({
|
||||
winner,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured, unable to fetch winner by id.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const updateWinnerById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { winner } = req.body;
|
||||
|
||||
if (id == null || id == "undefined") {
|
||||
return res.status(400).send({
|
||||
message: "Unable to update without id.",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
return winnerRepository
|
||||
.updateWinnerById(id, winner)
|
||||
.then(winner =>
|
||||
res.send({
|
||||
winner,
|
||||
message: `Updated winner: ${winner.name}`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while updating winner by id.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const deleteWinnerById = (req, res) => {
|
||||
const isAdmin = req.isAuthenticated();
|
||||
const { id } = req.params;
|
||||
|
||||
return winnerRepository
|
||||
.deleteWinnerById(id, isAdmin)
|
||||
.then(removedWinner => {
|
||||
var io = req.app.get("socketio");
|
||||
io.emit("refresh_data", {});
|
||||
return removedWinner;
|
||||
})
|
||||
.then(winner =>
|
||||
res.send({
|
||||
message: `Removed winner: ${winner.name}`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while deleteing wine by id.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const deleteWinners = (req, res) => {
|
||||
return winnerRepository
|
||||
.deleteWinners()
|
||||
.then(_ => {
|
||||
var io = req.app.get("socketio");
|
||||
io.emit("refresh_data", {});
|
||||
})
|
||||
.then(_ =>
|
||||
res.send({
|
||||
message: "Removed all winners.",
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while deleting wines",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
addWinners,
|
||||
allWinners,
|
||||
winnerById,
|
||||
updateWinnerById,
|
||||
deleteWinnerById,
|
||||
deleteWinners
|
||||
};
|
||||
30
api/controllers/messageController.js
Normal file
30
api/controllers/messageController.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const path = require("path");
|
||||
const messageRepository = require(path.join(__dirname, "../message"));
|
||||
const winnerRepository = require(path.join(__dirname, "../winner"));
|
||||
|
||||
const notifyWinnerById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const isAdmin = req.isAuthenticated();
|
||||
|
||||
return winnerRepository
|
||||
.winnerById(id, isAdmin)
|
||||
.then(winner => messageRepository.sendPrizeSelectionLink(winner))
|
||||
.then(messageResponse =>
|
||||
res.send({
|
||||
messageResponse,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while sending message to winner by id.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
notifyWinnerById
|
||||
};
|
||||
104
api/controllers/prizeDistributionController.js
Normal file
104
api/controllers/prizeDistributionController.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const path = require("path");
|
||||
|
||||
const prizeDistribution = require(path.join(__dirname, "../prizeDistribution"));
|
||||
const prelotteryWineRepository = require(path.join(__dirname, "../prelotteryWine"));
|
||||
const winnerRepository = require(path.join(__dirname, "../winner"));
|
||||
const message = require(path.join(__dirname, "../message"));
|
||||
|
||||
const start = async (req, res) => {
|
||||
const allWinners = await winnerRepository.allWinners(true);
|
||||
if (allWinners.length === 0) {
|
||||
return res.status(503).send({
|
||||
message: "No winners found to distribute prizes to.",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
const laterWinners = allWinners.slice(1);
|
||||
|
||||
return prizeDistribution
|
||||
.notifyNextWinner()
|
||||
.then(_ => message.sendInitialMessageToWinners(laterWinners))
|
||||
.then(_ =>
|
||||
res.send({
|
||||
message: `Send link to first winner and notified everyone else.`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while starting prize distribution.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getPrizesForWinnerById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
return prizeDistribution
|
||||
.verifyWinnerNextInLine(id)
|
||||
.then(winner => {
|
||||
return prelotteryWineRepository.allWinesWithoutWinner().then(wines => [wines, winner]);
|
||||
})
|
||||
.then(([wines, winner]) =>
|
||||
res.send({
|
||||
wines: wines,
|
||||
winner: winner,
|
||||
message: "Wines to select from",
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while fetching prizes.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const submitPrizeForWinnerById = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { wine } = req.body;
|
||||
|
||||
let prelotteryWine, winner;
|
||||
try {
|
||||
prelotteryWine = await prelotteryWineRepository.wineById(wine._id);
|
||||
winner = await winnerRepository.winnerById(id, true);
|
||||
} catch (error) {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while claiming prize.",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
return prizeDistribution
|
||||
.claimPrize(prelotteryWine, winner)
|
||||
.then(_ => prizeDistribution.notifyNextWinner())
|
||||
.then(_ =>
|
||||
res.send({
|
||||
message: `${winner.name} successfully claimed prize: ${prelotteryWine.name}`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while claiming prize.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
start,
|
||||
getPrizesForWinnerById,
|
||||
submitPrizeForWinnerById
|
||||
};
|
||||
104
api/controllers/requestController.js
Normal file
104
api/controllers/requestController.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const path = require("path");
|
||||
const requestRepository = require(path.join(__dirname, "../request"));
|
||||
|
||||
function addRequest(req, res) {
|
||||
const { wine } = req.body;
|
||||
|
||||
return verifyWineValues(wine)
|
||||
.then(_ => requestRepository.addNew(wine))
|
||||
.then(wine =>
|
||||
res.json({
|
||||
message: "Successfully added new request",
|
||||
wine: wine,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { message, statusCode } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to add requested wine."
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function allRequests(req, res) {
|
||||
return requestRepository
|
||||
.getAll()
|
||||
.then(wines =>
|
||||
res.json({
|
||||
wines: wines,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { message, statusCode } = error;
|
||||
return res.status(statusCode || 500).json({
|
||||
success: false,
|
||||
message: message || "Unable to fetch all requested wines."
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteRequest(req, res) {
|
||||
const { id } = req.params;
|
||||
|
||||
return requestRepository
|
||||
.deleteById(id)
|
||||
.then(_ =>
|
||||
res.json({
|
||||
message: `Slettet vin med id: ${id}`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to delete requested wine."
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function verifyWineValues(wine) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (wine == undefined) {
|
||||
reject({
|
||||
message: "No wine object found in request body.",
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
if (wine.id == null) {
|
||||
reject({
|
||||
message: "Wine object missing value id.",
|
||||
status: 400
|
||||
});
|
||||
} else if (wine.name == null) {
|
||||
reject({
|
||||
message: "Wine object missing value name.",
|
||||
status: 400
|
||||
});
|
||||
} else if (wine.vivinoLink == null) {
|
||||
reject({
|
||||
message: "Wine object missing value vivinoLink.",
|
||||
status: 400
|
||||
});
|
||||
} else if (wine.image == null) {
|
||||
reject({
|
||||
message: "Wine object missing value image.",
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addRequest,
|
||||
allRequests,
|
||||
deleteRequest
|
||||
};
|
||||
55
api/controllers/userController.js
Normal file
55
api/controllers/userController.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const path = require("path");
|
||||
const userRepository = require(path.join(__dirname, "../user"));
|
||||
|
||||
function register(req, res, next) {
|
||||
const { username, password } = req.body;
|
||||
|
||||
return userRepository
|
||||
.register(username, password)
|
||||
.then(user => userRepository.login(req, user))
|
||||
.then(_ =>
|
||||
res.send({
|
||||
messsage: `Bruker registrert. Velkommen ${username}`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unable to sign in with given username and passowrd",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const login = (req, res, next) => {
|
||||
return userRepository
|
||||
.authenticate(req)
|
||||
.then(user => userRepository.login(req, user))
|
||||
.then(user => {
|
||||
res.send({
|
||||
message: `Velkommen ${user.username}`,
|
||||
success: true
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unable to sign in with given username and passowrd",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const logout = (req, res) => {
|
||||
req.logout();
|
||||
res.redirect("/");
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
register,
|
||||
login,
|
||||
logout
|
||||
};
|
||||
101
api/controllers/vinmonopoletController.js
Normal file
101
api/controllers/vinmonopoletController.js
Normal file
@@ -0,0 +1,101 @@
|
||||
const path = require("path");
|
||||
const vinmonopoletRepository = require(path.join(__dirname, "../vinmonopolet"));
|
||||
|
||||
function searchWines(req, res) {
|
||||
const { name, page } = req.query;
|
||||
|
||||
return vinmonopoletRepository.searchWinesByName(name, page).then(wines =>
|
||||
res.json({
|
||||
wines: wines,
|
||||
count: wines.length,
|
||||
page: page,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || `Unexpected error occured trying to search for wine: ${name} at page: ${page}`,
|
||||
success: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wineByEAN(req, res) {
|
||||
const { ean } = req.params;
|
||||
|
||||
return vinmonopoletRepository.searchByEAN(ean).then(wines =>
|
||||
res.json({
|
||||
wines: wines,
|
||||
success: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function wineById(req, res) {
|
||||
const { id } = req.params;
|
||||
|
||||
return vinmonopoletRepository.wineById(id).then(wine =>
|
||||
res.json({
|
||||
wine: wine,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || `Unexpected error occured trying to fetch wine with id: ${id}`,
|
||||
success: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function allStores(req, res) {
|
||||
return vinmonopoletRepository
|
||||
.allStores()
|
||||
.then(stores =>
|
||||
res.send({
|
||||
stores,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while fetch all vinmonopolet stores.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function searchStores(req, res) {
|
||||
const { name } = req.query;
|
||||
|
||||
return vinmonopoletRepository
|
||||
.searchStoresByName(name)
|
||||
.then(stores =>
|
||||
res.send({
|
||||
stores,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while fetch all vinmonopolet stores.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
searchWines,
|
||||
wineByEAN,
|
||||
wineById,
|
||||
allStores,
|
||||
searchStores
|
||||
};
|
||||
60
api/controllers/wineController.js
Normal file
60
api/controllers/wineController.js
Normal file
@@ -0,0 +1,60 @@
|
||||
const path = require("path");
|
||||
const wineRepository = require(path.join(__dirname, "../wine"));
|
||||
|
||||
const allWines = (req, res) => {
|
||||
// TODO add "includeWinners"
|
||||
let { limit } = req.query;
|
||||
|
||||
if (limit && isNaN(limit)) {
|
||||
return res.status(400).send({
|
||||
message: "If limit query parameter is provided it must be a number",
|
||||
success: false
|
||||
});
|
||||
} else if (!!!isNaN(limit)) {
|
||||
limit = Number(limit);
|
||||
}
|
||||
|
||||
return wineRepository
|
||||
.allWines(limit)
|
||||
.then(wines =>
|
||||
res.send({
|
||||
wines: wines,
|
||||
message: `All wines.`,
|
||||
success: true
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
success: false,
|
||||
message: message || "Unable to fetch all wines."
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const wineById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
return wineRepository
|
||||
.wineById(id)
|
||||
.then(wine => {
|
||||
res.send({
|
||||
wine,
|
||||
success: true
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
const { statusCode, message } = error;
|
||||
|
||||
return res.status(statusCode || 500).send({
|
||||
message: message || "Unexpected error occured while fetching wine by id.",
|
||||
success: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
allWines,
|
||||
wineById
|
||||
};
|
||||
348
api/history.js
Normal file
348
api/history.js
Normal file
@@ -0,0 +1,348 @@
|
||||
const path = require("path");
|
||||
|
||||
const Winner = require(path.join(__dirname, "/schemas/Highscore"));
|
||||
const wineRepository = require(path.join(__dirname, "/wine"));
|
||||
|
||||
class HistoryByDateNotFound extends Error {
|
||||
constructor(message = "History for given date not found.") {
|
||||
super(message);
|
||||
this.name = "HistoryByDateNotFound";
|
||||
this.statusCode = 404;
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryForUserNotFound extends Error {
|
||||
constructor(message = "History for given user not found.") {
|
||||
super(message);
|
||||
this.name = "HistoryForUserNotFound";
|
||||
this.statusCode = 404;
|
||||
}
|
||||
}
|
||||
|
||||
// highscore
|
||||
const addWinnerWithWine = async (winner, wine) => {
|
||||
const exisitingWinner = await Winner.findOne({
|
||||
name: winner.name
|
||||
});
|
||||
const savedWine = await wineRepository.addWine(wine);
|
||||
|
||||
const date = new Date();
|
||||
date.setHours(5, 0, 0, 0);
|
||||
const winObject = {
|
||||
date: date,
|
||||
wine: savedWine,
|
||||
color: winner.color
|
||||
};
|
||||
if (exisitingWinner == undefined) {
|
||||
const newWinner = new Winner({
|
||||
name: winner.name,
|
||||
wins: [winObject]
|
||||
});
|
||||
|
||||
await newWinner.save();
|
||||
} else {
|
||||
exisitingWinner.wins.push(winObject);
|
||||
exisitingWinner.markModified("wins");
|
||||
await exisitingWinner.save();
|
||||
}
|
||||
|
||||
return exisitingWinner;
|
||||
};
|
||||
|
||||
// lottery
|
||||
const all = (includeWines = false) => {
|
||||
if (includeWines === false) {
|
||||
return Winner.find().sort("-wins.date");
|
||||
} else {
|
||||
return Winner.find()
|
||||
.sort("-wins.date")
|
||||
.populate("wins.wine");
|
||||
}
|
||||
};
|
||||
|
||||
// lottery
|
||||
const byDate = date => {
|
||||
const startQueryDate = new Date(date.setHours(0, 0, 0, 0));
|
||||
const endQueryDate = new Date(date.setHours(24, 59, 59, 99));
|
||||
const query = [
|
||||
{
|
||||
$match: {
|
||||
"wins.date": {
|
||||
$gte: startQueryDate,
|
||||
$lte: endQueryDate
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $unwind: "$wins" },
|
||||
{
|
||||
$match: {
|
||||
"wins.date": {
|
||||
$gte: startQueryDate,
|
||||
$lte: endQueryDate
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: "wines",
|
||||
localField: "wins.wine",
|
||||
foreignField: "_id",
|
||||
as: "wins.wine"
|
||||
}
|
||||
},
|
||||
{ $unwind: "$wins.wine" },
|
||||
{
|
||||
$project: {
|
||||
name: "$name",
|
||||
date: "$wins.date",
|
||||
color: "$wins.color",
|
||||
wine: "$wins.wine"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return Winner.aggregate(query).then(winners => {
|
||||
if (winners.length == 0) {
|
||||
throw new HistoryByDateNotFound();
|
||||
}
|
||||
return winners;
|
||||
});
|
||||
};
|
||||
|
||||
// highscore
|
||||
const byName = (name, sort = "desc") => {
|
||||
return Winner.findOne({ name }, ["name", "wins"])
|
||||
.sort("-wins.date")
|
||||
.populate("wins.wine")
|
||||
.then(winner => {
|
||||
if (winner) {
|
||||
winner.wins = sort !== "asc" ? winner.wins.reverse() : winner.wins;
|
||||
return winner;
|
||||
} else {
|
||||
throw new HistoryForUserNotFound();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// highscore
|
||||
const search = (query, sort = "desc") => {
|
||||
return Winner.find({ name: { $regex: query, $options: "i" } }, ["name"]).then(winners => {
|
||||
if (winners) {
|
||||
winners = sort === "desc" ? winners.reverse() : winners;
|
||||
return winners;
|
||||
} else {
|
||||
throw new HistoryForUserNotFound();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// lottery
|
||||
const latest = () => {
|
||||
const query = [
|
||||
{
|
||||
$unwind: "$wins"
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: "wines",
|
||||
localField: "wins.wine",
|
||||
foreignField: "_id",
|
||||
as: "wins.wine"
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$wins.date",
|
||||
winners: {
|
||||
$push: {
|
||||
_id: "$_id",
|
||||
name: "$name",
|
||||
color: "$wins.color",
|
||||
wine: "$wins.wine"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
date: "$_id",
|
||||
winners: "$winners"
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
_id: -1
|
||||
}
|
||||
},
|
||||
{
|
||||
$limit: 1
|
||||
}
|
||||
];
|
||||
|
||||
return Winner.aggregate(query).then(winners => winners[0]);
|
||||
};
|
||||
|
||||
// lottery - byDate
|
||||
const groupByDate = (includeWines = false, sort = "asc") => {
|
||||
const sortDirection = sort == "asc" ? -1 : 1;
|
||||
const query = [
|
||||
{
|
||||
$unwind: "$wins"
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$wins.date",
|
||||
winners: {
|
||||
$push: {
|
||||
_id: "$_id",
|
||||
name: "$name",
|
||||
color: "$wins.color",
|
||||
wine: "$wins.wine"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
date: "$_id",
|
||||
winners: "$winners"
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
date: sortDirection
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (includeWines) {
|
||||
query.splice(1, 0, {
|
||||
$lookup: {
|
||||
from: "wines",
|
||||
localField: "wins.wine",
|
||||
foreignField: "_id",
|
||||
as: "wins.wine"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Winner.aggregate(query);
|
||||
};
|
||||
|
||||
// highscore - byColor
|
||||
const groupByColor = (includeWines = false) => {
|
||||
const query = [
|
||||
{
|
||||
$unwind: "$wins"
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$wins.color",
|
||||
winners: {
|
||||
$push: {
|
||||
_id: "$_id",
|
||||
name: "$name",
|
||||
date: "$wins.date",
|
||||
wine: "$wins.wine"
|
||||
}
|
||||
},
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
color: "$_id",
|
||||
count: "$count",
|
||||
winners: "$winners"
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
_id: -1
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (includeWines) {
|
||||
query.splice(1, 0, {
|
||||
$lookup: {
|
||||
from: "wines",
|
||||
localField: "wins.wine",
|
||||
foreignField: "_id",
|
||||
as: "wins.wine"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Winner.aggregate(query);
|
||||
};
|
||||
|
||||
// highscore - byWineOccurences
|
||||
|
||||
// highscore - byWinCount
|
||||
const orderByWins = (includeWines = false, limit = undefined) => {
|
||||
let query = [
|
||||
{
|
||||
$project: {
|
||||
name: "$name",
|
||||
wins: "$wins",
|
||||
totalWins: { $size: "$wins" }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
totalWins: -1,
|
||||
"wins.date": -1
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (includeWines) {
|
||||
const includeWinesSubQuery = [
|
||||
{
|
||||
$unwind: "$wins"
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: "wines",
|
||||
localField: "wins.wine",
|
||||
foreignField: "_id",
|
||||
as: "wins.wine"
|
||||
}
|
||||
},
|
||||
{
|
||||
$unwind: "$wins._id"
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$_id",
|
||||
name: { $first: "$name" },
|
||||
totalWins: { $first: "$totalWins" },
|
||||
wins: { $push: "$wins" }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
query = includeWinesSubQuery.concat(query);
|
||||
}
|
||||
|
||||
return Winner.aggregate(query).then(winners => {
|
||||
if (limit == null) {
|
||||
return winners;
|
||||
}
|
||||
|
||||
return winners.slice(0, limit);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
addWinnerWithWine,
|
||||
all,
|
||||
byDate,
|
||||
byName,
|
||||
search,
|
||||
latest,
|
||||
groupByDate,
|
||||
groupByColor,
|
||||
orderByWins
|
||||
};
|
||||
59
api/login.js
59
api/login.js
@@ -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;
|
||||
296
api/lottery.js
Normal file
296
api/lottery.js
Normal file
@@ -0,0 +1,296 @@
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const Attendee = require(path.join(__dirname, "/schemas/Attendee"));
|
||||
const PreLotteryWine = require(path.join(__dirname, "/schemas/PreLotteryWine"));
|
||||
const VirtualWinner = require(path.join(__dirname, "/schemas/VirtualWinner"));
|
||||
const Lottery = require(path.join(__dirname, "/schemas/Purchase"));
|
||||
const { WineNotFound } = require(path.join(__dirname, "/vinlottisErrors"));
|
||||
|
||||
const Message = require(path.join(__dirname, "/message"));
|
||||
const historyRepository = require(path.join(__dirname, "/history"));
|
||||
const wineRepository = require(path.join(__dirname, "/wine"));
|
||||
const winnerRepository = require(path.join(__dirname, "/winner"));
|
||||
const prelotteryWineRepository = require(path.join(__dirname, "/prelotteryWine"));
|
||||
|
||||
const {
|
||||
WinnerNotFound,
|
||||
NoMoreAttendeesToWin,
|
||||
CouldNotFindNewWinnerAfterNTries,
|
||||
LotteryByDateNotFound
|
||||
} = require(path.join(__dirname, "/vinlottisErrors"));
|
||||
|
||||
const moveUnfoundPrelotteryWineToWines = async (error, tempWine) => {
|
||||
if(!(error instanceof WineNotFound)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if(!tempWine.winner) {
|
||||
throw new WinnerNotFound()
|
||||
}
|
||||
|
||||
const prelotteryWine = await prelotteryWineRepository.wineById(tempWine._id);
|
||||
const winner = await winnerRepository.winnerById(tempWine.winner.id, true);
|
||||
|
||||
return wineRepository
|
||||
.addWine(prelotteryWine)
|
||||
.then(_ => prelotteryWineRepository.addWinnerToWine(prelotteryWine, winner)) // prelotteryWine.deleteById
|
||||
.then(_ => historyRepository.addWinnerWithWine(winner, prelotteryWine))
|
||||
.then(_ => winnerRepository.setWinnerChosenById(winner.id))
|
||||
}
|
||||
|
||||
const archive = (date, raffles, stolen, wines) => {
|
||||
const { blue, red, yellow, green } = raffles;
|
||||
const bought = blue + red + yellow + green;
|
||||
|
||||
return Promise.all(
|
||||
wines.map(wine => wineRepository
|
||||
.findWine(wine)
|
||||
.catch(error => moveUnfoundPrelotteryWineToWines(error, wine)
|
||||
.then(_ => wineRepository.findWine(wine))
|
||||
))
|
||||
).then(resolvedWines => {
|
||||
const lottery = new Lottery({
|
||||
date,
|
||||
blue,
|
||||
red,
|
||||
yellow,
|
||||
green,
|
||||
bought,
|
||||
stolen,
|
||||
wines: resolvedWines
|
||||
});
|
||||
|
||||
return lottery.save();
|
||||
});
|
||||
};
|
||||
|
||||
const lotteryByDate = date => {
|
||||
const startOfDay = new Date(date.setHours(0, 0, 0, 0));
|
||||
const endOfDay = new Date(date.setHours(24, 59, 59, 99));
|
||||
|
||||
const query = [
|
||||
{
|
||||
$match: {
|
||||
date: {
|
||||
$gte: startOfDay,
|
||||
$lte: endOfDay
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: "wines",
|
||||
localField: "wines",
|
||||
foreignField: "_id",
|
||||
as: "wines"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const aggregateLottery = Lottery.aggregate(query);
|
||||
return aggregateLottery.project("-_id -__v").then(lotteries => {
|
||||
if (lotteries.length == 0) {
|
||||
throw new LotteryByDateNotFound(date);
|
||||
}
|
||||
return lotteries[0];
|
||||
});
|
||||
};
|
||||
|
||||
const allLotteries = (sort = "asc", yearFilter = undefined) => {
|
||||
const sortDirection = sort == "asc" ? 1 : -1;
|
||||
|
||||
let startQueryDate = new Date("1970-01-01");
|
||||
let endQueryDate = new Date("2999-01-01");
|
||||
if (yearFilter) {
|
||||
startQueryDate = new Date(`${yearFilter}-01-01`);
|
||||
endQueryDate = new Date(`${Number(yearFilter) + 1}-01-01`);
|
||||
}
|
||||
|
||||
const query = [
|
||||
{
|
||||
$match: {
|
||||
date: {
|
||||
$gte: startQueryDate,
|
||||
$lte: endQueryDate
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
date: sortDirection
|
||||
}
|
||||
},
|
||||
{
|
||||
$unset: ["_id", "__v"]
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: "wines",
|
||||
localField: "wines",
|
||||
foreignField: "_id",
|
||||
as: "wines"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return Lottery.aggregate(query);
|
||||
};
|
||||
|
||||
const allLotteriesIncludingWinners = async (sort = "asc", yearFilter = undefined) => {
|
||||
const lotteries = await allLotteries(sort, yearFilter);
|
||||
const allWinners = await historyRepository.groupByDate(false, sort);
|
||||
|
||||
return lotteries.map(lottery => {
|
||||
const { winners } = allWinners.pop();
|
||||
|
||||
return {
|
||||
wines: lottery.wines,
|
||||
date: lottery.date,
|
||||
blue: lottery.blue,
|
||||
green: lottery.green,
|
||||
yellow: lottery.yellow,
|
||||
red: lottery.red,
|
||||
bought: lottery.bought,
|
||||
stolen: lottery.stolen,
|
||||
winners: winners
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const latestLottery = async () => {
|
||||
return Lottery.findOne().sort({ date: -1 });
|
||||
};
|
||||
|
||||
const drawWinner = async () => {
|
||||
let allContestants = await Attendee.find({ winner: false });
|
||||
|
||||
if (allContestants.length == 0) {
|
||||
throw new NoMoreAttendeesToWin();
|
||||
}
|
||||
|
||||
let raffleColors = [];
|
||||
for (let i = 0; i < allContestants.length; i++) {
|
||||
let currentContestant = allContestants[i];
|
||||
for (let blue = 0; blue < currentContestant.blue; blue++) {
|
||||
raffleColors.push("blue");
|
||||
}
|
||||
for (let red = 0; red < currentContestant.red; red++) {
|
||||
raffleColors.push("red");
|
||||
}
|
||||
for (let green = 0; green < currentContestant.green; green++) {
|
||||
raffleColors.push("green");
|
||||
}
|
||||
for (let yellow = 0; yellow < currentContestant.yellow; yellow++) {
|
||||
raffleColors.push("yellow");
|
||||
}
|
||||
}
|
||||
|
||||
raffleColors = shuffle(raffleColors);
|
||||
|
||||
let colorToChooseFrom = raffleColors[Math.floor(Math.random() * raffleColors.length)];
|
||||
let findObject = { winner: false };
|
||||
|
||||
findObject[colorToChooseFrom] = { $gt: 0 };
|
||||
|
||||
let tries = 0;
|
||||
const maxTries = 3;
|
||||
let contestantsToChooseFrom = undefined;
|
||||
while (contestantsToChooseFrom == undefined && tries < maxTries) {
|
||||
const hit = await Attendee.find(findObject);
|
||||
if (hit && hit.length) {
|
||||
contestantsToChooseFrom = hit;
|
||||
break;
|
||||
}
|
||||
tries++;
|
||||
}
|
||||
if (contestantsToChooseFrom == undefined) {
|
||||
throw new CouldNotFindNewWinnerAfterNTries(maxTries);
|
||||
}
|
||||
|
||||
let attendeeListDemocratic = [];
|
||||
|
||||
let currentContestant;
|
||||
for (let i = 0; i < contestantsToChooseFrom.length; i++) {
|
||||
currentContestant = contestantsToChooseFrom[i];
|
||||
for (let y = 0; y < currentContestant[colorToChooseFrom]; y++) {
|
||||
attendeeListDemocratic.push({
|
||||
name: currentContestant.name,
|
||||
phoneNumber: currentContestant.phoneNumber,
|
||||
red: currentContestant.red,
|
||||
blue: currentContestant.blue,
|
||||
green: currentContestant.green,
|
||||
yellow: currentContestant.yellow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
attendeeListDemocratic = shuffle(attendeeListDemocratic);
|
||||
|
||||
let winner = attendeeListDemocratic[Math.floor(Math.random() * attendeeListDemocratic.length)];
|
||||
|
||||
let newWinnerElement = new VirtualWinner({
|
||||
name: winner.name,
|
||||
phoneNumber: winner.phoneNumber,
|
||||
color: colorToChooseFrom,
|
||||
red: winner.red,
|
||||
blue: winner.blue,
|
||||
green: winner.green,
|
||||
yellow: winner.yellow,
|
||||
id: sha512(winner.phoneNumber, genRandomString(10)),
|
||||
timestamp_drawn: new Date().getTime()
|
||||
});
|
||||
|
||||
await newWinnerElement.save();
|
||||
await Attendee.updateOne({ name: winner.name, phoneNumber: winner.phoneNumber }, { $set: { winner: true } });
|
||||
|
||||
let winners = await VirtualWinner.find({ timestamp_sent: undefined }).sort({
|
||||
timestamp_drawn: 1
|
||||
});
|
||||
|
||||
return { winner, color: colorToChooseFrom, winners };
|
||||
};
|
||||
|
||||
/** - - UTILS - - **/
|
||||
const genRandomString = function(length) {
|
||||
return crypto
|
||||
.randomBytes(Math.ceil(length / 2))
|
||||
.toString("hex") /** convert to hexadecimal format */
|
||||
.slice(0, length); /** return required number of characters */
|
||||
};
|
||||
|
||||
const sha512 = function(password, salt) {
|
||||
var hash = crypto.createHmac("md5", salt); /** Hashing algorithm sha512 */
|
||||
hash.update(password);
|
||||
var value = hash.digest("hex");
|
||||
return value;
|
||||
};
|
||||
|
||||
function shuffle(array) {
|
||||
let currentIndex = array.length,
|
||||
temporaryValue,
|
||||
randomIndex;
|
||||
|
||||
// While there remain elements to shuffle...
|
||||
while (0 !== currentIndex) {
|
||||
// Pick a remaining element...
|
||||
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex -= 1;
|
||||
|
||||
// And swap it with the current element.
|
||||
temporaryValue = array[currentIndex];
|
||||
array[currentIndex] = array[randomIndex];
|
||||
array[randomIndex] = temporaryValue;
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
drawWinner,
|
||||
archive,
|
||||
lotteryByDate,
|
||||
allLotteries,
|
||||
allLotteriesIncludingWinners,
|
||||
latestLottery
|
||||
};
|
||||
133
api/message.js
Normal file
133
api/message.js
Normal file
@@ -0,0 +1,133 @@
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const config = require(path.join(__dirname + "/../config/defaults/lottery"));
|
||||
|
||||
const dateString = date => {
|
||||
if (typeof date == "string") {
|
||||
date = new Date(date);
|
||||
}
|
||||
const ye = new Intl.DateTimeFormat("en", { year: "numeric" }).format(date);
|
||||
const mo = new Intl.DateTimeFormat("en", { month: "2-digit" }).format(date);
|
||||
const da = new Intl.DateTimeFormat("en", { day: "2-digit" }).format(date);
|
||||
|
||||
return `${da}-${mo}-${ye}`;
|
||||
};
|
||||
|
||||
async function sendInitialMessageToWinners(winners) {
|
||||
const numbers = winners.map(winner => ({ msisdn: `47${winner.phoneNumber}` }));
|
||||
|
||||
const body = {
|
||||
sender: "Vinlottis",
|
||||
message:
|
||||
"Gratulerer som vinner av vinlottisen! Du vil snart få en SMS med oppdatering om hvordan gangen går!",
|
||||
recipients: numbers,
|
||||
};
|
||||
|
||||
return gatewayRequest(body);
|
||||
}
|
||||
|
||||
async function sendPrizeSelectionLink(winner) {
|
||||
winner.timestamp_sent = new Date().getTime();
|
||||
winner.timestamp_limit = new Date().getTime() + 1000 * 600;
|
||||
await winner.save();
|
||||
|
||||
const { id, name, phoneNumber } = winner;
|
||||
const url = new URL(`/winner/${id}`, `https://${config.domain}`);
|
||||
const message = `Gratulerer som heldig vinner av vinlotteriet ${name}! Her er linken for \
|
||||
å velge hva slags vin du vil ha, du har 10 minutter på å velge ut noe før du blir lagt bakerst \
|
||||
i køen. ${url.href}. (Hvis den siden kommer opp som tom må du prøve å refreshe siden noen ganger.`;
|
||||
|
||||
return sendMessageToNumber(phoneNumber, message);
|
||||
}
|
||||
|
||||
async function sendWineConfirmation(winnerObject, wineObject, date) {
|
||||
date = dateString(date);
|
||||
return sendMessageToNumber(
|
||||
winnerObject.phoneNumber,
|
||||
`Bekreftelse på din vin ${winnerObject.name}.\nDato vunnet: ${date}.\nVin valgt: ${wineObject.name}.\
|
||||
\nDu vil bli kontaktet av ${config.name} ang henting. Ha en ellers fin helg!`
|
||||
);
|
||||
}
|
||||
|
||||
async function sendLastWinnerMessage(winnerObject, wineObject) {
|
||||
console.log(`User ${winnerObject.id} is only one left, chosing wine for him/her.`);
|
||||
winnerObject.timestamp_sent = new Date().getTime();
|
||||
winnerObject.timestamp_limit = new Date().getTime();
|
||||
await winnerObject.save();
|
||||
|
||||
return sendMessageToNumber(
|
||||
winnerObject.phoneNumber,
|
||||
`Gratulerer som heldig vinner av vinlotteriet ${winnerObject.name}! Du har vunnet vinen ${wineObject.name}, \
|
||||
du vil bli kontaktet av ${config.name} ang henting. Ha en ellers fin helg!`
|
||||
);
|
||||
}
|
||||
|
||||
async function sendWineSelectMessageTooLate(winnerObject) {
|
||||
return sendMessageToNumber(
|
||||
winnerObject.phoneNumber,
|
||||
`Hei ${winnerObject.name}, du har dessverre brukt mer enn 10 minutter på å velge premie og blir derfor \
|
||||
puttet bakerst i køen. Du vil få en ny SMS når det er din tur igjen.`
|
||||
);
|
||||
}
|
||||
|
||||
async function sendMessageToNumber(phoneNumber, message) {
|
||||
console.log(`Attempting to send message to ${phoneNumber}.`);
|
||||
|
||||
const body = {
|
||||
sender: "Vinlottis",
|
||||
message: message,
|
||||
recipients: [{ msisdn: `47${phoneNumber}` }],
|
||||
};
|
||||
|
||||
return gatewayRequest(body);
|
||||
}
|
||||
|
||||
async function gatewayRequest(body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: "gatewayapi.com",
|
||||
post: 443,
|
||||
path: `/rest/mtsms?token=${config.gatewayToken}`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, res => {
|
||||
console.log(`statusCode: ${res.statusCode}`);
|
||||
console.log(`statusMessage: ${res.statusMessage}`);
|
||||
|
||||
res.setEncoding("utf8");
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
res.on("data", data => {
|
||||
console.log("Response from message gateway:", data);
|
||||
|
||||
resolve(JSON.parse(data));
|
||||
});
|
||||
} else {
|
||||
res.on("data", data => {
|
||||
data = JSON.parse(data);
|
||||
return reject("Gateway error: " + data["message"] || data);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
req.on("error", error => {
|
||||
console.error(`Error from sms service: ${error}`);
|
||||
reject(`Error from sms service: ${error}`);
|
||||
});
|
||||
|
||||
req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendInitialMessageToWinners,
|
||||
sendPrizeSelectionLink,
|
||||
sendWineConfirmation,
|
||||
sendLastWinnerMessage,
|
||||
sendWineSelectMessageTooLate,
|
||||
};
|
||||
6
api/middleware/alwaysAuthenticatedWhenLocalhost.js
Normal file
6
api/middleware/alwaysAuthenticatedWhenLocalhost.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const alwaysAuthenticatedWhenLocalhost = (req, res, next) => {
|
||||
req.isAuthenticated = () => true;
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = alwaysAuthenticatedWhenLocalhost;
|
||||
@@ -1,5 +1,4 @@
|
||||
const mustBeAuthenticated = (req, res, next) => {
|
||||
console.log(req.isAuthenticated());
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).send({
|
||||
success: false,
|
||||
6
api/middleware/setAdminHeaderIfAuthenticated.js
Normal file
6
api/middleware/setAdminHeaderIfAuthenticated.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const setAdminHeaderIfAuthenticated = (req, res, next) => {
|
||||
res.set("Vinlottis-Admin", req.isAuthenticated());
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = setAdminHeaderIfAuthenticated;
|
||||
6
api/middleware/setupCORS.js
Normal file
6
api/middleware/setupCORS.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const openCORS = (req, res, next) => {
|
||||
res.set("Access-Control-Allow-Origin", "*")
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = openCORS;
|
||||
37
api/middleware/setupHeaders.js
Normal file
37
api/middleware/setupHeaders.js
Normal 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;
|
||||
103
api/prelotteryWine.js
Normal file
103
api/prelotteryWine.js
Normal file
@@ -0,0 +1,103 @@
|
||||
const path = require("path");
|
||||
|
||||
const PreLotteryWine = require(path.join(__dirname, "/schemas/PreLotteryWine"));
|
||||
const { WineNotFound } = require(path.join(__dirname, "/vinlottisErrors"));
|
||||
|
||||
const allWines = () => {
|
||||
return PreLotteryWine.find().populate("winner");
|
||||
};
|
||||
|
||||
const allWinesWithoutWinner = () => {
|
||||
return PreLotteryWine.find({ winner: { $exists: false } });
|
||||
};
|
||||
|
||||
const addWines = wines => {
|
||||
const prelotteryWines = wines.map(wine => {
|
||||
let newPrelotteryWine = new PreLotteryWine({
|
||||
name: wine.name,
|
||||
vivinoLink: wine.vivinoLink,
|
||||
rating: wine.rating,
|
||||
year: wine.year,
|
||||
image: wine.image,
|
||||
price: wine.price,
|
||||
country: wine.country,
|
||||
id: wine.id
|
||||
});
|
||||
console.log(newPrelotteryWine)
|
||||
return newPrelotteryWine.save();
|
||||
});
|
||||
|
||||
return Promise.all(prelotteryWines);
|
||||
};
|
||||
|
||||
const wineById = id => {
|
||||
return PreLotteryWine.findOne({ _id: id }).then(wine => {
|
||||
if (wine == null) {
|
||||
throw new WineNotFound();
|
||||
}
|
||||
return wine;
|
||||
});
|
||||
};
|
||||
|
||||
const updateWineById = (id, updateModel) => {
|
||||
return PreLotteryWine.findOne({ _id: id }).then(wine => {
|
||||
if (wine == null) {
|
||||
throw new WineNotFound();
|
||||
}
|
||||
|
||||
const updatedWine = {
|
||||
name: updateModel.name != null ? updateModel.name : wine.name,
|
||||
vivinoLink: updateModel.vivinoLink != null ? updateModel.vivinoLink : wine.vivinoLink,
|
||||
rating: updateModel.rating != null ? updateModel.rating : wine.rating,
|
||||
year: updateModel.year != null ? updateModel.year : wine.year,
|
||||
image: updateModel.image != null ? updateModel.image : wine.image,
|
||||
price: updateModel.price != null ? updateModel.price : wine.price,
|
||||
country: updateModel.country != null ? updateModel.country : wine.country,
|
||||
id: updateModel.id != null ? updateModel.id : wine.id
|
||||
};
|
||||
|
||||
return PreLotteryWine.updateOne({ _id: id }, updatedWine).then(_ => updatedWine);
|
||||
});
|
||||
};
|
||||
|
||||
const addWinnerToWine = (wine, winner) => {
|
||||
wine.winner = winner;
|
||||
winner.prize_selected = true;
|
||||
return Promise.all([wine.save(), winner.save()]);
|
||||
};
|
||||
|
||||
const deleteWineById = id => {
|
||||
return PreLotteryWine.findOne({ _id: id }).then(wine => {
|
||||
if (wine == null) {
|
||||
throw new WineNotFound();
|
||||
}
|
||||
|
||||
return PreLotteryWine.deleteOne({ _id: id }).then(_ => wine);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteWines = () => {
|
||||
return PreLotteryWine.deleteMany();
|
||||
};
|
||||
|
||||
const wineSchema = () => {
|
||||
let schema = { ...PreLotteryWine.schema.obj };
|
||||
let nulledSchema = Object.keys(schema).reduce((accumulator, current) => {
|
||||
accumulator[current] = "";
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
return Promise.resolve(nulledSchema);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
allWines,
|
||||
allWinesWithoutWinner,
|
||||
addWines,
|
||||
wineById,
|
||||
addWinnerToWine,
|
||||
updateWineById,
|
||||
deleteWineById,
|
||||
deleteWines,
|
||||
wineSchema
|
||||
};
|
||||
110
api/prizeDistribution.js
Normal file
110
api/prizeDistribution.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const path = require("path");
|
||||
|
||||
const Wine = require(path.join(__dirname, "/schemas/Wine"));
|
||||
const PreLotteryWine = require(path.join(__dirname, "/schemas/PreLotteryWine"));
|
||||
const VirtualWinner = require(path.join(__dirname, "/schemas/VirtualWinner"));
|
||||
|
||||
const message = require(path.join(__dirname, "/message"));
|
||||
const historyRepository = require(path.join(__dirname, "/history"));
|
||||
const winnerRepository = require(path.join(__dirname, "/winner"));
|
||||
const wineRepository = require(path.join(__dirname, "/wine"));
|
||||
const prelotteryWineRepository = require(path.join(__dirname, "/prelotteryWine"));
|
||||
|
||||
const { WinnerNotFound, WineSelectionWinnerNotNextInLine, WinnersTimelimitExpired } = require(path.join(
|
||||
__dirname,
|
||||
"/vinlottisErrors"
|
||||
));
|
||||
|
||||
const verifyWinnerNextInLine = async id => {
|
||||
let foundWinner = await VirtualWinner.findOne({ id: id });
|
||||
|
||||
if (!foundWinner) {
|
||||
throw new WinnerNotFound();
|
||||
} else if (foundWinner.timestamp_limit < new Date().getTime()) {
|
||||
throw new WinnersTimelimitExpired();
|
||||
}
|
||||
|
||||
let allWinners = await VirtualWinner.find().sort({ timestamp_drawn: 1 });
|
||||
|
||||
if (
|
||||
foundWinner.timestamp_limit == undefined ||
|
||||
foundWinner.timestamp_sent == undefined ||
|
||||
foundWinner.prize_selected == true
|
||||
) {
|
||||
throw new WineSelectionWinnerNotNextInLine();
|
||||
}
|
||||
|
||||
return Promise.resolve(foundWinner);
|
||||
};
|
||||
|
||||
const claimPrize = (wine, winner) => {
|
||||
return wineRepository
|
||||
.addWine(wine)
|
||||
.then(_ => prelotteryWineRepository.addWinnerToWine(wine, winner)) // prelotteryWine.deleteById
|
||||
.then(_ => historyRepository.addWinnerWithWine(winner, wine)) // wines.js : addWine
|
||||
.then(_ => message.sendWineConfirmation(winner, wine));
|
||||
};
|
||||
|
||||
const notifyNextWinner = async () => {
|
||||
let nextWinner = undefined;
|
||||
|
||||
const winnersLeft = await VirtualWinner.find({ prize_selected: false }).sort({ timestamp_drawn: 1 });
|
||||
const winesLeft = await PreLotteryWine.find({ winner: { $exists: false } });
|
||||
|
||||
if (winnersLeft.length > 1) {
|
||||
console.log("multiple winners left, choose next in line");
|
||||
nextWinner = winnersLeft[0]; // multiple winners left, choose next in line
|
||||
} else if (winnersLeft.length == 1 && winesLeft.length > 1) {
|
||||
console.log("one winner left, but multiple wines");
|
||||
nextWinner = winnersLeft[0]; // one winner left, but multiple wines
|
||||
} else if (winnersLeft.length == 1 && winesLeft.length == 1) {
|
||||
console.log("one winner and one wine left, choose for user");
|
||||
nextWinner = winnersLeft[0]; // one winner and one wine left, choose for user
|
||||
wine = winesLeft[0];
|
||||
return claimPrize(wine, nextWinner);
|
||||
}
|
||||
|
||||
if (nextWinner) {
|
||||
return message.sendPrizeSelectionLink(nextWinner).then(_ => startTimeout(nextWinner.id));
|
||||
} else {
|
||||
console.info("All winners notified. Could start cleanup here.");
|
||||
return Promise.resolve({
|
||||
message: "All winners notified."
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// these need to be register somewhere to cancel if something
|
||||
// goes wrong and we want to start prize distribution again
|
||||
function startTimeout(id) {
|
||||
const minute = 60000;
|
||||
const minutesForTimeout = 10;
|
||||
|
||||
console.log(`Starting timeout for user ${id}.`);
|
||||
console.log(`Timeout duration: ${minutesForTimeout * minute}`);
|
||||
setTimeout(async () => {
|
||||
let virtualWinner = await VirtualWinner.findOne({ id: id, prize_selected: false });
|
||||
if (!virtualWinner) {
|
||||
console.log(`Timeout done for user ${id}, but user has already sent data.`);
|
||||
return;
|
||||
}
|
||||
console.log(`Timeout done for user ${id}, sending update to user.`);
|
||||
|
||||
message.sendWineSelectMessageTooLate(virtualWinner);
|
||||
|
||||
virtualWinner.timestamp_drawn = new Date().getTime();
|
||||
virtualWinner.timestamp_limit = null;
|
||||
virtualWinner.timestamp_sent = null;
|
||||
await virtualWinner.save();
|
||||
|
||||
notifyNextWinner();
|
||||
}, minutesForTimeout * minute);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyWinnerNextInLine,
|
||||
claimPrize,
|
||||
notifyNextWinner
|
||||
};
|
||||
93
api/redis.js
93
api/redis.js
@@ -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");
|
||||
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 = {
|
||||
|
||||
68
api/request.js
Normal file
68
api/request.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const path = require("path");
|
||||
const RequestedWine = require(path.join(__dirname, "/schemas/RequestedWine"));
|
||||
const Wine = require(path.join(__dirname, "/schemas/Wine"));
|
||||
|
||||
class RequestedWineNotFound extends Error {
|
||||
constructor(message = "Wine with this id was not found.") {
|
||||
super(message);
|
||||
this.name = "RequestedWineNotFound";
|
||||
this.statusCode = 404;
|
||||
}
|
||||
}
|
||||
|
||||
const addNew = async wine => {
|
||||
let foundWine = await Wine.findOne({ id: wine.id });
|
||||
|
||||
if (foundWine == undefined) {
|
||||
foundWine = new Wine({
|
||||
name: wine.name,
|
||||
vivinoLink: wine.vivinoLink,
|
||||
rating: null,
|
||||
occurences: null,
|
||||
image: wine.image,
|
||||
id: wine.id
|
||||
});
|
||||
await foundWine.save();
|
||||
}
|
||||
|
||||
let requestedWine = await RequestedWine.findOne({ wineId: wine.id });
|
||||
|
||||
if (requestedWine == undefined) {
|
||||
requestedWine = new RequestedWine({
|
||||
count: 1,
|
||||
wineId: wine.id,
|
||||
wine: foundWine
|
||||
});
|
||||
} else {
|
||||
requestedWine.count += 1;
|
||||
}
|
||||
await requestedWine.save();
|
||||
|
||||
return requestedWine;
|
||||
};
|
||||
|
||||
const getById = id => {
|
||||
return RequestedWine.findOne({ wineId: id })
|
||||
.populate("wine")
|
||||
.then(wine => {
|
||||
if (wine == null) {
|
||||
throw new RequestedWineNotFound();
|
||||
}
|
||||
|
||||
return wine;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteById = id => {
|
||||
return getById(id).then(requestedWine => RequestedWine.deleteOne({ _id: requestedWine._id }));
|
||||
};
|
||||
|
||||
const getAll = () => {
|
||||
return RequestedWine.find({}).populate("wine");
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
addNew,
|
||||
getAll,
|
||||
deleteById
|
||||
};
|
||||
155
api/retrieve.js
155
api/retrieve.js
@@ -1,155 +0,0 @@
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
const router = express.Router();
|
||||
const mongoose = require("mongoose");
|
||||
mongoose.connect("mongodb://localhost:27017/vinlottis", {
|
||||
useNewUrlParser: true
|
||||
});
|
||||
|
||||
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"
|
||||
));
|
||||
|
||||
router.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
router.route("/wines/prelottery").get(async (req, res) => {
|
||||
let wines = await PreLotteryWine.find();
|
||||
res.json(wines);
|
||||
});
|
||||
|
||||
router.route("/purchase/statistics").get(async (req, res) => {
|
||||
let purchases = await Purchase.find()
|
||||
.populate("wines")
|
||||
.sort({ date: 1 });
|
||||
res.json(purchases);
|
||||
});
|
||||
|
||||
router.route("/purchase/statistics/color").get(async (req, res) => {
|
||||
const countColor = await Purchase.find();
|
||||
let red = 0;
|
||||
let blue = 0;
|
||||
let yellow = 0;
|
||||
let green = 0;
|
||||
let stolen = 0;
|
||||
for (let i = 0; i < countColor.length; i++) {
|
||||
let element = countColor[i];
|
||||
red += element.red;
|
||||
blue += element.blue;
|
||||
yellow += element.yellow;
|
||||
green += element.green;
|
||||
if (element.stolen != undefined) {
|
||||
stolen += element.stolen;
|
||||
}
|
||||
}
|
||||
|
||||
const highscore = await Highscore.find();
|
||||
let redWin = 0;
|
||||
let blueWin = 0;
|
||||
let yellowWin = 0;
|
||||
let greenWin = 0;
|
||||
for (let i = 0; i < highscore.length; i++) {
|
||||
let element = highscore[i];
|
||||
for (let y = 0; y < element.wins.length; y++) {
|
||||
let currentWin = element.wins[y];
|
||||
switch (currentWin.color) {
|
||||
case "blue":
|
||||
blueWin += 1;
|
||||
break;
|
||||
case "red":
|
||||
redWin += 1;
|
||||
break;
|
||||
case "yellow":
|
||||
yellowWin += 1;
|
||||
break;
|
||||
case "green":
|
||||
greenWin += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const total = red + yellow + blue + green;
|
||||
|
||||
res.json({
|
||||
red: {
|
||||
total: red,
|
||||
win: redWin
|
||||
},
|
||||
blue: {
|
||||
total: blue,
|
||||
win: blueWin
|
||||
},
|
||||
green: {
|
||||
total: green,
|
||||
win: greenWin
|
||||
},
|
||||
yellow: {
|
||||
total: yellow,
|
||||
win: yellowWin
|
||||
},
|
||||
stolen: stolen,
|
||||
total: total
|
||||
});
|
||||
});
|
||||
|
||||
router.route("/highscore/statistics").get(async (req, res) => {
|
||||
const highscore = await Highscore.find().populate("wins.wine");
|
||||
|
||||
res.json(highscore);
|
||||
});
|
||||
|
||||
router.route("/wines/statistics").get(async (req, res) => {
|
||||
const wines = await Wine.find();
|
||||
|
||||
res.json(wines);
|
||||
});
|
||||
|
||||
router.route("/wines/statistics/overall").get(async (req, res) => {
|
||||
const highscore = await Highscore.find().populate("wins.wine");
|
||||
let wines = {};
|
||||
|
||||
for (let i = 0; i < highscore.length; i++) {
|
||||
let person = highscore[i];
|
||||
for (let y = 0; y < person.wins.length; y++) {
|
||||
let wine = person.wins[y].wine;
|
||||
let date = person.wins[y].date;
|
||||
let color = person.wins[y].color;
|
||||
|
||||
if (wines[wine._id] == undefined) {
|
||||
wines[wine._id] = {
|
||||
name: wine.name,
|
||||
occurences: wine.occurences,
|
||||
vivinoLink: wine.vivinoLink,
|
||||
rating: wine.rating,
|
||||
image: wine.image,
|
||||
id: wine.id,
|
||||
_id: wine._id,
|
||||
dates: [date],
|
||||
winners: [person.name],
|
||||
red: 0,
|
||||
blue: 0,
|
||||
green: 0,
|
||||
yellow: 0
|
||||
};
|
||||
wines[wine._id][color] += 1;
|
||||
} else {
|
||||
wines[wine._id].dates.push(date);
|
||||
wines[wine._id].winners.push(person.name);
|
||||
if (wines[wine._id][color] == undefined) {
|
||||
wines[wine._id][color] = 1;
|
||||
} else {
|
||||
wines[wine._id][color] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json(Object.values(wines));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
107
api/router.js
Normal file
107
api/router.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
|
||||
const mustBeAuthenticated = require(path.join(__dirname, "/middleware/mustBeAuthenticated"));
|
||||
const setAdminHeaderIfAuthenticated = require(path.join(__dirname, "/middleware/setAdminHeaderIfAuthenticated"));
|
||||
|
||||
const requestController = require(path.join(__dirname, "/controllers/requestController"));
|
||||
const vinmonopoletController = require(path.join(__dirname, "/controllers/vinmonopoletController"));
|
||||
const chatController = require(path.join(__dirname, "/controllers/chatController"));
|
||||
const userController = require(path.join(__dirname, "/controllers/userController"));
|
||||
const historyController = require(path.join(__dirname, "/controllers/historyController"));
|
||||
const attendeeController = require(path.join(__dirname, "/controllers/lotteryAttendeeController"));
|
||||
const prelotteryWineController = require(path.join(__dirname, "/controllers/lotteryWineController"));
|
||||
const winnerController = require(path.join(__dirname, "/controllers/lotteryWinnerController"));
|
||||
const lotteryController = require(path.join(__dirname, "/controllers/lotteryController"));
|
||||
const prizeDistributionController = require(path.join(__dirname, "/controllers/prizeDistributionController"));
|
||||
const wineController = require(path.join(__dirname, "/controllers/wineController"));
|
||||
const messageController = require(path.join(__dirname, "/controllers/messageController"));
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/vinmonopolet/wine/search", vinmonopoletController.searchWines);
|
||||
router.get("/vinmonopolet/wine/by-ean/:ean", vinmonopoletController.wineByEAN);
|
||||
router.get("/vinmonopolet/wine/by-id/:id", vinmonopoletController.wineById);
|
||||
router.get("/vinmonopolet/stores/", vinmonopoletController.allStores);
|
||||
router.get("/vinmonopolet/stores/search", vinmonopoletController.searchStores);
|
||||
|
||||
router.get("/requests", setAdminHeaderIfAuthenticated, requestController.allRequests);
|
||||
router.post("/request", requestController.addRequest);
|
||||
router.delete("/request/:id", mustBeAuthenticated, requestController.deleteRequest);
|
||||
|
||||
router.get("/wines", wineController.allWines); // sort = by-date, by-name, by-occurences
|
||||
router.get("/wine/:id", wineController.wineById); // sort = by-date, by-name, by-occurences
|
||||
// router.update("/wine/:id", mustBeAuthenticated, wineController.update);
|
||||
|
||||
router.get("/history", historyController.all);
|
||||
router.get("/history/latest", historyController.latest);
|
||||
router.get("/history/by-wins/", historyController.orderByWins);
|
||||
router.get("/history/by-color/", historyController.groupByColor);
|
||||
router.get("/history/by-date/:date", historyController.byDate);
|
||||
router.get("/history/by-name/:name", historyController.byName);
|
||||
router.get("/history/search/", historyController.search);
|
||||
router.get("/history/by-date/", historyController.groupByDate);
|
||||
|
||||
// router.get("/purchases", purchaseController.lotteryPurchases);
|
||||
// // returns list per date and count of each colors that where bought
|
||||
// router.get("/purchases/summary", purchaseController.lotteryPurchases);
|
||||
// // returns total, wins?, stolen
|
||||
// router.get("/purchase/:date", purchaseController.lotteryPurchaseByDate);
|
||||
|
||||
router.get("/lottery/wines", prelotteryWineController.allWines);
|
||||
router.get("/lottery/wine/schema", mustBeAuthenticated, prelotteryWineController.wineSchema);
|
||||
router.get("/lottery/wine/:id", mustBeAuthenticated, prelotteryWineController.wineById);
|
||||
router.post("/lottery/wines", mustBeAuthenticated, prelotteryWineController.addWines);
|
||||
router.delete("/lottery/wines", mustBeAuthenticated, prelotteryWineController.deleteWines);
|
||||
router.put("/lottery/wine/:id", mustBeAuthenticated, prelotteryWineController.updateWineById);
|
||||
router.delete("/lottery/wine/:id", mustBeAuthenticated, prelotteryWineController.deleteWineById);
|
||||
|
||||
router.get("/lottery/attendees", setAdminHeaderIfAuthenticated, attendeeController.allAttendees);
|
||||
router.delete("/lottery/attendees", mustBeAuthenticated, attendeeController.deleteAttendees);
|
||||
router.post("/lottery/attendee", mustBeAuthenticated, attendeeController.addAttendee);
|
||||
router.put("/lottery/attendee/:id", mustBeAuthenticated, attendeeController.updateAttendeeById);
|
||||
router.delete("/lottery/attendee/:id", mustBeAuthenticated, attendeeController.deleteAttendeeById);
|
||||
|
||||
router.get("/lottery/winners", winnerController.allWinners);
|
||||
router.get("/lottery/winner/:id", winnerController.winnerById);
|
||||
router.post("/lottery/winners", mustBeAuthenticated, winnerController.addWinners);
|
||||
router.delete("/lottery/winners", mustBeAuthenticated, winnerController.deleteWinners);
|
||||
router.put("/lottery/winner/:id", mustBeAuthenticated, winnerController.updateWinnerById);
|
||||
router.delete("/lottery/winner/:id", mustBeAuthenticated, winnerController.deleteWinnerById);
|
||||
|
||||
router.get("/lottery/draw", mustBeAuthenticated, lotteryController.drawWinner);
|
||||
router.post("/lottery/archive", mustBeAuthenticated, lotteryController.archiveLottery);
|
||||
router.get("/lottery/latest", lotteryController.latestLottery);
|
||||
router.get("/lottery/:epoch", lotteryController.lotteryByDate);
|
||||
router.get("/lotteries/", lotteryController.allLotteries);
|
||||
|
||||
// router.get("/lottery/prize-distribution/status", mustBeAuthenticated, prizeDistributionController.status);
|
||||
router.post("/lottery/prize-distribution/start", mustBeAuthenticated, prizeDistributionController.start);
|
||||
// router.post("/lottery/prize-distribution/stop", mustBeAuthenticated, prizeDistributionController.stop);
|
||||
router.get("/lottery/prize-distribution/prizes/:id", prizeDistributionController.getPrizesForWinnerById);
|
||||
router.post("/lottery/prize-distribution/prize/:id", prizeDistributionController.submitPrizeForWinnerById);
|
||||
|
||||
router.post("/lottery/messages/winner/:id", mustBeAuthenticated, messageController.notifyWinnerById);
|
||||
|
||||
router.get("/chat/history", chatController.getAllHistory);
|
||||
router.delete("/chat/history", mustBeAuthenticated, chatController.deleteHistory);
|
||||
|
||||
router.post("/login", userController.login);
|
||||
router.get("/logout", userController.logout);
|
||||
if(process.env !== "production") {
|
||||
// We don't want to hide registering behind a
|
||||
// authentication-wall if we are in dev
|
||||
router.post("/register", userController.register);
|
||||
} else {
|
||||
router.post("/register", mustBeAuthenticated, userController.register);
|
||||
}
|
||||
|
||||
// router.get("/", documentation.apiInfo);
|
||||
|
||||
// router.get("/wine/schema", mustBeAuthenticated, update.schema);
|
||||
// router.get("/purchase/statistics", retrieve.allPurchase);
|
||||
// router.get("/highscore/statistics", retrieve.highscore);
|
||||
// router.get("/wines/statistics", retrieve.allWines);
|
||||
// router.get("/wines/statistics/overall", retrieve.allWinesSummary);
|
||||
|
||||
module.exports = router;
|
||||
@@ -6,7 +6,14 @@ const PreLotteryWine = new Schema({
|
||||
vivinoLink: String,
|
||||
rating: Number,
|
||||
id: String,
|
||||
image: String
|
||||
year: Number,
|
||||
image: String,
|
||||
price: String,
|
||||
country: String,
|
||||
winner: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "VirtualWinner"
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = mongoose.model("PreLotteryWine", PreLotteryWine);
|
||||
13
api/schemas/RequestedWine.js
Normal file
13
api/schemas/RequestedWine.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const mongoose = require("mongoose");
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
const RequestedWine = new Schema({
|
||||
count: Number,
|
||||
wineId: String,
|
||||
wine: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "Wine"
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = mongoose.model("RequestedWine", RequestedWine);
|
||||
@@ -8,7 +8,15 @@ const VirtualWinner = new Schema({
|
||||
green: Number,
|
||||
blue: Number,
|
||||
red: Number,
|
||||
yellow: Number
|
||||
yellow: Number,
|
||||
id: String,
|
||||
prize_selected: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
timestamp_drawn: Number,
|
||||
timestamp_sent: Number,
|
||||
timestamp_limit: Number
|
||||
});
|
||||
|
||||
module.exports = mongoose.model("VirtualWinner", VirtualWinner);
|
||||
@@ -1,15 +1,16 @@
|
||||
const mongoose = require("mongoose");
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
const Wine = new Schema({
|
||||
const WineSchema = new Schema({
|
||||
name: String,
|
||||
vivinoLink: String,
|
||||
rating: Number,
|
||||
occurences: Number,
|
||||
id: String,
|
||||
year: Number,
|
||||
image: String,
|
||||
price: String,
|
||||
country: String
|
||||
});
|
||||
|
||||
module.exports = mongoose.model("Wine", Wine);
|
||||
module.exports = mongoose.model("Wine", WineSchema);
|
||||
@@ -2,19 +2,14 @@ const express = require("express");
|
||||
const path = require("path");
|
||||
const router = express.Router();
|
||||
const webpush = require("web-push"); //requiring the web-push module
|
||||
const mongoose = require("mongoose");
|
||||
const schedule = require("node-schedule");
|
||||
|
||||
mongoose.connect("mongodb://localhost:27017/vinlottis", {
|
||||
useNewUrlParser: true
|
||||
});
|
||||
|
||||
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"
|
||||
));
|
||||
|
||||
154
api/update.js
154
api/update.js
@@ -1,154 +0,0 @@
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
const router = express.Router();
|
||||
const mongoose = require("mongoose");
|
||||
mongoose.connect("mongodb://localhost:27017/vinlottis", {
|
||||
useNewUrlParser: true
|
||||
});
|
||||
|
||||
const sub = require(path.join(__dirname + "/../api/subscriptions"));
|
||||
const mustBeAuthenticated = require(path.join(
|
||||
__dirname + "/../middleware/mustBeAuthenticated"
|
||||
));
|
||||
|
||||
const Subscription = require(path.join(__dirname + "/../schemas/Subscription"));
|
||||
const Purchase = require(path.join(__dirname + "/../schemas/Purchase"));
|
||||
const Wine = require(path.join(__dirname + "/../schemas/Wine"));
|
||||
const PreLotteryWine = require(path.join(
|
||||
__dirname + "/../schemas/PreLotteryWine"
|
||||
));
|
||||
const VirtualWinner = require(path.join(
|
||||
__dirname + "/../schemas/VirtualWinner"
|
||||
));
|
||||
const Highscore = require(path.join(__dirname + "/../schemas/Highscore"));
|
||||
|
||||
router.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
router.route("/log/wines").post(mustBeAuthenticated, async (req, res) => {
|
||||
const wines = req.body;
|
||||
for (let i = 0; i < wines.length; i++) {
|
||||
let wine = wines[i];
|
||||
let newWonWine = new PreLotteryWine({
|
||||
name: wine.name,
|
||||
vivinoLink: wine.vivinoLink,
|
||||
rating: wine.rating,
|
||||
image: wine.image,
|
||||
price: wine.price,
|
||||
country: wine.country,
|
||||
id: wine.id
|
||||
});
|
||||
await newWonWine.save();
|
||||
}
|
||||
|
||||
let subs = await Subscription.find();
|
||||
for (let i = 0; i < subs.length; i++) {
|
||||
let subscription = subs[i]; //get subscription from your databse here.
|
||||
const message = JSON.stringify({
|
||||
message: "Dagens vin er lagt til, se den på lottis.vin/dagens!",
|
||||
title: "Ny vin!",
|
||||
link: "/#/dagens"
|
||||
});
|
||||
sub.sendNotification(subscription, message);
|
||||
}
|
||||
|
||||
res.send(true);
|
||||
});
|
||||
|
||||
router.route("/log/schema").get(mustBeAuthenticated, async (req, res) => {
|
||||
let schema = { ...PreLotteryWine.schema.obj };
|
||||
let nulledSchema = Object.keys(schema).reduce((accumulator, current) => {
|
||||
accumulator[current] = "";
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
res.send(nulledSchema);
|
||||
});
|
||||
|
||||
router.route("/log").post(mustBeAuthenticated, async (req, res) => {
|
||||
await PreLotteryWine.deleteMany();
|
||||
|
||||
const purchaseBody = req.body.purchase;
|
||||
const winnersBody = req.body.winners;
|
||||
|
||||
const date = purchaseBody.date;
|
||||
const blue = purchaseBody.blue;
|
||||
const red = purchaseBody.red;
|
||||
const yellow = purchaseBody.yellow;
|
||||
const green = purchaseBody.green;
|
||||
|
||||
const bought = purchaseBody.bought;
|
||||
const stolen = purchaseBody.stolen;
|
||||
|
||||
const winesThisDate = [];
|
||||
|
||||
for (let i = 0; i < winnersBody.length; i++) {
|
||||
let currentWinner = winnersBody[i];
|
||||
|
||||
let wonWine = await Wine.findOne({ name: currentWinner.wine.name });
|
||||
if (wonWine == undefined) {
|
||||
let newWonWine = new Wine({
|
||||
name: currentWinner.wine.name,
|
||||
vivinoLink: currentWinner.wine.vivinoLink,
|
||||
rating: currentWinner.wine.rating,
|
||||
occurences: 1,
|
||||
image: currentWinner.wine.image,
|
||||
id: currentWinner.wine.id
|
||||
});
|
||||
await newWonWine.save();
|
||||
wonWine = newWonWine;
|
||||
} else {
|
||||
wonWine.occurences += 1;
|
||||
wonWine.image = currentWinner.wine.image;
|
||||
wonWine.id = currentWinner.wine.id;
|
||||
await wonWine.save();
|
||||
}
|
||||
|
||||
winesThisDate.push(wonWine);
|
||||
|
||||
const person = await Highscore.findOne({
|
||||
name: currentWinner.name
|
||||
});
|
||||
|
||||
if (person == undefined) {
|
||||
let newPerson = new Highscore({
|
||||
name: currentWinner.name,
|
||||
wins: [
|
||||
{
|
||||
color: currentWinner.color,
|
||||
date: date,
|
||||
wine: wonWine
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await newPerson.save();
|
||||
} else {
|
||||
person.wins.push({
|
||||
color: currentWinner.color,
|
||||
date: date,
|
||||
wine: wonWine
|
||||
});
|
||||
person.markModified("wins");
|
||||
await person.save();
|
||||
}
|
||||
}
|
||||
|
||||
let purchase = new Purchase({
|
||||
date: date,
|
||||
blue: blue,
|
||||
yellow: yellow,
|
||||
red: red,
|
||||
green: green,
|
||||
wines: winesThisDate,
|
||||
bought: bought,
|
||||
stolen: stolen
|
||||
});
|
||||
|
||||
await purchase.save();
|
||||
|
||||
res.send(true);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
90
api/user.js
Normal file
90
api/user.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const passport = require("passport");
|
||||
const path = require("path");
|
||||
const User = require(path.join(__dirname, "/schemas/User"));
|
||||
|
||||
class UserExistsError extends Error {
|
||||
constructor(message = "Username already exists.") {
|
||||
super(message);
|
||||
this.name = "UserExists";
|
||||
this.statusCode = 409;
|
||||
}
|
||||
}
|
||||
|
||||
class MissingUsernameError extends Error {
|
||||
constructor(message = "No username given.") {
|
||||
super(message);
|
||||
this.name = "MissingUsernameError";
|
||||
this.statusCode = 400;
|
||||
}
|
||||
}
|
||||
|
||||
class MissingPasswordError extends Error {
|
||||
constructor(message = "No password given.") {
|
||||
super(message);
|
||||
this.name = "MissingPasswordError";
|
||||
this.statusCode = 400;
|
||||
}
|
||||
}
|
||||
|
||||
class IncorrectUserCredentialsError extends Error {
|
||||
constructor(message = "Incorrect username or password") {
|
||||
super(message);
|
||||
this.name = "IncorrectUserCredentialsError";
|
||||
this.statusCode = 404;
|
||||
}
|
||||
}
|
||||
|
||||
function userAuthenticationErrorHandler(err) {
|
||||
if (err.name == "UserExistsError") {
|
||||
throw new UserExistsError(err.message);
|
||||
} else if (err.name == "MissingUsernameError") {
|
||||
throw new MissingUsernameError(err.message);
|
||||
} else if (err.name == "MissingPasswordError") {
|
||||
throw new MissingPasswordError(err.message);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
const register = (username, password) => {
|
||||
return User.register(new User({ username: username }), password).catch(userAuthenticationErrorHandler);
|
||||
};
|
||||
|
||||
const authenticate = req => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (username == undefined) throw new MissingUsernameError();
|
||||
if (password == undefined) throw new MissingPasswordError();
|
||||
|
||||
passport.authenticate("local", function(err, user, info) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
reject(new IncorrectUserCredentialsError());
|
||||
}
|
||||
|
||||
resolve(user);
|
||||
})(req);
|
||||
});
|
||||
};
|
||||
|
||||
const login = (req, user) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.logIn(user, err => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
|
||||
resolve(user);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
register,
|
||||
authenticate,
|
||||
login
|
||||
};
|
||||
90
api/vinlottisErrors.js
Normal file
90
api/vinlottisErrors.js
Normal file
@@ -0,0 +1,90 @@
|
||||
class UserNotFound extends Error {
|
||||
constructor(message = "User not found.") {
|
||||
super(message);
|
||||
this.name = "UserNotFound";
|
||||
this.statusCode = 404;
|
||||
}
|
||||
|
||||
// TODO log missing user
|
||||
}
|
||||
|
||||
class WineNotFound extends Error {
|
||||
constructor(message = "Wine not found.") {
|
||||
super(message);
|
||||
this.name = "WineNotFound";
|
||||
this.statusCode = 404;
|
||||
}
|
||||
|
||||
// TODO log missing user
|
||||
}
|
||||
|
||||
class WinnerNotFound extends Error {
|
||||
constructor(message = "Winner not found.") {
|
||||
super(message);
|
||||
this.name = "WinnerNotFound";
|
||||
this.statusCode = 404;
|
||||
}
|
||||
|
||||
// TODO log missing user
|
||||
}
|
||||
|
||||
class WinnersTimelimitExpired extends Error {
|
||||
constructor(message = "Timelimit expired, you will need to wait until it's your turn again.") {
|
||||
super(message);
|
||||
this.name = "WinnersTimelimitExpired";
|
||||
this.statusCode = 403;
|
||||
}
|
||||
}
|
||||
|
||||
class WineSelectionWinnerNotNextInLine extends Error {
|
||||
constructor(message = "Not the winner next in line!") {
|
||||
super(message);
|
||||
this.name = "WineSelectionWinnerNotNextInLine";
|
||||
this.statusCode = 403;
|
||||
}
|
||||
|
||||
// TODO log missing user
|
||||
}
|
||||
|
||||
class NoMoreAttendeesToWin extends Error {
|
||||
constructor(message = "No more attendees left to drawn from.") {
|
||||
super(message);
|
||||
this.name = "NoMoreAttendeesToWin";
|
||||
this.statusCode = 404;
|
||||
}
|
||||
}
|
||||
|
||||
class CouldNotFindNewWinnerAfterNTries extends Error {
|
||||
constructor(tries) {
|
||||
let message = `Could not a new winner after ${tries} tries.`;
|
||||
super(message);
|
||||
this.name = "CouldNotFindNewWinnerAfterNTries";
|
||||
this.statusCode = 404;
|
||||
}
|
||||
}
|
||||
|
||||
class LotteryByDateNotFound extends Error {
|
||||
constructor(date) {
|
||||
const ye = new Intl.DateTimeFormat("en", { year: "numeric" }).format(date);
|
||||
const mo = new Intl.DateTimeFormat("en", { month: "2-digit" }).format(date);
|
||||
const da = new Intl.DateTimeFormat("en", { day: "2-digit" }).format(date);
|
||||
|
||||
const dateString = `${ye}-${mo}-${da}`;
|
||||
const dateUnix = date.getTime();
|
||||
const message = `Could not find lottery for date: ${dateString}.`;
|
||||
super(message);
|
||||
this.name = "LotteryByDateNotFoundError";
|
||||
this.statusCode = 404;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UserNotFound,
|
||||
WineNotFound,
|
||||
WinnerNotFound,
|
||||
WinnersTimelimitExpired,
|
||||
WineSelectionWinnerNotNextInLine,
|
||||
NoMoreAttendeesToWin,
|
||||
CouldNotFindNewWinnerAfterNTries,
|
||||
LotteryByDateNotFound
|
||||
};
|
||||
138
api/vinmonopolet.js
Normal file
138
api/vinmonopolet.js
Normal file
@@ -0,0 +1,138 @@
|
||||
const fetch = require("node-fetch");
|
||||
const path = require("path");
|
||||
const config = require(path.join(__dirname + "/../config/env/lottery.config"));
|
||||
const vinmonopoletCache = require(path.join(__dirname, "vinmonopoletCache"));
|
||||
|
||||
const convertToOurWineObject = wine => {
|
||||
if (wine.basic.ageLimit === "18") {
|
||||
return {
|
||||
name: wine.basic.productShortName,
|
||||
vivinoLink: "https://www.vinmonopolet.no/p/" + wine.basic.productId,
|
||||
rating: wine.basic.alcoholContent,
|
||||
occurences: 0,
|
||||
id: wine.basic.productId,
|
||||
year: wine.basic.vintage,
|
||||
image: `https://bilder.vinmonopolet.no/cache/500x500-0/${wine.basic.productId}-1.jpg`,
|
||||
price: wine.prices[0].salesPrice.toString(),
|
||||
country: wine.origins.origin.country
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const convertVinmonopoletProductResponseToWineObject = wine => {
|
||||
return {
|
||||
name: wine.name,
|
||||
vivinoLink: "https://www.vinmonopolet.no" + wine.url,
|
||||
rating: null,
|
||||
occurences: 0,
|
||||
id: wine.code,
|
||||
year: wine.year,
|
||||
image: wine.images[1].url,
|
||||
price: wine.price.value,
|
||||
country: wine.main_country.name
|
||||
}
|
||||
};
|
||||
|
||||
const convertToOurStoreObject = store => {
|
||||
return {
|
||||
id: store.storeId,
|
||||
name: store.storeName,
|
||||
...store.address
|
||||
};
|
||||
};
|
||||
|
||||
const searchWinesByName = (name, page = 1) => {
|
||||
const pageSize = 25;
|
||||
|
||||
return vinmonopoletCache.wineByQueryName(name, page, pageSize)
|
||||
.catch(_ => {
|
||||
console.log(`No wines matching query: ${name} at page ${page} found in elastic index, searching vinmonopolet..`)
|
||||
|
||||
const url = `https://www.vinmonopolet.no/api/search?q=${name}:relevance:visibleInSearch:true&searchType=product&pageSize=${pageSize}¤tPage=${page-1}`
|
||||
const options = {
|
||||
headers: { "Content-Type": 'application/json' }
|
||||
};
|
||||
|
||||
return fetch(url, options)
|
||||
.then(resp => {
|
||||
if (resp.ok == false) {
|
||||
return Promise.reject({
|
||||
statusCode: 404,
|
||||
message: `No wines matching query ${name} at page ${page} found in local cache or at vinmonopolet.`,
|
||||
})
|
||||
}
|
||||
|
||||
return resp.json()
|
||||
.then(response => response?.productSearchResult?.products)
|
||||
})
|
||||
})
|
||||
.then(wines => wines.map(convertVinmonopoletProductResponseToWineObject))
|
||||
};
|
||||
|
||||
const wineByEAN = ean => {
|
||||
const url = `https://app.vinmonopolet.no/vmpws/v2/vmp/products/barCodeSearch/${ean}`;
|
||||
return fetch(url)
|
||||
.then(resp => resp.json())
|
||||
.then(response => response.map(convertToOurWineObject));
|
||||
};
|
||||
|
||||
const wineById = id => {
|
||||
return vinmonopoletCache.wineById(id)
|
||||
.catch(_ => {
|
||||
console.log(`Wine id: ${id} not found in elastic index, searching vinmonopolet..`)
|
||||
|
||||
const url = `https://www.vinmonopolet.no/api/products/${id}?fields=FULL`
|
||||
const options = {
|
||||
headers: {
|
||||
"Content-Type": 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
return fetch(url, options)
|
||||
.then(resp => {
|
||||
if (resp.ok == false) {
|
||||
return Promise.reject({
|
||||
statusCode: 404,
|
||||
message: `Wine with id ${id} not found in local cache or at vinmonopolet.`,
|
||||
})
|
||||
}
|
||||
|
||||
return resp.json()
|
||||
})
|
||||
})
|
||||
.then(wine => convertVinmonopoletProductResponseToWineObject(wine))
|
||||
};
|
||||
|
||||
const allStores = () => {
|
||||
const url = `https://apis.vinmonopolet.no/stores/v0/details`;
|
||||
const options = {
|
||||
headers: {
|
||||
"Ocp-Apim-Subscription-Key": config.vinmonopoletToken
|
||||
}
|
||||
};
|
||||
|
||||
return fetch(url, options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => response.map(convertToOurStoreObject));
|
||||
};
|
||||
|
||||
const searchStoresByName = name => {
|
||||
const url = `https://apis.vinmonopolet.no/stores/v0/details?storeNameContains=${name}`;
|
||||
const options = {
|
||||
headers: {
|
||||
"Ocp-Apim-Subscription-Key": config.vinmonopoletToken
|
||||
}
|
||||
};
|
||||
|
||||
return fetch(url, options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => response.map(convertToOurStoreObject));
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
searchWinesByName,
|
||||
wineByEAN,
|
||||
wineById,
|
||||
allStores,
|
||||
searchStoresByName
|
||||
};
|
||||
98
api/vinmonopoletCache.js
Normal file
98
api/vinmonopoletCache.js
Normal file
@@ -0,0 +1,98 @@
|
||||
const fetch = require("node-fetch");
|
||||
|
||||
const ELASTIC_URL = 'http://localhost:9200';
|
||||
const INDEX_URL = `${ELASTIC_URL}/wines*`;
|
||||
|
||||
const verifyAndUnpackElasticSearchResult = response => {
|
||||
const searchHits = response?.hits?.hits;
|
||||
|
||||
if (searchHits == null || searchHits.length == 0) {
|
||||
return Promise.reject({
|
||||
statusCode: 404,
|
||||
message: `Nothing found in vinmonopolet cache matching this.`,
|
||||
})
|
||||
}
|
||||
|
||||
return searchHits;
|
||||
}
|
||||
|
||||
const getWineObjectFromSearchHit = hit => {
|
||||
const { wine } = hit?._source;
|
||||
|
||||
if (wine == null) {
|
||||
return Promise.reject({
|
||||
statusCode: 500,
|
||||
message: `Found response, but it's missing a wine object. Unable to convert!`,
|
||||
})
|
||||
}
|
||||
|
||||
return wine;
|
||||
}
|
||||
|
||||
const wineById = id => {
|
||||
const url = `${INDEX_URL}/_search`
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
"size": 1,
|
||||
"query": {
|
||||
"match": {
|
||||
"wine.code": id
|
||||
}
|
||||
},
|
||||
"_source": {
|
||||
"includes": "wine"
|
||||
},
|
||||
"sort": [
|
||||
{
|
||||
"@timestamp": "desc"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return fetch(url, options)
|
||||
.then(resp => resp.json())
|
||||
.then(verifyAndUnpackElasticSearchResult)
|
||||
.then(searchHits => getWineObjectFromSearchHit(searchHits[0]))
|
||||
}
|
||||
|
||||
const wineByQueryName = (name, page=1, size=25) => {
|
||||
const url = `${INDEX_URL}/_search`
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', },
|
||||
body: JSON.stringify({
|
||||
"from": page - 1,
|
||||
"size": size,
|
||||
"query": {
|
||||
"multi_match" : {
|
||||
"query" : name,
|
||||
"fields": ["wine.name"],
|
||||
"fuzziness": 2
|
||||
}
|
||||
},
|
||||
"sort": [
|
||||
{
|
||||
"_score": {
|
||||
"order": "desc"
|
||||
}
|
||||
}
|
||||
],
|
||||
"_source": {
|
||||
"includes": "wine"
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
return fetch(url, options)
|
||||
.then(resp => resp.json())
|
||||
.then(verifyAndUnpackElasticSearchResult)
|
||||
.then(searchHits => Promise.all(searchHits.map(getWineObjectFromSearchHit)))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
wineById,
|
||||
wineByQueryName
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
const router = express.Router();
|
||||
const mongoose = require("mongoose");
|
||||
mongoose.connect("mongodb://localhost:27017/vinlottis", {
|
||||
useNewUrlParser: true
|
||||
});
|
||||
let io;
|
||||
const mustBeAuthenticated = require(path.join(
|
||||
__dirname + "/../middleware/mustBeAuthenticated"
|
||||
));
|
||||
|
||||
const Attendee = require(path.join(__dirname + "/../schemas/Attendee"));
|
||||
const VirtualWinner = require(path.join(
|
||||
__dirname + "/../schemas/VirtualWinner"
|
||||
));
|
||||
|
||||
router.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
router.route("/winners").delete(mustBeAuthenticated, async (req, res) => {
|
||||
await VirtualWinner.deleteMany();
|
||||
io.emit("refresh_data", {});
|
||||
res.json(true);
|
||||
});
|
||||
|
||||
router.route("/attendees").delete(mustBeAuthenticated, async (req, res) => {
|
||||
await Attendee.deleteMany();
|
||||
io.emit("refresh_data", {});
|
||||
res.json(true);
|
||||
});
|
||||
|
||||
router.route("/winners").get(async (req, res) => {
|
||||
let winners = await VirtualWinner.find();
|
||||
let winnersRedacted = [];
|
||||
let winner;
|
||||
for (let i = 0; i < winners.length; i++) {
|
||||
winner = winners[i];
|
||||
winnersRedacted.push({
|
||||
name: winner.name,
|
||||
color: winner.color
|
||||
});
|
||||
}
|
||||
res.json(winnersRedacted);
|
||||
});
|
||||
|
||||
router.route("/winners/secure").get(mustBeAuthenticated, async (req, res) => {
|
||||
let winners = await VirtualWinner.find();
|
||||
|
||||
res.json(winners);
|
||||
});
|
||||
|
||||
router.route("/winner").get(mustBeAuthenticated, async (req, res) => {
|
||||
let allContestants = await Attendee.find({ winner: false });
|
||||
if (allContestants.length == 0) {
|
||||
res.json(false);
|
||||
return;
|
||||
}
|
||||
let ballotColors = [];
|
||||
for (let i = 0; i < allContestants.length; i++) {
|
||||
let currentContestant = allContestants[i];
|
||||
for (let blue = 0; blue < currentContestant.blue; blue++) {
|
||||
ballotColors.push("blue");
|
||||
}
|
||||
for (let red = 0; red < currentContestant.red; red++) {
|
||||
ballotColors.push("red");
|
||||
}
|
||||
for (let green = 0; green < currentContestant.green; green++) {
|
||||
ballotColors.push("green");
|
||||
}
|
||||
for (let yellow = 0; yellow < currentContestant.yellow; yellow++) {
|
||||
ballotColors.push("yellow");
|
||||
}
|
||||
}
|
||||
|
||||
ballotColors = shuffle(ballotColors);
|
||||
|
||||
let colorToChooseFrom =
|
||||
ballotColors[Math.floor(Math.random() * ballotColors.length)];
|
||||
let findObject = { winner: false };
|
||||
|
||||
findObject[colorToChooseFrom] = { $gt: 0 };
|
||||
|
||||
let tries = 0;
|
||||
const maxTries = 3;
|
||||
let contestantsToChooseFrom = undefined;
|
||||
while (contestantsToChooseFrom == undefined && tries < maxTries) {
|
||||
const hit = await Attendee.find(findObject);
|
||||
if (hit && hit.length) {
|
||||
contestantsToChooseFrom = hit;
|
||||
break;
|
||||
}
|
||||
tries++;
|
||||
}
|
||||
if (contestantsToChooseFrom == undefined) {
|
||||
return res.status(404).send({
|
||||
success: false,
|
||||
message: `Klarte ikke trekke en vinner etter ${maxTries} forsøk.`
|
||||
});
|
||||
}
|
||||
|
||||
let attendeeListDemocratic = [];
|
||||
|
||||
let currentContestant;
|
||||
for (let i = 0; i < contestantsToChooseFrom.length; i++) {
|
||||
currentContestant = contestantsToChooseFrom[i];
|
||||
for (let y = 0; y < currentContestant[colorToChooseFrom]; y++) {
|
||||
attendeeListDemocratic.push({
|
||||
name: currentContestant.name,
|
||||
phoneNumber: currentContestant.phoneNumber,
|
||||
red: currentContestant.red,
|
||||
blue: currentContestant.blue,
|
||||
green: currentContestant.green,
|
||||
yellow: currentContestant.yellow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
attendeeListDemocratic = shuffle(attendeeListDemocratic);
|
||||
|
||||
let winner =
|
||||
attendeeListDemocratic[
|
||||
Math.floor(Math.random() * attendeeListDemocratic.length)
|
||||
];
|
||||
|
||||
io.emit("winner", { color: colorToChooseFrom, name: winner.name });
|
||||
|
||||
let newWinnerElement = new VirtualWinner({
|
||||
name: winner.name,
|
||||
phoneNumber: winner.phoneNumber,
|
||||
color: colorToChooseFrom,
|
||||
red: winner.red,
|
||||
blue: winner.blue,
|
||||
green: winner.green,
|
||||
yellow: winner.yellow
|
||||
});
|
||||
|
||||
await Attendee.update(
|
||||
{ name: winner.name, phoneNumber: winner.phoneNumber },
|
||||
{ $set: { winner: true } }
|
||||
);
|
||||
|
||||
await newWinnerElement.save();
|
||||
res.json(winner);
|
||||
});
|
||||
|
||||
router.route("/attendees").get(async (req, res) => {
|
||||
let attendees = await Attendee.find();
|
||||
let attendeesRedacted = [];
|
||||
let attendee;
|
||||
for (let i = 0; i < attendees.length; i++) {
|
||||
attendee = attendees[i];
|
||||
attendeesRedacted.push({
|
||||
name: attendee.name,
|
||||
ballots: attendee.red + attendee.blue + attendee.yellow + attendee.green,
|
||||
red: attendee.red,
|
||||
blue: attendee.blue,
|
||||
green: attendee.green,
|
||||
yellow: attendee.yellow
|
||||
});
|
||||
}
|
||||
res.json(attendeesRedacted);
|
||||
});
|
||||
|
||||
router.route("/attendees/secure").get(mustBeAuthenticated, async (req, res) => {
|
||||
let attendees = await Attendee.find();
|
||||
|
||||
res.json(attendees);
|
||||
});
|
||||
|
||||
router.route("/attendee").post(mustBeAuthenticated, async (req, res) => {
|
||||
const attendee = req.body;
|
||||
const { red, blue, yellow, green } = attendee;
|
||||
|
||||
let newAttendee = new Attendee({
|
||||
name: attendee.name,
|
||||
red,
|
||||
blue,
|
||||
green,
|
||||
yellow,
|
||||
phoneNumber: attendee.phoneNumber,
|
||||
winner: false
|
||||
});
|
||||
await newAttendee.save();
|
||||
|
||||
io.emit("new_attendee", {});
|
||||
|
||||
res.send(true);
|
||||
});
|
||||
|
||||
function shuffle(array) {
|
||||
let currentIndex = array.length,
|
||||
temporaryValue,
|
||||
randomIndex;
|
||||
|
||||
// While there remain elements to shuffle...
|
||||
while (0 !== currentIndex) {
|
||||
// Pick a remaining element...
|
||||
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex -= 1;
|
||||
|
||||
// And swap it with the current element.
|
||||
temporaryValue = array[currentIndex];
|
||||
array[currentIndex] = array[randomIndex];
|
||||
array[randomIndex] = temporaryValue;
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
module.exports = function(_io) {
|
||||
io = _io;
|
||||
return router;
|
||||
};
|
||||
63
api/wine.js
Normal file
63
api/wine.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const path = require("path");
|
||||
const Wine = require(path.join(__dirname, "/schemas/Wine"));
|
||||
|
||||
const { WineNotFound } = require(path.join(__dirname, "/vinlottisErrors"));
|
||||
|
||||
const addWine = async wine => {
|
||||
let existingWine = await Wine.findOne({ name: wine.name, id: wine.id, year: wine.year });
|
||||
|
||||
if (existingWine == undefined) {
|
||||
let newWine = new Wine({
|
||||
name: wine.name,
|
||||
vivinoLink: wine.vivinoLink,
|
||||
rating: wine.rating,
|
||||
occurences: 1,
|
||||
id: wine.id,
|
||||
year: wine.year,
|
||||
image: wine.image,
|
||||
price: wine.price,
|
||||
country: wine.country
|
||||
});
|
||||
await newWine.save();
|
||||
return newWine;
|
||||
} else {
|
||||
existingWine.occurences += 1;
|
||||
await existingWine.save();
|
||||
return existingWine;
|
||||
}
|
||||
};
|
||||
|
||||
const allWines = (limit = undefined) => {
|
||||
if (limit) {
|
||||
return Wine.find().limit(limit);
|
||||
} else {
|
||||
return Wine.find();
|
||||
}
|
||||
};
|
||||
|
||||
const wineById = id => {
|
||||
return Wine.findOne({ _id: id }).then(wine => {
|
||||
if (wine == null) {
|
||||
throw new WineNotFound();
|
||||
}
|
||||
|
||||
return wine;
|
||||
});
|
||||
};
|
||||
|
||||
const findWine = wine => {
|
||||
return Wine.findOne({ name: wine.name, id: wine.id, year: wine.year }).then(wine => {
|
||||
if (wine == null) {
|
||||
throw new WineNotFound();
|
||||
}
|
||||
|
||||
return wine;
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
addWine,
|
||||
allWines,
|
||||
wineById,
|
||||
findWine
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
const router = express.Router();
|
||||
const fetch = require('node-fetch')
|
||||
|
||||
const mustBeAuthenticated = require(path.join(__dirname + "/../middleware/mustBeAuthenticated"))
|
||||
|
||||
router.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
router.route("/wineinfo/:ean").get(async (req, res) => {
|
||||
const vinmonopoletResponse = await fetch("https://app.vinmonopolet.no/vmpws/v2/vmp/products/barCodeSearch/" + req.params.ean)
|
||||
.then(resp => resp.json())
|
||||
|
||||
if (vinmonopoletResponse.errors != null) {
|
||||
return vinmonopoletResponse.errors.map(error => {
|
||||
if (error.type == "UnknownProductError") {
|
||||
return res.status(404).json({
|
||||
message: error.message
|
||||
})
|
||||
} else {
|
||||
return next()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
res.send(vinmonopoletResponse);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
107
api/winner.js
Normal file
107
api/winner.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const path = require("path");
|
||||
|
||||
const VirtualWinner = require(path.join(__dirname, "/schemas/VirtualWinner"));
|
||||
const { WinnerNotFound } = require(path.join(__dirname, "/vinlottisErrors"));
|
||||
|
||||
const redactWinnerInfoMapper = winner => {
|
||||
return {
|
||||
name: winner.name,
|
||||
color: winner.color
|
||||
};
|
||||
};
|
||||
|
||||
const addWinner = winner => {
|
||||
let newWinner = new VirtualWinner({
|
||||
name: winner.name,
|
||||
color: winner.color,
|
||||
timestamp_drawn: new Date().getTime()
|
||||
});
|
||||
|
||||
return newWinner.save()
|
||||
}
|
||||
|
||||
const addWinners = winners => {
|
||||
return Promise.all(
|
||||
winners.map(winner => addWinner(winner))
|
||||
);
|
||||
};
|
||||
|
||||
const allWinners = (isAdmin = false) => {
|
||||
const sortQuery = { timestamp_drawn: 1 };
|
||||
|
||||
if (!isAdmin) {
|
||||
return VirtualWinner.find()
|
||||
.sort(sortQuery)
|
||||
.then(winners => winners.map(redactWinnerInfoMapper));
|
||||
} else {
|
||||
return VirtualWinner.find().sort(sortQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const winnerById = (id, isAdmin = false) => {
|
||||
return VirtualWinner.findOne({ id: id }).then(winner => {
|
||||
if (winner == null) {
|
||||
throw new WinnerNotFound();
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return redactWinnerInfoMapper(winner);
|
||||
}
|
||||
return winner;
|
||||
});
|
||||
};
|
||||
|
||||
const setWinnerChosenById = (id) => {
|
||||
return VirtualWinner.findOne({id: id}).then(winner => {
|
||||
winner.prize_selected = true
|
||||
winner.markModified("wins")
|
||||
return winner.save()
|
||||
})
|
||||
}
|
||||
|
||||
const updateWinnerById = (id, updateModel) => {
|
||||
return VirtualWinner.findOne({ id: id }).then(winner => {
|
||||
if (winner == null) {
|
||||
throw new WinnerNotFound();
|
||||
}
|
||||
|
||||
const updatedWinner = {
|
||||
name: updateModel.name != null ? updateModel.name : winner.name,
|
||||
phoneNumber: updateModel.phoneNumber != null ? updateModel.phoneNumber : winner.phoneNumber,
|
||||
red: updateModel.red != null ? updateModel.red : winner.red,
|
||||
green: updateModel.green != null ? updateModel.green : winner.green,
|
||||
blue: updateModel.blue != null ? updateModel.blue : winner.blue,
|
||||
yellow: updateModel.yellow != null ? updateModel.yellow : winner.yellow,
|
||||
timestamp_drawn: updateModel.timestamp_drawn != null ? updateModel.timestamp_drawn : winner.timestamp_drawn,
|
||||
timestamp_limit: updateModel.timestamp_limit != null ? updateModel.timestamp_limit : winner.timestamp_limit,
|
||||
timestamp_sent: updateModel.timestamp_sent != null ? updateModel.timestamp_sent : winner.timestamp_sent
|
||||
};
|
||||
|
||||
return VirtualWinner.updateOne({ id: id }, updatedWinner).then(_ => updatedWinner);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteWinnerById = id => {
|
||||
return VirtualWinner.findOne({ id: id }).then(winner => {
|
||||
if (winner == null) {
|
||||
throw new WinnerNotFound();
|
||||
}
|
||||
|
||||
return VirtualWinner.deleteOne({ id: id }).then(_ => winner);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteWinners = () => {
|
||||
return VirtualWinner.deleteMany();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
addWinner,
|
||||
addWinners,
|
||||
allWinners,
|
||||
winnerById,
|
||||
updateWinnerById,
|
||||
deleteWinnerById,
|
||||
deleteWinners,
|
||||
setWinnerChosenById
|
||||
};
|
||||
@@ -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,6 +11,6 @@ try {
|
||||
message: "INSERT MESSAGE",
|
||||
date: 5,
|
||||
hours: 15,
|
||||
apiUrl: "https://lottis.vin"
|
||||
gatewayToken: "asd"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
6
config/env/lottery.config.example.js
vendored
6
config/env/lottery.config.example.js
vendored
@@ -5,5 +5,9 @@ module.exports = {
|
||||
message: "VINLOTTERI",
|
||||
date: 5,
|
||||
hours: 15,
|
||||
apiUrl: undefined
|
||||
gatewayToken: undefined,
|
||||
vinmonopoletToken: undefined,
|
||||
googleanalytics_trackingId: undefined,
|
||||
googleanalytics_cookieLifetime: 60 * 60 * 24 * 14,
|
||||
sites: [],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -11,9 +11,15 @@ 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: [
|
||||
@@ -25,63 +31,62 @@ const webpackConfig = function(isDev) {
|
||||
options: {
|
||||
loaders: {
|
||||
scss: "vue-style-loader!css-loader!sass-loader",
|
||||
sass: "vue-style-loader!css-loader!sass-loader?indentedSyntax"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
sass: "vue-style-loader!css-loader!sass-loader?indentedSyntax",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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,
|
||||
{ loader: "css-loader", options: { sourceMap: isDev } }
|
||||
]
|
||||
MiniCSSExtractPlugin.loader,
|
||||
{ loader: "css-loader", options: { sourceMap: isDev } },
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
use: [
|
||||
isDev ? "vue-style-loader" : MiniCSSExtractPlugin.loader,
|
||||
MiniCSSExtractPlugin.loader,
|
||||
{ loader: "css-loader", options: { sourceMap: isDev } },
|
||||
{ loader: "sass-loader", options: { sourceMap: isDev } }
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.sass$/,
|
||||
use: [
|
||||
isDev ? "vue-style-loader" : MiniCSSExtractPlugin.loader,
|
||||
{ loader: "css-loader", options: { sourceMap: isDev } },
|
||||
{ loader: "sass-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]+)?$/,
|
||||
loader: "file-loader"
|
||||
}
|
||||
]
|
||||
loader: "file-loader",
|
||||
},
|
||||
],
|
||||
},
|
||||
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,
|
||||
__sites__: JSON.stringify(env.sites),
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,54 +1,66 @@
|
||||
"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"
|
||||
},
|
||||
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",
|
||||
disableHostCheck: true,
|
||||
hot: true,
|
||||
overlay: true,
|
||||
stats: {
|
||||
normal: true
|
||||
}
|
||||
}
|
||||
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",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
module.exports = webpackConfig;
|
||||
|
||||
@@ -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;
|
||||
|
||||
78
db/seedSingleDay.js
Normal file
78
db/seedSingleDay.js
Normal file
@@ -0,0 +1,78 @@
|
||||
|
||||
|
||||
const session = require("express-session");
|
||||
const mongoose = require("mongoose");
|
||||
const MongoStore = require("connect-mongo")(session);
|
||||
mongoose.promise = global.Promise;
|
||||
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);
|
||||
|
||||
const path = require("path")
|
||||
const prelotteryWineRepository = require(path.join(__dirname, "../api/prelotteryWine"));
|
||||
const attendeeRepository = require(path.join(__dirname, "../api/attendee"));
|
||||
|
||||
async function add() {
|
||||
const wines = [
|
||||
{
|
||||
vivinoLink: 'https://www.vinmonopolet.no/Land/Frankrike/Devevey-Bourgogne-Hautes-C%C3%B4tes-de-Beaune-Rouge-2018/p/12351301',
|
||||
name: 'Devevey Bourgogne Hautes-Côtes de Beaune Rouge 2018',
|
||||
rating: 3,
|
||||
id: '12351301',
|
||||
year: 2018,
|
||||
image: "https://bilder.vinmonopolet.no/cache/300x300-0/12351301-1.jpg",
|
||||
price: '370',
|
||||
country: "Frankrike"
|
||||
},
|
||||
{
|
||||
vivinoLink: 'https://www.vinmonopolet.no/Land/Frankrike/Devevey-Rully-La-Chaume-Rouge-2018/p/12351101',
|
||||
name: 'Devevey Rully La Chaume Rouge 2018',
|
||||
rating: 4,
|
||||
id: '12351101',
|
||||
year: 2018,
|
||||
image: 'https://bilder.vinmonopolet.no/cache/300x300-0/12351101-1.jpg',
|
||||
price: '372',
|
||||
country: 'Frankrike'
|
||||
}
|
||||
]
|
||||
|
||||
const attendees = [
|
||||
{
|
||||
name: "Kasper Rynning-Tønnesen",
|
||||
red: 0,
|
||||
blue: 10,
|
||||
green: 0,
|
||||
yellow: 0,
|
||||
phoneNumber: 97777777,
|
||||
winner: false
|
||||
},
|
||||
{
|
||||
name: "Kevin Midbøe",
|
||||
red: 3,
|
||||
blue: 3,
|
||||
green: 3,
|
||||
yellow: 3,
|
||||
phoneNumber: 95012321,
|
||||
winner: false
|
||||
}
|
||||
]
|
||||
|
||||
await prelotteryWineRepository.addWines(wines)
|
||||
await Promise.all(attendees.map(attendee => attendeeRepository.addAttendee(attendee)))
|
||||
|
||||
console.log("Added some wines, and 2 attendees to database.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
add()
|
||||
102
frontend/Vinlottis.vue
Normal file
102
frontend/Vinlottis.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<banner :routes="routes" />
|
||||
<router-view />
|
||||
<Footer />
|
||||
<UpdateToast v-if="showToast" :text="toastText" :refreshButton="refreshToast" v-on:closeToast="closeToast" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ServiceWorkerMixin from "@/mixins/ServiceWorkerMixin";
|
||||
import banner from "@/ui/Banner";
|
||||
import Footer from "@/ui/Footer";
|
||||
import UpdateToast from "@/ui/UpdateToast";
|
||||
|
||||
export default {
|
||||
name: "vinlottis",
|
||||
components: { banner, UpdateToast, Footer },
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
showToast: false,
|
||||
toastText: null,
|
||||
refreshToast: false,
|
||||
routes: [
|
||||
{
|
||||
name: "Virtuelt lotteri",
|
||||
route: "/lottery",
|
||||
},
|
||||
{
|
||||
name: "Dagens viner",
|
||||
route: "/dagens/",
|
||||
},
|
||||
{
|
||||
name: "Highscore",
|
||||
route: "/highscore",
|
||||
},
|
||||
{
|
||||
name: "Historie",
|
||||
route: "/history/",
|
||||
},
|
||||
{
|
||||
name: "Foreslå vin",
|
||||
route: "/request",
|
||||
},
|
||||
{
|
||||
name: "Foreslåtte viner",
|
||||
route: "/requested-wines",
|
||||
},
|
||||
{
|
||||
name: "Login",
|
||||
route: "/login",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
console.log("SNEAKY PETE!");
|
||||
this.$on("service-worker-updated", () => {
|
||||
this.toastText = "Det er ny oppdatering av siden, vil du oppdatere?";
|
||||
this.showToast = true;
|
||||
this.refreshToast = true;
|
||||
});
|
||||
this.$on("push-allowed", () => {
|
||||
this.toastText = "Push-notifications er skrudd på!";
|
||||
this.refreshToast = false;
|
||||
this.showToast = true;
|
||||
});
|
||||
},
|
||||
computed: {},
|
||||
mixins: [ServiceWorkerMixin],
|
||||
methods: {
|
||||
closeToast: function() {
|
||||
this.showToast = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "styles/global.scss";
|
||||
@import "styles/positioning.scss";
|
||||
@import "styles/vinlottis-icons";
|
||||
|
||||
body {
|
||||
background-color: $primary;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-container {
|
||||
background-color: white;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 80px auto 100px;
|
||||
|
||||
.main-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
367
frontend/api.js
Normal file
367
frontend/api.js
Normal file
@@ -0,0 +1,367 @@
|
||||
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";
|
||||
const getWinesFromBody = (resp) => resp.json().then(body => body.wines);
|
||||
return Promise.all([getWinesFromBody(resp), 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"
|
||||
};
|
||||
|
||||
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 = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ wine })
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
208
frontend/components/AccessCodePage.vue
Normal file
208
frontend/components/AccessCodePage.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="floating-video">
|
||||
<video autoplay loop muted playsinline id="office-party" ref="video">
|
||||
<source src="/public/assets/videos/office-party.mp4" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="container--code label-div row">
|
||||
<label>Din vinlottis kode:</label>
|
||||
</div>
|
||||
|
||||
<div class="codeinput-container">
|
||||
<input v-model="code" placeholder="KODE" @keyup.enter="submit" />
|
||||
<button class="vin-button" @click="submit">ENTER</button>
|
||||
</div>
|
||||
|
||||
<button class="mute-button" @click="toggleMute">
|
||||
{{ muted ? "🔇" : "🔈" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Footer from "@/ui/FooterUnbranded";
|
||||
import { createCookie } from "@/utils";
|
||||
|
||||
export default {
|
||||
components: { Footer },
|
||||
data() {
|
||||
return {
|
||||
muted: true,
|
||||
code: undefined,
|
||||
// volume: 50,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
const site = __sites__.find(site => site.code == this.code);
|
||||
},
|
||||
// watch: {
|
||||
// volume(newValue) {
|
||||
// this.$refs.video.volume = newValue / 100;
|
||||
// },
|
||||
// },
|
||||
methods: {
|
||||
toggleMute() {
|
||||
const { video } = this.$refs;
|
||||
this.muted = !this.muted;
|
||||
video.muted = this.muted;
|
||||
},
|
||||
togglePlayback() {
|
||||
const { video } = this.$refs;
|
||||
video.paused ? video.play() : video.pause();
|
||||
},
|
||||
submit() {
|
||||
const site = __sites__.find(site => site.code == this.code);
|
||||
|
||||
if (site) {
|
||||
createCookie("accesscode", site.code, 14);
|
||||
window.location.href = `${window.location.protocol}//${site.domain}`;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/media-queries";
|
||||
|
||||
.floating-video {
|
||||
position: absolute;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow-x: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: var(--primary);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.mute-button {
|
||||
z-index: 10;
|
||||
-webkit-appearance: unset;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
font-size: 1.5rem;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: calc(75px + 1rem);
|
||||
cursor: pointer;
|
||||
|
||||
input[type="range"] {
|
||||
transform: rotate(90deg);
|
||||
background-color: red;
|
||||
}
|
||||
}
|
||||
|
||||
video {
|
||||
position: absolute;
|
||||
display: block;
|
||||
// left: 0;
|
||||
height: 100%;
|
||||
|
||||
// -o-filter: blur(1px);
|
||||
filter: blur(5px);
|
||||
object-fit: cover;
|
||||
transform: scale(1.02);
|
||||
|
||||
@include mobile {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.codeinput-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
@include mobile {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
input {
|
||||
max-width: 24rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
|
||||
font-size: 4rem;
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
background-color: white;
|
||||
|
||||
@include mobile {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
height: 100%;
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 80px);
|
||||
margin: auto;
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
justify-content: center;
|
||||
|
||||
@include desktop {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
position: relative;
|
||||
// text-align: center;
|
||||
font-weight: 600;
|
||||
// color: white;
|
||||
|
||||
@include desktop {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--code {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
width: 40%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-line {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
margin-top: 2.4rem;
|
||||
|
||||
@include mobile {
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.button-container {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
</style>
|
||||
72
frontend/components/AdminPage.vue
Normal file
72
frontend/components/AdminPage.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div>
|
||||
<Tabs :tabs="tabs" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Tabs from "@/ui/Tabs";
|
||||
import RegisterWinePage from "@/components/admin/RegisterWinePage";
|
||||
import archiveLotteryPage from "@/components/admin/archiveLotteryPage";
|
||||
import registerAttendeePage from "@/components/admin/registerAttendeePage";
|
||||
import DrawWinnerPage from "@/components/admin/DrawWinnerPage";
|
||||
import PushPage from "@/components/admin/PushPage";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Tabs
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tabs: [
|
||||
{
|
||||
name: "Vin",
|
||||
component: RegisterWinePage,
|
||||
slug: "vin",
|
||||
counter: null
|
||||
},
|
||||
{
|
||||
name: "Legg til deltakere",
|
||||
component: registerAttendeePage,
|
||||
slug: "attendees",
|
||||
counter: null
|
||||
},
|
||||
{
|
||||
name: "Trekk vinner",
|
||||
component: DrawWinnerPage,
|
||||
slug: "draw",
|
||||
counter: null
|
||||
},
|
||||
{
|
||||
name: "Arkiver lotteri",
|
||||
component: archiveLotteryPage,
|
||||
slug: "reg",
|
||||
counter: null
|
||||
},
|
||||
{
|
||||
name: "Push meldinger",
|
||||
component: PushPage,
|
||||
slug: "push"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "@/styles/media-queries";
|
||||
|
||||
.page-container {
|
||||
padding: 0 1.5rem 3rem;
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
max-width: 60vw;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
102
frontend/components/AllRequestedWines.vue
Normal file
102
frontend/components/AllRequestedWines.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<main class="container">
|
||||
<div class="header">
|
||||
<h1>Alle foreslåtte viner</h1>
|
||||
<router-link class="vin-button" to="/anbefal">
|
||||
Anbefal ny vin
|
||||
<i class="icon icon--arrow-right"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<section class="wines-container">
|
||||
<p v-if="wines == undefined || wines.length == 0">
|
||||
Ingen har foreslått noe enda!
|
||||
</p>
|
||||
|
||||
<RequestedWineCard
|
||||
v-for="requestedWine in wines"
|
||||
:key="requestedWine.wine._id"
|
||||
:requestedElement="requestedWine"
|
||||
@wineDeleted="filterOutDeletedWine"
|
||||
:showDeleteButton="isAdmin"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RequestedWineCard from "@/ui/RequestedWineCard";
|
||||
export default {
|
||||
components: {
|
||||
RequestedWineCard,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
wines: undefined,
|
||||
isAdmin: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.fetchRequestedWines();
|
||||
},
|
||||
methods: {
|
||||
filterOutDeletedWine(wine) {
|
||||
this.wines = this.wines.filter((item) => item.wine._id !== wine._id);
|
||||
},
|
||||
fetchRequestedWines() {
|
||||
return fetch("/api/requests")
|
||||
.then((resp) => {
|
||||
this.isAdmin = resp.headers.get("vinlottis-admin") == "true";
|
||||
return resp;
|
||||
})
|
||||
.then((resp) => resp.json())
|
||||
.then((response) => (this.wines = response.wines));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/media-queries.scss";
|
||||
@import "@/styles/variables.scss";
|
||||
|
||||
.container {
|
||||
width: 90vw;
|
||||
margin: 3rem auto;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-family: "knowit";
|
||||
color: $matte-text-color;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
|
||||
a {
|
||||
align-self: flex-end;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.vin-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: calc(4rem - 20px);
|
||||
}
|
||||
|
||||
a .icon {
|
||||
margin-left: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
118
frontend/components/AllWinesPage.vue
Normal file
118
frontend/components/AllWinesPage.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1 class="">Alle viner</h1>
|
||||
|
||||
<div class="wines-container">
|
||||
<Wine :wine="wine" v-for="(wine, _, index) in wines" :key="wine._id">
|
||||
<div class="winners-container">
|
||||
<span class="label">Vinnende lodd:</span>
|
||||
<div class="flex row">
|
||||
<span class="raffle-element blue-raffle">{{ wine.blue == null ? 0 : wine.blue }}</span>
|
||||
<span class="raffle-element red-raffle">{{ wine.red == null ? 0 : wine.red }}</span>
|
||||
<span class="raffle-element green-raffle">{{ wine.green == null ? 0 : wine.green }}</span>
|
||||
<span class="raffle-element yellow-raffle">{{ wine.yellow == null ? 0 : wine.yellow }}</span>
|
||||
</div>
|
||||
|
||||
<div class="name-wins">
|
||||
<span class="label">Vunnet av:</span>
|
||||
<ul class="names">
|
||||
<li v-for="(winner, index) in wine.winners">
|
||||
<router-link class="vin-link" :to="`/highscore/` + winner">{{ winner }}</router-link>
|
||||
-
|
||||
<router-link class="vin-link" :to="winDateUrl(wine.dates[index])">{{
|
||||
dateString(wine.dates[index])
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Wine>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Wine from "@/ui/Wine";
|
||||
import { dateString } from "@/utils";
|
||||
|
||||
export default {
|
||||
components: { Wine },
|
||||
data() {
|
||||
return {
|
||||
wines: []
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.overallWineStatistics();
|
||||
},
|
||||
methods: {
|
||||
winDateUrl(date) {
|
||||
const timestamp = new Date(date).getTime();
|
||||
return `/history/${timestamp}`;
|
||||
},
|
||||
overallWineStatistics() {
|
||||
return fetch("/api/wines")
|
||||
.then(resp => resp.json())
|
||||
.then(response => (this.wines = response.wines));
|
||||
},
|
||||
dateString: dateString
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/media-queries";
|
||||
@import "@/styles/variables";
|
||||
|
||||
.container {
|
||||
width: 90vw;
|
||||
margin: 3rem auto;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-family: "knowit";
|
||||
font-weight: normal;
|
||||
|
||||
font-family: knowit, Arial;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.name-wins {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
a {
|
||||
font-weight: normal;
|
||||
&:not(:hover) {
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 0;
|
||||
|
||||
li {
|
||||
padding-left: 1.5rem;
|
||||
list-style: none;
|
||||
|
||||
&:before {
|
||||
content: "- ";
|
||||
margin-left: -0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.raffle-element {
|
||||
padding: 1rem;
|
||||
font-size: 1.3rem;
|
||||
margin: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -5,15 +5,14 @@
|
||||
Velg hvilke farger du vil ha, fyll inn antall lodd og klikk 'generer'
|
||||
</p>
|
||||
|
||||
<RaffleGenerator @numberOfBallots="val => this.numberOfBallots = val" />
|
||||
<RaffleGenerator @numberOfRaffles="val => (this.numberOfRaffles = val)" />
|
||||
|
||||
<Vipps class="vipps" :amount="numberOfBallots" />
|
||||
<Vipps class="vipps" :amount="numberOfRaffles" />
|
||||
<Countdown :hardEnable="hardStart" @countdown="changeEnabled" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { page, event } from "vue-analytics";
|
||||
import RaffleGenerator from "@/ui/RaffleGenerator";
|
||||
import Vipps from "@/ui/Vipps";
|
||||
import Countdown from "@/ui/Countdown";
|
||||
@@ -27,7 +26,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
hardStart: false,
|
||||
numberOfBallots: null
|
||||
numberOfRaffles: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -44,16 +43,16 @@ export default {
|
||||
this.hardStart = true;
|
||||
},
|
||||
track() {
|
||||
this.$ga.page("/lottery/generate");
|
||||
window.ga("send", "pageview", "/lottery/generate");
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../styles/variables.scss";
|
||||
@import "../styles/global.scss";
|
||||
@import "../styles/media-queries.scss";
|
||||
@import "@/styles/variables.scss";
|
||||
@import "@/styles/global.scss";
|
||||
@import "@/styles/media-queries.scss";
|
||||
h1 {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -68,7 +67,9 @@ p {
|
||||
}
|
||||
|
||||
.vipps {
|
||||
margin: 5rem auto 2.5rem auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 4rem;
|
||||
|
||||
@include mobile {
|
||||
margin-top: 2rem;
|
||||
@@ -76,7 +77,6 @@ p {
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
180
frontend/components/HighscorePage.vue
Normal file
180
frontend/components/HighscorePage.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>Vinlottis highscore</h1>
|
||||
|
||||
<div class="filter flex el-spacing">
|
||||
<input type="text" v-model="filterInput" placeholder="Filtrer på navn" />
|
||||
<button v-if="filterInput" @click="resetFilter" class="vin-button auto-height margin-left-sm">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="highscore-header margin-bottom-md"><b>Plassering.</b> Navn - Antall vinn</p>
|
||||
|
||||
<ol v-if="highscore.length > 0" class="highscore-list">
|
||||
<li
|
||||
v-for="person in filteredResults"
|
||||
@click="goToWinner(person)"
|
||||
@keydown.enter="goToWinner(person)"
|
||||
tabindex="0"
|
||||
>
|
||||
<b>{{ person.rank }}.</b> {{ person.name }} - {{ person.wins.length }}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="center desktop-only">
|
||||
👈 Se dine vin(n), trykk på navnet ditt
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { humanReadableDate, daysAgo } from "@/utils";
|
||||
import Wine from "@/ui/Wine";
|
||||
|
||||
export default {
|
||||
components: { Wine },
|
||||
data() {
|
||||
return {
|
||||
highscore: [],
|
||||
filterInput: ""
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
const winners = await this.highscoreStatistics();
|
||||
this.highscore = this.generateScoreBoard(winners);
|
||||
},
|
||||
computed: {
|
||||
filteredResults() {
|
||||
let highscore = this.highscore;
|
||||
let val = this.filterInput;
|
||||
|
||||
if (val.length) {
|
||||
val = val.toLowerCase();
|
||||
const nameIncludesString = person => person.name.toLowerCase().includes(val);
|
||||
highscore = highscore.filter(nameIncludesString);
|
||||
}
|
||||
|
||||
return highscore;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
highscoreStatistics() {
|
||||
return fetch("/api/history/by-wins")
|
||||
.then(resp => resp.json())
|
||||
.then(response => response.winners);
|
||||
},
|
||||
generateScoreBoard(highscore = this.highscore) {
|
||||
let place = 0;
|
||||
let highestWinCount = -1;
|
||||
|
||||
return highscore.map(win => {
|
||||
const wins = win.wins.length;
|
||||
if (wins != highestWinCount) {
|
||||
place += 1;
|
||||
highestWinCount = wins;
|
||||
}
|
||||
|
||||
const placeString = place.toString().padStart(2, "0");
|
||||
win.rank = placeString;
|
||||
return win;
|
||||
});
|
||||
},
|
||||
resetFilter() {
|
||||
this.filterInput = "";
|
||||
document.getElementsByTagName("input")[0].focus();
|
||||
},
|
||||
goToWinner(winner) {
|
||||
const path = "/highscore/" + encodeURIComponent(winner.name);
|
||||
this.$router.push(path);
|
||||
},
|
||||
humanReadableDate: humanReadableDate,
|
||||
daysAgo: daysAgo
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/media-queries.scss";
|
||||
@import "@/styles/variables.scss";
|
||||
$elementSpacing: 3.5rem;
|
||||
|
||||
.el-spacing {
|
||||
margin-bottom: $elementSpacing;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 90vw;
|
||||
margin: 3rem auto;
|
||||
max-width: 1200px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 3rem;
|
||||
|
||||
@include desktop {
|
||||
width: 80vw;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-family: "knowit";
|
||||
color: $matte-text-color;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.filter input {
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
border-color: black;
|
||||
border-width: 1.5px;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
@include desktop {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
.highscore-header {
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.3rem;
|
||||
color: $matte-text-color;
|
||||
}
|
||||
|
||||
.highscore-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 0;
|
||||
|
||||
li {
|
||||
width: fit-content;
|
||||
display: inline-block;
|
||||
margin-bottom: calc(1rem - 2px);
|
||||
font-size: 1.25rem;
|
||||
color: $matte-text-color;
|
||||
cursor: pointer;
|
||||
|
||||
border-bottom: 2px solid transparent;
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: $link-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
right: 10vw;
|
||||
max-width: 50vw;
|
||||
|
||||
font-size: 2.5rem;
|
||||
background-color: $primary;
|
||||
padding: 1rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-style: italic;
|
||||
|
||||
@include widescreen {
|
||||
right: 20vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
frontend/components/HistoryPage.vue
Normal file
53
frontend/components/HistoryPage.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Historie fra tidligere lotteri</h1>
|
||||
|
||||
<div v-if="lotteries.length || lotteries != null" v-for="lottery in lotteries">
|
||||
<Winners :winners="lottery.winners" :title="`Vinnere fra ${humanReadableDate(lottery.date)}`" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { historyByDate, historyAll } from "@/api";
|
||||
import { humanReadableDate } from "@/utils";
|
||||
import Winners from "@/ui/Winners";
|
||||
|
||||
export default {
|
||||
name: "History page of prev lotteries",
|
||||
components: { Winners },
|
||||
data() {
|
||||
return {
|
||||
lotteries: []
|
||||
};
|
||||
},
|
||||
created() {
|
||||
const dateFromUrl = this.$route.params.date;
|
||||
|
||||
if (dateFromUrl !== undefined) {
|
||||
this.fetchHistoryByDate(dateFromUrl).then(history => (this.lotteries = [history]));
|
||||
} else {
|
||||
this.fetchHistory().then(history => (this.lotteries = history));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
humanReadableDate: humanReadableDate,
|
||||
fetchHistory() {
|
||||
return fetch("/api/history/by-date")
|
||||
.then(resp => resp.json())
|
||||
.then(response => response.lotteries);
|
||||
},
|
||||
fetchHistoryByDate(date) {
|
||||
return fetch(`/api/history/by-date/${date}`)
|
||||
.then(resp => resp.json())
|
||||
.then(response => response);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@
|
||||
<input
|
||||
type="text"
|
||||
v-model="username"
|
||||
ref="username"
|
||||
placeholder="Brukernavn"
|
||||
autocapitalize="none"
|
||||
@keyup.enter="submit"
|
||||
@@ -34,6 +35,9 @@ export default {
|
||||
error: undefined
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.username.focus();
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
login(this.username, this.password)
|
||||
@@ -11,9 +11,7 @@ import VirtualLotteryPage from "@/components/VirtualLotteryPage";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Tabs,
|
||||
GeneratePage,
|
||||
VirtualLotteryPage
|
||||
Tabs
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
293
frontend/components/PersonalHighscorePage.vue
Normal file
293
frontend/components/PersonalHighscorePage.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>Vinlottis highscore</h1>
|
||||
|
||||
<div class="backdrop">
|
||||
<a @click="navigateBack" @keydown.enter="navigateBack" tabindex="0">
|
||||
⬅ <span class="vin-link navigate-back">Tilbake til {{ previousRoute.name }}</span>
|
||||
</a>
|
||||
|
||||
<section v-if="winner">
|
||||
<h2 class="name">{{ winner.name }}</h2>
|
||||
|
||||
<p class="win-count el-spacing">{{ numberOfWins }} vinn</p>
|
||||
|
||||
<h4 class="margin-bottom-0">Vinnende farger:</h4>
|
||||
<div class="raffle-container el-spacing">
|
||||
<div
|
||||
class="raffle-element"
|
||||
:class="color + `-raffle`"
|
||||
v-for="[color, occurences] in Object.entries(winningColors)"
|
||||
:key="color"
|
||||
>
|
||||
{{ occurences }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="el-spacing">Flasker vunnet:</h4>
|
||||
|
||||
<div v-for="win in winner.wins" :key="win._id">
|
||||
<router-link :to="winDateUrl(win.date)" class="days-ago">
|
||||
{{ humanReadableDate(win.date) }} - {{ daysAgo(win.date) }}
|
||||
</router-link>
|
||||
|
||||
<div class="won-wine" v-if="win.wine">
|
||||
<img :src="smallerWineImage(win.wine.image)" />
|
||||
|
||||
<div class="won-wine-details">
|
||||
<h3>{{ win.wine.name }}</h3>
|
||||
<a :href="win.wine.vivinoLink" class="vin-link no-margin">
|
||||
Les mer på vinmonopolet.no
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="raffle-element small" :class="win.color + `-raffle`"></div>
|
||||
</div>
|
||||
<div class="won-wine" v-else>
|
||||
<div class="won-wine-details">
|
||||
<h3>Oisann! Klarte ikke finne vin.</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2 v-else-if="error" class="error">
|
||||
{{ error }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { dateString, humanReadableDate, daysAgo } from "@/utils";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
winner: undefined,
|
||||
name: undefined,
|
||||
error: undefined,
|
||||
previousRoute: {
|
||||
default: true,
|
||||
name: "topplisten",
|
||||
path: "/highscore"
|
||||
}
|
||||
};
|
||||
},
|
||||
beforeRouteEnter(to, from, next) {
|
||||
next(vm => {
|
||||
if (from.name != null) vm.previousRoute = from;
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
numberOfWins() {
|
||||
return this.winner.wins.length;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.name = this.$route.params.name;
|
||||
this.getWinnerByName(this.name)
|
||||
.then(winner => this.setWinner(winner))
|
||||
.catch(err => (this.error = `Ingen med navn: "${nameFromURL}" funnet.`));
|
||||
},
|
||||
methods: {
|
||||
getWinnerByName(name) {
|
||||
return fetch(`/api/history/by-name/${name}`)
|
||||
.then(resp => resp.json())
|
||||
.then(response => response.winner);
|
||||
},
|
||||
setWinner(winner) {
|
||||
this.winner = {
|
||||
name: winner.name,
|
||||
highscore: [],
|
||||
...winner
|
||||
};
|
||||
this.winningColors = this.findWinningColors();
|
||||
},
|
||||
smallerWineImage(image) {
|
||||
if (image && image.includes(`515x515`)) return image.replace(`515x515`, `175x175`);
|
||||
if (image && image.includes(`500x500`)) return image.replace(`500x500`, `175x175`);
|
||||
return image;
|
||||
},
|
||||
findWinningColors() {
|
||||
const colors = this.winner.wins.map(win => win.color);
|
||||
const colorOccurences = {};
|
||||
colors.forEach(color => {
|
||||
if (colorOccurences[color] == undefined) {
|
||||
colorOccurences[color] = 1;
|
||||
} else {
|
||||
colorOccurences[color] += 1;
|
||||
}
|
||||
});
|
||||
return colorOccurences;
|
||||
},
|
||||
winDateUrl(date) {
|
||||
const dateParameter = dateString(new Date(date));
|
||||
return `/history/${dateParameter}`;
|
||||
},
|
||||
navigateBack() {
|
||||
if (this.previousRoute.default) {
|
||||
this.$router.push({ path: this.previousRoute.path });
|
||||
} else {
|
||||
this.$router.go(-1);
|
||||
}
|
||||
},
|
||||
humanReadableDate: humanReadableDate,
|
||||
daysAgo(date) {
|
||||
const days = daysAgo(date);
|
||||
if (days == 0) {
|
||||
return "i dag";
|
||||
} else {
|
||||
return `${days} dager siden`;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/variables";
|
||||
@import "@/styles/media-queries";
|
||||
|
||||
$elementSpacing: 3rem;
|
||||
|
||||
.el-spacing {
|
||||
margin-bottom: $elementSpacing;
|
||||
}
|
||||
|
||||
.navigate-back {
|
||||
font-weight: normal;
|
||||
font-size: 1.2rem;
|
||||
border-width: 2px;
|
||||
border-color: gray;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 90vw;
|
||||
margin: 3rem auto;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 3rem;
|
||||
max-width: 1200px;
|
||||
|
||||
@include desktop {
|
||||
width: 80vw;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-family: "knowit";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.name {
|
||||
text-transform: capitalize;
|
||||
font-size: 3.5rem;
|
||||
font-family: "knowit";
|
||||
font-weight: normal;
|
||||
margin: 2rem 0 1rem 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 2.5rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.win-count {
|
||||
font-size: 1.5rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.raffle-container {
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
|
||||
div:not(:last-of-type) {
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
.raffle-element {
|
||||
width: 5rem;
|
||||
height: 4rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1.5rem;
|
||||
margin-top: 0;
|
||||
|
||||
&.small {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.days-ago {
|
||||
color: $matte-text-color;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: $link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.won-wine {
|
||||
--spacing: 1rem;
|
||||
background-color: white;
|
||||
margin: var(--spacing) 0 3rem 0;
|
||||
padding: var(--spacing);
|
||||
|
||||
position: relative;
|
||||
|
||||
@include desktop {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
img {
|
||||
margin: 0 3rem;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
&-details {
|
||||
vertical-align: top;
|
||||
display: inline-block;
|
||||
|
||||
@include tablet {
|
||||
width: calc(100% - 160px - 80px);
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: normal;
|
||||
color: $matte-text-color;
|
||||
}
|
||||
|
||||
a {
|
||||
font-size: 1.2rem;
|
||||
border-width: 2px;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.raffle-element {
|
||||
position: absolute;
|
||||
top: calc(var(--spacing) * 2);
|
||||
right: calc(var(--spacing) * 2);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
$background: rgb(244, 244, 244);
|
||||
|
||||
--padding: 2rem;
|
||||
@include desktop {
|
||||
--padding: 5rem;
|
||||
}
|
||||
background-color: $background;
|
||||
padding: var(--padding);
|
||||
}
|
||||
</style>
|
||||
264
frontend/components/RequestWine.vue
Normal file
264
frontend/components/RequestWine.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<section class="main-container">
|
||||
<Modal
|
||||
v-if="showModal"
|
||||
modalText="Ønsket ditt har blitt lagt til"
|
||||
:buttons="modalButtons"
|
||||
@click="emitFromModalButton"
|
||||
></Modal>
|
||||
<h1>
|
||||
Foreslå en vin!
|
||||
</h1>
|
||||
|
||||
<section class="search-container">
|
||||
<section class="search-section">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchString"
|
||||
@keyup.enter="searchWines()"
|
||||
placeholder="Søk etter en vin du liker her!🍷"
|
||||
class="search-input-field"
|
||||
/>
|
||||
<button :disabled="!searchString" @click="searchWines()" class="vin-button">Søk</button>
|
||||
</section>
|
||||
|
||||
<section v-for="(wine, index) in wines" :key="index" class="single-result">
|
||||
<img v-if="wine.image" :src="wine.image" class="wine-image" :class="{ fullscreen: fullscreen }" />
|
||||
<img v-else class="wine-placeholder" alt="Wine image" />
|
||||
<section class="wine-info">
|
||||
<h2 v-if="wine.name">{{ wine.name }}</h2>
|
||||
<h2 v-else>(no name)</h2>
|
||||
<div class="details">
|
||||
<span v-if="wine.rating">{{ wine.rating }}%</span>
|
||||
<span v-if="wine.price">{{ wine.price }} NOK</span>
|
||||
<span v-if="wine.country">{{ wine.country }}</span>
|
||||
<span v-if="wine.year && wine.year !== '0000'">{{ wine.year }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<button class="vin-button" @click="requestWine(wine)">Foreslå denne</button>
|
||||
<a v-if="wine.vivinoLink" :href="wine.vivinoLink" class="wine-link">Les mer</a>
|
||||
</section>
|
||||
<p v-if="loading == false && wines && wines.length == 0">
|
||||
Fant ingen viner med det navnet!
|
||||
</p>
|
||||
<p v-else-if="loading">Loading...</p>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { searchForWine } from "@/api";
|
||||
import Wine from "@/ui/Wine";
|
||||
import Modal from "@/ui/Modal";
|
||||
import RequestedWineCard from "@/ui/RequestedWineCard";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Wine,
|
||||
Modal,
|
||||
RequestedWineCard
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchString: undefined,
|
||||
wines: undefined,
|
||||
showModal: false,
|
||||
loading: false,
|
||||
modalButtons: [
|
||||
{
|
||||
text: "Legg til flere viner",
|
||||
action: "stay"
|
||||
},
|
||||
{
|
||||
text: "Se alle viner",
|
||||
action: "move"
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
fetchWinesByQuery(query) {
|
||||
let url = new URL("/api/vinmonopolet/wine/search", window.location);
|
||||
url.searchParams.set("name", query);
|
||||
|
||||
this.wines = [];
|
||||
this.loading = true;
|
||||
|
||||
return fetch(url.href)
|
||||
.then(resp => resp.json())
|
||||
.then(response => (this.wines = response.wines))
|
||||
.finally(wines => (this.loading = false));
|
||||
},
|
||||
searchWines() {
|
||||
if (this.searchString) {
|
||||
let localSearchString = this.searchString.replace(/ /g, "_");
|
||||
this.fetchWinesByQuery(localSearchString);
|
||||
}
|
||||
},
|
||||
requestWine(wine) {
|
||||
const options = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wine: wine })
|
||||
};
|
||||
|
||||
return fetch("/api/request", options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.showModal = true;
|
||||
this.$toast.info({
|
||||
title: `Vinen ${wine.name} har blitt foreslått!`
|
||||
});
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: "Obs, her oppsto det en feil! Feilen er logget.",
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
emitFromModalButton(action) {
|
||||
if (action == "stay") {
|
||||
this.showModal = false;
|
||||
} else {
|
||||
this.$router.push("/requested-wines");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/media-queries";
|
||||
@import "@/styles/global";
|
||||
@import "@/styles/variables";
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
margin: auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 90%;
|
||||
color: black;
|
||||
border-radius: 4px;
|
||||
padding: 1rem 1rem;
|
||||
border: 1px solid black;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
display: grid;
|
||||
grid: 1fr / 1fr 0.2fr;
|
||||
|
||||
@include mobile {
|
||||
.vin-button {
|
||||
display: none;
|
||||
}
|
||||
.search-input-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.single-result {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid: 1fr / 0.5fr 2fr 0.5fr 0.5fr;
|
||||
grid-template-areas: "picture details button-left button-right";
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
grid-gap: 1em;
|
||||
padding-bottom: 1em;
|
||||
margin-bottom: 1em;
|
||||
box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2);
|
||||
|
||||
@include mobile {
|
||||
grid: 1fr 0.5fr / 0.5fr 1fr;
|
||||
grid-template-areas:
|
||||
"picture details"
|
||||
"button-left button-right";
|
||||
grid-gap: 0.5em;
|
||||
|
||||
.vin-button {
|
||||
grid-area: button-right;
|
||||
padding: 0.5em;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
.wine-link {
|
||||
grid-area: button-left;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1em;
|
||||
max-width: 80%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.wine-image {
|
||||
height: 100px;
|
||||
grid-area: picture;
|
||||
}
|
||||
|
||||
.wine-placeholder {
|
||||
height: 100px;
|
||||
width: 70px;
|
||||
grid-area: picture;
|
||||
}
|
||||
|
||||
.wine-info {
|
||||
grid-area: details;
|
||||
width: 100%;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.details {
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.wine-link {
|
||||
grid-area: button-left;
|
||||
color: #333333;
|
||||
font-family: Arial;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid $link-color;
|
||||
height: 1.2em;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.vin-button {
|
||||
grid-area: button-right;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
h2 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
h2 {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
207
frontend/components/SalgsbetingelserPage.vue
Normal file
207
frontend/components/SalgsbetingelserPage.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>Slagsbetingelser</h1>
|
||||
<section class="chapter cf" id="chapter-1">
|
||||
<h2 class="h2-title">Innledning</h2>
|
||||
<div class="content-wrap">
|
||||
<p>
|
||||
Dette kjøpet er regulert av de nedenstående standard salgsbetingelser for forbrukerkjøp av varer over Internett. Forbrukerkjøp over internett reguleres hovedsakelig av avtaleloven, forbrukerkjøpsloven, markedsføringsloven, angrerettloven og ehandelsloven, og disse lovene gir forbrukeren ufravikelige rettigheter. Lovene er tilgjengelig på
|
||||
<a target="_blank" class="vin-link" href="http://www.lovdata.no/" rel="noopener">www.lovdata.no.</a>
|
||||
Vilkårene i denne avtalen skal ikke forstås som noen begrensning i de lovbestemte rettighetene, men oppstiller partenes viktigste rettigheter og plikter for handelen.
|
||||
</p>
|
||||
<p>
|
||||
Salgsbetingelsene er utarbeidet og anbefalt av Forbrukertilsynet.
|
||||
<a class="vin-link" href="https://forbrukertilsynet.no/lov-og-rett/veiledninger-og-retningslinjer/veiledning-standard-salgsbetingelser-forbrukerkjop-varer-internett">For en bedre forståelse av disse salgsbetingelsene, se Forbrukertilsynets veileder her. </a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-2">
|
||||
<h2 class="h2-title">1. Avtalen</h2>
|
||||
<div class="content-wrap">
|
||||
<p>Avtalen består av disse salgsbetingelsene, opplysninger gitt i bestillingsløsningen og eventuelt særskilt avtalte vilkår. Ved eventuell motstrid mellom opplysningene, går det som særskilt er avtalt mellom partene foran, så fremt det ikke strider mot ufravikelig lovgivning.</p>
|
||||
<p>Avtalen vil i tillegg bli utfylt av relevante lovbestemmelser som regulerer kjøp av varer mellom næringsdrivende og forbrukere.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-3">
|
||||
<h2 class="h2-title">2. Partene</h2>
|
||||
<div class="content-wrap">
|
||||
<p>Selger er Kevin Midbøe, Schleppegrells gate 18, questions@vinlottis.no/kevin.midboe@gmail.com, 926432478, og betegnes i det følgende som selger/selgeren.</p>
|
||||
<p>Kjøper er den forbrukeren som foretar bestillingen, og betegnes i det følgende som kjøper/kjøperen.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-4">
|
||||
<h2 class="h2-title">3. Pris</h2>
|
||||
<div class="content-wrap">
|
||||
<p>Den oppgitte prisen for varen og tjenester er den totale prisen kjøper skal betale. Denne prisen inkluderer alle avgifter og tilleggskostnader. Ytterligere kostnader som selger før kjøpet ikke har informert om, skal kjøper ikke bære.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-5">
|
||||
<h2 class="h2-title">4. Avtaleinngåelse</h2>
|
||||
<div class="content-wrap">
|
||||
<p>Avtalen er bindende for begge parter når kjøperen har sendt sin bestilling til selgeren.</p>
|
||||
<p>Avtalen er likevel ikke bindende hvis det har forekommet skrive- eller tastefeil i tilbudet fra selgeren i bestillingsløsningen i nettbutikken eller i kjøperens bestilling, og den annen part innså eller burde ha innsett at det forelå en slik feil.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-6">
|
||||
<h2 class="h2-title">5. Betalingen</h2>
|
||||
<div class="content-wrap">
|
||||
<p>Selgeren kan kreve betaling for varen fra det tidspunkt den blir sendt fra selgeren til kjøperen.</p>
|
||||
<p>Dersom kjøperen bruker kredittkort eller debetkort ved betaling, kan selgeren reservere kjøpesummen på kortet ved bestilling. Kortet blir belastet samme dag som varen sendes.</p>
|
||||
<p>Ved betaling med faktura, blir fakturaen til kjøperen utstedt ved forsendelse av varen. Betalingsfristen fremgår av fakturaen og er på minimum 14 dager fra mottak.</p>
|
||||
<p>Kjøpere under 18 år kan ikke betale med etterfølgende faktura.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-7">
|
||||
<h2 class="h2-title">6. Levering</h2>
|
||||
<div class="content-wrap">
|
||||
<p>Levering er skjedd når kjøperen, eller hans representant, har overtatt tingen.</p>
|
||||
<p>Hvis ikke leveringstidspunkt fremgår av bestillingsløsningen, skal selgeren levere varen til kjøper uten unødig opphold og senest 30 dager etter bestillingen fra kunden. Varen skal leveres hos kjøperen med mindre annet er særskilt avtalt mellom partene.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-8">
|
||||
<h2 class="h2-title">7. Risikoen for varen</h2>
|
||||
<div class="content-wrap">
|
||||
<p>Risikoen for varen går over på kjøper når han, eller hans representant, har fått varene levert i tråd med punkt 6.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-9">
|
||||
<h2 class="h2-title">8. Angrerett</h2>
|
||||
<div class="content-wrap">
|
||||
<p>Med mindre avtalen er unntatt fra angrerett, kan kjøperen angre kjøpet av varen i henhold til angrerettloven.</p>
|
||||
<p>Kjøperen må gi selger melding om bruk av angreretten innen 14 dager fra fristen begynner å løpe. I fristen inkluderes alle kalenderdager. Dersom fristen ender på en lørdag, helligdag eller høytidsdag forlenges fristen til nærmeste virkedag.</p>
|
||||
<p>Angrefristen anses overholdt dersom melding er sendt før utløpet av fristen. Kjøper har bevisbyrden for at angreretten er blitt gjort gjeldende, og meldingen bør derfor skje skriftlig (angrerettskjema, e-post eller brev).</p>
|
||||
<p>Angrefristen begynner å løpe:</p>
|
||||
<ul>
|
||||
<li>Ved kjøp av enkeltstående varer vil angrefristen løpe fra dagen etter varen(e) er mottatt.</li>
|
||||
<li>Selges et abonnement, eller innebærer avtalen regelmessig levering av identiske varer, løper fristen fra dagen etter første forsendelse er mottatt.</li>
|
||||
<li>Består kjøpet av flere leveranser, vil angrefristen løpe fra dagen etter siste leveranse er mottatt.</li>
|
||||
</ul>
|
||||
<p>Angrefristen utvides til 12 måneder etter utløpet av den opprinnelige fristen dersom selger ikke før avtaleinngåelsen opplyser om at det foreligger angrerett og standardisert angreskjema. Tilsvarende gjelder ved manglende opplysning om vilkår, tidsfrister og fremgangsmåte for å benytte angreretten. Sørger den næringsdrivende for å gi opplysningene i løpet av disse 12 månedene, utløper angrefristen likevel 14 dager etter den dagen kjøperen mottok opplysningene.</p>
|
||||
<p>Ved bruk av angreretten må varen leveres tilbake til selgeren uten unødig opphold og senest 14 dager fra melding om bruk av angreretten er gitt. Kjøper dekker de direkte kostnadene ved å returnere varen, med mindre annet er avtalt eller selger har unnlatt å opplyse om at kjøper skal dekke returkostnadene. Selgeren kan ikke fastsette gebyr for kjøperens bruk av angreretten.</p>
|
||||
<p>Kjøper kan prøve eller teste varen på en forsvarlig måte for å fastslå varens art, egenskaper og funksjon, uten at angreretten faller bort. Dersom prøving eller test av varen går utover hva som er forsvarlig og nødvendig, kan kjøperen bli ansvarlig for eventuell redusert verdi på varen.</p>
|
||||
<p>Selgeren er forpliktet til å tilbakebetale kjøpesummen til kjøperen uten unødig opphold, og senest 14 dager fra selgeren fikk melding om kjøperens beslutning om å benytte angreretten. Selger har rett til å holde tilbake betalingen til han har mottatt varene fra kjøperen, eller til kjøper har lagt frem dokumentasjon for at varene er sendt tilbake.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-10">
|
||||
<h2 class="h2-title">9. Forsinkelse og manglende levering - kjøpernes rettigheter og frist for å melde krav</h2>
|
||||
<div class="content-wrap">
|
||||
<p>
|
||||
Dersom selgeren ikke leverer varen eller leverer den for sent i henhold til avtalen mellom partene, og dette ikke skyldes kjøperen eller forhold på kjøperens side, kan kjøperen i henhold til reglene i forbrukerkjøpslovens kapittel 5 etter omstendighetene
|
||||
<em>holde kjøpesummen tilbake</em>
|
||||
, kreve
|
||||
<em>oppfyl</em>
|
||||
<em>lelse</em>
|
||||
,
|
||||
<em>heve </em>
|
||||
avtalen og/eller kreve
|
||||
<em>erstatning </em>
|
||||
fra selgeren.
|
||||
</p>
|
||||
<p>Ved krav om misligholdsbeføyelser bør meldingen av bevishensyn være skriftlig (for eksempel e-post).</p>
|
||||
<h3>Oppfyllelse</h3>
|
||||
<p>Kjøper kan fastholde kjøpet og kreve oppfyllelse fra selger. Kjøper kan imidlertid ikke kreve oppfyllelse dersom det foreligger en hindring som selgeren ikke kan overvinne, eller dersom oppfyllelse vil medføre en så stor ulempe eller kostnad for selger at det står i vesentlig misforhold til kjøperens interesse i at selgeren oppfyller. Skulle vanskene falle bort innen rimelig tid, kan kjøper likevel kreve oppfyllelse.</p>
|
||||
<p>Kjøperen taper sin rett til å kreve oppfyllelse om han eller hun venter urimelig lenge med å fremme kravet.</p>
|
||||
<h3>Heving</h3>
|
||||
<p>Dersom selgeren ikke leverer varen på leveringstidspunktet, skal kjøperen oppfordre selger til å levere innen en rimelig tilleggsfrist for oppfyllelse. Dersom selger ikke leverer varen innen tilleggsfristen, kan kjøperen heve kjøpet.</p>
|
||||
<p>Kjøper kan imidlertid heve kjøpet umiddelbart hvis selger nekter å levere varen. Tilsvarende gjelder dersom levering til avtalt tid var avgjørende for inngåelsen av avtalen, eller dersom kjøperen har underrettet selger om at leveringstidspunktet er avgjørende.</p>
|
||||
<p>Leveres tingen etter tilleggsfristen forbrukeren har satt eller etter leveringstidspunktet som var avgjørende for inngåelsen av avtalen, må krav om heving gjøres gjeldende innen rimelig tid etter at kjøperen fikk vite om leveringen.</p>
|
||||
<h3>Erstatning</h3>
|
||||
<p>Kjøperen kan kreve erstatning for lidt tap som følge av forsinkelsen. Dette gjelder imidlertid ikke dersom selgeren godtgjør at forsinkelsen skyldes hindring utenfor selgers kontroll som ikke med rimelighet kunne blitt tatt i betraktning på avtaletiden, unngått, eller overvunnet følgene av.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-11">
|
||||
<h2 class="h2-title">10. Mangel ved varen - kjøperens rettigheter og reklamasjonsfrist</h2>
|
||||
<div class="content-wrap">
|
||||
<p>Hvis det foreligger en mangel ved varen må kjøper innen rimelig tid etter at den ble oppdaget eller burde ha blitt oppdaget, gi selger melding om at han eller hun vil påberope seg mangelen. Kjøper har alltid reklamert tidsnok dersom det skjer innen 2 mnd. fra mangelen ble oppdaget eller burde blitt oppdaget. Reklamasjon kan skje senest to år etter at kjøper overtok varen. Dersom varen eller deler av den er ment å vare vesentlig lenger enn to år, er reklamasjonsfristen fem år.</p>
|
||||
<p>
|
||||
Dersom varen har en mangel og dette ikke skyldes kjøperen eller forhold på kjøperens side, kan kjøperen i henhold til reglene i forbrukerkjøpsloven kapittel 6 etter omstendighetene
|
||||
<em>holde kjøpesummen tilbake</em>
|
||||
, velge mellom
|
||||
<em>retting </em>
|
||||
og
|
||||
<em>omlevering</em>
|
||||
, kreve
|
||||
<em>prisavslag</em>
|
||||
, kreve avtalen hevet og/eller kreve
|
||||
<em>erstatning </em>
|
||||
fra selgeren.
|
||||
</p>
|
||||
<p>Reklamasjon til selgeren bør skje skriftlig.</p>
|
||||
<h3>Retting eller omlevering</h3>
|
||||
<p>Kjøperen kan velge mellom å kreve mangelen rettet eller levering av tilsvarende ting. Selger kan likevel motsette seg kjøperens krav dersom gjennomføringen av kravet er umulig eller volder selgeren urimelige kostnader. Retting eller omlevering skal foretas innen rimelig tid. Selger har i utgangspunktet ikke rett til å foreta mer enn to avhjelpsforsøk for samme mangel.</p>
|
||||
<h3>Prisavslag</h3>
|
||||
<p>Kjøper kan kreve et passende prisavslag dersom varen ikke blir rettet eller omlevert. Dette innebærer at forholdet mellom nedsatt og avtalt pris svarer til forholdet mellom tingens verdi i mangelfull og kontraktsmessig stand. Dersom særlige grunner taler for det, kan prisavslaget i stedet settes lik mangelens betydning for kjøperen.</p>
|
||||
<h3>Heving</h3>
|
||||
<p>Dersom varen ikke er rettet eller omlevert, kan kjøperen også heve kjøpet når mangelen ikke er uvesentlig.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-12">
|
||||
<h2 class="h2-title">11. Selgerens rettigheter ved kjøperens mislighold</h2>
|
||||
<div class="content-wrap">
|
||||
<p>
|
||||
Dersom kjøperen ikke betaler eller oppfyller de øvrige pliktene etter avtalen eller loven, og dette ikke skyldes selgeren eller forhold på selgerens side, kan selgeren i henhold til reglene i forbrukerkjøpsloven kapittel 9 etter omstendighetene
|
||||
<em>holde</em>
|
||||
<em>varen tilbake</em>
|
||||
, kreve
|
||||
<em>oppfyllelse </em>
|
||||
av avtalen, kreve avtalen
|
||||
<em>hevet </em>
|
||||
samt kreve
|
||||
<em>erstatning </em>
|
||||
fra kjøperen. Selgeren vil også etter omstendighetene kunne kreve
|
||||
<em>renter ved forsinket betaling, inkassogebyr</em>
|
||||
og et rimelig
|
||||
<em>gebyr ved uavhentede varer</em>
|
||||
.
|
||||
</p>
|
||||
<h3>Oppfyllelse</h3>
|
||||
<p>Selger kan fastholde kjøpet og kreve at kjøperen betaler kjøpesummen. Er varen ikke levert, taper selgeren sin rett dersom han venter urimelig lenge med å fremme kravet.</p>
|
||||
<h3>Heving</h3>
|
||||
<p>Selger kan heve avtalen dersom det foreligger vesentlig betalingsmislighold eller annet vesentlig mislighold fra kjøperens side. Selger kan likevel ikke heve dersom hele kjøpesummen er betalt. Fastsetter selger en rimelig tilleggsfrist for oppfyllelse og kjøperen ikke betaler innen denne fristen, kan selger heve kjøpet.</p>
|
||||
<h3>Renter ved forsinket betaling/inkassogebyr</h3>
|
||||
<p>Dersom kjøperen ikke betaler kjøpesummen i henhold til avtalen, kan selger kreve renter av kjøpesummen etter forsinkelsesrenteloven. Ved manglende betaling kan kravet, etter forutgående varsel, bli sendt til Kjøper kan da bli holdt ansvarlig for gebyr etter inkassoloven.</p>
|
||||
<h3>Gebyr ved uavhentede ikke-forskuddsbetalte varer</h3>
|
||||
<p>Dersom kjøperen unnlater å hente ubetalte varer, kan selger belaste kjøper med et gebyr. Gebyret skal maksimalt dekke selgerens faktiske utlegg for å levere varen til kjøperen. Et slikt gebyr kan ikke belastes kjøpere under 18 år.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-13">
|
||||
<h2 class="h2-title">12. Garanti</h2>
|
||||
<div class="content-wrap">
|
||||
<p>Garanti som gis av selgeren eller produsenten, gir kjøperen rettigheter i tillegg til de kjøperen allerede har etter ufravikelig lovgivning. En garanti innebærer dermed ingen begrensninger i kjøperens rett til reklamasjon og krav ved forsinkelse eller mangler etter punkt 9 og 10.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-14">
|
||||
<h2 class="h2-title">13. Personopplysninger</h2>
|
||||
<div class="content-wrap">
|
||||
<p>Behandlingsansvarlig for innsamlede personopplysninger er selger. Med mindre kjøperen samtykker til noe annet, kan selgeren, i tråd med personopplysningsloven, kun innhente og lagre de personopplysninger som er nødvendig for at selgeren skal kunne gjennomføre forpliktelsene etter avtalen. Kjøperens personopplysninger vil kun bli utlevert til andre hvis det er nødvendig for at selger skal få gjennomført avtalen med kjøperen, eller i lovbestemte tilfelle.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chapter cf" id="chapter-15">
|
||||
<h2 class="h2-title">14. Konfliktløsning</h2>
|
||||
<div class="content-wrap">
|
||||
<p>
|
||||
Klager rettes til selger innen rimelig tid, jf. punkt 9 og 10. Partene skal forsøke å løse eventuelle tvister i minnelighet. Dersom dette ikke lykkes, kan kjøperen ta kontakt med Forbrukerrådet for mekling. Forbrukerrådet er tilgjengelig på telefon 23 400 500 eller
|
||||
<a target="_blank" class="vin-link" href="http://www.forbrukerradet.no/" rel="noopener">www.forbrukerradet.no.</a>
|
||||
</p>
|
||||
<p>
|
||||
Europa-Kommisjonens klageportal kan også brukes hvis du ønsker å inngi en klage. Det er særlig relevant, hvis du er forbruker bosatt i et annet EU-land. Klagen inngis her:
|
||||
<a class="vin-link" href="http://ec.europa.eu/odr">http://ec.europa.eu/odr</a>
|
||||
.
|
||||
</p>
|
||||
<p> </p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/variables.scss";
|
||||
|
||||
.container {
|
||||
margin: 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-self: center;
|
||||
width: 80%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<div class="outer">
|
||||
<div class="container">
|
||||
<h1 class="title">Dagens viner</h1>
|
||||
<div class="wines-container">
|
||||
<Wine :wine="wine" v-for="wine in wines" :key="wine" :fullscreen="true" :inlineSlot="true" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<h1 class="title">Dagens viner</h1>
|
||||
<div class="wines-container">
|
||||
<Wine :wine="wine" v-for="wine in wines" :key="wine" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { page, event } from "vue-analytics";
|
||||
import { prelottery } from "@/api";
|
||||
import Banner from "@/ui/Banner";
|
||||
import Wine from "@/ui/Wine";
|
||||
|
||||
@@ -25,14 +23,16 @@ export default {
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
const _wines = await fetch("/api/wines/prelottery");
|
||||
this.wines = await _wines.json();
|
||||
fetch("/api/lottery/wines")
|
||||
.then(resp => resp.json())
|
||||
.then(response => (this.wines = response.wines));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "./src/styles/media-queries";
|
||||
@import "@/styles/media-queries";
|
||||
@import "@/styles/variables";
|
||||
|
||||
.wine-image {
|
||||
height: 250px;
|
||||
@@ -44,19 +44,18 @@ h1 {
|
||||
}
|
||||
|
||||
.wines-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin: 0 2rem;
|
||||
width: 90vw;
|
||||
padding: 5vw;
|
||||
|
||||
@include desktop {
|
||||
width: 80vw;
|
||||
padding: 0 10vw;
|
||||
}
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -67,23 +66,6 @@ h3 {
|
||||
}
|
||||
}
|
||||
|
||||
.inner-wine-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: auto;
|
||||
width: 500px;
|
||||
font-family: Arial;
|
||||
margin-bottom: 30px;
|
||||
|
||||
@include desktop {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -110,7 +92,7 @@ a:visited {
|
||||
font-family: Arial;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #ff5fff;
|
||||
border-bottom: 1px solid $link-color;
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
379
frontend/components/VinlottisPage.vue
Normal file
379
frontend/components/VinlottisPage.vue
Normal file
@@ -0,0 +1,379 @@
|
||||
<template>
|
||||
<main class="main-container">
|
||||
<section class="top-container">
|
||||
<div class="want-to-win">
|
||||
<h1>
|
||||
Vil du også vinne?
|
||||
</h1>
|
||||
<img
|
||||
src="/public/assets/images/notification.svg"
|
||||
alt="Notification-bell"
|
||||
@click="requestNotificationAccess"
|
||||
class="notification-request-button"
|
||||
role="button"
|
||||
v-if="notificationAllowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<router-link to="/lottery" class="participate-button">
|
||||
<i class="icon icon--arrow-right"></i>
|
||||
<p>Trykk her for å delta</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/generate" class="see-details-link">
|
||||
Se vipps detaljer og QR-kode
|
||||
</router-link>
|
||||
|
||||
<div class="icons-container">
|
||||
<i class="icon icon--heart-sparks"></i>
|
||||
<i class="icon icon--face-1"></i>
|
||||
<i class="icon icon--face-3"></i>
|
||||
<i class="icon icon--ballon"></i>
|
||||
|
||||
<i class="icon icon--bottle"></i>
|
||||
<i class="icon icon--bottle"></i>
|
||||
<i class="icon icon--bottle"></i>
|
||||
<i class="icon icon--bottle"></i>
|
||||
<i class="icon icon--bottle"></i>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-container">
|
||||
<div class="scroll-info">
|
||||
<i class="icon icon--arrow-long-right"></i>
|
||||
<p>Scroll for å se vinnere og annen gøy statistikk</p>
|
||||
</div>
|
||||
|
||||
<Highscore class="highscore" />
|
||||
|
||||
<TotalBought class="total-bought" />
|
||||
|
||||
<section class="chart-container">
|
||||
<PurchaseGraph class="purchase" />
|
||||
<WinGraph class="win" />
|
||||
</section>
|
||||
|
||||
<Wines class="wine-container" />
|
||||
</section>
|
||||
|
||||
<Countdown :hardEnable="hardStart" @countdown="changeEnabled" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PurchaseGraph from "@/ui/PurchaseGraph";
|
||||
import TotalBought from "@/ui/TotalBought";
|
||||
import Highscore from "@/ui/Highscore";
|
||||
import WinGraph from "@/ui/WinGraph";
|
||||
import Wines from "@/ui/Wines";
|
||||
import Vipps from "@/ui/Vipps";
|
||||
import Countdown from "@/ui/Countdown";
|
||||
import { prelottery } from "@/api";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PurchaseGraph,
|
||||
TotalBought,
|
||||
Highscore,
|
||||
WinGraph,
|
||||
Wines,
|
||||
Vipps,
|
||||
Countdown
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hardStart: false,
|
||||
pushAllowed: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
notificationAllowed: function() {
|
||||
if (!("PushManager" in window)) {
|
||||
return false;
|
||||
}
|
||||
return Notification.permission !== "granted" || !this.pushAllowed || localStorage.getItem("push") == null;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
setTimeout(() => {
|
||||
document.getElementsByClassName("participate-button")[0].classList.add("pulse");
|
||||
}, 1800);
|
||||
|
||||
this.$on("push-allowed", () => {
|
||||
this.pushAllowed = true;
|
||||
});
|
||||
if (window.location.hostname == "localhost") {
|
||||
return;
|
||||
}
|
||||
this.track();
|
||||
},
|
||||
methods: {
|
||||
requestNotificationAccess() {
|
||||
this.$root.$children[0].registerServiceWorkerPushNotification();
|
||||
},
|
||||
changeEnabled(way) {
|
||||
this.hardStart = way;
|
||||
},
|
||||
track() {
|
||||
window.ga("send", "pageview", "/");
|
||||
},
|
||||
startCountdown() {
|
||||
this.hardStart = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/media-queries.scss";
|
||||
@import "@/styles/variables.scss";
|
||||
@import "@/styles/animations.scss";
|
||||
|
||||
.top-container {
|
||||
height: 30em;
|
||||
background-color: $primary;
|
||||
width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
grid-template-rows: repeat(12, 1fr);
|
||||
align-items: center;
|
||||
justify-items: start;
|
||||
|
||||
@include mobile {
|
||||
padding-bottom: 2em;
|
||||
height: 15em;
|
||||
grid-template-rows: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.want-to-win {
|
||||
grid-row: 2 / 4;
|
||||
grid-column: 2 / -1;
|
||||
display: flex;
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
h1 {
|
||||
font-size: 3em;
|
||||
}
|
||||
grid-row: 2 / 4;
|
||||
grid-column: 3 / -3;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-request-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.participate-button {
|
||||
grid-row: 4 / 6;
|
||||
grid-column: 2 / -1;
|
||||
|
||||
background: inherit;
|
||||
border: 4px solid black;
|
||||
padding: 0 1em 0 1em;
|
||||
display: flex;
|
||||
width: 17.5em;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
|
||||
i {
|
||||
color: $link-color;
|
||||
font-size: 1.2rem;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.4rem;
|
||||
margin: 1rem;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
grid-row: 4 / 6;
|
||||
grid-column: 3 / -3;
|
||||
}
|
||||
}
|
||||
|
||||
.see-details-link {
|
||||
grid-row: 6 / 8;
|
||||
grid-column: 2 / -1;
|
||||
|
||||
@include tablet {
|
||||
grid-row: 6 / 8;
|
||||
grid-column: 2 / 10;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
grid-column: 3 / -3;
|
||||
}
|
||||
|
||||
font-weight: bold;
|
||||
color: black;
|
||||
font-weight: 200;
|
||||
font-size: 1.3em;
|
||||
|
||||
text-decoration: underline;
|
||||
text-decoration-color: $link-color;
|
||||
text-underline-position: under;
|
||||
}
|
||||
|
||||
.icons-container {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 7 / -1;
|
||||
@include mobile {
|
||||
margin-top: 2em;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
grid-row: 6 / -1;
|
||||
grid-column: 7 / -1;
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
grid-row: 4 / -3;
|
||||
grid-column: 7 / 11;
|
||||
}
|
||||
|
||||
@include widescreen {
|
||||
grid-column: 6 / 10;
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
min-width: 375px;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid: repeat(6, 1fr) / repeat(12, 1fr);
|
||||
|
||||
i {
|
||||
font-size: 5em;
|
||||
|
||||
&.icon--heart-sparks {
|
||||
grid-column: 2 / 4;
|
||||
grid-row: 2 / 4;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
}
|
||||
&.icon--face-1 {
|
||||
grid-column: 4 / 7;
|
||||
grid-row: 2 / 4;
|
||||
justify-self: center;
|
||||
}
|
||||
&.icon--face-3 {
|
||||
grid-column: 7 / 10;
|
||||
grid-row: 1 / 4;
|
||||
align-self: center;
|
||||
}
|
||||
&.icon--ballon {
|
||||
grid-column: 9 / 11;
|
||||
grid-row: 3 / 5;
|
||||
}
|
||||
&.icon--bottle {
|
||||
grid-row: 4 / -1;
|
||||
|
||||
&:nth-of-type(5) {
|
||||
grid-column: 4 / 5;
|
||||
align-self: center;
|
||||
}
|
||||
&:nth-of-type(6) {
|
||||
grid-column: 5 / 6;
|
||||
}
|
||||
&:nth-of-type(7) {
|
||||
grid-column: 6 / 7;
|
||||
align-self: center;
|
||||
}
|
||||
&:nth-of-type(8) {
|
||||
grid-column: 7 / 8;
|
||||
}
|
||||
&:nth-of-type(9) {
|
||||
grid-column: 8 / 9;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-family: "knowit";
|
||||
}
|
||||
|
||||
.to-lottery {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
row-gap: 5em;
|
||||
|
||||
.scroll-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
.total-bought {
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
|
||||
.highscore {
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
|
||||
.wine-container {
|
||||
grid-column: 3 / -3;
|
||||
|
||||
@include mobile {
|
||||
grid-column: 2 / -2;
|
||||
}
|
||||
}
|
||||
|
||||
.icon--arrow-long-right {
|
||||
transform: rotate(90deg);
|
||||
color: $link-color;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
.scroll-info {
|
||||
grid-column: 3 / -3;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
grid-column: 3 / -3;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.total-bought {
|
||||
grid-column: 3 / -3;
|
||||
}
|
||||
|
||||
.highscore {
|
||||
grid-column: 3 / -3;
|
||||
}
|
||||
|
||||
.wines-container {
|
||||
grid-column: 3 / -3;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
398
frontend/components/VirtualLotteryPage.vue
Normal file
398
frontend/components/VirtualLotteryPage.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div>
|
||||
<header ref="header">
|
||||
<div class="container">
|
||||
<div class="instructions">
|
||||
<h1 class="title">Virtuelt lotteri</h1>
|
||||
<ol>
|
||||
<li>
|
||||
Vurder om du ønsker å bruke <router-link to="/generate" class="vin-link">loddgeneratoren</router-link>,
|
||||
eller sjekke ut <router-link to="/dagens" class="vin-link">dagens fangst.</router-link>
|
||||
</li>
|
||||
<li>Send vipps med melding "Vinlotteri" for å bli registrert til lotteriet.</li>
|
||||
<li>Send gjerne melding om fargeønske også.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<Vipps :amount="1" class="vipps-qr desktop-only" />
|
||||
|
||||
<VippsPill class="vipps-pill mobile-only" />
|
||||
|
||||
<p class="call-to-action">
|
||||
<span class="vin-link" @click="scrollToContent">Følg med på utviklingen</span> og
|
||||
<span class="vin-link" @click="scrollToContent">chat om trekningen</span>
|
||||
<i class="icon icon--arrow-left" @click="scrollToContent"></i>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container" ref="content">
|
||||
<WinnerDraw :currentWinnerDrawn="currentWinnerDrawn" :currentWinner="currentWinner" :attendees="attendees" />
|
||||
|
||||
<div class="todays-raffles">
|
||||
<h2>Liste av lodd kjøpt i dag</h2>
|
||||
|
||||
<div class="raffle-container">
|
||||
<div v-for="color in Object.keys(ticketsBought)" :class="color + '-raffle raffle-element'" :key="color">
|
||||
<span>{{ ticketsBought[color] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Winners :winners="winners" class="winners" :drawing="currentWinner" />
|
||||
|
||||
<div class="container-attendees">
|
||||
<h2>Deltakere ({{ attendees.length }})</h2>
|
||||
<Attendees :attendees="attendees" class="attendees" />
|
||||
</div>
|
||||
|
||||
<div class="container-chat">
|
||||
<h2>Chat</h2>
|
||||
<Chat class="chat" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="todays-wines">
|
||||
<h2>Dagens fangst ({{ wines.length }})</h2>
|
||||
<div class="wines-container">
|
||||
<Wine :wine="wine" v-for="wine in wines" :key="wine" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Chat from "@/ui/Chat";
|
||||
import Vipps from "@/ui/Vipps";
|
||||
import VippsPill from "@/ui/VippsPill";
|
||||
import Attendees from "@/ui/Attendees";
|
||||
import Wine from "@/ui/Wine";
|
||||
import Winners from "@/ui/Winners";
|
||||
import WinnerDraw from "@/ui/WinnerDraw";
|
||||
import io from "socket.io-client";
|
||||
|
||||
export default {
|
||||
components: { Chat, Attendees, Winners, WinnerDraw, Vipps, VippsPill, Wine },
|
||||
data() {
|
||||
return {
|
||||
attendees: [],
|
||||
attendeesFetched: false,
|
||||
winners: [],
|
||||
wines: [],
|
||||
currentWinnerDrawn: false,
|
||||
currentWinner: null,
|
||||
socket: null,
|
||||
wasDisconnected: false,
|
||||
ticketsBought: {
|
||||
red: 0,
|
||||
blue: 0,
|
||||
green: 0,
|
||||
yellow: 0
|
||||
}
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.track();
|
||||
this.getAttendees();
|
||||
this.getTodaysWines();
|
||||
this.getWinners();
|
||||
this.socket = io(window.location.origin);
|
||||
this.socket.on("color_winner", msg => {});
|
||||
|
||||
this.socket.on("disconnect", msg => {
|
||||
this.wasDisconnected = true;
|
||||
});
|
||||
|
||||
this.socket.on("winner", async msg => {
|
||||
this.currentWinnerDrawn = true;
|
||||
this.currentWinner = {
|
||||
name: msg.name,
|
||||
color: msg.color,
|
||||
winnerCount: msg.winner_count
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.getWinners();
|
||||
this.getAttendees();
|
||||
this.currentWinner = null;
|
||||
this.currentWinnerDrawn = false;
|
||||
}, 19250);
|
||||
});
|
||||
this.socket.on("refresh_data", async msg => {
|
||||
this.getAttendees();
|
||||
this.getWinners();
|
||||
});
|
||||
this.socket.on("new_attendee", async msg => {
|
||||
this.getAttendees();
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
},
|
||||
methods: {
|
||||
getWinners() {
|
||||
fetch("/api/lottery/winners")
|
||||
.then(resp => resp.json())
|
||||
.then(response => (this.winners = response.winners));
|
||||
},
|
||||
getTodaysWines() {
|
||||
fetch("/api/lottery/wines")
|
||||
.then(resp => resp.json())
|
||||
.then(response => response.wines)
|
||||
.then(wines => {
|
||||
this.wines = wines;
|
||||
this.todayExists = wines.length > 0;
|
||||
})
|
||||
.catch(_ => (this.todayExists = false));
|
||||
},
|
||||
getAttendees() {
|
||||
fetch("/api/lottery/attendees")
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
const { attendees } = response;
|
||||
this.attendees = attendees || [];
|
||||
|
||||
if (attendees == undefined || attendees.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addValueOfListObjectByKey = (list, key) => list.map(object => object[key]).reduce((a, b) => a + b);
|
||||
|
||||
this.ticketsBought = {
|
||||
red: addValueOfListObjectByKey(attendees, "red"),
|
||||
blue: addValueOfListObjectByKey(attendees, "blue"),
|
||||
green: addValueOfListObjectByKey(attendees, "green"),
|
||||
yellow: addValueOfListObjectByKey(attendees, "yellow")
|
||||
};
|
||||
})
|
||||
.finally(_ => (this.attendeesFetched = true));
|
||||
},
|
||||
scrollToContent() {
|
||||
console.log(window.scrollY);
|
||||
const intersectingHeaderHeight = this.$refs.header.getBoundingClientRect().bottom - 50;
|
||||
const { scrollY } = window;
|
||||
let scrollHeight = intersectingHeaderHeight;
|
||||
if (scrollY > 0) {
|
||||
scrollHeight = intersectingHeaderHeight + scrollY;
|
||||
}
|
||||
|
||||
window.scrollTo({
|
||||
top: scrollHeight,
|
||||
behavior: "smooth"
|
||||
});
|
||||
},
|
||||
track() {
|
||||
window.ga("send", "pageview", "/lottery/game");
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../styles/variables.scss";
|
||||
@import "../styles/media-queries.scss";
|
||||
|
||||
.container {
|
||||
width: 80vw;
|
||||
padding: 0 10vw;
|
||||
|
||||
@include mobile {
|
||||
width: 90vw;
|
||||
padding: 0 5vw;
|
||||
}
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
|
||||
> div,
|
||||
> section {
|
||||
@include mobile {
|
||||
grid-column: span 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
header {
|
||||
h1 {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: 3rem;
|
||||
margin: 4rem 0 2rem;
|
||||
|
||||
@include mobile {
|
||||
margin-top: 1rem;
|
||||
font-size: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
background-color: $primary;
|
||||
padding-bottom: 3rem;
|
||||
margin-bottom: 3rem;
|
||||
|
||||
.instructions {
|
||||
grid-column: 1 / 4;
|
||||
|
||||
@include mobile {
|
||||
grid-column: span 5;
|
||||
}
|
||||
}
|
||||
|
||||
.vipps-qr {
|
||||
grid-column: 4;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.vipps-pill {
|
||||
margin: 0 auto 2rem;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
.call-to-action {
|
||||
grid-column: span 5;
|
||||
}
|
||||
|
||||
ol {
|
||||
font-size: 1.4rem;
|
||||
line-height: 3rem;
|
||||
color: $matte-text-color;
|
||||
|
||||
@include mobile {
|
||||
line-height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.4rem;
|
||||
line-height: 2rem;
|
||||
margin-top: 0;
|
||||
position: relative;
|
||||
|
||||
.vin-link {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
color: $link-color;
|
||||
margin-left: 0.5rem;
|
||||
display: inline-block;
|
||||
transform: rotate(-90deg);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.vin-link {
|
||||
font-weight: 400;
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.todays-raffles {
|
||||
grid-column: 1;
|
||||
|
||||
@include mobile {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.raffle-container {
|
||||
width: 165px;
|
||||
height: 175px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.raffle-element {
|
||||
font-size: 1.6rem;
|
||||
color: $matte-text-color;
|
||||
height: 75px;
|
||||
width: 75px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.winners {
|
||||
grid-column: 2 / 5;
|
||||
|
||||
@include mobile {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.container-attendees {
|
||||
grid-column: 1 / 3;
|
||||
margin-right: 1rem;
|
||||
margin-top: 2rem;
|
||||
|
||||
@include mobile {
|
||||
margin-right: 0;
|
||||
order: 4;
|
||||
}
|
||||
|
||||
> div {
|
||||
padding: 1rem;
|
||||
max-height: 638px;
|
||||
overflow-y: scroll;
|
||||
|
||||
-webkit-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
|
||||
-moz-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.container-chat {
|
||||
grid-column: 3 / 5;
|
||||
margin-left: 1rem;
|
||||
margin-top: 2rem;
|
||||
|
||||
@include mobile {
|
||||
margin-left: 0;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
> div {
|
||||
padding: 1rem;
|
||||
|
||||
-webkit-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
|
||||
-moz-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.todays-wines {
|
||||
width: 80vw;
|
||||
padding: 0 10vw;
|
||||
|
||||
@include mobile {
|
||||
width: 90vw;
|
||||
padding: 0 5vw;
|
||||
}
|
||||
|
||||
h2 {
|
||||
width: 100%;
|
||||
grid-column: 1 / 5;
|
||||
}
|
||||
|
||||
.wine {
|
||||
margin-right: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
116
frontend/components/WinnerPage.vue
Normal file
116
frontend/components/WinnerPage.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!posted" class="container">
|
||||
<h1 v-if="name">Gratulerer {{ name }}!</h1>
|
||||
|
||||
<p v-if="name">
|
||||
Her er valgene for dagens lotteri, du har 10 minutter å velge etter du fikk SMS-en.
|
||||
</p>
|
||||
|
||||
<h1 v-else-if="!turn && wines.length" class="sent-container">Finner ikke noen vinner her..</h1>
|
||||
|
||||
<h1 v-else-if="!turn" class="sent-container">Du må vente på tur..</h1>
|
||||
|
||||
<div class="wines-container" v-if="name">
|
||||
<Wine :wine="wine" v-for="wine in wines" :key="wine">
|
||||
<button @click="chooseWine(wine)" class="vin-button select-wine">Velg denne vinnen</button>
|
||||
</Wine>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="posted" class="sent-container">
|
||||
<h1>Valget ditt er sendt inn!</h1>
|
||||
<p>Du får mer info om henting snarest!</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Wine from "@/ui/Wine";
|
||||
|
||||
export default {
|
||||
components: { Wine },
|
||||
data() {
|
||||
return {
|
||||
id: null,
|
||||
turn: false,
|
||||
name: null,
|
||||
wines: [],
|
||||
posted: false
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
const { id } = this.$router.currentRoute.params;
|
||||
|
||||
this.id = id;
|
||||
this.getPrizes(id);
|
||||
},
|
||||
methods: {
|
||||
getPrizes(id) {
|
||||
fetch(`/api/lottery/prize-distribution/prizes/${id}`)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.wines = response.wines;
|
||||
this.name = response.winner.name;
|
||||
this.turn = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
chooseWine(wine) {
|
||||
const options = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wine })
|
||||
};
|
||||
|
||||
fetch(`/api/lottery/prize-distribution/prize/${this.id}`, options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.$toast.info({ title: `Valgt vin: ${wine.name}` });
|
||||
this.posted = true;
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: "Klarte ikke velge vin :(",
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/global";
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
margin-top: 2rem;
|
||||
padding: 2rem;
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
max-width: 2000px;
|
||||
}
|
||||
|
||||
.wines-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sent-container {
|
||||
width: 100%;
|
||||
height: 90vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.select-wine {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
356
frontend/components/admin/DrawWinnerPage.vue
Normal file
356
frontend/components/admin/DrawWinnerPage.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h1>Trekk vinnere</h1>
|
||||
|
||||
<div class="draw-winner-container">
|
||||
<div v-if="drawingWinner == false" class="draw-container">
|
||||
<input type="number" v-model="winnersToDraw" />
|
||||
<button class="vin-button no-margin" @click="startDrawingWinners">Trekk vinnere</button>
|
||||
</div>
|
||||
|
||||
<div v-if="wines.length" class="wines-left">
|
||||
<span>Antall vin igjen: {{ winnersToDraw }} av {{ wines.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="drawingWinner == true">
|
||||
<p>Trekker vinner {{ winners.length }} av {{ wines.length }}.</p>
|
||||
<p>Neste trekning om {{ secondsLeft }} sekunder av {{ drawTime }}</p>
|
||||
|
||||
<div class="button-container draw-winner-actions">
|
||||
<button class="vin-button danger" @click="stopDraw">
|
||||
Stopp trekning
|
||||
</button>
|
||||
<button
|
||||
class="vin-button"
|
||||
:class="{ 'pulse-button': secondsLeft == 0 }"
|
||||
:disabled="secondsLeft > 0"
|
||||
@click="drawWinner"
|
||||
>
|
||||
Trekk neste
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prize-distribution">
|
||||
<h2>Prisutdeling</h2>
|
||||
|
||||
<div class="button-container">
|
||||
<button class="vin-button" @click="startPrizeDistribution">Start automatisk prisutdeling med SMS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 v-if="winners.length > 0">Vinnere</h2>
|
||||
<div class="winners" v-if="winners.length > 0">
|
||||
<div :class="winner.color + '-raffle'" class="raffle-element" v-for="(winner, index) in winners" :key="index">
|
||||
<span>{{ winner.name }}</span>
|
||||
<span>Phone: {{ winner.phoneNumber }}</span>
|
||||
<span>Rød: {{ winner.red }}</span>
|
||||
<span>Blå: {{ winner.blue }}</span>
|
||||
<span>Grønn: {{ winner.green }}</span>
|
||||
<span>Gul: {{ winner.yellow }}</span>
|
||||
|
||||
<div class="button-container">
|
||||
<button class="vin-button small" @click="editingWinner = editingWinner == winner ? false : winner">
|
||||
{{ editingWinner == winner ? "Lukk" : "Rediger" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="editingWinner == winner" class="edit">
|
||||
<div class="label-div" v-for="key in Object.keys(winner)" :key="key">
|
||||
<label>{{ key }}</label>
|
||||
<input type="text" v-model="winner[key]" :placeholder="key" />
|
||||
</div>
|
||||
|
||||
<div v-if="editingWinner == winner" class="button-container column">
|
||||
<button class="vin-button small" @click="notifyWinner(winner)">
|
||||
Send SMS
|
||||
</button>
|
||||
<button class="vin-button small warning" @click="updateWinner(winner)">
|
||||
Oppdater
|
||||
</button>
|
||||
<button class="vin-button small danger" @click="deleteWinner(winner)">
|
||||
Slett
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-container margin-md" v-if="winners.length > 0">
|
||||
<button class="vin-button danger" v-if="winners.length > 0" @click="deleteAllWinners">
|
||||
Slett virtuelle vinnere
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
wines: [],
|
||||
drawingWinner: false,
|
||||
secondsLeft: 20,
|
||||
drawTime: 20,
|
||||
winners: [],
|
||||
editingWinner: undefined
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.fetchLotterWines();
|
||||
this.fetchLotterWinners();
|
||||
},
|
||||
computed: {
|
||||
winnersToDraw() {
|
||||
if (this.wines.length == undefined || this.winners.length == undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.wines.length - this.winners.length;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
winners(val) {
|
||||
this.$emit("counter", val.length);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchLotterWines() {
|
||||
return fetch("/api/lottery/wines")
|
||||
.then(resp => resp.json())
|
||||
.then(response => (this.wines = response.wines));
|
||||
},
|
||||
fetchLotterWinners() {
|
||||
return fetch("/api/lottery/winners")
|
||||
.then(resp => resp.json())
|
||||
.then(response => (this.winners = response.winners));
|
||||
},
|
||||
countdown() {
|
||||
if (this.drawingWinner == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.secondsLeft > 0) {
|
||||
this.secondsLeft -= 1;
|
||||
|
||||
setTimeout(_ => {
|
||||
this.countdown();
|
||||
}, 1000);
|
||||
} else {
|
||||
if (this.winners.length == this.wines.length) {
|
||||
this.drawingWinner = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
startDrawingWinners() {
|
||||
if (window.confirm("Er du sikker på at du vil trekke vinnere?")) {
|
||||
this.drawWinner();
|
||||
}
|
||||
},
|
||||
drawWinner() {
|
||||
if (this.winnersToDraw <= 0) {
|
||||
this.$toast.error({ title: "No more wines to draw" });
|
||||
return;
|
||||
}
|
||||
this.secondsLeft = this.drawTime;
|
||||
this.drawingWinner = true;
|
||||
|
||||
fetch("/api/lottery/draw")
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
const { winner, color, success, message } = response;
|
||||
|
||||
if (success == false) {
|
||||
this.$toast.error({ title: message });
|
||||
return;
|
||||
}
|
||||
|
||||
winner.color = color;
|
||||
this.winners.push(winner);
|
||||
this.countdown();
|
||||
})
|
||||
.catch(error => {
|
||||
if (error) {
|
||||
this.$toast.error({ title: error.message });
|
||||
}
|
||||
this.drawingWinner = false;
|
||||
});
|
||||
},
|
||||
stopDraw() {
|
||||
this.drawingWinner = false;
|
||||
this.secondsLeft = this.drawTime;
|
||||
},
|
||||
startPrizeDistribution() {
|
||||
if (!window.confirm("Er du sikker på at du vil starte prisutdeling?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.drawingWinner = false;
|
||||
|
||||
const options = { method: "POST" };
|
||||
fetch(`/api/lottery/prize-distribution/start`, options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.$toast.info({
|
||||
title: `Startet prisutdeling. SMS'er sendt ut!`
|
||||
});
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: `Klarte ikke starte prisutdeling`,
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
notifyWinner(winner) {
|
||||
const options = { method: "POST" };
|
||||
|
||||
fetch(`/api/lottery/messages/winner/${winner.id}`, options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.$toast.info({
|
||||
title: `Sendte sms til vinner ${winner.name}.`
|
||||
});
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: `Klarte ikke sende sms til vinner ${winner.name}`,
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
updateWinner(winner) {
|
||||
const options = {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ winner })
|
||||
};
|
||||
|
||||
fetch(`/api/lottery/winner/${winner.id}`, options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.$toast.info({
|
||||
title: `Oppdaterte vinner ${winner.name}.`
|
||||
});
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: `Klarte ikke oppdatere vinner ${winner.name}`,
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
deleteWinner(winner) {
|
||||
if (winner._id != null && window.confirm(`Er du sikker på at du vil slette vinner ${winner.name}?`)) {
|
||||
const options = { method: "DELETE" };
|
||||
|
||||
fetch(`/api/lottery/winner/${winner.id}`, options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.winners = this.winners.filter(w => w.id != winner.id);
|
||||
|
||||
this.$toast.info({
|
||||
title: `Slettet vinner ${winner.name}.`
|
||||
});
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: `Klarte ikke slette vinner ${winner.name}`,
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
deleteAllWinners() {
|
||||
if (window.confirm("Er du sikker på at du vil slette alle vinnere?")) {
|
||||
const options = { method: "DELETE" };
|
||||
|
||||
fetch("/api/lottery/winners", options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.winners = [];
|
||||
this.$toast.info({
|
||||
title: "Slettet alle vinnere."
|
||||
});
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: "Klarte ikke slette vinnere",
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wines-left {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.draw-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
input {
|
||||
font-size: 1.7rem;
|
||||
padding: 7px;
|
||||
margin: 0;
|
||||
width: 10rem;
|
||||
height: 3rem;
|
||||
border: 1px solid rgba(#333333, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.button-container {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.draw-winner-actions {
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.winners {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
.raffle-element {
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
min-height: 250px;
|
||||
font-size: 1.1rem;
|
||||
padding: 1rem;
|
||||
font-weight: 500;
|
||||
// text-align: center;
|
||||
|
||||
-webkit-mask-size: cover;
|
||||
-moz-mask-size: cover;
|
||||
mask-size: cover;
|
||||
flex-direction: column;
|
||||
|
||||
span:first-of-type {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
span.active {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.edit {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
59
frontend/components/admin/PushPage.vue
Normal file
59
frontend/components/admin/PushPage.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h1>Send push melding</h1>
|
||||
|
||||
<div class="notification-element">
|
||||
<div class="label-div">
|
||||
<label for="notification">Melding</label>
|
||||
<textarea id="notification" type="text" rows="3" v-model="pushMessage" placeholder="Push meldingtekst" />
|
||||
</div>
|
||||
<div class="label-div">
|
||||
<label for="notification-link">Push åpner lenke</label>
|
||||
<input id="notification-link" type="text" v-model="pushLink" placeholder="Push-click link" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-container margin-top-sm">
|
||||
<button class="vin-button" @click="sendPush">Send push</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
pushMessage: "",
|
||||
pushLink: "/"
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
sendPush: async function() {
|
||||
const options = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: this.pushMessage,
|
||||
link: this.pushLink
|
||||
})
|
||||
};
|
||||
|
||||
return fetch("/subscription/send-notification", options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.$toast.info({
|
||||
title: "Sendt!"
|
||||
});
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: "Noe gikk galt!",
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
308
frontend/components/admin/RegisterWinePage.vue
Normal file
308
frontend/components/admin/RegisterWinePage.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Register vin</h1>
|
||||
|
||||
<ScanToVinmonopolet @wine="wineFromVinmonopoletScan" v-if="showCamera" />
|
||||
|
||||
<div class="button-container">
|
||||
<button class="vin-button" @click="showCamera = !showCamera">
|
||||
{{ showCamera ? "Skjul camera" : "Legg til vin med camera" }}
|
||||
</button>
|
||||
|
||||
<button class="vin-button" @click="manualyFillInnWine">
|
||||
Legg til en vin manuelt
|
||||
</button>
|
||||
|
||||
<button class="vin-button" @click="showImportLink = !showImportLink">
|
||||
{{ showImportLink ? "Skjul importer fra link" : "Importer fra link" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showImportLink" class="import-from-link">
|
||||
<label>Importer vin fra vinmonopolet link:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Vinmonopol lenke"
|
||||
ref="vinmonopoletLinkInput"
|
||||
autocapitalize="none"
|
||||
@input="addWineByUrl"
|
||||
/>
|
||||
|
||||
<div v-if="linkError" class="error">
|
||||
{{ linkError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="wines.length > 0" class="wine-edit-container">
|
||||
<h2>Dagens registrerte viner</h2>
|
||||
|
||||
<div>
|
||||
<button class="vin-button" @click="sendWines">Send inn dagens viner</button>
|
||||
</div>
|
||||
|
||||
<div class="wines">
|
||||
<wine v-for="wine in wines" :key="wine.id" :wine="wine">
|
||||
<template v-slot:default>
|
||||
<div v-if="editingWine == wine" class="wine-edit">
|
||||
<div class="label-div" v-for="key in Object.keys(wine)" :key="key">
|
||||
<label>{{ key }}</label>
|
||||
<input type="text" v-model="wine[key]" :placeholder="key" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:bottom>
|
||||
<div class="button-container row small">
|
||||
<button v-if="editingWine == wine && wine._id" class="vin-button small warning" @click="updateWine(wine)">
|
||||
Oppdater vin
|
||||
</button>
|
||||
|
||||
<button class="vin-button small" @click="editingWine = editingWine == wine ? false : wine">
|
||||
{{ editingWine == wine ? "Lukk" : "Rediger" }}
|
||||
</button>
|
||||
|
||||
<button class="danger vin-button small" @click="deleteWine(wine)">
|
||||
Slett
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</wine>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-container" v-if="wines.length > 0"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ScanToVinmonopolet from "@/ui/ScanToVinmonopolet";
|
||||
import Wine from "@/ui/Wine";
|
||||
|
||||
export default {
|
||||
components: { ScanToVinmonopolet, Wine },
|
||||
data() {
|
||||
return {
|
||||
wines: [],
|
||||
editingWine: undefined,
|
||||
showCamera: false,
|
||||
showImportLink: false,
|
||||
linkError: undefined
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
wines() {
|
||||
this.$emit("counter", this.wines.length);
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchLotterWines();
|
||||
},
|
||||
methods: {
|
||||
fetchLotterWines() {
|
||||
fetch("/api/lottery/wines")
|
||||
.then(resp => resp.json())
|
||||
.then(response => (this.wines = response.wines));
|
||||
},
|
||||
wineFromVinmonopoletScan(wineResponse) {
|
||||
if (this.wines.map(wine => wine.name).includes(wineResponse.name)) {
|
||||
this.toastText = "Vinen er allerede lagt til.";
|
||||
this.showToast = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastText = "Fant og la til vin:<br>" + wineResponse.name;
|
||||
this.showToast = true;
|
||||
|
||||
this.wines.unshift(wineResponse);
|
||||
},
|
||||
manualyFillInnWine() {
|
||||
fetch("/api/lottery/wine/schema")
|
||||
.then(resp => resp.json())
|
||||
.then(response => response.schema)
|
||||
.then(wineSchema => {
|
||||
this.editingWine = wineSchema;
|
||||
this.wines.unshift(wineSchema);
|
||||
});
|
||||
},
|
||||
addWineByUrl(event) {
|
||||
const url = event.target.value;
|
||||
this.linkError = null;
|
||||
|
||||
if (!url.includes("vinmonopolet.no")) {
|
||||
this.linkError = "Dette er ikke en gydlig vinmonopolet lenke.";
|
||||
return;
|
||||
}
|
||||
const id = url.split("/").pop();
|
||||
|
||||
fetch(`/api/vinmonopolet/wine/by-id/${id}`)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
const { wine } = response;
|
||||
this.wines.unshift(wine);
|
||||
this.$refs.vinmonopoletLinkInput.value = "";
|
||||
});
|
||||
},
|
||||
sendWines() {
|
||||
const filterOutExistingWines = wine => wine["_id"] == null;
|
||||
|
||||
const options = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
wines: this.wines.filter(filterOutExistingWines)
|
||||
})
|
||||
};
|
||||
|
||||
fetch("/api/lottery/wines", options).then(resp => {
|
||||
try {
|
||||
if (resp.ok == false) {
|
||||
throw resp;
|
||||
}
|
||||
|
||||
resp.json().then(response => {
|
||||
if (response.success == false) {
|
||||
throw response;
|
||||
} else {
|
||||
this.$toast.info({
|
||||
title: "Viner sendt inn!",
|
||||
timeout: 4000
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.$toast.error({
|
||||
title: "Feil oppsto ved innsending!",
|
||||
description: error.message,
|
||||
timeout: 4000
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
updateWine(updatedWine) {
|
||||
const options = {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wine: updatedWine })
|
||||
};
|
||||
|
||||
fetch(`/api/lottery/wine/${updatedWine._id}`, options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
this.editingWine = null;
|
||||
|
||||
if (response.success) {
|
||||
this.$toast.info({
|
||||
title: response.message
|
||||
});
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
deleteWine(deletedWine) {
|
||||
this.wines = this.wines.filter(wine => wine.name != deletedWine.name);
|
||||
|
||||
if (deletedWine._id == null) return;
|
||||
|
||||
const options = { method: "DELETE" };
|
||||
fetch(`/api/lottery/wine/${deletedWine._id}`, options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
this.editingWine = null;
|
||||
|
||||
this.$toast.info({
|
||||
title: response.message
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/media-queries.scss";
|
||||
@import "@/styles/variables.scss";
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
margin: 1.5rem 0 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.import-from-link {
|
||||
width: 70%;
|
||||
max-width: 800px;
|
||||
margin: 1.5rem auto 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 1.5rem;
|
||||
min-height: 2rem;
|
||||
line-height: 2rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid black;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
background-color: $light-red;
|
||||
color: $red;
|
||||
font-size: 1.3rem;
|
||||
|
||||
@include mobile {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wine-edit-container {
|
||||
max-width: 1500px;
|
||||
padding: 2rem;
|
||||
margin: 0 auto;
|
||||
|
||||
.wines {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
> div {
|
||||
margin: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
margin-top: 0.7rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
margin-top: 1rem;
|
||||
|
||||
button:not(:last-child) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
475
frontend/components/admin/archiveLotteryPage.vue
Normal file
475
frontend/components/admin/archiveLotteryPage.vue
Normal file
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h1>Arkiver lotteri</h1>
|
||||
|
||||
<h2>Registrer lodd kjøpt</h2>
|
||||
|
||||
<div class="colors">
|
||||
<div v-for="color in lotteryColors" :class="color.key + ' colors-box'" :key="color">
|
||||
<div class="colors-overlay">
|
||||
<p>{{ color.name }} kjøpt</p>
|
||||
<input v-model.number="color.value" min="0" :placeholder="0" type="number" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="label-div">
|
||||
<label>Penger mottatt på vipps:</label>
|
||||
<input v-model.number="payed" placeholder="NOK" type="number" :step="price || 1" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="wines.length > 0">
|
||||
<h2>Vinneres vin-valg</h2>
|
||||
|
||||
<div class="winner-container">
|
||||
<wine v-for="wine in wines" :key="wine.id" :wine="wine">
|
||||
<div class="label-div">
|
||||
<label for="potential-winner-name">Virtuelle vinnere</label>
|
||||
<select id="potential-winner-name" type="text" placeholder="Navn" v-model="wine.winner">
|
||||
<option v-for="winner in winners" :value="winner">{{ winner.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="winner-element">
|
||||
<div class="color-selector">
|
||||
<div class="label-div">
|
||||
<label>Farge vunnet</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="blue"
|
||||
:class="{ active: wine.winner.color == 'blue' }"
|
||||
@click="wine.winner.color = 'blue'"
|
||||
></button>
|
||||
<button
|
||||
class="red"
|
||||
:class="{ active: wine.winner.color == 'red' }"
|
||||
@click="wine.winner.color = 'red'"
|
||||
></button>
|
||||
<button
|
||||
class="green"
|
||||
:class="{ active: wine.winner.color == 'green' }"
|
||||
@click="wine.winner.color = 'green'"
|
||||
></button>
|
||||
<button
|
||||
class="yellow"
|
||||
:class="{ active: wine.winner.color == 'yellow' }"
|
||||
@click="wine.winner.color = 'yellow'"
|
||||
></button>
|
||||
</div>
|
||||
<div class="label-div">
|
||||
<label for="winner-name">Navn vinner</label>
|
||||
<input id="winner-name" type="text" placeholder="Navn" v-model="wine.winner.name" />
|
||||
</div>
|
||||
</div>
|
||||
</wine>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="wines.length > 0" class="button-container column">
|
||||
<p v-if="todaysAlreadySubmitted" class="info-message">
|
||||
Lotteriet er arkivert!<br />Du kan nå slette dagens viner, deltakere & vinnere for å tilbakestille til neste
|
||||
ukes lotteri.
|
||||
</p>
|
||||
|
||||
<button class="vin-button" @click="archiveLottery" :disabled="todaysAlreadySubmitted">
|
||||
{{ todaysAlreadySubmitted == false ? "Send inn og arkiver" : "Dagens lotteri er allerede arkivert" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { dateString } from "@/utils";
|
||||
import Wine from "@/ui/Wine";
|
||||
|
||||
export default {
|
||||
components: { Wine },
|
||||
data() {
|
||||
return {
|
||||
payed: undefined,
|
||||
todaysAlreadySubmitted: false,
|
||||
wines: [],
|
||||
winners: [],
|
||||
attendees: [],
|
||||
lotteryColors: [
|
||||
{ value: 0, name: "Blå", key: "blue" },
|
||||
{ value: 0, name: "Rød", key: "red" },
|
||||
{ value: 0, name: "Grønn", key: "green" },
|
||||
{ value: 0, name: "Gul", key: "yellow" }
|
||||
],
|
||||
price: __PRICE__ || 10
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.fetchLotteryWines();
|
||||
this.fetchLotteryWinners();
|
||||
this.fetchLotteryAttendees();
|
||||
this.checkIfAlreadySubmittedForToday();
|
||||
},
|
||||
watch: {
|
||||
lotteryColors: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.payed = this.getRaffleValue();
|
||||
}
|
||||
},
|
||||
payed(val) {
|
||||
this.$emit("counter", val);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
wineWithWinnerMapper(wine) {
|
||||
if (wine.winner == undefined) {
|
||||
wine.winner = {
|
||||
name: undefined,
|
||||
color: undefined
|
||||
};
|
||||
}
|
||||
return wine;
|
||||
},
|
||||
fetchLotteryWines() {
|
||||
return fetch("/api/lottery/wines")
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.wines = response.wines.map(this.wineWithWinnerMapper);
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: "Klarte ikke hente viner.",
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
fetchLotteryWinners() {
|
||||
return fetch("/api/lottery/winners")
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.winners = response.winners;
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: "Klarte ikke hente vinnere.",
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
fetchLotteryAttendees() {
|
||||
return fetch("/api/lottery/attendees")
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success && response.attendees) {
|
||||
this.attendees = response.attendees;
|
||||
this.updateLotteryColorsWithAttendees(response.attendees)
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: "Klarte ikke hente deltakere.",
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
checkIfAlreadySubmittedForToday() {
|
||||
return fetch("/api/lottery/latest")
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
const getDay = d => new Date(d).getDate();
|
||||
|
||||
if (response.lottery.date && (getDay(response.lottery.date) == getDay(new Date()))) {
|
||||
this.todaysAlreadySubmitted = true;
|
||||
} else {
|
||||
this.todaysAlreadySubmitted = false;
|
||||
}
|
||||
})
|
||||
},
|
||||
updateLotteryColorsWithAttendees(attendees) {
|
||||
this.attendees.map(attendee => {
|
||||
this.lotteryColors.map(color => (color.value += attendee[color.key]));
|
||||
});
|
||||
},
|
||||
getRaffleValue() {
|
||||
let rafflesBought = 0;
|
||||
this.lotteryColors.map(color => rafflesBought += Number(color.value));
|
||||
|
||||
return rafflesBought * this.price;
|
||||
},
|
||||
archiveLottery: async function(event) {
|
||||
const validation = this.wines.every(wine => {
|
||||
if (wine.winner.name == undefined || wine.winner.name == "") {
|
||||
this.$toast.error({
|
||||
title: `Navn på vinner må defineres for vin: ${wine.name}`
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (wine.winner.color == undefined || wine.winner.color == "") {
|
||||
this.$toast.error({
|
||||
title: `Farge vunnet må defineres for vin: ${wine.name}`
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validation == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rafflesPayload = {};
|
||||
this.lotteryColors.map(el => rafflesPayload.[el.key] = el.value);
|
||||
|
||||
let stolen = 0;
|
||||
const payedDiff = this.payed - this.getRaffleValue()
|
||||
if (payedDiff) {
|
||||
stolen = payedDiff / this.price;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
wines: this.wines,
|
||||
raffles: rafflesPayload,
|
||||
stolen: stolen
|
||||
};
|
||||
|
||||
const options = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
lottery: payload
|
||||
})
|
||||
};
|
||||
|
||||
return fetch("/api/lottery/archive", options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.todaysAlreadySubmitted = true;
|
||||
this.$toast.info({
|
||||
title: "Lotteriet er sendt inn og arkivert! Du kan nå slette viner, deltakere & vinnere slettes.",
|
||||
timeout: 10000
|
||||
});
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: "Noe gikk galt under innsending!",
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/global.scss";
|
||||
@import "@/styles/media-queries.scss";
|
||||
|
||||
select {
|
||||
margin: 0 0 auto;
|
||||
height: 2rem;
|
||||
min-width: 0;
|
||||
width: 98%;
|
||||
padding: 1%;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 0 1.5rem 3rem;
|
||||
|
||||
@include desktop {
|
||||
max-width: 60vw;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
.winner-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-around;
|
||||
|
||||
> div {
|
||||
margin: 1rem;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.info-message {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
background-color: var(--light-blue);
|
||||
color: var(--matte-text-color);
|
||||
border-radius: 4px;
|
||||
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.winner-element {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> div {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.color-selector {
|
||||
margin-bottom: 0.65rem;
|
||||
margin-right: 1rem;
|
||||
|
||||
@include desktop {
|
||||
min-width: 175px;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
max-width: 25vw;
|
||||
}
|
||||
|
||||
.active {
|
||||
border: 2px solid unset;
|
||||
|
||||
&.green {
|
||||
border-color: $green;
|
||||
}
|
||||
&.blue {
|
||||
border-color: $dark-blue;
|
||||
}
|
||||
&.red {
|
||||
border-color: $red;
|
||||
}
|
||||
&.yellow {
|
||||
border-color: $dark-yellow;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
border: 2px solid transparent;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
|
||||
// disable-dbl-tap-zoom
|
||||
touch-action: manipulation;
|
||||
|
||||
@include mobile {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
&.green {
|
||||
background: #c8f9df;
|
||||
}
|
||||
&.blue {
|
||||
background: #d4f2fe;
|
||||
}
|
||||
&.red {
|
||||
background: #fbd7de;
|
||||
}
|
||||
&.yellow {
|
||||
background: #fff6d6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.colors {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
|
||||
@include mobile {
|
||||
margin: 1.8rem auto 0;
|
||||
}
|
||||
|
||||
.label-div {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.colors-box {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
margin: 20px;
|
||||
-webkit-mask-image: url(/public/assets/images/lodd.svg);
|
||||
background-repeat: no-repeat;
|
||||
mask-image: url(/public/assets/images/lodd.svg);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@include mobile {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.colors-overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 0.5rem;
|
||||
position: relative;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 70%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-size: 3rem;
|
||||
height: unset;
|
||||
max-height: unset;
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.green,
|
||||
.green .colors-overlay > input {
|
||||
background-color: $light-green;
|
||||
color: $green;
|
||||
}
|
||||
|
||||
.blue,
|
||||
.blue .colors-overlay > input {
|
||||
background-color: $light-blue;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
.yellow,
|
||||
.yellow .colors-overlay > input {
|
||||
background-color: $light-yellow;
|
||||
color: $yellow;
|
||||
}
|
||||
|
||||
.red,
|
||||
.red .colors-overlay > input {
|
||||
background-color: $light-red;
|
||||
color: $red;
|
||||
}
|
||||
</style>
|
||||
329
frontend/components/admin/registerAttendeePage.vue
Normal file
329
frontend/components/admin/registerAttendeePage.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h1>Legg til deltaker</h1>
|
||||
|
||||
<div class="attendee-registration-container">
|
||||
<div class="row flex">
|
||||
<div class="label-div">
|
||||
<label for="name" ref="name">Navn</label>
|
||||
<input id="name" type="text" placeholder="Navn" v-model="name" />
|
||||
|
||||
<ul class="autocomplete" v-if="autocompleteAttendees.length">
|
||||
<a
|
||||
v-for="attendee in autocompleteAttendees"
|
||||
tabindex="0"
|
||||
@keydown.enter="setName(attendee)"
|
||||
@keydown.space="setName(attendee)"
|
||||
>
|
||||
<li @click="setName(attendee)">
|
||||
{{ attendee }}
|
||||
</li>
|
||||
</a>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="label-div">
|
||||
<label for="phoneNumber">Telefonnummer</label>
|
||||
<input
|
||||
id="phoneNumber"
|
||||
ref="phone"
|
||||
type="phone"
|
||||
pattern="[0-9]"
|
||||
placeholder="Telefonnummer"
|
||||
v-model="phoneNumber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="label-div">
|
||||
<label for="randomColors">Tilfeldig farger?</label>
|
||||
<input id="randomColors" type="checkbox" placeholder="Tilfeldig farger" v-model="randomColors" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!randomColors">
|
||||
<div class="row flex">
|
||||
<div class="label-div" v-for="color in colors">
|
||||
<label :for="color.key">{{ color.name }}</label>
|
||||
<input :id="color.key" type="number" :placeholder="color.name" v-model="color.value" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="vin-button" @click="sendAttendee">Send deltaker</button>
|
||||
|
||||
<div v-if="randomColors">
|
||||
<RaffleGenerator @colors="setWithRandomColors" :generateOnInit="true" :compact="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Attendees :attendees="attendees" :admin="isAdmin" />
|
||||
|
||||
<div v-if="attendees.length" class="button-container" style="margin-top: 2rem;">
|
||||
<button class="vin-button danger" @click="deleteAllAttendees">
|
||||
Slett alle deltakere
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import io from "socket.io-client";
|
||||
import Attendees from "@/ui/Attendees";
|
||||
import RaffleGenerator from "@/ui/RaffleGenerator";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Attendees,
|
||||
RaffleGenerator
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
red: {
|
||||
name: "Rød",
|
||||
key: "red",
|
||||
value: 0
|
||||
},
|
||||
blue: {
|
||||
name: "Blå",
|
||||
key: "blue",
|
||||
value: 0
|
||||
},
|
||||
green: {
|
||||
name: "Grønn",
|
||||
key: "green",
|
||||
value: 0
|
||||
},
|
||||
yellow: {
|
||||
name: "Gul",
|
||||
key: "yellow",
|
||||
value: 0
|
||||
},
|
||||
isAdmin: false,
|
||||
name: null,
|
||||
phoneNumber: null,
|
||||
raffles: 0,
|
||||
randomColors: false,
|
||||
attendees: [],
|
||||
autocompleteAttendees: [],
|
||||
socket: null,
|
||||
previousAttendees: []
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
attendees() {
|
||||
this.$emit("counter", this.attendees.length || 0);
|
||||
},
|
||||
randomColors(val) {
|
||||
if (val == false) {
|
||||
this.colors.map(color => (color.value = 0));
|
||||
}
|
||||
},
|
||||
name(newVal, oldVal) {
|
||||
if (newVal == "" || newVal == null) {
|
||||
this.autocompleteAttendees = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.autocompleteAttendees.includes(newVal)) {
|
||||
this.autocompleteAttendees = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.previousAttendees.length == 0) {
|
||||
fetch(`/api/history`)
|
||||
.then(resp => resp.json())
|
||||
.then(response => (this.previousAttendees = response.winners));
|
||||
}
|
||||
|
||||
this.autocompleteAttendees = this.previousAttendees
|
||||
.filter(attendee => attendee.name.toLowerCase().includes(newVal))
|
||||
.map(attendee => attendee.name);
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getAttendees();
|
||||
},
|
||||
computed: {
|
||||
colors() {
|
||||
return [this.red, this.blue, this.green, this.yellow];
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setName(name) {
|
||||
this.name = name;
|
||||
this.$refs.phone.focus();
|
||||
},
|
||||
setWithRandomColors(colors) {
|
||||
Object.keys(colors).forEach(color => (this[color].value = colors[color]));
|
||||
},
|
||||
checkIfAdmin(resp) {
|
||||
this.isAdmin = resp.headers.get("vinlottis-admin") == "true" || false;
|
||||
return resp;
|
||||
},
|
||||
getAttendees: async function() {
|
||||
return fetch("/api/lottery/attendees")
|
||||
.then(resp => this.checkIfAdmin(resp))
|
||||
.then(resp => resp.json())
|
||||
.then(response => (this.attendees = response.attendees));
|
||||
},
|
||||
sendAttendee: async function() {
|
||||
const { red, blue, green, yellow } = this;
|
||||
|
||||
if (red.value == 0 && blue.value == 0 && green.value == 0 && yellow.value == 0) {
|
||||
this.$toast.error({ title: "Ingen farger valgt!" });
|
||||
return;
|
||||
}
|
||||
if (this.name == 0 && this.phoneNumber) {
|
||||
this.$toast.error({ title: "Ingen navn eller tlf satt!" });
|
||||
return;
|
||||
}
|
||||
|
||||
const attendee = {
|
||||
name: this.name,
|
||||
phoneNumber: Number(this.phoneNumber),
|
||||
red: Number(red.value),
|
||||
blue: Number(blue.value),
|
||||
green: Number(green.value),
|
||||
yellow: Number(yellow.value),
|
||||
raffles: Number(this.raffles)
|
||||
};
|
||||
|
||||
const options = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ attendee })
|
||||
};
|
||||
|
||||
return fetch("/api/lottery/attendee", options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success == true) {
|
||||
this.$toast.info({
|
||||
title: `Sendt inn deltaker: ${this.name}`,
|
||||
timeout: 4000
|
||||
});
|
||||
|
||||
this.name = "";
|
||||
this.phoneNumber = null;
|
||||
this.yellow.value = 0;
|
||||
this.green.value = 0;
|
||||
this.red.value = 0;
|
||||
this.blue.value = 0;
|
||||
this.randomColors = false;
|
||||
|
||||
this.$refs.name.focus();
|
||||
this.getAttendees();
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: `Klarte ikke sende deltaker`,
|
||||
description: response.message,
|
||||
timeout: 4000
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
deleteAllAttendees() {
|
||||
if (window.confirm("Er du sikker på at du vil slette alle deltakere?")) {
|
||||
const options = { method: "DELETE" };
|
||||
|
||||
fetch("/api/lottery/attendees", options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.attendees = [];
|
||||
this.$toast.info({
|
||||
title: "Slettet alle deltakere."
|
||||
});
|
||||
} else {
|
||||
this.$toast.error({
|
||||
title: "Klarte ikke slette deltakere",
|
||||
description: response.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style lang="scss">
|
||||
// global styling for disabling height of attendee class
|
||||
@import "@/styles/media-queries.scss";
|
||||
|
||||
.attendee {
|
||||
max-height: unset;
|
||||
|
||||
.raffle-element {
|
||||
margin: 0;
|
||||
|
||||
@include mobile {
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/global.scss";
|
||||
@import "@/styles/media-queries.scss";
|
||||
|
||||
.attendee-registration-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.row.flex .label-div {
|
||||
margin-right: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
z-index: 10;
|
||||
background-color: white;
|
||||
border: 1px solid #e1e4e8;
|
||||
|
||||
& li {
|
||||
padding: 1rem;
|
||||
font-size: 1.1rem;
|
||||
|
||||
&:hover {
|
||||
background-color: #e1e4e8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 90%;
|
||||
margin: 2rem auto;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 0 1.5rem 3rem;
|
||||
|
||||
@include desktop {
|
||||
max-width: 60vw;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
#randomColors {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:checked::after {
|
||||
content: "✅";
|
||||
}
|
||||
|
||||
&::after {
|
||||
font-size: 2.1rem;
|
||||
content: "❌";
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2
frontend/mixins/EventBus.js
Normal file
2
frontend/mixins/EventBus.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import Vue from "vue";
|
||||
export default new Vue();
|
||||
@@ -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;
|
||||
166
frontend/plugins/Toast/Toast.vue
Normal file
166
frontend/plugins/Toast/Toast.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<div class="toast" :class="type" v-if="show" ref="toast">
|
||||
<div class="message">
|
||||
<span v-html="title"></span>
|
||||
<span class="description" v-if="description">
|
||||
{{ description }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<button @click="dismiss">Lukk</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
type: this.$root.type || "info",
|
||||
title: this.$root.title || undefined,
|
||||
description: this.$root.description || undefined,
|
||||
image: this.$root.image || undefined,
|
||||
link: this.$root.link || undefined,
|
||||
timeout: this.$root.timeout || 4500,
|
||||
show: false,
|
||||
mouseover: false,
|
||||
timedOut: false
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
// Here we set show when mounted in-order to get the transition animation to be displayed correctly
|
||||
this.show = true;
|
||||
const timeout = setTimeout(() => {
|
||||
console.log("Your toast time is up 👋");
|
||||
|
||||
if (this.mouseover === false) {
|
||||
this.show = false;
|
||||
} else {
|
||||
this.timedOut = true;
|
||||
}
|
||||
}, this.timeout);
|
||||
|
||||
setTimeout(() => {
|
||||
const { toast } = this.$refs;
|
||||
|
||||
if (toast) {
|
||||
toast.addEventListener("mouseenter", _ => {
|
||||
this.mouseover = true;
|
||||
});
|
||||
|
||||
toast.addEventListener("mouseleave", _ => {
|
||||
this.mouseover = false;
|
||||
|
||||
if (this.timedOut === true) {
|
||||
this.show = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 10);
|
||||
},
|
||||
methods: {
|
||||
dismiss() {
|
||||
this.show = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/media-queries.scss";
|
||||
|
||||
.slide-enter-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.slide-enter,
|
||||
.slide-leave-to {
|
||||
transform: translateY(100vh);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide-leave-active {
|
||||
transition: all 2s ease;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.3rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
background: #2d2d2d;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 80vw;
|
||||
|
||||
@include mobile {
|
||||
width: 85vw;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
& span {
|
||||
color: white;
|
||||
|
||||
&.description {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
& .button-container {
|
||||
& button {
|
||||
color: #2d2d2d;
|
||||
background-color: white;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin: 0 3px;
|
||||
font-size: 0.8rem;
|
||||
height: max-content;
|
||||
border: 0;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:active {
|
||||
background: #2d2d2d;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
background-color: #5bc2a1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: #2d2d2d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
border-left: 6px solid #f6993f;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: var(--red);
|
||||
|
||||
button {
|
||||
color: var(--dark-red);
|
||||
|
||||
&:active {
|
||||
background-color: var(--dark-red);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
frontend/plugins/Toast/index.js
Normal file
51
frontend/plugins/Toast/index.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import Vue from "vue";
|
||||
import ToastComponent from "./Toast";
|
||||
|
||||
const optionsDefaults = {
|
||||
data: {
|
||||
type: "info",
|
||||
show: true,
|
||||
timeout: 4500,
|
||||
|
||||
onCreate(created = null) {},
|
||||
onEdit(editted = null) {},
|
||||
onRemove(removed = null) {}
|
||||
}
|
||||
};
|
||||
|
||||
function toast(options) {
|
||||
// merge the default options with the passed options.
|
||||
const root = new Vue({
|
||||
data: {
|
||||
...optionsDefaults.data,
|
||||
...options
|
||||
},
|
||||
render: createElement => createElement(ToastComponent)
|
||||
});
|
||||
|
||||
root.$mount(document.body.appendChild(document.createElement("div")));
|
||||
}
|
||||
|
||||
export default {
|
||||
install(vue) {
|
||||
console.log("Installing toast plugin!");
|
||||
|
||||
Vue.prototype.$toast = {
|
||||
info(options) {
|
||||
toast({ type: "info", ...options });
|
||||
},
|
||||
success(options) {
|
||||
toast({ type: "success", ...options });
|
||||
},
|
||||
warning(options) {
|
||||
toast({ type: "warning", ...options });
|
||||
},
|
||||
error(options) {
|
||||
toast({ type: "error", ...options });
|
||||
},
|
||||
simple(options) {
|
||||
toast({ type: "simple", ...options });
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
172
frontend/router.js
Normal file
172
frontend/router.js
Normal file
@@ -0,0 +1,172 @@
|
||||
const VinlottisPage = () =>
|
||||
import(
|
||||
/* webpackChunkName: "landing-page" */
|
||||
"@/components/VinlottisPage"
|
||||
);
|
||||
const VirtualLotteryPage = () =>
|
||||
import(
|
||||
/* webpackChunkName: "landing-page" */
|
||||
"@/components/VirtualLotteryPage"
|
||||
);
|
||||
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 SalgsbetingelserPage = () =>
|
||||
import(
|
||||
/* webpackChunkName: "sub-pages" */
|
||||
"@/components/SalgsbetingelserPage"
|
||||
);
|
||||
|
||||
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: VirtualLotteryPage,
|
||||
},
|
||||
{
|
||||
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: "/generate/",
|
||||
component: GeneratePage,
|
||||
},
|
||||
{
|
||||
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: "/anbefal",
|
||||
name: "Anbefal ny vin",
|
||||
component: RequestWine,
|
||||
},
|
||||
{
|
||||
path: "/request",
|
||||
name: "Etterspør vin",
|
||||
component: RequestWine,
|
||||
},
|
||||
{
|
||||
path: "/anbefalte",
|
||||
name: "Anbefalte viner",
|
||||
component: AllRequestedWines,
|
||||
},
|
||||
{
|
||||
path: "/requested-wines",
|
||||
name: "Etterspurte vin",
|
||||
component: AllRequestedWines,
|
||||
},
|
||||
{
|
||||
path: "/salgsbetingelser",
|
||||
name: "Salgsbetingelser",
|
||||
component: SalgsbetingelserPage,
|
||||
},
|
||||
];
|
||||
|
||||
export { routes };
|
||||
22
frontend/styles/animations.scss
Normal file
22
frontend/styles/animations.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
.pulse {
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 1);
|
||||
transform: scale(1);
|
||||
animation: pulse 2s infinite;
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
240
frontend/styles/banner.scss
Normal file
240
frontend/styles/banner.scss
Normal file
@@ -0,0 +1,240 @@
|
||||
@import "./media-queries.scss";
|
||||
@import "./variables.scss";
|
||||
|
||||
.top-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 0.5fr 1fr 0.5fr;
|
||||
grid-template-areas: "menu logo clock";
|
||||
grid-gap: 1em;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
background-color: $primary;
|
||||
|
||||
// ios homescreen app whitespace above header fix.
|
||||
&::before {
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 3rem;
|
||||
position: absolute;
|
||||
top: -3rem;
|
||||
background-color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.company-logo {
|
||||
grid-area: logo;
|
||||
}
|
||||
|
||||
.menu-toggle-container {
|
||||
grid-area: menu;
|
||||
color: #1e1e1e;
|
||||
border-radius: 50% 50%;
|
||||
z-index: 3;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
height: 3px;
|
||||
width: 18px;
|
||||
background: #111;
|
||||
z-index: 1;
|
||||
transform-origin: 4px 0px;
|
||||
transition:
|
||||
transform 0.5s cubic-bezier(0.77,0.2,0.05,1.0),
|
||||
background 0.5s cubic-bezier(0.77,0.2,0.05,1.0),
|
||||
opacity 0.55s ease;
|
||||
}
|
||||
|
||||
span:first-child {
|
||||
transform-origin: 0% 0%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
span:nth-last-child(2) {
|
||||
transform-origin: 0% 100%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&.open {
|
||||
span{
|
||||
opacity: 1;
|
||||
transform: rotate(-45deg) translate(2px, -2px);
|
||||
background: #232323;
|
||||
}
|
||||
|
||||
span:nth-last-child(2) {
|
||||
opacity: 0;
|
||||
transform: rotate(0deg) scale(0.2, 0.2);
|
||||
}
|
||||
|
||||
span:nth-last-child(3) {
|
||||
transform: rotate(45deg) translate(3.5px, -2px);
|
||||
}
|
||||
}
|
||||
|
||||
&.open {
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
background-color: $primary;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.5s ease-out;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.collapsed {
|
||||
max-height: 0%;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
.icon {
|
||||
opacity: 1;
|
||||
right: -2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 35%;
|
||||
right: 0;
|
||||
color: $link-color;
|
||||
font-size: 1.4rem;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
}
|
||||
|
||||
.single-route {
|
||||
font-size: 3em;
|
||||
outline: 0;
|
||||
text-decoration: none;
|
||||
color: #1e1e1e;
|
||||
border-bottom: 4px solid transparent;
|
||||
display: block;
|
||||
|
||||
&.open {
|
||||
-webkit-animation: fadeInFromNone 3s ease-out;
|
||||
-moz-animation: fadeInFromNone 3s ease-out;
|
||||
-o-animation: fadeInFromNone 3s ease-out;
|
||||
animation: fadeInFromNone 3s ease-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
border-color: $link-color;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes fadeInFromNone {
|
||||
0% {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@-moz-keyframes fadeInFromNone {
|
||||
0% {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@-o-keyframes fadeInFromNone {
|
||||
0% {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInFromNone {
|
||||
0% {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.clock {
|
||||
grid-area: clock;
|
||||
text-decoration: none;
|
||||
color: #333333;
|
||||
display: flex;
|
||||
font-family: Arial;
|
||||
@include mobile {
|
||||
font-size: 0.8em;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
h2 {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
392
frontend/styles/global.scss
Normal file
392
frontend/styles/global.scss
Normal file
@@ -0,0 +1,392 @@
|
||||
@import "./media-queries.scss";
|
||||
@import "./variables.scss";
|
||||
|
||||
@font-face {
|
||||
font-family: "knowit";
|
||||
font-weight: 600;
|
||||
src: url("/public/assets/fonts/bold.woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "knowit";
|
||||
font-weight: 300;
|
||||
src: url("/public/assets/fonts/regular.woff");
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
width: fit-content;
|
||||
margin: 2rem auto;
|
||||
text-align: center;
|
||||
font-family: knowit, Arial;
|
||||
margin-top: 3.8rem;
|
||||
font-weight: 600;
|
||||
|
||||
@include mobile {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.subtext {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 1.22rem;
|
||||
|
||||
@include mobile {
|
||||
margin-top: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.label-div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
|
||||
label {
|
||||
margin-top: 0.7rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
margin-bottom: auto;
|
||||
height: 2rem;
|
||||
padding: 0.5rem;
|
||||
min-width: 0;
|
||||
width: 98%;
|
||||
padding: 1%;
|
||||
}
|
||||
}
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-right: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
&.column {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
> * {
|
||||
margin-right: unset;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
&:not(.row) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-right: unset;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
font-size: 1.1rem;
|
||||
border: 1px solid rgba(#333333, 0.3);
|
||||
}
|
||||
|
||||
.vin-button {
|
||||
font-family: Arial;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background: $primary;
|
||||
color: #333;
|
||||
padding: 10px 30px;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
width: fit-content;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.3rem;
|
||||
height: 4rem;
|
||||
max-height: 4rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: transform 0.5s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
// disable-dbl-tap-zoom
|
||||
touch-action: manipulation;
|
||||
|
||||
&.auto-height {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background-color: #f9826c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: $red;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.07), 0 4px 8px rgba(0, 0, 0, 0.07),
|
||||
0 8px 16px rgba(0, 0, 0, 0.07), 0 16px 32px rgba(0, 0, 0, 0.07), 0 32px 64px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.02) translateZ(0);
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: min-content;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-button:not(:hover) {
|
||||
animation: pulse 1.5s infinite cubic-bezier(0.66, 0, 0, 1);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.cursor {
|
||||
&-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vin-link {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid $link-color;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
text-decoration: none;
|
||||
color: $matte-text-color;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: $link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.margin {
|
||||
&-md {
|
||||
margin: 3rem;
|
||||
}
|
||||
&-sm {
|
||||
margin: 1rem;
|
||||
}
|
||||
&-0 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.margin-top {
|
||||
&-md {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
&-sm {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
&-0 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
.margin-left {
|
||||
&-md {
|
||||
margin-left: 3rem;
|
||||
}
|
||||
&-sm {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
&-0 {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
.margin-right {
|
||||
&-md {
|
||||
margin-right: 3rem;
|
||||
}
|
||||
&-sm {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
&-0 {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.margin-bottom {
|
||||
&-md {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
&-sm {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
&-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.width {
|
||||
&-100 {
|
||||
width: 100%;
|
||||
}
|
||||
&-75 {
|
||||
width: 75%;
|
||||
}
|
||||
&-50 {
|
||||
width: 50%;
|
||||
}
|
||||
&-25 {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor {
|
||||
&-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.no-margin {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.wines-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-gap: 2rem;
|
||||
}
|
||||
|
||||
.raffle-element {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
|
||||
margin: 20px 0;
|
||||
color: #333333;
|
||||
|
||||
-webkit-mask-image: url(/public/assets/images/lodd.svg);
|
||||
background-repeat: no-repeat;
|
||||
mask-image: url(/public/assets/images/lodd.svg);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
&.green-raffle {
|
||||
background-color: $light-green;
|
||||
}
|
||||
|
||||
&.blue-raffle {
|
||||
background-color: $light-blue;
|
||||
}
|
||||
|
||||
&.yellow-raffle {
|
||||
background-color: $light-yellow;
|
||||
}
|
||||
|
||||
&.red-raffle {
|
||||
background-color: $light-red;
|
||||
}
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin raffle {
|
||||
padding-bottom: 50px;
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 25px;
|
||||
height: 50px;
|
||||
background: radial-gradient(closest-side, #fff, #fff 50%, transparent 50%);
|
||||
background-size: 50px 50px;
|
||||
background-position: 0 25px;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
&::after {
|
||||
background: radial-gradient(closest-side, transparent, transparent 50%, #fff 50%);
|
||||
background-size: 50px 50px;
|
||||
background-position: 25px -25px;
|
||||
bottom: -25px;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
@include mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
@include desktop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -68,4 +68,5 @@ form {
|
||||
width: calc(100% - 5rem);
|
||||
background-color: $light-red;
|
||||
color: $red;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
40
frontend/styles/media-queries.scss
Normal file
40
frontend/styles/media-queries.scss
Normal file
@@ -0,0 +1,40 @@
|
||||
$mobile-width: 768px;
|
||||
$tablet-max: 1200px;
|
||||
$desktop-max: 2004px;
|
||||
|
||||
|
||||
@mixin mobile {
|
||||
@media (max-width: #{$mobile-width}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin tablet {
|
||||
@media (min-width: #{$mobile-width + 1px}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin desktop {
|
||||
@media (min-width: #{$tablet-max + 1px}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin widescreen {
|
||||
@media (min-width: #{$desktop-max + 1px}){
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
@include mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
@include tablet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
52
frontend/styles/positioning.scss
Normal file
52
frontend/styles/positioning.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
@import "@/styles/media-queries.scss";
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
|
||||
&.column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.row {
|
||||
flex-direction: row;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
&.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
&.justify-space-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
&.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
&.justify-start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.float {
|
||||
&-left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
&-right {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
49
frontend/styles/variables.scss
Normal file
49
frontend/styles/variables.scss
Normal file
@@ -0,0 +1,49 @@
|
||||
body {
|
||||
--primary: #b7debd;
|
||||
|
||||
--light-green: #c8f9df;
|
||||
--green: #0be881;
|
||||
--dark-green: #0ed277;
|
||||
|
||||
--light-blue: #d4f2fe;
|
||||
--blue: #4bcffa;
|
||||
--dark-blue: #24acda;
|
||||
|
||||
--light-yellow: #fff6d6;
|
||||
--yellow: #ffde5d;
|
||||
--dark-yellow: #ecc31d;
|
||||
|
||||
--light-red: #fbd7de;
|
||||
--red: #ef5878;
|
||||
--dark-red: #ec3b61;
|
||||
|
||||
--link-color: #ff5fff;
|
||||
--underlinenav-text: #e1e4e8;
|
||||
--underlinenav-text-active: #f9826c;
|
||||
--underlinenav-text-hover: #d1d5da;
|
||||
|
||||
--matte-text-color: #333333;
|
||||
}
|
||||
|
||||
$primary: var(--primary);
|
||||
|
||||
$light-green: var(--light-green);
|
||||
$green: var(--green);
|
||||
$dark-green: var(--dark-green);
|
||||
|
||||
$light-blue: var(--light-blue);
|
||||
$blue: var(--blue);
|
||||
$dark-blue: var(--dark-blue);
|
||||
|
||||
$light-yellow: var(--light-yellow);
|
||||
$yellow: var(--yellow);
|
||||
$dark-yellow: var(--dark-yellow);
|
||||
|
||||
$light-red: var(--light-red);
|
||||
$red: var(--red);
|
||||
$dark-red: var(--dark-red);
|
||||
|
||||
$link-color: var(--link-color);
|
||||
$underlinenav-text-active: var(--underlinenav-text-active);
|
||||
|
||||
$matte-text-color: var(--matte-text-color);
|
||||
122
frontend/styles/vinlottis-icons.css
Normal file
122
frontend/styles/vinlottis-icons.css
Normal file
@@ -0,0 +1,122 @@
|
||||
@font-face {
|
||||
font-family: 'vinlottis-icons';
|
||||
src:
|
||||
url('/public/assets/fonts/vinlottis-icons/vinlottis-icons.ttf?95xu5r') format('truetype'),
|
||||
url('/public/assets/fonts/vinlottis-icons/vinlottis-icons.woff?95xu5r') format('woff'),
|
||||
url('/public/assets/fonts/vinlottis-icons/vinlottis-icons.svg?95xu5r#vinlottis-icons') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
.icon {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: 'vinlottis-icons' !important;
|
||||
speak: never;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
|
||||
/* Better Font Rendering =========== */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon--arrow-long-right:before {
|
||||
content: "\e907";
|
||||
}
|
||||
.icon--arrow-long-left:before {
|
||||
content: "\e908";
|
||||
}
|
||||
.icon--arrow-right:before {
|
||||
content: "\e909";
|
||||
}
|
||||
.icon--arrow-left:before {
|
||||
content: "\e900";
|
||||
}
|
||||
.icon--ballon:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
.icon--bars:before {
|
||||
content: "\e90c";
|
||||
}
|
||||
.icon--bottle:before {
|
||||
content: "\e90d";
|
||||
}
|
||||
.icon--cake-chart:before {
|
||||
content: "\e90f";
|
||||
}
|
||||
.icon--stopwatch:before {
|
||||
content: "\e911";
|
||||
}
|
||||
.icon--cloud:before {
|
||||
content: "\e912";
|
||||
}
|
||||
.icon--dart:before {
|
||||
content: "\e914";
|
||||
}
|
||||
.icon--eye-1:before {
|
||||
content: "\e919";
|
||||
}
|
||||
.icon--eye-2:before {
|
||||
content: "\e91a";
|
||||
}
|
||||
.icon--eye-3:before {
|
||||
content: "\e91b";
|
||||
}
|
||||
.icon--eye-4:before {
|
||||
content: "\e91c";
|
||||
}
|
||||
.icon--eye-5:before {
|
||||
content: "\e91d";
|
||||
}
|
||||
.icon--eye-6:before {
|
||||
content: "\e91e";
|
||||
}
|
||||
.icon--eye-7:before {
|
||||
content: "\e91f";
|
||||
}
|
||||
.icon--eye-8:before {
|
||||
content: "\e920";
|
||||
}
|
||||
.icon--face-1:before {
|
||||
content: "\e922";
|
||||
}
|
||||
.icon--face-2:before {
|
||||
content: "\e923";
|
||||
}
|
||||
.icon--face-3:before {
|
||||
content: "\e924";
|
||||
}
|
||||
.icon--heart-sparks:before {
|
||||
content: "\e928";
|
||||
}
|
||||
.icon--heart:before {
|
||||
content: "\e929";
|
||||
}
|
||||
.icon--medal:before {
|
||||
content: "\e936";
|
||||
}
|
||||
.icon--megaphone:before {
|
||||
content: "\e937";
|
||||
}
|
||||
.icon--phone:before {
|
||||
content: "\e93a";
|
||||
}
|
||||
.icon--plus:before {
|
||||
content: "\e93e";
|
||||
}
|
||||
.icon--spark:before {
|
||||
content: "\e946";
|
||||
}
|
||||
.icon--tag:before {
|
||||
content: "\e949";
|
||||
}
|
||||
.icon--talk:before {
|
||||
content: "\e94b";
|
||||
}
|
||||
.icon--cross:before {
|
||||
content: "\e952";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user