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:
parent
7d33645d6d
commit
78138532c8
52 changed files with 1768 additions and 234 deletions
3
Envfile
3
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"
|
||||
|
|
|
|||
2
Gemfile
2
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
255
app/assets/stylesheets/classified_listings.scss
Normal file
255
app/assets/stylesheets/classified_listings.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
192
app/assets/stylesheets/credits.scss
Normal file
192
app/assets/stylesheets/credits.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
#notice {
|
||||
color: green;
|
||||
}
|
||||
|
||||
|
||||
#error_explanation {
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -30,6 +30,8 @@
|
|||
@import 'sticky-nav';
|
||||
@import 'sidebar-data';
|
||||
@import 'video-collection';
|
||||
@import 'classified_listings';
|
||||
@import 'credits';
|
||||
|
||||
@import 'ltags/LiquidTags';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
81
app/controllers/classified_listings_controller.rb
Normal file
81
app/controllers/classified_listings_controller.rb
Normal 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
|
||||
92
app/controllers/credits_controller.rb
Normal file
92
app/controllers/credits_controller.rb
Normal 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
|
||||
|
|
@ -30,6 +30,8 @@ module ApplicationHelper
|
|||
notifications
|
||||
reading_list_items
|
||||
html_variants
|
||||
classified_listings
|
||||
credits
|
||||
].include?(controller_name)
|
||||
end
|
||||
|
||||
|
|
|
|||
233
app/javascript/listings/listings.jsx
Normal file
233
app/javascript/listings/listings.jsx
Normal 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';
|
||||
27
app/javascript/listings/singleListing.jsx
Normal file
27
app/javascript/listings/singleListing.jsx
Normal 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,
|
||||
};
|
||||
15
app/javascript/packs/listings.jsx
Normal file
15
app/javascript/packs/listings.jsx
Normal 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();
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
104
app/models/classified_listing.rb
Normal file
104
app/models/classified_listing.rb
Normal 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
7
app/models/credit.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
class Credit < ApplicationRecord
|
||||
|
||||
attr_accessor :number_to_purchase
|
||||
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :organization, optional: true
|
||||
end
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
15
app/policies/classified_listing_policy.rb
Normal file
15
app/policies/classified_listing_policy.rb
Normal 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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<script async>
|
||||
<script defer>
|
||||
|
||||
function getQueryParams(qs) {
|
||||
qs = qs.split('+').join(' ');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
64
app/views/classified_listings/_form.html.erb
Normal file
64
app/views/classified_listings/_form.html.erb
Normal 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 %>
|
||||
26
app/views/classified_listings/edit.html.erb
Normal file
26
app/views/classified_listings/edit.html.erb
Normal 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>
|
||||
40
app/views/classified_listings/index.html.erb
Normal file
40
app/views/classified_listings/index.html.erb
Normal 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 %>
|
||||
4
app/views/classified_listings/new.html.erb
Normal file
4
app/views/classified_listings/new.html.erb
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<div class="classifieds-container">
|
||||
<%= render 'form', classified_listing: @classified_listing %>
|
||||
</div>
|
||||
|
||||
4
app/views/classified_listings/show.html.erb
Normal file
4
app/views/classified_listings/show.html.erb
Normal 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 %>
|
||||
23
app/views/credits/index.html.erb
Normal file
23
app/views/credits/index.html.erb
Normal 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>
|
||||
218
app/views/credits/new.html.erb
Normal file
218
app/views/credits/new.html.erb
Normal 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>
|
||||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
12
db/migrate/20190409123750_create_credits.rb
Normal file
12
db/migrate/20190409123750_create_credits.rb
Normal 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
|
||||
17
db/migrate/20190417171020_create_classified_listings.rb
Normal file
17
db/migrate/20190417171020_create_classified_listings.rb
Normal 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
|
||||
27
db/schema.rb
27
db/schema.rb
|
|
@ -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"
|
||||
|
|
|
|||
9
spec/factories/classified_listings.rb
Normal file
9
spec/factories/classified_listings.rb
Normal 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
|
||||
5
spec/factories/credits.rb
Normal file
5
spec/factories/credits.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
FactoryBot.define do
|
||||
factory :credit do
|
||||
end
|
||||
end
|
||||
|
||||
29
spec/models/classified_listing_spec.rb
Normal file
29
spec/models/classified_listing_spec.rb
Normal 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
|
||||
5
spec/models/credit_spec.rb
Normal file
5
spec/models/credit_spec.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Credit, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
34
spec/requests/credits_spec.rb
Normal file
34
spec/requests/credits_spec.rb
Normal 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
|
||||
123
spec/requests/listings_spec.rb
Normal file
123
spec/requests/listings_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue