Classified Listings (#2545)

* Initial classified listings work

* More work on tags

* Additional work finalizing stripe and getting the listings page along

* Finalize-ish listings 🤞
This commit is contained in:
Ben Halpern 2019-04-24 16:17:01 -04:00 committed by GitHub
parent 7d33645d6d
commit 78138532c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1768 additions and 234 deletions

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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 =

View file

@ -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')
},
};

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -1,7 +1,3 @@
#notice {
color: green;
}
#error_explanation {
width: 100%;

View file

@ -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;
}

View file

@ -30,6 +30,8 @@
@import 'sticky-nav';
@import 'sidebar-data';
@import 'video-collection';
@import 'classified_listings';
@import 'credits';
@import 'ltags/LiquidTags';

View file

@ -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);

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -30,6 +30,8 @@ module ApplicationHelper
notifications
reading_list_items
html_variants
classified_listings
credits
].include?(controller_name)
end

View file

@ -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 => (
<SingleListing
onAddTag={this.addTag}
listing={listing}
currentUserId={currentUserId}
/>
));
this.triggerMasonry()
const selectedTags = tags.map(tag => (
<span className="classified-tag">
<a href='/listings?tags=' className='tag-name' onClick={e => this.removeTag(e, tag)} data-no-instant><span>{tag}</span><span className='tag-close' onClick={e => this.removeTag(e, tag)} data-no-instant>×</span></a>
</span>
))
const categoryLinks = allCategories.map(cat => (
<a href={`/listings/${cat.slug}`} className={cat.slug === category ? 'selected' : ''} onClick={e => this.selectCategory(e, cat.slug)} data-no-instant>{cat.name}</a>
))
const clearQueryButton = query.length > 0 ? <button type="button" className='classified-search-clear' onClick={this.clearQuery}>×</button> : '';
return (
<div>
<div className="classified-filters">
<div className="classified-filters-categories">
<a href="/listings" className={category === '' ? 'selected' : ''} onClick={e => this.selectCategory(e, '')} data-no-instant>all</a>
{categoryLinks}
<a href='/listings/new' className='classified-create-link'>Create a Listing</a>
</div>
<div className="classified-filters-tags" id="classified-filters-tags">
<input type="text" placeholder="search" id="listings-search" autoComplete="off" defaultValue={query} onKeyUp={e => this.handleQuery(e)}/>{clearQueryButton}{selectedTags}
</div>
</div>
<div class="classifieds-columns" id="classified-listings-results">
{allListings}
</div>
</div>
)
return <div className="github-repos">{allListings}</div>;
}
}
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<allItems.length;i++){
resizeMasonryItem(allItems[i]);
}
}
Listings.displayName = 'Classified Listings';

View file

@ -0,0 +1,27 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
export const SingleListing = ({listing, onAddTag, currentUserId}) => {
const tagLinks = listing.tag_list.map(tag => (
<a href={`/listings?t=${tag}`} onClick={e => onAddTag(e, tag)} data-no-instant>{tag}</a>
));
const editButton = currentUserId === listing.user_id ? <a href={`/listings/${listing.id}/edit`}>edit</a> : '';
return (
<div className="single-classified-listing">
<div className="listing-content">
<h3>{listing.title}</h3>
<div className="single-classified-listing-body" dangerouslySetInnerHTML={{ __html: listing.processed_html }} />
<div className="single-classified-listing-tags">{ tagLinks }</div>
<div className="single-classified-listing-author-info">
<a href={`/${listing.author.username}`}>{listing.author.name}</a> {editButton}
</div>
</div>
</div>
);
}
SingleListing.propTypes = {
listing: PropTypes.object.isRequired,
onAddTag: PropTypes.func.isRequired,
currentUserId: PropTypes.number,
};

View file

@ -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(<Listings />, root, root.firstElementChild);
}
}
window.InstantClick.on('change', () => {
loadElement();
});
loadElement();

View file

@ -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 }

View file

@ -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

7
app/models/credit.rb Normal file
View file

