Compare commits

...

378 Commits

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

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

15
.babelrc Normal file
View File

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

View File

@@ -9,10 +9,17 @@ platform:
steps:
- name: frontend_install
image: node:13.6.0
image: node:14
commands:
- node -v
- yarn --version
- name: backend_build
image: node:14
commands:
- node -v
- yarn --version
- yarn
- yarn build
- name: deploy
image: appleboy/drone-ssh
pull: true
@@ -26,13 +33,13 @@ steps:
- drone-test
status: success
settings:
host: 10.0.0.114
host: 10.0.0.52
username: root
key:
from_secret: ssh_key
command_timeout: 600s
script:
- /home/kevin/deploy/vinlottis.sh
- /home/kevin/deploy.sh
trigger:
branch:

View File

@@ -1,9 +1,19 @@
# vinlattis
<h1 align="center">
Vinlottis 🍾
</h1>
[![Build Status](https://drone.kevinmidboe.com/api/badges/KevinMidboe/vinlottis/status.svg)](https://drone.kevinmidboe.com/KevinMidboe/vinlottis)
<div align="center">
[![Build Status](https://drone.schleppe.cloud/api/badges/KevinMidboe/vinlottis/status.svg)](https://drone.schleppe.cloud/KevinMidboe/vinlottis)
Prerequisits
</div>
<br/>
[**Vinlottis**](https://vinlottis.no) is the unofficial website for Knowit's wine-lottery, usually happening every friday at around 15:00.
### Prerequisites
```
mongodb
nodejs
@@ -12,7 +22,6 @@ npm
### Run dev
Since the backend and API runs separate from the Vue-on-save-compiler, when running the dev-server, the backend needs to be run separate
```

View File

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

View File

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

33
api/github.js Normal file
View File

@@ -0,0 +1,33 @@
const fetch = require('node-fetch')
class Github {
constructor(apiToken) {
this.apiToken = apiToken;
this.hostname = "https://api.github.com"
}
listRepositoryContributors() {
const headers = {
"Accept": "application/json",
"Authorization": `token ${ this.apiToken }`
};
const url = `${ this.hostname }/repos/KevinMidboe/vinlottis/contributors`
return fetch(url, { headers })
.then(resp => resp.json())
.then(contributors =>
contributors.map(contributor => new Contributor(contributor))
);
}
}
class Contributor {
constructor(contributorObject) {
this.name = contributorObject.login;
this.avatarUrl = contributorObject.avatar_url;
this.profileUrl = contributorObject.html_url;
this.projectContributions = contributorObject.contributions;
}
}
module.exports = Github;

View File

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

132
api/lottery.js Normal file
View File

@@ -0,0 +1,132 @@
const path = require('path');
const Highscore = require(path.join(__dirname, '/schemas/Highscore'));
const Wine = require(path.join(__dirname, '/schemas/Wine'));
// Utils
const epochToDateString = date => new Date(parseInt(date)).toDateString();
const sortNewestFirst = (lotteries) => {
return lotteries.sort((a, b) => parseInt(a.date) < parseInt(b.date) ? 1 : -1)
}
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
}
const existingDateIndex = highscoreByDate.findIndex(el => el.date == epochDate)
if (existingDateIndex > -1)
highscoreByDate[existingDateIndex].winners.push(winnerObject);
else
highscoreByDate.push({
date: epochDate,
winners: [winnerObject]
})
})
})
return sortNewestFirst(highscoreByDate);
}
const resolveWineReferences = (highscoreObject, key) => {
const listWithWines = highscoreObject[key]
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}`,
date,
winners: lottery.winners
}))
}
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.`
})
}
})
.then(highscore => resolveWineReferences(highscore, "wins"))
.then(highscore => res.send({
message: `Lottery winnings for name: ${ name }.`,
name: highscore.name,
highscore: sortNewestFirst(highscore.wins)
}))
}
module.exports = {
all,
latest,
byEpochDate,
byName
};

131
api/message.js Normal file
View File

@@ -0,0 +1,131 @@
const https = require("https");
const path = require("path");
const config = require(path.join(__dirname + "/../config/defaults/lottery"));
const dateString = (date) => {
if (typeof(date) == "string") {
date = new Date(date);
}
const ye = new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date)
const mo = new Intl.DateTimeFormat('en', { month: '2-digit' }).format(date)
const da = new Intl.DateTimeFormat('en', { day: '2-digit' }).format(date)
return `${da}-${mo}-${ye}`
}
async function sendWineSelectMessage(winnerObject) {
winnerObject.timestamp_sent = new Date().getTime();
winnerObject.timestamp_limit = new Date().getTime() * 600000;
await winnerObject.save();
let url = new URL(`/#/winner/${winnerObject.id}`, "https://lottis.vin");
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.)`
)
}
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!`)
}
async function sendLastWinnerMessage(winnerObject, wineObject) {
console.log(`User ${winnerObject.id} is only one left, chosing wine for him/her.`);
winnerObject.timestamp_sent = new Date().getTime();
winnerObject.timestamp_limit = new Date().getTime();
await winnerObject.save();
return sendMessageToUser(
winnerObject.phoneNumber,
`Gratulerer som heldig vinner av vinlotteriet ${winnerObject.name}! Du har vunnet vinen ${wineObject.name}, du vil bli kontaktet av ${ config.name } ang henting. Ha en ellers fin helg!`
);
}
async function sendWineSelectMessageTooLate(winnerObject) {
return sendMessageToUser(
winnerObject.phoneNumber,
`Hei ${winnerObject.name}, du har dessverre brukt mer enn 10 minutter på å velge premie og blir derfor puttet bakerst i køen. Du vil få en ny SMS når det er din tur igjen.`
);
}
async function sendMessageToUser(phoneNumber, message) {
console.log(`Attempting to send message to ${ phoneNumber }.`)
const body = {
sender: "Vinlottis",
message: message,
recipients: [{ msisdn: `47${ phoneNumber }`}]
};
return gatewayRequest(body);
}
async function 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 = {
hostname: "gatewayapi.com",
post: 443,
path: `/rest/mtsms?token=${ config.gatewayToken }`,
method: "POST",
headers: {
"Content-Type": "application/json"
}
}
const req = https.request(options, (res) => {
console.log(`statusCode: ${ res.statusCode }`);
console.log(`statusMessage: ${ res.statusMessage }`);
res.setEncoding('utf8');
if (res.statusCode == 200) {
res.on("data", (data) => {
console.log("Response from message gateway:", data)
resolve(JSON.parse(data))
});
} else {
res.on("data", (data) => {
data = JSON.parse(data);
return reject('Gateway error: ' + data['message'] || data)
});
}
})
req.on("error", (error) => {
console.error(`Error from sms service: ${ error }`);
reject(`Error from sms service: ${ error }`);
})
req.write(JSON.stringify(body));
req.end();
});
}
module.exports = {
sendWineSelectMessage,
sendWineConfirmation,
sendLastWinnerMessage,
sendWineSelectMessageTooLate,
sendInitialMessageToWinners
}

View File

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

View File

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

View File

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

View File

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

35
api/person.js Normal file
View File

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

View File

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

69
api/request.js Normal file
View File

@@ -0,0 +1,69 @@
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 deleteRequestedWineById = async (req, res) => {
const { id } = req.params;
if(id == null){
return res.json({
message: "Id er ikke definert",
success: false
})
}
await RequestedWine.deleteOne({wineId: id})
return res.json({
message: `Slettet vin med id: ${id}`,
success: true
});
}
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({
name: wine.name,
vivinoLink: wine.vivinoLink,
rating: null,
occurences: null,
image: wine.image,
id: wine.id
});
await thisWineIsLOKO.save()
}
let requestedWine = await RequestedWine.findOne({ "wineId": wine.id})
if(requestedWine == undefined){
requestedWine = new RequestedWine({
count: 1,
wineId: wine.id,
wine: thisWineIsLOKO
})
} else {
requestedWine.count += 1;
}
await requestedWine.save()
return res.send(requestedWine);
}
module.exports = {
requestNewWine,
getAllRequestedWines,
deleteRequestedWineById
};

View File

