Skip to content

Commit 73f4efb

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

File tree

2 files changed

+369
-0
lines changed

2 files changed

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