diff --git a/Envfile b/Envfile index fdde4b2fc..cac827daa 100644 --- a/Envfile +++ b/Envfile @@ -103,9 +103,6 @@ variable :GA_OPTIMIZE_ID, :String, default: "Optional" variable :GA_VIEW_ID, :String, default: "Optional" variable :GA_FETCH_RATE, :Integer, default: 25 -# Honeycomb -variable :HONEYCOMB_API_KEY, :String, default: "Optional" - # Mailchimp for mails variable :MAILCHIMP_API_KEY, :String, default: "Optional-valid" variable :MAILCHIMP_NEWSLETTER_ID, :String, default: "Optional" diff --git a/Gemfile b/Gemfile index 783a8d6cb..9a70330f0 100644 --- a/Gemfile +++ b/Gemfile @@ -12,8 +12,10 @@ group :production do gem 'nakayoshi_fork', '~> 0.0.4' # solves CoW friendly problem on MRI 2.2 and later end + gem "actionpack-action_caching", "~> 1.2" # Action caching for Action Pack (removed from core in Rails 4.0) gem "active_record_union", "~> 1.3" # Adds proper union and union_all methods to ActiveRecord::Relation +gem "activerecord-import", "~> 1.0" # Adds ability to bulk create activerecord objects gem "acts-as-taggable-on", "~> 6.0" # A tagging plugin for Rails applications that allows for custom tagging along dynamic contexts gem "acts_as_follower", github: "thepracticaldev/acts_as_follower", branch: "master" # Allow any model to follow any other model gem "addressable", "~> 2.5", ">= 2.5.2" # A replacement for the URI implementation that is part of Ruby's standard library diff --git a/Gemfile.lock b/Gemfile.lock index bbe017333..e78cdf54e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -59,6 +59,8 @@ GEM activemodel (= 5.2.3) activesupport (= 5.2.3) arel (>= 9.0) + activerecord-import (1.0.1) + activerecord (>= 3.2) activestorage (5.2.3) actionpack (= 5.2.3) activerecord (= 5.2.3) @@ -960,6 +962,7 @@ PLATFORMS DEPENDENCIES actionpack-action_caching (~> 1.2) active_record_union (~> 1.3) + activerecord-import (~> 1.0) acts-as-taggable-on (~> 6.0) acts_as_follower! addressable (~> 2.5, >= 2.5.2) diff --git a/app/assets/javascripts/initializers/initializeReadingListIcons.js b/app/assets/javascripts/initializers/initializeReadingListIcons.js index 65f25d3ee..02cc21ab5 100644 --- a/app/assets/javascripts/initializers/initializeReadingListIcons.js +++ b/app/assets/javascripts/initializers/initializeReadingListIcons.js @@ -33,7 +33,7 @@ function addReadingListCountToHomePage() { var readingListCount; if (user && document.getElementById('reading-list-count')) { readingListCount = - user.reading_list_ids.length > 0 ? user.reading_list_ids.length : 'EMPTY'; + user.reading_list_ids.length > 0 ? user.reading_list_ids.length : 'empty'; document.getElementById('reading-list-count').innerHTML = '(' + readingListCount + ')'; document.getElementById('reading-list-count').dataset.count = diff --git a/app/assets/javascripts/lib/pulltorefresh.js b/app/assets/javascripts/lib/pulltorefresh.js index f17d2a419..251a05488 100644 --- a/app/assets/javascripts/lib/pulltorefresh.js +++ b/app/assets/javascripts/lib/pulltorefresh.js @@ -32,7 +32,7 @@ resistanceFunction: function (t) { return Math.min(1, t / 2.5); }, shouldPullToRefresh: function () { return !window.scrollY && - (document.getElementById('articles-list') || document.getElementById("user-dashboard") || document.getElementById("article-body")) && + (document.getElementById('articles-list') || document.getElementById("user-dashboard") || document.getElementById("article-body") || document.getElementById('classifieds-index-container')) && !document.body.classList.contains('modal-open') }, }; diff --git a/app/assets/javascripts/serviceworker.js.erb b/app/assets/javascripts/serviceworker.js.erb index c37aa0a36..fe9568a04 100644 --- a/app/assets/javascripts/serviceworker.js.erb +++ b/app/assets/javascripts/serviceworker.js.erb @@ -1,4 +1,4 @@ -var CACHE_VERSION = 'v2.6.4'; +var CACHE_VERSION = 'v2.6.5'; var CACHE_NAME = CACHE_VERSION + ':sw-cache::'; var REQUESTS_LIMIT = 70; diff --git a/app/assets/stylesheets/articles.scss b/app/assets/stylesheets/articles.scss index dac0b39c6..b7c0622f8 100644 --- a/app/assets/stylesheets/articles.scss +++ b/app/assets/stylesheets/articles.scss @@ -904,10 +904,19 @@ margin-bottom: 20px; .sidebar-nav-block { padding: 0px; - width: calc(100% - 4px); + width: 100%; + box-sizing: border-box; + &.principle-sidebar-nav-block { + padding: 9px 0px; + .sidebar-nav-element { + .sidebar-nav-link { + padding: 5px 8px; + } + } + } @media screen and (min-width: 950px) { .sidebar-nav-block-inner { - height: 400px; + height: 420px; overflow-y: scroll; overflow-x: hidden; } @@ -917,7 +926,10 @@ font-size: 0.9em; font-family: $monospace; &.sidebar-nav-header-middle { - padding-top: 10px; + padding: 10px 10px; + text-decoration: underline; + color: $medium-gray; + color: var(--theme-secondary-color, $medium-gray); } img { width: 15px; @@ -946,48 +958,6 @@ display: block; // font-family: "Lucida Console", Monaco, monospace, sans-serif; position: relative; - &.sidebar-nav-readinglist { - font-family: $helvetica-condensed; - font-stretch: condensed; - font-size: 1.1em; - .sidebar-nav-link { - padding-top: 14px; - padding-bottom: 14px; - padding-left: 18px; - display: block; - &:hover { - background: $light-purple; - background: var(--theme-container-background, $light-purple); - } - } - &:hover { - background: white; - background: var(--theme-background, white); - } - .readinglist-sidebar-subcat { - font-weight: bold; - font-size: 0.78em; - font-family: $helvetica; - padding-bottom: 10px; - padding-left: 8px; - - a { - color: $medium-gray; - color: var(--theme-secondary-color, $medium-gray); - padding: 7px 12px; - display: inline-block; - &:hover { - background: darken($light-green, 5%); - color: $dark-gray; - } - } - .separator { - border-left: 2px solid $light-medium-gray; - padding: 4px 0px; - margin: 0px 4px; - } - } - } @media screen and (min-width: 950px) { .sidebar-nav-link-follow { display: none; @@ -1012,9 +982,10 @@ display: inline-block; width: calc(100% - 10px); font-weight: 500; - .sidebar-nav-tag-text { - border-radius: 3px; - padding: 3px 8px; + padding: 3px 8px; + &:hover { + background: $light-gray; + background: var(--theme-container-background-hover, $light-gray); } } a.sidebar-nav-link-follow { @@ -1028,10 +999,6 @@ font-size: 0.8em; padding: 2px 8px; } - &:hover { - background: $light-gray; - background: var(--theme-container-background-hover, $light-gray); - } } } } @@ -1285,6 +1252,14 @@ display: inline-block; text-align: center; } + .widget-listing-category { + color: $medium-gray; + } + .widget-listing-title { + font-family: $helvetica; + font-weight: 500; + margin-bottom: 8px; + } } .query-filter-button { font-size: 15px; diff --git a/app/assets/stylesheets/classified_listings.scss b/app/assets/stylesheets/classified_listings.scss new file mode 100644 index 000000000..d1918d4c3 --- /dev/null +++ b/app/assets/stylesheets/classified_listings.scss @@ -0,0 +1,255 @@ +@import 'variables'; + +.classifieds-container { + margin: 60px auto 150px; + text-align: left; + max-width: 96%; + min-height: 100vh; + @media screen and (min-width: 950px) { + margin: 40px auto; + } + .classified-filters { + padding: 20px 0px 10px; + a { + padding: 3px 16px; + border-radius: 100px; + display: inline-block; + font-size: 15px; + &.selected { + background: $bold-blue; + color: white; + } + &.new-listing-button { + color: $black; + background: $green; + margin: 0px 4px; + } + } + .classified-filters-categories { + border: 1px solid $light-medium-gray; + padding: 10px; + border-radius: 8px; + a { + display: block; + &.classified-create-link { + border: 2px solid darken($green, 14%); + color: darken($green, 19%); + padding: 2px 16px; + } + } + @media screen and (min-width: 550px) { + a { + display: inline-block; + margin: 6px 3px; + border: 1px solid $bold-blue; + min-width: 50px; + text-align: center; + } + } + } + .classified-filters-tags { + padding: 20px 0px; + position: relative; + input { + font-size: 0.8em; + color: $black; + color: var(--theme-color, $black); + background: $light-gray; + background: var(--theme-container-accent-background, $light-gray); + border-radius: 100px; + padding: 3px 14px; + border: 1px solid $light-medium-gray; + width: calc(100% - 26px); + margin-bottom: 4px; + @media screen and (min-width: 550px) { + width: 280px; + margin-right: 8px; + } + } + .classified-search-clear { + background: $light-gray; + border: 1px solid $light-medium-gray; + position: absolute; + left: calc(100% - 25px); + top: 24px; + border-radius: 8px; + @media screen and (min-width: 550px) { + left: 282px; + } + } + .classified-tag { + font-size: 0.7em; + color: $black; + border-radius: 100px; + display: inline-block; + background: $dark-gray; + margin-right: 5px; + a { + padding: 0px 8px; + } + &.clear-all { + background: $light-medium-gray; + a { color: $black } + } + span { + color: white; + padding: 3px 2px; + display: inline-block; + &.tag-name { + padding-right: 0px; + } + } + } + } + } + .classifieds-columns { + display: grid; + grid-gap: 1em; + grid-template-columns: repeat(auto-fill, minmax(300px,1fr)); + grid-auto-rows: 0; + .single-classified-listing { + width: 100%; + display: inline-block; + border: 1px solid $light-medium-gray; + margin: 0px auto; + box-sizing: border-box; + border-radius: 3px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.9em; + column-break-inside: avoid; + background: white; + background: var(--theme-container-background, white); + h3 { + margin: 0px; + padding: 9px 15px; + background: lighten($light-medium-gray, 8%); + background: var(--theme-container-accent-background, lighten($light-medium-gray, 8%)); + border-bottom: 1px solid $light-medium-gray; + font-size: 16px; + } + .single-classified-listing-body { + padding: 10px 15px; + font-size: 15px; + .classified-listing-contact-cta { + border: 2px solid $black; + color: $black; + padding: 8px 10px; + text-align: center; + display: block; + border-radius: 200px; + margin: 4px auto; + width: 80%; + } + } + .single-classified-listing-tags { + padding: 10px 15px; + a { + color: $dark-medium-gray; + color: var(--theme-secondary-color, $dark-medium-gray); + background: $light-medium-gray; + background: var(--theme-container-accent-background, $light-medium-gray); + display: inline-block; + margin: 2px; + padding: 2px 7px; + font-size: 0.77em; + border-radius: 3px; + font-weight: bold; + } + } + .single-classified-listing-author-info { + font-size: 0.69em; + color: $medium-gray; + color: var(--theme-secondary-color, $medium-gray); + padding: 0px 15px 4px; + a { + color: $medium-gray; + color: var(--theme-secondary-color, $medium-gray); + font-style: italic; + } + } + } + } + form { + margin: 100px auto 20px; + width: 90%; + max-width: 600px; + * { + box-sizing: border-box; + } + .listings-back-buton { + font-size: 0.8em; + margin: 5px 0px 10px; + display: block; + } + .classified-errors { + background: $red; + color: white; + padding: 20px; + border-radius: 3px; + } + label { + display: inline-block; + font-size: 0.7em; + margin: 12px 0px 3px; + border-radius: 3px; + padding: 6px 0px; + font-weight: bold; + } + input { + width: 100%; + font-size: 18px; + padding: 5px; + border-radius: 3px; + border: 1px solid $light-medium-gray; + background: white; + background: var(--theme-container-background, white); + color: $black; + color: var(--theme-color, $black); + &[type="checkbox"] { + width: 100px; + } + } + textarea { + width: 100%; + height: 300px; + padding: 5px; + font-size: 17px; + border-radius: 3px; + background: white; + background: var(--theme-container-background, white); + color: $black; + color: var(--theme-color, $black); + border: 1px solid $light-medium-gray; + } + select { + width: 100%; + font-size:20px; + } + details { + cursor: pointer; + font-size: 0.8em; + padding: 8px 0px; + color: $medium-gray; + color: var(--theme-secondary-color, $medium-gray); + } + input[type="submit"] { + border: 0px; + padding: 25px 0px; + border-radius: 3px; + background: $blue; + font-size: 1.5em; + color: white; + margin-top: 20px; + } + .listings-current-credits { + margin-top: 75px; + border-radius: 8px; + font-weight: bold; + } + + p { + font-size: 0.8em; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/credits.scss b/app/assets/stylesheets/credits.scss new file mode 100644 index 000000000..8eae46df6 --- /dev/null +++ b/app/assets/stylesheets/credits.scss @@ -0,0 +1,192 @@ +@import 'variables'; +.credits-container { + margin: 50px auto; + .notice { + background: $green; + padding: 30px 0px; + margin-top: 68px; + text-align: center; + } + .credits-banner { + background: $dark-purple; + padding: 60px; + margin-top: 60px; + a { + color: white; + border: 3px solid white; + padding: 15px 4px; + border-radius: 100px; + font-weight: bold; + display: block; + margin: 10px auto; + text-align: center; + font-size: 14px; + width: 320px; + } + } + + form#new_credit { + width: 500px; + max-width: 96%; + margin: 40px auto; + overflow: hidden; + } + + .InputElement { + background-color: white; + background-color: var(--theme-container-background, white); + color: $black; + color: var(--theme-color, $black); + } + + .StripeElement, input[type="number"], button, .existing-card { + box-sizing: border-box; + + height: 40px; + + padding: 10px 12px; + + border: 1px solid transparent; + border-radius: 4px; + background-color: white; + background-color: var(--theme-container-background, white); + color: $black; + color: var(--theme-color, $black); + + box-shadow: 0 1px 3px 0 #e6ebf1; + -webkit-transition: box-shadow 150ms ease; + transition: box-shadow 150ms ease; + margin-bottom: 20px; + } + + .StripeElement--focus { + box-shadow: 0 1px 3px 0 #cfd7df; + } + + .StripeElement--invalid { + border-color: #fa755a; + } + + .StripeElement--webkit-autofill { + background-color: #fefde5 !important; + } + + h1 { + text-align: center; + margin: 20px auto; + font-size: 2.5em; + border-radius: 3px; + padding: 30px 0px; + color: white; + } + + .credit-price { + box-shadow: 0 1px 3px 0 #e6ebf1; + border-radius: 3px; + width: 80%; + margin: 10px auto; + padding: 30px 0px; + border: 1px solid $light-gray; + font-weight: bold; + display: inline-block; + font-size: 15px; + cursor: pointer; + .credit-amount { + font-size: 33px; + color: $dark-purple; + margin-bottom: 15px; + } + @media screen and ( min-width:950px ){ + width: 290px; + } + } + + input[type="number"] { + width: calc(100% - 160px); + font-size: 18px; + display: inline-block; + } + + #calculated-price { + width: 140px; + padding-left: 10px; + color: $green; + font-size: 13px; + display: inline-block; + } + + input[type="radio"] { + vertical-align: 2px; + padding: 10px; + cursor: pointer; + } + + button { + width: 100%; + background: blue; + color: white; + font-size: 18px; + font-weight: bold; + } + button#add-new-card { + background-color: $light-gray; + background-color: var(--theme-container-background, $light-gray); + color: $black; + color: var(--theme-color, $black); + font-size: 15px; + } + + .existing-card { + margin-bottom: 5px; + font-size: 0.7em; + padding: 0px; + padding-left: 10px; + label { + cursor: pointer; + display: inline-block; + width: calc(100% - 50px); + padding: 10px 0px; + } + .credit-card-component { + background: $lightest-gray; + background: var(--theme-container-accent-background, $lightest-gray); + border-radius: 3px; + margin: 0px 5px; + display: inline-block; + font-weight: bold; + } + } + + label.main-form-label { + margin-bottom: 4px; + background: $light-gray; + background: var(--theme-container-accent-background, $light-gray); + border-radius: 3px; + padding: 2px 8px; + display: inline-block; + font-size: 0.7em; + font-weight: bold; + } + + .existing-credits { + background: $dark-purple; + color: white; + width: 600px; + margin: 100px auto; + padding: 30px; + border-radius: 3px; + text-align: center; + .existing-credits-status { + font-size: 1.8em; + font-weight: bold; + padding: 30px 0px 50px; + } + a { + color: white; + border: 3px solid white; + padding: 10px 30px; + border-radius: 100px; + font-weight: bold; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/fundamentals.scss b/app/assets/stylesheets/fundamentals.scss index 3b109b6ac..8b893e407 100644 --- a/app/assets/stylesheets/fundamentals.scss +++ b/app/assets/stylesheets/fundamentals.scss @@ -1,7 +1,3 @@ -#notice { - color: green; -} - #error_explanation { width: 100%; diff --git a/app/assets/stylesheets/giveaways.scss b/app/assets/stylesheets/giveaways.scss deleted file mode 100644 index 0db578378..000000000 --- a/app/assets/stylesheets/giveaways.scss +++ /dev/null @@ -1,130 +0,0 @@ -@import 'variables'; -#page-content.giveaways{ - #error_explanation{ - margin-bottom:50px; - } -} -.giveaway-header{ - font-family: $helvetica; - height: calc(22vw + 250px); - z-index:1; - position:relative; - margin-top:-50px; - text-align:center; - background: #000000 no-repeat center 0 fixed; - background-size:110%; - .logo{ - color:white; - font-weight:900; - padding-top: calc(7.5vw + 90px); - margin:0; - width: calc(18vw + 50px); - } - @media screen and ( min-width: 500px ){ - background-size:90%; - } - @media screen and ( min-width: 800px ){ - background-size:70%; - } - @media screen and ( min-width: 1100px ){ - background-size:60%; - } - @media screen and ( min-width: 1400px ){ - background-size:52%; - } -} - -.giveaway-container{ - font-family: $helvetica; - background:white; - padding:1vw 4vw; - width:760px; - max-width:calc(100% - 8vw); - margin:0 auto 100px; - z-index:2; - position:relative; - font-size:calc(0.4vw + 15px); - line-height:calc(0.7vw + 26px); - h1{ - text-align:center; - font-size:calc(3vw + 20px); - } - p{ - font-size:1.1em; - } - img{ - max-width:100%; - } - h2{ - margin:2vw 0px; - padding:0; - } - - .sign-up-link{ - font-size:calc(1.2vw + 18px); - margin:5px 0px; - display:inline-block; - .sign-up-text{ - background: $blue; - padding:14px 20px; - border-radius:5px; - color:white; - &.github-text{ - background: $black; - } - } - img{ - width:calc(1.88vw + 22px); - vertical-align:calc(-0.5vw - 4px); - } - &:hover{ - opacity:0.9; - } - } - .partner-logo{ - width:200px; - vertical-align:middle; - margin:20px; - } - form{ - - .field{ - label{ - padding:10px 0px; - display:inline-block; - font-size:0.95em; - } - input{ - width:calc(100% - 1vw - 6px); - padding:calc(0.3vw + 4px); - border:1px solid darken($light-medium-gray,5%); - font-size:1em; - border-radius:3px; - } - select{ - font-size:1em; - } - } - .actions{ - padding:1vw 0px; - input{ - background:$green; - color:white; - border:0px; - font-size:2em; - padding:10px 40px; - border-radius:5px; - cursor:pointer; - &:hover{ - opacity:0.9; - } - } - } - } -} - -.smarty-suggestion{ - font-size:0.88em; - padding:0.15em 0.2em; - font-family: $helvetica; -} diff --git a/app/assets/stylesheets/minimal.scss b/app/assets/stylesheets/minimal.scss index c2137dd22..d89eb3a04 100644 --- a/app/assets/stylesheets/minimal.scss +++ b/app/assets/stylesheets/minimal.scss @@ -30,6 +30,8 @@ @import 'sticky-nav'; @import 'sidebar-data'; @import 'video-collection'; +@import 'classified_listings'; +@import 'credits'; @import 'ltags/LiquidTags'; diff --git a/app/assets/stylesheets/variables.scss b/app/assets/stylesheets/variables.scss index 608dc58d7..aee72676e 100644 --- a/app/assets/stylesheets/variables.scss +++ b/app/assets/stylesheets/variables.scss @@ -30,7 +30,7 @@ $outline-color: darken($light-medium-gray, 2%); $shadow: 1px 2px 4px 0 rgba(0, 0, 0, 0.18); $light-shadow: 1px 2px 4px 0 rgba(0, 0, 0, 0.1); $dark-shadow: 1px 2px 4px 0 rgba(0, 0, 0, 0.25); -$bold-shadow: 3px 3px 0px darken($light-medium-gray, 13%); +$bold-shadow: 2px 2px 0px darken($light-medium-gray, 10%); $ease-in-cubic : cubic-bezier(0.55, 0.055, 0.675, 0.19); $ease-out-cubic : cubic-bezier(0.215, 0.61, 0.355, 1); diff --git a/app/assets/stylesheets/video-collection.scss b/app/assets/stylesheets/video-collection.scss index bf90ac3e0..d4ddb1102 100644 --- a/app/assets/stylesheets/video-collection.scss +++ b/app/assets/stylesheets/video-collection.scss @@ -3,15 +3,15 @@ .home.video-page-title { margin: 68px auto 10px; text-align: center; - background: $white; - background: var(--theme-container-background, #fff); + background: $lightest-gray; + background: var(--theme-container-background, $lightest-gray); header { font-size:calc(0.9vw + 10px); } } .video-collection { - background: $white; - background: var(--theme-container-background, #fff); + background: $lightest-gray; + background: var(--theme-container-background, $lightest-gray); display: flex; max-width: 100%; flex-direction: row; @@ -23,15 +23,28 @@ } .single-video-article { - border: solid 1px $medium-gray; + border: solid 1px $light-medium-gray; margin: 5px; - max-width: 350px; + width: 100%; padding-bottom: 8px; - @media screen and (max-width: 739px) { - min-width: 100%; + border-radius: 3px; + @media screen and (min-width: 550px) { + width: 47%; + } + @media screen and (min-width: 739px) { + width: 31%; } .video-image { position: relative; + padding-top: 56%; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + background: $black no-repeat center center; + -webkit-background-size: cover; + -moz-background-size: cover; + -o-background-size: cover; + background-size: cover; + } .video-timestamp { position: absolute; diff --git a/app/controllers/classified_listings_controller.rb b/app/controllers/classified_listings_controller.rb new file mode 100644 index 000000000..421213172 --- /dev/null +++ b/app/controllers/classified_listings_controller.rb @@ -0,0 +1,81 @@ +class ClassifiedListingsController < ApplicationController + before_action :set_classified_listing, only: [:show, :edit, :update] + before_action :set_cache_control_headers, only: %i[index] + after_action :verify_authorized, only: %i[edit update] + before_action :authenticate_user!, only: %i[edit update new] + + def index + if params[:category].blank? + @classified_listings = ClassifiedListing.where(published: true).order("bumped_at DESC").limit(12) + else + @classified_listings = [] + end + set_surrogate_key_header "classified-listings-#{params[:category]}" + end + + def new + @classified_listing = ClassifiedListing.new + @credits = current_user.credits.where(spent: false) + end + + def edit + authorize @classified_listing + @credits = current_user.credits.where(spent: false) + end + + def create + @classified_listing = ClassifiedListing.new(classified_listing_params) + @classified_listing.user_id = current_user.id + number_of_credits_needed = ClassifiedListing.cost_by_category(@classified_listing.category) + available_credits = current_user.credits.where(spent: false) + if available_credits.size >= number_of_credits_needed + @classified_listing.bumped_at = Time.current + @classified_listing.published = true + @classified_listing.organization_id = current_user.organization_id if @classified_listing.post_as_organization.to_i == 1 + if @classified_listing.save + CacheBuster.new.bust("/listings") + CacheBuster.new.bust("/listings?i=i") + available_credits.limit(number_of_credits_needed).update_all(spent: true) + redirect_to "/listings" + else + @credits = current_user.credits.where(spent: false) + render :new + end + else + redirect_to "/credits" + end + end + + def update + authorize @classified_listing + available_credits = current_user.credits.where(spent: false) + number_of_credits_needed = ClassifiedListing.cost_by_category(@classified_listing.category) # Bumping + if params[:classified_listing][:action] == "bump" + @classified_listing.bumped_at = Time.current + if available_credits.size >= number_of_credits_needed + @classified_listing.save + available_credits.limit(number_of_credits_needed).update_all(spent: true) + end + elsif params[:classified_listing][:action] == "unpublish" + @classified_listing.published = false + @classified_listing.save + @classified_listing.remove_from_index! + elsif params[:classified_listing][:body_markdown].present? && @classified_listing.bumped_at > 24.hours.ago + @classified_listing.body_markdown = params[:classified_listing][:body_markdown] + @classified_listing.save + end + redirect_to "/listings" + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_classified_listing + @classified_listing = ClassifiedListing.find(params[:id]) + end + + # Never trust parameters from the scary internet, only allow the white list through. + def classified_listing_params + accessible = %i[title body_markdown category tag_list contact_via_connect post_as_organization action] + params.require(:classified_listing).permit(accessible) + end +end diff --git a/app/controllers/credits_controller.rb b/app/controllers/credits_controller.rb new file mode 100644 index 000000000..d3186e16b --- /dev/null +++ b/app/controllers/credits_controller.rb @@ -0,0 +1,92 @@ +class CreditsController < ApplicationController + before_action :authenticate_user! + + def index + @credits = current_user.credits.where(spent: false) + @org_credits = current_user.organization.credits.where(spent: false) if current_user.org_admin + end + + def new + @credit = Credit.new + @purchaser = params[:purchaser] == "organization" ? current_user.organization : current_user + @customer = Stripe::Customer.retrieve(current_user.stripe_id_code) if current_user.stripe_id_code + end + + def create + @number_to_purchase = params[:credit][:number_to_purchase].to_i + make_payment + credit_objects = [] + @number_to_purchase.times do + if params[:user_type] == "organization" + raise unless current_user.org_admin + credit_objects << Credit.new(organization_id: current_user.organization_id, cost: cost_per_credit / 100.0) + else + credit_objects << Credit.new(user_id: current_user.id, cost: cost_per_credit / 100.0) + end + end + Credit.import credit_objects + redirect_to "/credits", notice: "#{@number_to_purchase} new credits purchased!" + end + + def make_payment + find_or_create_customer + find_or_create_card + update_user_stripe_info + create_charge + rescue Stripe::CardError => e + flash[:error] = e.message + redirect_to "/credits/new" + end + + def find_or_create_customer + @customer = if current_user.stripe_id_code + Stripe::Customer.retrieve(current_user.stripe_id_code) + else + Stripe::Customer.create({ + email: current_user.email + }) + end + end + + def find_or_create_card + if params[:stripe_token] + @card = Stripe::Customer.create_source( + @customer.id, + { + source: params[:stripe_token], + } + ) + else + @card = @customer.sources.retrieve(params[:selected_card]) + end + end + + def update_user_stripe_info + current_user.update_column(:stripe_id_code, @customer.id) if current_user.stripe_id_code.nil? + end + + def create_charge + @amount = generate_cost + Stripe::Charge.create({ + customer: @customer.id, + source: @card || @customer.default_source, + amount: @amount, + description: "Purchase of #{@number_to_purchase} credits.", + currency: "usd" + }) + end + + def generate_cost + @number_to_purchase * cost_per_credit + end + + def cost_per_credit + if @number_to_purchase < 50 + 1000 + elsif @number_to_purchase < 500 + 750 + else + 500 + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0f3471512..6ebc454da 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -30,6 +30,8 @@ module ApplicationHelper notifications reading_list_items html_variants + classified_listings + credits ].include?(controller_name) end diff --git a/app/javascript/listings/listings.jsx b/app/javascript/listings/listings.jsx new file mode 100644 index 000000000..3347481ee --- /dev/null +++ b/app/javascript/listings/listings.jsx @@ -0,0 +1,233 @@ +import { h, Component } from 'preact'; +import { SingleListing } from './singleListing'; + +export class Listings extends Component { + state = { + listings: [], + query: '', + tags: [], + index: null, + category: '', + allCategories: [], + initialFetch: true, + currentUserId: null, + }; + + componentWillMount() { + const params = this.getQueryParams(); + const t = this; + const algoliaId = document.querySelector("meta[name='algolia-public-id']") + .content; + const algoliaKey = document.querySelector("meta[name='algolia-public-key']") + .content; + const env = document.querySelector("meta[name='environment']").content; + const client = algoliasearch(algoliaId, algoliaKey); + const index = client.initIndex(`ClassifiedListing_${env}`); + const container = document.getElementById('classifieds-index-container') + const category = container.dataset.category || '' + const allCategories = JSON.parse(container.dataset.allcategories || []); + let tags = []; + if (params.t) { + tags = params.t.split(',') + } + const query = params.q || '' + let listings = []; + if (tags.length === 0 && query === '') { + listings = JSON.parse(container.dataset.listings) + } + t.setState({query, tags, index, category, allCategories, listings }); + t.listingSearch(params.q || '', tags, category); + t.setUser() + } + + addTag = (e, tag) => { + e.preventDefault(); + const { query, tags, category } = this.state; + const newTags = tags; + if (newTags.indexOf(tag) === -1) { + newTags.push(tag) + } + this.setState({tags: newTags}) + this.listingSearch(query, newTags, category) + window.scroll(0,0) + } + + removeTag = (e, tag) => { + e.preventDefault(); + const { query, tags, category } = this.state; + const newTags = tags; + const index = newTags.indexOf(tag); + if (newTags.indexOf(tag) > -1) { + newTags.splice(index, 1); + } + this.setState({tags: newTags}) + this.listingSearch(query, newTags, category) + } + + selectCategory = (e, cat) => { + e.preventDefault(); + const { query, tags } = this.state; + this.setState({category: cat}) + this.listingSearch(query, tags, cat) + } + + handleQuery = e => { + const { tags, category } = this.state; + this.setState({query: e.target.value}) + this.listingSearch(e.target.value, tags, category) + } + + clearQuery = () => { + const { tags, category } = this.state; + document.getElementById('listings-search').value = ''; + this.setState({query: ''}); + this.listingSearch('', tags, category); + } + + getQueryParams = () => { + let qs = document.location.search; + qs = qs.split('+').join(' '); + + const params = {}; + let tokens; + const re = /[?&]?([^=]+)=([^&]*)/g; + + // eslint-disable-next-line no-cond-assign + while (tokens = re.exec(qs)) { + params[decodeURIComponent(tokens[1])] = decodeURIComponent(tokens[2]); + } + + return params; + } + + setUser = () => { + const t = this; + setTimeout(function() { + if (window.currentUser && t.state.currentUserId === null) { + t.setState({currentUserId: window.currentUser.id }); + } + }, 300) + setTimeout(function() { + if (window.currentUser && t.state.currentUserId === null) { + t.setState({currentUserId: window.currentUser.id }); + } + }, 1000) + } + + triggerMasonry = () => { + resizeAllMasonryItems(); + setTimeout(function() { + resizeAllMasonryItems(); + }, 1) + setTimeout(function() { + resizeAllMasonryItems(); + }, 3) + } + + listingSearch(query, tags, category) { + const t = this; + const filterObject = {tagFilters: tags, hitsPerPage: 120,} + if (category.length > 0) { + filterObject.filters = `category:${category}` + } + t.state.index.search(query, filterObject) + .then(function searchDone(content) { + if (t.state.initialFetch && t.state.category === '') { + const fullListings = t.state.listings; + content.hits.forEach(function(listing) { + if (!t.state.listings.map(l => (l.id)).includes(listing.id)) { + fullListings.push(listing) + } + }); + t.setState({listings: fullListings, initialFetch: false}); + } else { + t.setState({listings: content.hits, initialFetch: false}); + } + + }); + let newLocation = '' + if (query.length > 0 && tags.length > 0) { + newLocation = `/listings/${category}?q=${query}&t=${tags}`; + } else if (query.length > 0){ + newLocation = `/listings/${category}?q=${query}`; + } else if (tags.length > 0) { + newLocation = `/listings/${category}?t=${tags}`; + } else if (category.length > 0) { + newLocation = `/listings/${category}`; + } else { + newLocation = '/listings' + } + window.history.replaceState(null, null, newLocation); + } + + render() { + const { listings, query, tags, category, allCategories, currentUserId } = this.state; + const allListings = listings.map(listing => ( + + )); + this.triggerMasonry() + const selectedTags = tags.map(tag => ( + + this.removeTag(e, tag)} data-no-instant>{tag} this.removeTag(e, tag)} data-no-instant>× + + )) + const categoryLinks = allCategories.map(cat => ( + this.selectCategory(e, cat.slug)} data-no-instant>{cat.name} + )) + const clearQueryButton = query.length > 0 ? : ''; + return ( +
+
+
+ this.selectCategory(e, '')} data-no-instant>all + {categoryLinks} + Create a Listing +
+
+ this.handleQuery(e)}/>{clearQueryButton}{selectedTags} +
+
+
+ {allListings} +
+
+) + + return
{allListings}
; + } +} + +function resizeMasonryItem(item){ + /* Get the grid object, its row-gap, and the size of its implicit rows */ + const grid = document.getElementsByClassName('classifieds-columns')[0]; + const rowGap = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-row-gap')); + const rowHeight = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-auto-rows')); + + const rowSpan = Math.ceil((item.querySelector('.listing-content').getBoundingClientRect().height+rowGap)/(rowHeight+rowGap)); + + /* Set the spanning as calculated above (S) */ + // eslint-disable-next-line no-param-reassign + item.style.gridRowEnd = `span ${ rowSpan}`; +} +function resizeAllMasonryItems(){ + // Get all item class objects in one list + const allItems = document.getElementsByClassName('single-classified-listing'); + + /* + * Loop through the above list and execute the spanning function to + * each list-item (i.e. each masonry item) + */ + // eslint-disable-next-line vars-on-top + for(var i=0;i { + const tagLinks = listing.tag_list.map(tag => ( + onAddTag(e, tag)} data-no-instant>{tag} + )); + const editButton = currentUserId === listing.user_id ? edit : ''; + return ( +
+
+

