Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions app/controllers/public/scenarios_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class Public::ScenariosController < ApplicationController
allow_unauthenticated_access only: :show
skip_before_action :require_organization_membership, only: :show

def show
@scenario = Current.organization.scenarios.find_by!(share_token: params[:token])
end
end
26 changes: 26 additions & 0 deletions app/controllers/scenarios/shares_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
class Scenarios::SharesController < ApplicationController
include ScenarioScoping

before_action :set_scenario

def create
@scenario.enable_sharing!
redirect_to scenario_path(@scenario)
end

def update
@scenario.regenerate_share_token!
redirect_to scenario_path(@scenario)
end

def destroy
@scenario.disable_sharing!
redirect_to scenario_path(@scenario)
end

private

def set_scenario
@scenario = accessible_scenarios.find(params[:scenario_id])
end
end
25 changes: 25 additions & 0 deletions app/javascript/controllers/clipboard_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["source", "label"]
static values = { confirmDuration: { type: Number, default: 1500 } }

select() {
this.sourceTarget.select()
}

async copy() {
try {
await navigator.clipboard.writeText(this.sourceTarget.value)
} catch {
this.sourceTarget.select()
document.execCommand("copy")
}

if (this.hasLabelTarget) {
const original = this.labelTarget.textContent
this.labelTarget.textContent = "Copied!"
setTimeout(() => { this.labelTarget.textContent = original }, this.confirmDurationValue)
}
}
}
16 changes: 16 additions & 0 deletions app/models/scenario.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ class Scenario < ApplicationRecord

before_create :ensure_greatest_community_need

def shared?
share_token.present?
end

def enable_sharing!
regenerate_share_token! unless shared?
end

def regenerate_share_token!
update!(share_token: SecureRandom.base58(24))
end

def disable_sharing!
update!(share_token: nil)
end

def ongoing_percentage_total
ongoing_allocations.sum { |allocation| allocation.percentage.to_i }
end
Expand Down
19 changes: 19 additions & 0 deletions app/views/public/scenarios/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<div class="mx-auto max-w-2xl w-full px-4 py-10">
<div class="rounded-md border border-line bg-surface-soft px-4 py-2.5 text-sm text-ink-soft">
Read-only shared view of a giving scenario.
</div>

<div class="mt-6">
<h1 class="font-serif font-medium text-3xl text-ink"><%= @scenario.name %></h1>
<p class="mt-1 text-sm text-ink-soft">Shared by <span class="font-medium text-ink"><%= @scenario.user.display_name %></span></p>
<% if @scenario.total_giving_amount.to_i.positive? %>
<p class="mt-3 text-ink-soft">
Total giving <span class="font-medium text-ink"><%= number_to_currency(@scenario.total_giving_amount, precision: 0) %></span>
</p>
<% end %>
</div>

<div class="mt-8">
<%= render "scenarios/summary", scenario: @scenario %>
</div>
</div>
30 changes: 30 additions & 0 deletions app/views/scenarios/_share.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<%# locals: (scenario:) %>
<div class="mt-6 shrink-0" data-controller="dialog">
<button type="button" data-action="dialog#open"
class="inline-flex items-center gap-1.5 rounded-md border border-line bg-surface px-3.5 py-2 text-sm font-medium text-ink-soft hover:bg-canvas cursor-pointer transition">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
</svg>
Share
</button>

<dialog data-dialog-target="dialog" data-action="click->dialog#backdropClose"
class="m-auto w-full max-w-lg rounded-2xl p-0 shadow-lg backdrop:bg-[rgba(20,18,14,0.48)]">
<div class="p-8">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="font-serif font-medium text-2xl text-ink">Share a read-only link</h2>
<p class="mt-2 text-sm text-ink-soft">Anyone with the link can view this scenario without signing in.</p>
</div>
<button type="button" data-action="dialog#close" aria-label="Close"
class="shrink-0 text-ink-faint hover:text-ink cursor-pointer bg-transparent p-0 leading-none">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>

<%= render "scenarios/share_panel", scenario: scenario %>
</div>
</dialog>
</div>
31 changes: 31 additions & 0 deletions app/views/scenarios/_share_panel.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<%# locals: (scenario:) %>
<%= turbo_frame_tag dom_id(scenario, :share) do %>
<% if scenario.shared? %>
<div class="mt-6" data-controller="clipboard">
<div class="flex items-center gap-2">
<input type="text" readonly
value="<%= public_scenario_url(scenario.share_token) %>"
data-clipboard-target="source"
data-action="focus->clipboard#select"
class="min-w-0 flex-1 rounded-md border border-line bg-surface-soft px-3 py-2 text-sm text-ink-soft" />
<button type="button" data-action="clipboard#copy"
class="shrink-0 rounded-md border border-line px-3 py-2 text-sm font-medium text-ink-soft hover:bg-canvas cursor-pointer transition">
<span data-clipboard-target="label">Copy</span>
</button>
</div>
<div class="mt-4 flex items-center gap-4 text-sm">
<%= button_to "Regenerate link", scenario_share_path(scenario), method: :patch,
class: "text-ink-soft hover:text-ink cursor-pointer bg-transparent p-0",
data: { turbo_confirm: "Regenerate the link? The current link will stop working." } %>
<%= button_to "Stop sharing", scenario_share_path(scenario), method: :delete,
class: "text-ink-soft hover:text-danger cursor-pointer bg-transparent p-0",
data: { turbo_confirm: "Stop sharing? The link will stop working." } %>
</div>
</div>
<% else %>
<div class="mt-6">
<%= button_to "Create share link", scenario_share_path(scenario), method: :post,
class: "w-full rounded-md px-4 py-2.5 bg-accent hover:bg-[#444] text-white text-sm font-medium cursor-pointer transition" %>
</div>
<% end %>
<% end %>
93 changes: 93 additions & 0 deletions app/views/scenarios/_summary.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<%# locals: (scenario:) %>
<% ongoing_total = scenario.ongoing_percentage_total %>
<% ongoing_amount = scenario.ongoing_giving_amount %>
<% one_time_amount = scenario.one_time_giving_amount %>
<% total_amount = scenario.total_giving_amount.to_i %>
<div class="rounded-2xl border border-line bg-surface p-6 shadow-sm">
<h2 class="font-serif font-medium text-xl text-ink">Allocation summary</h2>
<% if total_amount.positive? %>
<p class="mt-2 text-sm text-ink-soft">
Ongoing <span class="font-medium text-brand"><%= number_to_currency(ongoing_amount, precision: 0) %></span>
+ One-time <span class="font-medium text-ink"><%= number_to_currency(one_time_amount, precision: 0) %></span>
= Total <span class="font-medium text-ink"><%= number_to_currency(total_amount, precision: 0) %></span>
</p>
<% else %>
<p class="mt-2 text-sm text-ink-soft">Set your total above to see a full breakdown.</p>
<% end %>

<div class="mt-5">
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-ink-faint">Ongoing giving</h3>
<p class="mt-1 font-serif text-4xl font-medium text-ink"><%= number_to_currency(ongoing_amount, precision: 0) %></p>
<% if scenario.ongoing_allocations.any? %>
<p class="mt-1 text-sm text-ink-soft">
<%= ongoing_total %>% allocated across <%= pluralize(scenario.ongoing_allocations.count, "cause") %>
</p>

<% remaining = [100 - ongoing_total, 0].max %>
<div class="mt-4 flex h-3 gap-1 overflow-hidden rounded-full">
<% scenario.ongoing_allocations.each_with_index do |allocation, index| %>
<div style="width: <%= allocation.percentage %>%; background-color: <%= allocation_chart_color(index) %>"></div>
<% end %>
<% if remaining.positive? %>
<div class="bg-line-soft" style="width: <%= remaining %>%"></div>
<% end %>
</div>

