From a4164f82952c0516021aba7ae60a0d8259172573 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 14:36:04 +0300 Subject: [PATCH 01/13] wip --- lib/algora/bounties/bounties.ex | 18 +- lib/algora/bounties/schemas/bounty.ex | 3 +- lib/algora/organizations/schemas/member.ex | 2 + lib/algora/shared/validations.ex | 27 + .../components/layouts/user.html.heex | 49 ++ lib/algora_web/forms/contract_form.ex | 110 +++- lib/algora_web/live/contract_live.ex | 547 ++++++++++++++++++ lib/algora_web/live/org/nav.ex | 55 ++ lib/algora_web/router.ex | 1 + ...414105225_add_hours_per_week_to_bounty.exs | 9 + 10 files changed, 809 insertions(+), 12 deletions(-) create mode 100644 lib/algora_web/live/contract_live.ex create mode 100644 priv/repo/migrations/20250414105225_add_hours_per_week_to_bounty.exs diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 85b5f22fa..47459e224 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -51,7 +51,8 @@ defmodule Algora.Bounties do amount: Money.t(), ticket: Ticket.t(), visibility: Bounty.visibility(), - shared_with: [String.t()] + shared_with: [String.t()], + hours_per_week: integer() | nil }) :: {:ok, Bounty.t()} | {:error, atom()} defp do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket} = params) do @@ -62,7 +63,8 @@ defmodule Algora.Bounties do owner_id: owner.id, creator_id: creator.id, visibility: params[:visibility] || owner.bounty_mode, - shared_with: params[:shared_with] || [] + shared_with: params[:shared_with] || [], + hours_per_week: params[:hours_per_week] }) changeset @@ -109,7 +111,8 @@ defmodule Algora.Bounties do command_id: integer(), command_source: :ticket | :comment, visibility: Bounty.visibility() | nil, - shared_with: [String.t()] | nil + shared_with: [String.t()] | nil, + hours_per_week: integer() | nil ] ) :: {:ok, Bounty.t()} | {:error, atom()} @@ -140,7 +143,8 @@ defmodule Algora.Bounties do amount: amount, ticket: ticket, visibility: opts[:visibility], - shared_with: shared_with + shared_with: shared_with, + hours_per_week: opts[:hours_per_week] }) :set -> @@ -190,7 +194,8 @@ defmodule Algora.Bounties do opts :: [ strategy: strategy(), visibility: Bounty.visibility() | nil, - shared_with: [String.t()] | nil + shared_with: [String.t()] | nil, + hours_per_week: integer() | nil ] ) :: {:ok, Bounty.t()} | {:error, atom()} @@ -209,7 +214,8 @@ defmodule Algora.Bounties do amount: amount, ticket: ticket, visibility: opts[:visibility], - shared_with: shared_with + shared_with: shared_with, + hours_per_week: opts[:hours_per_week] }), {:ok, _job} <- notify_bounty(%{owner: owner, bounty: bounty}) do broadcast() diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex index b655e17ba..96adb2e0c 100644 --- a/lib/algora/bounties/schemas/bounty.ex +++ b/lib/algora/bounties/schemas/bounty.ex @@ -15,6 +15,7 @@ defmodule Algora.Bounties.Bounty do field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], null: false, default: :community field :shared_with, {:array, :string}, null: false, default: [] field :deadline, :utc_datetime_usec + field :hours_per_week, :integer belongs_to :ticket, Algora.Workspace.Ticket belongs_to :owner, User @@ -33,7 +34,7 @@ defmodule Algora.Bounties.Bounty do def changeset(bounty, attrs) do bounty - |> cast(attrs, [:amount, :ticket_id, :owner_id, :creator_id, :visibility, :shared_with]) + |> cast(attrs, [:amount, :ticket_id, :owner_id, :creator_id, :visibility, :shared_with, :hours_per_week]) |> validate_required([:amount, :ticket_id, :owner_id, :creator_id]) |> generate_id() |> foreign_key_constraint(:ticket) diff --git a/lib/algora/organizations/schemas/member.ex b/lib/algora/organizations/schemas/member.ex index 1891a8479..8913a32c4 100644 --- a/lib/algora/organizations/schemas/member.ex +++ b/lib/algora/organizations/schemas/member.ex @@ -36,4 +36,6 @@ defmodule Algora.Organizations.Member do end def can_create_bounty?(role), do: role in [:admin, :mod] + + def can_create_contract?(role), do: role in [:admin, :mod] end diff --git a/lib/algora/shared/validations.ex b/lib/algora/shared/validations.ex index 1e57806b0..7ea45497e 100644 --- a/lib/algora/shared/validations.ex +++ b/lib/algora/shared/validations.ex @@ -40,6 +40,33 @@ defmodule Algora.Validations do end end + def validate_github_handle(changeset, field, embed_field \\ nil) do + case get_change(changeset, field) do + handle when not is_nil(handle) -> + # Check if user is already embedded with matching provider_login + existing_user = embed_field && get_field(changeset, embed_field) + + if existing_user && existing_user.provider_login == handle do + changeset + else + case Algora.Workspace.ensure_user(Algora.Admin.token!(), handle) do + {:ok, user} -> + if embed_field do + put_embed(changeset, embed_field, user) + else + changeset + end + + {:error, error, _, _, _, _} -> + add_error(changeset, field, error) + end + end + + _ -> + changeset + end + end + def validate_date_in_future(changeset, field) do validate_change(changeset, field, fn _, date -> if date && Date.before?(date, DateTime.utc_now()) do diff --git a/lib/algora_web/components/layouts/user.html.heex b/lib/algora_web/components/layouts/user.html.heex index b4785238c..976cc8f04 100644 --- a/lib/algora_web/components/layouts/user.html.heex +++ b/lib/algora_web/components/layouts/user.html.heex @@ -135,6 +135,35 @@ <% end %> + <%= if main_contract_form = Map.get(assigns, :main_contract_form) do %> +
+ <.button + phx-click="open_main_contract_form" + class="h-9 w-9 rounded-md flex items-center justify-center relative" + > + <.icon name="tabler-user-dollar" class="h-6 w-6 shrink-0" /> + <.icon + name="tabler-plus" + class="h-[0.8rem] w-[0.8rem] shrink-0 absolute bottom-[0.2rem] right-[0.2rem]" + /> + + <.drawer + show={@main_contract_form_open?} + direction="right" + on_cancel="close_main_contract_form" + > + <.drawer_header> + <.drawer_title>Create new contract + <.drawer_description> + Engage a developer for ongoing work + + + <.drawer_content class="mt-4"> + + + +
+ <% end %> <%= if main_bounty_form = Map.get(assigns, :main_bounty_form) do %>
<.button @@ -237,6 +266,26 @@
<%= if @current_user do %> + <.button + phx-click="open_main_contract_form" + class="h-9 w-9 rounded-md flex items-center justify-center relative" + > + <.icon name="tabler-user-dollar" class="h-6 w-6 shrink-0" /> + <%!-- <.icon + name="tabler-plus" + class="h-[0.8rem] w-[0.8rem] shrink-0 absolute bottom-[0.2rem] right-[0.2rem]" + /> --%> + + <.button + phx-click="open_main_bounty_form" + class="h-9 w-9 rounded-md flex items-center justify-center relative" + > + <.icon name="tabler-diamond" class="h-6 w-6 shrink-0" /> + <.icon + name="tabler-plus" + class="h-[0.8rem] w-[0.8rem] shrink-0 absolute bottom-[0.2rem] right-[0.2rem]" + /> + <%!-- {live_render(@socket, AlgoraWeb.Activity.UserNavTimelineLive, id: "activity-timeline", session: %{}, diff --git a/lib/algora_web/forms/contract_form.ex b/lib/algora_web/forms/contract_form.ex index 5e4c9f837..db9ca13d0 100644 --- a/lib/algora_web/forms/contract_form.ex +++ b/lib/algora_web/forms/contract_form.ex @@ -1,22 +1,122 @@ defmodule AlgoraWeb.Forms.ContractForm do @moduledoc false use Ecto.Schema + use AlgoraWeb, :html import Ecto.Changeset + alias Algora.Accounts.User alias Algora.Types.USD alias Algora.Validations embedded_schema do - field :hourly_rate, USD + field :amount, USD field :hours_per_week, :integer + field :type, Ecto.Enum, values: [:fixed, :hourly], default: :fixed + field :title, :string + field :description, :string + field :contractor_handle, :string + + embeds_one :contractor, User + end + + def type_options do + [{"Fixed", :fixed}, {"Hourly", :hourly}] end def changeset(form, attrs) do form - |> cast(attrs, [:hourly_rate, :hours_per_week]) - |> validate_required([:hourly_rate, :hours_per_week]) - |> Validations.validate_money_positive(:hourly_rate) - |> validate_number(:hours_per_week, greater_than: 0, less_than_or_equal_to: 40) + |> cast(attrs, [:amount, :hours_per_week, :type, :title, :description, :contractor_handle]) + |> validate_required([:contractor_handle]) + |> validate_type_fields() + |> Validations.validate_money_positive(:amount) + |> Validations.validate_github_handle(:contractor_handle, :contractor) + end + + defp validate_type_fields(changeset) do + case get_field(changeset, :type) do + :hourly -> validate_required(changeset, [:amount, :hours_per_week]) + _ -> validate_required(changeset, [:amount]) + end + end + + def contract_form(assigns) do + ~H""" + <.form + id="main-contract-form" + for={@form} + phx-submit="create_contract_main" + phx-change="validate_contract_main" + > +
+
+ <%= for {label, value} <- type_options() do %> + + <% end %> +
+ + <.input label="Title" field={@form[:title]} placeholder="Brief description of the contract" /> + <.input + label="Description (optional)" + field={@form[:description]} + type="textarea" + placeholder="Requirements and acceptance criteria" + /> + <.input label="Amount" icon="tabler-currency-dollar" field={@form[:amount]} /> + + + +
+ <.input + label="GitHub handle" + field={@form[:contractor_handle]} + phx-debounce="500" + class="pl-10" + /> +
+ <.avatar :if={get_field(@form.source, :contractor)} class="h-7 w-7"> + <.avatar_image src={get_field(@form.source, :contractor).avatar_url} /> + + <.icon name="github" class="h-7 w-7 text-muted-foreground" /> +
+
+
+
+ <.button variant="secondary" phx-click="close_share_drawer" type="button"> + Cancel + + <.button type="submit"> + Share Contract <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+ + """ end end diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex new file mode 100644 index 000000000..44be4ea5e --- /dev/null +++ b/lib/algora_web/live/contract_live.ex @@ -0,0 +1,547 @@ +defmodule AlgoraWeb.ContractLive do + @moduledoc false + use AlgoraWeb, :live_view + + import Ecto.Changeset + import Ecto.Query + + alias Algora.Admin + alias Algora.Bounties + alias Algora.Bounties.Bounty + alias Algora.Bounties.LineItem + alias Algora.Chat + alias Algora.Organizations.Member + alias Algora.Repo + alias Algora.Util + alias Algora.Workspace + + require Logger + + defp tip_options, do: [{"None", 0}, {"10%", 10}, {"20%", 20}, {"50%", 50}] + + defmodule RewardBountyForm do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @primary_key false + embedded_schema do + field :amount, Algora.Types.USD + field :tip_percentage, :decimal + end + + def changeset(form, attrs) do + form + |> cast(attrs, [:amount, :tip_percentage]) + |> validate_required([:amount]) + |> validate_number(:tip_percentage, greater_than_or_equal_to: 0) + |> Algora.Validations.validate_money_positive(:amount) + end + end + + @impl true + def mount(%{"id" => bounty_id}, _session, socket) do + bounty = + Bounty + |> Repo.get!(bounty_id) + |> Repo.preload([:owner, :creator, :transactions, ticket: [repository: [:user]]]) + + {host, ticket_ref} = + if bounty.ticket.repository do + {bounty.ticket.repository.user, + %{ + owner: bounty.ticket.repository.user.provider_login, + repo: bounty.ticket.repository.name, + number: bounty.ticket.number + }} + else + {bounty.owner, nil} + end + + socket + |> assign(:bounty, bounty) + |> assign(:ticket_ref, ticket_ref) + |> assign(:host, host) + |> on_mount(bounty) + end + + @impl true + def mount(%{"repo_owner" => repo_owner, "repo_name" => repo_name, "number" => number}, _session, socket) do + number = String.to_integer(number) + + ticket_ref = %{owner: repo_owner, repo: repo_name, number: number} + + bounty = + from(b in Bounty, + join: t in assoc(b, :ticket), + join: r in assoc(t, :repository), + join: u in assoc(r, :user), + where: u.provider == "github", + where: u.provider_login == ^repo_owner, + where: r.name == ^repo_name, + where: t.number == ^number, + order_by: fragment("CASE WHEN ? = ? THEN 0 ELSE 1 END", u.id, ^socket.assigns.current_org.id), + limit: 1 + ) + |> Repo.one() + |> Repo.preload([:owner, :creator, :transactions, ticket: [repository: [:user]]]) + + socket + |> assign(:bounty, bounty) + |> assign(:ticket_ref, ticket_ref) + |> assign(:host, bounty.ticket.repository.user) + |> on_mount(bounty) + end + + defp on_mount(socket, bounty) do + debits = Enum.filter(bounty.transactions, &(&1.type == :debit and &1.status == :succeeded)) + + total_paid = + debits + |> Enum.map(& &1.net_amount) + |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) + + ticket_body_html = Algora.Markdown.render(bounty.ticket.description) + + reward_changeset = + RewardBountyForm.changeset(%RewardBountyForm{}, %{tip_percentage: 0}) + + {:ok, thread} = Chat.get_or_create_bounty_thread(bounty) + messages = thread.id |> Chat.list_messages() |> Repo.preload(:sender) + participants = thread.id |> Chat.list_participants() |> Repo.preload(:user) + + if connected?(socket) do + Chat.subscribe(thread.id) + end + + share_url = + if socket.assigns.ticket_ref do + url( + ~p"/#{socket.assigns.ticket_ref.owner}/#{socket.assigns.ticket_ref.repo}/issues/#{socket.assigns.ticket_ref.number}" + ) + else + url(~p"/#{socket.assigns.bounty.owner.handle}/bounties/#{socket.assigns.bounty.id}") + end + + {:ok, + socket + |> assign(:can_create_bounty, Member.can_create_bounty?(socket.assigns.current_user_role)) + |> assign(:share_url, share_url) + |> assign(:page_title, bounty.ticket.title) + |> assign(:ticket, bounty.ticket) + |> assign(:total_paid, total_paid) + |> assign(:ticket_body_html, ticket_body_html) + |> assign(:show_reward_modal, false) + |> assign(:selected_context, nil) + |> assign(:line_items, []) + |> assign(:thread, thread) + |> assign(:messages, messages) + |> assign(:participants, participants) + |> assign(:reward_form, to_form(reward_changeset)) + |> assign_contractor(bounty.shared_with) + |> assign_line_items()} + end + + @impl true + def handle_params(_params, _url, %{assigns: %{current_user: nil}} = socket) do + {:noreply, socket} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket} + end + + @impl true + def handle_info(%Chat.MessageCreated{message: message, participant: participant}, socket) do + socket = + if message.id in Enum.map(socket.assigns.messages, & &1.id), + do: socket, + else: Phoenix.Component.update(socket, :messages, &(&1 ++ [message])) + + socket = + if participant.id in Enum.map(socket.assigns.participants, & &1.id), + do: socket, + else: Phoenix.Component.update(socket, :participants, &(&1 ++ [participant])) + + {:noreply, socket} + end + + @impl true + def handle_event("send_message", %{"message" => content}, socket) do + {:ok, message} = + Chat.send_message( + socket.assigns.thread.id, + socket.assigns.current_user.id, + content + ) + + message = Repo.preload(message, :sender) + + {:noreply, + socket + |> Phoenix.Component.update(:messages, &(&1 ++ [message])) + |> push_event("clear-input", %{selector: "#message-input"})} + end + + @impl true + def handle_event("reward", _params, socket) do + {:noreply, assign(socket, :show_reward_modal, true)} + end + + @impl true + def handle_event("close_drawer", _params, socket) do + {:noreply, close_drawers(socket)} + end + + @impl true + def handle_event("validate_reward", %{"reward_bounty_form" => params}, socket) do + {:noreply, + socket + |> assign(:reward_form, to_form(RewardBountyForm.changeset(%RewardBountyForm{}, params))) + |> assign_line_items()} + end + + @impl true + def handle_event("assign_line_items", %{"reward_bounty_form" => params}, socket) do + {:noreply, assign_line_items(socket)} + end + + @impl true + def handle_event("pay_with_stripe", %{"reward_bounty_form" => params}, socket) do + changeset = RewardBountyForm.changeset(%RewardBountyForm{}, params) + + case apply_action(changeset, :save) do + {:ok, _data} -> + case reward_bounty(socket, socket.assigns.bounty, changeset) do + {:ok, session_url} -> + {:noreply, redirect(socket, external: session_url)} + + {:error, reason} -> + Logger.error("Failed to create payment session: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Something went wrong")} + end + + {:error, changeset} -> + {:noreply, assign(socket, :reward_form, to_form(changeset))} + end + end + + @impl true + def handle_event(_event, _params, socket) do + {:noreply, socket} + end + + @impl true + def render(assigns) do + ~H""" +
+ <.scroll_area class="xl:h-[calc(100svh-96px)] flex-1 pr-6"> +
+ <.card> + <.card_content> +
+
+
+ <.avatar class="h-12 w-12 ring-2 ring-background"> + <.avatar_image src={@bounty.owner.avatar_url} /> + <.avatar_fallback> + {Util.initials(@bounty.owner.name)} + + + <.avatar class="h-12 w-12 ring-2 ring-background"> + <.avatar_image src={@contractor.avatar_url} /> + <.avatar_fallback> + {Util.initials(@contractor.name)} + + +
+
+

+ Contract with {@contractor.name} +

+

+ Started {Calendar.strftime(@bounty.inserted_at, "%b %d, %Y")} +

+
+
+ +
+
+ {Money.to_string!(@bounty.amount)} + 0} class="text-base"> + /wk + +
+ <.button :if={@can_create_bounty} phx-click="reward"> + Reward + +
+
+ + + <.card> + <.card_header> + <.card_title> + Description + + + <.card_content> +
+ {Phoenix.HTML.raw(@ticket_body_html)} +
+ + +
+ + +
+
+
+
+ <.avatar> + <.avatar_image src={@contractor.avatar_url} alt="Developer avatar" /> + <.avatar_fallback> + {Util.initials(@contractor.name)} + + +
+
+
+
+

{@contractor.name}

+

+ <%!-- Active {Util.time_ago(@contractor.last_active_at)} --%> +

+
+
+
+ <.scroll_area + class="flex h-full flex-1 flex-col-reverse gap-6 p-4" + id="messages-container" + phx-hook="ScrollToBottom" + > +
+ <%= for {date, messages} <- @messages + |> Enum.group_by(fn msg -> + case Date.diff(Date.utc_today(), DateTime.to_date(msg.inserted_at)) do + 0 -> "Today" + 1 -> "Yesterday" + n when n <= 7 -> Calendar.strftime(msg.inserted_at, "%A") + _ -> Calendar.strftime(msg.inserted_at, "%b %d") + end + end) + |> Enum.sort_by(fn {_, msgs} -> hd(msgs).inserted_at end, Date) do %> +
+
+ {date} +
+
+ +
+ <%= for message <- Enum.sort_by(messages, & &1.inserted_at, Date) do %> +
+ <.avatar class="h-8 w-8"> + <.avatar_image src={message.sender.avatar_url} /> + <.avatar_fallback> + {Util.initials(message.sender.name)} + + +
+ {message.content} +
+ {message.inserted_at + |> DateTime.to_time() + |> Time.to_string() + |> String.slice(0..4)} +
+
+
+ <% end %> +
+ <% end %> +
+ + +
+
+
+ <.input + id="message-input" + type="text" + name="message" + value="" + placeholder="Type a message..." + autocomplete="off" + class="flex-1 pr-24" + phx-hook="ClearInput" + /> +
+ <.button + type="button" + variant="ghost" + size="icon-sm" + phx-hook="EmojiPicker" + id="emoji-trigger" + > + <.icon name="tabler-mood-smile" class="h-4 w-4" /> + +
+
+ <.button type="submit" size="icon"> + <.icon name="tabler-send" class="h-4 w-4" /> + +
+ + +
+
+
+ + <.drawer :if={@current_user} show={@show_reward_modal} on_cancel="close_drawer"> + <.drawer_header> + <.drawer_title>Reward Bounty + <.drawer_description> + You can pay the full bounty now or start with a partial amount - it's up to you! + + + <.drawer_content class="mt-4"> + <.form for={@reward_form} phx-change="validate_reward" phx-submit="pay_with_stripe"> +
+
+ <.card> + <.card_header> + <.card_title>Payment Details + + <.card_content> +
+ <.input + label="Amount" + icon="tabler-currency-dollar" + field={@reward_form[:amount]} + /> + +
+ <.label>Tip +
+ <.radio_group + class="grid grid-cols-4 gap-4" + field={@reward_form[:tip_percentage]} + options={tip_options()} + /> +
+
+
+ + + <.card> + <.card_header> + <.card_title>Payment Summary + + <.card_content> +
+ <%= for line_item <- @line_items do %> +
+
+ <%= if line_item.image do %> + <.avatar> + <.avatar_image src={line_item.image} /> + <.avatar_fallback> + {Util.initials(line_item.title)} + + + <% else %> +
+ <% end %> +
+
{line_item.title}
+
{line_item.description}
+
+
+
+ {Money.to_string!(line_item.amount)} +
+
+ <% end %> +
+
+
+
+
Total due
+
+
+ {LineItem.gross_amount(@line_items)} +
+
+
+ + +
+
+ <.button variant="secondary" phx-click="close_drawer" type="button"> + Cancel + + <.button type="submit"> + Pay with Stripe <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+
+ + + + """ + end + + defp assign_line_items(socket) do + line_items = + Bounties.generate_line_items( + %{ + owner: socket.assigns.bounty.owner, + amount: calculate_final_amount(socket.assigns.reward_form.source) + }, + ticket_ref: socket.assigns.ticket_ref, + recipient: socket.assigns.contractor + ) + + assign(socket, :line_items, line_items) + end + + defp reward_bounty(socket, bounty, changeset) do + final_amount = calculate_final_amount(changeset) + + Bounties.reward_bounty( + %{owner: bounty.owner, amount: final_amount, bounty_id: bounty.id, claims: []}, + ticket_ref: socket.assigns.ticket_ref, + recipient: socket.assigns.contractor + ) + end + + defp calculate_final_amount(data_or_changeset) do + tip_percentage = get_field(data_or_changeset, :tip_percentage) || Decimal.new(0) + amount = get_field(data_or_changeset, :amount) || Money.zero(:USD, no_fraction_if_integer: true) + + multiplier = tip_percentage |> Decimal.div(100) |> Decimal.add(1) + Money.mult!(amount, multiplier) + end + + defp close_drawers(socket) do + assign(socket, :show_reward_modal, false) + end + + defp assign_contractor(socket, shared_with) do + contractor = + shared_with + |> Enum.flat_map(fn provider_id -> + case Workspace.ensure_user_by_provider_id(Admin.token!(), provider_id) do + {:ok, user} -> [user] + _ -> [] + end + end) + |> List.first() + + assign(socket, :contractor, contractor) + end +end diff --git a/lib/algora_web/live/org/nav.ex b/lib/algora_web/live/org/nav.ex index a9f875a58..fef0fb891 100644 --- a/lib/algora_web/live/org/nav.ex +++ b/lib/algora_web/live/org/nav.ex @@ -6,10 +6,12 @@ defmodule AlgoraWeb.Org.Nav do import Ecto.Changeset import Phoenix.LiveView + alias Algora.Accounts.User alias Algora.Bounties alias Algora.Organizations alias Algora.Organizations.Member alias AlgoraWeb.Forms.BountyForm + alias AlgoraWeb.Forms.ContractForm alias AlgoraWeb.OrgAuth require Logger @@ -26,11 +28,18 @@ defmodule AlgoraWeb.Org.Nav do to_form(BountyForm.changeset(%BountyForm{}, %{})) end + main_contract_form = + if Member.can_create_contract?(current_user_role) do + to_form(ContractForm.changeset(%ContractForm{}, %{})) + end + {:cont, socket |> assign(:screenshot?, not is_nil(params["screenshot"])) |> assign(:main_bounty_form, main_bounty_form) |> assign(:main_bounty_form_open?, false) + |> assign(:main_contract_form, main_contract_form) + |> assign(:main_contract_form_open?, false) |> assign(:current_org, current_org) |> assign(:current_user_role, current_user_role) |> assign(:nav, nav_items(current_org.handle, current_user_role)) @@ -105,6 +114,44 @@ defmodule AlgoraWeb.Org.Nav do end end + defp handle_event("validate_contract_main", %{"contract_form" => params}, socket) do + changeset = ContractForm.changeset(%ContractForm{}, params) + {:cont, assign(socket, :main_contract_form, to_form(changeset))} + end + + defp handle_event("create_contract_main", %{"contract_form" => params}, socket) do + changeset = ContractForm.changeset(%ContractForm{}, params) + + case apply_action(changeset, :save) do + {:ok, data} -> + bounty_res = + Bounties.create_bounty( + %{ + creator: socket.assigns.current_user, + owner: socket.assigns.current_org, + amount: data.amount, + title: data.title, + description: data.description + }, + hours_per_week: data.hours_per_week, + shared_with: [data.contractor.provider_id], + visibility: :exclusive + ) + + case bounty_res do + {:ok, bounty} -> + {:cont, redirect(socket, to: ~p"/#{socket.assigns.current_org.handle}/contract/#{bounty.id}")} + + {:error, reason} -> + Logger.error("Failed to create bounty: #{inspect(reason)}") + {:cont, put_flash(socket, :error, "Something went wrong")} + end + + {:error, changeset} -> + {:cont, assign(socket, :main_bounty_form, to_form(changeset))} + end + end + defp handle_event("open_main_bounty_form", _params, socket) do {:cont, assign(socket, :main_bounty_form_open?, true)} end @@ -113,6 +160,14 @@ defmodule AlgoraWeb.Org.Nav do {:cont, assign(socket, :main_bounty_form_open?, false)} end + defp handle_event("open_main_contract_form", _params, socket) do + {:cont, assign(socket, :main_contract_form_open?, true)} + end + + defp handle_event("close_main_contract_form", _params, socket) do + {:cont, assign(socket, :main_contract_form_open?, false)} + end + defp handle_event(_event, _params, socket) do {:cont, socket} end diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index c22a49ec0..c60e7f44c 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -200,6 +200,7 @@ defmodule AlgoraWeb.Router do live "/bounties/new", Org.BountiesNewLive, :index live "/bounties/community", Org.BountiesNewLive, :index live "/bounties/:id", BountyLive, :index + live "/contract/:id", ContractLive live "/contracts/:id", Contract.ViewLive live "/team", Org.TeamLive, :index live "/leaderboard", Org.LeaderboardLive, :index diff --git a/priv/repo/migrations/20250414105225_add_hours_per_week_to_bounty.exs b/priv/repo/migrations/20250414105225_add_hours_per_week_to_bounty.exs new file mode 100644 index 000000000..8d38eb99b --- /dev/null +++ b/priv/repo/migrations/20250414105225_add_hours_per_week_to_bounty.exs @@ -0,0 +1,9 @@ +defmodule Algora.Repo.Migrations.AddHoursPerWeekToBounty do + use Ecto.Migration + + def change do + alter table(:bounties) do + add :hours_per_week, :integer + end + end +end From dc1916d1d6bce5e0c5e765b98390b885f5aec469 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 15:00:22 +0300 Subject: [PATCH 02/13] updates --- .../components/layouts/user.html.heex | 132 +++++++----------- lib/algora_web/forms/contract_form.ex | 22 +-- 2 files changed, 66 insertions(+), 88 deletions(-) diff --git a/lib/algora_web/components/layouts/user.html.heex b/lib/algora_web/components/layouts/user.html.heex index 976cc8f04..62d12ab0b 100644 --- a/lib/algora_web/components/layouts/user.html.heex +++ b/lib/algora_web/components/layouts/user.html.heex @@ -135,64 +135,6 @@ <% end %> - <%= if main_contract_form = Map.get(assigns, :main_contract_form) do %> -
- <.button - phx-click="open_main_contract_form" - class="h-9 w-9 rounded-md flex items-center justify-center relative" - > - <.icon name="tabler-user-dollar" class="h-6 w-6 shrink-0" /> - <.icon - name="tabler-plus" - class="h-[0.8rem] w-[0.8rem] shrink-0 absolute bottom-[0.2rem] right-[0.2rem]" - /> - - <.drawer - show={@main_contract_form_open?} - direction="right" - on_cancel="close_main_contract_form" - > - <.drawer_header> - <.drawer_title>Create new contract - <.drawer_description> - Engage a developer for ongoing work - - - <.drawer_content class="mt-4"> - - - -
- <% end %> - <%= if main_bounty_form = Map.get(assigns, :main_bounty_form) do %> -
- <.button - phx-click="open_main_bounty_form" - class="h-9 w-9 rounded-md flex items-center justify-center relative" - > - <.icon name="tabler-diamond" class="h-6 w-6 shrink-0" /> - <.icon - name="tabler-plus" - class="h-[0.8rem] w-[0.8rem] shrink-0 absolute bottom-[0.2rem] right-[0.2rem]" - /> - - <.drawer - show={@main_bounty_form_open?} - direction="right" - on_cancel="close_main_bounty_form" - > - <.drawer_header> - <.drawer_title>Create new bounty - <.drawer_description> - Create and fund a bounty for an issue - - - <.drawer_content class="mt-4"> - - - -
- <% end %>
@@ -266,26 +208,60 @@
<%= if @current_user do %> - <.button - phx-click="open_main_contract_form" - class="h-9 w-9 rounded-md flex items-center justify-center relative" - > - <.icon name="tabler-user-dollar" class="h-6 w-6 shrink-0" /> - <%!-- <.icon - name="tabler-plus" - class="h-[0.8rem] w-[0.8rem] shrink-0 absolute bottom-[0.2rem] right-[0.2rem]" - /> --%> - - <.button - phx-click="open_main_bounty_form" - class="h-9 w-9 rounded-md flex items-center justify-center relative" - > - <.icon name="tabler-diamond" class="h-6 w-6 shrink-0" /> - <.icon - name="tabler-plus" - class="h-[0.8rem] w-[0.8rem] shrink-0 absolute bottom-[0.2rem] right-[0.2rem]" - /> - + <%= if main_contract_form = Map.get(assigns, :main_contract_form) do %> +
+ <.button + phx-click="open_main_contract_form" + class="h-9 w-9 rounded-md flex items-center justify-center relative" + > + <.icon name="tabler-user-dollar" class="h-6 w-6 shrink-0" /> + + <.drawer + show={@main_contract_form_open?} + direction="right" + on_cancel="close_main_contract_form" + > + <.drawer_header> + <.drawer_title>Create new contract + <.drawer_description> + Engage a developer for ongoing work + + + <.drawer_content class="mt-4"> + + + +
+ <% end %> + <%= if main_bounty_form = Map.get(assigns, :main_bounty_form) do %> +
+ <.button + phx-click="open_main_bounty_form" + class="h-9 w-9 rounded-md flex items-center justify-center relative" + > + <.icon name="tabler-diamond" class="h-6 w-6 shrink-0" /> + <.icon + name="tabler-plus" + class="h-[0.8rem] w-[0.8rem] shrink-0 absolute bottom-[0.2rem] right-[0.2rem]" + /> + + <.drawer + show={@main_bounty_form_open?} + direction="right" + on_cancel="close_main_bounty_form" + > + <.drawer_header> + <.drawer_title>Create new bounty + <.drawer_description> + Create and fund a bounty for an issue + + + <.drawer_content class="mt-4"> + + + +
+ <% end %> <%!-- {live_render(@socket, AlgoraWeb.Activity.UserNavTimelineLive, id: "activity-timeline", session: %{}, diff --git a/lib/algora_web/forms/contract_form.ex b/lib/algora_web/forms/contract_form.ex index db9ca13d0..4538d0f53 100644 --- a/lib/algora_web/forms/contract_form.ex +++ b/lib/algora_web/forms/contract_form.ex @@ -49,6 +49,11 @@ defmodule AlgoraWeb.Forms.ContractForm do phx-change="validate_contract_main" >
+ <.input label="Title" field={@form[:title]} /> + <.input label="Description (optional)" field={@form[:description]} type="textarea" /> +
<%= for {label, value} <- type_options() do %>
- <.input label="Title" field={@form[:title]} placeholder="Brief description of the contract" /> - <.input - label="Description (optional)" - field={@form[:description]} - type="textarea" - placeholder="Requirements and acceptance criteria" - /> - <.input label="Amount" icon="tabler-currency-dollar" field={@form[:amount]} /> - +
+ <.input label="Amount" icon="tabler-currency-dollar" field={@form[:amount]} /> +
From 3a681dca2a54778f8264514a581c5cac1a63b18d Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 15:10:53 +0300 Subject: [PATCH 03/13] misc --- lib/algora_web/forms/contract_form.ex | 64 ++++++++++++++------------- lib/algora_web/live/contract_live.ex | 37 +++++++++++----- 2 files changed, 59 insertions(+), 42 deletions(-) diff --git a/lib/algora_web/forms/contract_form.ex b/lib/algora_web/forms/contract_form.ex index 4538d0f53..ee0657147 100644 --- a/lib/algora_web/forms/contract_form.ex +++ b/lib/algora_web/forms/contract_form.ex @@ -51,38 +51,40 @@ defmodule AlgoraWeb.Forms.ContractForm do
<.input label="Title" field={@form[:title]} /> <.input label="Description (optional)" field={@form[:description]} type="textarea" /> - -
- <%= for {label, value} <- type_options() do %> -
- {Money.to_string!(@bounty.amount)} - 0} class="text-base"> + {Money.to_string!(@bounty.amount)} 0} + class="text-base" + > /wk
@@ -287,7 +299,7 @@ defmodule AlgoraWeb.ContractLive do Description - <.card_content> + <.card_content class="pt-0">
{Phoenix.HTML.raw(@ticket_body_html)}
@@ -306,13 +318,16 @@ defmodule AlgoraWeb.ContractLive do {Util.initials(@contractor.name)} -
-
+ <%!--
+
--%>

{@contractor.name}

-

- <%!-- Active {Util.time_ago(@contractor.last_active_at)} --%> +

+ Active {Util.time_ago(@contractor.last_active_at)} +

+

+ Offline

From e618683359446bdd055ae1c3354405c921b4f279 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 15:12:14 +0300 Subject: [PATCH 04/13] misc --- lib/algora_web/live/contract_live.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index e5e206459..886f999bf 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -418,9 +418,9 @@ defmodule AlgoraWeb.ContractLive do <.drawer :if={@current_user} show={@show_reward_modal} on_cancel="close_drawer"> <.drawer_header> - <.drawer_title>Reward Bounty + <.drawer_title>Reward Contract <.drawer_description> - You can pay the full bounty now or start with a partial amount - it's up to you! + You can pay any amount at any time. <.drawer_content class="mt-4"> @@ -431,7 +431,7 @@ defmodule AlgoraWeb.ContractLive do <.card_header> <.card_title>Payment Details - <.card_content> + <.card_content class="pt-0">
<.input label="Amount" @@ -456,7 +456,7 @@ defmodule AlgoraWeb.ContractLive do <.card_header> <.card_title>Payment Summary - <.card_content> + <.card_content class="pt-0">
<%= for line_item <- @line_items do %>
From e3fe8c36c480a2b6c415e2567fe7f8fafc58f245 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 15:13:16 +0300 Subject: [PATCH 05/13] misc --- lib/algora_web/live/contract_live.ex | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index 886f999bf..6608d47f5 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -267,11 +267,8 @@ defmodule AlgoraWeb.ContractLive do :if={@bounty.hours_per_week && @bounty.hours_per_week > 0} class="space-x-2" > - - - <.icon name="tabler-clock" class="h-4 w-4" /> - {@bounty.hours_per_week} hours per week - + <.icon name="tabler-clock" class="h-4 w-4" /> + {@bounty.hours_per_week} hours per week
@@ -287,7 +284,7 @@ defmodule AlgoraWeb.ContractLive do
<.button :if={@can_create_bounty} phx-click="reward"> - Reward + Pay
@@ -418,7 +415,7 @@ defmodule AlgoraWeb.ContractLive do <.drawer :if={@current_user} show={@show_reward_modal} on_cancel="close_drawer"> <.drawer_header> - <.drawer_title>Reward Contract + <.drawer_title>Pay Contract <.drawer_description> You can pay any amount at any time. From a475a9f1d7a15cfebe44cffe180f1404bb30ac11 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 15:19:17 +0300 Subject: [PATCH 06/13] misc --- lib/algora_web/forms/contract_form.ex | 17 ++++++++++++----- lib/algora_web/live/org/nav.ex | 9 ++++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/algora_web/forms/contract_form.ex b/lib/algora_web/forms/contract_form.ex index ee0657147..5a7f1e74e 100644 --- a/lib/algora_web/forms/contract_form.ex +++ b/lib/algora_web/forms/contract_form.ex @@ -11,6 +11,7 @@ defmodule AlgoraWeb.Forms.ContractForm do embedded_schema do field :amount, USD + field :hourly_rate, USD field :hours_per_week, :integer field :type, Ecto.Enum, values: [:fixed, :hourly], default: :fixed field :title, :string @@ -26,17 +27,23 @@ defmodule AlgoraWeb.Forms.ContractForm do def changeset(form, attrs) do form - |> cast(attrs, [:amount, :hours_per_week, :type, :title, :description, :contractor_handle]) + |> cast(attrs, [:amount, :hourly_rate, :hours_per_week, :type, :title, :description, :contractor_handle]) |> validate_required([:contractor_handle]) |> validate_type_fields() - |> Validations.validate_money_positive(:amount) |> Validations.validate_github_handle(:contractor_handle, :contractor) end defp validate_type_fields(changeset) do case get_field(changeset, :type) do - :hourly -> validate_required(changeset, [:amount, :hours_per_week]) - _ -> validate_required(changeset, [:amount]) + :hourly -> + changeset + |> validate_required([:hourly_rate, :hours_per_week]) + |> Validations.validate_money_positive(:hourly_rate) + + _ -> + changeset + |> validate_required([:amount]) + |> Validations.validate_money_positive(:amount) end end @@ -92,7 +99,7 @@ defmodule AlgoraWeb.Forms.ContractForm do diff --git a/lib/algora_web/live/org/nav.ex b/lib/algora_web/live/org/nav.ex index fef0fb891..363455230 100644 --- a/lib/algora_web/live/org/nav.ex +++ b/lib/algora_web/live/org/nav.ex @@ -124,12 +124,18 @@ defmodule AlgoraWeb.Org.Nav do case apply_action(changeset, :save) do {:ok, data} -> + amount = + case data.type do + :fixed -> data.amount + :hourly -> data.hourly_rate + end + bounty_res = Bounties.create_bounty( %{ creator: socket.assigns.current_user, owner: socket.assigns.current_org, - amount: data.amount, + amount: amount, title: data.title, description: data.description }, @@ -148,6 +154,7 @@ defmodule AlgoraWeb.Org.Nav do end {:error, changeset} -> + Logger.error("Failed to create bounty: #{inspect(changeset)}") {:cont, assign(socket, :main_bounty_form, to_form(changeset))} end end From 7317b0de75b36cb6f9d5705478c04ed63a9fd584 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 16:23:35 +0300 Subject: [PATCH 07/13] misc fixes --- lib/algora/bounties/schemas/bounty.ex | 2 ++ lib/algora_web/live/org/dashboard_live.ex | 2 +- lib/algora_web/live/org/home_live.ex | 5 ++++- lib/algora_web/live/user/profile_live.ex | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex index 96adb2e0c..c85aadf29 100644 --- a/lib/algora/bounties/schemas/bounty.ex +++ b/lib/algora/bounties/schemas/bounty.ex @@ -68,6 +68,8 @@ defmodule Algora.Bounties.Bounty do Algora.Util.path_from_url(url) end + def path(_bounty), do: nil + def full_path(%{repository: %{name: name, owner: %{login: login}}, ticket: %{number: number}}) do "#{login}/#{name}##{number}" end diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 54171ced4..dc1402a75 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -79,6 +79,7 @@ defmodule AlgoraWeb.Org.DashboardLive do {:ok, socket + |> assign(:ip_address, AlgoraWeb.Util.get_ip(socket)) |> assign(:admins_last_active, admins_last_active) |> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user)) |> assign(:installations, installations) @@ -133,7 +134,6 @@ defmodule AlgoraWeb.Org.DashboardLive do {:noreply, socket - |> assign(:ip_address, AlgoraWeb.Util.get_ip(socket)) |> assign(:current_status, current_status) |> assign(:bounty_rows, to_bounty_rows(bounties)) |> assign(:transaction_rows, to_transaction_rows(transactions)) diff --git a/lib/algora_web/live/org/home_live.ex b/lib/algora_web/live/org/home_live.ex index 3d5c000f6..f60ab19e9 100644 --- a/lib/algora_web/live/org/home_live.ex +++ b/lib/algora_web/live/org/home_live.ex @@ -192,7 +192,10 @@ defmodule AlgoraWeb.Org.HomeLive do {ticket.title} -
+
<.icon name="tabler-chevron-right" class="h-4 w-4" /> <.link href={ diff --git a/lib/algora_web/live/user/profile_live.ex b/lib/algora_web/live/user/profile_live.ex index dba4b9b42..1380966af 100644 --- a/lib/algora_web/live/user/profile_live.ex +++ b/lib/algora_web/live/user/profile_live.ex @@ -138,7 +138,7 @@ defmodule AlgoraWeb.User.ProfileLive do {ticket.repository.name}#{ticket.number} <.link - :if={!ticket.repository} + :if={!ticket.repository && ticket.url} href={ticket.url} class="hover:underline" > From be351c7dad45a788b1ea80cf5d0df33e6b57daba Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 16:40:56 +0300 Subject: [PATCH 08/13] minor fixes --- lib/algora_web/live/contract_live.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index 6608d47f5..0ed5c42bd 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -290,7 +290,7 @@ defmodule AlgoraWeb.ContractLive do
- <.card> + <.card :if={@ticket_body_html}> <.card_header> <.card_title> Description @@ -360,7 +360,7 @@ defmodule AlgoraWeb.ContractLive do {Util.initials(message.sender.name)} -
+
{message.content}
{message.inserted_at From d03cf2275229c7b57c962b1d6e90a1f0bebf3333 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 16:51:58 +0300 Subject: [PATCH 09/13] feat: use global contract form in contributor table --- lib/algora_web/live/org/dashboard_live.ex | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index dc1402a75..b876f8a53 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -750,6 +750,21 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + @impl true + def handle_event("share_opportunity", %{"user_id" => user_id, "type" => "contract"}, socket) do + developer = Enum.find(socket.assigns.developers, &(&1.id == user_id)) + + {:noreply, + socket + |> assign(:main_contract_form_open?, true) + |> assign( + :main_contract_form, + %ContractForm{} + |> ContractForm.changeset(%{contractor_handle: developer.provider_login}) + |> to_form() + )} + end + @impl true def handle_event("share_opportunity", %{"user_id" => user_id, "type" => type}, socket) do developer = Enum.find(socket.assigns.developers, &(&1.id == user_id)) From c79abba3b1f006357c1a22122e98f04eee282bfb Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 16:54:29 +0300 Subject: [PATCH 10/13] decommission old contract page --- config/config.exs | 1 + lib/algora_web/live/org/nav.ex | 2 +- lib/algora_web/router.ex | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/config/config.exs b/config/config.exs index 7555d7bdd..b0e68564e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -23,6 +23,7 @@ config :algora, {"/create/org", "/onboarding/org"}, {"/solve", "/onboarding/dev"}, {"/onboarding/solver", "/onboarding/dev"}, + {"/:org/contract/:id", "/:org/contracts/:id"}, {"/org/*path", "/*path"}, {"/@/:handle", "/:handle/profile"} ] diff --git a/lib/algora_web/live/org/nav.ex b/lib/algora_web/live/org/nav.ex index 363455230..494e80c8a 100644 --- a/lib/algora_web/live/org/nav.ex +++ b/lib/algora_web/live/org/nav.ex @@ -146,7 +146,7 @@ defmodule AlgoraWeb.Org.Nav do case bounty_res do {:ok, bounty} -> - {:cont, redirect(socket, to: ~p"/#{socket.assigns.current_org.handle}/contract/#{bounty.id}")} + {:cont, redirect(socket, to: ~p"/#{socket.assigns.current_org.handle}/contracts/#{bounty.id}")} {:error, reason} -> Logger.error("Failed to create bounty: #{inspect(reason)}") diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index c60e7f44c..b9f5343ee 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -200,8 +200,8 @@ defmodule AlgoraWeb.Router do live "/bounties/new", Org.BountiesNewLive, :index live "/bounties/community", Org.BountiesNewLive, :index live "/bounties/:id", BountyLive, :index - live "/contract/:id", ContractLive - live "/contracts/:id", Contract.ViewLive + # live "/contracts/:id", Contract.ViewLive + live "/contracts/:id", ContractLive live "/team", Org.TeamLive, :index live "/leaderboard", Org.LeaderboardLive, :index end From 19e3d48aa6f8836ce614947b0198a6a1842367a4 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 17:09:18 +0300 Subject: [PATCH 11/13] display transactions on contract page --- lib/algora/bounties/bounties.ex | 24 ++++--- lib/algora_web/live/contract_live.ex | 104 +++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 47459e224..929929bf4 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -868,6 +868,7 @@ defmodule Algora.Bounties do initialize_charge(%{ id: Nanoid.generate(), user_id: owner.id, + bounty_id: opts[:bounty_id], gross_amount: gross_amount, net_amount: amount, total_fee: Money.sub!(gross_amount, amount), @@ -967,16 +968,18 @@ defmodule Algora.Bounties do end) end - defp initialize_charge(%{ - id: id, - user_id: user_id, - gross_amount: gross_amount, - net_amount: net_amount, - total_fee: total_fee, - line_items: line_items, - group_id: group_id, - idempotency_key: idempotency_key - }) do + defp initialize_charge( + %{ + id: id, + user_id: user_id, + gross_amount: gross_amount, + net_amount: net_amount, + total_fee: total_fee, + line_items: line_items, + group_id: group_id, + idempotency_key: idempotency_key + } = params + ) do %Transaction{} |> change(%{ id: id, @@ -984,6 +987,7 @@ defmodule Algora.Bounties do type: :charge, status: :initialized, user_id: user_id, + bounty_id: params[:bounty_id], gross_amount: gross_amount, net_amount: net_amount, total_fee: total_fee, diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index 0ed5c42bd..1dea3fa75 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -11,6 +11,7 @@ defmodule AlgoraWeb.ContractLive do alias Algora.Bounties.LineItem alias Algora.Chat alias Algora.Organizations.Member + alias Algora.Payments alias Algora.Repo alias Algora.Util alias Algora.Workspace @@ -47,6 +48,8 @@ defmodule AlgoraWeb.ContractLive do |> Repo.get!(bounty_id) |> Repo.preload([:owner, :creator, :transactions, ticket: [repository: [:user]]]) + timezone = if(params = get_connect_params(socket), do: params["timezone"]) + {host, ticket_ref} = if bounty.ticket.repository do {bounty.ticket.repository.user, @@ -63,6 +66,7 @@ defmodule AlgoraWeb.ContractLive do |> assign(:bounty, bounty) |> assign(:ticket_ref, ticket_ref) |> assign(:host, host) + |> assign(:timezone, timezone) |> on_mount(bounty) end @@ -140,6 +144,7 @@ defmodule AlgoraWeb.ContractLive do |> assign(:participants, participants) |> assign(:reward_form, to_form(reward_changeset)) |> assign_contractor(bounty.shared_with) + |> assign_transactions() |> assign_line_items()} end @@ -302,6 +307,51 @@ defmodule AlgoraWeb.ContractLive do
+ <.card :if={length(@transactions) > 0}> + <.card_content> +
+
+ + + + + + + + + + <%= for transaction <- @transactions do %> + + + + + + <% end %> + +
Date + Description + + Amount +
+ {Util.timestamp(transaction.inserted_at, @timezone)} + + {description(transaction)} + + <%= case transaction_direction(transaction.type) do %> + <% :plus -> %> + + {Money.to_string!(transaction.net_amount)} + + <% :minus -> %> + + {Money.to_string!(transaction.net_amount)} + + <% end %> +
+
+
+ +
@@ -556,4 +606,58 @@ defmodule AlgoraWeb.ContractLive do assign(socket, :contractor, contractor) end + + defp assign_transactions(socket) do + transactions = + Payments.list_transactions( + user_id: socket.assigns.bounty.owner.id, + status: :succeeded, + bounty_id: socket.assigns.bounty.id + ) + + balance = calculate_balance(transactions) + volume = calculate_volume(transactions) + + socket + |> assign(:transactions, transactions) + |> assign(:total_balance, balance) + |> assign(:total_volume, volume) + end + + defp calculate_balance(transactions) do + Enum.reduce(transactions, Money.new!(0, :USD), fn transaction, acc -> + case transaction.type do + type when type in [:charge, :deposit, :credit] -> + Money.add!(acc, transaction.net_amount) + + type when type in [:debit, :withdrawal, :transfer] -> + Money.sub!(acc, transaction.net_amount) + + _ -> + acc + end + end) + end + + defp calculate_volume(transactions) do + Enum.reduce(transactions, Money.new!(0, :USD), fn transaction, acc -> + case transaction.type do + type when type in [:charge, :credit] -> Money.add!(acc, transaction.net_amount) + _ -> acc + end + end) + end + + defp transaction_direction(type) do + case type do + t when t in [:charge, :credit, :deposit] -> :minus + t when t in [:debit, :withdrawal, :transfer] -> :plus + end + end + + defp description(%{type: :charge}), do: "Escrowed" + + defp description(%{type: :debit}), do: "Released" + + defp description(%{type: _type}), do: nil end From 78b644656e815341ad0d49f591896d9a3ed5ebac Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 17:13:55 +0300 Subject: [PATCH 12/13] improvements --- lib/algora_web/live/contract_live.ex | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index 1dea3fa75..319a4ef79 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -308,9 +308,14 @@ defmodule AlgoraWeb.ContractLive do <.card :if={length(@transactions) > 0}> - <.card_content> -
-
+ <.card_header> + <.card_title> + Timeline + + + <.card_content class="pt-0"> +
+
@@ -327,7 +332,14 @@ defmodule AlgoraWeb.ContractLive do <%= for transaction <- @transactions do %>
- {Util.timestamp(transaction.inserted_at, @timezone)} +
+ {Util.timestamp(transaction.inserted_at, @timezone)} +
+
+
{description(transaction)} From 699817b07f7f307c10ece093e9b614685b6ae8b9 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 14 Apr 2025 17:29:05 +0300 Subject: [PATCH 13/13] fix typo --- lib/algora_web/live/contract_live.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index 319a4ef79..24030efa9 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -285,7 +285,7 @@ defmodule AlgoraWeb.ContractLive do :if={@bounty.hours_per_week && @bounty.hours_per_week > 0} class="text-base" > - /wk + /hr <.button :if={@can_create_bounty} phx-click="reward">