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 %> - + <% unless @tier %> + + <% end %> <%= form_with(model: @monthly ? [@event, @recurring_donation] : donation, local: true, url: (make_donation_donations_path unless @monthly), class: "card mx-auto max-width-1 mb3", data: { turbo: true, turbo_action: "advance" }, html: { autocomplete: "off" }) do |form| %> @@ -17,7 +19,7 @@
<%= form.label :name, "Your name" %> - <%= form.text_field :name, placeholder: "John Smith", required: true, autofocus: true, autocomplete: "off" %> + <%= form.text_field :name, placeholder: "John Smith", required: true, autofocus: !@tier, autocomplete: "off" %>
@@ -44,7 +46,7 @@ return '0.00'; } }"> -
+
"> <% if @international %> <%= form.label :amount, "Donation amount (USD)" %> <% else %> @@ -53,7 +55,7 @@
$ <%= form.number_field :amount, - value: (donation.amount.nil? ? nil : ("%.2f" % (donation.amount.to_f / 100))), + value: @tier ? ("%.2f" % (@tier.amount_cents.to_f / 100)) : (donation.amount.nil? ? nil : ("%.2f" % (donation.amount.to_f / 100))), placeholder: @placeholder_amount, step: 0.01, min: 1, # Limitations placed by Stripe diff --git a/app/views/donations/_donation_tiers.html.erb b/app/views/donations/_donation_tiers.html.erb new file mode 100644 index 0000000000..68130b545c --- /dev/null +++ b/app/views/donations/_donation_tiers.html.erb @@ -0,0 +1,64 @@ +<% if @show_tiers %> + <% if params[:tier_id] || params[:custom_amount] %> +
+ <%= link_to upsert_query_params(tier_id: nil, custom_amount: nil), class: "mt-10 inline-flex -ml-2 items-center no-underline gap-2" do %> + <%= inline_icon "view-back" %> + Back + <% end %> +
+
+
+ <%= inline_icon "check-thin", size: 24 %> +
+

+ <%= @tier&.name || "Custom" %> +

+

+ <%= @tier&.description || "Pay a custom amount, one-time or recurring, to support #{@event.name}" %> +

+ <% if @tier %> +
+ + <%= render_money_short @tier.amount_cents %> + + per month +
+ + <%= link_to start_donation_tier_donations_path(@event, @tier), class: "btn btn-primary mt-4 w-full", onclick: "event.preventDefault();try {navigator.share({ text: 'Donate to ' + #{@event.name.to_json}, url: window.location.href })} catch (e) { navigator.clipboard.writeText(window.location.href); alert('Copied URL to clipboard!'); }" do %> + <%= inline_icon "share" %> + Share link + <% end %> + <% end %> +
+ <% else %> +

Make a contribution

+
+
+

+ Custom +

+

+ 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 %> +
+ <% @event.donation_tiers.each do |donation_tier| %> +
+

+ <%= donation_tier.name %> +

+

+ <%= 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 %> +
+ <% end %> +
+ <% end %> +<% end %> diff --git a/app/views/donations/start_donation.html.erb b/app/views/donations/start_donation.html.erb index 9fbb7c4766..3069dc029c 100644 --- a/app/views/donations/start_donation.html.erb +++ b/app/views/donations/start_donation.html.erb @@ -14,7 +14,13 @@ <% end %> -<%= render "events/landing/header" %> +<%= render "events/landing/header" do %> + <% if @event.donation_goal.present? %> +
+ <%= render "donations/donation_goal", donation: @donation %> +
+ <% end %> +<% end %> <% if @event.donation_page_message.present? %>
@@ -24,8 +30,10 @@
<% end %> -<%= render "donations/donation_goal", donation: @donation %> -<%= render "donations/donation_form", donation: @donation %> +<%= render "donations/donation_tiers", donation: @donation %> +<% if !@show_tiers || @tier || params[:custom_amount] %> + <%= render "donations/donation_form", donation: @donation %> +<% end %> <% if @event.demo_mode? %>
diff --git a/app/views/events/landing/_header.html.erb b/app/views/events/landing/_header.html.erb index 6016e4bd31..6a6fa06e00 100644 --- a/app/views/events/landing/_header.html.erb +++ b/app/views/events/landing/_header.html.erb @@ -35,13 +35,20 @@
style="background: linear-gradient(rgba(23, 23, 29, 0.3), rgba(23, 23, 29, 0.9)), url('<%= url_for @event.donation_header_image %>'); background-position: center; background-size: cover; background-repeat: no-repeat;" <% end %>> -

