diff --git a/app/assets/javascripts/initializePage.js.erb b/app/assets/javascripts/initializePage.js.erb index ddfcdb3ee..317839055 100644 --- a/app/assets/javascripts/initializePage.js.erb +++ b/app/assets/javascripts/initializePage.js.erb @@ -41,6 +41,7 @@ function callInitalizers(){ initializePWAFunctionality(); initializeEllipsisMenu(); initializeArchivedPostFilter(); + initializeCreditsPage(); initializeDrawerSliders(); diff --git a/app/assets/javascripts/initializers/initializeCreditsPage.js b/app/assets/javascripts/initializers/initializeCreditsPage.js new file mode 100644 index 000000000..9d7fb6723 --- /dev/null +++ b/app/assets/javascripts/initializers/initializeCreditsPage.js @@ -0,0 +1,13 @@ +/* global localizeTimeElements */ +function initializeCreditsPage() { + const datetimes = document.querySelectorAll('.ledger time'); + + localizeTimeElements(datetimes, { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} diff --git a/app/assets/javascripts/utilities/localDateTime.js b/app/assets/javascripts/utilities/localDateTime.js index 07ab763d2..ad3bc5606 100644 --- a/app/assets/javascripts/utilities/localDateTime.js +++ b/app/assets/javascripts/utilities/localDateTime.js @@ -55,3 +55,20 @@ function addLocalizedDateTimeToElementsTitles(elements, timestampAttribute) { } } } + +function localizeTimeElements(elements, timeOptions) { + for (let i = 0; i < elements.length; i += 1) { + const element = elements[i]; + + const timestamp = element.getAttribute('datetime'); + if (timestamp) { + const localDateTime = timestampToLocalDateTime( + timestamp, + navigator.language, + timeOptions, + ); + + element.textContent = localDateTime; + } + } +} diff --git a/app/assets/stylesheets/credits.scss b/app/assets/stylesheets/credits.scss index e19615fc2..260512c7c 100644 --- a/app/assets/stylesheets/credits.scss +++ b/app/assets/stylesheets/credits.scss @@ -3,12 +3,14 @@ .credits-container { margin: 50px auto; + .notice { background: $green; padding: 30px 0px; margin-top: 68px; text-align: center; } + .credits-banner { background-color: $dark-purple; background-image: linear-gradient(141deg, $dark-purple 0%, #1f89db 51%, #2981e5 75%); @@ -33,7 +35,7 @@ font-size: 1.8em; } } - + form#new_credit { width: 500px; @@ -44,13 +46,13 @@ .InputElement { @include themeable( - background-color, - theme-container-background, + background-color, + theme-container-background, white ); @include themeable( - color, - theme-color, + color, + theme-color, $black ); } @@ -62,13 +64,13 @@ border: 1px solid transparent; border-radius: 4px; @include themeable( - background-color, - theme-container-background, + background-color, + theme-container-background, white ); @include themeable( - color, - theme-color, + color, + theme-color, $black ); @@ -196,13 +198,13 @@ } button#add-new-card { @include themeable( - background-color, - theme-container-background, + background-color, + theme-container-background, $light-gray ); @include themeable( - color, - theme-color, + color, + theme-color, $black ); font-size: 15px; @@ -221,8 +223,8 @@ } .credit-card-component { @include themeable( - background, - theme-container-accent-background, + background, + theme-container-accent-background, $lightest-gray ); border-radius: 3px; @@ -235,8 +237,8 @@ label.main-form-label { margin-bottom: 4px; @include themeable( - background, - theme-container-accent-background, + background, + theme-container-accent-background, $light-gray ); border-radius: 3px; @@ -254,6 +256,7 @@ padding: 30px; border-radius: 3px; text-align: center; + .existing-credits-status { font-size: 1.8em; font-weight: bold; @@ -263,7 +266,8 @@ font-size: initial; } } - a { + + > a { color: white; border: 3px solid white; padding: 10px 30px; @@ -271,4 +275,28 @@ font-weight: bold; } } -} \ No newline at end of file + + .ledger { + margin: 2rem auto; + text-align: right; + width: 600px; + + caption { + font-size: 1.2rem; + margin-bottom: 1.5rem; + } + + td { + padding-top: 1.1rem; + } + + .misc { + td { + padding-top: 1.5rem; + &:first-child { font-style: italic; } + } + } + + a { color: #fff; } + } +} diff --git a/app/controllers/classified_listings_controller.rb b/app/controllers/classified_listings_controller.rb index 2b9e534c3..3f04220a7 100644 --- a/app/controllers/classified_listings_controller.rb +++ b/app/controllers/classified_listings_controller.rb @@ -7,11 +7,18 @@ class ClassifiedListingsController < ApplicationController def index @displayed_classified_listing = ClassifiedListing.find_by!(slug: params[:slug]) if params[:slug] - mod_page if params[:view] == "moderate" + + if params[:view] == "moderate" + return redirect_to "/internal/listings/#{@displayed_classified_listing.id}/edit" + end + @classified_listings = if params[:category].blank? - ClassifiedListing.where(published: true).order("bumped_at DESC").limit(12) + ClassifiedListing.where(published: true). + order("bumped_at DESC"). + includes(:user, :organization, :taggings). + limit(12) else - [] + ClassifiedListing.none end set_surrogate_key_header "classified-listings-#{params[:category]}" end @@ -19,59 +26,55 @@ class ClassifiedListingsController < ApplicationController def new @classified_listing = ClassifiedListing.new @organizations = current_user.organizations - @credits = current_user.credits.where(spent: false) + @credits = current_user.credits.unspent end def edit authorize @classified_listing @organizations = current_user.organizations - @credits = current_user.credits.where(spent: false) + @credits = current_user.credits.unspent end def create @classified_listing = ClassifiedListing.new(listing_params) - @classified_listing.user_id = current_user.id - @number_of_credits_needed = ClassifiedListing.cost_by_category(@classified_listing.category) - @org = Organization.find_by(id: @classified_listing.organization_id) - available_org_credits = @org.credits.where(spent: false) if @org - available_individual_credits = current_user.credits.where(spent: false) - if @org && available_org_credits.size >= @number_of_credits_needed - create_listing(available_org_credits) - elsif available_individual_credits.size >= @number_of_credits_needed - create_listing(available_individual_credits) - else - redirect_to "/credits" - end - end - - def create_listing(credits) - @classified_listing.bumped_at = Time.current - @classified_listing.published = true # this will 500 for now if they don't belong in the org authorize @classified_listing, :authorized_organization_poster? if @classified_listing.organization_id.present? - if @classified_listing.save - clear_listings_cache - credits.limit(@number_of_credits_needed).update_all(spent: true) - @classified_listing.index! - redirect_to "/listings" + + @classified_listing.user_id = current_user.id + cost = ClassifiedListing.cost_by_category(@classified_listing.category) + + org = Organization.find_by(id: @classified_listing.organization_id) + + available_org_credits = org.credits.unspent if org + available_user_credits = current_user.credits.unspent + + # we use the org's credits if available, otherwise we default to the user's + if org && available_org_credits.size >= cost + create_listing(org, cost) + elsif available_user_credits.size >= cost + create_listing(current_user, cost) else - @credits = current_user.credits.where(spent: false) - @classified_listing.cached_tag_list = listing_params[:tag_list] - @organizations = current_user.organizations - render :new + redirect_to credits_path, notice: "Not enough available 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 + + # NOTE: this should probably be split in three different actions: bump, unpublish, publish if listing_params[:action] == "bump" - bump_listing - if available_credits.size >= number_of_credits_needed - @classified_listing.save - available_credits.limit(number_of_credits_needed).update_all(spent: true) + cost = ClassifiedListing.cost_by_category(@classified_listing.category) + if current_user.credits.unspent.size >= cost + ActiveRecord::Base.transaction do + Credits::Buyer.call( + purchaser: current_user, + purchase: @classified_listing, + cost: cost, + ) + + raise ActiveRecord::Rollback unless bump_listing + end end elsif listing_params[:action] == "unpublish" unpublish_listing @@ -80,12 +83,15 @@ class ClassifiedListingsController < ApplicationController elsif listing_params[:body_markdown].present? && @classified_listing.bumped_at > 24.hours.ago update_listing_details end + clear_listings_cache redirect_to "/listings" end def dashboard - @classified_listings = current_user.classified_listings + @classified_listings = current_user.classified_listings. + includes(:organization, :taggings) + organizations_ids = current_user.organization_memberships. where(type_of_user: "admin"). pluck(:organization_id) @@ -96,8 +102,38 @@ class ClassifiedListingsController < ApplicationController private - def mod_page - redirect_to "/internal/listings/#{@displayed_classified_listing.id}/edit" + def create_listing(purchaser, cost) + successful_transaction = false + ActiveRecord::Base.transaction do + # substract credits + Credits::Buyer.call( + purchaser: purchaser, + purchase: @classified_listing, + cost: cost, + ) + + # save the listing + @classified_listing.bumped_at = Time.current + @classified_listing.published = true + + # since we can't raise active record errors in this transaction + # due to the fact that we need to display them in the :new view, + # we manually rollback the transaction if there are validation errors + raise ActiveRecord::Rollback unless @classified_listing.save + + successful_transaction = true + end + + if successful_transaction + clear_listings_cache + @classified_listing.index! + redirect_to classified_listings_path + else + @credits = current_user.credits.unspent + @classified_listing.cached_tag_list = listing_params[:tag_list] + @organizations = current_user.organizations + render :new + end end def set_classified_listing diff --git a/app/controllers/concerns/classified_listings_toolkit.rb b/app/controllers/concerns/classified_listings_toolkit.rb index 130068377..f9a7c319e 100644 --- a/app/controllers/concerns/classified_listings_toolkit.rb +++ b/app/controllers/concerns/classified_listings_toolkit.rb @@ -24,7 +24,9 @@ module ClassifiedListingsToolkit def bump_listing @classified_listing.bumped_at = Time.current - @classified_listing.save + saved = @classified_listing.save + @classified_listing.index! if saved + saved end def clear_listings_cache diff --git a/app/controllers/credits_controller.rb b/app/controllers/credits_controller.rb index 717c5b337..148a4bd55 100644 --- a/app/controllers/credits_controller.rb +++ b/app/controllers/credits_controller.rb @@ -2,7 +2,9 @@ class CreditsController < ApplicationController before_action :authenticate_user! def index - @credits = current_user.credits.where(spent: false) + @user_unspent_credits_count = current_user.credits.unspent.size + @ledger = Credits::Ledger.call(current_user) + @organizations = current_user.admin_organizations end @@ -35,10 +37,10 @@ class CreditsController < ApplicationController end Credit.import credit_objects @purchaser.credits_count = @purchaser.credits.size - @purchaser.spent_credits_count = @purchaser.credits.where(spent: true).size - @purchaser.unspent_credits_count = @purchaser.credits.where(spent: false).size + @purchaser.spent_credits_count = @purchaser.credits.spent.size + @purchaser.unspent_credits_count = @purchaser.credits.unspent.size @purchaser.save - redirect_to "/credits", notice: "#{@number_to_purchase} new credits purchased!" + redirect_to credits_path, notice: "#{@number_to_purchase} new credits purchased!" end def make_payment @@ -49,7 +51,7 @@ class CreditsController < ApplicationController true rescue Stripe::CardError => e flash[:error] = e.message - redirect_to "/credits/purchase" + redirect_to purchase_credits_path false end diff --git a/app/models/classified_listing.rb b/app/models/classified_listing.rb index 519ad890e..8af8bf8bc 100644 --- a/app/models/classified_listing.rb +++ b/app/models/classified_listing.rb @@ -9,6 +9,7 @@ class ClassifiedListing < ApplicationRecord before_create :create_slug before_validation :modify_inputs acts_as_taggable_on :tags + has_many :credits, as: :purchase, inverse_of: :purchase, dependent: :nullify validates :user_id, presence: true validates :organization_id, presence: true, unless: :user_id? @@ -71,7 +72,7 @@ class ClassifiedListing < ApplicationRecord "forsale" => { cost: 1, name: "Stuff for Sale", rules: "Personally owned physical items for sale." }, "events" => { cost: 1, name: "Upcoming Events", rules: "In-person or online events with date included." }, "misc" => { cost: 1, name: "Miscellaneous", rules: "Must not fit in any other category." } - } + }.with_indifferent_access end def path diff --git a/app/models/credit.rb b/app/models/credit.rb index d25959951..731145200 100644 --- a/app/models/credit.rb +++ b/app/models/credit.rb @@ -1,8 +1,12 @@ class Credit < ApplicationRecord attr_accessor :number_to_purchase - belongs_to :user, optional: true - belongs_to :organization, optional: true + belongs_to :user, optional: true + belongs_to :organization, optional: true + belongs_to :purchase, polymorphic: true, optional: true + + scope :spent, -> { where(spent: true) } + scope :unspent, -> { where(spent: false) } counter_culture :user, column_name: proc { |model| "#{model.spent ? 'spent' : 'unspent'}_credits_count" }, diff --git a/app/services/credits/buyer.rb b/app/services/credits/buyer.rb new file mode 100644 index 000000000..6cf4dbe9a --- /dev/null +++ b/app/services/credits/buyer.rb @@ -0,0 +1,33 @@ +module Credits + class Buyer + def initialize(purchaser:, purchase:, cost:) + @purchaser = purchaser + @purchase = purchase + @cost = cost + end + + def self.call(*args) + new(*args).call + end + + def call + return false unless has_available_credits? + + purchaser.credits.unspent.limit(cost).update_all( + spent: true, + spent_at: Time.current, + purchase_type: purchase.class.name, + purchase_id: purchase.id, + ) + true + end + + private + + attr_reader :purchaser, :purchase, :cost + + def has_available_credits? + purchaser.credits.unspent.size >= cost + end + end +end diff --git a/app/services/credits/ledger.rb b/app/services/credits/ledger.rb new file mode 100644 index 000000000..2eed5b5fb --- /dev/null +++ b/app/services/credits/ledger.rb @@ -0,0 +1,69 @@ +module Credits + class Ledger + Item = Struct.new(:purchase, :cost, :purchased_at, keyword_init: true) + + def initialize(user) + @user = user + end + + def self.call(*args) + new(*args).call + end + + def call + # build the ledger for the user + ledger = { + [User.name, user.id] => build_ledger_for(user.credits) + } + + # build the ledger for the organizations the user is an admin at + user.admin_organizations.find_each do |org| + ledger[[Organization.name, org.id]] = build_ledger_for(org.credits) + end + + ledger + end + + private + + attr_reader :user + + def purchaseable + %w[ClassifiedListing] + end + + def load_purchases(credits) + credits.spent.select( + :purchase_id, + :purchase_type, + Arel.sql("COUNT(*)").as("cost"), + Arel.sql("MAX(spent_at)").as("purchased_at"), + ).group(:purchase_id, :purchase_type).order(purchased_at: :desc) + end + + def build_ledger_for(credits) + purchases = load_purchases(credits) + items = [] + + # to avoid N+1 on purchases, we load them by type separately + listings_purchases = purchases.select { |row| row.purchase_type == "ClassifiedListing" } + listings = ClassifiedListing.where(id: listings_purchases.map(&:purchase_id)) + listings_purchases.each do |purchase| + listing = listings.detect { |l| l.id == purchase.purchase_id } + items << Item.new( + purchase: listing, + cost: purchase.cost.to_i, + purchased_at: purchase.purchased_at, + ) + end + + # add items without a purchase association at the bottom + unassociated_purchase = purchases.reject(&:purchase_type).first + if unassociated_purchase + items << Item.new(cost: unassociated_purchase.cost.to_i) + end + + items + end + end +end diff --git a/app/views/credits/_ledger.html.erb b/app/views/credits/_ledger.html.erb new file mode 100644 index 000000000..922ab1c2a --- /dev/null +++ b/app/views/credits/_ledger.html.erb @@ -0,0 +1,39 @@ +
| Item | +Cost (credits) | +Date | +
|---|---|---|
| + <% if item.purchase %> + + <%= truncate(item.purchase.title, length: 20) %> + + <% else %> + Miscellaneous items + <% end %> + | +<%= item.cost %> | ++ <% if item.purchased_at %> + + <% end %> + | +
- Create a Listing + Create a Listing
+ <% if @organizations.present? %>- View Partnership Opportunities + View Partnership Opportunities
<% end %>