@ -0,0 +1,7 @@
class Credit < ApplicationRecord
attr_accessor :number_to_purchase
belongs_to :user, optional: true
belongs_to :organization, optional: true
end

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -1,4 +1,4 @@
<script async>
<script defer>
function getQueryParams(qs) {
qs = qs.split('+').join(' ');

View file

@ -13,21 +13,28 @@
</a>
</div>
<% end %>
<% if user_signed_in? %>
<div class="widget sidebar-nav-block">
<div class="sidebar-nav-element sidebar-nav-readinglist">
<div class="widget sidebar-nav-block sidebar-tag-block">
<% if user_signed_in? %>
<div class="sidebar-nav-header sidebar-nav-header-middle">
navigation
</div>
<div class="sidebar-nav-element">
<a class="sidebar-nav-link" href="/readinglist">
<img alt="bookmarks" src="<%= asset_path("readinglist-button.png") %>" /> MY READING LIST
saved posts
<span id="reading-list-count"></span>
</a>
<div class="readinglist-sidebar-subcat">
<a href="/readinglist">Saved Posts</a><span class="separator"></span><a href="/readinglist?v=comments">Comment
Activity</a>
</div>
<a class="sidebar-nav-link" href="/videos">
videos
<span id="reading-list-count"></span>
</a>
<% if 1==2 #not for display yet %>
<a class="sidebar-nav-link" href="/listings">
listings
<span id="reading-list-count"></span>
</a>
<% end %>
</div>
</div>
<% end %>
<div class="widget sidebar-nav-block sidebar-tag-block">
<% end %>
<div class="sidebar-nav-header sidebar-nav-header-middle">
<% if user_signed_in? %>
my tags

View file

@ -2,7 +2,7 @@
<style>
.primary-sticky-nav-author, .primary-sticky-nav-author-element {
border: 1px solid <%= HexComparer.new([user_colors(@actor)[:bg], user_colors(@actor)[:text]]).brightness(0.88) %> !important;
box-shadow: 3px 4px 0px <%= HexComparer.new([user_colors(@actor)[:bg], user_colors(@actor)[:text]]).brightness(0.88) %> !important;
box-shadow: 2px 2px 0px <%= HexComparer.new([user_colors(@actor)[:bg], user_colors(@actor)[:text]]).brightness(0.88) %> !important;
}
.primary-sticky-nav-author button {

View file

@ -0,0 +1,64 @@
<%= form_with(model: classified_listing, local: true) do |form| %>
<a href="/listings" class="listings-back-buton">< go back to listings</a>
<% if classified_listing.errors.any? %>
<div class="classified-errors">
<h2><%= pluralize(classified_listing.errors.count, "error") %> prohibited this classified_listing from being saved:</h2>
<ul>
<% classified_listing.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label "title", "Title" %>
<%= form.text_field "title", maxlength: 128, placeholder: "128 characters max, plain text" %>
</div>
<div class="field">
<%= form.label "body_markdown", "Body Markdown" %>
<%= form.text_area "body_markdown", maxlength: 400, placeholder: "400 characters max, 12 line break max, no images allowed" %>
</div>
<div class="field">
<%= form.label "category" %>
<%= form.select :category, options_for_select(ClassifiedListing.select_options_for_categories, ClassifiedListing.select_options_for_categories.first) %>
<details>
<summary>
Category details/rules
</summary>
<% ClassifiedListing.categories_available.keys.each do |key| %>
<p>
<%= ClassifiedListing.categories_available[key][:name] %>: <%= ClassifiedListing.categories_available[key][:rules] %>
</p>
<% end %>
</details>
</div>
<div class="field">
<%= form.label "tag_list", "Tags" %>
<%= form.text_field "tag_list", placeholder: "8 tags max, comma separated, no spaces or special characters" %>
</div>
<% if false # Put connect button (future feature) %>
<div class="field">
<%= form.label "contact_via_connect" %>
<%= form.check_box "contact_via_connect" %>
</div>
<% end %>
<% if current_user.organization && current_user.org_admin %>
<div class="field">
<%= form.label "post_as_organization", "Post as #{current_user.organization.name} organization" %>
<%= form.check_box "post_as_organization" %>
</div>
<p><em>Posting on behalf of org spends org credits.</em></p>
<% end %>
<div class="actions">
<%= form.submit "CREATE LISTING", class: "cta" %>
</div>
<div class="listings-current-credits">
You have <%= @credits.size %> credits available — <a href="/credits/new" data-no-instant>Buy More</a>
<% if current_user.organization && current_user.org_admin %>
<br/><br/><%= current_user.organization.name %> has <%= @credits.size %> credits available — <a href="/credits/new?purchaser=organization" data-no-instant>Buy More</a>
<% end %>
</div>
<p><em><%= ApplicationConfig["COMMUNITY_NAME"] %> admins may take down and refund listings which are miscategorized or otherwise inappropriate.</em></p>
<p><em>Email <a href="mailto:yo@dev.to">yo@dev.to</a> if you need support.</em></p>
<% end %>

View file

@ -0,0 +1,26 @@
<div class="classifieds-container">
<%= form_for(@classified_listing) do |f| %>
<p>
You can bump your listing for the same price as the original listing
</p>
<h2>
Bumped at: <%= @classified_listing.bumped_at %>
</h2>
<input type="hidden" name="classified_listing[action]" value="bump" />
<%= f.submit "Bump Listing" %>
<% end %>
<%= form_for(@classified_listing) do |f| %>
<h2>
Published: <%= @classified_listing.published %>
</h2>
<input type="hidden" name="classified_listing[action]" value="unpublish" />
<%= f.submit "Unpublish Listing" %>
<% end %>
<%= form_for(@classified_listing) do |f| %>
<h2>
Markdown: You can only modify within the first 24 hours of listing/bumping
</h2>
<%= f.text_area :body_markdown%>
<%= f.submit "Update Body Markdown" %>
<% end %>
</div>

View file

@ -0,0 +1,40 @@
<%= content_for :page_meta do %>
<% title "Listings" %>
<link rel="canonical" href="https://dev.to<%= request.path %>" />
<meta name="description" content="Where programmers share ideas and help each other grow.">
<meta name="keywords" content="software development,engineering,rails,javascript,ruby">
<meta property="og:type" content="website" />
<meta property="og:url" content="https://dev.to<%= request.path %>" />
<meta property="og:title" content="Listings" />
<meta property="og:image" content="https://thepracticaldev.s3.amazonaws.com/i/g355ol6qsrg0j2mhngz9.png">
<meta property="og:description" content="Where programmers share ideas and help each other grow." />
<meta property="og:site_name" content="<%= community_qualified_name %>" />
<meta name="twitter:site" content="@ThePracticalDev">
<meta name="twitter:title" content="Listings">
<meta name="twitter:description" content="Where programmers share ideas, experiences, and help each other grow.">
<meta name="twitter:image:src" content="https://thepracticaldev.s3.amazonaws.com/i/g355ol6qsrg0j2mhngz9.png">
<meta name="twitter:card" content="summary_large_image">
<%= auto_discovery_link_tag(:rss, "https://dev.to/feed", title: "#{ApplicationConfig['COMMUNITY_NAME']} RSS Feed") %>
<% end %>
<div class="home">
<div class="classifieds-container" id="classifieds-index-container"
data-category="<%= params[:category] %>" data-listings="<%= @classified_listings.to_json(
only: %i[title processed_html tag_list category id user_id],
include: {
author: { only: %i[username name], methods: [:profile_image_90] },
},
) %>"
data-allcategories="<%= ClassifiedListing.categories_for_display.to_json %>"
>
<div class="classified-filters">
<div class="classified-filters-categories">
<a href="/listings" class="<%= "selected" if params[:category].blank? %> data-no-instant="true">all</a><% ClassifiedListing.categories_for_display.each do |cat| %><a href="/listings/<%= cat[:slug] %>" class="<%= "selected" if params[:category] == cat[:slug] %> data-no-instant="true"><%= cat[:name] %></a><% end %><a href='/listings/new' class='classified-create-link'>Create a Listing</a>
</div>
</div>
</div>
<%= javascript_pack_tag "listings", defer: true %>

View file

@ -0,0 +1,4 @@
<div class="classifieds-container">
<%= render 'form', classified_listing: @classified_listing %>
</div>

View file

@ -0,0 +1,4 @@
<p id="notice"><%= notice %></p>
<%= link_to 'Edit', edit_classified_listing_path(@classified_listing) %> |
<%= link_to 'Back', classified_listings_path %>

View file

@ -0,0 +1,23 @@
<div class="credits-container">
<% if flash[:notice].present? %>
<div class="notice" id="notice">
<%= flash[:notice] %>
</div>
<% end %>
<div class="existing-credits">
<div class="existing-credits-status">You have <%= @credits.size %> credits to spend</div>
<a href="/credits/new" data-no-instant>Purchase additional credits</a>
</div>
<% if current_user.organization %>
<div class="existing-credits">
<div class="existing-credits-status"><%= current_user.organization.name %> has <%= @org_credits.size %> credits to spend</div>
<a href="/credits/new?purchaser=organization" data-no-instant>Purchase additional credits</a>
</div>
<% end %>
<% if @credits.size > 0 %>
<center>
<a href="/listings/new">Create a Listing</a>
</center>
<% end %>
</div>

View file

@ -0,0 +1,218 @@
<div class="credits-container">
<div class="credits-banner">
<h1>Purchase credits for @<%= @purchaser.username %></h1>
<% if current_user.org_admin %>
<% if @purchaser.username == current_user.username %>
<a href="/credits/new?purchaser=organization">Switch to <%= current_user.organization.name %></a>
<% else %>
<a href="/credits/new">Switch to personal purchase</a>
<% end %>
<% end %>
</div>
<script src="https://js.stripe.com/v3/"></script>
<div style="margin-top: 30px; text-align: center">
<div class="credit-price" data-num="1">
<div class="credit-amount" data-num="1">1+ credit</div>
$10/per credit
</div>
<div class="credit-price" data-num="50">
<div class="credit-amount" data-num="50">50+ credits</div>
$7.50/per credit</div>
<div class="credit-price" data-num="500">
<div class="credit-amount" data-num="500">
500+ credits
</div>
$5/per credit</div>
</div>
<%= form_for(@credit) do |f| %>
<label for="amount-input" class="main-form-label">
Amount to Purchase
</label>
<div class="amount-wrapper">
<%= f.number_field :number_to_purchase, id: "amount-input",
placeholder: "How many credits would you like?", autocomplete: "off", min: "0", max: "10000000", required: true %>
<div id="calculated-price"></div>
</div>
<% if params[:purchaser] == "organization" %>
<div>
<input type="hidden" name="user_type" value="organization">
</div>
<% end %>
<div class="form-row">
<label for="card-element" class="main-form-label">
Payment Method
</label>
<% if @customer %>
<div id="cards-wrapper">
<% @customer.sources.each do |source| %>
<div class="existing-card">
<input type="radio" <%= "checked" if source.id == @customer.default_source %>
name="selected_card" value="<%= source.id %>" id="existing-card-<%= source.id %>" />
<label for="existing-card-<%= source.id %>">
<span class="credit-card-component credit-card-component--bold"><%= source.brand %></span>
<span class="credit-card-component">**** **** **** <%= source.last4 %></span>
<span class="credit-card-component"><%= source.exp_month %>/<%= source.exp_year.to_s.last(2) %></span>
</label>
</div>
<% end %>
<button id="add-new-card">Add New Card</button>
</div>
<% else %>
<div id="card-element">
<!-- A Stripe Element will be inserted here. -->
</div>
<% end %>
<!-- Used to display form errors. -->
<div id="card-errors" role="alert"></div>
</div>
<button id="add-credit-card-button" class="credit-card-button">Complete Purchase</button>
<% if params[:purchaser] == "organization" %>
<p><em>*All org admins have access to credits owned by organization</em></p>
<% end %>
<p><em>Email <a href="mailto:yo@dev.to">yo@dev.to</a> if you need support</em></p>
<% end %>
</div>
<script>
var stripe = Stripe('<%= ApplicationConfig["STRIPE_PUBLISHABLE_KEY"] %>');
var elements = stripe.elements();
<% if current_user.config_theme == "night_theme" %>
var style = {
base: {
color: 'white',
fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
fontSmoothing: 'antialiased',
fontSize: '16px',
'::placeholder': {
color: '#d9dee2'
}
},
invalid: {
color: '#fa755a',
iconColor: '#fa755a'
}
};
<% else %>
var style = {
base: {
color: '#32325d',
fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
fontSmoothing: 'antialiased',
fontSize: '16px',
'::placeholder': {
color: '#aab7c4'
}
},
invalid: {
color: '#fa755a',
iconColor: '#fa755a'
}
};
<% end %>
var card = elements.create('card', {style: style});
createCardElement();
handleNewCardClick();
handleCreditPriceClick();
listenForNumberChange();
function createCardElement() {
if (document.getElementById("card-element")) {
card.mount('#card-element');
card.addEventListener('change', function(event) {
var displayError = document.getElementById('card-errors');
if (event.error) {
displayError.textContent = event.error.message;
} else {
displayError.textContent = '';
}
});
var form = document.getElementById('new_credit');
form.addEventListener('submit', function(event) {
event.preventDefault();
stripe.createToken(card).then(function(result) {
if (result.error) {
// Inform the user if there was an error.
var errorElement = document.getElementById('card-errors');
errorElement.textContent = result.error.message;
} else {
// Send the token to your server.
stripeTokenHandler(result.token);
}
});
});
}
}
function handleNewCardClick() {
if (!document.getElementById('add-new-card')) {
return
}
document.getElementById("add-new-card").onclick = function(e) {
e.preventDefault();
document.getElementById("cards-wrapper").innerHTML = '<div id="card-element"></div>';
createCardElement();
}
}
// Handle form submission.
// Submit the form with the token ID.
function stripeTokenHandler(token) {
// Insert the token ID into the form so it gets submitted to the server
var form = document.getElementById('new_credit');
var hiddenInput = document.createElement('input');
hiddenInput.setAttribute('type', 'hidden');
hiddenInput.setAttribute('name', 'stripe_token');
hiddenInput.setAttribute('value', token.id);
form.appendChild(hiddenInput);
// Submit the form
form.submit();
}
function getAmount(multiple) {
var value = document.getElementById('amount-input').value
var pricePer = 0;
var saleValue = 0;
if (value < 50) {
pricePer = 10;
} else if (value < 500) {
pricePer = 7.50
} else {
pricePer = 5;
}
saleValue = value * pricePer * multiple
return {value: saleValue, pricePer: pricePer };
}
function handleCreditPriceClick() {
var els = document.getElementsByClassName('credit-price')
for(i = 0; i < els.length; i++) {
els[i].onclick = function(e) {
document.getElementById("amount-input").value = e.target.dataset.num;
calculatePriceAndShow();
}
}
}
function listenForNumberChange() {
document.getElementById("amount-input").addEventListener("keydown",function (e) {
if ((e.keyCode < 48 || e.keyCode > 57) && e.keyCode != 8) {
e.preventDefault();
}
calculatePriceAndShow();
});
}
function calculatePriceAndShow() {
setTimeout(function(){
document.getElementById("calculated-price").innerHTML = '$'+getAmount(1.0)['value'] + ' ($'+getAmount(1.0)['pricePer']+'/each)';
}, 100)
}
</script>

View file

@ -72,6 +72,20 @@
<%= Rails.application.assets["leaderboard.css"].to_s.html_safe %>
<%= Rails.application.assets["footer.css"].to_s.html_safe %>
</style>
<% elsif view_class.include? "classified_listings-" %>
<style>
<%= Rails.application.assets["scaffolds.css"].to_s.html_safe %>
<%= Rails.application.assets["top-bar.css"].to_s.html_safe %>
<%= Rails.application.assets["classified_listings.css"].to_s.html_safe %>
<%= Rails.application.assets["footer.css"].to_s.html_safe %>
</style>
<% elsif view_class.include? "credits-" %>
<style>
<%= Rails.application.assets["scaffolds.css"].to_s.html_safe %>
<%= Rails.application.assets["top-bar.css"].to_s.html_safe %>
<%= Rails.application.assets["credits.css"].to_s.html_safe %>
<%= Rails.application.assets["footer.css"].to_s.html_safe %>
</style>
<% elsif view_class.include? "videos-index" %>
<style>
<%= Rails.application.assets["scaffolds.css"].to_s.html_safe %>

View file

@ -8,7 +8,7 @@
<meta property="og:type" content="article" />
<meta property="og:url" content="https://dev.to/readinglist" />
<meta property="og:title" content="Reading List - dev.to" />
<meta property="og:description" content="My Reading List on dev.to" />
<meta property="og:description" content="My Saved Posts on dev.to" />
<meta property="og:site_name" content="The Practical Dev" />
<meta name="twitter:card" content="summary_large_image">
@ -19,7 +19,7 @@
<div class="user-profile-header tag-header readinglist-header">
<div class="tag-or-query-header-container">
<h1 class="notifications-header">My Reading List</h1>
<h1 class="notifications-header">My Saved Posts</h1>
</div>
</div>
@ -30,7 +30,7 @@
<div class="widget sidebar-nav-block">
<div class="sidebar-nav-element sidebar-nav-readinglist">
<a class="sidebar-nav-link" href="/readinglist">
<img src="<%= asset_path("readinglist-button.png") %>" /> MY READING LIST
<img src="<%= asset_path("readinglist-button.png") %>" /> My Saved Posts
<span id="reading-list-count"></span>
</a>
</div>

View file

@ -1,17 +1,5 @@
<!-- cached at: <%= Time.current %> -->
<h3>Billing Details</h3>
<% if current_user.monthly_dues == 0 %>
<div class="default-billing-message">
You will be billed monthly based on your use of
<a href="/membership">premium features. 🔒</a>
</div>
<% else %>
<div class="default-billing-message">
You will be billed monthly for your dev.to sustaining membership.
To make adjustments, visit your
<a href="/settings/membership">membership settings</a>.
</div>
<% end %>
<% if current_user.stripe_id_code.blank? %>
<%= form_tag stripe_subscriptions_path, id: "credit-card-form" do %>
<article>

View file

@ -26,8 +26,7 @@
<div class="video-collection" id="video-collection">
<% @video_articles.each do |video_article| %>
<a class="single-video-article single-article" href="<%= video_article.path %>" id="video-article-<%= video_article.id %>">
<div class="video-image">
<img src="<%= video_article.cloudinary_video_url %>">
<div class="video-image" style="background-image: url(<%= video_article.cloudinary_video_url %>)">
<span class="video-timestamp"><%= video_article.video_duration_in_minutes %></span>
</div>
<p><strong><%= video_article.title %></strong></p>

View file

@ -12,6 +12,7 @@ else
"searchables_#{Rails.env}",
"Tag_#{Rails.env}",
"ordered_articles_#{Rails.env}",
"ClassifiedListing_#{Rails.env}",
"ordered_articles_by_published_at_#{Rails.env}",
"ordered_articles_by_positive_reactions_count_#{Rails.env}",
"ordered_comments_#{Rails.env}",

View file

@ -138,7 +138,11 @@ Rails.application.routes.draw do
resources :tag_adjustments, only: [:create]
resources :rating_votes, only: [:create]
resources :page_views, only: %i[create update]
resources :classified_listings, path: :listings, only: %i[index new create edit update delete]
resources :credits, only: %i[index new create]
resources :buffer_updates, only: [:create]
get "/listings/:category" => "classified_listings#index"
get "/notifications/:filter" => "notifications#index"
get "/notifications/:filter/:org_id" => "notifications#index"
patch "/onboarding_update" => "users#onboarding_update"

View file

@ -0,0 +1,12 @@
class CreateCredits < ActiveRecord::Migration[5.1]
def change
create_table :credits do |t|
t.bigint :user_id
t.bigint :organization_id
t.float :cost, default: 0.0
t.string :spent_on
t.boolean :spent, default: false
t.timestamps
end
end
end

View file

@ -0,0 +1,17 @@
class CreateClassifiedListings < ActiveRecord::Migration[5.1]
def change
create_table :classified_listings do |t|
t.bigint :user_id
t.bigint :organization_id
t.string :title
t.text :body_markdown
t.text :processed_html
t.string :category
t.string :cached_tag_list
t.datetime :bumped_at
t.boolean :published
t.boolean :contact_via_connect, default: false
t.timestamps
end
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_04_17_171019) do
ActiveRecord::Schema.define(version: 2019_04_17_171020) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -231,6 +231,21 @@ ActiveRecord::Schema.define(version: 2019_04_17_171019) do
t.datetime "updated_at", null: false
end
create_table "classified_listings", force: :cascade do |t|
t.text "body_markdown"
t.datetime "bumped_at"
t.string "cached_tag_list"
t.string "category"
t.boolean "contact_via_connect", default: false
t.datetime "created_at", null: false
t.bigint "organization_id"
t.text "processed_html"
t.boolean "published"
t.string "title"
t.datetime "updated_at", null: false
t.bigint "user_id"
end
create_table "collections", id: :serial, force: :cascade do |t|
t.datetime "created_at", null: false
t.string "description"
@ -271,6 +286,16 @@ ActiveRecord::Schema.define(version: 2019_04_17_171019) do
t.index ["user_id"], name: "index_comments_on_user_id"
end
create_table "credits", force: :cascade do |t|
t.float "cost", default: 0.0
t.datetime "created_at", null: false
t.bigint "organization_id"
t.boolean "spent", default: false
t.string "spent_on"
t.datetime "updated_at", null: false
t.bigint "user_id"
end
create_table "delayed_jobs", id: :serial, force: :cascade do |t|
t.integer "attempts", default: 0, null: false
t.datetime "created_at"