"> - "><%= defined?(lead) ? lead : "Donate to" %> -
- <%= render "donations/logo" %> - <%= @event.name %> +
"> +
+

<%= "smoke drop-shadow-lg" if @has_header_image %>"> + "><%= defined?(lead) ? lead : "Donate to" %> +
+ <%= render "donations/logo" %> + <%= @event.name %> +
+

-

+ <% if yield.present? %> + <%= yield %> + <% end %> +
<% if @background.present? %> diff --git a/app/views/events/settings/_donation_tiers.html.erb b/app/views/events/settings/_donation_tiers.html.erb new file mode 100644 index 0000000000..2fb6a52349 --- /dev/null +++ b/app/views/events/settings/_donation_tiers.html.erb @@ -0,0 +1,71 @@ +<% if @frame %> + +<% end %> +
+
+
+ Enable donation tiers +

+ Offer preset monthly donation options with custom names, prices, and perks. +

+
+
+ <%= form_with(model: event, local: true) do |form| %> + <%= form.label :donation_tiers_enabled do %> + <%= form.check_box :donation_tiers_enabled, + data: { action: "change->accordion#toggle", target: "accordion.checkbox" }, + disabled:, + checked: @event.donation_tiers_enabled, + onchange: "this.closest('form').requestSubmit();", + switch: true %> + + <% end %> + <% end %> +
+
+
+
+ <%= form_with(url: event_donation_tiers_path(event), local: true, method: :patch) do |form| %> +
    + <% event.donation_tiers.each do |tier| %> +
    +
    +
    + <%= form.text_field "tiers[#{tier.id}][name]", value: tier.name, class: "flex-1 !max-w-full", placeholder: "Tier name", required: true, oninput: "clearTimeout(this.dataset.timer); this.dataset.timer = setTimeout(() => this.form.requestSubmit(), 500);" %> +
    + $ + <%= form.number_field "tiers[#{tier.id}][amount_cents]", value: tier.amount_cents / 100, required: true, min: 0, class: "!border-none !px-0", style: "width: 50px", placeholder: "Cost", oninput: "clearTimeout(this.dataset.timer); this.dataset.timer = setTimeout(() => this.form.requestSubmit(), 500);" %> + /month +
    +
    + <%= form.text_area "tiers[#{tier.id}][description]", value: tier.description, class: "w-100 max-w-full mt-1", placeholder: "Description", oninput: "clearTimeout(this.dataset.timer); this.dataset.timer = setTimeout(() => this.form.requestSubmit(), 500);" %> +
    + +
    + <%= pop_icon_to "drag-indicator", "javascript:void(0)", class: "draggable-handle cursor-move" %> + <%= pop_icon_to "delete", event_donation_tiers_path(event, tier), method: :delete, data: { turbo_confirm: "Are you sure?" } %> +
    +
    + <% end %> +