{listing.title}

+
+
{ tagLinks }
+
+ {listing.author.name} {editButton} +
+
+
+); +} + +SingleListing.propTypes = { + listing: PropTypes.object.isRequired, + onAddTag: PropTypes.func.isRequired, + currentUserId: PropTypes.number, +}; diff --git a/app/javascript/packs/listings.jsx b/app/javascript/packs/listings.jsx new file mode 100644 index 000000000..31ff5be47 --- /dev/null +++ b/app/javascript/packs/listings.jsx @@ -0,0 +1,15 @@ +import { h, render } from 'preact'; +import { Listings } from '../listings/listings'; + +function loadElement() { + const root = document.getElementById('classifieds-index-container'); + if (root) { + render(, root, root.firstElementChild); + } +} + +window.InstantClick.on('change', () => { + loadElement(); +}); + +loadElement(); diff --git a/app/models/article.rb b/app/models/article.rb index c262e5f07..a55ea6f6e 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -24,7 +24,7 @@ class Article < ApplicationRecord has_many :rating_votes has_many :page_views - validates :slug, presence: { if: :published? }, format: /\A[0-9a-z-]*\z/, + validates :slug, presence: { if: :published? }, format: /\A[0-9a-z-_]*\z/, uniqueness: { scope: :user_id } validates :title, presence: true, length: { maximum: 128 } diff --git a/app/models/classified_listing.rb b/app/models/classified_listing.rb new file mode 100644 index 000000000..a1b7c8378 --- /dev/null +++ b/app/models/classified_listing.rb @@ -0,0 +1,104 @@ +class ClassifiedListing < ApplicationRecord + + include AlgoliaSearch + + attr_accessor :post_as_organization, :action + + belongs_to :user, optional: true + belongs_to :organization, optional: true + + before_save :evaluate_markdown + before_validation :modify_inputs + acts_as_taggable_on :tags + + validates :title, presence: true, + length: { maximum: 128 } + validates :body_markdown, presence: true, + length: { maximum: 400 } + validate :restrict_markdown_input + validate :validate_tags + validate :validate_category + + algoliasearch per_environment: true do + attribute :title, :processed_html, :bumped_at, :tag_list, :category, :id, :user_id + attribute :author do + { username: author.username, + name: author.name, + profile_image_90: ProfileImage.new(author).get(90) } + end + tags do + [tag_list, + "user_#{user_id}", + "organization_#{organization_id}"] + end + attributesForFaceting [:category] + customRanking ["desc(bumped_at)"] + searchableAttributes %w[title processed_html tag_list] + end + + def self.cost_by_category(category = "education") + categories_available[category][:cost] + end + + def author + organization || user + end + + def self.select_options_for_categories + categories_available.keys.map do |key| + ["#{categories_available[key][:name]} (#{ActionController::Base.helpers.pluralize(categories_available[key][:cost], 'Credit')})", key] + end + end + + def self.categories_for_display + categories_available.keys.map do |key| + { slug: key, name: categories_available[key][:name] } + end + end + + def self.categories_available + { + "cfp" => { cost: 1, name: "Conference CFP", rules: "Currently open for proposals, with link to form" }, + "contractors" => { cost: 1, name: "Contractor for Hire", rules: "You are available for hire." }, + "collabs" => { cost: 1, name: "Contributors/Collaborators Wanted" }, + "education" => { cost: 1, name: "Education/Courses", rules: "Educational material and/or schools/bootcamps" }, + "jobs" => { cost: 10, name: "Job Listings", rules: "Companies offering employment right now." }, + "products" => { cost: 1, name: "Products/Tools", rules: "Must be availabel right now" }, + "events" => { cost: 1, name: "Upcoming Events", rules: "Live or online events with date included" }, + "misc" => { cost: 1, name: "Miscellaneous", rules: "Must not fit in any other category." } + } + end + + private + + def evaluate_markdown + parsed_markdown = MarkdownParser.new(body_markdown) + self.processed_html = parsed_markdown.finalize + end + + def modify_inputs + ActsAsTaggableOn::Taggable::Cache.included(ClassifiedListing) + ActsAsTaggableOn.default_parser = ActsAsTaggableOn::TagParser + self.category = category.to_s.downcase + end + + def restrict_markdown_input + if body_markdown.to_s.scan(/(?=\n)/).count > 12 + errors.add(:body_markdown, "has too many linebreaks. No no more than 12 allowed.") + end + if body_markdown.to_s.include?("![") + errors.add(:body_markdown, "is not allowed to include images.") + end + if body_markdown.to_s.include?("{% ") + errors.add(:body_markdown, "is not allowed to include liquid tags.") + end + end + + def validate_tags + errors.add(:tag_list, "exceed the maximum of 4 tags") if tag_list.length > 8 + end + + def validate_category + errors.add(:category, "not a valid category") unless ClassifiedListing.categories_available[category] + end +end diff --git a/app/models/credit.rb b/app/models/credit.rb new file mode 100644 index 000000000..89f204aae --- /dev/null +++ b/app/models/credit.rb @@ -0,0 +1,7 @@ +class Credit < ApplicationRecord + + attr_accessor :number_to_purchase + + belongs_to :user, optional: true + belongs_to :organization, optional: true +end diff --git a/app/models/organization.rb b/app/models/organization.rb index 0dea0e620..7a4b9a173 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -10,6 +10,7 @@ class Organization < ApplicationRecord has_many :collections has_many :display_ads has_many :notifications + has_many :credits validates :name, :summary, :url, :profile_image, presence: true validates :name, diff --git a/app/models/user.rb b/app/models/user.rb index b67c1e505..1cc8811ce 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -34,6 +34,7 @@ class User < ApplicationRecord has_many :rating_votes has_many :html_variants, dependent: :destroy has_many :page_views + has_many :credits has_many :mentor_relationships_as_mentee, class_name: "MentorRelationship", foreign_key: "mentee_id", inverse_of: :mentee has_many :mentor_relationships_as_mentor, diff --git a/app/policies/classified_listing_policy.rb b/app/policies/classified_listing_policy.rb new file mode 100644 index 000000000..defdb7247 --- /dev/null +++ b/app/policies/classified_listing_policy.rb @@ -0,0 +1,15 @@ +class ClassifiedListingPolicy < ApplicationPolicy + def edit? + user_is_author? + end + + def update? + user_is_author? + end + + private + + def user_is_author? + record.user_id == user.id + end +end diff --git a/app/views/articles/_algolia_search.html.erb b/app/views/articles/_algolia_search.html.erb index f0d74f949..60397a91e 100644 --- a/app/views/articles/_algolia_search.html.erb +++ b/app/views/articles/_algolia_search.html.erb @@ -1,4 +1,4 @@ - +
+
+
1+ credit
+ $10/per credit +
+
+
50+ credits
+ $7.50/per credit
+
+
+ 500+ credits +
+ $5/per credit
+
+ <%= form_for(@credit) do |f| %> + +
+ <%= f.number_field :number_to_purchase, id: "amount-input", + placeholder: "How many credits would you like?", autocomplete: "off", min: "0", max: "10000000", required: true %> +
+
+ <% if params[:purchaser] == "organization" %> +
+ +
+ <% end %> +
+ + <% if @customer %> +
+ <% @customer.sources.each do |source| %> +
+ + name="selected_card" value="<%= source.id %>" id="existing-card-<%= source.id %>" /> + +
+ <% end %> + +
+ <% else %> +
+ +
+ <% end %> + + +
+ + + <% if params[:purchaser] == "organization" %> +

*All org admins have access to credits owned by organization

+ <% end %> +

Email yo@dev.to if you need support

+ <% end %> +
+ \ No newline at end of file diff --git a/app/views/layouts/_styles.html.erb b/app/views/layouts/_styles.html.erb index 8ca5f7a5b..f2f7bd93a 100644 --- a/app/views/layouts/_styles.html.erb +++ b/app/views/layouts/_styles.html.erb @@ -72,6 +72,20 @@ <%= Rails.application.assets["leaderboard.css"].to_s.html_safe %> <%= Rails.application.assets["footer.css"].to_s.html_safe %> + <% elsif view_class.include? "classified_listings-" %> + + <% elsif view_class.include? "credits-" %> + <% elsif view_class.include? "videos-index" %>