View file

@ -0,0 +1,9 @@
FactoryBot.define do
factory :classified_listing do
title { Faker::Book.title + rand(100).to_s }
body_markdown { Faker::Hipster.paragraph(2) }
category { "education" }
published { true }
bumped_at { Time.current }
end
end

View file

@ -0,0 +1,5 @@
FactoryBot.define do
factory :credit do
end
end

View file

@ -0,0 +1,29 @@
require 'rails_helper'
RSpec.describe ClassifiedListing, type: :model do
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:body_markdown) }
let(:user) { create(:user)}
let(:classified_listing) { create(:classified_listing, user_id: user.id)}
describe "body html" do
it "converts markdown to html" do
expect(classified_listing.processed_html).to include("<p>")
end
it "accepts 8 tags or less" do
classified_listing.tag_list = "a, b, c, d, e, f, g"
expect(classified_listing.valid?).to eq(true)
end
it "accepts 8 tags or less" do
classified_listing.tag_list = "a, b, c, d, e, f, g, h, z, t, s, p"
expect(classified_listing.valid?).to eq(false)
end
it "parses away spaces" do
classified_listing.tag_list = "the best, tag list"
classified_listing.save
expect(classified_listing.tag_list).to eq(%w[thebest taglist])
end
end
end

View file

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe Credit, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View file

