diff --git a/network-api/networkapi/buyersguide/models.py b/network-api/networkapi/buyersguide/models.py index 3624709e1c..4a42afd4a8 100644 --- a/network-api/networkapi/buyersguide/models.py +++ b/network-api/networkapi/buyersguide/models.py @@ -313,6 +313,7 @@ def votes(self): # Build + return the votes dict votes['creepiness'] = creepiness votes['confidence'] = confidence_vote_breakdown + votes['total'] = BooleanVote.objects.filter(product=self).count() return votes except ObjectDoesNotExist: diff --git a/network-api/networkapi/buyersguide/templates/product_page.html b/network-api/networkapi/buyersguide/templates/product_page.html index 0c159f5b91..00992d31a1 100644 --- a/network-api/networkapi/buyersguide/templates/product_page.html +++ b/network-api/networkapi/buyersguide/templates/product_page.html @@ -23,7 +23,11 @@

{{product.name}}

{{product.blurb}}
-
+
+ {% csrf_token %} + + +

Can it spy on me?

diff --git a/network-api/networkapi/buyersguide/tests.py b/network-api/networkapi/buyersguide/tests.py index a8ea217f25..b8ebb04c97 100644 --- a/network-api/networkapi/buyersguide/tests.py +++ b/network-api/networkapi/buyersguide/tests.py @@ -44,7 +44,8 @@ def test_aggregate_product_votes_default(self): 'confidence': { '0': 0, '1': 0 - } + }, + 'total': 0 }) def test_aggregate_product_votes(self): @@ -73,6 +74,7 @@ def test_aggregate_product_votes(self): self.assertEqual(response.status_code, 201) call_command('aggregate_product_votes') + self.assertDictEqual(product.votes, { 'creepiness': { 'average': 45, @@ -87,7 +89,8 @@ def test_aggregate_product_votes(self): 'confidence': { '0': 5, '1': 5 - } + }, + 'total': 10 }) diff --git a/source/images/buyers-guide/icon-thumb-down-black.svg b/source/images/buyers-guide/icon-thumb-down-black.svg new file mode 100644 index 0000000000..91af594870 --- /dev/null +++ b/source/images/buyers-guide/icon-thumb-down-black.svg @@ -0,0 +1 @@ + diff --git a/source/images/buyers-guide/icon-thumb-up-black.svg b/source/images/buyers-guide/icon-thumb-up-black.svg new file mode 100644 index 0000000000..04b029e3fa --- /dev/null +++ b/source/images/buyers-guide/icon-thumb-up-black.svg @@ -0,0 +1 @@ + diff --git a/source/js/buyers-guide/bg-main.js b/source/js/buyers-guide/bg-main.js index efed67a789..2512000fab 100644 --- a/source/js/buyers-guide/bg-main.js +++ b/source/js/buyers-guide/bg-main.js @@ -13,7 +13,9 @@ let main = { this.enableCopyLinks(); this.injectReactComponents(); primaryNav.init(); - HomepageSlider.init(); + if (document.getElementById(`pni-home`)) { + HomepageSlider.init(); + } }, enableCopyLinks() { @@ -80,7 +82,23 @@ let main = { injectReactComponents() { if (document.querySelectorAll(`.creep-vote-target`)) { Array.from(document.querySelectorAll(`.creep-vote-target`)).forEach(element => { - ReactDOM.render(, element); + let csrf = element.querySelector(`input[name=csrfmiddlewaretoken]`); + let productID = element.querySelector(`input[name=productID]`).value; + let votes = element.querySelector(`input[name=votes]`).value; + + try { + votes = JSON.parse(votes.replace(/'/g,`"`)); + } catch (e) { + votes = { + creepiness: { + average: 50, + 'vote_breakdown': {'0': 0, '1': 0, '2': 0, '3': 0, '4': 0} + }, + confidence: {'0': 0, '1': 0} + }; + } + + ReactDOM.render(, element); }); } diff --git a/source/js/buyers-guide/components/creep-vote/creep-vote.jsx b/source/js/buyers-guide/components/creep-vote/creep-vote.jsx index 010628031a..de110261f7 100644 --- a/source/js/buyers-guide/components/creep-vote/creep-vote.jsx +++ b/source/js/buyers-guide/components/creep-vote/creep-vote.jsx @@ -1,42 +1,183 @@ import React from 'react'; import Creepometer from '../creepometer/creepometer.jsx'; +import CreepChart from '../creepiness-chart/creepiness-chart.jsx'; +import LikelyhoodChart from '../likelyhood-chart/likelyhood-chart.jsx'; export default class CreepVote extends React.Component { constructor(props) { super(props); + this.state = this.getInitialState(); + } + + getInitialState() { + // let conf = this.props.votes.confidence; + let totalVotes = this.props.votes.total; - this.state = {}; + return { + totalVotes, + creepiness: 50, + confidence: undefined, + didVote: false + }; } - render() { - return ( -
-
-
-
-

How creepy is this product?

-

Majority of voters think it is super creepy

+ showVoteResult() { + if (this.state.creepinessSubmitted && this.state.confidenceSubmitted) { + this.setState({ didVote: true }); + } + } + + sendVoteFor(payload) { + let attribute = payload.attribute; + let url = `/privacynotincluded/vote`; + let method = `POST`; + let credentials = `same-origin`; + let headers = { + "X-CSRFToken": this.props.csrf, + "Content-Type": `application/json` + }; + + fetch(url, { + method, + credentials, + headers, + body: JSON.stringify(payload) + }) + .then(() => { + let update = {}; + + update[`${attribute}Submitted`] = true; + this.setState(update, () => { + this.showVoteResult(); + }); + }) + .catch(e => { + console.warn(e); + this.setState({ disableVoteButton: false }); + }); + } + + submitVote(evt) { + evt.preventDefault(); + + let confidence = this.state.confidence; + + if (confidence === undefined) { + return; + } + + this.setState({ disableVoteButton: true }); + + let productID = this.props.productID; + + this.sendVoteFor({ + attribute: `confidence`, + productID, + value: confidence, + }); + + this.sendVoteFor({ + attribute: `creepiness`, + productID, + value: this.state.creepiness + }); + } + + setCreepiness(creepiness) { + this.setState({ creepiness }); + } + + setConfidence(confidence) { + this.setState({ confidence }); + } + + /** + * @returns {jsx} What users see when they haven't voted on this product yet. + */ + renderVoteAsk() { + return (
this.submitVote(evt)}> +
+
+
+

How creepy is this product?

+

Majority of voters think it is super creepy

+
+ this.setCreepiness(value)}> +
+
+
+

How likely are you to buy it?

+

Majority of voters are not likely to buy it

+
+
+
+ +
-
-
-
-

How likely are you to buy it?

-

Majority of voters are not likely to buy it

+
+
+
+
+ +

{this.state.totalVotes} votes

+
+
+ ); + } + + /** + * @returns {jsx} What users see when they have voted on this product. + */ + renderDidVote(){ + return( +
+
+
+

Thanks for voting! Here are the results so far:

+
{this.state.totalVotes + 1} Votes
+
+
+
+
-
- - +
+
-
-
- -

367 votes

+
+
View comments or share your results
+ {/* TODO: Make these share links work */} +
); } + + render() { + let voteContent; + + if(this.state.didVote){ + voteContent = this.renderDidVote(); + } else { + voteContent = this.renderVoteAsk(); + } + + return ( +
+ { voteContent } +
+ ); + } } diff --git a/source/js/buyers-guide/components/creep-vote/creep-vote.scss b/source/js/buyers-guide/components/creep-vote/creep-vote.scss index 539e4af9ff..6b6dd85d3d 100644 --- a/source/js/buyers-guide/components/creep-vote/creep-vote.scss +++ b/source/js/buyers-guide/components/creep-vote/creep-vote.scss @@ -1,4 +1,67 @@ +$radio-button-radius: 50px; +$btn-shadow-width: 3px; + .creep-vote { - border-top: 1px solid #c8c8ca; border-bottom: 1px solid #c8c8ca; + border-top: 1px solid #c8c8ca; + + button[disabled] { + color: #bbb; + + &.btn-ghost:hover { + cursor: auto; + color: inherit; + background: inherit; + } + } + + .btn-group { + display: flex; + justify-content: center; + + .btn { + border: 1px solid black; + border-bottom-width: $btn-shadow-width; + color: black; + padding: 0.5em 1.5em; + text-transform: initial; + transition: all 0.1s linear; + } + + input { display: none; } + + input:checked + span { + background-color: #5cccff; + } + + input:-moz-ui-invalid + span { + border-color: red; + } + + label:first-of-type span { + border-radius: $radio-button-radius 0 0 $radio-button-radius; + } + + label:last-of-type span { + border-radius: 0 $radio-button-radius $radio-button-radius 0; + } + + img { + height: 1em; + } + } + + .help-text { + font-style: italic; + } + + a.share-results { + text-decoration: underline; + color: black; + + &:hover { + color: #4383bf; + font-weight: bold; + } + } } diff --git a/source/js/buyers-guide/components/creepiness-chart/creepiness-chart.jsx b/source/js/buyers-guide/components/creepiness-chart/creepiness-chart.jsx new file mode 100644 index 0000000000..62f0dcf92b --- /dev/null +++ b/source/js/buyers-guide/components/creepiness-chart/creepiness-chart.jsx @@ -0,0 +1,57 @@ +import React from 'react'; + +export default class CreepChart extends React.Component { + constructor(props) { + super(props); + this.state = this.getInitialState(); + } + + getInitialState() { + let values = this.props.values; + let data = [ + {c: `no-creep`, label: `Not creepy`, value: values[0], offset: 0}, + {c: `little-creep`, label: `A little creepy`, value: values[1], offset: 225}, + {c: `somewhat-creep`, label: `Somewhat creepy`, value: values[2], offset: 475}, + {c: `very-creep`, label: `Very creepy`, value: values[3], offset: 725}, + {c: `super-creep`, label: `Super creepy`, value: values[4], offset: 975} + ]; + let sum = data.reduce((tally, v) => tally + v.value, 0); + + return { + totalCreepiness: sum, + creepinessData: data + }; + } + + render() { + return ( +
+ + + { + this.state.creepinessData.map((data,index) => { + let percent = Math.round(100 * data.value / this.state.totalCreepiness); + let voteColumn = this.props.userVoteGroup === index ? `your-vote` : ``; + + return ( + + + + + ); + }) + } + +
+
+ {data.label} + +
{percent}%
+
+
Not creepy
+
Super creepy
+
+
+ ); + } +} diff --git a/source/js/buyers-guide/components/creepiness-chart/creepiness-chart.scss b/source/js/buyers-guide/components/creepiness-chart/creepiness-chart.scss new file mode 100644 index 0000000000..d6ced7a7f2 --- /dev/null +++ b/source/js/buyers-guide/components/creepiness-chart/creepiness-chart.scss @@ -0,0 +1,116 @@ +#creepiness-score { + display: flex; + // There's probably a better way to do this. + border-image: url("/_images/buyers-guide/gradient-bar.svg") 7/0px 6px 6px; + + tbody { + display: flex; + flex-direction: row; + align-items: flex-end; + width: 100%; + } + + th { + order: 2; + margin: 0 1px; + text-align: center; + align-self: flex-end; + width: 100%; + display: flex; + justify-content: flex-end; + flex-direction: column; + position: relative; + min-height: 85px; + } + + .bar, + td { + position: relative; + bottom: -40px; + } + + .creepiness { + font-weight: bold; + font-size: 14px; + } + //For some nice animations later on + .creepiness, + .bar { + transition: all 2.5s ease; + } + + .creep-label { + //Make sure screen readers can see the label + clip: rect(1px, 1px, 1px, 1px); + position: absolute; + } + + //Height of this is set inline via JSX + .bar { + background-color: $light-gray; + } + + //Show a different creep-face in each bar of the graph + .creep-face { + background-image: url("/_images/buyers-guide/faces/sprite-resized-64-colors.png"); + background-position: center 0; + background-size: 35px auto; + background-color: transparent; + background-repeat: no-repeat; + height: 35px; + margin-bottom: 10px; + position: relative; + } + + .little-creep .creep-face { + background-position: center 25.6%; + } + + .somewhat-creep .creep-face { + background-position: center 51.25%; + } + + .very-creep .creep-face { + background-position: center 76.9%; + } + + .super-creep .creep-face { + background-position: center 100%; + } + + //Style the graph bar representing the range the user voted + .your-vote { + &.no-creep .bar { + background-color: #1808f2; + } + + &.little-creep .bar { + background-color: #4a17d4; + } + + &.somewhat-creep .bar { + background-color: #7f28b7; + } + + &.very-creep .bar { + background-color: #b0379b; + } + + &.super-creep .bar { + background-color: #e4487d; + } + } + + td { + order: 1; + text-align: center; + } + + tr { + display: flex; + justify-content: space-between; + flex-direction: column; + flex-grow: 1; + margin: 0 1px; + } +} diff --git a/source/js/buyers-guide/components/creepometer/creepometer.jsx b/source/js/buyers-guide/components/creepometer/creepometer.jsx index eb55c7004e..389ed2bd21 100644 --- a/source/js/buyers-guide/components/creepometer/creepometer.jsx +++ b/source/js/buyers-guide/components/creepometer/creepometer.jsx @@ -4,93 +4,129 @@ export default class Creepometer extends React.Component { constructor(props) { super(props); + this.faceCount = 40; // Number of face frames + this.faceHeight = 70; // pixel height for one frame + this.framePath = `/_images/buyers-guide/faces/`; + this.state = { - isHandleGrabbed: false, - handleOffset: 0 + dragging: false, + percentage: 50, + value: 50 }; - this.handleWidth = 70; // px - this.faceCount = 40; // Number of face frames - this.encodedStepCount = 100; // Upper range of values to be recorded - this.framePath = `/_images/buyers-guide/faces/`; + this.setupDocumentListeners(); + } - this.slideStart = this.slideStart.bind(this); - this.slideMove = this.slideMove.bind(this); - this.slideStop = this.slideStop.bind(this); + setupDocumentListeners() { + this.moveListener = (function(evt) { + evt.preventDefault(); + evt.stopPropagation(); + this.slideMove(evt); + }).bind(this); + + this.releaseListener = (function(evt) { + evt.preventDefault(); + evt.stopPropagation(); + this.slideReleased(evt); + this.removeDocumentListeners(); + }).bind(this); + } - this.setSliderRef = element => { - this.sliderElement = element; - }; + addDocumentListeners() { + document.addEventListener('mousemove', this.moveListener, true); + document.addEventListener('touchmove', this.moveListener, true); + document.addEventListener('mouseup', this.releaseListener, true); + document.addEventListener('touchstart', this.releaseListener, true); } - componentDidMount() { - // Slight delay because Firefox is too dang fast - setTimeout(() => { - // Set initial position - this.setState({ - handleOffset: Math.floor(this.props.initialValue / this.encodedStepCount * this.sliderElement.scrollWidth), - encodedValue: this.props.initialValue - }); - }, 100); + removeDocumentListeners() { + document.removeEventListener('mousemove', this.moveListener, true); + document.removeEventListener('touchmove', this.moveListener, true); + document.removeEventListener('mouseup', this.releaseListener, true); + document.removeEventListener('touchstart', this.releaseListener, true); } slideStart(e) { - if (e.nativeEvent.target.className === `handle`) { - this.setState({ - isHandleGrabbed: true - }); - } + this.setState({ + parentBBox: this.sliderElement.getBoundingClientRect(), + dragging: true + }); + // The "move" and "release" events have to be handled at + // the document level, because the events can be generated + // "nowhere near the React-managed DOM node". + this.addDocumentListeners(); + } + + slideReleased() { + this.setState({ + dragging: false + }); } slideMove(e) { - if (this.state.isHandleGrabbed) { - let clientX, sliderLeftEdgeX, offset; + if (this.state.dragging) { + let x = e.clientX, bbox = this.state.parentBBox, percentage, value; + + if (e.touches){ + x = e.touches[0].clientX; + } - if (e.nativeEvent.type === `touchmove`){ - clientX = e.nativeEvent.touches[0].pageX; - } else { - clientX = e.nativeEvent.clientX; + // cap the position: + if (x > bbox.right) { + x = bbox.right; + } else if (x < bbox.left) { + x = bbox.left; } - sliderLeftEdgeX = this.sliderElement.getBoundingClientRect().left; - offset = Math.floor(clientX - sliderLeftEdgeX); + + + // compute the handle offset + percentage = Math.round(100 * (x - bbox.left) / bbox.width); + value = percentage ? percentage : 1; this.setState({ - handleOffset: offset, - encodedValue: Math.floor(offset / this.sliderElement.scrollWidth * this.encodedStepCount) + percentage, + value + }, () => { + if (this.props.onChange) { + this.props.onChange(value); + } }); } } - slideStop() { - this.setState({ - isHandleGrabbed: false - }); - } - render() { - let handleX = this.state.handleOffset - this.handleWidth / 2; - let frameChoice = Math.floor(this.faceCount / 2); + let frameOffset = Math.round(this.state.percentage * (this.faceCount-1)/100); - // Don't let handle overflow slider's left side - handleX = handleX < 0 ? 0 : handleX; - - if (this.sliderElement) { - // Don't let handle overflow slider's right side - if (handleX > this.sliderElement.scrollWidth - this.handleWidth) { - handleX = this.sliderElement.scrollWidth - this.handleWidth; - } + let trackheadOpts = { + style: { + left: `${this.state.value}%` + }, + }; - frameChoice = Math.floor(handleX / this.sliderElement.scrollWidth * this.faceCount) + 1; - } + let faceOpts = { + style: { + background: `url("${this.framePath}sprite-resized-64-colors.png"), #f2b946`, + backgroundSize: `70px`, + backgroundPositionX: 0, + backgroundPositionY: `-${frameOffset * this.faceHeight}px`, + backgroundRepeat: `no-repeat`, + }, + }; - let pxOffset = this.handleWidth * this.faceCount - this.handleWidth * frameChoice; // offset position for spritesheet + let mouseOpts = { + onMouseDown: evt => this.slideStart(evt), + onTouchStart: evt => this.slideStart(evt), + }; return ( -
-
-
+
+
+
(this.sliderElement=e)}>
Not creepy
-
+
+
+
+
Super creepy
diff --git a/source/js/buyers-guide/components/creepometer/creepometer.scss b/source/js/buyers-guide/components/creepometer/creepometer.scss index 8aaa2742cd..67529679d1 100644 --- a/source/js/buyers-guide/components/creepometer/creepometer.scss +++ b/source/js/buyers-guide/components/creepometer/creepometer.scss @@ -1,4 +1,6 @@ .creepometer { + margin-top: 70px; + .slider { background: url("/_images/buyers-guide/gradient-bar.svg") no-repeat 0 center / contain; position: relative; @@ -24,13 +26,41 @@ right: 0; } - .handle { - border-radius: 50%; + .trackhead { cursor: grab; - height: 70px; - position: absolute; - user-select: none; - width: 70px; + position: relative; + display: inline-block; z-index: 1; + margin-left: -35px; + top: -55px; + user-select: none; + + .face { + position: relative; + border-radius: 50%; + height: 70px; + width: 70px; + z-index: 0; + } + + .pip { + position: relative; + width: 70px; + height: 20px; + background: transparent; + + &::after { + content: ' '; + background: white; + height: 12px; + width: 12px; + border-radius: 10px; + position: relative; + margin-left: 28px; + margin-top: 15px; + display: inline-block; + border: 1px solid grey; + } + } } } diff --git a/source/js/buyers-guide/components/likelyhood-chart/likelyhood-chart.jsx b/source/js/buyers-guide/components/likelyhood-chart/likelyhood-chart.jsx new file mode 100644 index 0000000000..d0085ef744 --- /dev/null +++ b/source/js/buyers-guide/components/likelyhood-chart/likelyhood-chart.jsx @@ -0,0 +1,42 @@ +// TODO: Inject likely % in .bar and .likelyhood-words + +import React from 'react'; + +export default class LikelyhoodChart extends React.Component { + constructor(props) { + super(props); + } + + render(){ + let values = this.props.values; + let total = values[0] + values[1]; + let perc = Math.round(100 * values[0]/total, 10); + + return ( +
+ + + + + + + + + + + +
+ Likely + + + {100 - perc}% Likely to buy it +
+ Not likely + + + {perc}% Not likely to buy it +
+
+ ); + } +} diff --git a/source/js/buyers-guide/components/likelyhood-chart/likelyhood-chart.scss b/source/js/buyers-guide/components/likelyhood-chart/likelyhood-chart.scss new file mode 100644 index 0000000000..ff9df9984c --- /dev/null +++ b/source/js/buyers-guide/components/likelyhood-chart/likelyhood-chart.scss @@ -0,0 +1,51 @@ +#likelyhood-score { + tbody { + width: 100%; + display: flex; + flex-direction: column; + height: 4.5em; + justify-content: space-between; + } + + .bar { + //For some nice animations later on, width set in JSX + transition: width 2.5s ease; + background-color: $light-gray; + position: absolute; + height: 25px; + } + + tr { + position: relative; + display: block; + } + + .likelyhood-words { + position: relative; + font-weight: bold; + font-variant: small-caps; + padding: 0 5px; + } + + .likely-label { + //Make sure screen readers can see the label + clip: rect(1px, 1px, 1px, 1px); + position: absolute; + } + + th::before { + content: ''; + display: block; + height: 1em; + margin: 0 0.5em; + width: 1em; + } + + .likely th::before { + background: url("/_images/buyers-guide/icon-thumb-up.svg") no-repeat; + } + + .unlikely th::before { + background: url("/_images/buyers-guide/icon-thumb-down.svg") no-repeat; + } +} diff --git a/source/sass/buyers-guide/bg-main.scss b/source/sass/buyers-guide/bg-main.scss index b88f3cc985..191c50e7d1 100644 --- a/source/sass/buyers-guide/bg-main.scss +++ b/source/sass/buyers-guide/bg-main.scss @@ -23,6 +23,8 @@ $bp-xl: #{map-get($grid-breakpoints, xl)}; // >= 1200px @import '../../js/buyers-guide/components/creepometer/creepometer'; @import '../../js/buyers-guide/components/creep-vote/creep-vote'; @import '../../js/buyers-guide/components/primary-nav/primary-nav'; +@import '../../js/buyers-guide/components/creepiness-chart/creepiness-chart'; +@import '../../js/buyers-guide/components/likelyhood-chart/likelyhood-chart'; // Non-React Components