Compare commits

...

564 Commits

Author SHA1 Message Date
dependabot[bot]
12d89304c6 Bump path-parse from 1.0.6 to 1.0.7
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-08-11 22:27:51 +00:00
2f88516326 Update README.md 2021-06-22 10:16:43 +02:00
168aad21e9 Removed hash route prefix from backend.
Also linting.
2021-05-21 17:06:09 +02:00
836a06537a Merge branch 'master' of github.com:KevinMidboe/vinlottis 2021-05-21 17:01:43 +02:00
a44a2f5e2a Use toast not alert for bingo genearte msg. 2021-05-21 17:01:15 +02:00
560fb81a31 Playsinline attr on video for autoplay on ios 2021-05-21 11:13:20 +02:00
ddd497f074 Update .drone.yml 2021-05-21 11:12:33 +02:00
45d1df92a1 Merge pull request #88 from KevinMidboe/feat/access-code
Feat/access code
2021-05-21 10:39:18 +02:00
b15c9cecb6 Updated lock file. 2021-05-20 10:07:03 +02:00
6c708532ac Disable host check for local development. 2021-05-20 10:06:49 +02:00
d4f059945d Add list of sites to config. 2021-05-20 10:05:36 +02:00
b94ea75941 Check for cookie accessCode and redirect if sites code exists. 2021-05-20 10:04:44 +02:00
019e763341 Util functions for set/get cookie. 2021-05-20 10:04:26 +02:00
19c1f18ef6 Slimmer and unbranded footer for accesscode page. 2021-05-20 10:03:07 +02:00
5a69001efd Access code landing page w/ video. 2021-05-20 10:02:40 +02:00
9f3b3777d9 prettier rc file. 2021-05-20 10:02:09 +02:00
783373da22 Linting 2021-05-20 10:01:58 +02:00
105820cdbb Prevent crawling 2021-05-05 17:04:43 +02:00
d21a33ab42 Merge pull request #87 from KevinMidboe/feat/pulsing-participate-button
Feat/pulsing participate button
2021-04-30 21:00:17 +02:00
3188e83aeb Increased size of participate button. 2021-04-30 18:39:04 +02:00
f91466f1bf Add pulse class after mount timeout 2sec. 2021-04-30 18:38:06 +02:00
7092fb1959 Don't display wine year if value is 0000. 2021-04-30 17:51:34 +02:00
210f9ddbc9 Buttons on admin addwines page should be small. 2021-04-30 17:50:55 +02:00
c96b52c935 Changed vinlottis to history mode. 2021-04-30 17:48:21 +02:00
8c964a4815 Replaced node-sass w/ sass. 2021-04-30 17:47:43 +02:00
127fc6741f Merge pull request #86 from KevinMidboe/feat/vinmonopolet-elastic-cache
Feat/vinmonopolet elastic cache
2021-04-30 09:56:58 +02:00
66253f7bfe Merge pull request #80 from KevinMidboe/feat/nodemon-for-dev
feat: Add nodemon for automatic restarting of server in development
2021-04-29 17:50:32 +02:00
1d2a443915 Merge branch 'master' into feat/nodemon-for-dev 2021-04-29 17:48:37 +02:00
ab15e24574 Updated yarn lock. 2021-04-29 17:45:00 +02:00
908b61f5bb Update package.json 2021-04-29 17:41:16 +02:00
fb4e7d4506 Merge pull request #83 from KevinMidboe/feat/register
feat: Allow register locally without authentication
2021-04-29 17:37:26 +02:00
753e4eb422 Merge branch 'master' into feat/register 2021-04-29 17:37:17 +02:00
d71a3a4c5a Added local elastic vinmonopolet cache for query get by id.
First tries to search local elastic instance, if no results are found a
reject is thrown and we search vinmonopolet api manually.
2021-04-29 17:23:35 +02:00
a700de6e2e GetById should only return single wine object. 2021-04-29 17:22:47 +02:00
c0d98af6e1 Controller gracefully handles errors. 2021-04-29 17:21:55 +02:00
947d8958a0 Merge branch 'master' of github.com:KevinMidboe/vinlottis 2021-04-09 12:33:31 +02:00
9503967e7e Updated packages. 2021-04-09 12:33:19 +02:00
57ddd77493 Button for navigating to request a new wine. 2021-04-09 12:31:57 +02:00
d44cc3cd39 Norwegian url names for request pages. 2021-04-09 12:31:46 +02:00
7b406ef432 Merge pull request #79 from KevinMidboe/fix/archive-but-save-if-chosen
fix: Archive but save set winners as winners
2021-03-26 09:18:53 +01:00
Kasper Rynning-Tønnesen
e498dad860 Merge branch 'master' into feat/nodemon-for-dev 2021-03-25 22:19:42 +01:00
e637455059 Merge pull request #82 from KevinMidboe/feat/seed-tiny
feat: Add tiny seed for 2 wines, and 2 users
2021-03-25 22:01:09 +01:00
Kasper Rynning-Tønnesen
56072ff282 feat: Allow register locally without authentication 2021-03-25 20:48:00 +01:00
Kasper Rynning-Tønnesen
0e3c4d98e6 feat: Add tiny seed for 2 wines, and 2 users 2021-03-25 20:45:08 +01:00
Kasper Rynning-Tønnesen
64377b7cc0 feat: Add nodemon for automatic restarting of server 2021-03-25 20:23:42 +01:00
Kasper Rynning-Tønnesen
59b4366ed5 fix: Archive but save set winners as winners
If archiving a lottery, let archive-functionality circumvent SMS-service if the winners have been set on the archive page.
- Also remove unused file
2021-03-25 20:15:38 +01:00
ea10f95a22 Replaced package-lock w/ yarn.lock. 2021-02-20 14:25:16 +01:00
bd4d833533 Updated lock file. 2021-02-20 14:23:43 +01:00
4ed912df46 Merge pull request #78 from KevinMidboe/fix/vipps-qr
Always generate vipps url with amount.
2021-02-20 14:21:39 +01:00
5af082784c Always generate vipps url with amount.
Also update vipps component to correctly show vipps and mobile info text
in column correctly on mobile.
2021-02-20 14:11:36 +01:00
b0424a519c Fixed drawing winner count was +1.
On /lottery frontend page the count of winners that is sent to frontend
over socketIO started one index higher than it should.

This was from updating how winners are saved in the backend. Our winners
array now no longer needs to be incremented with 1 from the backend.
2021-02-20 13:57:39 +01:00
33fa7c14c6 Removed src folder. 2021-02-20 13:49:01 +01:00
4a270f05b8 Merge pull request #77 from KevinMidboe/fix/personal-highscore-days-ago
No longer shows "0 days ago", instead human string.
2021-02-20 13:45:58 +01:00
b0000293a6 No longer shows "0 days ago", instead human string.
When viewing winners that won today it previously displayed "0 dager
siden", now changed to return "i dag" instead.
2021-02-20 13:41:16 +01:00
dbcd56a98b Merge pull request #76 from KevinMidboe/feat/latest-lottery
Feat/latest lottery
2021-02-20 13:36:02 +01:00
8df082dd48 If todays lottery is archived show dialog.
Use new api/lottery/latest to check if todays lottery has already been
archived. If yes we disable button and show dialog explaining how to
reset to next weeks lottery.
2021-02-20 13:30:05 +01:00
80e6c91045 API endpoint for getting latest lottery. 2021-02-20 13:29:41 +01:00
7e2b5a5bb0 Only have fixed height on graph on desktop. 2021-02-19 09:23:10 +01:00
ac6a5195a5 Removed row-gap in banner. 2021-02-19 09:22:54 +01:00
113f286f03 More space for wines on mobile. 2021-02-19 09:22:43 +01:00
b260991116 Merge pull request #75 from KevinMidboe/feat/controllers
Feat/controllers - refactor entire backend and new admin interface
2021-02-19 01:19:52 +01:00
7402fb7a7c Promise.all needs a list of promises. 2021-02-19 01:18:12 +01:00
e89952d965 Forgot this prefix when referencing data value. 2021-02-19 01:04:03 +01:00
60044859eb Query parameters can not be constants. 2021-02-19 01:03:26 +01:00
d108f331ca Merge branch 'feat/controllers' of github.com:KevinMidboe/vinlottis into feat/controllers 2021-02-19 01:01:20 +01:00
3388bed400 Disable registering new users. 2021-02-19 01:01:06 +01:00
2fce0e66ab Merge branch 'master' into feat/controllers 2021-02-19 00:55:27 +01:00
704ed51db5 Prizedistribution wants winners w/ phone number. 2021-02-19 00:54:12 +01:00
48c1842b8b When env=development use middleware to always auth. 2021-02-19 00:39:59 +01:00
7bd2e9d931 All our new routes 🎉
- GET /history/search
 - GET /purchases
   - /purchases
   - /purchases/summary
   - /purchases/:date
 - PUT /lottery/winner/:id (not currently used)
 - GET /lotteries
 - POST /lottery/messages/winner/:id
2021-02-19 00:29:57 +01:00
d337329765 Use styles from global.scss. 2021-02-19 00:29:28 +01:00
83d9b30048 Request wine front & backend talk nicer togheter.
- requestWineController validates wine object and returns helpfull
 error repsonse if anything is missing.
 - requestWine uses new endpoint and calls api from within itself.
 - Linting.
2021-02-19 00:29:05 +01:00
a37c08880c Crazy breakpoints to always show filles rows.
Hides elements so we always show filled filled rows. The wine compoennt
does not load images before they are in the viewport, so it should not
decrease performance.
2021-02-19 00:00:30 +01:00
6b33e03aae Call api within the component itself & linting. 2021-02-18 23:59:39 +01:00
9e25b374e0 New underlinenav, also defined all as css vars. 2021-02-18 23:57:05 +01:00
9913b5984a Flex row class should column on mobile. 2021-02-18 23:56:51 +01:00
1d4b74b56b Added centralized .wines-container, + misc changes.
- .raffleElement gets more of it's styling in the global stylesheet.
 - margin class in steps, md, sm & 0.
2021-02-18 23:54:51 +01:00
f1a0f2a0f2 Button has more styles w/ added clases.
- .small: minimal height for content.
 - .pulse-button: animate a pulsating effect, used for getting user
 attention.
 - .warning: new orange warning button styling, used for update buttons.
2021-02-18 23:51:05 +01:00
9b2d0f2d31 .small button has minimal height. 2021-02-18 23:49:52 +01:00
e20e952573 Label-div has some margin top from label. 2021-02-18 23:49:16 +01:00
8d3a21825d Links always has pointer cursor & inherits color. 2021-02-18 23:48:17 +01:00
b02472ef75 Api calls done within & refactored as housekeeping.
Strings "Følg med på utviklingen" and "chat om trekningen" scrolls the
page content into view if clicked.
2021-02-18 23:45:13 +01:00
3f77722f4f allLotteries w/ sort & includeWinners query opts. 2021-02-18 23:42:21 +01:00
324ca5d9bc Date defaults to today and return response message. 2021-02-18 23:41:46 +01:00
b493fa2bea Commands dev=backend w/o auth & watch=frontend HMR. 2021-02-18 23:39:32 +01:00
1e6ec3d4c8 Require attendee name AND number when adding. 2021-02-18 23:39:00 +01:00
1b12453df0 LotteryWinner functionality not yet used.
Can post winners directly with parsing of parameters and error handling.
2021-02-18 23:38:00 +01:00
2eb933f03e Get including winners, filter by year and sort.
Separate function for getting all history including the winners object.
Both have sort parameter that is used within the mongo query instead of
doing it in javascript.
2021-02-18 23:30:09 +01:00
de664b3a29 Crazy breakpoints to always show filles rows.
Hides elements so we always show filled filled rows. The wine compoennt
does not load images before they are in the viewport, so it should not
decrease performance.
2021-02-18 23:26:05 +01:00
07dd0d43f5 Calls api from within and get as screen can fit. 2021-02-18 23:25:13 +01:00
cff64999b3 Removed unused code. 2021-02-18 23:23:37 +01:00
710f276a9b Api call from component itself. 2021-02-18 23:23:16 +01:00
6e0b2b76fe New admin pages with better isolated functionality.
Draw winner page has:
 - Better user feedback
 - Knows how many wines are left
 - Separat button for starting sms prize distribution.
 - Edit, update and delete wine by id.
RegisterWinePage:
 - Import wine by url.
 - Edit, update and delete wine by id.
2021-02-18 23:18:13 +01:00
7234c2fbba Backend endpoint makes it easier consume for graph.
New endpoint has the data aggregated so we don't need to do as much in
js.
Also added simple year select when we have data spanning multiple years.
2021-02-18 23:14:22 +01:00
d9de155174 Liting and compact prop for thiner styling. 2021-02-18 22:52:51 +01:00
7267c5f5bd If admin prop enable editing & more info.
Leveraging the new api supporting CRUD we check if admin prop is true
and display edit, update and delete buttons.
2021-02-18 22:50:54 +01:00
be70fa6ddf No longer hides if mouse leaves before timeout ends. 2021-02-18 22:49:02 +01:00
30a9d30b1e Renamed to reflect archive lottery functionality. 2021-02-18 22:48:37 +01:00
2734e9a840 Now dedicated to submitting/archiving lottery.
Removed all other features than adding raffles bought, money received
and mapping of wines and winners.
Also now does api calls from within the component, not external api.js.
Better validation and use of toast plugin for user feedback.
2021-02-18 22:42:25 +01:00
3886313351 Renamed to reflect admin attendee functionality. 2021-02-18 22:22:25 +01:00
fc261b9274 Rewrote to only contain add attendee functions.
The ui for adding a new user is much more friendly and enables adding
using only keyboard.
Leveraging the new api design supporting CRUD we can update & delete
attendees individually.
2021-02-18 22:19:30 +01:00
442e0ffbfd Api call from component itself and refactoring. 2021-02-18 22:15:26 +01:00
20dc2b8e38 Api call from component itself and linting. 2021-02-18 22:11:09 +01:00
2477f36f96 Get all wines with limit parameter. 2021-02-18 22:07:54 +01:00
4ab67877b9 New toast plugin, replacing ui/TextToast.vue.
Globally register toast plugin allows us to call this.$toast.info({})
from anywhere for a toast.
Currently styled types:
 - error
 - info
2021-02-18 21:57:29 +01:00
6968ccf389 Linting some ui components. 2021-02-18 21:54:01 +01:00
8bd41cc691 Much simpler using prizeDistribution endpoints. 2021-02-18 21:44:33 +01:00
eaf57115e8 Liting. 2021-02-18 21:42:03 +01:00
cded690fba Api call from component itself and linting. 2021-02-18 21:41:23 +01:00
eb9e7d4b43 When mounted focus on username input. 2021-02-18 21:35:32 +01:00
b2755add12 Api call from component itself and linting. 2021-02-18 21:35:18 +01:00
b5cca00ed4 Liting 2021-02-18 21:18:01 +01:00
2cf4095b97 Api call from component itself and linting. 2021-02-18 21:17:22 +01:00
72c1896747 Do api call from within the component itself. 2021-02-18 21:15:21 +01:00
011aec3dea Tabs redesigned, update url and can show counter.
Tabs have been redesigned and can now display a counter. This counter
can be set by emiting a "counter" event from the child.
Tabs read and set a ?tab={slug} query parameter when changing tab, when
loaded we check for this parameter to automatically select the correct
tab.
2021-02-18 21:10:14 +01:00
b57fb5f1f8 Admin gets new tabs.
Tabs for registering wines, adding attendees, drawing winner,
registering/archiving lottery and sending push notifications.
Each tab also has a slug and counter attribute.
2021-02-18 21:08:37 +01:00
9823197a48 Better validation and error resp validating wines. 2021-02-18 21:06:49 +01:00
d0fa89b92b Update lottery winner by id.
Endpoint, controller and repository function for updating lottery winner
by id and json payload.
Keeps previous definition for undefined/null values.
2021-02-18 21:05:40 +01:00
fc029f80df New message & wine controllers!
These interface towards the respective repositories.
Wine:
 - /api/wine/:id - getWineById
 - /api/wines - allWines

Messages:
 - /api/lottery/messages/winner/:id - notifyWinnerById
2021-02-18 21:02:07 +01:00
824bd60c02 Updated internal name of wine schema. 2021-02-18 21:00:32 +01:00
6003151e3b Better error handling for claim prize.
We wrap await function calls with try catch and return the res if any
error occur.
2021-02-18 20:58:57 +01:00
ab58a45da5 Better var name and response message text. 2021-02-18 20:58:40 +01:00
dcaaeae51f Get prizes only returns wines without a winner already. 2021-02-18 20:58:09 +01:00
9fd67a6bc3 Removed unused parameter. 2021-02-18 20:55:41 +01:00
70c80849df No longer delete winner as part of selecting prize.
Winners are rather marked with prize_selected and have it's own endpoint
to remove all winners.
2021-02-18 20:54:15 +01:00
a28a8ccacb When selecting prize, add winner to wine.
Changed the way we register a prize for winner.
Now we have a new prize_selected boolean field on a winner. This is
used to filter on when finding what winners have not selected a prize
yet. This also replaces the previous method of removing virtualWinners
after they selected a prize.

PrelotteryWine also get's a winner reference. This is used to filter on
when finding what prizes are left, and also makes it easier to
archive/register a lottery when the wine has a winner attached.
2021-02-18 20:50:30 +01:00
4bd3b688e9 Get wines by id or search with wine object.
Search with wine object tries to find wines matching name, year and id.
This is used for finding a wine from a prelottery wine where their _id
do not match.
2021-02-18 20:44:54 +01:00
930c458d9c Search history for winner name. 2021-02-18 20:40:35 +01:00
787882e753 History ordered by wins has limit parameter. 2021-02-18 20:40:14 +01:00
68b4e96ad0 Import with lowercase name, it's not a class. 2021-02-18 20:39:27 +01:00
56d2513a9c Incorrect history.js function called. 2021-02-18 20:38:49 +01:00
1c40fae69d Repository gets new search winner by query func.
Has sort parameter that defaults to newest first.
2021-02-17 19:26:01 +01:00
bca4558d59 orderByWins has limit parameter to slice response
Limit the number of rows returned, used for frontpage where we display
max 20.
2021-02-17 19:21:33 +01:00
38eb98e68b Sorting happens in mongo query, no longer in js. 2021-02-17 19:20:52 +01:00
c98ccbc3f0 We want a mongo instance of both winner and wine.
The inputs to these functions are objects, if we want to use mongoose we
need to get a new instance of wine and winner.
2021-02-17 19:19:13 +01:00
3d99a3e5f2 Wine repository gets function for all wines. 2021-02-17 19:17:36 +01:00
7292cf7983 Endpoint for getting wine schema.
This is for manual registration of prelottery wines from the admin page.
2021-02-15 22:38:22 +01:00
57fe7d444b Used correct func name for wines from vinmonpolet. 2021-02-15 22:34:51 +01:00
cb4a30b5e9 BIGBOY rewrite.
All endpoints have been re-worked to be more clear on what they do. They
also all have their own controller now.
2021-01-26 23:05:52 +01:00
56095cb3e2 Linting. 2021-01-26 23:04:49 +01:00
b321f2cfdd Fixed import location for redis. 2021-01-26 23:04:15 +01:00
ce480e790a Replaced for clearer project structure & new ctrls.
These files where directly called from the api endpoints. Now all
functoins have been replaced with a controller interfacing to the
endpoint and a repository file for each of the functions.
2021-01-26 23:01:49 +01:00
ba86bf3ada New custom errors to throw. 2021-01-26 23:01:18 +01:00
f4a16bc417 Removed always setting isAdmin true. 2021-01-26 23:01:08 +01:00
4c33708ff4 Made some required wine attributes optional. 2021-01-26 22:59:46 +01:00
87257fd5b2 Moved everything not lottery out.
Moved all logic related to lotteryAttendees, lotteryWinners and
prelotteryWines into each their repository and controller.

Now only holds draw, archive and get archived lotteries.
2021-01-26 22:58:41 +01:00
ac829052b6 Wine models now have extra year field. 2021-01-26 22:55:43 +01:00
1b1a99ccc3 Linting and more clear function names. 2021-01-26 22:43:18 +01:00
5e018f071d Prize distribution for selecting wines.
Moved the same functionality out from lottery.js and simplified a bit.
Now the backend also sends with wines to pick from.
When hitting the controller we check that the user is the next user in
line.
2021-01-26 22:42:33 +01:00
939e7e34df All CRUDS on winners for current lottery. 2021-01-26 22:37:36 +01:00
33070ae31a Keeps more information when adding prelottery wine 2021-01-26 22:35:12 +01:00
6e02c5e393 Reflecting changes, isolated winner from lottery. 2021-01-26 22:32:14 +01:00
b596dc28e8 Prelotterywine has it's own repo and ctrl. 2021-01-26 22:30:19 +01:00
03c0513da3 Rm from lottery.js attendee has own repo and ctrl. 2021-01-26 22:28:27 +01:00
afab4387cc Renamed winner.js to history.js. 2021-01-26 22:18:47 +01:00
1c1f52308f Add winner w/ wine to highscore. 2021-01-26 22:12:42 +01:00
f5d3b16f27 isAdmin default value = false. 2021-01-24 15:36:03 +01:00
b5b61784cc Add winners manually by posting /lottery/winners. 2021-01-24 15:34:58 +01:00
2f3a6aeba7 Catch exceptions when deleting wines from lottery. 2021-01-24 14:03:27 +01:00
84fa1ff925 Linted. 2021-01-24 14:02:53 +01:00
53135acc05 Get/add/update/delete winners from lottery. 2021-01-24 14:01:36 +01:00
fac50805bd Get stores from vinmonopolet.
Update all endpoint names to distinguish between wine and store actions.
Renamed wineController to vinmonopoletController.
2021-01-24 14:01:04 +01:00
6d5f0e824f Lottery wine functions & controller.
Split all wine/prelottrey wines into separate controller.
Now also have endpoints for deleting or updating single wine by id.
2021-01-24 12:12:52 +01:00
4d822ccb64 Isolated lottery attendee functs to it's own file. 2021-01-24 11:10:49 +01:00
7aa5f7e9ce Add, update and delete attendees to lottery. 2021-01-24 11:02:04 +01:00
18d8c2c7ca Lottery, get/delete attendees.
Changed so get attendees no longer is done in two endpoints, now we use
req.isAuthenticated() to check if we want to return full or minimal json
data about each attendee.
2021-01-24 10:14:00 +01:00
edc4d26647 User logout can happen in controller. 2021-01-24 10:08:39 +01:00
e07e6ae09a Winner controller, winners by date, color or name.
Winner controller replaces a lot of what happens in retrieve did for
aggregating when and what had been won. Now this is more clearly defined
in winner.js.
Also leverage mongo's query more than sorting and aggregating data like
previous implementation where a lot happened in js.
2021-01-24 10:05:00 +01:00
93854bc131 Updated text. 2021-01-22 12:40:42 +01:00
872f1f5fa3 Nicer styling of salgsbetingelser. 2021-01-22 12:40:18 +01:00
ccba3e5f10 Merge pull request #74 from KevinMidboe/feat/better-today-wines-layout
fix todays wines layout
2021-01-22 12:36:12 +01:00
8d320e73c0 Router gets salgsbetingelser. 2021-01-22 12:33:23 +01:00
bfa13892d5 Renamed salgsbetingelser to SalgsbetingelserPage. 2021-01-22 12:33:13 +01:00
Adrian Thompson
c430d07703 Merge branch 'master' of github.com:KevinMidboe/vinlottis into feat/better-today-wines-layout 2021-01-22 12:31:07 +01:00
Adrian Thompson
3ed0ce7dac add grid 2021-01-22 12:24:14 +01:00
e36c6b42eb Merged master into feature branch. 2021-01-22 12:18:03 +01:00
11b988ba19 Added salgsbetingelser page. 2021-01-22 12:17:04 +01:00
e9ece6963e /by-color endpoint for sort all winners by color.
Can also include query parameter includeWines for resolved wine
references.
2021-01-17 17:26:37 +01:00
53780878af Renamed to winners. Winners gets controller setup.
Rewrote everything that happened in history to better take advantage of
monogdb instead of doing everything in js.

Our endpoints become:
 - /winners - getAll w/ includeWines and sort query params.
 - /winners/latest - latest winners grouped w/ includeWines query
 params.
 - /winners/by-date - all winners grouped by date w/ includeWines and
 sort.
 - /winners/by-date/:date - get winners per epoch or string date.
 - /winners/by-name/:name - get winner by name parameter w/ sort for
 wins direction.