<ul class="mt-4 space-y-3">
<% scenario.ongoing_allocations.each_with_index do |allocation, index| %>
<li>
<div class="flex items-baseline justify-between">
<span class="flex items-center gap-2 text-ink">
<span class="h-2.5 w-2.5 shrink-0 rounded-full" style="background-color: <%= allocation_chart_color(index) %>"></span>
<%= allocation.display_label %>
</span>
<span class="flex items-baseline gap-3">
<span class="font-medium text-ink"><%= allocation.percentage %>%</span>
<span class="text-ink-soft"><%= number_to_currency(allocation.dollar_amount, precision: 0) %></span>
</span>
</div>
<p class="ml-[18px] mt-0.5 text-xs text-ink-faint">
&infin; Est. <%= number_to_currency(allocation.perpetuity_annual_amount, precision: 0) %> /yr in perpetuity
</p>
</li>
<% end %>
</ul>
<% else %>
<p class="mt-3 text-ink-faint">No ongoing allocations yet.</p>
<% end %>
</div>

<% if scenario.one_time_allocations.any? %>
<div class="mt-6 border-t border-line pt-6">
<div class="flex items-center justify-between">
<h3 class="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
One time giving
<span class="rounded border border-line px-1.5 py-0.5 text-[10px] font-medium tracking-normal text-ink-soft">ADD-ON</span>
</h3>
<span class="font-medium text-ink"><%= number_to_currency(one_time_amount, precision: 0) %></span>
</div>
<ul class="mt-3 space-y-2">
<% scenario.one_time_allocations.each do |allocation| %>
<li class="flex items-baseline justify-between">
<span class="flex items-center gap-2 text-ink">
<span class="h-2.5 w-2.5 shrink-0 rounded-full bg-danger"></span>
<%= allocation.display_label %>
</span>
<span class="flex items-baseline gap-3">
<span class="font-medium text-ink-soft"><%= allocation.share_percentage %>%</span>
<span class="text-ink-soft"><%= number_to_currency(allocation.amount, precision: 0) %></span>
</span>
</li>
<% end %>
</ul>
</div>
<% end %>

<% if total_amount.positive? %>
<div class="mt-6 rounded-xl border border-line bg-surface-soft p-4 text-sm text-ink-soft">
Ongoing <span class="font-medium text-brand"><%= number_to_currency(ongoing_amount, precision: 0) %></span>
+ One-time <span class="font-medium text-ink"><%= number_to_currency(one_time_amount, precision: 0) %></span>
= Total <span class="font-medium text-ink"><%= number_to_currency(total_amount, precision: 0) %></span>
</div>
<% end %>
</div>
98 changes: 5 additions & 93 deletions app/views/scenarios/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
</div>
<% end %>

<%= render "scenarios/names/name", scenario: @scenario %>
<div class="flex items-start justify-between gap-3">
<%= render "scenarios/names/name", scenario: @scenario %>
<%= render "scenarios/share", scenario: @scenario %>
</div>

<%= render "scenarios/total_giving_amounts/total_giving_amount", scenario: @scenario %>

Expand All @@ -37,97 +40,6 @@
</div>

<!-- Summary -->
<% ongoing_total = @scenario.ongoing_percentage_total %>
<% ongoing_amount = @scenario.ongoing_giving_amount %>
<% one_time_amount = @scenario.one_time_giving_amount %>
<% total_amount = @scenario.total_giving_amount.to_i %>
<div class="rounded-2xl border border-line bg-surface p-6 shadow-sm">
<h2 class="font-serif font-medium text-xl text-ink">Allocation summary</h2>
<% if total_amount.positive? %>
<p class="mt-2 text-sm text-ink-soft">
Ongoing <span class="font-medium text-brand"><%= number_to_currency(ongoing_amount, precision: 0) %></span>
+ One-time <span class="font-medium text-ink"><%= number_to_currency(one_time_amount, precision: 0) %></span>
= Total <span class="font-medium text-ink"><%= number_to_currency(total_amount, precision: 0) %></span>
</p>
<% else %>
<p class="mt-2 text-sm text-ink-soft">Set your total above to see a full breakdown.</p>
<% end %>

<div class="mt-5">
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-ink-faint">Ongoing giving</h3>
<p class="mt-1 font-serif text-4xl font-medium text-ink"><%= number_to_currency(ongoing_amount, precision: 0) %></p>
<% if @scenario.ongoing_allocations.any? %>
<p class="mt-1 text-sm text-ink-soft">
<%= ongoing_total %>% allocated across <%= pluralize(@scenario.ongoing_allocations.count, "cause") %>
</p>

