|
| 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 |
0 commit comments