Skip to content

[Donations] Adding donation tiers #10676

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 51 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
934aeba
Add schema
manuthecoder Jun 23, 2025
b346c49
Add donation tiers feature with enable/disable option in settings
manuthecoder Jun 23, 2025
c3fc47b
Finish rails migration
manuthecoder Jun 23, 2025
eea26a2
Fix linting issues
manuthecoder Jun 23, 2025
b83b28c
Allow users to toggle donation tiers in settings
manuthecoder Jun 23, 2025
e347bc2
Enhance donation tiers settings with detailed descriptions and create…
manuthecoder Jun 24, 2025
db66ec9
Add donation tiers model and controller for event donations
manuthecoder Jun 24, 2025
322774d
Fix input tag self-closing syntax in donation tiers settings
manuthecoder Jun 24, 2025
c81348d
Reintroduce Donation Tiers Controller with complete CRUD functionality
manuthecoder Jun 24, 2025
4ec9e97
Refactor donation tiers controller and update settings view for impro…
manuthecoder Jun 24, 2025
4efca75
Refactor donation tiers form to improve structure and enhance usability
manuthecoder Jun 24, 2025
7c7afdb
Refactor donation tiers form layout for improved button organization …
manuthecoder Jun 24, 2025
427cebe
Refactor donation tiers update logic and form input names for improve…
manuthecoder Jun 24, 2025
dd6c59b
Refactor donation tiers controller and views for improved error handl…
manuthecoder Jun 24, 2025
5ef6e32
Refactor donation tiers form to conditionally display the save change…
manuthecoder Jun 24, 2025
f3446d7
Fix formatting in donation tiers description paragraph
manuthecoder Jun 24, 2025
d066f0a
Add blank line at the end of donation_tier.rb for improved readability
manuthecoder Jun 24, 2025
85eee29
Refactor TiersController and DonationTier model for improved structur…
manuthecoder Jun 24, 2025
5d65861
Merge branch 'main' into donation-tiers
manuthecoder Jun 24, 2025
93306c8
change module to class
manuthecoder Jun 24, 2025
0629404
Fix linting issues
manuthecoder Jun 24, 2025
b9d0e23
Merge branch 'main' into donation-tiers
manuthecoder Jun 24, 2025
6c4b98d
Add donation tiers feature preview to settings page
manuthecoder Jun 24, 2025
a59ca9a
Merge branch 'donation-tiers' of https://github.com/hackclub/hcb into…
manuthecoder Jun 24, 2025
dbc76ed
Fix input on dark mode
manuthecoder Jun 24, 2025
9ca6856
Enhance styling for donation tier image upload section in dark mode
manuthecoder Jun 24, 2025
80b936f
Add donation tiers rendering to the start donation page
manuthecoder Jun 24, 2025
7486fef
Improve layout and structure of donation tiers and header sections
manuthecoder Jun 25, 2025
c9e1db7
Clean up header code
manuthecoder Jun 25, 2025
f9455e4
Refactor donation form and tiers rendering for improved logic and use…
manuthecoder Jun 25, 2025
dcd315b
Enhance donation tiers layout and add share link functionality
manuthecoder Jun 25, 2025
cfcdf02
Fix donation amount field visibility logic in donation form
manuthecoder Jun 25, 2025
9170590
Merge branch 'main' into donation-tiers
manuthecoder Jun 25, 2025
1d1ea94
Remove unused image_url column from donation_tiers migration and schema
manuthecoder Jun 25, 2025
6272844
Add sortable component
manuthecoder Jun 25, 2025
7482812
Fix CSS
manuthecoder Jun 25, 2025
2ef9e90
Refactor donation tiers form to auto-submit on field blur and update …
manuthecoder Jun 26, 2025
4fabc33
Improve form user experience
manuthecoder Jun 26, 2025
84e7bba
Fix draggable handle link to prevent navigation
manuthecoder Jun 26, 2025
d5bb01c
Add basic reordering functionality
manuthecoder Jun 26, 2025
54f90e1
Rename organizerPositions to positions in donation tier sort controll…
manuthecoder Jun 26, 2025
f761b48
Remove background color from the "Create tier" button in donation tie…
manuthecoder Jun 26, 2025
d7c81c4
Implement donation tier reordering functionality with set_index action
manuthecoder Jun 26, 2025
d30cb0e
Update schema information comments in DonationTier model
manuthecoder Jun 26, 2025
e9da536
Refactor donation tier ordering to use sort_index instead of position
manuthecoder Jun 26, 2025
964a419
Refactor donation tier creation to use sort_index for ordering and up…
manuthecoder Jun 26, 2025
fb21b37
Update sort_index calculation for new donation tiers to ensure correc…
manuthecoder Jun 26, 2025
3fce218
Remove error flash messages on record invalid exceptions in donation …
manuthecoder Jun 26, 2025
060e967
Remove debug output and ensure consistent error handling in donation …
manuthecoder Jun 26, 2025
f087fbe
Debounce input!
manuthecoder Jun 26, 2025
add1ef7
Add minimum value validation for donation tier amount input
manuthecoder Jun 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/assets/images/icons/drag-indicator.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion app/assets/stylesheets/components/_cards.scss
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
z-index: 999;
}

