diff --git a/hasjob/models/jobpost.py b/hasjob/models/jobpost.py index 07737de0f..e0e430b59 100644 --- a/hasjob/models/jobpost.py +++ b/hasjob/models/jobpost.py @@ -10,7 +10,6 @@ from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.dialects.postgresql import TSVECTOR import tldextract -from coaster.auth import current_auth from coaster.sqlalchemy import make_timestamp_columns, Query, JsonDict, StateManager from baseframe import cache, _, __ from baseframe.utils import is_public_email_domain @@ -426,7 +425,7 @@ def tag_content(self): Markup('
') + Markup(escape(self.headline)) + Markup('
'), Markup('
') + Markup(self.description) + Markup('
'), Markup('
') + Markup(self.perks) + Markup('
') - )) + )) @staticmethod def viewcounts_key(jobpost_id): diff --git a/hasjob/static/css/app.css b/hasjob/static/css/app.css index 91d65f572..a54688f54 100644 --- a/hasjob/static/css/app.css +++ b/hasjob/static/css/app.css @@ -933,9 +933,8 @@ tr > div { position: relative; vertical-align: top; display: block; - font-family: "McLaren", sans-serif; overflow: visible; - padding: 24px 18px; + padding: 24px 0 0; word-wrap: break-word; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; @@ -956,17 +955,11 @@ tr > div { .stickie label { font-weight: normal; } -.stickie .count { - font-size: 90%; - color: #816894; - display: block; -} .stickie .annotation { font-size: 75%; display: block; position: absolute; color: #816894; - font-family: "McLaren", sans-serif; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -981,19 +974,127 @@ tr > div { left: 0.5em; width: 75%; } -.stickie .bottom-right { - right: 0.5em; - bottom: 0.1em; - width: 70%; - text-align: right; +.stickie .headline, +.stickie .pay { + display: block; + padding: 0 18px; + position: static; } -.stickie .bottom-left { - left: 0.5em; - bottom: 0.1em; +.stickie .headline { + font-family: "McLaren", sans-serif; +} +.stickie .star { + position: static; + float: left; + width: 20%; + margin-left: 0.5em; } .stickie .new { color: #df5e0e; } +.stickie .company-name { + position: static; + float: right; + width: 70%; + text-align: right; + margin-right: 0.5em; +} +.stickie .count { + margin-top: 5px; + color: #816894; + display: flex; +} +.stickie .count-items { + flex: 1; + font-size: 9px; +} +.stickie .count-text { + display: inline-block; + width: calc(100% - 6px); +} +.stickie .count-items.impressions { + flex: 1.5; +} +.stickie .count-arrow { + float: right; +} +.stickie .count-text, +.stickie .count-arrow { + font-size: 10px; +} +@media (min-width: 480px) { + .stickie .count-text, + .stickie .count-arrow { + font-size: 14px; + } +} +@media (min-width: 768px) { + .stickie .count-text, + .stickie .count-arrow { + font-size: 7px; + } + .stickie .count-arrow { + margin-top: 2px; + } +} +@media (min-width: 1200px) { + .stickie .count-text, + .stickie .count-arrow { + font-size: 8px; + } + .stickie .count-arrow { + margin-top: 1px; + } +} +.stickie .count-background { + display: flex; + height: 7px; + margin-top: 4px; + border-radius: 0 0 2px 2px; + overflow: hidden; + padding: 0; + list-style: none; +} +.stickie .count-background .background { + flex: 1; + height: 100%; + margin: 0; + padding: 0; +} +.stickie .count-background .impressions.background { + flex: 1.5; + position: relative; + z-index: 4; +} +.stickie .count-background .viewed.background { + position: relative; + z-index: 3; +} +.stickie .count-background .opened.background { + position: relative; + z-index: 2; +} +.stickie .count-background .applied.background { + position: relative; + z-index: 1; +} +.stickie .count-background .background.arrow:before { + content: ""; + display: inline-block; + width: 7px; + height: 7px; + border-style: solid; + border-width: 2px 2px 0 0; + border-color: #ffffa2; + position: absolute; + top: 0; + right: -2px; + vertical-align: top; + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + -webkit-transform: rotate(45deg); + transform: rotate(45deg); +} .stickie .pinned { text-indent: -10000px; display: block; @@ -1047,6 +1148,11 @@ tr > div { left: 1.5em; right: 1.5em; } +.stickie.grouped.under .count-background { + position: absolute; + bottom: 0; + width: 100%; +} .no-touch li:hover > .stickie.grouped.under { top: 0px; left: 15px; @@ -1103,6 +1209,7 @@ tr > div { .stickie.special { background-color: #f6f6f6; + padding: 24px 18px; } .stickie.org { @@ -1283,6 +1390,7 @@ tr > div { text-align: center; width: auto; margin: 24px 0; + padding: 24px 18px; } #i40x { diff --git a/hasjob/static/js/app.js b/hasjob/static/js/app.js index ac5238a66..9bd33c50e 100644 --- a/hasjob/static/js/app.js +++ b/hasjob/static/js/app.js @@ -1,7 +1,7 @@ //window.Hasjob initialized in layout.html Hasjob.Util = { - updateGA: function(){ + updateGA: function() { /* Resets the path in the tracker object and updates GA with the current path. To be called after updating the URL with pushState or replaceState. @@ -12,6 +12,17 @@ Hasjob.Util = { window.ga('set', 'page', path); window.ga('send', 'pageview'); } + }, + createCustomEvent: function(eventName) { + // Raise a custom event + if (typeof(window.Event) === "function") { + var customEvent = new Event(eventName); + } else { + // 'Event' constructor is not supported by IE + var customEvent = document.createEvent('Event'); + customEvent.initEvent(eventName, true, true); + } + return customEvent; } }; @@ -73,7 +84,7 @@ window.Hasjob.JobPost = { window.Hasjob.StickieList = { init: function(){ - var stickielist = this; + window.dispatchEvent(Hasjob.Util.createCustomEvent('onStickiesInit')); }, loadmore: function(config){ var stickielist = this; @@ -94,6 +105,7 @@ window.Hasjob.StickieList = { $('ul#stickie-area').append(data.trim()); stickielist.loadmoreRactive.set('loading', false); stickielist.loadmoreRactive.set('error', false); + window.dispatchEvent(Hasjob.Util.createCustomEvent('onStickiesPagination')); }, error: function() { stickielist.loadmoreRactive.set('error', true); @@ -153,11 +165,97 @@ window.Hasjob.StickieList = { $('#main-content').html(data); window.Hasjob.Filters.refresh(); NProgress.done(); + window.dispatchEvent(Hasjob.Util.createCustomEvent('onStickiesRefresh')); } }); history.replaceState({reloadOnPop: true}, '', window.location.href); history.pushState({reloadOnPop: true}, '', searchUrl); window.Hasjob.Util.updateGA(); + }, + createGradientColourScale: function(funnelName, maxValue) { + /* + Creates a linear colour gradient with canvas of width equal to maxValue. The canvas indicates a scale from 0 to maxValue. + + Takes + 'funnelName' - conversion funnel's name. + 'maxValue' - max value of conversion funnel across job posts of last 30 days + */ + + var canvas = document.createElement("canvas"); + canvas.id = funnelName; + canvas.width = maxValue; + canvas.height = 10; + + var context = canvas.getContext('2d'); + context.rect(0, 0, canvas.width, canvas.height); + var grd = context.createLinearGradient(0, 0, canvas.width, canvas.height); + + grd.addColorStop(1, '#DF3499'); + grd.addColorStop(0.7, '#E05F26'); + grd.addColorStop(0.5, '#DF5C2A'); + grd.addColorStop(0.1, '#F1D564'); + grd.addColorStop(0, '#FFFFA2'); + + context.fillStyle = grd; + context.fill(); + //Store the canvas context and end colour of the conversion funnel, later to be used by window.Hasjob.Util.setFunnelColour for picking the colour for a conversion funnel value for a job post. + window.Hasjob.Config[funnelName] = {}; + window.Hasjob.Config[funnelName].canvasContext = context; + window.Hasjob.Config[funnelName].maxColour = '#DF3499'; + }, + setGradientColour: function(funnelName, value, elementId) { + /* + Picks the colour for the value from the colour gradient canvas based on a scale of 0 to maxValue. + + Takes 'funnelName', value, elementId' + 'funnelName' - conversion funnel's name. + 'value' - conversion funnel value for the job post + 'elementId' - id attribute of the element of which background colour is to be set + */ + + //rgba - RGBA values at a particular point in the canvas. + var rgba = window.Hasjob.Config[funnelName].canvasContext.getImageData(value, 1, 1, 1).data; + if (rgba[0] > 255 || rgba[1] > 255 || rgba[2] > 255) { + // rgb value is invalid, hence return white + colourHex ="#FFFFFF"; + } else if (rgba[0] == 0 && rgba[1] == 0 && rgba[2] == 0) { + // value greater than maxValue hence return the last colour of the gradient + colourHex = window.Hasjob.Config[funnelName].maxColour; + } else { + // Get the colour code in hex from RGB values returned by getImageData + colourHex = "#" + (("000000" + (rgba[0] << 16) | (rgba[1] << 8) | rgba[2]).toString(16)).slice(-6); + } + // Set the background colour of the element + var element = document.getElementById(elementId); + element.classList.add("funnel-color-set"); + element.style.backgroundColor = colourHex; + }, + renderGradientColour: function() { + $('.js-funnel').each(function() { + if(!$(this).hasClass("funnel-color-set")) { + Hasjob.StickieList.setGradientColour($(this).data('funnel-name'), $(this).data('funnel-value'), $(this).attr('id')); + } + }); + }, + createGradientColour: function() { + Hasjob.StickieList.createGradientColourScale('impressions', Hasjob.Config.MaxCounts.max_impressions); + Hasjob.StickieList.createGradientColourScale('views', Hasjob.Config.MaxCounts.max_views); + Hasjob.StickieList.createGradientColourScale('opens', Hasjob.Config.MaxCounts.max_opens); + Hasjob.StickieList.createGradientColourScale('applied', Hasjob.Config.MaxCounts.max_applied); + }, + initFunnelViz: function() { + window.addEventListener('onStickiesInit', function (e) { + Hasjob.StickieList.createGradientColour(); + Hasjob.StickieList.renderGradientColour(); + }, false); + + window.addEventListener('onStickiesRefresh', function (e) { + Hasjob.StickieList.renderGradientColour(); + }, false); + + window.addEventListener('onStickiesPagination', function (e) { + Hasjob.StickieList.renderGradientColour(); + }, false); } }; @@ -589,6 +687,9 @@ $(function() { window.Hasjob.Filters.init(); window.Hasjob.JobPost.handleStarClick(); window.Hasjob.JobPost.handleGroupClick(); + if (window.Hasjob.Config.MaxCounts) { + window.Hasjob.StickieList.initFunnelViz(); + } var getCurrencyVal = function() { return $("input[type='radio'][name='currency']:checked").val(); diff --git a/hasjob/static/sass/_40x.scss b/hasjob/static/sass/_40x.scss index 3ff162969..392eab270 100644 --- a/hasjob/static/sass/_40x.scss +++ b/hasjob/static/sass/_40x.scss @@ -8,6 +8,7 @@ text-align: center; width: auto; margin: 24px 0; + padding: 24px 18px; } #i40x { font-size: 150px; diff --git a/hasjob/static/sass/_stickie.sass b/hasjob/static/sass/_stickie.sass index e64837b95..9e2459e0a 100644 --- a/hasjob/static/sass/_stickie.sass +++ b/hasjob/static/sass/_stickie.sass @@ -15,9 +15,8 @@ position: relative vertical-align: top display: block - font-family: $font-stickie-headline overflow: visible // To make shadow visible - padding: 24px 18px + padding: 24px 0 0 word-wrap: break-word +box-sizing(border-box) +border-radius(2px) @@ -32,44 +31,140 @@ label font-weight: normal - .count - font-size: 90% - color: $color-stickie-location - display: block - .annotation font-size: 75% display: block position: absolute color: $color-stickie-location - font-family: $font-stickie-headline overflow: hidden white-space: nowrap text-overflow: ellipsis height: 1.5em .top-right - top: 0.1em - right: 0.5em + top: .1em + right: .5em .top-left - top: 0.1em - left: 0.5em + top: .1em + left: .5em width: 75% - .bottom-right - right: 0.5em - bottom: 0.1em - width: 70% - text-align: right + .headline, + .pay, + display: block + padding: 0 18px + position: static - .bottom-left - left: 0.5em - bottom: 0.1em + .headline + font-family: $font-stickie-headline + + .star + position: static + float: left + width: 20% + margin-left: 0.5em .new color: $color-stickie-new + .company-name + position: static + float: right + width: 70% + text-align: right + margin-right: 0.5em + + .count + margin-top: 5px + color: $color-stickie-location + display: flex + + .count-items + flex: 1 + font-size: 9px + + .count-text + display: inline-block + width: calc(100% - 6px) + + .count-items.impressions + flex: 1.5 + + .count-arrow + float: right + + .count-text, + .count-arrow + font-size: 10px + + @media (min-width: 480px) + .count-text, + .count-arrow + font-size: 14px + + @media (min-width: 768px) + .count-text, + .count-arrow + font-size: 7px + + .count-arrow + margin-top: 2px + + @media (min-width: 1200px) + .count-text, + .count-arrow + font-size: 8px + + .count-arrow + margin-top: 1px + + .count-background + display: flex + height: 7px + margin-top: 4px + border-radius: 0 0 2px 2px + overflow: hidden + padding: 0 + list-style: none + + .background + flex: 1 + height: 100% + margin: 0 + padding: 0 + + .impressions.background + flex: 1.5 + position: relative + z-index: 4 + + .viewed.background + position: relative + z-index: 3 + + .opened.background + position: relative + z-index: 2 + + .applied.background + position: relative + z-index: 1 + + .background.arrow:before + content: '' + display: inline-block + width: 7px + height: 7px + border-style: solid + border-width: 2px 2px 0 0 + border-color: $color-stickie + position: absolute + top: 0 + right: -2px + vertical-align: top + @include transform(rotate(45deg)) + .pinned text-indent: -10000px display: block @@ -106,6 +201,11 @@ left: 1.5em right: 1.5em + .count-background + position: absolute + bottom: 0 + width: 100% + // Extra stickies, beyond those specified below. // Keep in same position .no-touch li:hover > & @@ -162,6 +262,7 @@ .stickie.special background-color: $color-stickie-info + padding: 24px 18px .stickie.org background-color: #fff diff --git a/hasjob/templates/detail.html.jinja2 b/hasjob/templates/detail.html.jinja2 index 3955f869b..9ddb2c5e5 100644 --- a/hasjob/templates/detail.html.jinja2 +++ b/hasjob/templates/detail.html.jinja2 @@ -447,7 +447,16 @@ if (!triggered_related) { triggered_related = true; $.get("{{ post.url_for('related_posts') }}", function (data) { - $('#stickie-area').html(data); + $('#stickie-area').html(data.template); + {%- if can_see_post_stats %} + window.Hasjob.Config.MaxCounts = { + 'max_impressions': data.max_impressions, + 'max_views': data.max_views, + 'max_opens': data.max_opens, + 'max_applied': data.max_applied + } + {%- endif %} + window.Hasjob.StickieList.init(); } ); } diff --git a/hasjob/templates/index.html.jinja2 b/hasjob/templates/index.html.jinja2 index c6d762090..66a463d4f 100644 --- a/hasjob/templates/index.html.jinja2 +++ b/hasjob/templates/index.html.jinja2 @@ -171,6 +171,14 @@ {%- block footerscripts -%} {%- if not paginated -%} diff --git a/hasjob/templates/macros.html.jinja2 b/hasjob/templates/macros.html.jinja2 index 65ad2e12e..96f427ce4 100644 --- a/hasjob/templates/macros.html.jinja2 +++ b/hasjob/templates/macros.html.jinja2 @@ -9,29 +9,46 @@ {{ post.datetime|shortdate }} {% if is_bgroup %}{{ post.headlineb }}{% else %}{{ post.headline }}{% endif %} {%- if show_viewcounts or show_pay %} - {%- with post_viewcounts=get_post_viewcounts(post.id) %} - - {%- if show_viewcounts %} - - {{ post_viewcounts['impressions'] }} › {{ post_viewcounts['viewed'] }} › {{ post_viewcounts['opened'] }} › {{ post_viewcounts['applied'] }} - + {%- set post_viewcounts=get_post_viewcounts(post.id) %} + {%- endif %} + {%- if show_pay %} + {{ post_viewcounts['pay_label'] }} + {%- endif %} + + + {%- if starred is not none %} + + {%- endif %} + {%- if post.state.NEW %} + New! + {%- elif post.state.UNPUBLISHED %} + {{ post.state.label.title }} {%- endif %} - {%- if show_viewcounts and show_pay %} · {%- endif %} - {%- if show_pay %}{{ post_viewcounts['pay_label'] }}{%- endif %} - {%- endwith %} - {%- endif %} - {{ post.company_name }} - - {%- if starred is not none %} - - {%- endif %} - {%- if post.state.NEW %} - New! - {%- elif post.state.UNPUBLISHED %} - {{ post.state.label.title }} - {%- endif %} + {{ post.company_name }} + {%- if show_viewcounts %} + + + {{ post_viewcounts['impressions'] }} impressions + + + {{ post_viewcounts['viewed'] }} viewed + + + {{ post_viewcounts['opened'] }} opened + + + {{ post_viewcounts['applied'] }} applied + + +
    +
  1. +
  2. +
  3. +
  4. +
+ {%- endif -%} {%- if groupedunder %} {%- else %} diff --git a/hasjob/views/helper.py b/hasjob/views/helper.py index ff9802ee7..45e350f31 100644 --- a/hasjob/views/helper.py +++ b/hasjob/views/helper.py @@ -19,13 +19,14 @@ from .. import app, redis_store, lastuser from ..extapi import location_geodata -from ..models import (agelimit, newlimit, db, JobCategory, JobPost, JobType, POST_STATE, BoardJobPost, Tag, JobPostTag, +from ..models import (newlimit, db, JobCategory, JobPost, JobType, BoardJobPost, Tag, JobPostTag, Campaign, CampaignView, CampaignAnonView, EventSessionBase, EventSession, UserEventBase, UserEvent, JobImpression, - JobViewSession, AnonUser, campaign_event_session_table, JobLocation, PAY_TYPE) + JobViewSession, AnonUser, campaign_event_session_table, JobLocation, PAY_TYPE, UserJobView, JobApplication) from ..utils import scrubemail, redactemail, cointoss gif1x1 = 'R0lGODlhAQABAJAAAP8AAAAAACH5BAUQAAAALAAAAAABAAEAAAICBAEAOw=='.decode('base64') +MAX_COUNTS_KEY = u'maxcounts' @app.route('/_sniffle.gif') @@ -384,6 +385,24 @@ def get_post_viewcounts(jobpost_id): return values +def get_max_counts(postids): + values = g.maxcounts if 'maxcounts' in g else {} + if not values: + view_counts = [get_post_viewcounts(postid) for postid in postids] + values = { + 'max_impressions': max([vc['impressions'] for vc in view_counts] or [0]), + 'max_views': max([vc['viewed'] for vc in view_counts] or [0]), + 'max_opens': max([vc['opened'] for vc in view_counts] or [0]), + 'max_applied': max([vc['applied'] for vc in view_counts] or [0]) + } + redis_store.hset(MAX_COUNTS_KEY, 'max_impressions', values['max_impressions']) + redis_store.hset(MAX_COUNTS_KEY, 'max_views', values['max_views']) + redis_store.hset(MAX_COUNTS_KEY, 'max_opens', values['max_opens']) + redis_store.hset(MAX_COUNTS_KEY, 'max_applied', values['max_applied']) + redis_store.expire(MAX_COUNTS_KEY, 86400) + return values + + @app.context_processor def inject_post_viewcounts(): return {'get_post_viewcounts': get_post_viewcounts} @@ -391,11 +410,16 @@ def inject_post_viewcounts(): def load_viewcounts(posts): redis_pipe = redis_store.pipeline() - viewcounts_keys = JobPost.viewcounts_key([p.id for p in posts]) + postids = [p.id for p in posts] + viewcounts_keys = JobPost.viewcounts_key(postids) for key in viewcounts_keys: redis_pipe.hgetall(key) - viewcounts_values = redis_pipe.execute() + redis_pipe.hgetall(MAX_COUNTS_KEY) + values = redis_pipe.execute() + viewcounts_values = values[:-1] + maxcounts_values = values[-1] g.viewcounts = dict(zip(viewcounts_keys, viewcounts_values)) + g.maxcounts = maxcounts_values def getposts(basequery=None, pinned=False, showall=False, statusfilter=None, ageless=False, limit=2000, order=True): diff --git a/hasjob/views/index.py b/hasjob/views/index.py index 892f4cd10..b604e297b 100644 --- a/hasjob/views/index.py +++ b/hasjob/views/index.py @@ -13,7 +13,7 @@ from ..models import (db, JobCategory, JobPost, JobType, POST_STATE, newlimit, agelimit, JobLocation, Board, Filterset, Domain, Location, Tag, JobPostTag, Campaign, CAMPAIGN_POSITION, CURRENCY, JobApplication, starred_job_table, BoardJobPost) from ..views.helper import (getposts, getallposts, gettags, location_geodata, load_viewcounts, session_jobpost_ab, - bgroup, make_pay_graph, index_is_paginated, get_post_viewcounts) + bgroup, make_pay_graph, index_is_paginated, get_post_viewcounts, get_max_counts) from ..uploads import uploaded_logos from ..utils import string_to_number @@ -438,8 +438,17 @@ def index(basequery=None, filters={}, md5sum=None, tag=None, domain=None, locati if data['domain'] and data['domain'] not in db.session: data['domain'] = db.session.merge(data['domain']) data['show_viewcounts'] = show_viewcounts + + postids = [jobpost.id for jobpost in data['posts']] + max_counts = get_max_counts(postids) + data['max_impressions'] = max_counts['max_impressions'] + data['max_views'] = max_counts['max_views'] + data['max_opens'] = max_counts['max_opens'] + data['max_applied'] = max_counts['max_applied'] + if filterset: data['filterset'] = filterset + return data diff --git a/hasjob/views/listing.py b/hasjob/views/listing.py index 2bb671efb..82ca984ab 100644 --- a/hasjob/views/listing.py +++ b/hasjob/views/listing.py @@ -29,7 +29,7 @@ from hasjob.nlp import identify_language from hasjob.views.helper import ( gif1x1, load_viewcounts, session_jobpost_ab, bgroup, has_post_stats, - get_post_viewcounts) + get_post_viewcounts, get_max_counts) @app.route('//', methods=('GET', 'POST'), subdomain='') @@ -189,9 +189,17 @@ def job_related_posts(domain, hashid): if is_siteadmin or (g.user and g.user.flags.get('is_employer_month')): load_viewcounts(related_posts) g.impressions = {rp.id: (False, rp.id, bgroup(jobpost_ab, rp)) for rp in related_posts} - return render_template('related_posts.html.jinja2', post=post, - related_posts=related_posts, is_siteadmin=is_siteadmin) - + postids = [related_post.id for related_post in related_posts] + max_counts = get_max_counts(postids) + return jsonify(template=render_template('related_posts.html.jinja2', post=post, + related_posts=related_posts, + is_siteadmin=is_siteadmin + ), + max_impressions=max_counts['max_impressions'], + max_views=max_counts['max_views'], + max_opens=max_counts['max_opens'], + max_applied=max_counts['max_applied'] + ) @app.route('///star', defaults={'domain': None}, methods=['POST'], subdomain='') @app.route('///star', defaults={'domain': None}, methods=['POST'])