@ -0,0 +1,34 @@
require "rails_helper"
RSpec.describe "Credits", type: :request do
describe "GET /credits" do
let(:user) {create(:user)}
before do
sign_in user
end
it "shows credits page" do
get "/credits"
expect(response.body).to include("You have")
end
end
describe "POST credits" do
let(:user) {create(:user)}
let(:stripe_helper) { StripeMock.create_test_helper }
before do
StripeMock.start
sign_in user
end
after do
StripeMock.stop
end
xit "creates unspent credits" do
post "/credits", params: {
credit: {
number_to_purchase: 20
},
stripe_token: stripe_helper.generate_card_token
}
expect(user.credits.size).to eq(20)
end
end
end

View file

@ -0,0 +1,123 @@
require "rails_helper"
RSpec.describe "/listings", type: :request do
describe "GETS /listings" do
it "has page content" do
get "/listings"
expect(response.body).to include("classified-filters")
end
it "has page content for category page" do
get "/listings/saas"
expect(response.body).to include("classified-filters")
end
end
describe "GETS /listings/new" do
it "has page content" do
get "/listings"
expect(response.body).to include("classified-filters")
end
end
describe "POST /listings" do
before do
@organization = create(:organization)
@user = create(:user)
20.times do
create(:credit, user_id: @user.id)
end
sign_in @user
end
it "creates proper listing if credits are available" do
post "/listings", params: { classified_listing: {
title: "Hey", category: "education", body_markdown: "hey hey my my"
}}
expect(ClassifiedListing.last.processed_html).to include("hey my")
end
it "spends credits" do
num_credits = Credit.where(spent: true).size
post "/listings", params: { classified_listing: {
title: "Hey", category: "education", body_markdown: "hey hey my my"
}}
expect(Credit.where(spent: true).size).to be > num_credits
end
it "adds tags" do
num_credits = Credit.where(spent: true).size
post "/listings", params: { classified_listing: {
title: "Hey", category: "education", body_markdown: "hey hey my my", tag_list: "ruby, rails, go"
}}
expect(ClassifiedListing.last.cached_tag_list).to include("rails")
end
it "creates the listing for the user" do
@user.update(organization_id: @organization.id)
post "/listings", params: { classified_listing: {
title: "Hey", category: "education", body_markdown: "hey hey my my", tag_list: "ruby, rails, go",
post_as_organization: 0
}}
expect(ClassifiedListing.last.organization_id).not_to eq(@organization.id)
end
it "creates the listing for the organization" do
@user.update(organization_id: @organization.id)
post "/listings", params: { classified_listing: {
title: "Hey", category: "education", body_markdown: "hey hey my my", tag_list: "ruby, rails, go",
post_as_organization: 1
}}
expect(ClassifiedListing.last.organization_id).to eq(@organization.id)
end
end
describe "GETS /listings/edit" do
before do
@organization = create(:organization)
@user = create(:user)
@classified_listing = create(:classified_listing, user_id: @user.id)
20.times do
create(:credit, user_id: @user.id)
end
sign_in @user
end
it "has page content" do
get "/listings/#{@classified_listing.id}/edit"
expect(response.body).to include("You can bump your listing")
end
end
describe "PUT /listings/:id" do
before do
@organization = create(:organization)
@user = create(:user)
@classified_listing = create(:classified_listing, user_id: @user.id)
20.times do
create(:credit, user_id: @user.id)
end
sign_in @user
end
it "updates bumped_at if action is bump" do
# block = create(:block, user_id: user.id, input_css: ".blue { color: blue;}")
put "/listings/#{@classified_listing.id}", params: {
classified_listing: { action: "bump"}
}
expect(ClassifiedListing.last.bumped_at).to be > 10.seconds.ago
end
it "updates publish if action is unpublish" do
put "/listings/#{@classified_listing.id}", params: {
classified_listing: { action: "unpublish"}
}
expect(ClassifiedListing.last.published).to eq(false)
end
it "updates body_markdown" do
put "/listings/#{@classified_listing.id}", params: {
classified_listing: { body_markdown: "hello new markdown"}
}
expect(ClassifiedListing.last.body_markdown).to eq("hello new markdown")
end
it "does not update body_markdown if not bumped/created recently" do
@classified_listing.update_column(:bumped_at, 50.hours.ago)
put "/listings/#{@classified_listing.id}", params: {
classified_listing: { body_markdown: "hello new markdown"}
}
expect(ClassifiedListing.last.body_markdown).not_to eq("hello new markdown")
end
end
end

View file

@ -9,7 +9,7 @@ RSpec.describe "ReadingListIndex", type: :request do
describe "GET reading list" do
it "returns reading list page" do
get "/readinglist"
expect(response.body).to include("My Reading List")
expect(response.body).to include("Saved Posts")
end
end
end