Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
321
lib/tarakan_web/live/bounty_live/show.ex
Normal file
321
lib/tarakan_web/live/bounty_live/show.ex
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
defmodule TarakanWeb.BountyLive.Show do
|
||||
@moduledoc "One contract: target, reward, claims, and sponsor/moderator controls."
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
import TarakanWeb.BountyComponents
|
||||
|
||||
alias Tarakan.Market
|
||||
alias Tarakan.Market.Bounty
|
||||
alias Tarakan.Policy
|
||||
|
||||
@impl true
|
||||
def mount(%{"public_id" => public_id}, _session, socket) do
|
||||
bounty = Market.get_bounty!(public_id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Contract: #{bounty.title}")
|
||||
|> assign(:meta_description, meta_description(bounty))
|
||||
|> assign(:canonical_path, ~p"/bounties/#{bounty.public_id}")
|
||||
|> assign_bounty(bounty)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("claim", _params, socket) do
|
||||
case Market.claim_bounty(socket.assigns.current_scope, socket.assigns.bounty) do
|
||||
{:ok, _claim} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Contract claimed - the job is yours for the next two hours.")
|
||||
|> reload_bounty()}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, claim_error_message(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("cancel", _params, socket) do
|
||||
case Market.cancel_bounty(socket.assigns.current_scope, socket.assigns.bounty) do
|
||||
{:ok, _bounty} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Contract cancelled. Escrowed funds were returned.")
|
||||
|> reload_bounty()}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, cancel_error_message(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("settle", _params, socket) do
|
||||
case Market.settle_bounty(socket.assigns.current_scope, socket.assigns.bounty) do
|
||||
{:ok, bounty} ->
|
||||
message =
|
||||
if bounty.funding == "fiat",
|
||||
do: "Contract settled. Payout is pending manual processing.",
|
||||
else: "Contract settled. Credits were paid to the winner."
|
||||
|
||||
{:noreply, socket |> put_flash(:info, message) |> reload_bounty()}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, settle_error_message(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("mark_paid", _params, socket) do
|
||||
case Market.mark_paid(socket.assigns.current_scope, socket.assigns.bounty) do
|
||||
{:ok, _bounty} ->
|
||||
{:noreply, socket |> put_flash(:info, "Contract marked paid.") |> reload_bounty()}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "The contract could not be marked paid.")}
|
||||
end
|
||||
end
|
||||
|
||||
defp reload_bounty(socket) do
|
||||
assign_bounty(socket, Market.get_bounty!(socket.assigns.bounty.public_id))
|
||||
end
|
||||
|
||||
defp assign_bounty(socket, bounty) do
|
||||
scope = socket.assigns.current_scope
|
||||
account_id = scope && scope.account_id
|
||||
|
||||
claims = Enum.sort_by(bounty.claims, & &1.id, :desc)
|
||||
|
||||
my_claim =
|
||||
if account_id, do: Enum.find(claims, &(&1.account_id == account_id)), else: nil
|
||||
|
||||
socket
|
||||
|> assign(:bounty, bounty)
|
||||
|> assign(:claims, claims)
|
||||
|> assign(:claim_count, length(claims))
|
||||
|> assign(:my_claim, my_claim)
|
||||
|> assign(:sponsor?, account_id != nil and account_id == bounty.sponsor_account_id)
|
||||
|> assign(:winner?, account_id != nil and account_id == bounty.winner_account_id)
|
||||
|> assign(:moderator?, scope != nil and Policy.moderator?(scope))
|
||||
|> assign(:admin?, scope != nil and Policy.admin?(scope))
|
||||
|> assign(:can_claim?, can_claim?(scope, bounty))
|
||||
|> assign(:can_cancel?, can_cancel?(scope, bounty))
|
||||
end
|
||||
|
||||
defp can_claim?(nil, _bounty), do: false
|
||||
|
||||
defp can_claim?(scope, %Bounty{} = bounty) do
|
||||
scope.account_id != nil and scope.account_state == "active" and
|
||||
bounty.status == "open" and scope.account_id != bounty.sponsor_account_id
|
||||
end
|
||||
|
||||
defp can_cancel?(nil, _bounty), do: false
|
||||
|
||||
defp can_cancel?(scope, %Bounty{} = bounty) do
|
||||
bounty.status in ["pending_funding", "open"] and bounty.claims == [] and
|
||||
(scope.account_id == bounty.sponsor_account_id or Policy.moderator?(scope))
|
||||
end
|
||||
|
||||
defp meta_description(bounty) do
|
||||
"#{amount_label(bounty)} contract on #{target_label(bounty)}: #{bounty.title}"
|
||||
|> String.replace(~r/\s+/, " ")
|
||||
|> String.slice(0, 160)
|
||||
end
|
||||
|
||||
defp claim_error_message(:own_bounty), do: "You cannot claim your own contract."
|
||||
defp claim_error_message(:not_open), do: "This contract is no longer open."
|
||||
defp claim_error_message(:claim_limit), do: "You already hold too many active claims."
|
||||
defp claim_error_message(:unauthorized), do: "Your account cannot claim contracts."
|
||||
defp claim_error_message(_reason), do: "The contract could not be claimed. Try again shortly."
|
||||
|
||||
defp cancel_error_message(:has_claims), do: "A contract with claims cannot be cancelled."
|
||||
defp cancel_error_message(:invalid_state), do: "This contract can no longer be cancelled."
|
||||
defp cancel_error_message(:unauthorized), do: "You cannot cancel this contract."
|
||||
|
||||
defp cancel_error_message(_reason),
|
||||
do: "The contract could not be cancelled. Try again shortly."
|
||||
|
||||
defp settle_error_message(:no_winning_claim), do: "No submitted or accepted claim to settle."
|
||||
defp settle_error_message(:invalid_state), do: "This contract is not awaiting settlement."
|
||||
defp settle_error_message(_reason), do: "The contract could not be settled. Try again shortly."
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:focused}>
|
||||
<div id={"bounty-poster-#{@bounty.public_id}"} class="border-2 border-strong">
|
||||
<div class="flex items-center justify-between border-b-2 border-strong bg-panel px-5 py-3">
|
||||
<.notch_badge class="bg-signal text-ground">Contract</.notch_badge>
|
||||
<span
|
||||
id="bounty-status"
|
||||
class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
|
||||
>
|
||||
{status_label(@bounty.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-6">
|
||||
<p id="bounty-amount" class="font-display text-5xl font-medium leading-none text-signal">
|
||||
{amount_label(@bounty)}
|
||||
</p>
|
||||
<h1 class="mt-4 font-display text-2xl font-medium uppercase leading-tight tracking-[0.02em] text-ink">
|
||||
{@bounty.title}
|
||||
</h1>
|
||||
|
||||
<dl class="mt-5 space-y-2 border-y-2 border-rule py-4 font-mono text-xs text-ink-muted">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Target</dt>
|
||||
<dd class="text-right">
|
||||
<%= if path = target_path(@bounty) do %>
|
||||
<.link navigate={path} class="text-signal hover:underline">
|
||||
{target_label(@bounty)}
|
||||
</.link>
|
||||
<% else %>
|
||||
{target_label(@bounty)}
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Sponsor</dt>
|
||||
<dd>
|
||||
<.link
|
||||
navigate={~p"/#{@bounty.sponsor_account.handle}"}
|
||||
class="text-signal hover:underline"
|
||||
>
|
||||
@{@bounty.sponsor_account.handle}
|
||||
</.link>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Funding</dt>
|
||||
<dd>{if @bounty.funding == "fiat", do: "card escrow", else: "credits"}</dd>
|
||||
</div>
|
||||
<div :if={@bounty.expires_at} class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Expires</dt>
|
||||
<dd>{Calendar.strftime(@bounty.expires_at, "%Y-%m-%d")}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Claims</dt>
|
||||
<dd>{@claim_count}</dd>
|
||||
</div>
|
||||
<div :if={@bounty.winner_account} class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Winner</dt>
|
||||
<dd>
|
||||
<.link
|
||||
navigate={~p"/#{@bounty.winner_account.handle}"}
|
||||
class="text-signal hover:underline"
|
||||
>
|
||||
@{@bounty.winner_account.handle}
|
||||
</.link>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="mt-5 whitespace-pre-wrap text-sm leading-6 text-ink">{@bounty.description}</p>
|
||||
|
||||
<div
|
||||
:if={@winner? and @bounty.status == "payout_pending"}
|
||||
class="mt-5 border-2 border-rule bg-panel px-4 py-3 text-xs leading-5 text-ink-muted"
|
||||
>
|
||||
Payout pending - the platform processes fiat payouts manually. You'll be
|
||||
contacted at your account email.
|
||||
</div>
|
||||
<div
|
||||
:if={@sponsor? and @bounty.status == "payout_pending"}
|
||||
class="mt-5 border-2 border-rule bg-panel px-4 py-3 text-xs leading-5 text-ink-muted"
|
||||
>
|
||||
Payout pending - escrowed funds are being released to the winner.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
:if={@can_claim?}
|
||||
id="bounty-claim-button"
|
||||
type="button"
|
||||
phx-click="claim"
|
||||
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
||||
>
|
||||
Claim this contract
|
||||
</button>
|
||||
<.link
|
||||
:if={not @can_claim? and @bounty.status == "open" and @current_scope == nil}
|
||||
id="bounty-login-to-claim"
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
||||
>
|
||||
Log in to claim
|
||||
</.link>
|
||||
<button
|
||||
:if={@can_cancel?}
|
||||
id="bounty-cancel-button"
|
||||
type="button"
|
||||
phx-click="cancel"
|
||||
data-confirm="Cancel this contract? Escrowed funds will be returned."
|
||||
class="cursor-pointer border border-strong px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink-muted transition hover:border-ink hover:text-ink"
|
||||
>
|
||||
Cancel contract
|
||||
</button>
|
||||
<button
|
||||
:if={@moderator? and @bounty.status == "claimed"}
|
||||
id="bounty-settle-button"
|
||||
type="button"
|
||||
phx-click="settle"
|
||||
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
||||
>
|
||||
Settle contract
|
||||
</button>
|
||||
<button
|
||||
:if={@admin? and @bounty.status == "payout_pending"}
|
||||
id="bounty-mark-paid-button"
|
||||
type="button"
|
||||
phx-click="mark_paid"
|
||||
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
||||
>
|
||||
Mark paid
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section :if={@claims != []} id="bounty-claims" class="mt-8">
|
||||
<h2 class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Claims
|
||||
</h2>
|
||||
<ul class="mt-3 divide-y divide-rule border-2 border-strong">
|
||||
<li
|
||||
:for={claim <- @claims}
|
||||
id={"bounty-claim-#{claim.id}"}
|
||||
class="flex items-center justify-between gap-4 px-4 py-3"
|
||||
>
|
||||
<span class="font-mono text-xs text-ink">
|
||||
@{claim.account.handle}
|
||||
<span :if={claim.review_task} class="text-ink-faint">
|
||||
·
|
||||
<.link
|
||||
navigate={~p"/jobs/#{claim.review_task.id}"}
|
||||
class="text-signal hover:underline"
|
||||
>job</.link>
|
||||
</span>
|
||||
</span>
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-muted">
|
||||
{claim.status}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p
|
||||
:if={@my_claim && @my_claim.status in ["claimed", "submitted"]}
|
||||
class="mt-6 text-xs leading-5 text-ink-muted"
|
||||
>
|
||||
Your claim is backed by
|
||||
<.link
|
||||
navigate={~p"/jobs/#{@my_claim.review_task.id}"}
|
||||
class="font-semibold text-signal hover:underline"
|
||||
>
|
||||
this job
|
||||
</.link>
|
||||
- submit your work there. Settlement follows acceptance.
|
||||
</p>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue