diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index ef32cc8a3..ebe416c9e 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -169,6 +169,36 @@ defmodule Algora.Accounts do list_developers_with(base_query(), criteria) end + def list_contributed_projects(user, opts \\ []) do + query = + from tx in Transaction, + where: tx.type == :credit, + where: tx.status == :succeeded, + where: tx.user_id == ^user.id, + left_join: bounty in assoc(tx, :bounty), + left_join: tip in assoc(tx, :tip), + join: t in Ticket, + on: t.id == bounty.ticket_id or t.id == tip.ticket_id, + left_join: r in assoc(t, :repository), + as: :r, + left_join: ro in assoc(r, :user), + group_by: ro.id, + order_by: [desc: sum(tx.net_amount)], + select: {ro, sum(tx.net_amount)}, + limit: ^opts[:limit] + + query = + if opts[:tech_stack] do + from([b, r: r] in query, + where: fragment("? && ?::citext[]", r.tech_stack, ^opts[:tech_stack]) + ) + else + query + end + + Repo.all(query) + end + @spec fetch_developer(binary()) :: {:ok, User.t()} | {:error, :not_found} def fetch_developer(id) do case list_developers(id: id, limit: 1) do diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index c239e14d5..73ed62b17 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -21,6 +21,12 @@ defmodule Algora.Admin do require Logger + def init_contributors(repo_owner, repo_name) do + with {:ok, repo} <- Workspace.ensure_repository(token(), repo_owner, repo_name) do + Workspace.ensure_contributors(token(), repo) + end + end + def migrate_user!(old_user_id, new_user_id) do old_user = Accounts.get_user!(old_user_id) diff --git a/lib/algora/settings/settings.ex b/lib/algora/settings/settings.ex index 0e161137e..1a7d51691 100644 --- a/lib/algora/settings/settings.ex +++ b/lib/algora/settings/settings.ex @@ -2,6 +2,7 @@ defmodule Algora.Settings do @moduledoc false use Ecto.Schema + alias Algora.Accounts alias Algora.Repo @primary_key {:key, :string, []} @@ -49,14 +50,39 @@ defmodule Algora.Settings do set("featured_developers", %{"handles" => handles}) end - def get_org_matches(org_handle) when is_binary(org_handle) do - case get("org_matches:#{org_handle}") do - %{"handles" => handles} when is_list(handles) -> handles - _ -> nil + def get_org_matches(org) do + case get("org_matches:#{org.handle}") do + %{"matches" => matches} when is_list(matches) -> + user_map = + [handles: Enum.map(matches, & &1["handle"])] + |> Accounts.list_developers() + |> Map.new(fn user -> {user.handle, user} end) + + Enum.flat_map(matches, fn match -> + if user = Map.get(user_map, match["handle"]) do + # TODO: N+1 + projects = Accounts.list_contributed_projects(user, limit: 2, tech_stack: org.tech_stack) + + [ + %{ + user: user, + projects: projects, + badge_variant: match["badge_variant"], + badge_text: match["badge_text"], + hourly_rate: Money.new(:USD, match["hourly_rate"], no_fraction_if_integer: true) + } + ] + else + [] + end + end) + + _ -> + nil end end - def set_org_matches(org_handle, handles) when is_binary(org_handle) and is_list(handles) do - set("org_matches:#{org_handle}", %{"handles" => handles}) + def set_org_matches(org_handle, matches) when is_binary(org_handle) and is_list(matches) do + set("org_matches:#{org_handle}", %{"matches" => matches}) end end diff --git a/lib/algora_web/components/ui/badge.ex b/lib/algora_web/components/ui/badge.ex index 9ca817786..13867f6fd 100644 --- a/lib/algora_web/components/ui/badge.ex +++ b/lib/algora_web/components/ui/badge.ex @@ -46,7 +46,10 @@ defmodule AlgoraWeb.Components.UI.Badge do "destructive" => "bg-destructive/10 text-destructive border-destructive/20", "success" => "bg-success/10 text-success border-success/20", "warning" => "bg-warning/10 text-warning border-warning/20", - "outline" => "bg-transparent text-foreground border-foreground/30" + "outline" => "bg-transparent text-foreground border-foreground/30", + "indigo" => "bg-indigo-400/10 text-indigo-400 border-indigo-400/20", + "purple" => "bg-purple-400/10 text-purple-400 border-purple-400/20", + "blue" => "bg-blue-400/10 text-blue-400 border-blue-400/20" } } diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 80b3ef2e3..64f62bd83 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -63,16 +63,7 @@ defmodule AlgoraWeb.Org.DashboardLive do contributors = list_contributors(current_org) - matches = - if current_org.handle do - if handles = Algora.Settings.get_org_matches(current_org.handle) do - Repo.all( - from u in User, - where: u.handle in ^handles, - order_by: fragment("array_position(?, ?::text)", ^handles, u.handle) - ) - end - end + matches = Algora.Settings.get_org_matches(current_org) admins_last_active = Algora.Admin.admins_last_active() @@ -281,22 +272,18 @@ defmodule AlgoraWeb.Org.DashboardLive do <.section - :if={@matches != nil && @matches != []} + :if={@matches != []} title="Algora Matches" subtitle="Developers that match your tech stack and requirements" > -
- - - <%= for user <- @matches do %> - <.developer_card - user={user} - contract_for_user={contract_for_user(@contracts, user)} - current_org={@current_org} - /> - <% end %> - -
+
+ <%= for match <- @matches do %> + <.match_card + match={match} + contract_for_user={contract_for_user(@contracts, match.user)} + current_org={@current_org} + /> + <% end %>
@@ -1300,6 +1287,150 @@ defmodule AlgoraWeb.Org.DashboardLive do """ end + defp match_card(assigns) do + ~H""" +
+
+
+ <.link navigate={User.url(@match.user)}> + <.avatar class="h-16 w-16 rounded-full"> + <.avatar_image src={@match.user.avatar_url} alt={@match.user.name} /> + <.avatar_fallback class="rounded-lg"> + {Algora.Util.initials(@match.user.name)} + + + + +
+
+ <.link + navigate={User.url(@match.user)} + class="text-lg sm:text-xl font-semibold hover:underline truncate" + > + {@match.user.name} + + <.badge variant={@match.badge_variant} size="lg"> + {@match.badge_text} + +
+
+ <.link + :if={@match.user.provider_login} + href={"https://github.com/#{@match.user.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + + {@match.user.provider_login} + + <.link + :if={@match.user.provider_meta["twitter_handle"]} + href={"https://x.com/#{@match.user.provider_meta["twitter_handle"]}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="tabler-brand-x" class="shrink-0 h-4 w-4" /> + {@match.user.provider_meta["twitter_handle"]} + +
+
+ + {Money.to_string!(@match.hourly_rate)}/hr + +
+
+
+
+ <.button + phx-click="share_opportunity" + phx-value-user_id={@match.user.id} + phx-value-type="bounty" + variant="none" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-800/10 hover:text-blue-300 hover:drop-shadow-[0_1px_5px_#60a5fa80] focus:bg-blue-800/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#60a5fa80] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" + > + <.icon name="tabler-diamond" class="size-4 text-current mr-2 -ml-1" /> Bounty + + <.button + :if={@contract_for_user && @contract_for_user.status in [:active, :paid]} + navigate={~p"/org/#{@current_org.handle}/contracts/#{@contract_for_user.id}"} + variant="none" + class="bg-emerald-800/10 text-emerald-300 drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-emerald-400/50 focus:border-emerald-400/50" + > + <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract + + <.button + :if={@contract_for_user && @contract_for_user.status in [:draft]} + navigate={~p"/org/#{@current_org.handle}/contracts/#{@contract_for_user.id}"} + variant="none" + class="bg-gray-800/10 text-gray-400 drop-shadow-[0_1px_5px_#94a3b880] focus:bg-gray-800/10 focus:text-gray-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#94a3b880] border border-gray-400/50 focus:border-gray-400/50" + > + <.icon name="tabler-clock" class="size-4 text-current mr-2 -ml-1" /> Contract + + <.button + :if={!@contract_for_user} + phx-click="share_opportunity" + phx-value-user_id={@match.user.id} + phx-value-type="contract" + variant="none" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-300 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" + > + <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract + +
+
+ +
+
+ Completed {@match.user.transactions_count} + bounties across + {@match.user.contributed_projects_count} + projects + + ({Money.to_string!(@match.user.total_earned)}) + +
+
+ <%= for {project, total_earned} <- @match.projects |> Enum.take(2) do %> + <.link + navigate={~p"/org/#{@current_org.handle}"} + class="flex flex-1 items-center gap-2 sm:gap-4 text-sm rounded-lg" + > + <.avatar class="h-10 w-10 sm:h-12 sm:w-12 rounded-lg saturate-0"> + <.avatar_image src={project.avatar_url} alt={project.name} /> + <.avatar_fallback class="rounded-lg"> + {Algora.Util.initials(project.name)} + + +
+
+ {project.name} +
+ +
+
+ <.icon name="tabler-star-filled" class="size-4 sm:size-5 text-amber-400 mr-1" />{format_number( + project.stargazers_count + )} +
+
+ + {total_earned} + + awarded +
+
+
+ + <% end %> +
+
+
+ """ + end + defp contract_for_user(contracts, user) do Enum.find(contracts, fn contract -> contract.contractor_id == user.id end) end @@ -1888,4 +2019,8 @@ defmodule AlgoraWeb.Org.DashboardLive do _ -> "Your" end end + + defp format_number(n) when n >= 1_000_000, do: "#{Float.round(n / 1_000_000, 1)}M" + defp format_number(n) when n >= 1_000, do: "#{Float.round(n / 1_000, 1)}K" + defp format_number(n), do: to_string(n) end