<% remaining = [100 - ongoing_total, 0].max %>
<div class="mt-4 flex h-3 gap-1 overflow-hidden rounded-full">
<% @scenario.ongoing_allocations.each_with_index do |allocation, index| %>
<div style="width: <%= allocation.percentage %>%; background-color: <%= allocation_chart_color(index) %>"></div>
<% end %>
<% if remaining.positive? %>
<div class="bg-line-soft" style="width: <%= remaining %>%"></div>
<% end %>
</div>

<ul class="mt-4 space-y-3">
<% @scenario.ongoing_allocations.each_with_index do |allocation, index| %>
<li>
<div class="flex items-baseline justify-between">
<span class="flex items-center gap-2 text-ink">
<span class="h-2.5 w-2.5 shrink-0 rounded-full" style="background-color: <%= allocation_chart_color(index) %>"></span>
<%= allocation.display_label %>
</span>
<span class="flex items-baseline gap-3">
<span class="font-medium text-ink"><%= allocation.percentage %>%</span>
<span class="text-ink-soft"><%= number_to_currency(allocation.dollar_amount, precision: 0) %></span>
</span>
</div>
<p class="ml-[18px] mt-0.5 text-xs text-ink-faint">
&infin; Est. <%= number_to_currency(allocation.perpetuity_annual_amount, precision: 0) %> /yr in perpetuity
</p>
</li>
<% end %>
</ul>
<% else %>
<p class="mt-3 text-ink-faint">No ongoing allocations yet.</p>
<% end %>
</div>

<% if @scenario.one_time_allocations.any? %>
<div class="mt-6 border-t border-line pt-6">
<div class="flex items-center justify-between">
<h3 class="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
One time giving
<span class="rounded border border-line px-1.5 py-0.5 text-[10px] font-medium tracking-normal text-ink-soft">ADD-ON</span>
</h3>
<span class="font-medium text-ink"><%= number_to_currency(one_time_amount, precision: 0) %></span>
</div>
<ul class="mt-3 space-y-2">
<% @scenario.one_time_allocations.each do |allocation| %>
<li class="flex items-baseline justify-between">
<span class="flex items-center gap-2 text-ink">
<span class="h-2.5 w-2.5 shrink-0 rounded-full bg-danger"></span>
<%= allocation.display_label %>
</span>
<span class="flex items-baseline gap-3">
<span class="font-medium text-ink-soft"><%= allocation.share_percentage %>%</span>
<span class="text-ink-soft"><%= number_to_currency(allocation.amount, precision: 0) %></span>
</span>
</li>
<% end %>
</ul>
</div>
<% end %>

<% if total_amount.positive? %>
<div class="mt-6 rounded-xl border border-line bg-surface-soft p-4 text-sm text-ink-soft">
Ongoing <span class="font-medium text-brand"><%= number_to_currency(ongoing_amount, precision: 0) %></span>
+ One-time <span class="font-medium text-ink"><%= number_to_currency(one_time_amount, precision: 0) %></span>
= Total <span class="font-medium text-ink"><%= number_to_currency(total_amount, precision: 0) %></span>
</div>
<% end %>
</div>
<%= render "summary", scenario: @scenario %>
<% end %>
</div>
5 changes: 5 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@
# Dev-only convenience: sign in as the first user without a password
get "auto_sign_in", to: "auto_sign_in#create" if Rails.env.development?

namespace :public do
resources :scenarios, only: :show, param: :token
end

resources :scenarios do
resource :name, only: %i[ show edit update ], module: :scenarios
resource :total_giving_amount, only: %i[ show edit update ], module: :scenarios
resource :share, only: %i[ create update destroy ], module: :scenarios
resources :allocations, only: %i[ create update destroy ]
end

Expand Down
6 changes: 6 additions & 0 deletions db/migrate/20260701000000_add_share_token_to_scenarios.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddShareTokenToScenarios < ActiveRecord::Migration[8.1]
def change
add_column :scenarios, :share_token, :string
add_index :scenarios, :share_token, unique: true
end
end
Loading
Loading