2021-01-17 16:55:57 +01:00
5e06a3fc28 ChatHistory behaved like controller already, renamed. 2021-01-16 14:23:02 +01:00
54c6c0eb97 History actions now have a controller setup. 2021-01-16 12:33:56 +01:00
e754f0a909 Wine ctrl for search wineinfo by query, ean or id. 2021-01-15 19:17:12 +01:00
a010641a8e Renamed wineinfo -> vinmonopolet. 2021-01-15 19:16:46 +01:00
89389ddc59 Renamed lottery -> history. 2021-01-15 19:16:15 +01:00
c03f5aa0cf Now requestAll returns object w/ wine within.
Also some housekeeping.
2021-01-11 20:56:14 +01:00
ca6f6cb2ba Request new wine response includes success bool.
Displays alert instead of modal if not true.
2021-01-11 20:54:48 +01:00
4043954f95 Split request into controller and repo.
Also try returning better error message on exceptions and check for
errors in payload to return well-defined errors.
2021-01-11 20:52:22 +01:00
fc69accea3 Spacing and wrapping of virtuallottery wines. 2021-01-08 12:38:30 +01:00
3906816b80 Norwegian text for footer. 2021-01-08 12:16:38 +01:00
6c4b6588d2 Opacity should have float value. 2021-01-02 14:42:14 +01:00
8044759264 Header menu routes have animated arrows on hover. 2021-01-02 14:27:45 +01:00
242aa28847 Added login route to header menu. 2021-01-02 14:27:26 +01:00
f34857f5a8 If no tickets bought default 0 all colors. 2021-01-02 14:05:47 +01:00
593de53073 Display number of wines to title. 2021-01-02 14:05:28 +01:00
938b92cf0d Merge pull request #70 from KevinMidboe/refactor/virtual-lottery
Refactor/Virtual lottery
2021-01-02 13:52:58 +01:00
342651a90c Footer gets link to github repo & mailto address. 2021-01-02 13:37:07 +01:00
f115ee79e6 Added element grid order for mobile styling.
- Added styling for positioning and styling on mobile.
- New vippsPill component for smaller vipps button when on mobile.
2021-01-02 13:28:57 +01:00
326101f7d2 New vipps-pill component opens vipps when clicked. 2021-01-02 13:28:17 +01:00
fea81dcd63 Desktop-/mobile-only css classes. 2021-01-02 13:19:16 +01:00
dfe89160b1 Aling text center. 2021-01-02 13:18:35 +01:00
9ab3c8c83d Displays logged-in username and moved logout btn. 2021-01-02 13:17:30 +01:00
7a2b5600c4 Fetch and display todays wines. 2020-12-31 17:29:53 +01:00
30b63a8e61 Page styles re-written and updated! 2020-12-31 17:28:55 +01:00
d59d6fbd6c vin-link font-size should be inherited. 2020-12-31 17:27:54 +01:00
814ee4fa7d Drawing bool for diff text before & during draw.
- Updated style of winners container.
- Moved out header.
- Extra info-text in container before and during the drawing of first
winner.
2020-12-31 17:26:08 +01:00
18079ae312 Increased page-size from 10 to 100. 2020-12-31 17:25:15 +01:00
8275292526 Doesn't keep its own header and set max-height. 2020-12-31 17:25:02 +01:00
e61a6c0432 Attendees list has hr seperators and max-height. 2020-12-31 17:23:25 +01:00
d0cf99e8f8 Slowed down animation and renamed variables.
- Unified html elements for smaller footprint.
- winnersNameDrawn is used to have separte messages for before and after
the winners name is decided.
2020-12-31 17:22:09 +01:00
50ea05cad3 Winner draw writes out winner number.
The websocket message now includes the number of winners and this is
used to spell out the current drawn winner's position.
2020-12-31 17:19:56 +01:00
59328de496 Updated generate and lottery routes for frontpage. 2020-12-31 17:15:20 +01:00
3c0b8d4c06 Generate and lottery has distinct routes.
No longer use tab view for these pages.
2020-12-31 17:13:41 +01:00
0144780bb1 Fixed element positioning register page. 2020-12-11 13:12:05 +01:00
baa348bc95 Merge pull request #67 from KevinMidboe/refactor/project-structure
Refactor/Project structure
2020-12-10 23:20:38 +01:00
1839810e66 Dev-server proxies /api and WS request to backend. 2020-12-06 23:21:44 +01:00
9265572e45 Updated dev webpack config. 2020-12-06 22:56:30 +01:00
ce7e05fd43 Renamed /src to /frontend. 2020-12-06 21:48:51 +01:00
913268b01c Only setup sentry when not in development. 2020-12-06 21:45:34 +01:00
37f41ff894 Updated outdates packages.
Also includes updating webpack configs to support newer versions.
2020-12-06 21:38:37 +01:00
a1a14b5361 Enabled codesplitting by chuck for frontend pages. 2020-12-06 20:51:44 +01:00
466e21aa0e Send ga event for pageview when visiting virtualLotteryPage. 2020-12-06 20:49:45 +01:00
9e7be82f57 Misc updates and better logging. 2020-12-06 20:48:39 +01:00
2fa0c1085c Build bundle analyzer and no-auth start commands.
build-report: uses webpack-bundle-analzer for interactive bundle size
explorer.
start-noauth: running locally without endpoint authentication enabled.
2020-12-06 20:44:54 +01:00
c4c74ca3ef Invalid username validation prompt. 2020-12-06 20:42:59 +01:00
ced7ebfcac Refined chat scroll handling & styling.
Moved chat functionality from parent VirtualLotteryPage to isolate
within Chat component.
Chat has better handling for username validation.
When receiving or sending messages to chat the scroll bar position more
user-friendly when loading more pages, sending message or scrolling back
in history while receiving messages.
2020-12-06 17:52:08 +01:00
539386664c Better username validation for chat registration. 2020-12-06 17:50:29 +01:00
6503b309c5 Chat history pages w/ page & limit params.
Updated api to use page and limit for fetching chat history.
2020-12-04 22:29:50 +01:00
da8251c733 Now saves messages as redis sorted list.
Moved from Redis lists to sorted lists by timestamp for better
pagination. Now we have better interfacing w/ page & limit. History now
also returns the total record count.
2020-12-04 22:26:44 +01:00
e9eada0002 Remove pickup location from confirmation SMS. 2020-12-04 12:55:43 +01:00
8f844dbf85 Updated yarn.lock 2020-11-25 00:00:46 +01:00
ccc72997c0 Moved entry and HtmlWebpackPlugin from Vinlottis.config to webpack.common and webpack.prod. Removed Vinlottis.config. 2020-11-24 23:54:43 +01:00
055d13af35 Added download location and date. 2020-11-24 23:51:50 +01:00
d58f6dd210 Removed sentry tracing and before events are sent we console.error log them and in dev disable all events. 2020-11-24 23:50:59 +01:00
edd09c012c Removed vue-analytics and update all code refs. 2020-11-24 23:50:00 +01:00
ea1237464d Updated and remove unused dependencies. 2020-11-24 23:47:38 +01:00
7c0d7c14ec Urlloader uses options not query params. 2020-11-24 23:45:33 +01:00
cf06140f60 'Use' for babel-loader and removed unused deps. 2020-11-24 23:44:35 +01:00
81ce466318 Replaced uglifyjs w/ webpack 5 TerserPlugin. 2020-11-24 23:42:05 +01:00
7bebe07db6 All style imports use @ webpack alias for root. 2020-11-24 23:38:38 +01:00
ad27ba8b33 Require statements now use path.join correctly. 2020-11-24 23:37:14 +01:00
236c07f3d0 Moved middleware/ & schemas/ into api/. 2020-11-24 23:34:13 +01:00
036f6ea499 Upgraded html-webpack-plugin. 2020-11-22 15:31:23 +01:00
51e11d0df0 Frontend checks if localhost before req sw.js. 2020-11-20 21:25:39 +01:00
d36aad3f9e Webpack conf now outputs to public/dist. 2020-11-20 21:24:36 +01:00
cc0bef927f Single template & analytics.js script reference. 2020-11-20 21:22:53 +01:00
594c4cc482 Preprended /public to all /assets references. 2020-11-20 21:21:10 +01:00
a2a81e488e Manually include local google analytics file.
This removes vue-analytics dependency.
2020-11-20 21:20:10 +01:00
0e8f73ebd5 Moved assets into public folder. 2020-11-20 21:17:34 +01:00
30fdbb5e2f Pulled feature branch up-to-date w/ master. 2020-11-20 19:15:51 +01:00
1f17429c8f Updated font url w/ updated assets path. 2020-11-20 18:37:48 +01:00
7096fbed86 Better url handling when connecting to socketio. 2020-11-20 18:37:22 +01:00
054ff69b27 Define socketIo as io on app. 2020-11-20 18:36:42 +01:00
7eb69aa3f7 Cleaned redirects and disabled sw when localhost. 2020-11-20 18:36:09 +01:00
c85e6ca56a Assets and dist static paths. 2020-11-20 18:34:45 +01:00
50c79abaf7 Moved to api router. 2020-11-20 18:33:52 +01:00
2504dc55d6 Frontend updated: Login and register moved to /api/ path. 2020-11-20 18:33:33 +01:00
e12e5cafb0 Exclude moment from chart.js from webpack. 2020-11-20 18:30:55 +01:00
ee5f817248 Chathistory only returns funcs and used by router. 2020-11-20 16:29:26 +01:00
f7ac5a96ee Minimal setup for sentry error tracing. 2020-11-20 10:21:18 +01:00
9816642362 Obscure fix for homescreen apps on ios.
When saving the webpage as a homescreen app on ios the entire screen is
used resulting in whitespace above the sticky header. This adds some
content above so the green header streches to the top of the screen.
2020-11-16 14:36:45 +01:00
d20acadf87 Filter input on highscore 100% width on mobile. 2020-11-16 14:35:50 +01:00
3b8d6f22ca Use grid for better spacing between elements. 2020-11-16 14:35:12 +01:00
Kasper Rynning-Tønnesen
c1c11f9782 Merge pull request #61 from KevinMidboe/fix/header-sticky
fix: z-index-fuckery
2020-11-03 13:23:58 +01:00
kasperrt
90f026cfe7 fix: z-index-fuckery 2020-11-03 13:23:38 +01:00
Kasper Rynning-Tønnesen
03347e6ac9 Merge pull request #60 from KevinMidboe/fix/header-sticky
fix: Sticky header
2020-11-03 13:14:01 +01:00
607d47aee5 Margin between all wines elements. 2020-11-03 13:13:23 +01:00
dc0b9859fc Merge pull request #59 from KevinMidboe/hotfix/layout-stolen-raffles
readd stolen raffles and layout fixes
2020-11-03 13:09:44 +01:00
kasperrt
0b04e9294f fix: Sticky header
- Set header as sticky
- Remove unecessary box-shadows
2020-11-03 13:04:22 +01:00
Adrian Thompson
8296474538 readd stolen raffles and layout fixes 2020-11-03 13:01:12 +01:00
Adrian Thompson
f8b58eb64c Merge pull request #56 from KevinMidboe/enhancement/vinlottispage-rework
Vinlottispage UI redesign
2020-11-03 12:41:38 +01:00
Adrian Thompson
82d708cf82 merge 2020-11-03 12:39:20 +01:00
f690124871 CSS files can not have filetype suffix in scss. 2020-11-03 12:36:50 +01:00
Adrian Thompson
3ab9f2d7ab rename ballot to raffle 2020-11-03 10:25:15 +01:00
Adrian Thompson
d5a7ce1b1e fix comments 2020-11-03 10:13:36 +01:00
Adrian Thompson
c2c3ae153a remove progress on build in console, add notification bell back 2020-11-03 09:09:00 +01:00
Adrian Thompson
425975b4b9 fix styling and use new Wine slots 2020-11-02 16:15:43 +01:00
Adrian Thompson
7de2530b9b merge 2020-11-02 15:28:30 +01:00
627f96b420 Merge pull request #53 from KevinMidboe/refactor/requested-wines-ui
WIP Refactor - Requested wines ui
2020-11-02 15:20:20 +01:00
da29e3a8ce Merge branch 'refactor/requested-wines-ui' of github.com:KevinMidboe/vinlottis into refactor/requested-wines-ui 2020-11-02 15:18:34 +01:00
8b498f3606 Globally import vinlottis-icons font. 2020-11-02 15:17:57 +01:00
7b05c0da7c Globally import vinlottis-icons font. 2020-11-02 15:17:25 +01:00
003f0d1c4d Requested wines header looks the same as other headers. 2020-11-02 15:16:39 +01:00
3aa989d2c1 Before requested hearth is grey, clicking sets colors to pink. 2020-11-02 15:14:13 +01:00
Adrian Thompson
c93671d14e Merge branch 'master' of github.com:KevinMidboe/vinlottis into enhancement/vinlottispage-rework 2020-11-02 15:11:39 +01:00
Adrian Thompson
58b91e67f9 change element naming to include main and header 2020-11-02 15:11:34 +01:00
2fc5fd29b9 Update README.md 2020-11-01 13:22:37 +01:00
Adrian Thompson
8758752b26 use correct code 2020-10-30 13:52:48 +01:00
Adrian Thompson
0d0022f420 footer stays at bottom, link to pages for buttons in header 2020-10-30 13:24:57 +01:00
Adrian Thompson
2ee071e2b2 remove icon container in header on mobile 2020-10-30 12:58:48 +01:00
Adrian Thompson
c96356127c Merge branch 'enhancement/vinlottispage-rework' of github.com:KevinMidboe/vinlottis into enhancement/vinlottispage-rework 2020-10-30 12:53:24 +01:00
Adrian Thompson
9f2adb54e0 add footer and fix layout in highscore 2020-10-30 12:52:37 +01:00
554948d67c Use new hearth icon and smaller delete btn. 2020-10-26 13:53:09 +01:00
f01b58c1b6 Removed trailing whitespace. 2020-10-26 13:10:01 +01:00
12d0137987 New global width classes for 100/75/50/25. 2020-10-26 12:57:21 +01:00
e13b4125be Small vin-button class for setting hight to content. 2020-10-26 12:56:20 +01:00
13a9c00b50 Use the most unique id as key. 2020-10-26 12:55:44 +01:00
87c309d094 Fix issue where backend didn't delete. Now pass correct id. 2020-10-26 12:55:20 +01:00
dda526eb5c Renamed icon css file to vinlottis-icons. 2020-10-26 10:13:03 +01:00
f1d500936b Better redis logging. 2020-10-22 22:35:00 +02:00
b6e2bde4d1 New command start-dev runs with node_env flag set to development. 2020-10-22 22:34:44 +02:00
5cbb3cbe87 Where we import module.exports don't work. 2020-10-22 22:15:29 +02:00
d49303a42a Replace babel/polyfill w/ core-js v3.
Babel versions above 7.4 recommend using new core-js polyfill over
deprecated babel/polyfill. Also updated babel/core and babel/preset-env.
Added babelrc for defined config of corejs version & targeted browsers.
2020-10-22 22:13:07 +02:00
d2242d4b9b Repalced body-parser for native express.json(). 2020-10-22 21:13:38 +02:00
efbad63fcd Replaced helmet, cors & policy w/ local implem.
The used functionality of helmet, cors & referrer-policy has been
defined in setupCors and setupHeaders.
2020-10-22 21:09:03 +02:00
bce28c5eb9 Output index file should be in root of dist folder. 2020-10-22 13:39:40 +02:00
794a2b06e4 Updated webpack so dist folder now lives at project root. 2020-10-22 13:36:21 +02:00
d5f3a7a1f0 Separated router and user authentication. Renamed login.js to user.js and now it only exports functions for the router to use. 2020-10-22 13:29:06 +02:00
abf673faeb Removed unused PM2 config. 2020-10-22 13:25:34 +02:00
526be378d3 Moving the assets to root level. 2020-10-22 13:21:18 +02:00
Adrian Thompson
61851e4935 cleanup 2020-10-21 15:38:23 +02:00
Adrian Thompson
daeae25e93 object-fit and redesign top-wines 2020-10-21 15:29:25 +02:00
Adrian Thompson
315af4d50e keys on v-for and layout on icons in top info 2020-10-21 13:20:57 +02:00
Adrian Thompson
7cf9e41b6f merge 2020-10-21 13:15:35 +02:00
Adrian Thompson
f8f5cd519a add raffle in css 2020-10-21 12:42:30 +02:00
Adrian Thompson
eee7a85cba renaming and dynamic layout 2020-10-21 10:41:50 +02:00
Adrian Thompson
cee0e1c8a6 bought ballots css fixes to resemble design 2020-10-20 16:30:42 +02:00
Adrian Thompson
397c635551 autofill columns in highscore 2020-10-20 15:35:54 +02:00
Adrian Thompson
1f4f4c224e use some icons and layout fixes 2020-10-20 11:36:31 +02:00
Adrian Thompson
a0a9a7205e add icons 2020-10-20 11:36:13 +02:00
95105582e7 Merge pull request #55 from KevinMidboe/bug/54
Incorrect sort order for lottery/by-name
2020-10-19 10:05:30 +02:00
82512fa4ba Comments for readability. 2020-10-19 10:01:53 +02:00
56edf7e4d6 by-name endpoint sorts wins newest first. 2020-10-19 10:00:47 +02:00
72ba0fb398 Updated references to new assets location.
All references are now also absolute to escape relative references.
2020-10-14 21:42:10 +02:00
73d15dcdff Moved assets to src folder. 2020-10-14 21:35:46 +02:00
46fb5dc6c1 intrinsic is not a css attribute in chrome. Changed to fit-content. 2020-10-12 13:53:59 +02:00
KevinMidboe
74d15bbfc2 Merge branch 'master' into refactor/requested-wines-ui 2020-10-12 00:35:23 +02:00
a7fc12038c Moved active class declation to parent. 2020-10-12 00:35:17 +02:00
859a018c87 Removed removing margin-top. 2020-10-12 00:26:09 +02:00
53ef555822 Resolved merge conflict. 2020-10-12 00:13:13 +02:00
82068f22a9 Added margin between wine-details. 2020-10-12 00:11:28 +02:00
8cdd9cb2c6 New global class for cursor: pointer. 2020-10-12 00:10:19 +02:00
2c574020d0 Removed unsued styling from winnerPage. 2020-10-12 00:09:08 +02:00
da00c7735e Moved duplicate container & h1 styling to global. 2020-10-12 00:06:54 +02:00
40d5062d2f Check if already requested. 2020-10-11 23:40:20 +02:00
401b9b8ac9 Use new Wine slots, remove all duplicate code. 2020-10-11 23:38:59 +02:00
cb368ee6a3 Wine gets named slots for bottom and top section. 2020-10-11 23:38:07 +02:00
3344af6e90 Cleaup, grid replaced with flex for new Wine.vue.. 2020-10-11 23:37:20 +02:00
ba522c350a Cleaned up some styling. 2020-10-11 23:31:28 +02:00
KevinMidboe
0ad3d4cafd Merge branch 'master' of github.com:kevinmidboe/vinlottis 2020-10-11 22:35:21 +02:00
6603fc489c Send confirmation after select wine & text update.
Text update: sendWineConfirmation.
Confirmation on what wine recipient selected, date wone
and where to pick up the wine.
2020-10-11 22:34:50 +02:00
a90b332e27 Merge pull request #52 from KevinMidboe/bug/51
Bug/51 - Last winner is not automatically receiving the last wine!
2020-10-11 22:30:01 +02:00
7e0d3cd75e Text update: sendLastWinnerMessage.
Specified where to pick up wone wine.
2020-10-11 22:29:37 +02:00
e76e814877 Always return success bool from getWinner endpoint 2020-10-11 22:21:02 +02:00
b3b5e87ab5 Log gateway response data. 2020-10-11 22:19:26 +02:00
439191008a More logging to console during draw. 2020-10-11 22:19:02 +02:00
795f110e1b Syntax error w/ capitalization crashes draw! 2020-10-11 22:18:41 +02:00
432d9211b2 Also lowercase query in highscorePage. 2020-10-11 19:26:10 +02:00
25823540ff Update background-colors to new primary color. 2020-10-11 19:24:26 +02:00
2013675802 Re-added named routes. 2020-10-11 19:10:20 +02:00
d6839dd10b Fixed ambiguous null check 2020-10-11 19:10:01 +02:00
f868e03e7b Highscore shows ranking, not position in list. 2020-10-11 19:02:33 +02:00
a50c94f77c Merge pull request #50 from KevinMidboe/fix/personal-highscore-page
Fix/personal highscore page
2020-10-11 18:49:18 +02:00
99a94a6bc2 Merge pull request #49 from KevinMidboe/revert-45-feat/history-router
Revert "Feat vue router mode changed to history"
2020-10-11 18:48:58 +02:00
685db1f8f5 Revert "Feat vue router mode changed to history" 2020-10-11 18:46:20 +02:00
f44402cf03 Function squased w/ merging, re-added. 2020-10-11 18:38:23 +02:00
98e04ecd63 Return object renamed from wins --> highscore. 2020-10-11 18:37:43 +02:00
61fad22ef4 Merge pull request #48 from KevinMidboe/refactor/wine-ui
Refactor/implement new design for Wine
2020-10-11 18:27:37 +02:00
d3bfdb632b Merge pull request #47 from KevinMidboe/refactor/ballot-becomes-raffle
Renamed all references to ballot to raffle.
2020-10-11 18:23:41 +02:00
eaf3dbb586 Merge branch 'master' into refactor/ballot-becomes-raffle 2020-10-11 18:23:35 +02:00
292cdab3dd Merge pull request #46 from KevinMidboe/feat/history-page-by-date
Feat/history page by date
2020-10-11 18:22:10 +02:00
139b80ccff Merge branch 'master' into feat/history-page-by-date 2020-10-11 18:22:04 +02:00
335d834cd4 Merge pull request #45 from KevinMidboe/feat/history-router
Feat vue router mode changed to history
2020-10-11 18:20:28 +02:00
d68cd17ba1 Merge branch 'master' into feat/history-router 2020-10-11 18:20:22 +02:00
afff3fcdee Merge pull request #44 from KevinMidboe/feat/personal-highscore
Feat/personal highscore
2020-10-11 18:19:51 +02:00
feed7774ce Merge branch 'master' into feat/personal-highscore 2020-10-11 18:18:44 +02:00
21904f4bb6 More global styling properties. 2020-10-11 18:16:16 +02:00
f2989d2534 Use api.js funcs over manual fetch. 2020-10-11 18:14:45 +02:00
9c1b290219 Make sure date is date in dateString(). 2020-10-11 18:11:47 +02:00
a7d673026e Added missing space before rating. 2020-10-11 18:11:13 +02:00
587239b799 All wines simplified. Refactor script and template. 2020-10-11 18:10:21 +02:00
6670f43b11 Implements intersectObserver to lazy load images.
IntersectionObserver for delaying the load of images until the component
enteres the viewport.
2020-10-11 17:45:11 +02:00
dc7ffbae7a Removed unused css. 2020-10-11 16:34:39 +02:00
5f2b29324d Reflecting changes no longer use props in Wine.vue
Reflecting changes now that wine should not have custom logic. No longer
send prop values other than wine down.
WinnerPage function called from the slot passed down, not using custom
:winner prop and @chosenWine event.
2020-10-11 16:33:51 +02:00
64a1a8a93a Updated wine w/ new design, removed lots of code.
Updated wine component to reflect changes in design.
Removed all methods, computed and unnecessary props.
The inlineSlot props has been removed, now slot always renders inline.
Removed functions for custom logic, not this is resides in the parent
and should be passed down in the slot.
2020-10-11 16:27:31 +02:00
5f973b199d All wines endpoint returns newest first. 2020-10-11 15:55:17 +02:00
52dedd1e7c Renamed all references to ballot to raffle. 2020-10-11 15:43:24 +02:00
823bbb7437 Winners clickable to route to highscore/{name}. 2020-10-11 15:42:48 +02:00
03c13d9558 Linting. 2020-10-11 15:27:02 +02:00
c0f26f9061 Refactored lottery controller. 2020-10-11 15:26:14 +02:00
20be3cc5ca If date in params, fetch only that date.
- Now handles only fetching date by epoch from url params.
- Displays date w/ helper function humanReadableDate.
2020-10-11 14:05:37 +02:00
90eb557f0b New api route for history by date. 2020-10-11 14:04:10 +02:00
0e74534cde Removed unnecessary mongo connection string. 2020-10-11 14:01:19 +02:00
0923965544 Removed hash from routes. 2020-10-11 13:56:19 +02:00
2062f64999 Import VueRouter instance from router.js. 2020-10-11 13:54:17 +02:00
9d9947d7dc Router mode history, and all routes got names. 2020-10-11 13:53:46 +02:00
d5558863e4 Merge branch 'master' into feat/personal-highscore 2020-10-11 13:49:32 +02:00
27f4c8faef Updated styling to match design. 2020-10-11 13:36:34 +02:00
0702507963 Tabable back link. 2020-10-11 13:36:19 +02:00
8ef1a2dd88 Save previous route name, if none found push route highscore. 2020-10-11 13:32:47 +02:00
a59f1e2b17 Show error if no one w/ name found. 2020-10-11 12:29:10 +02:00
1383a310b3 Try get a smaller version of the wine image. 2020-10-11 12:27:04 +02:00
584d497c6a Click dates send to history page for given lottery. 2020-10-11 12:26:10 +02:00
98c2707cb0 Full rewrite, faster, better listing and less code.
- Only list the highscore and send to PersonalHighscorePage on click.
- List numbers are not sequential position in list, but the persons
place in the highscore, allows shared place ranking.
- Filter is a computed value that uses the filterInput, reduces
overhead.
- Stripped unused styling.
2020-10-11 12:10:17 +02:00
ingridvold
4ce8ca1a99 en morsom vri ;) 2020-10-09 13:30:23 +02:00
cb286b6894 Back link and better wine name container width. 2020-10-09 01:14:12 +02:00
73f1614ed4 Redesigned and separate page for personal highscore. 2020-10-09 01:02:55 +02:00
bd96a19faa More positioning and variables defined in scss. 2020-10-09 01:02:28 +02:00
c01692bf1e Renamed css ballot to raffle. 2020-10-09 01:01:26 +02:00
59792f9aae Moved date helper funcs to utils.js 2020-10-09 00:57:20 +02:00
e87a59b1e6 Helper func for lottery/by-name. 2020-10-09 00:55:52 +02:00
4c02c81cc3 Name also part of highscore/by-name resp. 2020-10-09 00:55:20 +02:00
0e8bf9546a Reverse highscore for newest first. 2020-10-09 00:52:06 +02:00
9b2de75910 Received and searched names are lowercased.
Endpoint /lottery/by-name now lowercases the path input name and the
search query towards mongo.
2020-10-09 00:51:34 +02:00
05b07ca465 Defined new route for PersonalHighscorePage.
This is a subpage for showing additional information about a winner from
highscore.
2020-10-08 23:40:18 +02:00
Adrian Thompson
5ffb10468c wip 2020-10-01 14:29:30 +02:00
Adrian Thompson
2b44a6454f first work on design rework 2020-10-01 10:56:20 +02:00
53257a16ff Merge pull request #42 from KevinMidboe/enhancement/wine-css-fixes
Rework how the list of the top 10 wines looks
2020-09-23 23:50:44 +02:00
8ce7c254fb Updated host and script path. 2020-09-23 23:29:36 +02:00
f4be12d00b Added missing ) 😬 2020-09-23 23:22:39 +02:00
8d8550a835 Fixed admin header not being set properly.
Incorrectly used Boolean(). Now we check for the header value by string comparison, should always
return boolean.
2020-09-23 23:20:20 +02:00
b0b99ed921 Fixed timeout for SMS response 1 min to 10 minutes
Error where timeout was set to a single minute. Forgot to update from testing. Now made timeout a bit more clear and added log message.
2020-09-18 15:56:11 +02:00
Adrian Thompson
89557c89b7 Update README.md 2020-09-17 16:41:54 +02:00
Adrian Thompson
67cd06444f reworked css and removed wine popup 2020-09-17 14:08:22 +02:00
Adrian Thompson
53156e2330 Merge branch 'master' of github.com:KevinMidboe/vinlottis 2020-09-17 13:17:03 +02:00
Adrian Thompson
c1c4b414c3 align highscore baseline 2020-09-17 13:16:50 +02:00
Adrian Thompson
ce67ecc23d Merge pull request #40 from KevinMidboe/hotfix/highscore-css
Highscore page css fixes
2020-09-17 12:47:00 +02:00
Adrian Thompson
06a50a6662 Merge pull request #39 from KevinMidboe/enhancement/vinlottispage-css-fixes
Enhancement/vinlottispage css fixes
2020-09-17 12:39:16 +02:00
Adrian Thompson
b31bee8731 fixed flow on safari 2020-09-17 12:34:59 +02:00
Adrian Thompson
a5abdfd002 added link to lottery and fixed flow on safari 2020-09-17 12:33:21 +02:00
Adrian Thompson
3f6b362d5a working on safari 2020-09-17 12:03:10 +02:00
Adrian Thompson
9f7a4df61b readd font 2020-09-17 11:55:02 +02:00
Adrian Thompson
0f24afd07c Merge branch 'master' of github.com:KevinMidboe/vinlottis into enhancement/vinlottispage-css-fixes 2020-09-17 11:52:00 +02:00
guggerud
48e43edcf4 Merge pull request #41 from KevinMidboe/feat/confetti
bumped the numbers up
2020-09-16 09:27:06 +02:00
Egil Uggerud
122eaad1d1 bumped the numbers up 2020-09-14 13:25:51 +02:00
Adrian Thompson
6aa535bade hotfix safari 2020-09-11 15:26:17 +02:00
Adrian Thompson
1e8a77ec3f added max-width 2020-09-11 13:47:01 +02:00
Adrian Thompson
833cf649f3 renamed and removed some classes, added media breakpoint and aligned stuff 2020-09-11 13:42:15 +02:00
Adrian Thompson
2c9821d753 better tablet layout 2020-09-11 11:53:05 +02:00
Adrian Thompson
d5cf6d31ca readded highscore and borders 2020-09-11 10:35:29 +02:00
Adrian Thompson
00cbd3c871 Merge branch 'master' of github.com:KevinMidboe/vinlottis into enhancement/vinlottispage-css-fixes 2020-09-11 10:15:23 +02:00
06c2d35580 Fixed casing issue when filtering highscore. 2020-09-11 10:01:48 +02:00
1f44b7bf8e Merge pull request #37 from KevinMidboe/feat/confetti
Confetti for the winners
2020-09-11 09:47:49 +02:00
Egil Uggerud
c0171412c1 added more descriptive names 2020-09-11 09:45:15 +02:00
Adrian Thompson
4089ad9050 Merge pull request #36 from KevinMidboe/enhancement/request-wine-css-fixes
Better styling for request wine related pages
2020-09-11 09:44:05 +02:00
Egil Uggerud
80f935db26 Fixed according to review comments 2020-09-10 16:48:11 +02:00
Adrian Thompson
934eb2e9ee more margin 2020-09-10 16:32:06 +02:00
Adrian Thompson
4f270a9ca0 reworked css 2020-09-10 16:30:40 +02:00
Adrian Thompson
c405cfef54 Merge branch 'enhancement/request-wine-css-fixes' of github.com:KevinMidboe/vinlottis into enhancement/vinlottispage-css-fixes 2020-09-10 15:55:52 +02:00
Adrian Thompson
86e7b5a56d Merge pull request #38 from KevinMidboe/hotfix/add-route-link-to-highscore
Add link to highscore
2020-09-10 15:27:22 +02:00
Adrian Thompson
3ab4a17d5a added link 2020-09-10 15:18:57 +02:00
Adrian Thompson
1327650492 rename grid areas 2020-09-10 15:10:53 +02:00
Adrian Thompson
036958b279 rework media breakpoints 2020-09-10 15:08:15 +02:00
Egil Uggerud
9ab84fffda Confetti for the winners 2020-09-10 13:44:51 +02:00
Adrian Thompson
e9e7b60f22 cleanup 2020-09-09 15:59:49 +02:00
Adrian Thompson
ef367bd1db reworked requestwine css 2020-09-09 15:46:16 +02:00
Adrian Thompson
d2ad209d13 added grid for all requested wines, also added new media-breakpoints 2020-09-09 12:52:31 +02:00
Adrian Thompson
861568069c working mobile 2020-09-09 12:13:21 +02:00
Adrian Thompson
fa6e5afa9c hotfix mobile menu 2020-09-09 10:20:39 +02:00
Adrian Thompson
4e6121e618 Merge pull request #34 from KevinMidboe/enhancement/header-rework
Rework the banner
2020-09-09 09:54:41 +02:00
Adrian Thompson
45d47924e9 Apply suggestions from code review
Simpler class name logic
2020-09-09 09:32:02 +02:00
Adrian Thompson
2eb664fc79 remove unused references 2020-09-09 09:23:06 +02:00
Adrian Thompson
13e0c26eef rework to boolean logic on classname 2020-09-09 09:20:59 +02:00
Adrian Thompson
d1044ee6c8 add background color on header and enlargen menu toggle a bit 2020-09-08 16:31:47 +02:00
Adrian Thompson
3b490e9995 cleanup 2020-09-08 16:14:33 +02:00
Adrian Thompson
21603dc856 added link color variable 2020-09-08 16:03:23 +02:00
Adrian Thompson
07d751700f reworked banner with animations 2020-09-08 16:02:58 +02:00
a2fbbf0715 Merge pull request #30 from KevinMidboe/feat/highscore-page
Feat/highscore page
2020-09-08 10:56:55 +02:00
83f69ff007 Removed unused console.log. 2020-09-08 10:24:29 +02:00
aa19c3b3a7 Pulled feature branch up-stream. 2020-09-08 09:19:56 +02:00
23165c0b3f Updated drone node docker version from 13.6.0 > 14. 2020-09-07 17:58:09 +02:00
3ebd3d3dce drone builds solution on changes w/ cmd backend. 2020-09-07 17:54:31 +02:00
8c0a002020 Fixed loose check for headers.
Now search for lower-case header variable and convert to boolean before
sending back to caller.
2020-09-07 17:49:33 +02:00
Adrian Thompson
5cfec8bb9d Merge pull request #25 from KevinMidboe/feat/navigation-in-header
Add navigation to the banner
2020-09-07 16:57:39 +02:00
Adrian Thompson
f17931a014 add middleware 2020-09-07 16:46:45 +02:00
Adrian Thompson
ee15c65988 add routes to request wine 2020-09-07 16:32:46 +02:00
Adrian Thompson
68f20268b8 Merge branch 'master' of github.com:KevinMidboe/vinlottis into feat/navigation-in-header 2020-09-07 16:30:00 +02:00
Adrian Thompson
563c45bc27 Merge pull request #24 from KevinMidboe/feat/request-wine
Functionality to request wines for next lottery
2020-09-07 16:28:30 +02:00
Adrian Thompson
2764972abd remove unused code and fix or check 2020-09-07 16:22:59 +02:00
Adrian Thompson
2406d2b81a remove commented code 2020-09-07 15:56:19 +02:00
Adrian Thompson
052b5007c2 add is admin middleware 2020-09-07 15:53:03 +02:00
Adrian Thompson
e060ff3aaa cleanup api use 2020-09-07 14:57:52 +02:00
Adrian Thompson
ea278a4892 remove console log 2020-09-07 14:50:40 +02:00
Adrian Thompson
4a98e79414 fix conflicts and rework routes 2020-09-07 14:49:24 +02:00
Adrian Thompson
6d26da1817 resolve first batch of comments 2020-09-07 13:15:10 +02:00
34216c2708 Moved link styling to global.scss. 2020-09-06 22:34:55 +02:00
9174338621 Highscore has viewmore link for highscorePage. 2020-09-06 22:34:19 +02:00
2cc5c81df6 More positioning and global style rules. 2020-09-06 22:32:30 +02:00
ace222749a Person highscore overview page. 2020-09-06 22:32:07 +02:00
1aa266ad71 Merge pull request #21 from KevinMidboe/refactor/routeSeparation
Refactor/route separation
2020-09-06 16:00:11 +02:00
20dd638133 Resovled merge conflict in yarn.lock. 2020-09-06 15:58:17 +02:00
f6c1e35180 Removed deprecated package: requests. 2020-09-06 15:56:09 +02:00
bf0670c285 Reflect backend changes. Winners nested in lottery. 2020-09-06 15:20:11 +02:00
7e08e2d628 Finer control when updating lottery.
Lottery update now has endpoint/functions for also submitting winners,
wines and lottery (what's bought) separatly.
2020-09-06 15:17:56 +02:00
4b926c9ece Forward /winner/:id to frontend endpoint. (w/ hash) 2020-09-06 15:17:16 +02:00
a7f4f9af27 Utils file. Includes date format function. 2020-09-06 14:42:27 +02:00
ec1685dfa8 Adding attendee shows toast not alert.
Also better feedback when no colors are defined or name/phone number
missing.
2020-09-06 14:41:04 +02:00
f171853c22 Register wines, lottery and winners separatly.
Register page now uses new /lottery, /lottery/wines and /lottery/winners
endpoints for sending information separatly. This allows lottery
(colors, bought total & stolen), lottery wines (prelottery wines) and
lottery winners (wine mapped to winner) sent. Before /lottery/winners &
/lottery was sent together to a single endpoint.
2020-09-06 14:34:43 +02:00
c1f0a1b7f3 Padding for air around elements. 2020-09-06 14:33:45 +02:00
55b3552786 Flex wrap winner raffles to not overflow vertical. 2020-09-06 14:32:29 +02:00
4f9b4ad3cf Some margin between button and text. 2020-09-06 14:29:30 +02:00
ef035d3c44 Set line-height for vin-button same as font-size. 2020-09-06 14:22:01 +02:00
7684fde8e5 global rule for flex column. 2020-09-06 14:21:45 +02:00
6dc4c9080e Reflecting changes from renaming backend routes. 2020-09-06 14:21:10 +02:00
8268efe625 Include positioning stylesheet in root Vue comp. 2020-09-06 14:20:37 +02:00
a30dc2a419 Renamed & moved frontend router. 2020-09-06 14:20:01 +02:00
1d714d1e5a Removed router, refactored virtual functoinality.
- Removed the router from virtualRegistration so router.js handles routing
and virtualRegistration only exports functions.
- Refactored what code is in each of the virtual(Lottery/Registration)
files. /finish in Lottery now only sends update to all winners and
delegates sending to next winner in line, and setting timeout to
virtualRegistration.
- Better error handling and all responses from virtualRegistration has
message in json response.
2020-09-06 14:14:26 +02:00
4f054a0437 Message uses node's https insteadof requests.
Messages re-written to use nodes built in https over external requests
package.
Also updated message functions to always return promises and have
clearer names for what their purpose is.
2020-09-06 14:13:36 +02:00
dde8fe1cbe Renamed app > router.js. VirtualLottery in router. 2020-09-06 14:07:18 +02:00
Adrian Thompson
ed6ac2ac01 move api call, remove delete requested wine button, rename emit 2020-09-04 15:42:03 +02:00
Adrian Thompson
fe5f9d2124 remove unused code 2020-09-02 13:22:17 +02:00
Adrian Thompson
58b424a873 bigger clock margin on desktop 2020-09-02 13:04:34 +02:00
Adrian Thompson
e481b6a812 reworked header to be one component scaling on css properties 2020-09-02 12:52:45 +02:00
Adrian Thompson
f5768044e8 Merge branch 'master' of github.com:KevinMidboe/vinlottis 2020-09-02 09:51:45 +02:00
Adrian Thompson
1e5c1faa5b quick css fix 2020-09-02 09:51:11 +02:00
Adrian Thompson
e86f956b03 add mobile-banner component and conditional render on window-width 2020-09-01 14:04:41 +02:00
Adrian Thompson
f31823803d add routes in header 2020-09-01 13:24:42 +02:00
Adrian Thompson
88e42cb87a fix refresh of requested wines 2020-09-01 12:30:50 +02:00
Adrian Thompson
99df5bb8c1 fix check 2020-09-01 10:56:17 +02:00
Adrian Thompson
c38f5c8819 some cleaning and empty wine list check 2020-09-01 10:44:54 +02:00
Adrian Thompson
a5ae46f5c3 working delete requested wine if admin 2020-09-01 10:17:17 +02:00
Adrian Thompson
1c95244850 interface on requested wines with sub-par css 2020-08-31 16:45:19 +02:00
Adrian Thompson
dd9edf160e add endpoint for retrieving all requested wines 2020-08-31 12:15:22 +02:00
Adrian Thompson
d67c1e77bd add new route and component for requested wines 2020-08-31 10:46:07 +02:00
Adrian Thompson
c6a2bfe4b2 include modal on request specific wine 2020-08-31 10:45:42 +02:00
Adrian Thompson
b25e3a38f8 add modal component 2020-08-31 10:45:18 +02:00
Adrian Thompson
543a7a6eb3 some cleanup and css fix for mobile 2020-08-30 17:27:03 +02:00
fd17e11e87 Register io to all req with app.set('socketio'). 2020-08-28 18:46:35 +02:00
51a7107802 Endpoints defined and exported as functions.
Changed all routes to functions and export them to app.js to handle the
registration of route using the functions exported from this file.
2020-08-28 18:45:33 +02:00
Adrian Thompson
aea808dae1 🍷 2020-08-28 17:20:37 +02:00
Adrian Thompson
7b7895728b loko 2020-08-28 17:01:53 +02:00
Adrian Thompson
f785a111d8 cleanup 2020-08-27 16:50:04 +02:00
Adrian Thompson
15b84a9b18 add token and some styling 2020-08-27 15:15:43 +02:00
Adrian Thompson
09f0436f98 add search functionality 2020-08-27 13:25:44 +02:00
3256c62a39 Merge pull request #20 from KevinMidboe/dependabot/npm_and_yarn/elliptic-6.5.3
Bump elliptic from 6.5.2 to 6.5.3
2020-08-27 00:52:10 +02:00
827eb716d7 Define running port as var and log it. 2020-08-27 00:33:03 +02:00
a6a84e4b29 Api files no longer return router, exports usable functions. 2020-08-27 00:31:01 +02:00
ec80aa8bcc All api routes & their respective functions. 2020-08-27 00:29:01 +02:00
262efa0380 All api endpoints are moved to apiRouter. 2020-08-27 00:28:35 +02:00
5fc3bca01a Merge pull request #18 from KevinMidboe/feat/lottery-endpoint
Feat/lottery endpoint
2020-08-14 16:56:47 +02:00
dependabot[bot]
8431e52e0a Bump elliptic from 6.5.2 to 6.5.3
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.2 to 6.5.3.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.2...v6.5.3)