+ <% end %> + <%= form_with(url: event_donation_tiers_path(event), local: true, method: :post) do |form| %> +
+ <% unless event.donation_tiers.empty? %> + Changes will be saved automatically + <% end %> + +
+ <% end %> +
+
+
diff --git a/app/views/events/settings/_donations.html.erb b/app/views/events/settings/_donations.html.erb index 7063e812d4..49463ca3d2 100644 --- a/app/views/events/settings/_donations.html.erb +++ b/app/views/events/settings/_donations.html.erb @@ -112,4 +112,6 @@
<% end %> + + <%= render "events/settings/donation_tiers", disabled:, event: @event if Flipper.enabled?(:donation_tiers_2025_06_24, @event) %> <% end %> diff --git a/app/views/events/settings/_features.html.erb b/app/views/events/settings/_features.html.erb index a7ce58bb13..6251e80ccb 100644 --- a/app/views/events/settings/_features.html.erb +++ b/app/views/events/settings/_features.html.erb @@ -16,6 +16,24 @@ event: @event } %> +<%= render partial: "features/preview", locals: { + id: "transaction-tags", + classes: ["feature-transaction-tags"], + feature_flag: :transaction_tags_2022_07_29, + name: "Transaction Tags", + description: "Categorize and filter transactions using tags!", + event: @event + } %> + +<%= render partial: "features/preview", locals: { + id: "donation-tiers", + classes: ["feature-generic"], + feature_flag: :donation_tiers_2025_06_24, + name: "Donation Tiers", + description: "Offer preset monthly donation options with custom names, prices, and perks.", + event: @event + } %> + <% active_spending_control_count = @event.organizer_positions.map(&:active_spending_control).compact.count %> <%= render partial: "features/preview", locals: { id: "spending-controls", diff --git a/config/routes.rb b/config/routes.rb index dcc5c423e6..56775ecac0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -542,6 +542,7 @@ collection do get "start/:event_name", to: "donations#start_donation", as: "start_donation" post "start/:event_name", to: "donations#make_donation", as: "make_donation" + get "start/:event_name/tiers/:tier_id", to: "donations#start_donation", as: "start_donation_tier" get "qr/:event_name.png", to: "donations#qr_code", as: "qr_code" get ":event_name/:donation", to: "donations#finish_donation", as: "finish_donation" get ":event_name/:donation/finished", to: "donations#finished", as: "finished_donation" @@ -738,6 +739,9 @@ namespace :donation do resource :goals, only: [:create, :update] + resource :tiers, only: [:create, :update, :destroy] do + post :set_index, on: :member + end end resources :recurring_donations, only: [:create], path: "recurring" do diff --git a/db/migrate/20250623035907_create_donation_tiers.rb b/db/migrate/20250623035907_create_donation_tiers.rb new file mode 100644 index 0000000000..9eaa3e9c0e --- /dev/null +++ b/db/migrate/20250623035907_create_donation_tiers.rb @@ -0,0 +1,16 @@ +class CreateDonationTiers < ActiveRecord::Migration[7.2] + def change + create_table :donation_tiers do |t| + t.references :event, null: false, foreign_key: true + t.integer :amount_cents, null: false + t.string :name, null: false + t.text :description + t.integer :sort_index + + t.datetime :deleted_at + t.timestamps + end + + add_column :events, :donation_tiers_enabled, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 80e8e360cf..62aebe15b3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -657,6 +657,18 @@ t.index ["stripe_payout_id"], name: "index_donation_payouts_on_stripe_payout_id", unique: true end + create_table "donation_tiers", force: :cascade do |t| + t.bigint "event_id", null: false + t.integer "amount_cents", null: false + t.string "name", null: false + t.text "description" + t.integer "sort_index" + t.datetime "deleted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["event_id"], name: "index_donation_tiers_on_event_id" + end + create_table "donations", force: :cascade do |t| t.text "email" t.text "name" @@ -905,6 +917,7 @@ t.string "short_name" t.integer "risk_level" t.boolean "financially_frozen", default: false, null: false + t.boolean "donation_tiers_enabled", default: false, null: false t.index ["point_of_contact_id"], name: "index_events_on_point_of_contact_id" end @@ -2281,6 +2294,7 @@ add_foreign_key "documents", "users" add_foreign_key "documents", "users", column: "archived_by_id" add_foreign_key "donation_goals", "events" + add_foreign_key "donation_tiers", "events" add_foreign_key "donations", "donation_payouts", column: "payout_id" add_foreign_key "donations", "events" add_foreign_key "donations", "fee_reimbursements"