@@ -1,35 +1,25 @@
const express = require("express");
const path = require("path");
const router = express.Router();
const mongoose = require("mongoose");
mongoose.connect("mongodb://localhost:27017/vinlottis", {
useNewUrlParser: true
});
const Purchase = require(path.join(__dirname + "/../schemas/Purchase"));
const Wine = require(path.join(__dirname + "/../schemas/Wine"));
const Highscore = require(path.join(__dirname + "/../schemas/Highscore"));
const Purchase = require(path.join(__dirname, "/schemas/Purchase"));
const Wine = require(path.join(__dirname, "/schemas/Wine"));
const Highscore = require(path.join(__dirname, "/schemas/Highscore"));
const PreLotteryWine = require(path.join(
__dirname + "/../schemas/PreLotteryWine"
__dirname, "/schemas/PreLotteryWine"
));
router.use((req, res, next) => {
next();
});
router.route("/wines/prelottery").get(async (req, res) => {
const prelotteryWines = async (req, res) => {
let wines = await PreLotteryWine.find();
res.json(wines);
});
return res.json(wines);
};
router.route("/purchase/statistics").get(async (req, res) => {
const allPurchase = async (req, res) => {
let purchases = await Purchase.find()
.populate("wines")
.sort({ date: 1 });
res.json(purchases);
});
return res.json(purchases);
};
router.route("/purchase/statistics/color").get(async (req, res) => {
const purchaseByColor = async (req, res) => {
const countColor = await Purchase.find();
let red = 0;
let blue = 0;
@@ -75,7 +65,7 @@ router.route("/purchase/statistics/color").get(async (req, res) => {
const total = red + yellow + blue + green;
res.json({
return res.json({
red: {
total: red,
win: redWin
@@ -95,21 +85,21 @@ router.route("/purchase/statistics/color").get(async (req, res) => {
stolen: stolen,
total: total
});
});
};
router.route("/highscore/statistics").get(async (req, res) => {
const highscore = async (req, res) => {
const highscore = await Highscore.find().populate("wins.wine");
res.json(highscore);
});
return res.json(highscore);
};
router.route("/wines/statistics").get(async (req, res) => {
const allWines = async (req, res) => {
const wines = await Wine.find();
res.json(wines);
});
return res.json(wines);
};
router.route("/wines/statistics/overall").get(async (req, res) => {
const allWinesSummary = async (req, res) => {
const highscore = await Highscore.find().populate("wins.wine");
let wines = {};
@@ -149,7 +139,16 @@ router.route("/wines/statistics/overall").get(async (req, res) => {
}
}
res.json(Object.values(wines));
});
wines = Object.values(wines).reverse()
module.exports = router;
return res.json(wines);
};
module.exports = {
prelotteryWines,
allPurchase,
purchaseByColor,
highscore,
allWines,
allWinesSummary
};

73
api/router.js Normal file
View File

@@ -0,0 +1,73 @@
const express = require("express");
const path = require("path");
const mustBeAuthenticated = require(path.join(__dirname, "/middleware/mustBeAuthenticated"));
const setAdminHeaderIfAuthenticated = require(path.join(__dirname, "/middleware/setAdminHeaderIfAuthenticated"));
const 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 githubController = require(path.join(__dirname, "/controllers/githubController"));
const router = express.Router();
router.get("/wineinfo/search", wineinfo.wineSearch);
router.get("/request/all", setAdminHeaderIfAuthenticated, request.getAllRequestedWines);
router.post("/request/new-wine", request.requestNewWine);
router.delete("/request/:id", request.deleteRequestedWineById);
router.get("/wineinfo/schema", mustBeAuthenticated, update.schema);
router.get("/wineinfo/:ean", wineinfo.byEAN);
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("/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("/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.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.post('/winner/notify/:id', virtualRegistrationApi.sendNotificationToWinnerById);
router.get('/winner/:id', virtualRegistrationApi.getWinesToWinnerById);
router.post('/winner/:id', virtualRegistrationApi.registerWinnerSelection);
router.get('/chat/history', chatHistoryApi.getAllHistory)
router.delete('/chat/history', mustBeAuthenticated, chatHistoryApi.deleteHistory)
router.get("/project/contributors", githubController.getProjectContributors);
router.post('/login', userApi.login);
router.post('/register', mustBeAuthenticated, userApi.register);
router.get('/logout', userApi.logout);
module.exports = router;

View File

@@ -6,7 +6,9 @@ const PreLotteryWine = new Schema({
vivinoLink: String,
rating: Number,
id: String,
image: String
image: String,
price: String,
country: String
});
module.exports = mongoose.model("PreLotteryWine", PreLotteryWine);

View File

@@ -0,0 +1,13 @@
const mongoose = require("mongoose");
const Schema = mongoose.Schema;
const RequestedWine = new Schema({
count: Number,
wineId: String,
wine: {
type: Schema.Types.ObjectId,
ref: "Wine"
}
});
module.exports = mongoose.model("RequestedWine", RequestedWine);

View File

@@ -8,7 +8,11 @@ const VirtualWinner = new Schema({
green: Number,
blue: Number,
red: Number,
yellow: Number
yellow: Number,
id: String,
timestamp_drawn: Number,
timestamp_sent: Number,
timestamp_limit: Number
});
module.exports = mongoose.model("VirtualWinner", VirtualWinner);

View File

@@ -2,19 +2,14 @@ const express = require("express");
const path = require("path");
const router = express.Router();
const webpush = require("web-push"); //requiring the web-push module
const mongoose = require("mongoose");
const schedule = require("node-schedule");
mongoose.connect("mongodb://localhost:27017/vinlottis", {
useNewUrlParser: true
});
const mustBeAuthenticated = require(path.join(
__dirname + "/../middleware/mustBeAuthenticated"
__dirname, "/middleware/mustBeAuthenticated"
));
const config = require(path.join(__dirname + "/../config/defaults/push"));
const Subscription = require(path.join(__dirname + "/../schemas/Subscription"));
const Subscription = require(path.join(__dirname, "/schemas/Subscription"));
const lotteryConfig = require(path.join(
__dirname + "/../config/defaults/lottery"
));

View File

@@ -1,32 +1,17 @@
const express = require("express");
const path = require("path");
const router = express.Router();
const mongoose = require("mongoose");
mongoose.connect("mongodb://localhost:27017/vinlottis", {
useNewUrlParser: true
});
const sub = require(path.join(__dirname + "/../api/subscriptions"));
const mustBeAuthenticated = require(path.join(
__dirname + "/../middleware/mustBeAuthenticated"
));
const sub = require(path.join(__dirname, "/subscriptions"));
const Subscription = require(path.join(__dirname + "/../schemas/Subscription"));
const Purchase = require(path.join(__dirname + "/../schemas/Purchase"));
const Wine = require(path.join(__dirname + "/../schemas/Wine"));
const _wineFunctions = require(path.join(__dirname, "/wine"));
const _personFunctions = require(path.join(__dirname, "/person"));
const Subscription = require(path.join(__dirname, "/schemas/Subscription"));
const Lottery = require(path.join(__dirname, "/schemas/Purchase"));
const PreLotteryWine = require(path.join(
__dirname + "/../schemas/PreLotteryWine"
__dirname, "/schemas/PreLotteryWine"
));
const VirtualWinner = require(path.join(
__dirname + "/../schemas/VirtualWinner"
));
const Highscore = require(path.join(__dirname + "/../schemas/Highscore"));
router.use((req, res, next) => {
next();
});
router.route("/log/wines").post(mustBeAuthenticated, async (req, res) => {
const submitWines = async (req, res) => {
const wines = req.body;
for (let i = 0; i < wines.length; i++) {
let wine = wines[i];
@@ -43,112 +28,115 @@ router.route("/log/wines").post(mustBeAuthenticated, async (req, res) => {
}
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"
});
sub.sendNotification(subscription, message);
try {
sub.sendNotification(subscription, message);
} catch (error) {
console.error("Error when trying to send push notification to subscriber.");
console.error(error);
}
}
res.send(true);
});
return res.send({
message: "Submitted and notified push subscribers of new wines!",
success: true
});
};
router.route("/log/schema").get(mustBeAuthenticated, async (req, res) => {
const schema = async (req, res) => {
let schema = { ...PreLotteryWine.schema.obj };
let nulledSchema = Object.keys(schema).reduce((accumulator, current) => {
accumulator[current] = "";
return accumulator;
return accumulator
}, {});
res.send(nulledSchema);
});
return res.send(nulledSchema);
}
router.route("/log").post(mustBeAuthenticated, async (req, res) => {
await PreLotteryWine.deleteMany();
// 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)))
const purchaseBody = req.body.purchase;
const winnersBody = req.body.winners;
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 }));
}
const date = purchaseBody.date;
const blue = purchaseBody.blue;
const red = purchaseBody.red;
const yellow = purchaseBody.yellow;
const green = purchaseBody.green;
/**
* @apiParam (Request body) {Array} winners List of winners
*/
const submitWinnersToLottery = async (req, res) => {
const { lottery } = req.body;
const { winners, date } = lottery;
const bought = purchaseBody.bought;
const stolen = purchaseBody.stolen;
const winesThisDate = [];
for (let i = 0; i < winnersBody.length; i++) {
let currentWinner = winnersBody[i];
let wonWine = await Wine.findOne({ name: currentWinner.wine.name });
if (wonWine == undefined) {
let newWonWine = new Wine({
name: currentWinner.wine.name,
vivinoLink: currentWinner.wine.vivinoLink,
rating: currentWinner.wine.rating,
occurences: 1,
image: currentWinner.wine.image,
id: currentWinner.wine.id
});
await newWonWine.save();
wonWine = newWonWine;
} else {
wonWine.occurences += 1;
wonWine.image = currentWinner.wine.image;
wonWine.id = currentWinner.wine.id;
await wonWine.save();
}
winesThisDate.push(wonWine);
const person = await Highscore.findOne({
name: currentWinner.name
});
if (person == undefined) {
let newPerson = new Highscore({
name: currentWinner.name,
wins: [
{
color: currentWinner.color,
date: date,
wine: wonWine
}
]
});
await newPerson.save();
} else {
person.wins.push({
color: currentWinner.color,
date: date,
wine: wonWine
});
person.markModified("wins");
await person.save();
}
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
}
let purchase = new Purchase({
date: date,
blue: blue,
yellow: yellow,
red: red,
green: green,
wines: winesThisDate,
bought: bought,
stolen: stolen
});
return res.json(true);
}
await purchase.save();
/**
* @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
res.send(true);
});
const { date,
blue,
red,
yellow,
green,
bought,
stolen } = lottery;
module.exports = router;
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
};

51
api/user.js Normal file
View File

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

View File

@@ -1,37 +1,16 @@
const express = require("express");
const path = require("path");
const router = express.Router();
const mongoose = require("mongoose");
mongoose.connect("mongodb://localhost:27017/vinlottis", {
useNewUrlParser: true
});
let io;
const mustBeAuthenticated = require(path.join(
__dirname + "/../middleware/mustBeAuthenticated"
));
const crypto = require("crypto");
const Attendee = require(path.join(__dirname + "/../schemas/Attendee"));
const VirtualWinner = require(path.join(
__dirname + "/../schemas/VirtualWinner"
));
const config = require(path.join(__dirname, "/../config/defaults/lottery"));
const Message = require(path.join(__dirname, "/message"));
const { findAndNotifyNextWinner } = require(path.join(__dirname, "/virtualRegistration"));
router.use((req, res, next) => {
next();
});
const Attendee = require(path.join(__dirname, "/schemas/Attendee"));
const VirtualWinner = require(path.join(__dirname, "/schemas/VirtualWinner"));
const PreLotteryWine = require(path.join(__dirname, "/schemas/PreLotteryWine"));
router.route("/winners").delete(mustBeAuthenticated, async (req, res) => {
await VirtualWinner.deleteMany();
io.emit("refresh_data", {});
res.json(true);
});
router.route("/attendees").delete(mustBeAuthenticated, async (req, res) => {
await Attendee.deleteMany();
io.emit("refresh_data", {});
res.json(true);
});
router.route("/winners").get(async (req, res) => {
const winners = async (req, res) => {
let winners = await VirtualWinner.find();
let winnersRedacted = [];
let winner;
@@ -43,41 +22,104 @@ router.route("/winners").get(async (req, res) => {
});
}
res.json(winnersRedacted);
});
};
router.route("/winners/secure").get(mustBeAuthenticated, async (req, res) => {
const winnersSecure = async (req, res) => {
let winners = await VirtualWinner.find();
res.json(winners);
});
return res.json(winners);
};
router.route("/winner").get(mustBeAuthenticated, async (req, res) => {
let allContestants = await Attendee.find({ winner: false });
if (allContestants.length == 0) {
res.json(false);
return;
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
});
}
let ballotColors = [];
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++) {
ballotColors.push("blue");
raffleColors.push("blue");
}
for (let red = 0; red < currentContestant.red; red++) {
ballotColors.push("red");
raffleColors.push("red");
}
for (let green = 0; green < currentContestant.green; green++) {
ballotColors.push("green");
raffleColors.push("green");
}
for (let yellow = 0; yellow < currentContestant.yellow; yellow++) {
ballotColors.push("yellow");
raffleColors.push("yellow");
}
}
ballotColors = shuffle(ballotColors);
raffleColors = shuffle(raffleColors);
let colorToChooseFrom =
ballotColors[Math.floor(Math.random() * ballotColors.length)];
raffleColors[Math.floor(Math.random() * raffleColors.length)];
let findObject = { winner: false };
findObject[colorToChooseFrom] = { $gt: 0 };
@@ -124,7 +166,16 @@ router.route("/winner").get(mustBeAuthenticated, async (req, res) => {
Math.floor(Math.random() * attendeeListDemocratic.length)
];
io.emit("winner", { color: colorToChooseFrom, name: winner.name });
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,
@@ -133,7 +184,9 @@ router.route("/winner").get(mustBeAuthenticated, async (req, res) => {
red: winner.red,
blue: winner.blue,
green: winner.green,
yellow: winner.yellow
yellow: winner.yellow,
id: sha512(winner.phoneNumber, genRandomString(10)),
timestamp_drawn: new Date().getTime()
});
await Attendee.update(
@@ -142,52 +195,57 @@ router.route("/winner").get(mustBeAuthenticated, async (req, res) => {
);
await newWinnerElement.save();
res.json(winner);
});
return res.json({
success: true,
winner
});
};
router.route("/attendees").get(async (req, res) => {
let attendees = await Attendee.find();
let attendeesRedacted = [];
let attendee;
for (let i = 0; i < attendees.length; i++) {
attendee = attendees[i];
attendeesRedacted.push({
name: attendee.name,
ballots: attendee.red + attendee.blue + attendee.yellow + attendee.green,
red: attendee.red,
blue: attendee.blue,
green: attendee.green,
yellow: attendee.yellow
const finish = async (req, res) => {
if (!config.gatewayToken) {
return res.json({
message: "Missing api token for sms gateway.",
success: false
});
}
res.json(attendeesRedacted);
});
router.route("/attendees/secure").get(mustBeAuthenticated, async (req, res) => {
let attendees = await Attendee.find();
res.json(attendees);
});
router.route("/attendee").post(mustBeAuthenticated, async (req, res) => {
const attendee = req.body;
const { red, blue, yellow, green } = attendee;
let newAttendee = new Attendee({
name: attendee.name,
red,
blue,
green,
yellow,
phoneNumber: attendee.phoneNumber,
winner: false
let winners = await VirtualWinner.find({ timestamp_sent: undefined }).sort({
timestamp_drawn: 1
});
await newAttendee.save();
io.emit("new_attendee", {});
if (winners.length == 0) {
return res.json({
message: "No winners to draw from.",
success: false
});
}
res.send(true);
});
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,
@@ -209,7 +267,15 @@ function shuffle(array) {
return array;
}
module.exports = function(_io) {
io = _io;
return router;
};
module.exports = {
deleteWinners,
deleteAttendees,
winners,
winnersSecure,
drawWinner,
finish,
attendees,
attendeesSecure,
addAttendee
}

200
api/virtualRegistration.js Normal file
View File

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

27
api/wine.js Normal file
View File

@@ -0,0 +1,27 @@
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,
occurences: 1,
image: prelotteryWine.image,
id: prelotteryWine.id
});
await newWonWine.save();
wonWine = newWonWine;
} else {
wonWine.occurences += 1;
wonWine.image = prelotteryWine.image;
wonWine.id = prelotteryWine.id;
await wonWine.save();
}
return wonWine;
}
module.exports.findSaveWine = findSaveWine;

View File

@@ -1,15 +1,53 @@
const express = require("express");
const path = require("path");
const router = express.Router();
const fetch = require('node-fetch')
const path = require('path')
const config = require(path.join(__dirname + "/../config/env/lottery.config"));
const mustBeAuthenticated = require(path.join(__dirname + "/../middleware/mustBeAuthenticated"))
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
}
}
}
router.use((req, res, next) => {
next();
});
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)
router.route("/wineinfo/:ean").get(async (req, res) => {
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())
@@ -25,7 +63,10 @@ router.route("/wineinfo/:ean").get(async (req, res) => {
})
}
res.send(vinmonopoletResponse);
});
return res.send(vinmonopoletResponse);
};
module.exports = router;
module.exports = {
byEAN,
wineSearch
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
<template>
<div class="app-container">
<banner />
<banner :routes="routes"/>
<router-view />
<Footer />
<UpdateToast
v-if="showToast"
:text="toastText"
@@ -14,17 +15,48 @@
<script>
import ServiceWorkerMixin from "@/mixins/ServiceWorkerMixin";
import banner from "@/ui/Banner";
import Footer from "@/ui/Footer";
import UpdateToast from "@/ui/UpdateToast";
export default {
name: "vinlottis",
components: { banner, UpdateToast },
components: { banner, UpdateToast, Footer },
props: {},
data() {
return {
showToast: false,
toastText: null,
refreshToast: false
refreshToast: false,
routes: [
{
name: "Virtuelt lotteri",
route: "/lottery"
},
{
name: "Dagens viner",
route: "/dagens/"
},
{
name: "Highscore",
route: "/highscore"
},
{
name: "Historie",
route: "/history/"
},
{
name: "Foreslå vin",
route: "/request"
},
{
name: "Foreslåtte viner",
route: "/requested-wines"
},
{
name: "Login",
route: "/login"
}
]
};
},
mounted() {
@@ -45,37 +77,31 @@ export default {
methods: {
closeToast: function() {
this.showToast = false;
}
},
}
};
</script>
<style lang="scss">
@import "./styles/global.scss";
@font-face {
font-family: "knowit";
font-weight: 600;
src: url("/../public/assets/fonts/bold.woff"),
url("/../public/assets/fonts/bold.woff") format("woff"), local("Arial");
font-display: swap;
}
@font-face {
font-family: "knowit";
font-weight: 300;
src: url("/../public/assets/fonts/regular.eot"),
url("/../public/assets/fonts/regular.woff") format("woff"), local("Arial");
font-display: swap;
}
@import "styles/global.scss";
@import "styles/positioning.scss";
@import "styles/vinlottis-icons";
body {
background-color: #dbeede;
background-color: $primary;
}
</style>
<style scoped>
<style lang="scss" scoped>
.app-container {
background-color: white;
min-height: 100vh;
display: grid;
grid-template-rows: 80px auto 100px;
.main-container{
height: 100%;
width: 100%;
}
}
</style>

375
frontend/api.js Normal file
View File

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

View File

@@ -0,0 +1,135 @@
<template>
<div>
<div class="header-chin">
<h1>Om oss</h1>
</div>
<div class="container">
<div class="github">
<h2>Project contributors</h2>
<div class="contributors">
<a class="contributor" v-for="contributor in contributors" :href="contributor.profileUrl">
<img :src="contributor.avatarUrl" />
<span class="name">{{ contributor.name }}</span>
<span>Contributions: {{ contributor.projectContributions }}</span>
</a>
</div>
</div>
<div class="">
<h2>Project guidelines</h2>
<p>Lorem ipsum</p>
</div>
</div>
</div>
</template>
<script>
import { projectContributors } from "@/api";
export default {
data() {
return {
contributors: []
}
},
mounted() {
this.fetchContributors()
},
methods: {
fetchContributors() {
projectContributors()
.then(contributors => contributors.contributors)
.then(contributors => this.filterOutBots(contributors))
.then(contributors => this.contributors = contributors);
},
filterOutBots(contributors) {
return contributors.filter(contributor => !contributor.name.includes('[bot]'))
}
}
}
</script>
<style lang="scss" scoped>
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
.header-chin {
padding: 3rem 0 4rem;
margin-bottom: 4rem;
background-color: $primary;
h1 {
font-family: 'knowit';
font-weight: 400;
font-size: 3.5rem;
display: block;
width: max-content;
margin: 0 auto;
}
}
.container {
margin: 0 10vw 1rem;
display: grid;
grid-template-columns: repeat(2, 1fr);
@include mobile {
margin: 0 5vw 1rem;
grid-template-columns: 1fr;
}
}
.github {
padding-right: 1.5rem;
}
.contributors {
display: grid;
grid-template-columns: repeat(4, 1fr);
@include mobile {
grid-template-columns: repeat(3, 1fr);
}
// grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.contributor {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
margin-right: 1rem;
// min-width: 125px;
color: $matte-text-color;
-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);
img {
width: 100%;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
span {
margin: 0.5rem;
&:first-of-type {
margin-bottom: 0;
}
}
.name {
font-weight: 600;
width: max-content;
border-bottom: 2px solid transparent;
&:hover {
border-color: $link-color;
}
}
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<main class="container">
<h1>Alle foreslåtte viner</h1>
<section class="requested-wines-container">
<p v-if="wines == undefined || wines.length == 0">Ingen har foreslått noe enda!</p>
<RequestedWineCard v-for="requestedEl in wines" :key="requestedEl.wine._id" :requestedElement="requestedEl" @wineDeleted="filterOutDeletedWine" :showDeleteButton="isAdmin"/>
</section>
</main>
</template>
<script>
import { allRequestedWines } from "@/api";
import RequestedWineCard from "@/ui/RequestedWineCard";
export default {
components: {
RequestedWineCard
},
data(){
return{
wines: undefined,
canRequest: true,
isAdmin: false
}
},
methods: {
filterOutDeletedWine(wine){
this.wines = this.wines.filter(item => item.wine._id !== wine._id)
},
async refreshData(){
[this.wines, this.isAdmin] = await allRequestedWines() || [[], false]
}
},
mounted() {
this.refreshData()
}
}
</script>
<style lang="scss" scoped>
@import "@/styles/media-queries.scss";
@import "@/styles/variables.scss";
.container {
width: 90vw;
margin: 3rem auto;
margin-bottom: 0;
padding-bottom: 3rem;
}
h1 {
font-size: 3rem;
font-family: "knowit";
color: $matte-text-color;
font-weight: normal;
}
.requested-wines-container{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 2rem;
}
</style>

View File

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

View File

@@ -5,15 +5,14 @@
Velg hvilke farger du vil ha, fyll inn antall lodd og klikk 'generer'
</p>
<RaffleGenerator @numberOfBallots="val => this.numberOfBallots = val" />
<RaffleGenerator @numberOfRaffles="val => this.numberOfRaffles = val" />
<Vipps class="vipps" :amount="numberOfBallots" />
<Vipps class="vipps" :amount="numberOfRaffles" />
<Countdown :hardEnable="hardStart" @countdown="changeEnabled" />
</div>
</template>
<script>
import { page, event } from "vue-analytics";
import RaffleGenerator from "@/ui/RaffleGenerator";
import Vipps from "@/ui/Vipps";
import Countdown from "@/ui/Countdown";
@@ -27,7 +26,7 @@ export default {
data() {
return {
hardStart: false,
numberOfBallots: null
numberOfRaffles: null
};
},
mounted() {
@@ -44,7 +43,7 @@ export default {
this.hardStart = true;
},
track() {
this.$ga.page("/lottery/generate");
window.ga('send', 'pageview', '/lottery/generate');
}
}
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,9 +78,14 @@
</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" :inlineSlot="true">
<wine v-for="winner in winners" :key="winner" :wine="winner.wine">
<div class="winner-element">
<div class="color-selector">
<div class="label-div">
@@ -107,16 +112,30 @@
@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">
<button class="vin-button" @click="sendInfo">Send inn vinnere</button>
<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>
@@ -126,7 +145,17 @@
</template>
<script>
import { prelottery, log, logWines, wineSchema } from "@/api";
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";
@@ -137,6 +166,7 @@ export default {
return {
payed: undefined,
winners: [],
fetchedWinners: [],
wines: [],
pushMessage: "",
pushLink: "/",
@@ -160,8 +190,67 @@ export default {
},
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;
},
@@ -173,6 +262,7 @@ export default {
this.winners.push({
name: "",
color: "",
potentialWinner: "",
wine: {
name: wine.name,
vivinoLink: wine.vivinoLink,
@@ -222,7 +312,7 @@ export default {
},
sendWines: async function() {
let response = await logWines(this.wines);
if (response == true) {
if (response.success == true) {
alert("Sendt!");
window.location.reload();
} else {
@@ -240,7 +330,7 @@ export default {
}
});
},
sendInfo: async function(event) {
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,
@@ -249,48 +339,63 @@ export default {
};
let sendObject = {
purchase: {
date: new Date(),
lottery: {
date: dateString(new Date()),
...colors
},
winners: this.winners
}
};
if (sendObject.purchase.red == undefined) {
if (sendObject.lottery.red == undefined) {
alert("Rød må defineres");
return;
}
if (sendObject.purchase.green == undefined) {
if (sendObject.lottery.green == undefined) {
alert("Grønn må defineres");
return;
}
if (sendObject.purchase.yellow == undefined) {
if (sendObject.lottery.yellow == undefined) {
alert("Gul må defineres");
return;
}
if (sendObject.purchase.blue == undefined) {
if (sendObject.lottery.blue == undefined) {
alert("Blå må defineres");
return;
}
sendObject.purchase.bought =
sendObject.lottery.bought =
parseInt(colors.blue) +
parseInt(colors.red) +
parseInt(colors.green) +
parseInt(colors.yellow);
const stolen = sendObject.purchase.bought - parseInt(this.payed) / 10;
const stolen = sendObject.lottery.bought - parseInt(this.payed) / 10;
if (isNaN(stolen) || stolen == undefined) {
alert("Betalt må registreres");
return;
}
sendObject.purchase.stolen = stolen;
sendObject.lottery.stolen = stolen;
if (sendObject.winners.length == 0) {
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.winners.length; i++) {
let currentWinner = sendObject.winners[i];
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");
@@ -302,7 +407,7 @@ export default {
}
}
let response = await log(sendObject);
let response = await sendLotteryWinners(sendObject);
if (response == true) {
alert("Sendt!");
window.location.reload();
@@ -341,7 +446,6 @@ export default {
}
},
setWinnerdataToStorage() {
console.log("saving localstorage");
localStorage.setItem("winners", JSON.stringify(this.winners));
localStorage.setItem(
"colorValues",
@@ -362,7 +466,13 @@ export default {
<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;
@@ -376,12 +486,24 @@ h2 {
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;
@@ -391,8 +513,7 @@ hr {
}
}
.winner-container {
width: max-content;
max-width: 100%;
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
@@ -405,7 +526,13 @@ hr {
margin-top: 2rem;
display: flex;
justify-content: center;
flex-direction: column;
flex-direction: row;
flex-wrap: wrap;
> .wine {
margin-right: 1rem;
margin-bottom: 1rem;
}
}
.edit {
width: 100%;
@@ -419,10 +546,10 @@ hr {
}
.winner-element {
display: flex;
flex-direction: row;
flex-direction: column;
@include desktop {
margin-top: 1.5rem;
> div {
margin-bottom: 1rem;
}
@include mobile {
@@ -516,7 +643,7 @@ hr {
flex-wrap: wrap;
justify-content: center;
max-width: 1400px;
margin: 3rem auto 0;
margin: 3rem auto 1rem;
@include mobile {
margin: 1.8rem auto 0;
@@ -532,9 +659,9 @@ hr {
width: 150px;
height: 150px;
margin: 20px;
-webkit-mask-image: url(/../../public/assets/images/lodd.svg);
-webkit-mask-image: url(/public/assets/images/lodd.svg);
background-repeat: no-repeat;
mask-image: url(/../../public/assets/images/lodd.svg);
mask-image: url(/public/assets/images/lodd.svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;

View File

@@ -0,0 +1,237 @@
<template>
<section class="main-container">
<Modal
v-if="showModal"
modalText="Ønsket ditt har blitt lagt til"
:buttons="modalButtons"
@click="emitFromModalButton"
></Modal>
<h1>
Foreslå en vin!
</h1>
<section class="search-container">
<section class="search-section">
<input type="text" v-model="searchString" @keyup.enter="fetchWineFromVin()" placeholder="Søk etter en vin du liker her!🍷" class="search-input-field">
<button :disabled="!searchString" @click="fetchWineFromVin()" class="vin-button">Søk</button>
</section>
<section v-for="(wine, index) in this.wines" :key="index" class="single-result">
<img
v-if="wine.image"
:src="wine.image"
class="wine-image"
:class="{ 'fullscreen': fullscreen }"
/>
<img v-else class="wine-placeholder" alt="Wine image" />
<section class="wine-info">
<h2 v-if="wine.name">{{ wine.name }}</h2>
<h2 v-else>(no name)</h2>
<div class="details">
<span v-if="wine.rating">{{ wine.rating }}%</span>
<span v-if="wine.price">{{ wine.price }} NOK</span>
<span v-if="wine.country">{{ wine.country }}</span>
</div>
</section>
<button class="vin-button" @click="request(wine)">Foreslå denne</button>
<a
v-if="wine.vivinoLink"
:href="wine.vivinoLink"
class="wine-link"
>Les mer</a>
</section>
<p v-if="this.wines && this.wines.length == 0">
Fant ingen viner med det navnet!
</p>
</section>
</section>
</template>
<script>
import { searchForWine, requestNewWine } from "@/api";
import Wine from "@/ui/Wine";
import Modal from "@/ui/Modal";
export default {
components: {
Wine,
Modal
},
data() {
return {
searchString: undefined,
wines: undefined,
showModal: false,
modalButtons: [
{
text: "Legg til flere viner",
action: "stay"
},
{
text: "Se alle viner",
action: "move"
}
]
}
},
methods: {
fetchWineFromVin(){
if(this.searchString){
this.wines = []
let localSearchString = this.searchString.replace(/ /g,"_");
searchForWine(localSearchString)
.then(res => this.wines = res)
}
},
request(wine){
requestNewWine(wine)
.then(() => this.showModal = true)
},
emitFromModalButton(action){
if(action == "stay"){
this.showModal = false
} else {
this.$router.push("/requested-wines");
}
}
},
}
</script>
<style lang="scss" scoped>
@import "@/styles/media-queries";
@import "@/styles/global";
@import "@/styles/variables";
h1{
text-align: center;
}
.main-container{
margin: auto;
max-width: 1200px;
}
input[type="text"] {
width: 90%;
color: black;
border-radius: 4px;
padding: 1rem 1rem;
border: 1px solid black;
max-width: 90%;
}
.search-container{
margin: 1rem;
}
.search-section{
display: grid;
grid: 1fr / 1fr .2fr;
@include mobile{
.vin-button{
display: none;
}
.search-input-field{
grid-column: 1 / -1;
}
}
}
.single-result{
margin-top: 1rem;
display: grid;
grid: 1fr / .5fr 2fr .5fr .5fr;
grid-template-areas: "picture details button-left button-right";
justify-items: center;
align-items: center;
grid-gap: 1em;
padding-bottom: 1em;
margin-bottom: 1em;
box-shadow: 0 1px 0 0 rgba(0,0,0,0.2);
@include mobile{
grid: 1fr .5fr / .5fr 1fr;
grid-template-areas: "picture details"
"button-left button-right";
grid-gap: .5em;
.vin-button{
grid-area: button-right;
padding: .5em;
font-size: 1em;
line-height: 1em;
height: 2em;
}
.wine-link{
grid-area: button-left;
}
h2{
font-size: 1em;
max-width: 80%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis
}
}
.wine-image {
height: 100px;
grid-area: picture;
}
.wine-placeholder {
height: 100px;
width: 70px;
grid-area: picture;
}
.wine-info{
grid-area: details;
width: 100%;
h2{
margin: 0;
}
.details{
top: 0;
display: flex;
flex-direction: column;
}
}
.wine-link {
grid-area: button-left;
color: #333333;
font-family: Arial;
text-decoration: none;
font-weight: bold;
border-bottom: 1px solid $link-color;
height: 1.2em;
width: max-content;
}
.vin-button{
grid-area: button-right;
}
@include tablet{
h2{
font-size: 1.2em;
}
}
@include desktop{
h2{
font-size: 1.6em;
}
}
}
</style>

View File

@@ -1,16 +1,14 @@
<template>
<div class="outer">
<div class="container">
<h1 class="title">Dagens viner</h1>
<div class="wines-container">
<Wine :wine="wine" v-for="wine in wines" :key="wine" :fullscreen="true" :inlineSlot="true" />
</div>
<div class="container">
<h1 class="title">Dagens viner</h1>
<div class="wines-container">
<Wine :wine="wine" v-for="wine in wines" :key="wine" />
</div>
</div>
</template>
<script>
import { page, event } from "vue-analytics";
import { prelottery } from "@/api";
import Banner from "@/ui/Banner";
import Wine from "@/ui/Wine";
@@ -25,14 +23,14 @@ export default {
};
},
async mounted() {
const _wines = await fetch("/api/wines/prelottery");
this.wines = await _wines.json();
prelottery().then(wines => this.wines = wines);
}
};
</script>
<style lang="scss" scoped>
@import "./src/styles/media-queries";
@import "@/styles/media-queries";
@import "@/styles/variables";
.wine-image {
height: 250px;
@@ -46,7 +44,7 @@ h1 {
.wines-container {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
justify-content: space-evenly;
margin: 0 2rem;
@media (min-width: 1500px) {
@@ -110,7 +108,7 @@ a:visited {
font-family: Arial;
text-decoration: none;
font-weight: bold;
border-bottom: 1px solid #ff5fff;
border-bottom: 1px solid $link-color;
width: fit-content;
}
</style>

View File

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

View File

@@ -0,0 +1,380 @@
<template>
<div>
<header ref="header">
<div class="container">
<div class="instructions">
<h1 class="title">Virtuelt lotteri</h1>
<ol>
<li>Vurder om du ønsker å bruke <router-link to="/generate" class="vin-link">loddgeneratoren</router-link>, eller sjekke ut <router-link to="/dagens" class="vin-link">dagens fangst.</router-link></li>
<li>Send vipps med melding "Vinlotteri" for å bli registrert til lotteriet.</li>
<li>Send gjerne melding om fargeønske også.</li>
</ol>
</div>
<Vipps :amount="1" class="vipps-qr desktop-only" />
<VippsPill class="vipps-pill mobile-only" />
<p class="call-to-action">
<span class="vin-link">Følg med utviklingen</span> og <span class="vin-link">chat om trekningen</span>
<i class="icon icon--arrow-left" @click="scrollToContent"></i></p>
</div>
</header>
<div class="container" ref="content">
<WinnerDraw
:currentWinnerDrawn="currentWinnerDrawn"
:currentWinner="currentWinner"
:attendees="attendees"
/>
<div class="todays-raffles">
<h2>Liste av lodd kjøpt i dag</h2>
<div class="raffle-container">
<div v-for="color in Object.keys(ticketsBought)" :class="color + '-raffle raffle-element'" :key="color">
<span>{{ ticketsBought[color] }}</span>
</div>
</div>
</div>
<Winners :winners="winners" class="winners" :drawing="currentWinner" />
<div class="container-attendees">
<h2>Deltakere ({{ attendees.length }})</h2>
<Attendees :attendees="attendees" class="attendees" />
</div>
<div class="container-chat">
<h2>Chat</h2>
<Chat class="chat" />
</div>
</div>
<div class="container wines-container">
<h2>Dagens fangst ({{ wines.length }})</h2>
<Wine :wine="wine" v-for="wine in wines" :key="wine" />
</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";
import Attendees from "@/ui/Attendees";
import Wine from "@/ui/Wine";
import Winners from "@/ui/Winners";
import WinnerDraw from "@/ui/WinnerDraw";
import io from "socket.io-client";
export default {
components: { Chat, Attendees, Winners, WinnerDraw, Vipps, VippsPill, Wine },
data() {
return {
attendees: [],
winners: [],
wines: [],
currentWinnerDrawn: false,
currentWinner: null,
socket: null,
attendeesFetched: false,
wasDisconnected: false,
ticketsBought: {
"red": 0,
"blue": 0,
"green": 0,
"yellow": 0
}
};
},
mounted() {
this.track();
this.getAttendees();
this.getTodaysWines();
this.getWinners();
this.socket = io(window.location.origin);
this.socket.on("color_winner", msg => {});
this.socket.on("disconnect", msg => {
this.wasDisconnected = true;
});
this.socket.on("winner", async msg => {
this.currentWinnerDrawn = true;
this.currentWinner = {
name: msg.name,
color: msg.color,
winnerCount: msg.winner_count
};
setTimeout(() => {
this.getWinners();
this.getAttendees();
this.currentWinner = null;
this.currentWinnerDrawn = false;
}, 19250);
});
this.socket.on("refresh_data", async msg => {
this.getAttendees();
this.getWinners();
});
this.socket.on("new_attendee", async msg => {
this.getAttendees();
});
},
beforeDestroy() {
this.socket.disconnect();
this.socket = null;
},
methods: {
getWinners: async function() {
let response = await winners();
if (response) {
this.winners = response;
}
},
getTodaysWines() {
prelottery()
.then(wines => {
this.wines = wines;
this.todayExists = wines.length > 0;
})
.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;
return;
}
const addValueOfListObjectByKey = (list, key) =>
list.map(object => object[key]).reduce((a, b) => a + b);
this.ticketsBought = {
red: addValueOfListObjectByKey(response, "red"),
blue: addValueOfListObjectByKey(response, "blue"),
green: addValueOfListObjectByKey(response, "green"),
yellow: addValueOfListObjectByKey(response, "yellow")
};
}
this.attendeesFetched = true;
},
scrollToContent() {
console.log(window.scrollY)
const intersectingHeaderHeight = this.$refs.header.getBoundingClientRect().bottom - 50;
const { scrollY } = window;
let scrollHeight = intersectingHeaderHeight;
if (scrollY > 0) {
scrollHeight = intersectingHeaderHeight + scrollY;
}
window.scrollTo({
top: scrollHeight,
behavior: "smooth"
});
},
track() {
window.ga('send', 'pageview', '/lottery/game');
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
.container {
width: 80vw;
padding: 0 10vw;
@include mobile {
width: 90vw;
padding: 0 5vw;
}
display: grid;
grid-template-columns: repeat(4, 1fr);
> div, > section {
@include mobile {
grid-column: span 5;
}
}
}
h2 {
font-size: 1.1rem;
margin-bottom: 1.75rem;
}
header {
h1 {
text-align: left;
font-weight: 500;
font-size: 3rem;
margin: 4rem 0 2rem;
@include mobile {
margin-top: 1rem;
font-size: 2.75rem;
}
}
background-color: $primary;
padding-bottom: 3rem;
margin-bottom: 3rem;
.instructions {
grid-column: 1 / 4;
@include mobile {
grid-column: span 5;
}
}
.vipps-qr {
grid-column: 4;
margin-left: 1rem;
}
.vipps-pill {
margin: 0 auto 2rem;
max-width: 80vw;
}
.call-to-action {
grid-column: span 5;
}
ol {
font-size: 1.4rem;
line-height: 3rem;
color: $matte-text-color;
@include mobile {
line-height: 2rem;
}
}
p {
font-size: 1.4rem;
line-height: 2rem;
margin-top: 0;
position: relative;
.vin-link {
cursor: default;
}
.icon {
position: absolute;
bottom: 3px;
color: $link-color;
margin-left: 0.5rem;
display: inline-block;
transform: rotate(-90deg);
cursor: pointer;
}
}
.vin-link {
font-weight: 400;
border-width: 2px;
}
}
.todays-raffles {
grid-column: 1;
@include mobile {
order: 2;
}
}
.raffle-container {
width: 165px;
height: 175px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
@include mobile {
width: 100%;
height: 100%;
}
.raffle-element {
font-size: 1.6rem;
color: $matte-text-color;
height: 75px;
width: 75px;
display: flex;
justify-content: center;
align-items: center;
margin: 0;
}
}
.winners {
grid-column: 2 / 5;
@include mobile {
order: 1;
}
}
.container-attendees {
grid-column: 1 / 3;
margin-right: 1rem;
margin-top: 2rem;
@include mobile {
margin-right: 0;
order: 4;
}
> div {
padding: 1rem;
-webkit-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
-moz-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
}
}
.container-chat {
grid-column: 3 / 5;
margin-left: 1rem;
margin-top: 2rem;
@include mobile {
margin-left: 0;
order: 3;
}
> div {
padding: 1rem;
-webkit-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
-moz-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
}
}
.wines-container {
margin-bottom: 4rem;
h2 {
grid-column: 1 / 5;
}
}
</style>

View File

@@ -18,7 +18,7 @@
<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 + '-ballot'" class="ballot-element">
<div :class="winner.color + '-raffle'" class="raffle-element">
<span>{{ winner.name }}</span>
<span>{{ winner.phoneNumber }}</span>
<span>Rød: {{ winner.red }}</span>
@@ -47,11 +47,11 @@
<span class="name">{{ attendee.name }}</span>
<span class="phoneNumber">{{ attendee.phoneNumber }}</span>
</div>
<div class="ballots-container">
<div class="red-ballot ballot-element small">{{ attendee.red }}</div>
<div class="blue-ballot ballot-element small">{{ attendee.blue }}</div>
<div class="green-ballot ballot-element small">{{ attendee.green }}</div>
<div class="yellow-ballot ballot-element small">{{ attendee.yellow }}</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>
@@ -106,6 +106,8 @@
</div>
<br />
<button class="vin-button" @click="sendAttendee">Send deltaker</button>
<TextToast v-if="showToast" :text="toastText" v-on:closeToast="showToast = false" />
</div>
</template>
@@ -118,13 +120,17 @@ import {
attendees,
winnersSecure,
deleteWinners,
deleteAttendees
deleteAttendees,
finishedDraw,
prelottery
} from "@/api";
import TextToast from "@/ui/TextToast";
import RaffleGenerator from "@/ui/RaffleGenerator";
export default {
components: {
RaffleGenerator
RaffleGenerator,
TextToast
},
data() {
return {
@@ -134,7 +140,7 @@ export default {
blue: 0,
green: 0,
yellow: 0,
ballots: 0,
raffles: 0,
randomColors: false,
attendees: [],
winners: [],
@@ -143,7 +149,9 @@ export default {
drawTime: 20,
currentWinners: 1,
numberOfWinners: 4,
socket: null
socket: null,
toastText: undefined,
showToast: false
};
},
mounted() {
@@ -165,12 +173,23 @@ export default {
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,
@@ -178,12 +197,17 @@ export default {
blue: this.blue,
green: this.green,
yellow: this.yellow,
ballots: this.ballots
raffles: this.raffles
});
if (response == true) {
alert("Sendt inn deltaker!");
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;
@@ -201,19 +225,29 @@ export default {
this.secondsLeft = this.drawTime;
},
drawWinner: async function() {
this.drawingWinner = true;
let response = await getVirtualWinner();
if (response) {
if (this.currentWinners < this.numberOfWinners) {
this.countdown();
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"]);
}
this.getWinners();
this.getAttendees();
} else {
this.drawingWinner = false;
alert("Noe gikk galt under trekningen..!");
}
},
countdown: function() {
@@ -236,19 +270,23 @@ export default {
}, 1000);
},
deleteAllWinners: async function() {
let response = await deleteWinners();
if (response) {
this.getWinners();
} else {
alert("Klarte ikke hente ut vinnere");
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() {
let response = await deleteAttendees();
if (response) {
this.getAttendees();
} else {
alert("Klarte ikke hente ut vinnere");
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() {
@@ -317,13 +355,13 @@ hr {
}
}
.ballot-element {
.raffle-element {
width: 140px;
height: 150px;
margin: 20px 0;
-webkit-mask-image: url(/../../public/assets/images/lodd.svg);
-webkit-mask-image: url(/public/assets/images/lodd.svg);
background-repeat: no-repeat;
mask-image: url(/../../public/assets/images/lodd.svg);
mask-image: url(/public/assets/images/lodd.svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
color: #333333;
@@ -341,19 +379,19 @@ hr {
font-size: 1rem;
}
&.green-ballot {
&.green-raffle {
background-color: $light-green;
}
&.blue-ballot {
&.blue-raffle {
background-color: $light-blue;
}
&.yellow-ballot {
&.yellow-raffle {
background-color: $light-yellow;
}
&.red-ballot {
&.red-raffle {
background-color: $light-red;
}
}
@@ -385,7 +423,7 @@ button {
margin: 0 auto;
& .name-and-phone,
& .ballots-container {
& .raffles-container {
display: flex;
justify-content: center;
}
@@ -394,7 +432,7 @@ button {
flex-direction: column;
}
& .ballots-container {
& .raffles-container {
flex-direction: row;
}
}

View File

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

View File

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

View File

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

132
frontend/router.js Normal file
View File

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

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

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

View File

@@ -4,13 +4,13 @@
@font-face {
font-family: "knowit";
font-weight: 600;
src: url("/../../public/assets/fonts/bold.woff");
src: url("/public/assets/fonts/bold.woff");
}
@font-face {
font-family: "knowit";
font-weight: 300;
src: url("/../../public/assets/fonts/regular.eot");
src: url("/public/assets/fonts/regular.woff");
}
body {
@@ -18,6 +18,10 @@ body {
margin: 0;
}
a {
text-decoration: none;
}
.title {
text-align: center;
width: fit-content;
@@ -74,6 +78,16 @@ body {
margin-right: 2rem;
}
&.column {
flex-direction: column;
align-items: center;
> * {
margin-right: unset;
margin-bottom: 1rem;
}
}
@include mobile {
&:not(.row) {
flex-direction: column;
@@ -98,16 +112,16 @@ textarea {
.vin-button {
font-family: Arial;
$color: #b7debd;
position: relative;
display: inline-block;
background: $color;
background: $primary;
color: #333;
padding: 10px 30px;
margin: 0;
border: 0;
width: fit-content;
font-size: 1.3rem;
line-height: 1.3rem;
height: 4rem;
max-height: 4rem;
cursor: pointer;
@@ -118,6 +132,15 @@ textarea {
// disable-dbl-tap-zoom
touch-action: manipulation;
&.auto-height {
height: auto;
}
&.danger {
background-color: $red;
color: white;
}
&::after {
content: "";
position: absolute;
@@ -133,41 +156,175 @@ textarea {
0 16px 32px rgba(0, 0, 0, 0.07), 0 32px 64px rgba(0, 0, 0, 0.07);
}
&:hover {
&:hover:not(:disabled) {
transform: scale(1.02) translateZ(0);
&::after {
opacity: 1;
}
}
&:disabled{
opacity: 0.25;
cursor: not-allowed;
}
&.small {
height: min-content;
}
}
.cursor {
&-pointer {
cursor: pointer;
}
}
.text-center {
text-align: center;
}
.vin-link {
font-weight: bold;
border-bottom: 1px solid $link-color;
font-size: inherit;
cursor: pointer;
text-decoration: none;
color: $matte-text-color;
&:focus, &:hover {
border-color: $link-color;
}
}
.margin-top {
&-md {
margin-top: 3rem;
}
&-sm {
margin-top: 1rem;
}
&-0 {
margin-top: 0;
}
}
.margin-left {
&-md {
margin-left: 3rem;
}
&-sm {
margin-left: 1rem;
}
&-0 {
margin-left: 0;
}
}
.margin-right {
&-md {
margin-right: 3rem;
}
&-sm {
margin-right: 1rem;
}
&-0 {
margin-right: 0;
}
}
.margin-bottom {
&-md {
margin-bottom: 3rem;
}
&-sm {
margin-bottom: 1rem;
}
&-0 {
margin-bottom: 0;
}
}
.width {
&-100 {
width: 100%;
}
&-75 {
width: 75%;
}
&-50 {
width: 50%;
}
&-25 {
width: 25%;
}
}
.cursor {
&-pointer {
cursor: pointer;
}
}
.no-margin {
margin: 0 !important;
}
.ballot-element {
.raffle-element {
margin: 20px 0;
-webkit-mask-image: url(/../../public/assets/images/lodd.svg);
-webkit-mask-image: url(/public/assets/images/lodd.svg);
background-repeat: no-repeat;
mask-image: url(/../../public/assets/images/lodd.svg);
mask-image: url(/public/assets/images/lodd.svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
color: #333333;
&.green-ballot {
&.green-raffle {
background-color: $light-green;
}
&.blue-ballot {
&.blue-raffle {
background-color: $light-blue;
}
&.yellow-ballot {
&.yellow-raffle {
background-color: $light-yellow;
}
&.red-ballot {
&.red-raffle {
background-color: $light-red;
}
}
@mixin raffle {
padding-bottom: 50px;
&::before, &::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 25px;
height: 50px;
background: radial-gradient(closest-side, #fff, #fff 50%, transparent 50%);
background-size: 50px 50px;
background-position: 0 25px;
background-repeat: repeat-x;
}
&::after{
background: radial-gradient(closest-side, transparent, transparent 50%, #fff 50%);
background-size: 50px 50px;
background-position: 25px -25px;
bottom: -25px
}
}
.desktop-only {
@include mobile {
display: none;
}
}
.mobile-only {
@include desktop {
display: none;
}
}

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
$primary: #dbeede;
$primary: #b7debd;
$light-green: #c8f9df;
$green: #0be881;
@@ -15,3 +15,7 @@ $dark-yellow: #ecc31d;
$light-red: #fbd7de;
$red: #ef5878;
$dark-red: #ec3b61;
$link-color: #ff5fff;
$matte-text-color: #333333;

View File

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

View File

@@ -44,7 +44,7 @@
color="#23101f"
/>
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#dbeede" />
<meta name="theme-color" content="#b7debd" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
@@ -55,6 +55,7 @@
<div id="app"></div>
<noscript>Du trenger vin, jeg trenger javascript!</noscript>
<script src="/public/analytics.js" async></script>
</body>
</html>
@@ -65,7 +66,7 @@
align-items: center;
text-align: center;
height: 100vh;
background-color: #dbeede;
background-color: #b7debd;
font-size: 1.5rem;
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,13 +1,12 @@
<template>
<div class="attendees" v-if="attendees.length > 0">
<h2>Deltakere ({{ attendees.length }})</h2>
<div class="attendees-container" ref="attendees">
<div class="attendee" v-for="(attendee, index) in flipList(attendees)" :key="index">
<span class="attendee-name">{{ attendee.name }}</span>
<div class="red-ballot ballot-element small">{{ attendee.red }}</div>
<div class="blue-ballot ballot-element small">{{ attendee.blue }}</div>
<div class="green-ballot ballot-element small">{{ attendee.green }}</div>
<div class="yellow-ballot ballot-element small">{{ attendee.yellow }}</div>
<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>
@@ -42,11 +41,17 @@ export default {
@import "../styles/global.scss";
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
.attendee-name {
width: 60%;
}
.ballot-element {
hr {
border: 2px solid black;
width: 100%;
}
.raffle-element {
font-size: 0.75rem;
width: 45px;
height: 45px;
@@ -56,20 +61,24 @@ export default {
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;
width: 65%;
height: 100%;
height: auto;
}
.attendees-container {
width: 100%;
height: 100%;
overflow-y: scroll;
max-height: 550px;
}
.attendee {
@@ -78,5 +87,9 @@ export default {
align-items: center;
width: 100%;
margin: 0 auto;
&:not(:last-of-type) {
border-bottom: 2px solid #d7d8d7;
}
}
</style>

View File

@@ -1,36 +1,55 @@
<template>
<router-link to="/" class="link">
<div class="top-banner">
<header class="top-banner">
<!-- Mobile -->
<router-link to="/" class="company-logo">
<img src="/public/assets/images/knowit.svg" alt="knowit logo" />
<div class="clock">
<h2 v-if="!fiveMinutesLeft && !tenMinutesOver">
<span v-if="days > 0">{{ pad(days) }}:</span>
<span>{{ pad(hours) }}</span>:
<span>{{ pad(minutes) }}</span>:
<span>{{ pad(seconds) }}</span>
</h2>
<h2 v-if="twoMinutesLeft || tenMinutesOver">Lotteriet er i gang!</h2>
</div>
</router-link>
<a class="menu-toggle-container" aria-label="show-menu" @click="toggleMenu" :class="isOpen ? 'open' : 'collapsed'" >
<span class="menu-toggle"></span>
<span class="menu-toggle"></span>
<span class="menu-toggle"></span>
</a>
<nav class="menu" :class="isOpen ? 'open' : 'collapsed'" >
<router-link v-for="(route, index) in routes" :key="index" :to="route.route" class="menu-item-link" >
<a @click="toggleMenu" class="single-route" :class="isOpen ? 'open' : 'collapsed'">{{ route.name }}</a>
<i class="icon icon--arrow-right"></i>
</router-link>
</nav>
<div class="clock">
<h2 v-if="!fiveMinutesLeft || !tenMinutesOver">
<span v-if="days > 0">{{ pad(days) }}:</span>
<span>{{ pad(hours) }}</span>:
<span>{{ pad(minutes) }}</span>:
<span>{{ pad(seconds) }}</span>
</h2>
<h2 v-if="twoMinutesLeft || tenMinutesOver">Lotteriet er i gang!</h2>
</div>
</router-link>
</header>
</template>
<script>
export default {
data() {
return {
isOpen: false,
nextLottery: null,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
distance: 0,
enabled: false,
code: "38384040373937396665",
codeDone: "",
interval: null
interval: null,
};
},
props: {
routes: {
required: true,
type: Array
}
},
mounted() {
this.initialize(), this.countdown();
},
@@ -49,25 +68,15 @@ export default {
}
},
methods: {
toggleMenu(){
this.isOpen = this.isOpen ? false : true;
},
pad: function(num) {
if (num < 10) {
return `0${num}`;
}
return num;
},
listenerFunction: function(event) {
this.codeDone += event.keyCode;
if (this.code.substring(0, this.codeDone.length) == this.codeDone) {
if (this.code == this.codeDone && !this.enabled) {
this.enabled = true;
this.initialize();
this.countdown();
this.codeDone = "";
}
} else {
this.codeDone = "";
}
},
initialize: function() {
let d = new Date();
let dayOfLottery = __DATE__;
@@ -79,8 +88,17 @@ export default {
nextDayOfLottery = new Date(nextDayOfLottery.setHours(__HOURS__));
nextDayOfLottery = new Date(nextDayOfLottery.setMinutes(0));
nextDayOfLottery = new Date(nextDayOfLottery.setSeconds(0));
let nowDate = new Date();
let now = nowDate.getTime();
if (nextDayOfLottery.getTimezoneOffset() != nowDate.getTimezoneOffset()) {
let _diff =
(nextDayOfLottery.getTimezoneOffset() - nowDate.getTimezoneOffset()) *
60 *
-1;
nextDayOfLottery.setSeconds(nextDayOfLottery.getSeconds() + _diff);
}
this.nextLottery = nextDayOfLottery;
let now = new Date().getTime();
this.distance = new Date(this.nextLottery).getTime() - now;
},
countdown: function() {
@@ -106,49 +124,11 @@ export default {
this.initialize();
}
this.interval = setTimeout(this.countdown, 500);
}
},
}
};
</script>
<style lang="scss" scoped>
@import "../styles/media-queries.scss";
@import "../styles/variables.scss";
.link {
text-decoration: none;
}
.top-banner {
text-align: center;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: calc(100% - 80px);
margin-top: 0px;
padding: 0px 40px;
background-color: $primary;
-webkit-box-shadow: 0px 0px 22px -8px rgba(0, 0, 0, 0.65);
-moz-box-shadow: 0px 0px 22px -8px rgba(0, 0, 0, 0.65);
box-shadow: 0px 0px 22px -8px rgba(0, 0, 0, 0.65);
@include mobile {
padding: 0px 40px;
> img {
height: 23px;
}
}
}
.clock {
text-decoration: none;
color: #333333;
display: flex;
font-family: Arial;
h2 {
display: flex;
}
}
@import "../styles/banner.scss";
</style>

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

@@ -0,0 +1,347 @@
<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>
<div class="history" ref="history" v-if="chatHistory.length > 0">
<div class="opaque-skirt"></div>
<div v-if="hasMorePages" class="fetch-older-history">
<button @click="loadMoreHistory">Hent eldre meldinger</button>
</div>
<div class="history-message"
v-for="(history, index) in chatHistory"
:key="`${history.username}-${history.timestamp}-${index}`"
>
<div>
<span class="username">{{ history.username }}</span>
<span class="timestamp">{{ getTime(history.timestamp) }}</span>
</div>
<span class="message">{{ history.message }}</span>
</div>
</div>
<div v-if="username" class="user-actions">
<input @keyup.enter="sendMessage" type="text" v-model="message" placeholder="Melding.." />
<button @click="sendMessage">Send</button>
</div>
<div v-else class="username-dialog">
<input
type="text"
@keyup.enter="setUsername"
v-model="temporaryUsername"
maxlength="30"
placeholder="Ditt navn.."
/>
<div class="validation-error" v-if="validationError">
{{ validationError }}
</div>
<button @click="setUsername">Lagre brukernavn</button>
</div>
</div>
</template>
<script>
import { getChatHistory } from "@/api";
import io from "socket.io-client";
export default {
data() {
return {
socket: null,
chatHistory: [],
hasMorePages: true,
message: "",
page: 1,
pageSize: 100,
temporaryUsername: null,
username: null,
validationError: undefined
};
},
created() {
getChatHistory(1, this.pageSize)
.then(resp => {
this.chatHistory = resp.messages;
this.hasMorePages = resp.total != resp.messages.length;
});
const username = window.localStorage.getItem('username');
if (username) {
this.username = username;
this.emitUsernameOnConnect = true;
}
},
watch: {
chatHistory: {
handler: function(newVal, oldVal) {
if (oldVal.length == 0) {
this.scrollToBottomOfHistory();
}
else if (newVal && newVal.length == oldVal.length) {
if (this.isScrollPositionAtBottom()) {
this.scrollToBottomOfHistory();
}
} else {
const prevOldestMessage = oldVal[0];
this.scrollToMessageElement(prevOldestMessage);
}
},
deep: true
}
},
beforeDestroy() {
this.socket.disconnect();
this.socket = null;
},
mounted() {
this.socket = io(window.location.origin);
this.socket.on("chat", msg => {
this.chatHistory.push(msg);
});
this.socket.on("disconnect", msg => {
this.wasDisconnected = true;
});
this.socket.on("connect", msg => {
if (
this.emitUsernameOnConnect ||
(this.wasDisconnected && this.username != null)
) {
this.setUsername(this.username);
}
});
this.socket.on("accept_username", msg => {
const { reason, success, username } = msg;
this.usernameAccepted = success;
if (success !== true) {
this.username = null;
this.validationError = reason;
} else {
this.usernameAllowed = true;
this.username = username;
this.validationError = null;
window.localStorage.setItem("username", username);
}
});
},
methods: {
loadMoreHistory() {
let { page, pageSize } = this;
page = page + 1;
getChatHistory(page, pageSize)
.then(resp => {
this.chatHistory = resp.messages.concat(this.chatHistory);
this.page = page;
this.hasMorePages = resp.total != this.chatHistory.length;
});
},
pad(num) {
if (num > 9) return num;
return `0${num}`;
},
getTime(timestamp) {
let date = new Date(timestamp);
const timeString = `${this.pad(date.getHours())}:${this.pad(
date.getMinutes()
)}:${this.pad(date.getSeconds())}`;
if (date.getDate() == new Date().getDate()) {
return timeString;
}
return `${date.toLocaleDateString()} ${timeString}`;
},
sendMessage() {
const message = { message: this.message };
this.socket.emit("chat", message);
this.message = '';
this.scrollToBottomOfHistory();
},
setUsername(username=undefined) {
if (this.temporaryUsername) {
username = this.temporaryUsername;
}
const message = { username: username };
this.socket.emit("username", message);
},
removeUsername() {
this.username = null;
this.temporaryUsername = null;
window.localStorage.removeItem("username");
},
isScrollPositionAtBottom() {
const { history } = this.$refs;
if (history) {
return history.offsetHeight + history.scrollTop >= history.scrollHeight;
}
return false
},
scrollToBottomOfHistory() {
setTimeout(() => {
const { history } = this.$refs;
history.scrollTop = history.scrollHeight;
}, 1);
},
scrollToMessageElement(message) {
const elemTimestamp = this.getTime(message.timestamp);
const self = this;
const getTimeStamp = (elem) => elem.getElementsByClassName('timestamp')[0].innerText;
const prevOldestMessageInNewList = (elem) => getTimeStamp(elem) == elemTimestamp;
setTimeout(() => {
const { history } = self.$refs;
const childrenElements = Array.from(history.getElementsByClassName('history-message'));
const elemInNewList = childrenElements.find(prevOldestMessageInNewList);
history.scrollTop = elemInNewList.offsetTop - 70
}, 1);
}
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/media-queries.scss";
@import "@/styles/variables.scss";
.chat-container {
position: relative;
transform: translate3d(0,0,0);
}
input {
width: 100%;
height: 3.25rem;
}
.logged-in-username {
position: absolute;
top: 0.75rem;
left: 1rem;
color: $matte-text-color;
width: calc(100% - 2rem);
button {
width: unset;
padding: 5px 10px;
position: absolute;
right: 0rem;
}
.username {
border-bottom: 2px solid $link-color;
}
}
.user-actions {
display: flex;
}
.history {
height: 75%;
overflow-y: scroll;
position: relative;
max-height: 550px;
margin-top: 2rem;
&-message {
display: flex;
flex-direction: column;
margin: 0.35rem 0;
position: relative;
.username {
font-weight: bold;
font-size: 1.05rem;
margin-right: 0.3rem;
}
.timestamp {
font-size: 0.9rem;
top: 2px;
position: absolute;
}
}
&-message:nth-of-type(2) {
margin-top: 1rem;
}
& .opaque-skirt {
width: calc(100% - 2rem);
position: fixed;
height: 2rem;
z-index: 1;
background: linear-gradient(
to bottom,
white,
rgba(255, 255, 255, 0)
);
}
& .fetch-older-history {
display: flex;
justify-content: center;
margin: 1rem 0;
}
@include mobile {
height: 300px;
}
}
.username-dialog {
display: flex;
flex-direction: row;
justify-content: center;
position: relative;
.validation-error {
position: absolute;
background-color: $light-red;
color: $red;
top: -3.5rem;
left: 0.5rem;
padding: 0.75rem;
border-radius: 4px;
&::before {
content: '';
position: absolute;
top: 2.1rem;
left: 2rem;
width: 1rem;
height: 1rem;
transform: rotate(45deg);
background-color: $light-red;
}
}
}
button {
position: relative;
display: inline-block;
background: #b7debd;
color: #333;
padding: 10px 30px;
border: 0;
width: fit-content;
font-size: 1rem;
/* height: 1.5rem; */
/* max-height: 1.5rem; */
margin: 0 2px;
cursor: pointer;
font-weight: 500;
transition: transform 0.5s ease;
-webkit-font-smoothing: antialiased;
touch-action: manipulation;
@include mobile {
padding: 10px 10px;
}
}
</style>

97
frontend/ui/Footer.vue Normal file
View File

@@ -0,0 +1,97 @@
<template>
<footer>
<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">
</a>
</li>
<li>
<a href="mailto:questions@vinlottis.no" class="mail">
<span class="vin-link">questions@vinlottis.no</span>
</a>
</li>
</ul>
<router-link to="/" class="company-logo">
<img src="/public/assets/images/knowit.svg" alt="knowit logo">
</router-link>
</footer>
</template>
<script>
export default {
name: 'WineFooter'
}
</script>
<style lang="scss" scoped>
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
footer {
width: 100%;
height: 100px;
display: flex;
justify-content: space-between;
align-items: center;
background: #f4f4f4;
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>

120
frontend/ui/Highscore.vue Normal file
View File

@@ -0,0 +1,120 @@
<template>
<div class="highscores" v-if="highscore.length > 0">
<section class="heading">
<h3>
Topp 5 vinnere
</h3>
<router-link to="highscore" class="">
<span class="vin-link">Se alle vinnere</span>
</router-link>
</section>
<ol class="winner-list-container">
<li v-for="(person, index) in highscore" :key="person._id" class="single-winner">
<span class="placement">{{index + 1}}.</span>
<i class="icon icon--medal"></i>
<p class="winner-name">{{ person.name }}</p>
</li>
</ol>
</div>
</template>
<script>
import { highscoreStatistics } from "@/api";
export default {
data() {
return { highscore: [] };
},
async mounted() {
let response = await highscoreStatistics();
response.sort((a, b) => a.wins.length < b.wins.length ? 1 : -1)
this.highscore = this.generateScoreBoard(response.slice(0, 5));
},
methods: {
generateScoreBoard(highscore=this.highscore) {
let place = 0;
let highestWinCount = -1;
return highscore.map(win => {
const wins = win.wins.length
if (wins != highestWinCount) {
place += 1
highestWinCount = wins
}
const placeString = place.toString().padStart(2, "0");
win.rank = placeString;
return win
})
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/variables.scss";
.heading {
display: flex;
justify-content: space-between;
align-items: center;
}
a {
text-decoration: none;
color: #333333;
&:focus,
&:active,
&:visited {
text-decoration: none;
color: #333333;
}
}
ol {
list-style-type: none;
margin-left: 0;
padding: 0;
}
.winner-list-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12.5em, 1fr));
gap: 5%;
.single-winner {
box-sizing: border-box;
width: 100%;
background: $primary;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
padding: 1em;
i {
font-size: 3em;
width: max-content;
justify-self: end;
}
.placement {
grid-row: 1;
grid-column: 1 / 3;
font-size: 3em;
}
.winner-name {
grid-row: 2;
grid-column: 1 / -1;
}
.winner-icon {
grid-row: 1;
grid-column: 3;
}
}
}
</style>

101
frontend/ui/Modal.vue Normal file
View File

@@ -0,0 +1,101 @@
<template>
<transition name="modal-fade">
<main class="modal-backdrop">
<section class="modal">
<header class="modal-header" v-if="headerText">
{{headerText}}
</header>
<section class="modal-body">
<p>
{{modalText}}
</p>
<section class="button-container">
<button v-for="(button, index) in buttons" :key="index" @click="modalButtonClicked(button.action)" class="vin-button">
{{button.text}}
</button>
</section>
</section>
</section>
</main>
</transition>
</template>
<script>
export default {
props: {
headerText: {
type: String,
required: false
},
modalText: {
type: String,
required: true
},
buttons: {
type: Array,
required: true
},
},
methods:{
modalButtonClicked(action){
this.$emit('click', action)
}
}
}
</script>
<style lang="scss" scoped>
@import "../styles/global.scss";
.modal-fade-enter,
.modal-fade-leave-active {
opacity: 0;
}
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity .5s ease
}
.modal-backdrop {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
width: 100vw;
height: 100vh;
}
.modal {
background: #FFFFFF;
-webkit-box-shadow: 0px 0px 22px 1px rgba(0, 0, 0, 0.65);
-moz-box-shadow: 0px 0px 22px 1px rgba(0, 0, 0, 0.65);
box-shadow: 0px 0px 22px 1px rgba(0, 0, 0, 0.65);
overflow-x: auto;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 15px;
display: flex;
}
.modal-header {
border-bottom: 1px solid #eeeeee;
color: #4AAE9B;
justify-content: space-between;
}
.modal-body {
position: relative;
padding: 20px 10px;
}
</style>

View File

@@ -141,13 +141,6 @@ export default {
.chart {
height: 40vh;
max-height: 500px;
@include mobile {
position: relative;
width: 90vw !important;
max-height: unset;
height: 30vh;
margin-bottom: 2rem;
}
width: 100%;
}
</style>

View File

@@ -35,11 +35,10 @@
type="number"
placeholder="Antall lodd"
@keyup.enter="generateColors"
v-model="numberOfBallots"
v-model="numberOfRaffles"
/>
<button class="vin-button" @click="generateColors">Generer</button>
</div>
<div class="colors">
<div
v-for="color in colors"
@@ -69,7 +68,7 @@ export default {
},
data() {
return {
numberOfBallots: 4,
numberOfRaffles: 4,
colors: [],
blue: 0,
red: 0,
@@ -85,14 +84,14 @@ export default {
};
},
beforeMount() {
this.$emit("numberOfBallots", this.numberOfBallots);
this.$emit("numberOfRaffles", this.numberOfRaffles);
if (this.generateOnInit) {
this.generateColors();
}
},
watch: {
numberOfBallots: function() {
this.$emit("numberOfBallots", this.numberOfBallots);
numberOfRaffles: function() {
this.$emit("numberOfRaffles", this.numberOfRaffles);
this.generateColors();
}
},
@@ -102,7 +101,7 @@ export default {
if (time == 5) {
this.generating = false;
this.generated = true;
if (this.numberOfBallots > 1 &&
if (this.numberOfRaffles > 1 &&
[this.redCheckbox, this.greenCheckbox, this.yellowCheckbox, this.blueCheckbox].filter(value => value == true).length == 1) {
return
}
@@ -113,13 +112,11 @@ export default {
this.emitColors()
if (window.location.hostname == "localhost") {
return;
}
this.$ga.event({
eventCategory: "Ballots",
window.ga('send', {
hitType: "event",
eventCategory: "Raffles",
eventAction: "Generate",
eventValue: JSON.stringify(this.colors)
eventLabel: JSON.stringify(this.colors)
});
return;
}
@@ -148,8 +145,8 @@ export default {
alert("Du må velge MINST 1 farge");
return;
}
if (this.numberOfBallots > 0) {
for (let i = 0; i < this.numberOfBallots; i++) {
if (this.numberOfRaffles > 0) {
for (let i = 0; i < this.numberOfRaffles; i++) {
let color =
randomArray[Math.floor(Math.random() * randomArray.length)];
this.colors.push(color);
@@ -293,9 +290,9 @@ label .text {
width: 150px;
height: 150px;
margin: 20px;
-webkit-mask-image: url(/../../public/assets/images/lodd.svg);
-webkit-mask-image: url(/public/assets/images/lodd.svg);
background-repeat: no-repeat;
mask-image: url(/../../public/assets/images/lodd.svg);
mask-image: url(/public/assets/images/lodd.svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;

View File

@@ -0,0 +1,124 @@
<template>
<Wine :wine="wine">
<template v-slot:top>
<div class="flex justify-end">
<div class="requested-count cursor-pointer" @click="request">
<span>{{ requestedElement.count }}</span>
<i class="icon icon--heart" :class="{ 'active': locallyRequested }" />
</div>
</div>
</template>
<template v-slot:default>
<button @click="deleteWine(wine)" v-if="showDeleteButton == true" class="vin-button small danger width-100">
Slett vinen
</button>
</template>
<template v-slot:bottom>
<div class="float-left request">
<i class="icon icon--heart request-icon" :class="{ 'active': locallyRequested }"></i>
<a aria-role="button" tabindex="0" class="link" @click="request"
:class="{ 'active': locallyRequested }">
{{ locallyRequested ? 'Anbefalt' : 'Anbefal' }}
</a>
</div>
</template>
</Wine>
</template>
<script>
import { deleteRequestedWine, requestNewWine } from "@/api";
import Wine from "@/ui/Wine";
export default {
components: {
Wine
},
data(){
return {
wine: this.requestedElement.wine,
locallyRequested: false
}
},
props: {
requestedElement: {
required: true,
type: Object
},
showDeleteButton: {
required: false,
type: Boolean,
default: false
}
},
methods: {
request(){
if (this.locallyRequested)
return
console.log("requesting", this.wine)
this.locallyRequested = true
this.requestedElement.count = this.requestedElement.count +1
requestNewWine(this.wine)
},
async deleteWine() {
const wine = this.wine
if (window.confirm("Er du sikker på at du vil slette vinen?")) {
let response = await deleteRequestedWine(wine);
if (response['success'] == true) {
this.$emit('wineDeleted', wine);
} else {
alert("Klarte ikke slette vinen");
}
}
},
},
}
</script>
<style lang="scss" scoped>
@import "@/styles/variables";
.requested-count {
display: flex;
align-items: center;
margin-top: -0.5rem;
background-color: rgb(244,244,244);
border-radius: 1.1rem;
padding: 0.25rem 1rem;
font-size: 1.25em;
span {
padding-right: 0.5rem;
line-height: 1.25em;
}
.icon--heart{
color: grey;
}
}
.active {
&.link {
border-color: $link-color
}
&.icon--heart {
color: $link-color;
}
}
.request {
display: flex;
align-items: center;
&-icon {
font-size: 1.5rem;
color: grey;
}
a {
margin-left: 0.5rem;
}
}
</style>

View File

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

View File

@@ -7,22 +7,16 @@
:key="index"
@click="changeTab(index)"
:class="chosenTab == index ? 'active' : null"
>
{{ tab.name }}
</div>
>{{ tab.name }}</div>
</div>
<div class="tab-elements">
<component
v-for="(tab, index) in tabs"
:key="index"
:is="tab.component"
:class="chosenTab == index ? null : 'hide'"
/>
<component :is="tabs[chosenTab].component" />
</div>
</div>
</template>
<script>
import eventBus from "@/mixins/EventBus";
export default {
props: {
tabs: {
@@ -45,6 +39,7 @@ export default {
changeTab: function(num) {
this.chosenTab = num;
this.$emit("tabChange", num);
eventBus.$emit("tab-change");
}
}
};
@@ -54,9 +49,6 @@ export default {
h1 {
text-align: center;
}
.hide {
display: none;
}
.tab-container {
display: flex;

View File

@@ -1,6 +1,16 @@
<template>
<div class="outer-bought">
<section class="outer-bought">
<h3>Loddstatistikk</h3>
<div class="total-raffles">
Totalt&nbsp;
<span class="total">{{ total }}</span>
&nbsp;kjøpte,&nbsp;
<span>{{ totalWin }}&nbsp;vinn og&nbsp;</span>
<span> {{ stolen }} stjålet </span>
</div>
<div class="bought-container">
<div
v-for="color in colors"
@@ -8,34 +18,19 @@
color.name +
'-container ' +
color.name +
'-ballot inner-bought-container ballot-element'
'-raffle raffle-element-local'
"
:key="color.name"
>
<div class="number-container">
<span class="color-total bought-number-span">
{{ color.total }}
</span>
<span>kjøpte</span>
</div>
<div class="inner-text-container">
<div>{{ color.win }} vinn</div>
<div>{{ color.totalPercentage }}% vinn</div>
</div>
</div>
<div class="inner-bought-container total-ballots">
<div class="total-container">
Totalt&nbsp;
<div>
<span class="total">{{ total }}</span> kjøpte
</div>
<div>{{ totalWin }} vinn</div>
<div>{{ stolen }} stjålet</div>
</div>
>
<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>
</div>
</section>
</template>
<script>
import { colorStatistics } from "@/api";
@@ -60,11 +55,13 @@ export default {
},
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;
@@ -114,119 +111,106 @@ export default {
this.colors = this.colors.sort((a, b) => (a.win > b.win ? -1 : 1));
},
methods: {
translate(color){
switch(color) {
case "blue":
return "Blå"
break;
case "red":
return "Rød"
break;
case "green":
return "Grønn"
break;
case "yellow":
return "Gul"
break;
break;
}
},
getPercentage: function(win, total) {
return this.round(win == 0 ? 0 : (win / total) * 100);
},
round: function(number) {
return Math.round(number * 100) / 100;
//this can make the odds added together more than 100%, maybe rework?
let actualPercentage = Math.round(number * 100) / 100;
let rounded = actualPercentage.toFixed(0);
return rounded;
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
@import "../styles/global.scss";
.inner-bought-container {
@include mobile{
section {
margin-top: 5em;
}
}
.total-raffles {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.ballot-element {
width: 140px;
height: 150px;
margin: 20px 0;
}
.number-container {
display: flex;
align-items: flex-end;
& span:last-child {
padding-bottom: 5px;
padding-left: 5px;
}
}
.inner-text-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
// TODO fix styling for displaying in columns
@include mobile {
& div {
padding: 0 5px;
}
}
}
.total-ballots {
width: 150px;
height: 150px;
margin: 20px 0;
}
.total-container {
align-items: flex-start;
}
@include mobile {
.total-container {
> div:nth-of-type(2) {
margin-top: auto;
padding-bottom: 4px;
padding-left: 5px;
}
}
}
.bought-number-span {
display: inline-flex;
}
.outer-bought {
@include mobile {
padding: 0 20px;
}
}
.bought-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
padding-bottom: 3rem;
max-width: 1400px;
margin: auto;
justify-content: space-between;
font-family: Arial;
margin-top: 2em;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 50px;
@include mobile {
padding-bottom: 0px;
}
}
.raffle-element-local {
height: 250px;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
@include raffle;
.color-total,
.total {
font-size: 2rem;
font-weight: bold;
}
.win-percentage {
margin-left: 30px;
font-size: 50px;
}
.small {
font-weight: bold;
font-size: 1.25rem;
display: inline-block;
}
p {
margin-left: 30px;
&.winner-chance {
margin-top: 40px;
}
@include mobile {
.bought-container {
flex-wrap: wrap;
&.total-bought-color{
font-weight: bold;
margin-top: 25px;
}
&.amount-of-wins {
font-weight: bold;
}
}
h3 {
margin-left: 15px;
}
&.green-raffle {
background-color: $light-green;
}
&.blue-raffle {
background-color: $light-blue;
}
&.yellow-raffle {
background-color: $light-yellow;
}
&.red-raffle {
background-color: $light-red;
}
}
}
</style>

81
frontend/ui/VippsPill.vue Normal file
View File

@@ -0,0 +1,81 @@
<template>
<div aria-label="button" role="button" @click="openVipps" tabindex="0">
<img src="public/assets/images/vipps-pay_with_vipps_pill.png" />
</div>
</template>
<script>
export default {
props: {
amount: {
type: Number,
default: 1
}
},
data() {
return {
phone: __PHONE__,
name: __NAME__,
price: __PRICE__,
message: __MESSAGE__
};
},
computed: {
isMobile: function() {
return this.isMobileFunction();
},
priceToPay: function() {
return this.amount * (this.price * 100);
},
vippsUrlBasedOnUserAgent: function() {
if (navigator.userAgent.includes("iPhone")) {
return (
"https://qr.vipps.no/28/2/01/031/47" +
this.phone.replace(/ /g, "") +
"?v=1&m=" +
this.message +
"&a=" +
this.priceToPay
);
}
return (
"https://qr.vipps.no/28/2/01/031/47" +
this.phone.replace(/ /g, "") +
"?v=1&m=" +
this.message
);
}
},
methods: {
openVipps() {
if (!this.isMobileFunction()) {
return;
}
window.location.assign(this.vippsUrlBasedOnUserAgent);
},
isMobileFunction() {
if (
navigator.userAgent.match(/Android/i) ||
navigator.userAgent.match(/webOS/i) ||
navigator.userAgent.match(/iPhone/i) ||
navigator.userAgent.match(/iPad/i) ||
navigator.userAgent.match(/iPod/i) ||
navigator.userAgent.match(/BlackBerry/i) ||
navigator.userAgent.match(/Windows Phone/i)
) {
return true;
} else {
return false;
}
}
}
};
</script>
<style lang="scss" scoped>
img {
cursor: pointer;
width: 100%;
}
</style>

View File

@@ -107,13 +107,6 @@ export default {
.chart {
height: 40vh;
max-height: 500px;
@include mobile {
position: relative;
width: 90vw !important;
max-height: unset;
height: 30vh;
margin-bottom: 2rem;
}
width: 100%;
}
</style>

138
frontend/ui/Wine.vue Normal file
View File

@@ -0,0 +1,138 @@
<template>
<div class="wine">
<slot name="top"></slot>
<div class="wine-image">
<img
v-if="wine.image && loadImage"
:src="wine.image"
/>
<img v-else class="wine-placeholder" alt="Wine image" />
</div>
<div class="wine-details">
<span v-if="wine.name" class="wine-name">{{ wine.name }}</span>
<span v-if="wine.rating"><b>Rating:</b> {{ wine.rating }}</span>
<span v-if="wine.price"><b>Pris:</b> {{ wine.price }} NOK</span>
<span v-if="wine.country"><b>Land:</b> {{ wine.country }}</span>
</div>
<slot></slot>
<div class="bottom-section">
<slot name="bottom"></slot>
<a v-if="wine.vivinoLink" :href="wine.vivinoLink" class="link float-right">
Les mer
</a>
</div>
</div>
</template>
<script>
export default {
props: {
wine: {
type: Object,
required: true
}
},
data() {
return {
loadImage: false
}
},
methods: {
setImage(entries) {
const { target, isIntersecting } = entries[0];
if (!isIntersecting) return;
this.loadImage = true;
this.observer.unobserve(target);
}
},
created() {
this.observer = new IntersectionObserver(this.setImage, {
root: this.$el,
threshold: 0
})
},
mounted() {
this.observer.observe(this.$el);
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/media-queries";
@import "@/styles/variables";
.wine {
padding: 1rem;
box-sizing: border-box;
position: relative;
-webkit-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
-moz-box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.15);
@include tablet {
width: 250px;
height: 100%;
}
}
.wine-image {
display: flex;
justify-content: center;
margin-top: 10px;
img {
height: 250px;
@include mobile {
object-fit: cover;
max-width: 90px;
}
}
.wine-placeholder {
height: 250px;
width: 70px;
}
}
.wine-details {
display: flex;
flex-direction: column;
> span {
margin-bottom: 0.5rem;
}
}
.wine-name{
font-size: 20px;
margin: 1em 0;
}
.wine-details {
display: flex;
flex-direction: column;
}
.bottom-section {
width: 100%;
margin-top: 1rem;
.link {
color: $matte-text-color;
font-family: Arial;
font-size: 1.2rem;
cursor: pointer;
font-weight: normal;
border-bottom: 2px solid $matte-text-color;
&:hover {
font-weight: normal;
border-color: $link-color;
}
}
}
</style>

166
frontend/ui/Wines.vue Normal file
View File

@@ -0,0 +1,166 @@
<template>
<div v-if="wines.length > 0" class="wines-main-container">
<div class="info-and-link">
<h3>
Topp 5 viner
</h3>
<router-link to="viner">
<span class="vin-link">Se alle viner </span>
</router-link>
</div>
<div class="wine-container">
<Wine v-for="wine in wines" :key="wine" :wine="wine">
<template v-slot:top>
<div class="flex justify-end">
<div class="requested-count cursor-pointer">
<span> {{ wine.occurences }} </span>
<i class="icon icon--heart" />
</div>
</div>
</template>
</Wine>
</div>
</div>
</template>
<script>
import Wine from "@/ui/Wine";
import { overallWineStatistics } from "@/api";
export default {
components: {
Wine
},
data() {
return {
wines: [],
clickedWine: null,
};
},
async mounted() {
let response = await overallWineStatistics();
response.sort();
response = response
.filter(wine => wine.name != null && wine.name != "")
.sort(
this.predicate(
{
name: "occurences",
reverse: true
},
{
name: "rating",
reverse: true
}
)
);
this.wines = response.slice(0, 5);
},
methods: {
predicate: function() {
var fields = [],
n_fields = arguments.length,
field,
name,
cmp;
var default_cmp = function(a, b) {
if (a == undefined) a = 0;
if (b == undefined) b = 0;
if (a === b) return 0;
return a < b ? -1 : 1;
},
getCmpFunc = function(primer, reverse) {
var dfc = default_cmp,
// closer in scope
cmp = default_cmp;
if (primer) {
cmp = function(a, b) {
return dfc(primer(a), primer(b));
};
}
if (reverse) {
return function(a, b) {
return -1 * cmp(a, b);
};
}
return cmp;
};
// preprocess sorting options
for (var i = 0; i < n_fields; i++) {
field = arguments[i];
if (typeof field === "string") {
name = field;
cmp = default_cmp;
} else {
name = field.name;
cmp = getCmpFunc(field.primer, field.reverse);
}
fields.push({
name: name,
cmp: cmp
});
}
// final comparison function
return function(A, B) {
var name, result;
for (var i = 0; i < n_fields; i++) {
result = 0;
field = fields[i];
name = field.name;
result = field.cmp(A[name], B[name]);
if (result !== 0) break;
}
return result;
};
}
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/variables.scss";
@import "@/styles/global.scss";
@import "../styles/media-queries.scss";
.wines-main-container {
margin-bottom: 10em;
}
.info-and-link{
display: flex;
justify-content: space-between;
}
.wine-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 2rem;
.requested-count {
display: flex;
align-items: center;
margin-top: -0.5rem;
background-color: rgb(244,244,244);
border-radius: 1.1rem;
padding: 0.25rem 1rem;
font-size: 1.25em;
span {
padding-right: 0.5rem;
line-height: 1.25em;
}
.icon--heart{
font-size: 1.5rem;
color: $link-color;
}
}
}
</style>

View File

@@ -1,39 +1,23 @@
<template>
<div class="current-drawn-container">
<div class="current-draw" v-if="drawing">
<h2>TREKKER</h2>
<div
:class="currentColor + '-ballot'"
class="ballot-element center-new-winner"
:style="{ transform: 'rotate(' + getRotation() + 'deg)' }"
>
<span v-if="currentName && colorDone">{{ currentName }}</span>
</div>
<br />
<br />
<br />
<br />
<br />
</div>
<div class="current-draw" v-if="drawingDone">
<h2>VINNER</h2>
<div
:class="currentColor + '-ballot'"
class="ballot-element center-new-winner"
:style="{ transform: 'rotate(' + getRotation() + 'deg)' }"
>
<span v-if="currentName && colorDone">{{ currentName }}</span>
</div>
<br />
<br />
<br />
<br />
<br />
<div class="current-drawn-container" v-if="drawing">
<h2 v-if="winnersNameDrawn !== true">TREKKER {{ ordinalNumber() }} VINNER</h2>
<h2 v-else>VINNER</h2>
<div
:class="currentColor + '-raffle'"
class="raffle-element"
:style="{ transform: 'rotate(' + getRotation() + 'deg)' }"
>
<span v-if="currentName && colorDone">{{ currentName }}</span>
</div>
<br />
<br />
</div>
</template>
<script>
import confetti from "canvas-confetti";
export default {
props: {
currentWinner: {
@@ -59,14 +43,13 @@ export default {
nameTimeout: null,
colorDone: false,
drawing: false,
drawingDone: false,
winnersNameDrawn: false,
winnerQueue: []
};
},
watch: {
currentWinner: function(currentWinner) {
if (currentWinner == null) {
this.drawingDone = false;
return;
}
if (this.drawing) {
@@ -74,6 +57,7 @@ export default {
return;
}
this.drawing = true;
this.winnersNameDrawn = false;
this.currentName = null;
this.currentColor = null;
this.nameRounds = 0;
@@ -97,8 +81,8 @@ export default {
this.drawColor(this.currentWinnerLocal.color);
return;
}
this.drawing = false;
this.drawingDone = true;
this.winnersNameDrawn = true;
this.startConfetti(this.currentName);
return;
}
this.currentName = this.attendees[
@@ -111,7 +95,7 @@ export default {
}, 50);
},
drawColor: function(winnerColor) {
this.drawingDone = false;
this.winnersNameDrawn = false;
if (this.colorRounds == 100) {
this.currentColor = winnerColor;
this.colorDone = true;
@@ -126,7 +110,7 @@ export default {
clearTimeout(this.colorTimeout);
this.colorTimeout = setTimeout(() => {
this.drawColor(winnerColor);
}, 50);
}, 70);
},
getRotation: function() {
if (this.colorDone) {
@@ -147,9 +131,63 @@ export default {
case 3:
return "yellow";
}
},
startConfetti(currentName) {
//duration is computed as x * 1000 miliseconds, in this case 7*1000 = 7000 miliseconds ==> 7 seconds.
var duration = 7 * 1000;
var animationEnd = Date.now() + duration;
var defaults = { startVelocity: 50, spread: 160, ticks: 50, zIndex: 0, particleCount: 20};
var uberDefaults = { startVelocity: 65, spread: 75, zIndex: 0, particleCount: 35}
function randomInRange(min, max) {
return Math.random() * (max - min) + min;
}
const self = this;
var interval = setInterval(function() {
var timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
self.drawing = false;
console.time("drawing finished")
return clearInterval(interval);
}
if (currentName == "Amund Brandsrud") {
runCannon(uberDefaults, {x: 1, y: 1 }, {angle: 135});
runCannon(uberDefaults, {x: 0, y: 1 }, {angle: 45});
runCannon(uberDefaults, {y: 1 }, {angle: 90});
runCannon(uberDefaults, {x: 0 }, {angle: 45});
runCannon(uberDefaults, {x: 1 }, {angle: 135});
} else {
runCannon(defaults, {x: 0 }, {angle: 45});
runCannon(defaults, {x: 1 }, {angle: 135});
runCannon(defaults, {y: 1 }, {angle: 90});
}
}, 250);
function runCannon(confettiDefaultValues, originPoint, launchAngle){
confetti(Object.assign({}, confettiDefaultValues, {origin: originPoint }, launchAngle))
}
},
ordinalNumber(number=this.currentWinnerLocal.winnerCount) {
const dictonary = {
1: "første",
2: "andre",
3: "tredje",
4: "fjerde",
5: "femte",
6: "sjette",
7: "syvende",
8: "åttende",
9: "niende",
10: "tiende",
11: "ellevte",
12: "tolvte"
};
return number in dictonary ? dictonary[number] : number;
}
}
};
</script>
<style lang="scss" scoped>
@@ -159,22 +197,27 @@ export default {
h2 {
text-align: center;
text-transform: uppercase;
}
.current-drawn-container {
display: flex;
justify-content: center;
align-items: center;
grid-column: 1 / 5;
display: grid;
place-items: center;
position: relative;
}
.ballot-element {
width: 140px;
height: 140px;
font-size: 1.2rem;
.raffle-element {
width: 280px;
height: 300px;
font-size: 2rem;
display: flex;
justify-content: center;
align-items: center;
font-size: 0.75rem;
text-align: center;
-webkit-mask-size: cover;
-moz-mask-size: cover;
mask-size: cover;
}
</style>

100
frontend/ui/Winners.vue Normal file
View File

@@ -0,0 +1,100 @@
<template>
<section>
<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) }`">
<div :class="winner.color + '-raffle'" class="raffle-element">{{ winner.name }}</div>
</router-link>
</div>
</div>
<div v-else-if="drawing" class="container">
<h3>Trekningen er igang!</h3>
</div>
<div v-else class="container">
<h3>Trekningen har ikke startet enda <button></button></h3>
</div>
</section>
</template>
<script>
export default {
props: {
winners: {
type: Array
},
drawing: {
type: Boolean,
},
title: {
type: String,
required: false
}
}
};
</script>
<style lang="scss" scoped>
@import "../styles/global.scss";
@import "../styles/variables.scss";
@import "../styles/media-queries.scss";
section {
width: 100%;
display: flex;
flex-direction: column;
position: relative;
}
h2 {
font-size: 1.1rem;
margin-bottom: 0.6rem;
width: 100%;
text-align: center;
}
h3 {
margin: auto;
color: $matte-text-color;
font-size: 1.6rem;
text-align: center;
}
.winning-raffles {
display: flex;
flex-flow: wrap;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
}
.raffle-element {
font-size: 1rem;
width: 145px;
height: 145px;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
text-align: center;
}
.container {
display: flex;
height: 100%;
button {
-webkit-appearance: unset;
background-color: unset;
padding: 0;
margin: 0;
font-size: inherit;
border: unset;
height: auto;
width: auto;
cursor: pointer;
}
}
</style>

27
frontend/utils.js Normal file
View File

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

View File

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

5075
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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