Signed-off-by: dependabot[bot] <support@github.com>
2020-08-01 11:01:41 +00:00
171529497d Merge pull request #17 from KevinMidboe/refactor/chat-history
Refactor/chat history
2020-06-26 13:28:59 +02:00
KevinMidboe
eeb14786e8 Merge branch 'feat/lottery-endpoint' of github.com:kevinmidboe/vinlottis into feat/lottery-endpoint 2020-06-14 14:32:36 +02:00
9b9d5bd528 Winners component has title prop but defaults "Vinnere" 2020-06-14 14:32:05 +02:00
6633994940 Registered HistoryPage api funcs & router endpoint. 2020-06-14 14:31:34 +02:00
b4b6643581 HistoryPage shows winners from prev lotteries.
The design of this pages sucks, but Winners.vue matched pretty well.
Need to have it's own design.
2020-06-14 14:30:45 +02:00
f059d6f662 New /lottery endpoint for server. 2020-06-14 14:28:47 +02:00
ab8fd9dd83 Insteadof mount all tabs & hiding,use :is to mount 2020-06-14 13:39:16 +02:00
efb5dfcd3e Prevent stopping execution. 2020-06-14 13:36:59 +02:00
a84441078c Opaque gradient over -1 msg & can fetch n+1 pages
- An opaque gradient overlay on the top message. This creates the illusion
that there are more messages under that the user can scroll to.
- Changed the formatting of the chat messages.
- Repaired the scrolling that happens on load and when chatHistory prop
list updates. If a full page arrives we want to keep the position, but
on load and a single new message we want to jump to the bottom of the
list.
- Emitting loadMoreHistory expects a expanded list of chatHistory.
2020-06-14 13:36:16 +02:00
1e0a76757a Parent now pages history on event.
loadMoreHistory func uses new variables historyPage and historyPageSize
to fetch the next x = historyPageSize elements with skip, take query
parameters for /history endpoint before prepending the received messages
to the existing.
2020-06-14 13:25:54 +02:00
267b2df2d7 New /lottery endpoint for server. 2020-05-30 10:27:36 +02:00
368b6a29aa Renamed api/lotteries.js -> api/lottery.js 2020-05-30 10:25:37 +02:00
fa59d71717 Lottery endpoints for all, by-date or by-name.
Can now get all highscore entries grouped by shared highscore dates.
This gives a complete list of all the lotteries and their winners.
 - By-date takes a epoch date to match a given lottery.
 - By-name uses query parameter `name` for searching for all winnings by