.draggable-source--is-dragging .card {
.draggable-source--is-dragging .card, .draggable-source--is-dragging.card {
opacity: 0.5;
}

Expand Down
88 changes: 88 additions & 0 deletions app/controllers/donation/tiers_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# 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 = DonationTier.find_by(id: params[:event_id])
authorize tier.event, :update?

index = params[:index]

# get all the organizer positions 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: organizer_positions.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
puts @event.inspect
render json: { error: "Event not found" }, status: :not_found unless @event
end

end

end
1 change: 1 addition & 0 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,7 @@ def event_params
:slug,
:hidden,
:donation_page_enabled,
:donation_tiers_enabled,
:donation_page_message,
:reimbursements_require_organizer_peer_review,
:public_reimbursement_page_enabled,
Expand Down
30 changes: 30 additions & 0 deletions app/javascript/controllers/donation_tier_sort_controller.js
Original file line number Diff line number Diff line change
@@ -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({ index: newIndex }),
})
}
}
2 changes: 2 additions & 0 deletions app/javascript/controllers/sortable_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions app/models/donation_tier.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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 DonationTier < 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
2 changes: 2 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -257,6 +258,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

has_many :lob_addresses
has_many :checks, through: :lob_addresses
Expand Down
22 changes: 12 additions & 10 deletions app/views/donations/_donation_form.html.erb
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<%= turbo_frame_tag :donation do %>
<ul class="tab-container max-width-1 mx-auto bg-transparent">
<li class="<%= "active" unless @monthly %>">
<%= link_to "One-time", start_donation_donations_path(@event), data: { turbo: true } %>
</li>
<li class="<%= "active" if @monthly %>">
<%= link_to "Monthly", start_donation_donations_path(@event, monthly: true), data: { turbo: true } %>
</li>
</ul>
<% unless @tier %>
<ul class="tab-container max-width-1 mx-auto bg-transparent">
<li class="<%= "active" unless @monthly %>">
<%= link_to "One-time", upsert_query_params(monthly: nil), data: { turbo: true } %>
</li>
<li class="<%= "active" if @monthly %>">
<%= link_to "Monthly", upsert_query_params(monthly: true), data: { turbo: true } %>
</li>
</ul>
<% 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| %>
Expand Down Expand Up @@ -44,7 +46,7 @@
return '0.00';
}
}">
<div class="field">
<div class="field <%= "hidden" if @tier %>">
<% if @international %>
<%= form.label :amount, "Donation amount (USD)" %>
<% else %>
Expand All @@ -53,7 +55,7 @@
<div class="flex items-center">
<span class="bold muted shrink-none" style="width: 1rem;">$</span>
<%= 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
Expand Down
63 changes: 63 additions & 0 deletions app/views/donations/_donation_tiers.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<% if @event.donation_tiers.size > 0 %>
<% 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 %>

<div class="card max-w-sm w-full mx-auto border border-2 border-blue relative shadow-none mt-3 mb-5">
<div class="flex items-center justify-center absolute top-0 right-0 m-3 bg-blue rounded-full p-1 text-white">
<%= inline_icon "check-thin", size: 24 %>
</div>
<h2 class="mt-3 mb-4">
<%= @tier&.name || "Custom" %>
</h2>
<p class="text opacity-60 mb-3 flex-1 max-w-full break-words whitespace-normal">
<%= @tier&.description || "Pay a custom amount, one-time or recurring, to support #{@event.name}" %>
</p>
<% if @tier %>
<div class="flex items-end gap-1">
<span class="text-xl font-bold">
<%= render_money_short @tier.amount_cents %>
</span>
<span class="opacity-60">per month</span>
</div>

