Compare commits

...

193 Commits

Author SHA1 Message Date
1cf43a3768 Merge pull request #94 from KevinMidboe/fix/passcode-wall-redirect
Fix/passcode wall redirect
2021-11-26 20:50:23 +01:00
e1daf00609 Make text more readable 2021-11-26 20:45:03 +01:00
3a6a9122e1 Some shake animation on unsuccessful code input 2021-11-26 20:44:58 +01:00
0d14305ded Make sure we also add the path when redirecting successfull code input
This is most relevant when SMS is sent to device used to watch the
lottery.
2021-11-26 20:43:11 +01:00
40b4a0657f Code input is now case in-sensitive 2021-11-26 20:42:56 +01: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
104 changed files with 9344 additions and 18202 deletions

View File

@@ -33,7 +33,7 @@ steps:
- drone-test
status: success
settings:
host: 10.0.0.52
host: vinlottis.schleppe
username: root
key:
from_secret: ssh_key

8
.prettierrc Normal file
View File

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

View File

@@ -10,7 +10,7 @@
<br/>
[**Vinlottis**](https://vinlottis.no) is the unofficial website for Knowit's wine-lottery, usually happening every friday at around 15:00.
[**Vinlottis**](https://vinlottis.no) is a home-brewed solution for wine-lottery.
### Prerequisites

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,5 +1,6 @@
const path = require("path");
const { history, clearHistory } = require(path.join(__dirname + "/../api/redis"));
const { history, clearHistory } = require(path.join(__dirname + "/../redis"));
console.log("loading chat");
const getAllHistory = (req, res) => {
let { page, limit } = req.query;
@@ -8,19 +9,23 @@ const getAllHistory = (req, res) => {
return history(page, limit)
.then(messages => res.json(messages))
.catch(error => res.status(500).json({
.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({
.catch(error =>
res.status(500).json({
message: error.message,
success: false
}));
})
);
};
module.exports = {

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,132 +1,296 @@
const path = require('path');
const path = require("path");
const crypto = require("crypto");
const Highscore = require(path.join(__dirname, '/schemas/Highscore'));
const Wine = require(path.join(__dirname, '/schemas/Wine'));
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"));
// Utils
const epochToDateString = date => new Date(parseInt(date)).toDateString();
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 sortNewestFirst = (lotteries) => {
return lotteries.sort((a, b) => parseInt(a.date) < parseInt(b.date) ? 1 : -1)
const {
WinnerNotFound,
NoMoreAttendeesToWin,
CouldNotFindNewWinnerAfterNTries,
LotteryByDateNotFound
} = require(path.join(__dirname, "/vinlottisErrors"));
const moveUnfoundPrelotteryWineToWines = async (error, tempWine) => {
if(!(error instanceof WineNotFound)) {
throw error
}
const groupHighscoreByDate = async (highscore=undefined) => {
if (highscore == undefined)
highscore = await Highscore.find();
const highscoreByDate = [];
highscore.forEach(person => {
person.wins.map(win => {
const epochDate = new Date(win.date).setHours(0,0,0,0);
const winnerObject = {
name: person.name,
color: win.color,
wine: win.wine,
date: epochDate
if(!tempWine.winner) {
throw new WinnerNotFound()
}
const existingDateIndex = highscoreByDate.findIndex(el => el.date == epochDate)
if (existingDateIndex > -1)
highscoreByDate[existingDateIndex].winners.push(winnerObject);
else
highscoreByDate.push({
date: epochDate,
winners: [winnerObject]
})
})
})
const prelotteryWine = await prelotteryWineRepository.wineById(tempWine._id);
const winner = await winnerRepository.winnerById(tempWine.winner.id, true);
return sortNewestFirst(highscoreByDate);
return wineRepository
.addWine(prelotteryWine)
.then(_ => prelotteryWineRepository.addWinnerToWine(prelotteryWine, winner)) // prelotteryWine.deleteById
.then(_ => historyRepository.addWinnerWithWine(winner, prelotteryWine))
.then(_ => winnerRepository.setWinnerChosenById(winner.id))
}
const resolveWineReferences = (highscoreObject, key) => {
const listWithWines = highscoreObject[key]
const archive = (date, raffles, stolen, wines) => {
const { blue, red, yellow, green } = raffles;
const bought = blue + red + yellow + green;
return Promise.all(listWithWines.map(element =>
Wine.findById(element.wine)
.then(wine => {
element.wine = wine
return element
}))
)
.then(resolvedListWithWines => {
highscoreObject[key] = resolvedListWithWines;
return highscoreObject
})
}
// end utils
// Routes
const all = (req, res) => {
return Highscore.find()
.then(highscore => groupHighscoreByDate(highscore))
.then(lotteries => res.send({
message: "Lotteries by date!",
lotteries
}))
}
const latest = (req, res) => {
return groupHighscoreByDate()
.then(lotteries => lotteries.shift()) // first element in list
.then(latestLottery => resolveWineReferences(latestLottery, "winners"))
.then(lottery => res.send({
message: "Latest lottery!",
winners: lottery.winners
})
)
}
const byEpochDate = (req, res) => {
let { date } = req.params;
date = new Date(new Date(parseInt(date)).setHours(0,0,0,0)).getTime()
const dateString = epochToDateString(date);
return groupHighscoreByDate()
.then(lotteries => {
const lottery = lotteries.filter(lottery => lottery.date == date)
if (lottery.length > 0) {
return lottery[0]
} else {
return res.status(404).send({
message: `No lottery found for date: ${ dateString }`
})
}
})
.then(lottery => resolveWineReferences(lottery, "winners"))
.then(lottery => res.send({
message: `Lottery for date: ${ dateString}`,
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,
winners: lottery.winners
}))
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 byName = (req, res) => {
const { name } = req.params;
const regexName = new RegExp(name, "i"); // lowercase regex of the name
return Highscore.find({ name })
.then(highscore => {
if (highscore.length > 0) {
return highscore[0]
} else {
return res.status(404).send({
message: `Name: ${ name } not found in leaderboards.`
})
const query = [
{
$match: {
date: {
$gte: startQueryDate,
$lte: endQueryDate
}
})
.then(highscore => resolveWineReferences(highscore, "wins"))
.then(highscore => res.send({
message: `Lottery winnings for name: ${ name }.`,
name: highscore.name,
highscore: sortNewestFirst(highscore.wins)
}))
}
},
{
$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 = {
all,
latest,
byEpochDate,
byName
drawWinner,
archive,
lotteryByDate,
allLotteries,
allLotteriesIncludingWinners,
latestLottery
};

View File

@@ -2,34 +2,51 @@ const https = require("https");
const path = require("path");
const config = require(path.join(__dirname + "/../config/defaults/lottery"));
const dateString = (date) => {
if (typeof(date) == "string") {
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)
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}`
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 sendWineSelectMessage(winnerObject) {
winnerObject.timestamp_sent = new Date().getTime();
winnerObject.timestamp_limit = new Date().getTime() * 600000;
await winnerObject.save();
async function sendPrizeSelectionLink(winner) {
winner.timestamp_sent = new Date().getTime();
winner.timestamp_limit = new Date().getTime() + 1000 * 600;
await winner.save();
let url = new URL(`/#/winner/${winnerObject.id}`, "https://lottis.vin");
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 sendMessageToUser(
winnerObject.phoneNumber,
`Gratulerer som heldig vinner av vinlotteriet ${winnerObject.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 sendMessageToUser(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!`)
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) {
@@ -38,48 +55,33 @@ async function sendLastWinnerMessage(winnerObject, wineObject) {
winnerObject.timestamp_limit = new Date().getTime();
await winnerObject.save();
return sendMessageToUser(
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!`
`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 sendMessageToUser(
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.`
`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 sendMessageToUser(phoneNumber, message) {
console.log(`Attempting to send message to ${ phoneNumber }.`)
async function sendMessageToNumber(phoneNumber, message) {
console.log(`Attempting to send message to ${phoneNumber}.`);
const body = {
sender: "Vinlottis",
message: message,
recipients: [{ msisdn: `47${ phoneNumber }`}]
recipients: [{ msisdn: `47${phoneNumber}` }],
};
return gatewayRequest(body);
}
async function sendInitialMessageToWinners(winners) {
let numbers = [];
for (let i = 0; i < winners.length; i++) {
numbers.push({ msisdn: `47${winners[i].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 gatewayRequest(body) {
return new Promise((resolve, reject) => {
const options = {
@@ -88,34 +90,34 @@ async function gatewayRequest(body) {
path: `/rest/mtsms?token=${config.gatewayToken}`,
method: "POST",
headers: {
"Content-Type": "application/json"
}
}
"Content-Type": "application/json",
},
};
const req = https.request(options, (res) => {
const req = https.request(options, res => {
console.log(`statusCode: ${res.statusCode}`);
console.log(`statusMessage: ${res.statusMessage}`);
res.setEncoding('utf8');
res.setEncoding("utf8");
if (res.statusCode == 200) {
res.on("data", (data) => {
console.log("Response from message gateway:", data)
res.on("data", data => {
console.log("Response from message gateway:", data);
resolve(JSON.parse(data))
resolve(JSON.parse(data));
});
} else {
res.on("data", (data) => {
res.on("data", data => {
data = JSON.parse(data);
return reject('Gateway error: ' + data['message'] || data)
return reject("Gateway error: " + data["message"] || data);
});
}
})
});
req.on("error", (error) => {
req.on("error", error => {
console.error(`Error from sms service: ${error}`);
reject(`Error from sms service: ${error}`);
})
});
req.write(JSON.stringify(body));
req.end();
@@ -123,9 +125,9 @@ async function gatewayRequest(body) {
}
module.exports = {
sendWineSelectMessage,
sendInitialMessageToWinners,
sendPrizeSelectionLink,
sendWineConfirmation,
sendLastWinnerMessage,
sendWineSelectMessageTooLate,
sendInitialMessageToWinners
}
};

View File

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

View File

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

View File

@@ -1,35 +0,0 @@
const path = require("path");
const Highscore = require(path.join(__dirname, "/schemas/Highscore"));
async function findSavePerson(foundWinner, wonWine, date) {
let person = await Highscore.findOne({
name: foundWinner.name
});
if (person == undefined) {
let newPerson = new Highscore({
name: foundWinner.name,
wins: [
{
color: foundWinner.color,
date: date,
wine: wonWine
}
]
});
await newPerson.save();
} else {
person.wins.push({
color: foundWinner.color,
date: date,
wine: wonWine
});
person.markModified("wins");
await person.save();
}
return person;
}
module.exports.findSavePerson = findSavePerson;

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,41 +1,20 @@
const express = require("express");
const path = require("path");
const RequestedWine = require(path.join(
__dirname, "/schemas/RequestedWine"
));
const Wine = require(path.join(
__dirname, "/schemas/Wine"
));
const RequestedWine = require(path.join(__dirname, "/schemas/RequestedWine"));
const Wine = require(path.join(__dirname, "/schemas/Wine"));
const deleteRequestedWineById = async (req, res) => {
const { id } = req.params;
if(id == null){
return res.json({
message: "Id er ikke definert",
success: false
})
class RequestedWineNotFound extends Error {
constructor(message = "Wine with this id was not found.") {
super(message);
this.name = "RequestedWineNotFound";
this.statusCode = 404;
}
}
await RequestedWine.deleteOne({wineId: id})
return res.json({
message: `Slettet vin med id: ${id}`,
success: true
});
}
const addNew = async wine => {
let foundWine = await Wine.findOne({ id: wine.id });
const getAllRequestedWines = async (req, res) => {
const allWines = await RequestedWine.find({}).populate("wine");
return res.json(allWines);
}
const requestNewWine = async (req, res) => {
const {wine} = req.body
let thisWineIsLOKO = await Wine.findOne({id: wine.id})
if(thisWineIsLOKO == undefined){
thisWineIsLOKO = new Wine({
if (foundWine == undefined) {
foundWine = new Wine({
name: wine.name,
vivinoLink: wine.vivinoLink,
rating: null,
@@ -43,27 +22,47 @@ const requestNewWine = async (req, res) => {
image: wine.image,
id: wine.id
});
await thisWineIsLOKO.save()
await foundWine.save();
}
let requestedWine = await RequestedWine.findOne({ "wineId": wine.id})
let requestedWine = await RequestedWine.findOne({ wineId: wine.id });
if (requestedWine == undefined) {
requestedWine = new RequestedWine({
count: 1,
wineId: wine.id,
wine: thisWineIsLOKO
})
wine: foundWine
});
} else {
requestedWine.count += 1;
}
await requestedWine.save()
await requestedWine.save();
return res.send(requestedWine);
return requestedWine;
};
const getById = id => {
return RequestedWine.findOne({ wineId: id })
.populate("wine")
.then(wine => {
if (wine == null) {
throw new RequestedWineNotFound();
}
module.exports = {
requestNewWine,
getAllRequestedWines,
deleteRequestedWineById
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,154 +0,0 @@
const path = require("path");
const Purchase = require(path.join(__dirname, "/schemas/Purchase"));
const Wine = require(path.join(__dirname, "/schemas/Wine"));
const Highscore = require(path.join(__dirname, "/schemas/Highscore"));
const PreLotteryWine = require(path.join(
__dirname, "/schemas/PreLotteryWine"
));
const prelotteryWines = async (req, res) => {
let wines = await PreLotteryWine.find();
return res.json(wines);
};
const allPurchase = async (req, res) => {
let purchases = await Purchase.find()
.populate("wines")
.sort({ date: 1 });
return res.json(purchases);
};
const purchaseByColor = 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;
return 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
});
};
const highscore = async (req, res) => {
const highscore = await Highscore.find().populate("wins.wine");
return res.json(highscore);
};
const allWines = async (req, res) => {
const wines = await Wine.find();
return res.json(wines);
};
const allWinesSummary = 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;
}
}
}
}
wines = Object.values(wines).reverse()
return res.json(wines);
};
module.exports = {
prelotteryWines,
allPurchase,
purchaseByColor,
highscore,
allWines,
allWinesSummary
};

View File

@@ -4,67 +4,104 @@ const path = require("path");
const mustBeAuthenticated = require(path.join(__dirname, "/middleware/mustBeAuthenticated"));
const setAdminHeaderIfAuthenticated = require(path.join(__dirname, "/middleware/setAdminHeaderIfAuthenticated"));
const update = require(path.join(__dirname, "/update"));
const retrieve = require(path.join(__dirname, "/retrieve"));
const request = require(path.join(__dirname, "/request"));
const subscriptionApi = require(path.join(__dirname, "/subscriptions"));
const userApi = require(path.join(__dirname, "/user"));
const wineinfo = require(path.join(__dirname, "/wineinfo"));
const virtualApi = require(path.join(__dirname, "/virtualLottery"));
const virtualRegistrationApi = require(path.join(
__dirname, "/virtualRegistration"
));
const lottery = require(path.join(__dirname, "/lottery"));
const chatHistoryApi = require(path.join(__dirname, "/chatHistory"));
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("/wineinfo/search", wineinfo.wineSearch);
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("/request/all", setAdminHeaderIfAuthenticated, request.getAllRequestedWines);
router.post("/request/new-wine", request.requestNewWine);
router.delete("/request/:id", request.deleteRequestedWineById);
router.get("/requests", setAdminHeaderIfAuthenticated, requestController.allRequests);
router.post("/request", requestController.addRequest);
router.delete("/request/:id", mustBeAuthenticated, requestController.deleteRequest);
router.get("/wineinfo/schema", mustBeAuthenticated, update.schema);
router.get("/wineinfo/:ean", wineinfo.byEAN);
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.post("/log/wines", mustBeAuthenticated, update.submitWines);
router.post("/lottery", update.submitLottery);
router.post("/lottery/wines", update.submitWinesToLottery);
// router.delete("/lottery/wine/:id", update.deleteWineFromLottery);
router.post("/lottery/winners", update.submitWinnersToLottery);
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("/wines/prelottery", retrieve.prelotteryWines);
router.get("/purchase/statistics", retrieve.allPurchase);
router.get("/purchase/statistics/color", retrieve.purchaseByColor);
router.get("/highscore/statistics", retrieve.highscore)
router.get("/wines/statistics", retrieve.allWines);
router.get("/wines/statistics/overall", retrieve.allWinesSummary);
// 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/all", lottery.all);
router.get("/lottery/latest", lottery.latest);
router.get("/lottery/by-date/:date", lottery.byEpochDate);
router.get("/lottery/by-name/:name", lottery.byName);
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.delete('/virtual/winner/all', mustBeAuthenticated, virtualApi.deleteWinners);
router.delete('/virtual/attendee/all', mustBeAuthenticated, virtualApi.deleteAttendees);
router.get('/virtual/winner/draw', virtualApi.drawWinner);
router.get('/virtual/winner/all', virtualApi.winners);
router.get('/virtual/winner/all/secure', mustBeAuthenticated, virtualApi.winnersSecure);
router.post('/virtual/finish', mustBeAuthenticated, virtualApi.finish);
router.get('/virtual/attendee/all', virtualApi.attendees);
router.get('/virtual/attendee/all/secure', mustBeAuthenticated, virtualApi.attendeesSecure);
router.post('/virtual/attendee/add', mustBeAuthenticated, virtualApi.addAttendee);
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.post('/winner/notify/:id', virtualRegistrationApi.sendNotificationToWinnerById);
router.get('/winner/:id', virtualRegistrationApi.getWinesToWinnerById);
router.post('/winner/:id', virtualRegistrationApi.registerWinnerSelection);
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('/chat/history', chatHistoryApi.getAllHistory)
router.delete('/chat/history', mustBeAuthenticated, chatHistoryApi.deleteHistory)
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.post('/login', userApi.login);
router.post('/register', mustBeAuthenticated, userApi.register);
router.get('/logout', userApi.logout);
// 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,9 +6,14 @@ const PreLotteryWine = new Schema({
vivinoLink: String,
rating: Number,
id: String,
year: Number,
image: String,
price: String,
country: String
country: String,
winner: {
type: Schema.Types.ObjectId,
ref: "VirtualWinner"
}
});
module.exports = mongoose.model("PreLotteryWine", PreLotteryWine);

View File

@@ -10,6 +10,10 @@ const VirtualWinner = new Schema({
red: Number,
yellow: Number,
id: String,
prize_selected: {
type: Boolean,
default: false
},
timestamp_drawn: Number,
timestamp_sent: Number,
timestamp_limit: Number

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

@@ -1,142 +0,0 @@
const express = require("express");
const path = require("path");
const sub = require(path.join(__dirname, "/subscriptions"));
const _wineFunctions = require(path.join(__dirname, "/wine"));
const _personFunctions = require(path.join(__dirname, "/person"));
const Subscription = require(path.join(__dirname, "/schemas/Subscription"));
const Lottery = require(path.join(__dirname, "/schemas/Purchase"));
const PreLotteryWine = require(path.join(
__dirname, "/schemas/PreLotteryWine"
));
const submitWines = 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();
console.log("Sending new wines w/ push notification to all subscribers.")
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"
});
try {
sub.sendNotification(subscription, message);
} catch (error) {
console.error("Error when trying to send push notification to subscriber.");
console.error(error);
}
}
return res.send({
message: "Submitted and notified push subscribers of new wines!",
success: true
});
};
const schema = async (req, res) => {
let schema = { ...PreLotteryWine.schema.obj };
let nulledSchema = Object.keys(schema).reduce((accumulator, current) => {
accumulator[current] = "";
return accumulator
}, {});
return res.send(nulledSchema);
}
// TODO IMPLEMENT WITH FRONTEND (unused)
const submitWinesToLottery = async (req, res) => {
const { lottery } = req.body;
const { date, wines } = lottery;
const wineObjects = await Promise.all(wines.map(async (wine) => await _wineFunctions.findSaveWine(wine)))
return Lottery.findOneAndUpdate({ date: date }, {
date: date,
wines: wineObjects
}, {
upsert: true
}).then(_ => res.send(true))
.catch(err => res.status(500).send({ message: 'Unexpected error while updating/saving wine to lottery.',
success: false,
exception: err.message }));
}
/**
* @apiParam (Request body) {Array} winners List of winners
*/
const submitWinnersToLottery = async (req, res) => {
const { lottery } = req.body;
const { winners, date } = lottery;
for (let i = 0; i < winners.length; i++) {
let currentWinner = winners[i];
let wonWine = await _wineFunctions.findSaveWine(currentWinner.wine); // TODO rename to findAndSaveWineToLottery
await _personFunctions.findSavePerson(currentWinner, wonWine, date); // TODO rename to findAndSaveWineToPerson
}
return res.json(true);
}
/**
* @apiParam (Request body) {Date} date Date of lottery
* @apiParam (Request body) {Number} blue Number of blue tickets
* @apiParam (Request body) {Number} red Number of red tickets
* @apiParam (Request body) {Number} green Number of green tickets
* @apiParam (Request body) {Number} yellow Number of yellow tickets
* @apiParam (Request body) {Number} bought Number of tickets bought
* @apiParam (Request body) {Number} stolen Number of tickets stolen
*/
const submitLottery = async (req, res) => {
const { lottery } = req.body
const { date,
blue,
red,
yellow,
green,
bought,
stolen } = lottery;
return Lottery.findOneAndUpdate({ date: date }, {
date: date,
blue: blue,
yellow: yellow,
red: red,
green: green,
bought: bought,
stolen: stolen
}, {
upsert: true
}).then(_ => res.send(true))
.catch(err => res.status(500).send({ message: 'Unexpected error while updating/saving lottery.',
success: false,
exception: err.message }));
return res.send(true);
};
module.exports = {
submitWines,
schema,
submitLottery,
submitWinnersToLottery,
submitWinesToLottery
};

View File

@@ -1,51 +1,90 @@
const passport = require("passport");
const path = require("path");
const User = require(path.join(__dirname, "/schemas/User"));
const router = require("express").Router();
const register = (req, res, next) => {
User.register(
new User({ username: req.body.username }),
req.body.password,
function(err) {
if (err) {
if (err.name == "UserExistsError")
res.status(409).send({ success: false, message: err.message })
else if (err.name == "MissingUsernameError" || err.name == "MissingPasswordError")
res.status(400).send({ success: false, message: err.message })
return next(err);
class UserExistsError extends Error {
constructor(message = "Username already exists.") {
super(message);
this.name = "UserExists";
this.statusCode = 409;
}
}
return res.status(200).send({ message: "Bruker registrert. Velkommen " + req.body.username, success: true })
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 login = (req, res, next) => {
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) {
if (err.name == "MissingUsernameError" || err.name == "MissingPasswordError")
return res.status(400).send({ message: err.message, success: false })
return next(err);
reject(err);
}
if (!user) return res.status(404).send({ message: "Incorrect username or password", success: false })
if (!user) {
reject(new IncorrectUserCredentialsError());
}
req.logIn(user, (err) => {
if (err) { return next(err) }
return res.status(200).send({ message: "Velkommen " + user.username, success: true })
})
})(req, res, next);
resolve(user);
})(req);
});
};
const logout = (req, res) => {
req.logout();
res.redirect("/");
const login = (req, user) => {
return new Promise((resolve, reject) => {
req.logIn(user, err => {
if (err) {
reject(err);
}
resolve(user);
});
});
};
module.exports = {
register,
login,
logout
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,281 +0,0 @@
const path = require("path");
const crypto = require("crypto");
const config = require(path.join(__dirname, "/../config/defaults/lottery"));
const Message = require(path.join(__dirname, "/message"));
const { findAndNotifyNextWinner } = require(path.join(__dirname, "/virtualRegistration"));
const Attendee = require(path.join(__dirname, "/schemas/Attendee"));
const VirtualWinner = require(path.join(__dirname, "/schemas/VirtualWinner"));
const PreLotteryWine = require(path.join(__dirname, "/schemas/PreLotteryWine"));
const winners = async (req, res) => {
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);
};
const winnersSecure = async (req, res) => {
let winners = await VirtualWinner.find();
return res.json(winners);
};
const deleteWinners = async (req, res) => {
await VirtualWinner.deleteMany();
var io = req.app.get('socketio');
io.emit("refresh_data", {});
return res.json(true);
};
const attendees = 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,
raffles: attendee.red + attendee.blue + attendee.yellow + attendee.green,
red: attendee.red,
blue: attendee.blue,
green: attendee.green,
yellow: attendee.yellow
});
}
return res.json(attendeesRedacted);
};
const attendeesSecure = async (req, res) => {
let attendees = await Attendee.find();
return res.json(attendees);
};
const addAttendee = 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();
var io = req.app.get('socketio');
io.emit("new_attendee", {});
return res.send(true);
};
const deleteAttendees = async (req, res) => {
await Attendee.deleteMany();
var io = req.app.get('socketio');
io.emit("refresh_data", {});
return res.json(true);
};
const drawWinner = async (req, res) => {
let allContestants = await Attendee.find({ winner: false });
if (allContestants.length == 0) {
return res.json({
success: false,
message: "No attendees left that have not won."
});
}
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) {
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)
];
let winners = await VirtualWinner.find({ timestamp_sent: undefined }).sort({
timestamp_drawn: 1
});
var io = req.app.get('socketio');
io.emit("winner", {
color: colorToChooseFrom,
name: winner.name,
winner_count: winners.length + 1
});
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 Attendee.update(
{ name: winner.name, phoneNumber: winner.phoneNumber },
{ $set: { winner: true } }
);
await newWinnerElement.save();
return res.json({
success: true,
winner
});
};
const finish = async (req, res) => {
if (!config.gatewayToken) {
return res.json({
message: "Missing api token for sms gateway.",
success: false
});
}
let winners = await VirtualWinner.find({ timestamp_sent: undefined }).sort({
timestamp_drawn: 1
});
if (winners.length == 0) {
return res.json({
message: "No winners to draw from.",
success: false
});
}
Message.sendInitialMessageToWinners(winners.slice(1));
return findAndNotifyNextWinner()
.then(() => res.json({
success: true,
message: "Sent wine select message to first winner and update message to rest of winners."
}))
.catch(error => res.json({
message: error["message"] || "Unable to send message to first winner.",
success: false
}))
};
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 = {
deleteWinners,
deleteAttendees,
winners,
winnersSecure,
drawWinner,
finish,
attendees,
attendeesSecure,
addAttendee
}

View File

@@ -1,200 +0,0 @@
const path = require("path");
const _wineFunctions = require(path.join(__dirname, "/wine"));
const _personFunctions = require(path.join(__dirname, "/person"));
const Message = require(path.join(__dirname, "/message"));
const VirtualWinner = require(path.join(
__dirname, "/schemas/VirtualWinner"
));
const PreLotteryWine = require(path.join(
__dirname, "/schemas/PreLotteryWine"
));
const getWinesToWinnerById = async (req, res) => {
let id = req.params.id;
let foundWinner = await VirtualWinner.findOne({ id: id });
if (!foundWinner) {
return res.json({
success: false,
message: "No winner with this id.",
existing: false,
turn: false
});
}
let allWinners = await VirtualWinner.find().sort({ timestamp_drawn: 1 });
if (
allWinners[0].id != foundWinner.id ||
foundWinner.timestamp_limit == undefined ||
foundWinner.timestamp_sent == undefined
) {
return res.json({
success: false,
message: "Not the winner next in line!",
existing: true,
turn: false
});
}
return res.json({
success: true,
existing: true,
turn: true,
name: foundWinner.name,
color: foundWinner.color
});
};
const registerWinnerSelection = async (req, res) => {
let id = req.params.id;
let wineName = req.body.wineName;
let foundWinner = await VirtualWinner.findOne({ id: id });
if (!foundWinner) {
return res.json({
success: false,
message: "No winner with this id."
})
} else if (foundWinner.timestamp_limit < new Date().getTime()) {
return res.json({
success: false,
message: "Timelimit expired, you will receive a wine after other users have chosen.",
limit: true
})
}
let date = new Date();
date.setHours(5, 0, 0, 0);
let prelotteryWine = await PreLotteryWine.findOne({ name: wineName });
if (!prelotteryWine) {
return res.json({
success: false,
message: "No wine with this name.",
wine: false
});
}
let wonWine = await _wineFunctions.findSaveWine(prelotteryWine);
await prelotteryWine.delete();
await _personFunctions.findSavePerson(foundWinner, wonWine, date);
await Message.sendWineConfirmation(foundWinner, wonWine, date);
await foundWinner.delete();
console.info("Saved winners choice.");
return findAndNotifyNextWinner()
.then(() => res.json({
message: "Choice saved and next in line notified.",
success: true
}))
.catch(error => res.json({
message: error["message"] || "Error when notifing next winner.",
success: false
}))
};
const chooseLastWineForUser = (winner, preLotteryWine) => {
let date = new Date();
date.setHours(5, 0, 0, 0);
return _wineFunctions.findSaveWine(preLotteryWine)
.then(wonWine => _personFunctions.findSavePerson(winner, wonWine, date))
.then(() => preLotteryWine.delete())
.then(() => Message.sendLastWinnerMessage(winner, preLotteryWine))
.then(() => winner.delete())
.catch(err => {
console.log("Error thrown from chooseLastWineForUser: " + err);
throw err;
})
}
const findAndNotifyNextWinner = async () => {
let nextWinner = undefined;
let winnersLeft = await VirtualWinner.find().sort({ timestamp_drawn: 1 });
let winesLeft = await PreLotteryWine.find();
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 chooseLastWineForUser(nextWinner, wine);
}
if (nextWinner) {
return Message.sendWineSelectMessage(nextWinner)
.then(messageResponse => startTimeout(nextWinner.id))
} else {
console.info("All winners notified. Could start cleanup here.");
return Promise.resolve({
message: "All winners notified."
})
}
};
const sendNotificationToWinnerById = async (req, res) => {
const { id } = req.params;
let winner = await VirtualWinner.findOne({ id: id });
if (!winner) {
return res.json({
message: "No winner with this id.",
success: false
})
}
return Message.sendWineSelectMessage(winner)
.then(success => res.json({
success: success,
message: `Message sent to winner ${id} successfully!`
}))
.catch(err => res.json({
success: false,
message: "Error while trying to send sms.",
error: err
}))
}
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 });
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();
findAndNotifyNextWinner();
}, minutesForTimeout * minute);
return Promise.resolve()
}
module.exports = {
getWinesToWinnerById,
registerWinnerSelection,
findAndNotifyNextWinner,
sendNotificationToWinnerById
};

View File

@@ -1,27 +1,63 @@
const path = require("path");
const Wine = require(path.join(__dirname, "/schemas/Wine"));
async function findSaveWine(prelotteryWine) {
let wonWine = await Wine.findOne({ name: prelotteryWine.name });
if (wonWine == undefined) {
let newWonWine = new Wine({
name: prelotteryWine.name,
vivinoLink: prelotteryWine.vivinoLink,
rating: prelotteryWine.rating,
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,
image: prelotteryWine.image,
id: prelotteryWine.id
id: wine.id,
year: wine.year,
image: wine.image,
price: wine.price,
country: wine.country
});
await newWonWine.save();
wonWine = newWonWine;
await newWine.save();
return newWine;
} else {
wonWine.occurences += 1;
wonWine.image = prelotteryWine.image;
wonWine.id = prelotteryWine.id;
await wonWine.save();
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 wonWine;
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();
}
module.exports.findSaveWine = findSaveWine;
return wine;
});
};
module.exports = {
addWine,
allWines,
wineById,
findWine
};

View File

@@ -1,72 +0,0 @@
const fetch = require('node-fetch')
const path = require('path')
const config = require(path.join(__dirname + "/../config/env/lottery.config"));
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,
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 wineSearch = async (req, res) => {
const {query} = req.query
let url = new URL(`https://apis.vinmonopolet.no/products/v0/details-normal?productShortNameContains=test&maxResults=15`)
url.searchParams.set('productShortNameContains', query)
const vinmonopoletResponse = await fetch(url, {
headers: {
"Ocp-Apim-Subscription-Key": config.vinmonopoletToken
}
})
.then(resp => resp.json())
.catch(err => console.error(err))
if (vinmonopoletResponse.errors != null) {
return vinmonopoletResponse.errors.map(error => {
if (error.type == "UnknownProductError") {
return res.status(404).json({
message: error.message
})
} else {
return next()
}
})
}
const winesConverted = vinmonopoletResponse.map(convertToOurWineObject).filter(Boolean)
return res.send(winesConverted);
}
const byEAN = 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()
}
})
}
return res.send(vinmonopoletResponse);
};
module.exports = {
byEAN,
wineSearch
};

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

@@ -8,5 +8,6 @@ module.exports = {
gatewayToken: undefined,
vinmonopoletToken: undefined,
googleanalytics_trackingId: undefined,
googleanalytics_cookieLifetime: 60 * 60 * 24 * 14
googleanalytics_cookieLifetime: 60 * 60 * 24 * 14,
sites: [],
};

View File

@@ -11,15 +11,15 @@ const webpackConfig = function(isDev) {
resolve: {
extensions: [".js", ".vue"],
alias: {
vue$: "vue/dist/vue.min.js",
"@": helpers.root("frontend")
}
"vue$": "vue/dist/vue.min.js",
"@": helpers.root("frontend"),
},
},
entry: {
vinlottis: helpers.root("frontend", "vinlottis-init")
vinlottis: helpers.root("frontend", "vinlottis-init"),
},
externals: {
moment: 'moment' // comes with chart.js
moment: "moment", // comes with chart.js
},
module: {
rules: [
@@ -31,45 +31,45 @@ 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$/,
use: ["babel-loader"],
include: [helpers.root("frontend")]
include: [helpers.root("frontend")],
},
{
test: /\.css$/,
use: [
MiniCSSExtractPlugin.loader,
{ loader: "css-loader", options: { sourceMap: isDev } }
]
{ loader: "css-loader", options: { sourceMap: isDev } },
],
},
{
test: /\.scss$/,
use: [
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",
options: {
limit: 10000,
mimetype: "application/font-woff"
}
mimetype: "application/font-woff",
},
},
{
test: /\.(ttf|eot|svg)(\?[a-z0-9]+)?$/,
loader: "file-loader"
}
]
loader: "file-loader",
},
],
},
plugins: [
new VueLoaderPlugin(),
@@ -83,9 +83,10 @@ const webpackConfig = function(isDev) {
__HOURS__: env.hours,
__PUSHENABLED__: JSON.stringify(require("./defaults/push") != false),
__GA_TRACKINGID__: JSON.stringify(env.googleanalytics_trackingId),
__GA_COOKIELIFETIME__: env.googleanalytics_cookieLifetime
})
]
__GA_COOKIELIFETIME__: env.googleanalytics_cookieLifetime,
__sites__: JSON.stringify(env.sites),
}),
],
};
};

View File

@@ -15,51 +15,52 @@ let webpackConfig = merge(commonConfig(true), {
output: {
path: helpers.root("dist"),
publicPath: "/",
filename: "js/[name].bundle.js"
filename: "js/[name].bundle.js",
},
optimization: {
concatenateModules: true,
splitChunks: {
chunks: "initial"
}
chunks: "initial",
},
},
plugins: [
new webpack.EnvironmentPlugin(environment),
new FriendlyErrorsPlugin(),
new MiniCSSExtractPlugin({
filename: "css/[name].css"
})
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
changeOrigin: true,
},
"/socket.io": {
target: "ws://localhost:30030",
changeOrigin: false,
ws: true
}
ws: true,
},
},
writeToDisk: false,
},
writeToDisk: false
}
});
webpackConfig = merge(webpackConfig, {
plugins: [
new HtmlWebpackPlugin({
template: "frontend/templates/Index.html"
})
]
template: "frontend/templates/Index.html",
}),
],
});
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()