name.
 - Both /by-date and /by-name don't just return the wine _id, but
resolves it's wine reference.
2020-05-30 10:24:56 +02:00
Kasper Rynning-Tønnesen
6e380c5f5c Timeouts are nice 2020-04-17 16:29:19 +02:00
Kasper Rynning-Tønnesen
37cb4a8f56 Add alerts for success/non-success on SMS sendings 2020-04-17 15:54:34 +02:00
Kasper Rynning-Tønnesen
7aa47b08ad Fix automatic choser issue 2020-04-17 15:50:22 +02:00
Kasper Rynning-Tønnesen
cab623b3f7 url 2020-04-06 09:52:36 +02:00
Kasper Rynning-Tønnesen
4051c04c91 token -> gatewayTokeng 2020-04-06 09:42:19 +02:00
Kasper Rynning-Tønnesen
55e1a00e67 Merge pull request #15 from KevinMidboe/feat/sms-ing
Add sms capabilities, and online-fixing of everything
2020-04-06 09:39:21 +02:00
Kasper Rynning-Tønnesen
39c4d8f134 Generalized some functions 2020-04-06 09:36:33 +02:00
Kasper Rynning-Tønnesen
096dbdb2e6 MOre sms, and automatic choser 2020-04-06 09:36:20 +02:00
Kasper Rynning-Tønnesen
e6582983f2 Cleaned up a bit 2020-04-06 09:26:05 +02:00
Kasper Rynning-Tønnesen
062b01f784 Only 10 min 2020-04-04 01:06:29 +02:00
Kasper Rynning-Tønnesen
40927ac286 Add sms capabilities, and online-fixing of everything 2020-04-04 01:05:52 +02:00
131ffe42e7 Fiveminutes left or ten minutes over. 2020-04-03 15:04:52 +02:00
706ef44491 Don't need user to see chat. Increased to fetch last 100 messages. 2020-04-03 13:55:26 +02:00
Kasper Rynning-Tønnesen
09648f0b2d Eventlistener on change 2020-04-03 12:37:27 +02:00
Kasper Rynning-Tønnesen
a7ab191ed1 The eventbus 2020-04-03 12:35:33 +02:00
Kasper Rynning-Tønnesen
6e5f99391f Fix for tab-elements fetching 2020-04-03 12:35:24 +02:00
Kasper Rynning-Tønnesen
14fbf40ac8 laways show dagens 2020-04-03 12:18:14 +02:00
Kasper Rynning-Tønnesen
52e773bc7e Fix some null-errors 2020-03-27 16:45:56 +01:00
Kasper Rynning-Tønnesen
3b91f9693e Fixed time-zone difference issue 2020-03-27 11:20:00 +01:00
Kasper Rynning-Tønnesen
0c08672dcd More automated 2020-03-27 10:34:12 +01:00
Kasper Rynning-Tønnesen
0dff0c91e1 prelottery wine should have prices as well 2020-03-26 16:46:56 +01:00
164 changed files with 15934 additions and 21104 deletions