<button class="btn btn-primary mt-4 w-full" onclick="try {navigator.share({ text: 'Donate to <%= @event.name %>', url: window.location.href })} catch (e) { navigator.clipboard.writeText(window.location.href); alert('Copied URL to clipboard!'); }">
<%= inline_icon "share" %>
Share link
</button>
<% end %>
</div>
<% else %>
<h2 class="mt-8">Dontate</h2>
<div class="mt-4 px-2 -mx-2 flex gap-4 overflow-x-auto whitespace-nowrap pb-2 mx-auto">
<div class="card w-[250px] shrink-0 mb-8 flex flex-col">
<h3 class="mb-0 mt-0">
Custom
</h3>
<p class="text opacity-60 mb-2 flex-1 max-w-full break-words whitespace-normal">
Pay a custom amount, one-time or recurring, to support <%= @event.name %>
</p>
<div class="flex-1"></div>
<%= link_to upsert_query_params(custom_amount: true), class: "w-full btn btn-primary mt-auto" do %>
Continue
<% end %>
</div>
<% @event.donation_tiers.each do |donation_tier| %>
<div class="card w-[250px] shrink-0 mb-8 flex flex-col">
<h3 class="mb-0 mt-0">
<%= donation_tier.name %>
</h3>
<p class="text opacity-60 mb-3 flex-1 max-w-full break-words whitespace-normal">
<%= donation_tier.description %>
</p>
<div class="flex-1"></div>
<%= link_to upsert_query_params(tier_id: donation_tier.id, monthly: true), class: "w-full btn btn-primary mt-auto" do %>
<%= render_money_short donation_tier.amount_cents %><span class="font-bold text-base opacity-60">/month</span>
<% end %>
</div>
<% end %>
</div>
<% end %>
<% end %>
15 changes: 12 additions & 3 deletions app/views/donations/start_donation.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<% title "Give to " + @event.name %>
<% page_sm %>
<% no_app_shell %>
<% @tier = @event.donation_tiers.find(params[:tier_id]) if params[:tier_id] %>

<% content_for :head do %>
<meta name="twitter:card" content="summary_large_image">
Expand All @@ -14,7 +15,13 @@
<meta name="description" content="<%= description %>">
<% end %>

<%= render "events/landing/header" %>
<%= render "events/landing/header" do %>
<% if @event.donation_goal.present? %>
<div class="shrink-0 w-full max-w-sm ml-auto">
<%= render "donations/donation_goal", donation: @donation %>
</div>
<% end %>
<% end %>

<% if @event.donation_page_message.present? %>
<div class="container container--sm">
Expand All @@ -24,8 +31,10 @@
</div>
<% end %>

<%= render "donations/donation_goal", donation: @donation %>
<%= render "donations/donation_form", donation: @donation %>
<%= render "donations/donation_tiers", donation: @donation %>
<% if @event.donation_tiers.empty? || @tier || params[:custom_amount] %>
<%= render "donations/donation_form", donation: @donation %>
<% end %>

<% if @event.demo_mode? %>
<div class="container container--sm">
Expand Down
19 changes: 13 additions & 6 deletions app/views/events/landing/_header.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,20 @@
<div class="donations-header-image py-20"
<% if @has_header_image %> 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 %>>
<h1 class="mt-0 mb0 border-none center <%= "smoke drop-shadow-lg" if @has_header_image %>">
<span class="h3 caps block <%= @has_header_image ? "muted" : "secondary" %>"><%= defined?(lead) ? lead : "Donate to" %></span>
<div class="flex justify-center items-center mt1">
<%= render "donations/logo" %>
<span class="ml2"><%= @event.name %></span>
<div class="container flex flex-col sm:flex-row items-center <%= yield.present? ? "" : "justify-center" %>">
<div>
<h1 class="mt-0 mb0 border-none <%= yield.present? ? "" : "center" %> <%= "smoke drop-shadow-lg" if @has_header_image %>">
<span class="h3 caps block <%= @has_header_image ? "muted" : "secondary" %>"><%= defined?(lead) ? lead : "Donate to" %></span>
<div class="flex justify-center items-center mt1">
<%= render "donations/logo" %>
<span class="ml2"><%= @event.name %></span>
</div>
</h1>
</div>
</h1>
<% if yield.present? %>
<%= yield %>
<% end %>
</div>
</div>

<% if @background.present? %>
Expand Down
Loading
Loading