diff --git a/app/assets/images/icons/drag-indicator.svg b/app/assets/images/icons/drag-indicator.svg new file mode 100644 index 0000000000..561d7d065b --- /dev/null +++ b/app/assets/images/icons/drag-indicator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/components/_cards.scss b/app/assets/stylesheets/components/_cards.scss index 2171b84a45..0bacd9e692 100644 --- a/app/assets/stylesheets/components/_cards.scss +++ b/app/assets/stylesheets/components/_cards.scss @@ -58,7 +58,8 @@ z-index: 999; } -.draggable-source--is-dragging .card { +.draggable-source--is-dragging .card, +.draggable-source--is-dragging.card { opacity: 0.5; } diff --git a/app/controllers/donation/tiers_controller.rb b/app/controllers/donation/tiers_controller.rb new file mode 100644 index 0000000000..be74fb31e5 --- /dev/null +++ b/app/controllers/donation/tiers_controller.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +class Donation + class TiersController < ApplicationController + before_action :set_event, except: [:set_index] + + def index + @tiers = @event.donation_tiers + end + + def set_index + tier = Donation::Tier.find_by(id: params[:id]) + authorize tier.event, :update? + + index = params[:index] + + # get all the tiers as an array + tiers = tier.event.donation_tiers.order(:sort_index).to_a + + return head status: :bad_request if index < 0 || index >= tiers.size + + # switch the position *in the in-memory array* + tiers.delete tier + tiers.insert index, tier + + # persist the sort order + ActiveRecord::Base.transaction do + tiers.each_with_index do |op, idx| + op.update(sort_index: idx) + end + end + + render json: tiers.pluck(:id) + end + + def create + authorize @event, :update? + + @tier = @event.donation_tiers.new( + name: "Untitled tier", + amount_cents: 1000, + description: "", + sort_index: @event.donation_tiers.maximum(:sort_index).to_i + 1 + ) + @tier.save! + redirect_back fallback_location: edit_event_path(@event.slug), flash: { success: "Donation tier created successfully." } + rescue ActiveRecord::RecordInvalid => e + redirect_back fallback_location: edit_event_path(@event.slug), flash: { error: e.message } + end + + def update + authorize @event, :update? + params[:tiers]&.each do |id, tier_data| + tier = @event.donation_tiers.find_by(id: id) + next unless tier + + tier.update( + name: tier_data[:name], + description: tier_data[:description], + amount_cents: (tier_data[:amount_cents].to_f * 100).to_i + ) + end + + render json: { success: true, message: "Donation tiers updated successfully." } + rescue ActiveRecord::RecordInvalid => e + redirect_back fallback_location: edit_event_path(@event.slug), flash: { error: e.message } + end + + def destroy + authorize @event, :update? + @tier = @event.donation_tiers.find(params[:format]) + @tier.destroy + redirect_back fallback_location: edit_event_path(@event.slug), flash: { success: "Donation tiers updated successfully." } + rescue ActiveRecord::RecordInvalid => e + redirect_back fallback_location: edit_event_path(@event.slug), flash: { error: e.message } + end + + private + + def set_event + @event = Event.where(slug: params[:event_id]).first + render json: { error: "Event not found" }, status: :not_found unless @event + end + + end + +end diff --git a/app/controllers/donations_controller.rb b/app/controllers/donations_controller.rb index 12bc758b43..378dbc6641 100644 --- a/app/controllers/donations_controller.rb +++ b/app/controllers/donations_controller.rb @@ -53,6 +53,13 @@ def start_donation tax_deductible = params[:goods].nil? || params[:goods] == "0" + @show_tiers = @event.donation_tiers_enabled? && @event.donation_tiers.any? + @tier = @event.donation_tiers.find_by(id: params[:tier_id]) if params[:tier_id] + if params[:tier_id] && @tier.nil? + redirect_to start_donation_donations_path(@event), flash: { error: "Donation tier could not be found." } + end + + @donation = Donation.new( name: params[:name] || (organizer_signed_in? ? nil : current_user&.name), email: params[:email] || (organizer_signed_in? ? nil : current_user&.email), diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index c53ea94a9b..510bd3fd12 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -956,6 +956,7 @@ def event_params :hidden, :donation_page_enabled, :donation_page_message, + :donation_tiers_enabled, :reimbursements_require_organizer_peer_review, :public_reimbursement_page_enabled, :public_reimbursement_page_message, @@ -1006,6 +1007,7 @@ def user_event_params :end, :donation_page_enabled, :donation_page_message, + :donation_tiers_enabled, :reimbursements_require_organizer_peer_review, :public_reimbursement_page_enabled, :public_reimbursement_page_message, diff --git a/app/controllers/features_controller.rb b/app/controllers/features_controller.rb index 4c4dbe0122..3e19379d18 100644 --- a/app/controllers/features_controller.rb +++ b/app/controllers/features_controller.rb @@ -15,6 +15,7 @@ class FeaturesController < ApplicationController totp_2024_06_13: %w[🔒 ⏰], event_home_page_redesign_2024_09_21: %w[🏠 📊 📉 💸], card_logos_2024_08_27: %w[🌈 💳 📸], + donation_tiers_2025_06_24: %w[💖 🥇 🥈 🥉] }.freeze def enable_feature diff --git a/app/javascript/controllers/donation_tier_sort_controller.js b/app/javascript/controllers/donation_tier_sort_controller.js new file mode 100644 index 0000000000..3a0e7cfe38 --- /dev/null +++ b/app/javascript/controllers/donation_tier_sort_controller.js @@ -0,0 +1,30 @@ +import { Controller } from '@hotwired/stimulus' +import csrf from '../common/csrf' + +export default class extends Controller { + static values = { + positions: Array, + } + + async sort({ detail: { oldIndex, newIndex } }) { + if (oldIndex == newIndex) return + + const copy = this.positionsValue + + const id = copy[oldIndex] + + copy.splice(oldIndex, 1) + copy.splice(newIndex, 0, id) + + this.positionsValue = copy + + await fetch(`/${id}/donation/tiers/set_index`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf(), + }, + body: JSON.stringify({ id, index: newIndex }), + }) + } +} diff --git a/app/javascript/controllers/sortable_controller.js b/app/javascript/controllers/sortable_controller.js index 386cac7f4c..4ba4f75237 100644 --- a/app/javascript/controllers/sortable_controller.js +++ b/app/javascript/controllers/sortable_controller.js @@ -4,11 +4,13 @@ import { Sortable, Plugins, Draggable } from '@shopify/draggable' export default class extends Controller { static values = { appendTo: String, + handle: String, } connect() { this.sortable = new Sortable(this.element, { draggable: '.draggable', + handle: this.handleValue, mirror: { constrainDimensions: true, appendTo: this.appendToValue, diff --git a/app/models/donation/tier.rb b/app/models/donation/tier.rb new file mode 100644 index 0000000000..80e06e9a3f --- /dev/null +++ b/app/models/donation/tier.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: donation_tiers +# +# id :bigint not null, primary key +# amount_cents :integer not null +# deleted_at :datetime +# description :text +# name :string not null +# sort_index :integer +# created_at :datetime not null +# updated_at :datetime not null +# event_id :bigint not null +# +# Indexes +# +# index_donation_tiers_on_event_id (event_id) +# +# Foreign Keys +# +# fk_rails_... (event_id => events.id) +# +class Donation + class Tier < ApplicationRecord + belongs_to :event + + validates :name, :amount_cents, presence: true + validates :amount_cents, numericality: { only_integer: true, greater_than: 0 } + + default_scope { order(sort_index: :asc) } + + acts_as_paranoid + + end + +end diff --git a/app/models/event.rb b/app/models/event.rb index ca5b66ff72..661e9c981e 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -18,6 +18,7 @@ # donation_page_message :text # donation_reply_to_email :text # donation_thank_you_message :text +# donation_tiers_enabled :boolean default(FALSE), not null # financially_frozen :boolean default(FALSE), not null # hidden_at :datetime # holiday_features :boolean default(TRUE), not null @@ -258,6 +259,7 @@ class Event < ApplicationRecord has_many :donation_payouts, through: :donations, source: :payout has_many :recurring_donations has_one :donation_goal, dependent: :destroy, class_name: "Donation::Goal" + has_many :donation_tiers, -> { order(sort_index: :asc) }, dependent: :destroy, class_name: "Donation::Tier" has_many :lob_addresses has_many :checks, through: :lob_addresses diff --git a/app/views/donations/_donation_form.html.erb b/app/views/donations/_donation_form.html.erb index ff6ccb4c47..0a5199e44f 100644 --- a/app/views/donations/_donation_form.html.erb +++ b/app/views/donations/_donation_form.html.erb @@ -1,12 +1,14 @@ <%= turbo_frame_tag :donation do %> -
+ <%= @tier&.description || "Pay a custom amount, one-time or recurring, to support #{@event.name}" %> +
+ <% if @tier %> ++ Pay a custom amount, one-time or recurring, to support <%= @event.name %> +
+ + <%= link_to upsert_query_params(custom_amount: true), class: "w-full btn btn-primary mt-auto" do %> + Continue + <% end %> ++ <%= donation_tier.description %> +
+ + <%= link_to start_donation_tier_donations_path(@event, donation_tier), class: "w-full btn btn-primary mt-auto" do %> + <%= render_money_short donation_tier.amount_cents %>/month + <% end %> ++ Offer preset monthly donation options with custom names, prices, and perks. +
+