Skip to content

Commit 6f34700

Browse files
committed
feat: add new community bounty creation page
1 parent 89ba674 commit 6f34700

File tree

2 files changed

+376
-0
lines changed

2 files changed

+376
-0
lines changed
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
defmodule AlgoraWeb.Org.BountiesNewLive do
2+
@moduledoc false
3+
use AlgoraWeb, :live_view
4+
5+
import Ecto.Changeset
6+
7+
alias Algora.Accounts
8+
alias Algora.Accounts.User
9+
alias Algora.Bounties
10+
alias Algora.Bounties.Bounty
11+
alias Algora.Github
12+
alias Algora.Payments
13+
alias AlgoraWeb.Forms.BountyForm
14+
15+
require Logger
16+
17+
@impl true
18+
def mount(_params, _session, socket) do
19+
org = socket.assigns.current_org
20+
21+
open_bounties =
22+
Bounties.list_bounties(
23+
owner_id: org.id,
24+
status: :open,
25+
limit: page_size(),
26+
current_user: socket.assigns[:current_user]
27+
)
28+
29+
top_earners = Accounts.list_developers(org_id: org.id, limit: 10, earnings_gt: Money.zero(:USD))
30+
stats = Bounties.fetch_stats(org_id: org.id, current_user: socket.assigns[:current_user])
31+
transactions = Payments.list_hosted_transactions(org.id, limit: page_size())
32+
33+
if connected?(socket) do
34+
Phoenix.PubSub.subscribe(Algora.PubSub, "auth:#{socket.id}")
35+
end
36+
37+
socket =
38+
socket
39+
|> assign(:org, org)
40+
|> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user))
41+
|> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id}))
42+
|> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{})))
43+
|> assign(:page_title, org.name)
44+
|> assign(:open_bounties, open_bounties)
45+
|> assign(:transactions, transactions)
46+
|> assign(:top_earners, top_earners)
47+
|> assign(:stats, stats)
48+
49+
{:ok, socket}
50+
end
51+
52+
@impl true
53+
def render(assigns) do
54+
~H"""
55+
<div class="container mx-auto max-w-7xl space-y-6 p-6">
56+
<!-- Org Header -->
57+
<div class="rounded-xl border bg-card p-6 text-card-foreground">
58+
<div class="flex flex-col gap-6 md:flex-row">
59+
<div class="flex-shrink-0">
60+
<.avatar class="h-12 w-12 md:h-16 md:w-16">
61+
<.avatar_image src={@org.avatar_url} alt={@org.name} />
62+
</.avatar>
63+
</div>
64+
65+
<div class="flex-1 space-y-2">
66+
<div>
67+
<h1 class="text-2xl font-bold">{@org.name}</h1>
68+
<p class="mt-1 text-muted-foreground">{@org.bio}</p>
69+
</div>
70+
71+
<div class="flex gap-4 items-center">
72+
<%= for {platform, icon} <- social_links(),
73+
url = social_link(@org, platform),
74+
not is_nil(url) do %>
75+
<.link href={url} target="_blank" class="text-muted-foreground hover:text-foreground">
76+
<.icon name={icon} class="size-5" />
77+
</.link>
78+
<% end %>
79+
</div>
80+
</div>
81+
</div>
82+
</div>
83+
84+
<div class="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4">
85+
<.stat_card
86+
title="Open Bounties"
87+
value={Money.to_string!(@stats.open_bounties_amount)}
88+
subtext={"#{@stats.open_bounties_count} bounties"}
89+
navigate={~p"/org/#{@org.handle}/bounties?status=open"}
90+
icon="tabler-diamond"
91+
/>
92+
<.stat_card
93+
title="Total Awarded"
94+
value={Money.to_string!(@stats.total_awarded_amount)}
95+
subtext={"#{@stats.rewarded_bounties_count} #{ngettext("bounty", "bounties", @stats.rewarded_bounties_count)} / #{@stats.rewarded_tips_count} #{ngettext("tip", "tips", @stats.rewarded_tips_count)} / #{@stats.rewarded_contracts_count} #{ngettext("contract", "contracts", @stats.rewarded_contracts_count)}"}
96+
navigate={~p"/org/#{@org.handle}/bounties?status=completed"}
97+
icon="tabler-gift"
98+
/>
99+
<.stat_card
100+
title="Solvers"
101+
value={@stats.solvers_count}
102+
subtext={"+#{@stats.solvers_diff} from last month"}
103+
navigate={~p"/org/#{@org.handle}/leaderboard"}
104+
icon="tabler-user-code"
105+
/>
106+
<.stat_card
107+
title="Members"
108+
value={@stats.members_count}
109+
subtext=""
110+
navigate={~p"/org/#{@org.handle}/team"}
111+
icon="tabler-users"
112+
/>
113+
</div>
114+
115+
<.section>
116+
{create_bounty(assigns)}
117+
</.section>
118+
119+
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
120+
<!-- Bounties Section -->
121+
<div class="space-y-4 rounded-xl border bg-card p-6">
122+
<div class="flex items-center justify-between">
123+
<h2 class="text-lg font-semibold">Open Bounties</h2>
124+
<.link
125+
href={~p"/org/#{@org.handle}/bounties?status=open"}
126+
class="text-sm text-muted-foreground hover:underline"
127+
>
128+
View all
129+
</.link>
130+
</div>
131+
<div class="relative -ml-4 w-full overflow-auto scrollbar-thin">
132+
<table class="w-full caption-bottom text-sm">
133+
<tbody>
134+
<%= for bounty <- @open_bounties do %>
135+
<tr class="h-10 border-b transition-colors hover:bg-muted/10">
136+
<td class="p-4 py-0 align-middle">
137+
<div class="flex items-center gap-4">
138+
<div class="font-display shrink-0 whitespace-nowrap text-base font-semibold text-success">
139+
{Money.to_string!(bounty.amount)}
140+
</div>
141+
142+
<.link
143+
href={Bounty.url(bounty)}
144+
class="max-w-[400px] truncate text-sm text-foreground hover:underline"
145+
>
146+
{bounty.ticket.title}
147+
</.link>
148+
149+
<div class="flex shrink-0 items-center gap-1 whitespace-nowrap text-sm text-muted-foreground">
150+
<.icon name="tabler-chevron-right" class="h-4 w-4" />
151+
<.link href={Bounty.url(bounty)} class="hover:underline">
152+
{Bounty.path(bounty)}
153+
</.link>
154+
</div>
155+
</div>
156+
</td>
157+
</tr>
158+
<% end %>
159+
</tbody>
160+
</table>
161+
</div>
162+
</div>
163+
<!-- Completed Bounties -->
164+
<div class="space-y-4 rounded-xl border bg-card p-6">
165+
<div class="flex items-center justify-between">
166+
<h2 class="text-lg font-semibold">Completed Bounties</h2>
167+
<.link
168+
href={~p"/org/#{@org.handle}/bounties?status=completed"}
169+
class="text-sm text-muted-foreground hover:underline"
170+
>
171+
View all
172+
</.link>
173+
</div>
174+
<div class="relative -ml-4 w-full overflow-auto scrollbar-thin">
175+
<table class="w-full caption-bottom text-sm">
176+
<tbody>
177+
<%= for %{transaction: transaction, ticket: ticket} <- @transactions do %>
178+
<tr class="h-10 border-b transition-colors hover:bg-muted/10">
179+
<td class="p-4 py-0 align-middle">
180+
<div class="flex items-center gap-4">
181+
<div class="font-display shrink-0 whitespace-nowrap text-base font-semibold text-success">
182+
{Money.to_string!(transaction.net_amount)}
183+
</div>
184+
185+
<.link
186+
href={
187+
if ticket.repository,
188+
do:
189+
"https://github.com/#{ticket.repository.user.provider_login}/#{ticket.repository.name}/issues/#{ticket.number}",
190+
else: ticket.url
191+
}
192+
class="max-w-[400px] truncate text-sm text-foreground hover:underline"
193+
>
194+
{ticket.title}
195+
</.link>
196+
197+
<div class="flex shrink-0 items-center gap-1 whitespace-nowrap text-sm text-muted-foreground">
198+
<.icon name="tabler-chevron-right" class="h-4 w-4" />
199+
<.link
200+
href={
201+
if ticket.repository,
202+
do:
203+
"https://github.com/#{ticket.repository.user.provider_login}/#{ticket.repository.name}/issues/#{ticket.number}",
204+
else: ticket.url
205+
}
206+
class="hover:underline"
207+
>
208+
{Bounty.path(%{ticket: ticket})}
209+
</.link>
210+
</div>
211+
</div>
212+
</td>
213+
</tr>
214+
<% end %>
215+
</tbody>
216+
</table>
217+
</div>
218+
</div>
219+
</div>
220+
221+
<div class="space-y-4">
222+
<h2 class="text-lg font-semibold">Top Earners</h2>
223+
<div class="rounded-xl border bg-card">
224+
<%= for {earner, idx} <- Enum.with_index(@top_earners) do %>
225+
<div class="flex items-center gap-4 border-b p-4 last:border-0">
226+
<div class="w-8 flex-shrink-0 text-center font-mono text-muted-foreground">
227+
#{idx + 1}
228+
</div>
229+
<.link navigate={User.url(earner)} class="flex flex-1 items-center gap-3">
230+
<.avatar class="h-8 w-8">
231+
<.avatar_image src={earner.avatar_url} alt={earner.name} />
232+
</.avatar>
233+
<div>
234+
<div class="font-medium">
235+
{earner.name} {Algora.Misc.CountryEmojis.get(earner.country)}
236+
</div>
237+
<div class="text-sm text-muted-foreground">@{User.handle(earner)}</div>
238+
</div>
239+
</.link>
240+
<div class="font-display flex-shrink-0 font-medium text-success">
241+
{Money.to_string!(earner.total_earned)}
242+
</div>
243+
</div>
244+
<% end %>
245+
</div>
246+
</div>
247+
</div>
248+
"""
249+
end
250+
251+
@impl true
252+
def handle_info({:authenticated, user}, socket) do
253+
socket =
254+
socket
255+
|> assign(:current_user, user)
256+
|> assign(:current_context, Accounts.get_last_context_user(user))
257+
|> assign(:all_contexts, Accounts.get_contexts(user))
258+
|> assign(:has_fresh_token?, true)
259+
260+
case socket.assigns.pending_action do
261+
{event, params} ->
262+
socket = assign(socket, :pending_action, nil)
263+
handle_event(event, params, socket)
264+
265+
nil ->
266+
{:noreply, socket}
267+
end
268+
end
269+
270+
@impl true
271+
def handle_event("create_bounty" = event, %{"bounty_form" => params} = unsigned_params, socket) do
272+
if socket.assigns.has_fresh_token? do
273+
changeset = %BountyForm{} |> BountyForm.changeset(params) |> Map.put(:action, :validate)
274+
275+
amount = get_field(changeset, :amount)
276+
ticket_ref = get_field(changeset, :ticket_ref)
277+
278+
with %{valid?: true} <- changeset,
279+
{:ok, _bounty} <-
280+
Bounties.create_bounty(
281+
%{
282+
creator: socket.assigns.current_user,
283+
owner: socket.assigns.current_user,
284+
amount: amount,
285+
ticket_ref: %{
286+
owner: ticket_ref.owner,
287+
repo: ticket_ref.repo,
288+
number: ticket_ref.number
289+
}
290+
},
291+
visibility: get_field(changeset, :visibility),
292+
shared_with: get_field(changeset, :shared_with)
293+
) do
294+
{:noreply, put_flash(socket, :info, "Bounty created")}
295+
else
296+
%{valid?: false} ->
297+
{:noreply, assign(socket, :bounty_form, to_form(changeset))}
298+
299+
{:error, :already_exists} ->
300+
{:noreply, put_flash(socket, :warning, "You already have a bounty for this ticket")}
301+
302+
{:error, _reason} ->
303+
{:noreply, put_flash(socket, :error, "Something went wrong")}
304+
end
305+
else
306+
{:noreply,
307+
socket
308+
|> assign(:pending_action, {event, unsigned_params})
309+
|> push_event("open_popup", %{url: socket.assigns.oauth_url})}
310+
end
311+
end
312+
313+
@impl true
314+
def handle_event(_event, _params, socket) do
315+
{:noreply, socket}
316+
end
317+
318+
defp create_bounty(assigns) do
319+
~H"""
320+
<div class="border ring-1 ring-transparent rounded-xl overflow-hidden">
321+
<div class="bg-card/75 flex flex-col h-full p-4 rounded-xl border-t-4 sm:border-t-0 sm:border-l-4 border-emerald-400">
322+
<div class="p-4 sm:p-6">
323+
<div class="text-2xl font-semibold text-foreground">
324+
Fund any {@org.name} issue<br class="block sm:hidden" />
325+
<span class="text-success drop-shadow-[0_1px_5px_#34d39980]">
326+
in seconds
327+
</span>
328+
</div>
329+
<div class="pt-1 text-base font-medium text-muted-foreground">
330+
Prioritize issues and reward contributors when work is done
331+
</div>
332+
<.simple_form for={@bounty_form} phx-submit="create_bounty">
333+
<div class="flex flex-col gap-6 pt-6">
334+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
335+
<.input
336+
label="Issue URL"
337+
field={@bounty_form[:url]}
338+
placeholder={"https://github.com/#{@org.provider_login}/repo/issues/1337"}
339+
/>
340+
<.input label="Amount" icon="tabler-currency-dollar" field={@bounty_form[:amount]} />
341+
</div>
342+
<p class="text-sm text-muted-foreground">
343+
<.icon name="tabler-sparkles" class="size-4 text-current mr-1" /> Comment
344+
<code class="px-1 py-0.5 text-success">/bounty $100</code>
345+
on GitHub issues
346+
</p>
347+
<div class="flex justify-end gap-4">
348+
<.button>Submit</.button>
349+
</div>
350+
</div>
351+
</.simple_form>
352+
</div>
353+
</div>
354+
</div>
355+
"""
356+
end
357+
358+
defp social_links do
359+
[
360+
{:website, "tabler-world"},
361+
{:github, "github"},
362+
{:twitter, "tabler-brand-x"},
363+
{:youtube, "tabler-brand-youtube"},
364+
{:twitch, "tabler-brand-twitch"},
365+
{:discord, "tabler-brand-discord"},
366+
{:slack, "tabler-brand-slack"},
367+
{:linkedin, "tabler-brand-linkedin"}
368+
]
369+
end
370+
371+
defp social_link(user, :github), do: if(login = user.provider_login, do: "https://github.com/#{login}")
372+
defp social_link(user, platform), do: Map.get(user, :"#{platform}_url")
373+
374+
defp page_size, do: 5
375+
end

lib/algora_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ defmodule AlgoraWeb.Router do
104104
live "/", Org.DashboardLive, :index
105105
live "/home", Org.HomeLive, :index
106106
live "/bounties", Org.BountiesLive, :index
107+
live "/bounties/new", Org.BountiesNewLive, :index
107108
live "/bounties/:id", BountyLive, :index
108109
live "/contracts/:id", Contract.ViewLive
109110
live "/team", Org.TeamLive, :index

0 commit comments

Comments
 (0)