15
.babelrc Normal file
View File

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

View File

@@ -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
View File

@@ -0,0 +1,8 @@
{
"printWidth": 100,
"quoteProps": "consistent",
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"useTabs": true
}

View File

@@ -1,9 +1,19 @@
# vinlattis
<h1 align="center">
Vinlottis 🍾
</h1>
[![Build Status](https://drone.kevinmidboe.com/api/badges/KevinMidboe/vinlottis/status.svg)](https://drone.kevinmidboe.com/KevinMidboe/vinlottis)
<div align="center">
[![Build Status](https://drone.schleppe.cloud/api/badges/KevinMidboe/vinlottis/status.svg)](https://drone.schleppe.cloud/KevinMidboe/vinlottis)
Prerequisits
</div>
<br/>
[**Vinlottis**](https://vinlottis.no) is a home-brewed solution for wine-lottery.
### 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
View 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
};

View File

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

View File

@@ -1,33 +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;

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
View 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
};

View File

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

296
api/lottery.js Normal file
View 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
View 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,
};

View File

@@ -0,0 +1,6 @@
const alwaysAuthenticatedWhenLocalhost = (req, res, next) => {
req.isAuthenticated = () => true;
return next();
};
module.exports = alwaysAuthenticatedWhenLocalhost;

View File

@@ -1,5 +1,4 @@
const mustBeAuthenticated = (req, res, next) => {
console.log(req.isAuthenticated());
if (!req.isAuthenticated()) {
return res.status(401).send({
success: false,

View File

@@ -0,0 +1,6 @@
const setAdminHeaderIfAuthenticated = (req, res, next) => {
res.set("Vinlottis-Admin", req.isAuthenticated());
return next();
};
module.exports = setAdminHeaderIfAuthenticated;

View File

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

View File

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

103
api/prelotteryWine.js Normal file
View 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
View 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
};

View File

@@ -1,29 +1,40 @@
const { promisify } = require("util"); // from node
let client;
let llenAsync;
let lrangeAsync;
try {
const redis = require("redis");
console.log("trying to create");
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
View 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
};

View File

@@ -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
View 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;

View File

@@ -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);

View 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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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"
));

View File

@@ -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
View 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
View 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
View 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}&currentPage=${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
View 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
}

View File

@@ -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
View 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
};

View File

@@ -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
View 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
};

View File

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

View File

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

View File

@@ -5,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: [],
};

View File

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

View File

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

View File

@@ -11,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),
}),
],
};
};

View File

@@ -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;

View File

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

78
db/seedSingleDay.js Normal file
View 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
View 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
View 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
};

View 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>

View 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>

View 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>

View 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>
-&nbsp;
<router-link class="vin-link" :to="winDateUrl(wine.dates[index])">{{
dateString(wine.dates[index])
}}</router-link>
</li>
</ul>
</div>
</div>
</Wine>
</div>
</div>
</template>
<script>
import 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>

View File

@@ -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;
}

View 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>&nbsp;&nbsp;&nbsp;{{ person.name }} - {{ person.wins.length }}
</li>
</ol>
<div class="center desktop-only">
👈 Se dine vin(n), trykk navnet ditt
</div>
</div>
</template>
<script>
import { 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>

View 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>

View File

@@ -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)

View File

@@ -11,9 +11,7 @@ import VirtualLotteryPage from "@/components/VirtualLotteryPage";
export default {
components: {
Tabs,
GeneratePage,
VirtualLotteryPage
Tabs
},
data() {
return {

View 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 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>

View 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>

View 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
<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, 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 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 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 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 gi selger melding om bruk av angreretten innen 14 dager fra fristen begynner å løpe. I fristen inkluderes alle kalenderdager. Dersom fristen ender 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 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 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 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 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 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 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, 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 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 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 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 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 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 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>&nbsp;</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>

View File

@@ -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>

View 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>

View 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 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>

View 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 vente 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>

View 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>

View 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>

View 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>

View 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 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 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>

View 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>

View File

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

View File

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

View 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>

View 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
View 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 };

View 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
View 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
View 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;
}
}

View File

@@ -68,4 +68,5 @@ form {
width: calc(100% - 5rem);
background-color: $light-red;
color: $red;
font-size: 1.5rem;
}

View 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;
}
}

View 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;
}
}

View 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);

View File

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

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