View File

@@ -3,12 +3,7 @@
<banner :routes="routes" />
<router-view />
<Footer />
<UpdateToast
v-if="showToast"
:text="toastText"
:refreshButton="refreshToast"
v-on:closeToast="closeToast"
/>
<UpdateToast v-if="showToast" :text="toastText" :refreshButton="refreshToast" v-on:closeToast="closeToast" />
</div>
</template>
@@ -30,33 +25,33 @@ export default {
routes: [
{
name: "Virtuelt lotteri",
route: "/lottery"
route: "/lottery",
},
{
name: "Dagens viner",
route: "/dagens/"
route: "/dagens/",
},
{
name: "Highscore",
route: "/highscore"
route: "/highscore",
},
{
name: "Historie",
route: "/history/"
route: "/history/",
},
{
name: "Foreslå vin",
route: "/request"
route: "/request",
},
{
name: "Foreslåtte viner",
route: "/requested-wines"
route: "/requested-wines",
},
{
name: "Login",
route: "/login"
}
]
route: "/login",
},
],
};
},
mounted() {
@@ -78,7 +73,7 @@ export default {
closeToast: function() {
this.showToast = false;
},
}
},
};
</script>

View File

@@ -26,7 +26,8 @@ const allRequestedWines = () => {;
return fetch("/api/request/all")
.then(resp => {
const isAdmin = resp.headers.get("vinlottis-admin") == "true";
return Promise.all([resp.json(), isAdmin]);
const getWinesFromBody = (resp) => resp.json().then(body => body.wines);
return Promise.all([getWinesFromBody(resp), isAdmin]);
});
};
@@ -109,8 +110,7 @@ const deleteRequestedWine = wineToBeDeleted => {
headers: {
"Content-Type": "application/json"
},
method: "DELETE",
body: JSON.stringify(wineToBeDeleted)
method: "DELETE"
};
return fetch("api/request/" + wineToBeDeleted.id, options)
@@ -148,14 +148,12 @@ const attendees = () => {
const requestNewWine = (wine) => {
const options = {
body: JSON.stringify({
wine: wine
}),
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
method: "post"
body: JSON.stringify({ wine })
}
return fetch("/api/request/new-wine", options)

View File

@@ -0,0 +1,247 @@
<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 id="code-container" 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();
},
smh() {
let inputContainer = document.getElementById('code-container')
inputContainer.classList.add('shake')
if (this.timeout)
clearTimeout(this.timeout)
this.timeout = setTimeout(() => inputContainer.classList.remove('shake'), 600);
},
submit() {
const site = __sites__.find(site => site.code?.toLowerCase() == this.code?.toLowerCase());
if (site) {
createCookie("accesscode", site.code, 14);
const path = (location.pathname+location.search).substr(1)
const redirectUrl = `${window.location.protocol}//${site.domain}/${path}`
window.location.href = redirectUrl;
} else {
this.smh()
}
},
},
};
</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%;
}
&.shake {
animation: shake 0.6s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
animation-iteration-count: infinite;
}
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;
font-weight: bold;
}
}
.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);
text-shadow: 1px 1px black;
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;
}
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}
</style>

View File

@@ -1,34 +1,72 @@
<template>
<div>
<h1>Admin-side</h1>
<Tabs :tabs="tabs" />
</div>
</template>
<script>
import Tabs from "@/ui/Tabs";
import RegisterPage from "@/components/RegisterPage";
import VirtualLotteryRegistrationPage from "@/components/VirtualLotteryRegistrationPage";
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,
RegisterPage,
VirtualLotteryRegistrationPage
Tabs
},
data() {
return {
tabs: [
{ name: "Registrering", component: RegisterPage },
{ name: "Virtuelt lotteri", component: VirtualLotteryRegistrationPage }
{
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" scoped>
<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

@@ -1,41 +1,59 @@
<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="requested-wines-container">
<p v-if="wines == undefined || wines.length == 0">Ingen har foreslått noe enda!</p>
<section class="wines-container">
<p v-if="wines == undefined || wines.length == 0">
Ingen har foreslått noe enda!
</p>
<RequestedWineCard v-for="requestedEl in wines" :key="requestedEl.wine._id" :requestedElement="requestedEl" @wineDeleted="filterOutDeletedWine" :showDeleteButton="isAdmin"/>
<RequestedWineCard
v-for="requestedWine in wines"
:key="requestedWine.wine._id"
:requestedElement="requestedWine"
@wineDeleted="filterOutDeletedWine"
:showDeleteButton="isAdmin"
/>
</section>
</main>
</template>
<script>
import { allRequestedWines } from "@/api";
import RequestedWineCard from "@/ui/RequestedWineCard";
export default {
components: {
RequestedWineCard
RequestedWineCard,
},
data() {
return {
wines: undefined,
canRequest: true,
isAdmin: false
}
isAdmin: false,
};
},
mounted() {
this.fetchRequestedWines();
},
methods: {
filterOutDeletedWine(wine) {
this.wines = this.wines.filter(item => item.wine._id !== wine._id)
this.wines = this.wines.filter((item) => item.wine._id !== wine._id);
},
async refreshData(){
[this.wines, this.isAdmin] = await allRequestedWines() || [[], false]
}
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));
},
mounted() {
this.refreshData()
}
}
},
};
</script>
<style lang="scss" scoped>
@@ -56,9 +74,29 @@ h1 {
font-weight: normal;
}
.requested-wines-container{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 2rem;
.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

@@ -2,10 +2,9 @@
<div class="container">
<h1 class="">Alle viner</h1>
<div id="wines-container">
<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>
@@ -20,42 +19,43 @@
<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>
<router-link class="vin-link" :to="winDateUrl(wine.dates[index])">{{
dateString(wine.dates[index])
}}</router-link>
</li>
</ul>
</div>
</div>
</Wine>
</div>
</div>
</template>
<script>
import Banner from "@/ui/Banner";
import Wine from "@/ui/Wine";
import { overallWineStatistics } from "@/api";
import { dateString } from "@/utils";
export default {
components: {
Banner,
Wine
},
components: { Wine },
data() {
return {
wines: []
};
},
mounted() {
this.overallWineStatistics();
},
methods: {
winDateUrl(date) {
const timestamp = new Date(date).getTime();
return `/history/${timestamp}`
return `/history/${timestamp}`;
},
overallWineStatistics() {
return fetch("/api/wines")
.then(resp => resp.json())
.then(response => (this.wines = response.wines));
},
dateString: dateString
},
async mounted() {
this.wines = await overallWineStatistics();
}
};
</script>
@@ -84,18 +84,6 @@ h1 {
font-weight: 600;
}
#wines-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 2rem;
> div {
justify-content: flex-start;
margin-bottom: 2rem;
}
}
.name-wins {
display: flex;
flex-direction: column;

View File

@@ -5,7 +5,7 @@
Velg hvilke farger du vil ha, fyll inn antall lodd og klikk 'generer'
</p>
<RaffleGenerator @numberOfRaffles="val => this.numberOfRaffles = val" />
<RaffleGenerator @numberOfRaffles="val => (this.numberOfRaffles = val)" />
<Vipps class="vipps" :amount="numberOfRaffles" />
<Countdown :hardEnable="hardStart" @countdown="changeEnabled" />
@@ -43,16 +43,16 @@ export default {
this.hardStart = true;
},
track() {
window.ga('send', 'pageview', '/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;
}
@@ -67,7 +67,9 @@ p {
}
.vipps {
margin: 5rem auto 2.5rem auto;
display: flex;
justify-content: center;
margin-top: 4rem;
@include mobile {
margin-top: 2rem;
@@ -75,7 +77,6 @@ p {
}
.container {
margin: auto;
display: flex;
flex-direction: column;
}

View File

@@ -12,7 +12,12 @@
<p class="highscore-header margin-bottom-md"><b>Plassering.</b> Navn - Antall vinn</p>
<ol v-if="highscore.length > 0" class="highscore-list">
<li v-for="person in filteredResults" @click="selectWinner(person)" @keydown.enter="selectWinner(person)" tabindex="0">
<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>
@@ -24,8 +29,6 @@
</template>
<script>
import { highscoreStatistics } from "@/api";
import { humanReadableDate, daysAgo } from "@/utils";
import Wine from "@/ui/Wine";
@@ -34,18 +37,12 @@ export default {
data() {
return {
highscore: [],
filterInput: ''
}
filterInput: ""
};
},
async mounted() {
let response = await highscoreStatistics();
response.sort((a, b) => {
return a.wins.length > b.wins.length ? -1 : 1;
});
response = response.filter(
person => person.name != null && person.name != ""
);
this.highscore = this.generateScoreBoard(response);
const winners = await this.highscoreStatistics();
this.highscore = this.generateScoreBoard(winners);
},
computed: {
filteredResults() {
@@ -53,37 +50,42 @@ export default {
let val = this.filterInput;
if (val.length) {
val = val.toLowerCase()
const nameIncludesString = (person) => person.name.toLowerCase().includes(val);
highscore = highscore.filter(nameIncludesString)
val = val.toLowerCase();
const nameIncludesString = person => person.name.toLowerCase().includes(val);
highscore = highscore.filter(nameIncludesString);
}
return highscore
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
const wins = win.wins.length;
if (wins != highestWinCount) {
place += 1
highestWinCount = wins
place += 1;
highestWinCount = wins;
}
const placeString = place.toString().padStart(2, "0");
win.rank = placeString;
return win
})
return win;
});
},
resetFilter() {
this.filterInput = '';
document.getElementsByTagName('input')[0].focus();
this.filterInput = "";
document.getElementsByTagName("input")[0].focus();
},
selectWinner(winner) {
const path = "/highscore/" + encodeURIComponent(winner.name)
goToWinner(winner) {
const path = "/highscore/" + encodeURIComponent(winner.name);
this.$router.push(path);
},
humanReadableDate: humanReadableDate,
@@ -152,7 +154,8 @@ h1 {
cursor: pointer;
border-bottom: 2px solid transparent;
&:hover, &:focus {
&:hover,
&:focus {
border-color: $link-color;
}
}

View File

@@ -9,32 +9,41 @@
</template>
<script>
import { historyByDate, historyAll } from '@/api'
import { historyByDate, historyAll } from "@/api";
import { humanReadableDate } from "@/utils";
import Winners from '@/ui/Winners'
import Winners from "@/ui/Winners";
export default {
name: 'History page of prev lotteries',
name: "History page of prev lotteries",
components: { Winners },
data() {
return {
lotteries: [],
}
},
methods: {
humanReadableDate: humanReadableDate
lotteries: []
};
},
created() {
const dateFromUrl = this.$route.params.date;
if (dateFromUrl !== undefined)
historyByDate(dateFromUrl)
.then(history => this.lotteries = { "lottery": history })
else
historyAll()
.then(history => this.lotteries = history.lotteries)
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>

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

@@ -14,20 +14,25 @@
<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">
<div
class="raffle-element"
:class="color + `-raffle`"
v-for="[color, occurences] in Object.entries(winningColors)"
:key="color"
>
{{ occurences }}
</div>
</div>
<h4 class="el-spacing">Flasker vunnet:</h4>
<div v-for="win in winner.highscore" :key="win._id">
<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) }} dager siden
{{ humanReadableDate(win.date) }} - {{ daysAgo(win.date) }}
</router-link>
<div class="won-wine">
<img :src="smallerWineImage(win.wine.image)">
<div class="won-wine" v-if="win.wine">
<img :src="smallerWineImage(win.wine.image)" />
<div class="won-wine-details">
<h3>{{ win.wine.name }}</h3>
@@ -38,6 +43,11 @@
<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>
@@ -49,67 +59,71 @@
</template>
<script>
import { getWinnerByName } from "@/api";
import { humanReadableDate, daysAgo } from "@/utils";
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
})
if (from.name != null) vm.previousRoute = from;
});
},
computed: {
numberOfWins() {
return this.winner.highscore.length
return this.winner.wins.length;
}
},
created() {
const nameFromURL = this.$route.params.name;
getWinnerByName(nameFromURL)
this.name = this.$route.params.name;
this.getWinnerByName(this.name)
.then(winner => this.setWinner(winner))
.catch(err => this.error = `Ingen med navn: "${nameFromURL}" funnet.`)
.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()
};
this.winningColors = this.findWinningColors();
},
smallerWineImage(image) {
if (image && image.includes(`515x515`))
return image.replace(`515x515`, `175x175`)
return 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.highscore.map(win => win.color)
const colorOccurences = {}
const colors = this.winner.wins.map(win => win.color);
const colorOccurences = {};
colors.forEach(color => {
if (colorOccurences[color] == undefined) {
colorOccurences[color] = 1
colorOccurences[color] = 1;
} else {
colorOccurences[color] += 1
colorOccurences[color] += 1;
}
})
return colorOccurences
});
return colorOccurences;
},
winDateUrl(date) {
const timestamp = new Date(date).getTime();
return `/history/${timestamp}`
const dateParameter = dateString(new Date(date));
return `/history/${dateParameter}`;
},
navigateBack() {
if (this.previousRoute.default) {
@@ -119,9 +133,16 @@ export default {
}
},
humanReadableDate: humanReadableDate,
daysAgo: daysAgo
daysAgo(date) {
const days = daysAgo(date);
if (days == 0) {
return "i dag";
} else {
return `${days} dager siden`;
}
}
}
};
</script>
<style lang="scss" scoped>
@@ -259,7 +280,6 @@ h1 {
}
}
.backdrop {
$background: rgb(244, 244, 244);

View File

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

View File

@@ -9,18 +9,21 @@
<h1>
Foreslå en vin!
</h1>
<section class="search-container">
<section class="search-section">
<input type="text" v-model="searchString" @keyup.enter="fetchWineFromVin()" placeholder="Søk etter en vin du liker her!🍷" class="search-input-field">
<button :disabled="!searchString" @click="fetchWineFromVin()" class="vin-button">Søk</button>
</section>
<section v-for="(wine, index) in this.wines" :key="index" class="single-result">
<img
v-if="wine.image"
:src="wine.image"
class="wine-image"
:class="{ 'fullscreen': fullscreen }"
<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>
@@ -29,37 +32,38 @@
<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="request(wine)">Foreslå denne</button>
<a
v-if="wine.vivinoLink"
:href="wine.vivinoLink"
class="wine-link"
>Les mer</a>
<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="this.wines && this.wines.length == 0">
<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, requestNewWine } from "@/api";
import { searchForWine } from "@/api";
import Wine from "@/ui/Wine";
import Modal from "@/ui/Modal";
import RequestedWineCard from "@/ui/RequestedWineCard";
export default {
components: {
Wine,
Modal
Modal,
RequestedWineCard
},
data() {
return {
searchString: undefined,
wines: undefined,
showModal: false,
loading: false,
modalButtons: [
{
text: "Legg til flere viner",
@@ -70,30 +74,59 @@ export default {
action: "move"
}
]
}
};
},
methods: {
fetchWineFromVin(){
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) {
this.wines = []
let localSearchString = this.searchString.replace(/ /g, "_");
searchForWine(localSearchString)
.then(res => this.wines = res)
this.fetchWinesByQuery(localSearchString);
}
},
request(wine){
requestNewWine(wine)
.then(() => this.showModal = true)
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
this.showModal = false;
} else {
this.$router.push("/requested-wines");
}
}
},
}
};
</script>
<style lang="scss" scoped>
@@ -101,7 +134,6 @@ export default {
@import "@/styles/global";
@import "@/styles/variables";
h1 {
text-align: center;
}
@@ -120,14 +152,13 @@ input[type="text"] {
max-width: 90%;
}
.search-container {
margin: 1rem;
}
.search-section {
display: grid;
grid: 1fr / 1fr .2fr;
grid: 1fr / 1fr 0.2fr;
@include mobile {
.vin-button {
@@ -142,7 +173,7 @@ input[type="text"] {
.single-result {
margin-top: 1rem;
display: grid;
grid: 1fr / .5fr 2fr .5fr .5fr;
grid: 1fr / 0.5fr 2fr 0.5fr 0.5fr;
grid-template-areas: "picture details button-left button-right";
justify-items: center;
align-items: center;
@@ -152,15 +183,15 @@ input[type="text"] {
box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2);
@include mobile {
grid: 1fr .5fr / .5fr 1fr;
grid-template-areas: "picture details"
grid: 1fr 0.5fr / 0.5fr 1fr;
grid-template-areas:
"picture details"
"button-left button-right";
grid-gap: .5em;
grid-gap: 0.5em;
.vin-button {
grid-area: button-right;
padding: .5em;
padding: 0.5em;
font-size: 1em;
line-height: 1em;
height: 2em;
@@ -175,12 +206,10 @@ input[type="text"] {
max-width: 80%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis
text-overflow: ellipsis;
}
}
.wine-image {
height: 100px;
grid-area: picture;
@@ -232,6 +261,4 @@ input[type="text"] {
}
}
}
</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

@@ -23,7 +23,9 @@ export default {
};
},
async mounted() {
prelottery().then(wines => this.wines = wines);
fetch("/api/lottery/wines")
.then(resp => resp.json())
.then(response => (this.wines = response.wines));
}
};
</script>
@@ -42,19 +44,18 @@ h1 {
}
.wines-container {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
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 {
@@ -65,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;

View File

@@ -1,8 +1,6 @@
<template>
<main class="main-container">
<section class="top-container">
<div class="want-to-win">
<h1>
Vil du også vinne?
@@ -38,17 +36,16 @@
<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">
@@ -56,12 +53,10 @@
<WinGraph class="win" />
</section>
<Wines class="wines-container" />
<Wines class="wine-container" />
</section>
<Countdown :hardEnable="hardStart" @countdown="changeEnabled" />
</main>
</template>
@@ -96,14 +91,14 @@ export default {
if (!("PushManager" in window)) {
return false;
}
return (
Notification.permission !== "granted" ||
!this.pushAllowed ||
localStorage.getItem("push") == null
);
return Notification.permission !== "granted" || !this.pushAllowed || localStorage.getItem("push") == null;
}
},
async mounted() {
mounted() {
setTimeout(() => {
document.getElementsByClassName("participate-button")[0].classList.add("pulse");
}, 1800);
this.$on("push-allowed", () => {
this.pushAllowed = true;
});
@@ -120,7 +115,7 @@ export default {
this.hardStart = way;
},
track() {
window.ga('send', 'pageview', '/');
window.ga("send", "pageview", "/");
},
startCountdown() {
this.hardStart = true;
@@ -130,8 +125,9 @@ export default {
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.scss";
@import "../styles/variables.scss";
@import "@/styles/media-queries.scss";
@import "@/styles/variables.scss";
@import "@/styles/animations.scss";
.top-container {
height: 30em;
@@ -182,18 +178,20 @@ export default {
border: 4px solid black;
padding: 0 1em 0 1em;
display: flex;
width: 12.5em;
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: 16px;
font-size: 1.4rem;
margin: 1rem;
margin-left: 15px;
}
@@ -262,13 +260,11 @@ export default {
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;
@@ -278,7 +274,6 @@ export default {
&.icon--ballon {
grid-column: 9 / 11;
grid-row: 3 / 5;
}
&.icon--bottle {
grid-row: 4 / -1;
@@ -304,7 +299,6 @@ export default {
}
}
}
}
h1 {
@@ -346,9 +340,13 @@ h1 {
grid-column: 2 / -2;
}
.wines-container {
.wine-container {
grid-column: 3 / -3;
@include mobile {
grid-column: 2 / -2;
}
}
.icon--arrow-long-right {
transform: rotate(90deg);
@@ -356,7 +354,6 @@ h1 {
}
@include tablet {
.scroll-info {
grid-column: 3 / -3;
}

View File

@@ -5,7 +5,10 @@
<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>
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>
@@ -16,17 +19,15 @@
<VippsPill class="vipps-pill mobile-only" />
<p class="call-to-action">
<span class="vin-link">Følg med utviklingen</span> og <span class="vin-link">chat om trekningen</span>
<i class="icon icon--arrow-left" @click="scrollToContent"></i></p>
<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"
/>
<WinnerDraw :currentWinnerDrawn="currentWinnerDrawn" :currentWinner="currentWinner" :attendees="attendees" />
<div class="todays-raffles">
<h2>Liste av lodd kjøpt i dag</h2>
@@ -51,15 +52,16 @@
</div>
</div>
<div class="container wines-container">
<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 { attendees, winners, prelottery } from "@/api";
import Chat from "@/ui/Chat";
import Vipps from "@/ui/Vipps";
import VippsPill from "@/ui/VippsPill";
@@ -74,18 +76,18 @@ export default {
data() {
return {
attendees: [],
attendeesFetched: false,
winners: [],
wines: [],
currentWinnerDrawn: false,
currentWinner: null,
socket: null,
attendeesFetched: false,
wasDisconnected: false,
ticketsBought: {
"red": 0,
"blue": 0,
"green": 0,
"yellow": 0
red: 0,
blue: 0,
green: 0,
yellow: 0
}
};
},
@@ -129,42 +131,45 @@ export default {
this.socket = null;
},
methods: {
getWinners: async function() {
let response = await winners();
if (response) {
this.winners = response;
}
getWinners() {
fetch("/api/lottery/winners")
.then(resp => resp.json())
.then(response => (this.winners = response.winners));
},
getTodaysWines() {
prelottery()
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)
.catch(_ => (this.todayExists = false));
},
getAttendees: async function() {
let response = await attendees();
if (response) {
this.attendees = response;
if (this.attendees == undefined || this.attendees.length == 0) {
this.attendeesFetched = true;
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);
const addValueOfListObjectByKey = (list, key) => list.map(object => object[key]).reduce((a, b) => a + b);
this.ticketsBought = {
red: addValueOfListObjectByKey(response, "red"),
blue: addValueOfListObjectByKey(response, "blue"),
green: addValueOfListObjectByKey(response, "green"),
yellow: addValueOfListObjectByKey(response, "yellow")
red: addValueOfListObjectByKey(attendees, "red"),
blue: addValueOfListObjectByKey(attendees, "blue"),
green: addValueOfListObjectByKey(attendees, "green"),
yellow: addValueOfListObjectByKey(attendees, "yellow")
};
}
this.attendeesFetched = true;
})
.finally(_ => (this.attendeesFetched = true));
},
scrollToContent() {
console.log(window.scrollY)
console.log(window.scrollY);
const intersectingHeaderHeight = this.$refs.header.getBoundingClientRect().bottom - 50;
const { scrollY } = window;
let scrollHeight = intersectingHeaderHeight;
@@ -178,14 +183,13 @@ export default {
});
},
track() {
window.ga('send', 'pageview', '/lottery/game');
window.ga("send", "pageview", "/lottery/game");
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
@@ -201,7 +205,8 @@ export default {
display: grid;
grid-template-columns: repeat(4, 1fr);
> div, > section {
> div,
> section {
@include mobile {
grid-column: span 5;
}
@@ -343,6 +348,8 @@ header {
> 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);
@@ -369,12 +376,23 @@ header {
}
}
.todays-wines {
width: 80vw;
padding: 0 10vw;
.wines-container {
margin-bottom: 4rem;
@include mobile {
width: 90vw;
padding: 0 5vw;
}
h2 {
width: 100%;
grid-column: 1 / 5;
}
.wine {
margin-right: 1rem;
margin-bottom: 1rem;
}
}
</style>

View File

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

View File

@@ -1,21 +1,23 @@
<template>
<div class="container">
<div v-if="!posted">
<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 && !existing" class="sent-container">Finner ikke noen vinner her..</h1>
<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.name)"
class="vin-button select-wine"
>Velg denne vinnen</button>
<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>
@@ -24,15 +26,13 @@
</template>
<script>
import { getAmIWinner, postWineChosen, prelottery } from "@/api";
import Wine from "@/ui/Wine";
export default {
components: { Wine },
data() {
return {
id: null,
existing: false,
fetched: false,
turn: false,
name: null,
wines: [],
@@ -40,30 +40,43 @@ export default {
};
},
async mounted() {
this.id = this.$router.currentRoute.params.id;
const { id } = this.$router.currentRoute.params;
let winnerObject = await getAmIWinner(this.id);
this.fetched = true;
if (!winnerObject || !winnerObject.existing) {
console.error("non existing", winnerObject);
return;
}
this.existing = true;
if (winnerObject.existing && !winnerObject.turn) {
console.error("not your turn yet", winnerObject);
return;
}
this.turn = true;
this.name = winnerObject.name;
this.wines = await prelottery();
this.id = id;
this.getPrizes(id);
},
methods: {
chooseWine: async function(name) {
let posted = await postWineChosen(this.id, name);
console.log("response", posted);
if (posted.success) {
this.posted = true;
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
});
}
});
}
}
};
@@ -74,9 +87,19 @@ export default {
.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;
@@ -90,11 +113,4 @@ export default {
.select-wine {
margin-top: 1rem;
}
.wines-container {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
align-items: flex-start;
}
</style>

View File

@@ -0,0 +1,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,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 });
}
};
}
};

View File

@@ -1,124 +1,172 @@
const VinlottisPage = () => import(
const VinlottisPage = () =>
import(
/* webpackChunkName: "landing-page" */
"@/components/VinlottisPage");
const VirtualLotteryPage = () => import(
"@/components/VinlottisPage"
);
const VirtualLotteryPage = () =>
import(
/* webpackChunkName: "landing-page" */
"@/components/VirtualLotteryPage");
const GeneratePage = () => import(
"@/components/VirtualLotteryPage"
);
const GeneratePage = () =>
import(
/* webpackChunkName: "landing-page" */
"@/components/GeneratePage");
"@/components/GeneratePage"
);
const TodaysPage = () => import(
const TodaysPage = () =>
import(
/* webpackChunkName: "sub-pages" */
"@/components/TodaysPage");
const AllWinesPage = () => import(
"@/components/TodaysPage"
);
const AllWinesPage = () =>
import(
/* webpackChunkName: "sub-pages" */
"@/components/AllWinesPage");
const HistoryPage = () => import(
"@/components/AllWinesPage"
);
const HistoryPage = () =>
import(
/* webpackChunkName: "sub-pages" */
"@/components/HistoryPage");
const WinnerPage = () => import(
"@/components/HistoryPage"
);
const WinnerPage = () =>
import(
/* webpackChunkName: "sub-pages" */
"@/components/WinnerPage");
"@/components/WinnerPage"
);
const SalgsbetingelserPage = () =>
import(
/* webpackChunkName: "sub-pages" */
"@/components/SalgsbetingelserPage"
);
const LoginPage = () => import(
const LoginPage = () =>
import(
/* webpackChunkName: "user" */
"@/components/LoginPage");
const CreatePage = () => import(
"@/components/LoginPage"
);
const CreatePage = () =>
import(
/* webpackChunkName: "user" */
"@/components/CreatePage");
const AdminPage = () => import(
"@/components/CreatePage"
);
const AdminPage = () =>
import(
/* webpackChunkName: "admin" */
"@/components/AdminPage");
"@/components/AdminPage"
);
const PersonalHighscorePage = () => import(
const PersonalHighscorePage = () =>
import(
/* webpackChunkName: "highscore" */
"@/components/PersonalHighscorePage");
const HighscorePage = () => import(
"@/components/PersonalHighscorePage"
);
const HighscorePage = () =>
import(
/* webpackChunkName: "highscore" */
"@/components/HighscorePage");
"@/components/HighscorePage"
);
const RequestWine = () => import(
const RequestWine = () =>
import(
/* webpackChunkName: "request" */
"@/components/RequestWine");
const AllRequestedWines = () => import(
"@/components/RequestWine"
);
const AllRequestedWines = () =>
import(
/* webpackChunkName: "request" */
"@/components/AllRequestedWines");
"@/components/AllRequestedWines"
);
const routes = [
{
path: "*",
name: "Hjem",
component: VinlottisPage
component: VinlottisPage,
},
{
path: "/lottery",
name: "Lotteri",
component: VirtualLotteryPage
component: VirtualLotteryPage,
},
{
path: "/dagens",
name: "Dagens vin",
component: TodaysPage
component: TodaysPage,
},
{
path: "/viner",
name: "All viner",
component: AllWinesPage
component: AllWinesPage,
},
{
path: "/login",
name: "Login",
component: LoginPage
component: LoginPage,
},
{
path: "/create",
name: "Registrer",
component: CreatePage
component: CreatePage,
},
{
path: "/admin",
name: "Admin side",
component: AdminPage
component: AdminPage,
},
{
path: "/generate/",
component: GeneratePage
component: GeneratePage,
},
{
path: "/winner/:id",
component: WinnerPage
component: WinnerPage,
},
{
path: "/history/:date",
name: "Historie for dato",
component: HistoryPage
component: HistoryPage,
},
{
path: "/history",
name: "Historie",
component: HistoryPage
component: HistoryPage,
},
{
path: "/highscore/:name",
name: "Personlig topplisten",
component: PersonalHighscorePage
component: PersonalHighscorePage,
},
{
path: "/highscore",
name: "Topplisten",
component: HighscorePage
component: HighscorePage,
},
{
path: "/anbefal",
name: "Anbefal ny vin",
component: RequestWine,
},
{
path: "/request",
name: "Etterspør vin",
component: RequestWine
component: RequestWine,
},
{
path: "/anbefalte",
name: "Anbefalte viner",
component: AllRequestedWines,
},
{
path: "/requested-wines",
name: "Etterspurte vin",
component: AllRequestedWines
}
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);
}
}
}

View File

@@ -106,7 +106,6 @@
flex-direction: column;
align-items: center;
justify-content: center;
row-gap: 3em;
&.collapsed {
max-height: 0%;

View File

@@ -20,6 +20,8 @@ body {
a {
text-decoration: none;
cursor: pointer;
color: inherit;
}
.title {
@@ -51,8 +53,10 @@ a {
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;
@@ -76,6 +80,7 @@ a {
> *:not(:last-child) {
margin-right: 2rem;
margin-bottom: 0.75rem;
}
&.column {
@@ -95,7 +100,7 @@ a {
> *:not(:last-child) {
margin-right: unset;
margin-bottom: .75rem;
margin-bottom: 0.75rem;
}
}
}
@@ -105,6 +110,8 @@ 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);
@@ -136,6 +143,11 @@ textarea {
height: auto;
}
&.warning {
background-color: #f9826c;
color: white;
}
&.danger {
background-color: $red;
color: white;
@@ -151,9 +163,12 @@ textarea {
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);
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) {
@@ -173,6 +188,21 @@ textarea {
}
}
.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 {
@@ -193,11 +223,23 @@ textarea {
text-decoration: none;
color: $matte-text-color;
&:focus, &:hover {
&:focus,
&:hover {
border-color: $link-color;
}
}
.margin {
&-md {
margin: 3rem;
}
&-sm {
margin: 1rem;
}
&-0 {
margin: 0;
}
}
.margin-top {
&-md {
@@ -269,14 +311,29 @@ textarea {
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;
color: #333333;
&.green-raffle {
background-color: $light-green;
@@ -293,11 +350,16 @@ textarea {
&.red-raffle {
background-color: $light-red;
}
&:not(:last-of-type) {
margin-right: 1rem;
}
}
@mixin raffle {
padding-bottom: 50px;
&::before, &::after {
&::before,
&::after {
content: "";
position: absolute;
left: 0;
@@ -313,7 +375,7 @@ textarea {
background: radial-gradient(closest-side, transparent, transparent 50%, #fff 50%);
background-size: 50px 50px;
background-position: 25px -25px;
bottom: -25px
bottom: -25px;
}
}

View File

@@ -1,3 +1,5 @@
@import "@/styles/media-queries.scss";
.flex {
display: flex;
@@ -7,6 +9,10 @@
&.row {
flex-direction: row;
@include mobile {
flex-direction: column;
}
}
&.wrap {

View File

@@ -1,21 +1,49 @@
$primary: #b7debd;
body {
--primary: #b7debd;
$light-green: #c8f9df;
$green: #0be881;
$dark-green: #0ed277;
--light-green: #c8f9df;
--green: #0be881;
--dark-green: #0ed277;
$light-blue: #d4f2fe;
$blue: #4bcffa;
$dark-blue: #24acda;
--light-blue: #d4f2fe;
--blue: #4bcffa;
--dark-blue: #24acda;
$light-yellow: #fff6d6;
$yellow: #ffde5d;
$dark-yellow: #ecc31d;
--light-yellow: #fff6d6;
--yellow: #ffde5d;
--dark-yellow: #ecc31d;
$light-red: #fbd7de;
$red: #ef5878;
$dark-red: #ec3b61;
--light-red: #fbd7de;
--red: #ef5878;
--dark-red: #ec3b61;
$link-color: #ff5fff;
--link-color: #ff5fff;
--underlinenav-text: #e1e4e8;
--underlinenav-text-active: #f9826c;
--underlinenav-text-hover: #d1d5da;
$matte-text-color: #333333;
--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

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<title>Vinlottis</title>
<meta name="robots" content="noindex">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"

View File

@@ -1,14 +1,51 @@
<template>
<div class="attendees" v-if="attendees.length > 0">
<div class="attendees-container" ref="attendees">
<div v-if="attendees.length > 0" class="attendee-container">
<div class="attendee" v-for="(attendee, index) in flipList(attendees)" :key="index">
<span class="attendee-name">{{ attendee.name }}</span>
<div class="attendee-info">
<router-link class="attendee-name" :to="`/highscore/${attendee.name}`">
{{ attendee.name }}
</router-link>
<div v-if="admin" class="flex column justify-center margin-top-sm">
<span>Phone: {{ attendee.phoneNumber }}</span>
<span>Has won: {{ attendee.winner }}</span>
</div>
<div class="raffle-container">
<div class="red-raffle raffle-element small">{{ attendee.red }}</div>
<div class="blue-raffle raffle-element small">{{ attendee.blue }}</div>
<div class="green-raffle raffle-element small">{{ attendee.green }}</div>
<div class="yellow-raffle raffle-element small">{{ attendee.yellow }}</div>
</div>
</div>
<div v-if="admin" class="attendee-admin">
<button class="vin-button edit small" @click="editingAttendee = editingAttendee == attendee ? false : attendee">
{{ editingAttendee == attendee ? "Lukk" : "Rediger" }}
</button>
<button class="vin-button small danger" @click="deleteAttendee(attendee)">
Slett deltaker
</button>
</div>
<div v-if="editingAttendee == attendee" class="attendee-edit">
<div class="label-div" v-for="key in Object.keys(attendee)" :key="key">
<label>{{ key }}</label>
<input type="text" v-model="attendee[key]" :placeholder="key" />
</div>
<div v-if="editingAttendee == attendee">
<button class="vin-button small warning" @click="updateAttendee(attendee)">
Oppdater deltaker
</button>
<button class="vin-button small danger" @click="deleteAttendee(attendee)">
Slett deltaker
</button>
</div>
</div>
</div>
</div>
</template>
@@ -17,33 +54,79 @@ export default {
props: {
attendees: {
type: Array
},
admin: {
type: Boolean,
default: false
}
},
data() {
return {
editingAttendee: undefined
};
},
methods: {
flipList: (list) => list.slice().reverse()
flipList: list => list.slice().reverse(),
updateAttendee(updatedAttendee) {
const options = {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ attendee: updatedAttendee })
};
fetch(`/api/lottery/attendee/${updatedAttendee._id}`, options)
.then(resp => resp.json())
.then(response => {
this.editingAttendee = null;
const { message, success } = response;
if (success) {
this.$toast.info({
title: response.message
});
} else {
this.$toast.error({
title: response.message
});
}
});
},
watch: {
attendees: {
deep: true,
handler() {
if (this.$refs && this.$refs.history) {
setTimeout(() => {
this.$refs.attendees.scrollTop = this.$refs.attendees.scrollHeight;
}, 50);
}
deleteAttendee(deletedAttendee) {
const options = {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ attendee: deletedAttendee })
};
fetch(`/api/lottery/attendee/${deletedAttendee._id}`, options)
.then(resp => resp.json())
.then(response => {
this.editingAttendee = null;
const { message, success } = response;
if (success) {
this.$toast.info({
title: response.message
});
} else {
this.$toast.error({
title: response.message
});
}
});
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
@import "@/styles/variables.scss";
@import "@/styles/media-queries.scss";
.attendee-name {
width: 60%;
font-size: 1.1rem;
}
hr {
@@ -51,45 +134,60 @@ hr {
width: 100%;
}
.raffle-element {
font-size: 0.75rem;
width: 45px;
height: 45px;
display: flex;
justify-content: center;
.attendee-container {
align-items: center;
font-weight: bold;
font-size: 0.75rem;
text-align: center;
&:not(:last-of-type) {
margin-right: 1rem;
}
}
.attendees {
display: flex;
flex-direction: column;
align-items: center;
height: auto;
}
.attendees-container {
width: 100%;
height: 100%;
overflow-y: scroll;
max-height: 550px;
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);
}
.attendee {
padding: 0.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
@include mobile {
align-items: center;
width: 100%;
margin: 0 auto;
justify-content: center;
}
&:not(:last-of-type) {
border-bottom: 2px solid #d7d8d7;
}
&:not(:first-of-type) {
margin-top: 0.5rem;
}
button {
margin-bottom: 0.5rem;
}
&-info {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
@include mobile {
flex-direction: column;
}
}
&-edit {
button {
margin-top: 0.5rem;
}
}
.raffle-container {
display: flex;
flex-direction: row;
}
}
</style>

View File

@@ -5,15 +5,27 @@
<img src="/public/assets/images/knowit.svg" alt="knowit logo" />
</router-link>
<a class="menu-toggle-container" aria-label="show-menu" @click="toggleMenu" :class="isOpen ? 'open' : 'collapsed'" >
<a
class="menu-toggle-container"
aria-label="show-menu"
@click="toggleMenu"
:class="isOpen ? 'open' : 'collapsed'"
>
<span class="menu-toggle"></span>
<span class="menu-toggle"></span>
<span class="menu-toggle"></span>
</a>
<nav class="menu" :class="isOpen ? 'open' : 'collapsed'">
<router-link v-for="(route, index) in routes" :key="index" :to="route.route" class="menu-item-link" >
<a @click="toggleMenu" class="single-route" :class="isOpen ? 'open' : 'collapsed'">{{ route.name }}</a>
<router-link
v-for="(route, index) in routes"
:key="index"
:to="route.route"
class="menu-item-link"
>
<a @click="toggleMenu" class="single-route" :class="isOpen ? 'open' : 'collapsed'">{{
route.name
}}</a>
<i class="icon icon--arrow-right"></i>
</router-link>
</nav>
@@ -21,8 +33,9 @@
<div class="clock">
<h2 v-if="!fiveMinutesLeft || !tenMinutesOver">
<span v-if="days > 0">{{ pad(days) }}:</span>
<span>{{ pad(hours) }}</span>:
<span>{{ pad(minutes) }}</span>:
<span>{{ pad(hours) }}</span
>: <span>{{ pad(minutes) }}</span
>:
<span>{{ pad(seconds) }}</span>
</h2>
<h2 v-if="twoMinutesLeft || tenMinutesOver">Lotteriet er i gang!</h2>
@@ -47,8 +60,8 @@ export default {
props: {
routes: {
required: true,
type: Array
}
type: Array,
},
},
mounted() {
this.initialize(), this.countdown();
@@ -65,7 +78,7 @@ export default {
return true;
}
return false;
}
},
},
methods: {
toggleMenu() {
@@ -91,10 +104,7 @@ export default {
let nowDate = new Date();
let now = nowDate.getTime();
if (nextDayOfLottery.getTimezoneOffset() != nowDate.getTimezoneOffset()) {
let _diff =
(nextDayOfLottery.getTimezoneOffset() - nowDate.getTimezoneOffset()) *
60 *
-1;
let _diff = (nextDayOfLottery.getTimezoneOffset() - nowDate.getTimezoneOffset()) * 60 * -1;
nextDayOfLottery.setSeconds(nextDayOfLottery.getSeconds() + _diff);
}
this.nextLottery = nextDayOfLottery;
@@ -110,12 +120,8 @@ export default {
// Time calculations for days, hours, minutes and seconds
this.days = Math.floor(this.distance / (1000 * 60 * 60 * 24));
this.hours = Math.floor(
(this.distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
);
this.minutes = Math.floor(
(this.distance % (1000 * 60 * 60)) / (1000 * 60)
);
this.hours = Math.floor((this.distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
this.minutes = Math.floor((this.distance % (1000 * 60 * 60)) / (1000 * 60));
this.seconds = Math.floor((this.distance % (1000 * 60)) / 1000);
if (this.days == 7) {
this.days = 0;
@@ -125,7 +131,7 @@ export default {
}
this.interval = setTimeout(this.countdown, 500);
},
}
},
};
</script>

View File

@@ -1,6 +1,9 @@
<template>
<div class="chat-container">
<span class="logged-in-username" v-if="username">Logget inn som: <span class="username">{{ username }}</span> <button @click="removeUsername">Logg ut</button></span>
<span class="logged-in-username" v-if="username"
>Logget inn som: <span class="username">{{ username }}</span>
<button @click="removeUsername">Logg ut</button></span
>
<div class="history" ref="history" v-if="chatHistory.length > 0">
<div class="opaque-skirt"></div>
@@ -8,7 +11,8 @@
<button @click="loadMoreHistory">Hent eldre meldinger</button>
</div>
<div class="history-message"
<div
class="history-message"
v-for="(history, index) in chatHistory"
:key="`${history.username}-${history.timestamp}-${index}`"
>
@@ -61,12 +65,11 @@ export default {
};
},
created() {
getChatHistory(1, this.pageSize)
.then(resp => {
getChatHistory(1, this.pageSize).then(resp => {
this.chatHistory = resp.messages;
this.hasMorePages = resp.total != resp.messages.length;
});
const username = window.localStorage.getItem('username');
const username = window.localStorage.getItem("username");
if (username) {
this.username = username;
this.emitUsernameOnConnect = true;
@@ -77,8 +80,7 @@ export default {
handler: function(newVal, oldVal) {
if (oldVal.length == 0) {
this.scrollToBottomOfHistory();
}
else if (newVal && newVal.length == oldVal.length) {
} else if (newVal && newVal.length == oldVal.length) {
if (this.isScrollPositionAtBottom()) {
this.scrollToBottomOfHistory();
}
@@ -105,10 +107,7 @@ export default {
});
this.socket.on("connect", msg => {
if (
this.emitUsernameOnConnect ||
(this.wasDisconnected && this.username != null)
) {
if (this.emitUsernameOnConnect || (this.wasDisconnected && this.username != null)) {
this.setUsername(this.username);
}
});
@@ -133,8 +132,7 @@ export default {
let { page, pageSize } = this;
page = page + 1;
getChatHistory(page, pageSize)
.then(resp => {
getChatHistory(page, pageSize).then(resp => {
this.chatHistory = resp.messages.concat(this.chatHistory);
this.page = page;
this.hasMorePages = resp.total != this.chatHistory.length;
@@ -146,9 +144,7 @@ export default {
},
getTime(timestamp) {
let date = new Date(timestamp);
const timeString = `${this.pad(date.getHours())}:${this.pad(
date.getMinutes()
)}:${this.pad(date.getSeconds())}`;
const timeString = `${this.pad(date.getHours())}:${this.pad(date.getMinutes())}:${this.pad(date.getSeconds())}`;
if (date.getDate() == new Date().getDate()) {
return timeString;
@@ -158,7 +154,7 @@ export default {
sendMessage() {
const message = { message: this.message };
this.socket.emit("chat", message);
this.message = '';
this.message = "";
this.scrollToBottomOfHistory();
},
setUsername(username = undefined) {
@@ -178,7 +174,7 @@ export default {
if (history) {
return history.offsetHeight + history.scrollTop >= history.scrollHeight;
}
return false
return false;
},
scrollToBottomOfHistory() {
setTimeout(() => {
@@ -189,15 +185,15 @@ export default {
scrollToMessageElement(message) {
const elemTimestamp = this.getTime(message.timestamp);
const self = this;
const getTimeStamp = (elem) => elem.getElementsByClassName('timestamp')[0].innerText;
const prevOldestMessageInNewList = (elem) => getTimeStamp(elem) == elemTimestamp;
const getTimeStamp = elem => elem.getElementsByClassName("timestamp")[0].innerText;
const prevOldestMessageInNewList = elem => getTimeStamp(elem) == elemTimestamp;
setTimeout(() => {
const { history } = self.$refs;
const childrenElements = Array.from(history.getElementsByClassName('history-message'));
const childrenElements = Array.from(history.getElementsByClassName("history-message"));
const elemInNewList = childrenElements.find(prevOldestMessageInNewList);
history.scrollTop = elemInNewList.offsetTop - 70
history.scrollTop = elemInNewList.offsetTop - 70;
}, 1);
}
}
@@ -241,7 +237,6 @@ input {
display: flex;
}
.history {
height: 75%;
overflow-y: scroll;
@@ -276,11 +271,7 @@ input {
position: fixed;
height: 2rem;
z-index: 1;
background: linear-gradient(
to bottom,
white,
rgba(255, 255, 255, 0)
);
background: linear-gradient(to bottom, white, rgba(255, 255, 255, 0));
}
& .fetch-older-history {
@@ -310,7 +301,7 @@ input {
border-radius: 4px;
&::before {
content: '';
content: "";
position: absolute;
top: 2.1rem;
left: 2rem;

View File

@@ -3,8 +3,8 @@
<ul>
<li>
<a href="https://github.com/KevinMidboe/vinlottis" class="github">
<span>Open-sourced at github</span>
<img src="/public/assets/images/logo-github.png" alt="github logo">
<span>Utforsk koden github</span>
<img src="/public/assets/images/logo-github.png" alt="github logo" />
</a>
</li>
@@ -16,15 +16,15 @@
</ul>
<router-link to="/" class="company-logo">
<img src="/public/assets/images/knowit.svg" alt="knowit logo">
<img src="/public/assets/images/knowit.svg" alt="knowit logo" />
</router-link>
</footer>
</template>
<script>
export default {
name: 'WineFooter'
}
name: "WineFooter",
};
</script>
<style lang="scss" scoped>
@@ -57,7 +57,6 @@ footer {
display: flex;
align-items: center;
img {
margin-left: 0.5rem;
height: 30px;
@@ -93,5 +92,4 @@ footer {
}
}
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<footer>
<a href="https://github.com/KevinMidboe/vinlottis" class="github">
<span>Utforsk koden github</span>
<img src="/public/assets/images/logo-github.png" alt="github logo" />
</a>
<a href="mailto:questions@vinlottis.no" class="mail">
<span class="vin-link">questions@vinlottis.no</span>
</a>
</footer>
</template>
<script>
export default {
name: "WineFooter",
};
</script>
<style lang="scss" scoped>
@import "@/styles/variables.scss";
@import "@/styles/media-queries.scss";
footer {
width: 100%;
height: 75px;
display: flex;
justify-content: space-between;
align-items: center;
background: #f4f4f4;
position: fixed;
bottom: 0;
> *:first-of-type {
margin-left: 0.5rem;
}
> *:last-of-type {
margin-right: 0.5rem;
}
@include desktop {
> *:first-of-type {
margin-left: 4rem;
}
> *:last-of-type {
margin-right: 4rem;
}
}
ul {
list-style-type: none;
padding: 0;
margin-left: 5rem;
li:not(:first-of-type) {
margin-top: 0.75rem;
}
}
a {
color: $matte-text-color;
}
.github {
display: flex;
align-items: center;
img {
margin-left: 0.5rem;
height: 30px;
}
}
.mail {
display: flex;
align-items: center;
img {
margin-left: 0.5rem;
height: 23px;
}
}
.company-logo {
margin-right: 5em;
img {
width: 100px;
}
}
@include mobile {
$margin: 1rem;
ul {
margin-left: $margin;
}
.company-logo {
margin-right: $margin;
}
}
}
</style>

View File

@@ -1,60 +1,48 @@
<template>
<div class="highscores" v-if="highscore.length > 0">
<section class="heading">
<h3>
Topp 5 vinnere
Topp vinnere
</h3>
<router-link to="highscore" class="">
<span class="vin-link">Se alle vinnere</span>
</router-link>
</section>
<ol class="winner-list-container">
<li v-for="(person, index) in highscore" :key="person._id" class="single-winner">
<li v-for="(person, index) in highscore" :key="person._id">
<router-link :to="`/highscore/${person.name}`" class="single-winner">
<span class="placement">{{ index + 1 }}.</span>
<i class="icon icon--medal"></i>
<p class="winner-name">{{ person.name }}</p>
</router-link>
</li>
</ol>
</div>
</template>
<script>
import { highscoreStatistics } from "@/api";
export default {
data() {
return { highscore: [] };
return {
highscore: [],
limit: 22
};
},
async mounted() {
let response = await highscoreStatistics();
response.sort((a, b) => a.wins.length < b.wins.length ? 1 : -1)
this.highscore = this.generateScoreBoard(response.slice(0, 5));
},
methods: {
generateScoreBoard(highscore=this.highscore) {
let place = 0;
let highestWinCount = -1;
return highscore.map(win => {
const wins = win.wins.length
if (wins != highestWinCount) {
place += 1
highestWinCount = wins
}
const placeString = place.toString().padStart(2, "0");
win.rank = placeString;
return win
})
}
return fetch(`/api/history/by-wins?limit=${this.limit}`)
.then(resp => resp.json())
.then(response => {
this.highscore = response.winners;
});
}
};
</script>
<style lang="scss" scoped>
@import "../styles/variables.scss";
@import "@/styles/variables.scss";
.heading {
display: flex;
justify-content: space-between;
@@ -81,8 +69,8 @@ ol {
.winner-list-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12.5em, 1fr));
gap: 5%;
grid-template-columns: repeat(auto-fit, minmax(12em, 1fr));
gap: 2rem;
.single-winner {
box-sizing: border-box;
@@ -110,11 +98,71 @@ ol {
grid-column: 1 / -1;
}
.winner-count {
grid-row: 3;
grid-column: 1 / -1;
margin: 0;
}
.winner-icon {
grid-row: 1;
grid-column: 3;
}
}
// I'm sorry mama
@media (max-width: 550px) {
*:nth-child(n + 7) {
display: none;
}
}
@media (max-width: 1295px) {
*:nth-child(n + 7) {
display: none;
}
}
@media (max-width: 1630px) {
*:nth-child(n + 9) {
display: none;
}
}
@media (max-width: 1968px) {
*:nth-child(n + 11) {
display: none;
}
}
@media (max-width: 2300px) {
*:nth-child(n + 13) {
display: none;
}
}
@media (max-width: 2645px) {
*:nth-child(n + 15) {
display: none;
}
}
@media (max-width: 2975px) {
*:nth-child(n + 17) {
display: none;
}
}
@media (max-width: 3311px) {
*:nth-child(n + 19) {
display: none;
}
}
@media (max-width: 3647px) {
*:nth-child(n + 21) {
display: none;
}
}
}
</style>

View File

@@ -2,19 +2,95 @@
<div class="chart">
<canvas ref="purchase-chart" width="100" height="50"></canvas>
<div ref="chartjsLegend" class="chartjsLegend"></div>
<div class="year-select" v-if="years.length">
<button
class="vin-button small"
v-for="year in years"
:class="{ active: yearSelected == year }"
@click="yearFilterClicked(year)"
>
{{ year }}
</button>
</div>
</div>
</template>
<script>
import Chartjs from "chart.js";
import { chartPurchaseByColor } from "@/api";
export default {
data() {
return {
lotteries: [],
years: [],
yearSelected: undefined,
chart: undefined
};
},
async mounted() {
let canvas = this.$refs["purchase-chart"].getContext("2d");
let response = await chartPurchaseByColor();
let labels = [];
this.lotteries = await this.chartPurchaseByColor();
if (this.lotteries?.length) this.years = [...new Set(this.lotteries.map(lot => lot.date.slice(0, 4)))];
const dataset = this.calculateChartDatapoints();
let chartData = {
labels: dataset.labels,
datasets: [dataset.blue, dataset.green, dataset.red, dataset.yellow]
};
this.chart = new Chart(canvas, {
type: "line",
data: chartData,
options: {
maintainAspectRatio: false,
animation: {
duration: 0 // general animation time
},
title: {
display: true,
text: "Antall kjøpt",
fontSize: 20
},
legend: {
display: true,
boxWidth: 3,
usePointStyle: true,
borderRadius: 10,
labels: {
padding: 12,
boxWidth: 20,
usePointStyle: true
}
},
scales: {
yAxes: [
{
ticks: {
beginAtZero: true
}
}
]
}
}
});
},
methods: {
async yearFilterClicked(year) {
this.yearSelected = this.yearSelected === year ? null : year;
this.lotteries = await this.chartPurchaseByColor();
const dataset = this.calculateChartDatapoints();
let chartData = {
labels: dataset.labels,
datasets: [dataset.blue, dataset.green, dataset.red, dataset.yellow]
};
this.chart.data = chartData;
this.chart.update();
},
setupDataset() {
let blue = {
label: "Blå",
borderColor: "#57d2fb",
@@ -44,86 +120,39 @@ export default {
data: []
};
if (response.length == 1) {
labels.push("");
blue.data.push(0);
yellow.data.push(0);
red.data.push(0);
green.data.push(0);
}
let highestNumber = 0;
for (let i = 0; i < response.length; i++) {
let thisDate = response[i];
let dateObject = new Date(thisDate.date);
labels.push(this.getPrettierDateString(dateObject));
blue.data.push(thisDate.blue);
yellow.data.push(thisDate.yellow);
red.data.push(thisDate.red);
green.data.push(thisDate.green);
if (thisDate.blue > highestNumber) {
highestNumber = thisDate.blue;
}
if (thisDate.yellow > highestNumber) {
highestNumber = thisDate.yellow;
}
if (thisDate.green > highestNumber) {
highestNumber = thisDate.green;
}
if (thisDate.red > highestNumber) {
highestNumber = thisDate.red;
}
}
let datasets = [blue, yellow, green, red];
let chartdata = {
labels: labels,
datasets: datasets
return {
labels: [""],
blue,
green,
red,
yellow
};
let chart = new Chart(canvas, {
type: "line",
data: chartdata,
options: {
maintainAspectRatio: false,
animation: {
duration: 0 // general animation time
},
title: {
display: true,
text: "Antall kjøpt",
fontSize: 20
},
legend: {
display: true,
boxWidth: 3,
usePointStyle: true,
borderRadius: 10,
labels: {
padding: 12,
boxWidth: 20,
usePointStyle: true
}
},
scales: {
yAxes: [
{
ticks: {
beginAtZero: true,
suggestedMax: highestNumber + 5
}
}
]
}
}
calculateChartDatapoints() {
let dataset = this.setupDataset();
this.lotteries.map(lottery => {
const date = new Date(lottery.date);
dataset.labels.push(this.getPrettierDateString(date));
dataset.blue.data.push(lottery.blue);
dataset.green.data.push(lottery.green);
dataset.red.data.push(lottery.red);
dataset.yellow.data.push(lottery.yellow);
});
return dataset;
},
chartPurchaseByColor() {
const url = new URL("/api/lotteries", window.location);
if (this.yearSelected != null) url.searchParams.set("year", this.yearSelected);
return fetch(url.href)
.then(resp => resp.json())
.then(response => response.lotteries);
},
methods: {
getPrettierDateString(date) {
return `${this.pad(date.getDate())}.${this.pad(
date.getMonth() + 1
)}.${this.pad(date.getYear() - 100)}`;
return `${this.pad(date.getDate())}.${this.pad(date.getMonth() + 1)}.${this.pad(date.getYear() - 100)}`;
},
pad(num) {
if (num < 10) {
@@ -136,11 +165,22 @@ export default {
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.scss";
@import "@/styles/media-queries.scss";
.chart {
width: 100%;
@include desktop {
height: 40vh;
max-height: 500px;
width: 100%;
}
}
.year-select {
margin-top: 1rem;
button:not(:first-of-type) {
margin-left: 0.5rem;
}
}
</style>

View File

@@ -31,15 +31,10 @@
</label>
</div>
<div class="input-line">
<input
type="number"
placeholder="Antall lodd"
@keyup.enter="generateColors"
v-model="numberOfRaffles"
/>
<input type="number" placeholder="Antall lodd" @keyup.enter="generateColors" v-model="numberOfRaffles" />
<button class="vin-button" @click="generateColors">Generer</button>
</div>
<div class="colors">
<div class="colors" :class="{ compact }">
<div
v-for="color in colors"
:class="getColorClass(color)"
@@ -47,13 +42,6 @@
:style="{ transform: 'rotate(' + getRotation() + 'deg)' }"
></div>
</div>
<div class="color-count-container" v-if="generated">
<span>Rød: {{ red }}</span>
<span>Blå: {{ blue }}</span>
<span>Gul: {{ yellow }}</span>
<span>Grønn: {{ green }}</span>
</div>
</div>
</template>
@@ -64,11 +52,15 @@ export default {
type: Boolean,
required: false,
default: false
},
compact: {
type: Boolean,
default: false
}
},
data() {
return {
numberOfRaffles: 4,
numberOfRaffles: 6,
colors: [],
blue: 0,
red: 0,
@@ -101,18 +93,21 @@ export default {
if (time == 5) {
this.generating = false;
this.generated = true;
if (this.numberOfRaffles > 1 &&
[this.redCheckbox, this.greenCheckbox, this.yellowCheckbox, this.blueCheckbox].filter(value => value == true).length == 1) {
return
if (
this.numberOfRaffles > 1 &&
[this.redCheckbox, this.greenCheckbox, this.yellowCheckbox, this.blueCheckbox].filter(value => value == true)
.length == 1
) {
return;
}
if (new Set(this.colors).size == 1) {
alert("BINGO");
this.$toast.info({ title: "BINGO" });
}
this.emitColors()
this.emitColors();
window.ga('send', {
window.ga("send", {
hitType: "event",
eventCategory: "Raffles",
eventAction: "Generate",
@@ -147,8 +142,7 @@ export default {
}
if (this.numberOfRaffles > 0) {
for (let i = 0; i < this.numberOfRaffles; i++) {
let color =
randomArray[Math.floor(Math.random() * randomArray.length)];
let color = randomArray[Math.floor(Math.random() * randomArray.length)];
this.colors.push(color);
if (color == 1) {
this.red += 1;
@@ -201,12 +195,12 @@ export default {
</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";
.container {
margin: auto;
// margin: auto;
display: flex;
flex-direction: column;
}
@@ -282,6 +276,15 @@ label .text {
max-width: 1400px;
margin: 3rem auto 0;
&.compact {
margin-top: 0.5rem;
> .color-box {
width: 100px;
height: 100px;
}
}
@include mobile {
margin: 1.8rem auto 0;
}
@@ -309,20 +312,6 @@ label .text {
justify-content: space-around;
}
.color-count-container {
margin: auto;
width: 300px;
justify-content: space-around;
align-items: center;
display: flex;
font-family: Arial;
margin-top: 35px;
@include mobile {
width: 80vw;
}
}
.green {
background-color: $light-green;
}

View File

@@ -4,7 +4,7 @@
<div class="flex justify-end">
<div class="requested-count cursor-pointer" @click="request">
<span>{{ requestedElement.count }}</span>
<i class="icon icon--heart" :class="{ 'active': locallyRequested }" />
<i class="icon icon--heart" :class="{ active: locallyRequested }" />
</div>
</div>
</template>
@@ -17,10 +17,9 @@
<template v-slot:bottom>
<div class="float-left request">
<i class="icon icon--heart request-icon" :class="{ 'active': locallyRequested }"></i>
<a aria-role="button" tabindex="0" class="link" @click="request"
:class="{ 'active': locallyRequested }">
{{ locallyRequested ? 'Anbefalt' : 'Anbefal' }}
<i class="icon icon--heart request-icon" :class="{ active: locallyRequested }"></i>
<a aria-role="button" tabindex="0" class="link" @click="request" :class="{ active: locallyRequested }">
{{ locallyRequested ? "Anbefalt" : "Anbefal" }}
</a>
</div>
</template>
@@ -39,10 +38,10 @@ export default {
return {
wine: this.requestedElement.wine,
locallyRequested: false
}
};
},
props: {
requestedElement: {
requestedElement: {
required: true,
type: Object
},
@@ -54,26 +53,25 @@ export default {
},
methods: {
request() {
if (this.locallyRequested)
return
console.log("requesting", this.wine)
this.locallyRequested = true
this.requestedElement.count = this.requestedElement.count +1
requestNewWine(this.wine)
if (this.locallyRequested) return;
this.locallyRequested = true;
this.requestedElement.count = this.requestedElement.count + 1;
requestNewWine(this.wine);
},
async deleteWine() {
const wine = this.wine
const wine = this.wine;
if (window.confirm("Er du sikker på at du vil slette vinen?")) {
let response = await deleteRequestedWine(wine);
if (response['success'] == true) {
this.$emit('wineDeleted', wine);
if (response["success"] == true) {
this.$emit("wineDeleted", wine);
} else {
alert("Klarte ikke slette vinen");
}
}
},
},
}
}
};
</script>
<style lang="scss" scoped>
@@ -100,7 +98,7 @@ export default {
.active {
&.link {
border-color: $link-color
border-color: $link-color;
}
&.icon--heart {

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div id="camera-stream">
<h2 v-if="errorMessage">{{ errorMessage }}</h2>
<video playsinline autoplay class="hidden"></video>
</div>
@@ -47,13 +47,8 @@ export default {
this.searchVideoForBarcode(this.video);
},
handleError(error) {
console.log(
"navigator.MediaDevices.getUserMedia error: ",
error.message,
error.name
);
this.errorMessage =
"Feil ved oppstart av kamera! Feilmelding: " + error.message;
console.log("navigator.MediaDevices.getUserMedia error: ", error.message, error.name);
this.errorMessage = "Feil ved oppstart av kamera! Feilmelding: " + error.message;
},
searchVideoForBarcode(video) {
const codeReader = new BrowserBarcodeReader();
@@ -84,10 +79,7 @@ export default {
this.errorMessage = "Feil! " + error.message || error;
},
scrollIntoView() {
window.scrollTo(
0,
document.getElementById("addwine-title").offsetTop - 10
);
window.scrollTo(0, document.getElementById("camera-stream").offsetTop - 10);
}
}
};

View File

@@ -1,51 +1,80 @@
<template>
<div>
<div class="tab-container">
<div
<nav class="tab-container">
<a
class="tab"
v-for="(tab, index) in tabs"
:key="index"
@click="changeTab(index)"
@keydown.enter="changeTab(index)"
tabindex="0"
:class="chosenTab == index ? 'active' : null"
>{{ tab.name }}</div>
</div>
>
{{ tab.name }}
<span v-if="tab.counter" class="counter">{{ tab.counter }}</span>
</a>
</nav>
<div class="tab-elements">
<component :is="tabs[chosenTab].component" />
<component :is="tabs[chosenTab].component" @counter="updateCounter" />
</div>
</div>
</template>
<script>
import eventBus from "@/mixins/EventBus";
export default {
props: {
tabs: {
type: Array
},
active: {
type: Number,
default: 0
}
},
beforeMount() {
this.chosenTab = this.active;
const url = location.href;
if (url.includes("tab=")) {
const tabParameter = url.split("tab=")[1];
const matchingSlug = this.tabs.findIndex(tab => tab.slug == tabParameter);
console.log("matchingSlug:", matchingSlug);
this.chosenTab = matchingSlug;
}
},
data() {
return {
chosenTab: 0
};
},
computed: {
activeTab() {
return this.tabs[this.chosenTab];
}
},
methods: {
changeTab: function(num) {
changeTab(num) {
this.chosenTab = num;
this.$emit("tabChange", num);
eventBus.$emit("tab-change");
let url = location.href;
const tabParameterIndex = url.indexOf("tab=");
if (tabParameterIndex > 0) {
url = url.split("tab=")[0] + `tab=${this.activeTab.slug}`;
} else {
url = url + `?tab=${this.activeTab.slug}`;
}
window.history.pushState({}, "", url);
},
updateCounter(val) {
this.activeTab.counter = val;
}
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/variables.scss";
@import "@/styles/media-queries.scss";
h1 {
text-align: center;
}
@@ -54,28 +83,50 @@ h1 {
display: flex;
flex-direction: row;
justify-content: center;
margin-top: 25px;
border-bottom: 1px solid #333333;
// margin-top: 25px;
border-bottom: 1px solid var(--underlinenav-text);
margin-top: 2rem;
@include mobile {
flex-direction: column;
}
}
.tab {
cursor: pointer;
font-size: 1.2rem;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
margin: 0 15px;
border: 1px solid #333333;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
background: #00000008;
border-bottom: 1px solid #333333;
margin-bottom: -1px;
font-size: 1.1rem;
padding: 8px 16px;
border-bottom: 2px solid transparent;
color: rgba($matte-text-color, 0.9);
&.active {
border-bottom: 1px solid white;
color: $matte-text-color;
border-color: var(--underlinenav-text-active) !important;
background: white;
font-weight: 600;
}
&:hover,
&:focus {
border-color: var(--underlinenav-text-hover);
outline: 0;
}
& .counter {
margin-left: 4px;
box-sizing: border-box;
display: inline-block;
min-width: 20px;
padding: 0 6px;
font-size: 14px;
font-weight: 600;
line-height: 18px;
text-align: center;
background-color: rgba(209, 213, 218, 0.5);
border: 1px solid transparent;
border-radius: 2em;
}
}
</style>

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<section class="outer-bought">
<div>
<h3>Loddstatistikk</h3>
<div class="total-raffles">
@@ -10,28 +10,21 @@
<span> {{ stolen }} stjålet </span>
</div>
<div class="bought-container">
<div
v-for="color in colors"
:class="
color.name +
'-container ' +
color.name +
'-raffle raffle-element-local'
"
:class="color.name + '-container ' + color.name + '-raffle raffle-element-local'"
:key="color.name"
>
<p class="winner-chance">
{{translate(color.name)}} vinnersjanse
</p>
<p class="winner-chance">{{ translate(color.name) }} vinnersjanse</p>
<span class="win-percentage">{{ color.totalPercentage }}% </span>
<p class="total-bought-color">{{ color.total }} kjøpte</p>
<p class="amount-of-wins">{{ color.win }} vinn</p>
</div>
</div>
</section>
</div>
</template>
<script>
import { colorStatistics } from "@/api";
@@ -45,85 +38,31 @@ export default {
green: 0,
total: 0,
totalWin: 0,
stolen: 0,
wins: 0,
redPercentage: 0,
yellowPercentage: 0,
greenPercentage: 0,
bluePercentage: 0
stolen: 0
};
},
async mounted() {
let response = await colorStatistics();
this.red = response.red;
this.blue = response.blue;
this.green = response.green;
this.yellow = response.yellow;
this.total = response.total;
this.totalWin =
this.red.win + this.yellow.win + this.blue.win + this.green.win;
this.stolen = response.stolen;
this.redPercentage = this.round(
this.red.win == 0 ? 0 : (this.red.win / this.totalWin) * 100
);
this.greenPercentage = this.round(
this.green.win == 0 ? 0 : (this.green.win / this.totalWin) * 100
);
this.bluePercentage = this.round(
this.blue.win == 0 ? 0 : (this.blue.win / this.totalWin) * 100
);
this.yellowPercentage = this.round(
this.yellow.win == 0 ? 0 : (this.yellow.win / this.totalWin) * 100
);
this.colors.push({
name: "red",
total: this.red.total,
win: this.red.win,
totalPercentage: this.getPercentage(this.red.win, this.totalWin),
percentage: this.getPercentage(this.red.win, this.red.total)
});
this.colors.push({
name: "blue",
total: this.blue.total,
win: this.blue.win,
totalPercentage: this.getPercentage(this.blue.win, this.totalWin),
percentage: this.getPercentage(this.blue.win, this.blue.total)
});
this.colors.push({
name: "yellow",
total: this.yellow.total,
win: this.yellow.win,
totalPercentage: this.getPercentage(this.yellow.win, this.totalWin),
percentage: this.getPercentage(this.yellow.win, this.yellow.total)
});
this.colors.push({
name: "green",
total: this.green.total,
win: this.green.win,
totalPercentage: this.getPercentage(this.green.win, this.totalWin),
percentage: this.getPercentage(this.green.win, this.green.total)
});
this.colors = this.colors.sort((a, b) => (a.win > b.win ? -1 : 1));
this.allLotteries().then(this.computeColors);
},
methods: {
allLotteries() {
return fetch("/api/lotteries?includeWinners=true")
.then(resp => resp.json())
.then(response => response.lotteries);
},
translate(color) {
switch (color) {
case "blue":
return "Blå"
return "Blå";
break;
case "red":
return "Rød"
return "Rød";
break;
case "green":
return "Grønn"
return "Grønn";
break;
case "yellow":
return "Gul"
return "Gul";
break;
break;
}
@@ -132,20 +71,93 @@ export default {
return this.round(win == 0 ? 0 : (win / total) * 100);
},
round: function(number) {
//this can make the odds added together more than 100%, maybe rework?
let actualPercentage = Math.round(number * 100) / 100;
let rounded = actualPercentage.toFixed(0);
return rounded;
},
computeColors(lotteries) {
let totalRed = 0;
let totalGreen = 0;
let totalYellow = 0;
let totalBlue = 0;
let total = 0;
let stolen = 0;
const colorAccumulatedWins = {
blue: 0,
green: 0,
red: 0,
yellow: 0
};
const accumelatedColors = (winners, colorAccumulatedWins) => {
winners.forEach(winner => {
const winnerColor = winner.color;
colorAccumulatedWins[winnerColor] += 1;
});
};
lotteries.forEach(lottery => {
totalRed += lottery.red;
totalGreen += lottery.green;
totalYellow += lottery.yellow;
totalBlue += lottery.blue;
total += lottery.bought;
stolen += lottery.stolen;
accumelatedColors(lottery.winners, colorAccumulatedWins);
});
this.red = totalRed;
this.yellow = totalYellow;
this.green = totalGreen;
this.blue = totalBlue;
this.total = total;
this.totalWin =
colorAccumulatedWins.red + colorAccumulatedWins.yellow + colorAccumulatedWins.blue + colorAccumulatedWins.green;
this.stolen = stolen;
this.colors.push({
name: "red",
total: totalRed,
win: colorAccumulatedWins.red,
totalPercentage: this.getPercentage(colorAccumulatedWins.red, this.totalWin),
percentage: this.getPercentage(colorAccumulatedWins.red, this.red.total)
});
this.colors.push({
name: "blue",
total: totalBlue,
win: colorAccumulatedWins.blue,
totalPercentage: this.getPercentage(colorAccumulatedWins.blue, this.totalWin),
percentage: this.getPercentage(colorAccumulatedWins.blue, this.blue.total)
});
this.colors.push({
name: "yellow",
total: totalYellow,
win: colorAccumulatedWins.yellow,
totalPercentage: this.getPercentage(colorAccumulatedWins.yellow, this.totalWin),
percentage: this.getPercentage(colorAccumulatedWins.yellow, this.yellow.total)
});
this.colors.push({
name: "green",
total: totalGreen,
win: colorAccumulatedWins.green,
totalPercentage: this.getPercentage(colorAccumulatedWins.green, this.totalWin),
percentage: this.getPercentage(colorAccumulatedWins.green, this.green.total)
});
this.colors = this.colors.sort((a, b) => (a.win > b.win ? -1 : 1));
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
@import "../styles/global.scss";
@import "@/styles/variables.scss";
@import "@/styles/media-queries.scss";
@import "@/styles/global.scss";
@include mobile {
section {

View File

@@ -1,15 +1,7 @@
<template>
<div>
<div
class="vipps-container"
:class="isMobile ? 'clickable' : null"
@click="openVipps"
>
<img
src="/public/assets/images/vipps-logo.svg"
class="vipps-logo"
alt="vipps logo"
/>
<div class="page-container">
<div class="vipps-container" :class="isMobile ? 'clickable' : null" @click="openVipps">
<img src="/public/assets/images/vipps-logo.svg" class="vipps-logo" alt="vipps logo" />
<span v-if="amount * price > price">
kr.
<span class="big-money">{{ amount * price }},-</span>
@@ -20,11 +12,7 @@
<span class="big-money">{{ amount * price }},-</span>
pr. lodd
</span>
<ing
src="/public/assets/images/vipps-qr.png"
class="qr-logo"
v-if="qrFailed"
/>
<ing src="/public/assets/images/vipps-qr.png" class="qr-logo" v-if="qrFailed" />
<canvas v-if="!qrFailed" ref="canvas" class="qr-logo"></canvas>
<span class="phone-number">{{ phone }}</span>
<span class="name">{{ name }}</span>
@@ -72,7 +60,6 @@ export default {
return this.amount * (this.price * 100);
},
vippsUrlBasedOnUserAgent: function() {
if (navigator.userAgent.includes("iPhone")) {
return (
"https://qr.vipps.no/28/2/01/031/47" +
this.phone.replace(/ /g, "") +
@@ -82,28 +69,15 @@ export default {
this.priceToPay
);
}
return (
"https://qr.vipps.no/28/2/01/031/47" +
this.phone.replace(/ /g, "") +
"?v=1&m=" +
this.message
);
}
},
methods: {
calculateQr: function() {
let canvas = this.$refs["canvas"];
QRCode.toCanvas(
canvas,
this.vippsUrlBasedOnUserAgent,
{ errorCorrectionLevel: "Q" },
(err, url) => {
QRCode.toCanvas(canvas, this.vippsUrlBasedOnUserAgent, { errorCorrectionLevel: "Q" }, (err, url) => {
if (err != null) {
this.qrFailed = true;
}
}
);
});
this.drawLogoOverCanvas(canvas);
},
@@ -148,8 +122,17 @@ export default {
</script>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/media-queries.scss";
@import "@/styles/global.scss";
@import "@/styles/media-queries.scss";
.page-container {
@include mobile {
display: flex;
flex-direction: column;
margin-bottom: 1.5rem;
}
}
.vipps-container {
font-family: Arial;
border-radius: 10px;

View File

@@ -5,13 +5,17 @@
</template>
<script>
import { chartWinsByColor } from "@/api";
export default {
methods: {
fetchWinsByColor() {
return fetch("/api/history/by-color").then(resp => resp.json());
}
},
async mounted() {
let canvas = this.$refs["win-chart"].getContext("2d");
let response = await chartWinsByColor();
let response = await this.fetchWinsByColor();
const { colors } = response;
let labels = ["Vunnet"];
let blue = {
label: "Blå",
@@ -42,23 +46,26 @@ export default {
data: []
};
blue.data.push(response.blue.win);
yellow.data.push(response.yellow.win);
red.data.push(response.red.win);
green.data.push(response.green.win);
const findColorWinners = (colorSelect, colors) => {
return colors.filter(color => color.color == colorSelect)[0].count;
};
const blueWinCount = findColorWinners("blue", colors);
const redWinCount = findColorWinners("red", colors);
const greenWinCount = findColorWinners("green", colors);
const yellowWinCount = findColorWinners("yellow", colors);
blue.data.push(blueWinCount);
red.data.push(redWinCount);
green.data.push(greenWinCount);
yellow.data.push(yellowWinCount);
let highestNumber = 0;
if (response.blue.win > highestNumber) {
highestNumber = response.blue.win;
}
if (response.red.win > highestNumber) {
highestNumber = response.red.win;
}
if (response.green.win > highestNumber) {
highestNumber = response.green.win;
}
if (response.yellow.win > highestNumber) {
highestNumber = response.yellow.win;
[blueWinCount, redWinCount, greenWinCount, greenWinCount].forEach(winCount => {
if (winCount > highestNumber) {
highestNumber = winCount;
}
});
let datasets = [blue, yellow, green, red];
let chartdata = {
@@ -102,8 +109,6 @@ export default {
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.scss";
.chart {
height: 40vh;
max-height: 500px;

View File

@@ -2,10 +2,7 @@
<div class="wine">
<slot name="top"></slot>
<div class="wine-image">
<img
v-if="wine.image && loadImage"
:src="wine.image"
/>
<img v-if="wine.image && loadImage" :src="wine.image" />
<img v-else class="wine-placeholder" alt="Wine image" />
</div>
@@ -38,7 +35,7 @@ export default {
data() {
return {
loadImage: false
}
};
},
methods: {
setImage(entries) {
@@ -53,7 +50,7 @@ export default {
this.observer = new IntersectionObserver(this.setImage, {
root: this.$el,
threshold: 0
})
});
},
mounted() {
this.observer.observe(this.$el);
@@ -66,16 +63,17 @@ export default {
@import "@/styles/variables";
.wine {
align-self: flex-start;
padding: 1rem;
box-sizing: border-box;
position: relative;
-webkit-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
-moz-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
width: 100%;
@include tablet {
width: 250px;
height: 100%;
max-width: 280px;
}
}
@@ -85,19 +83,18 @@ export default {
margin-top: 10px;
img {
height: 250px;
height: 280px;
@include mobile {
object-fit: cover;
max-width: 90px;
}
}
.wine-placeholder {
height: 250px;
height: 280px;
width: 70px;
}
}
.wine-details {
display: flex;
flex-direction: column;
@@ -120,6 +117,7 @@ export default {
.bottom-section {
width: 100%;
margin-top: 1rem;
align-self: flex-end;
.link {
color: $matte-text-color;

View File

@@ -2,18 +2,18 @@
<div v-if="wines.length > 0" class="wines-main-container">
<div class="info-and-link">
<h3>
Topp 5 viner
Topp viner
</h3>
<router-link to="viner">
<span class="vin-link">Se alle viner </span>
</router-link>
</div>
<div class="wine-container">
<div class="wines-container">
<Wine v-for="wine in wines" :key="wine" :wine="wine">
<template v-slot:top>
<div class="flex justify-end">
<div class="requested-count cursor-pointer">
<span> {{ wine.occurences }} </span>
<span> {{ wine.occurences }} </span>
<i class="icon icon--heart" />
</div>
</div>
@@ -35,15 +35,20 @@ export default {
return {
wines: [],
clickedWine: null,
limit: 18
};
},
async mounted() {
let response = await overallWineStatistics();
this.getAllWines();
},
methods: {
getAllWines() {
return fetch(`/api/wines?limit=${this.limit}`)
.then(resp => resp.json())
.then(response => {
let { wines, success } = response;
response.sort();
response = response
.filter(wine => wine.name != null && wine.name != "")
.sort(
this.wines = wines.sort(
this.predicate(
{
name: "occurences",
@@ -55,9 +60,8 @@ export default {
}
)
);
this.wines = response.slice(0, 5);
});
},
methods: {
predicate: function() {
var fields = [],
n_fields = arguments.length,
@@ -125,7 +129,7 @@ export default {
<style lang="scss" scoped>
@import "@/styles/variables.scss";
@import "@/styles/global.scss";
@import "../styles/media-queries.scss";
@import "@/styles/media-queries.scss";
.wines-main-container {
margin-bottom: 10em;
@@ -136,11 +140,6 @@ export default {
justify-content: space-between;
}
.wine-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 2rem;
.requested-count {
display: flex;
align-items: center;
@@ -156,11 +155,46 @@ export default {
}
.icon--heart {
font-size: 1.5rem;
color: $link-color;
}
color: var(--link-color);
}
}
// Call for help
.wines-container {
@media (max-width: 1643px) {
*:nth-child(n + 7) {
display: none;
}
}
@media (max-width: 2066px) {
*:nth-child(n + 9) {
display: none;
}
}
@media (max-width: 2490px) {
*:nth-child(n + 11) {
display: none;
}
}
@media (max-width: 2915px) {
*:nth-child(n + 13) {
display: none;
}
}
@media (max-width: 3335px) {
*:nth-child(n + 15) {
display: none;
}
}
@media (max-width: 3758px) {
*:nth-child(n + 17) {
display: none;
}
}
}
</style>

View File

@@ -85,9 +85,7 @@ export default {
this.startConfetti(this.currentName);
return;
}
this.currentName = this.attendees[
this.nameRounds % this.attendees.length
].name;
this.currentName = this.attendees[this.nameRounds % this.attendees.length].name;
this.nameRounds += 1;
clearTimeout(this.nameTimeout);
this.nameTimeout = setTimeout(() => {
@@ -137,7 +135,7 @@ export default {
var duration = 7 * 1000;
var animationEnd = Date.now() + duration;
var defaults = { startVelocity: 50, spread: 160, ticks: 50, zIndex: 0, particleCount: 20 };
var uberDefaults = { startVelocity: 65, spread: 75, zIndex: 0, particleCount: 35}
var uberDefaults = { startVelocity: 65, spread: 75, zIndex: 0, particleCount: 35 };
function randomInRange(min, max) {
return Math.random() * (max - min) + min;
@@ -148,7 +146,6 @@ export default {
var timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
self.drawing = false;
console.time("drawing finished")
return clearInterval(interval);
}
if (currentName == "Amund Brandsrud") {
@@ -165,7 +162,7 @@ export default {
}, 250);
function runCannon(confettiDefaultValues, originPoint, launchAngle) {
confetti(Object.assign({}, confettiDefaultValues, {origin: originPoint }, launchAngle))
confetti(Object.assign({}, confettiDefaultValues, { origin: originPoint }, launchAngle));
}
},
ordinalNumber(number = this.currentWinnerLocal.winnerCount) {
@@ -187,7 +184,6 @@ export default {
}
}
};
</script>
<style lang="scss" scoped>

View File

@@ -1,9 +1,9 @@
<template>
<section>
<h2>{{ title ? title : 'Vinnere' }}</h2>
<h2>{{ title ? title : "Vinnere" }}</h2>
<div class="winning-raffles" v-if="winners.length > 0">
<div v-for="(winner, index) in winners" :key="index">
<router-link :to="`/highscore/${ encodeURIComponent(winner.name) }`">
<router-link :to="`/highscore/${winner.name}`">
<div :class="winner.color + '-raffle'" class="raffle-element">{{ winner.name }}</div>
</router-link>
</div>
@@ -26,7 +26,7 @@ export default {
type: Array
},
drawing: {
type: Boolean,
type: Boolean
},
title: {
type: String,

View File

@@ -1,17 +1,16 @@
const dateString = (date) => {
if (typeof(date) == "string") {
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)
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}`
}
return `${ye}-${mo}-${da}`;
};
function humanReadableDate(date) {
const options = { year: 'numeric', month: 'long', day: 'numeric' };
const options = { year: "numeric", month: "long", day: "numeric" };
return new Date(date).toLocaleDateString(undefined, options);
}
@@ -20,8 +19,31 @@ function daysAgo(date) {
return Math.round(Math.abs((new Date() - new Date(date)) / day));
}
export {
dateString,
humanReadableDate,
daysAgo
export function createCookie(name, value, days) {
if (days) {
var date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
var expires = "; expires=" + date.toGMTString();
} else var expires = "";
const domain = `${window.location.hostname}`;
console.log("cookie:", `${name}=${value + expires}; path=/; domain=${domain}`);
document.cookie = `${name}=${value + expires}; path=/; domain=${domain}`;
}
export function readCookie(name) {
var nameEQ = name + "=";
var ca = document.cookie.split(";");
for (var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == " ") c = c.substring(1, c.length);
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
export function eraseCookie(name) {
createCookie(name, "", -1);
}
export { dateString, humanReadableDate, daysAgo };

View File

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

10
nodemon.json Normal file
View File

@@ -0,0 +1,10 @@
{
"restartable": "rs",
"ignore": [".git", "node_modules/**/node_modules"],
"verbose": true,
"execMap": {
"js": "node --harmony"
},
"watch": ["./config", "./api"],
"ext": "js"
}

12188
package-lock.json generated

File diff suppressed because it is too large Load Diff

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