Initial commit on Forgejo
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s

Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:40 -04:00
commit af6077b9c3
455 changed files with 78366 additions and 0 deletions

View file

@ -0,0 +1,159 @@
defmodule TarakanWeb.BountyLive.Index do
@moduledoc "Open contracts - the public reward board."
use TarakanWeb, :live_view
import TarakanWeb.BountyComponents
alias Tarakan.Market
@filters [
{"all", "All"},
{"repository", "Repositories"},
{"infestation", "Infestations"},
{"finding", "Findings"}
]
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Contracts")
|> assign(
:meta_description,
"Open contracts on Tarakan. Sponsors post rewards for security work on public targets."
)
|> assign(:canonical_path, ~p"/bounties")
|> assign(:filters, @filters)}
end
@impl true
def handle_params(params, _uri, socket) do
filter = normalize_filter(params["type"])
bounties = Market.list_open(target_type: filter)
{:noreply,
socket
|> assign(:filter, filter || "all")
|> assign(:bounties, bounties)
|> assign(:bounty_count, length(bounties))
|> assign(:open_cents, sum_by(bounties, :amount_cents))
|> assign(:open_credits, sum_by(bounties, :credit_amount))}
end
defp sum_by(bounties, key) do
Enum.reduce(bounties, 0, fn bounty, total -> total + (Map.get(bounty, key) || 0) end)
end
defp normalize_filter(filter) when filter in ["repository", "infestation", "finding"],
do: filter
defp normalize_filter(_), do: nil
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
<Layouts.page width={:wide}>
<div class="flex flex-col gap-6 border-b-2 border-strong pb-6 sm:flex-row sm:items-end sm:justify-between">
<div class="min-w-0 max-w-2xl">
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-5xl">
Contracts
</h1>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-4">
<p class="font-mono text-sm tabular-nums text-ink">
<span class="font-display text-3xl">{@bounty_count}</span>
<span class="ml-1 text-xs uppercase tracking-[0.12em] text-ink-faint">open</span>
</p>
<p :if={@open_cents > 0} class="font-mono text-sm tabular-nums text-ink">
<span class="font-display text-3xl">${div(@open_cents, 100)}</span>
<span class="ml-1 text-xs uppercase tracking-[0.12em] text-ink-faint">escrowed</span>
</p>
<p :if={@open_credits > 0} class="font-mono text-sm tabular-nums text-ink">
<span class="font-display text-3xl">{@open_credits}</span>
<span class="ml-1 text-xs uppercase tracking-[0.12em] text-ink-faint">credits</span>
</p>
<.link
navigate={~p"/bounties/new"}
class="border-2 border-signal px-4 py-1.5 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
>
Post a contract
</.link>
</div>
</div>
<div id="bounty-filters" class="mt-6 flex flex-wrap items-center gap-2">
<.link
:for={{value, label} <- @filters}
patch={~p"/bounties?#{%{type: value}}"}
class={[
"wire-badge font-display text-[10px] uppercase tracking-[0.12em] transition",
@filter == value && "bg-signal text-ground",
@filter != value && "text-ink-muted hover:text-ink"
]}
>
<span class="wire-badge-label">{label}</span>
</.link>
</div>
<div
:if={@bounties == []}
id="bounties-empty"
class="mt-8 bg-panel px-6 py-12 text-center"
>
<p class="text-sm font-medium text-ink">No open contracts right now</p>
<p class="mt-2 text-xs leading-5 text-ink-muted">
<.link navigate={~p"/bounties/new"} class="font-semibold text-signal hover:underline">
Post the first one
</.link>
or browse the <.link
navigate={~p"/jobs"}
class="font-semibold text-signal hover:underline"
>jobs queue</.link>.
</p>
</div>
<ul
:if={@bounties != []}
id="bounties"
class="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3"
>
<li :for={bounty <- @bounties} id={"bounty-#{bounty.public_id}"}>
<.link
navigate={~p"/bounties/#{bounty.public_id}"}
class="group flex h-full flex-col border-2 border-strong bg-panel transition-colors hover:bg-ground"
>
<div class="flex items-center justify-between border-b-2 border-strong px-4 py-2">
<.notch_badge class="bg-signal text-ground">Contract</.notch_badge>
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
{bounty.target_type}
</span>
</div>
<div class="flex flex-1 flex-col px-4 py-4">
<p class="font-display text-3xl font-medium leading-none text-signal">
{amount_label(bounty)}
</p>
<p class="mt-3 text-sm font-semibold leading-5 text-ink group-hover:text-signal">
{bounty.title}
</p>
<p class="mt-2 line-clamp-2 text-xs leading-5 text-ink-muted">
{bounty.description}
</p>
<p class="mt-auto pt-4 font-mono text-[11px] text-ink-faint">
{target_label(bounty)}
<span :if={bounty.sponsor_account} class="mx-1">·</span>
<span :if={bounty.sponsor_account}>by @{bounty.sponsor_account.handle}</span>
<span :if={bounty.expires_at} class="mx-1">·</span>
<span :if={bounty.expires_at}>
expires {Calendar.strftime(bounty.expires_at, "%b %-d")}
</span>
</p>
</div>
</.link>
</li>
</ul>
</Layouts.page>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,386 @@
defmodule TarakanWeb.BountyLive.New do
@moduledoc """
Post a new contract (a bounty internally).
This route lives in the public live session so `/bounties/:public_id` can
stay public; it enforces authentication in mount and redirects anonymous
visitors to the login page.
Targets are picked, not typed: repositories come from a live search and
infestations from a select, so `target_ref` always holds a reference the
market can resolve. `?target_type=&target_ref=` prefills the form, which is
how repository and infestation pages link into it.
"""
use TarakanWeb, :live_view
alias Tarakan.Credits
alias Tarakan.Infestations
alias Tarakan.Market
alias Tarakan.Repositories
alias Tarakan.Repositories.Repository
@target_types [
{"Repository", "repository"},
{"Infestation", "infestation"},
{"Finding", "finding"}
]
@fundings [{"Credits", "credits"}, {"Card (fiat escrow)", "fiat"}]
@search_limit 8
@infestation_limit 50
@impl true
def mount(params, _session, socket) do
scope = socket.assigns.current_scope
if scope && scope.account do
form_params = prefill(params)
{:ok,
socket
|> assign(:page_title, "Post a contract")
|> assign(:target_types, @target_types)
|> assign(:fundings, @fundings)
|> assign(:credit_balance, Credits.balance(scope.account))
|> assign(:infestation_options, infestation_options())
|> assign(:repo_results, [])
|> assign_form(form_params)}
else
{:ok,
socket
|> put_flash(:error, "You must log in to access this page.")
|> redirect(to: ~p"/accounts/log-in")}
end
end
defp prefill(params) do
%{
"target_type" => present_or(params["target_type"], "repository"),
"target_ref" => present_or(params["target_ref"], ""),
"funding" => present_or(params["funding"], "credits")
}
end
defp present_or(value, default) when is_binary(value) do
if String.trim(value) == "", do: default, else: value
end
defp present_or(_value, default), do: default
@impl true
def handle_event("validate", %{"bounty" => params}, socket) do
{:noreply,
socket
|> assign_form(params)
|> search_repositories(params)}
end
def handle_event("pick_repository", %{"ref" => ref}, socket) do
params =
socket.assigns.form.params
|> Map.put("target_ref", ref)
|> Map.put("repo_query", "")
{:noreply,
socket
|> assign_form(params)
|> assign(:repo_results, [])}
end
def handle_event("clear_repository", _params, socket) do
{:noreply,
socket
|> assign_form(Map.put(socket.assigns.form.params, "target_ref", ""))
|> assign(:repo_results, [])}
end
def handle_event("create", %{"bounty" => params}, socket) do
attrs = build_attrs(params)
case Market.create_bounty(socket.assigns.current_scope, attrs) do
{:ok, bounty} ->
{:noreply,
socket
|> put_flash(:info, "Contract posted - credits are held in escrow until settlement.")
|> push_navigate(to: ~p"/bounties/#{bounty.public_id}")}
{:ok, _bounty, checkout_url} ->
{:noreply, redirect(socket, external: checkout_url)}
{:error, reason} ->
{:noreply, put_flash(socket, :error, create_error_message(reason))}
end
end
# The repository search runs only while the repository target is selected and
# nothing has been picked yet - once a ref is set the results list is noise.
defp search_repositories(socket, params) do
query = params["repo_query"] || ""
results =
if params["target_type"] == "repository" and blank?(params["target_ref"]) do
Repositories.search_repositories(query, @search_limit)
else
[]
end
assign(socket, :repo_results, results)
end
defp assign_form(socket, params) do
params = Map.put_new(params, "target_type", "repository")
socket
|> assign(:form, to_form(params, as: :bounty))
|> assign(:target_type, params["target_type"])
|> assign(:funding, if(params["funding"] == "fiat", do: "fiat", else: "credits"))
|> assign(:selected_repository, selected_repository(params))
end
defp selected_repository(%{"target_type" => "repository", "target_ref" => ref})
when is_binary(ref) do
if String.trim(ref) == "", do: nil, else: ref
end
defp selected_repository(_params), do: nil
defp blank?(nil), do: true
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
defp infestation_options do
Infestations.list_infestations(limit: @infestation_limit)
|> Enum.map(fn infestation ->
{"#{infestation.title} (#{infestation.repo_count} repos)", infestation.pattern_key}
end)
end
# Hosted repositories are addressed without a host; remote ones need theirs.
defp repository_ref(%Repository{} = repository) do
if Repository.hosted?(repository) do
"#{repository.owner}/#{repository.name}"
else
"#{repository.host}/#{repository.owner}/#{repository.name}"
end
end
defp build_attrs(params) do
funding = if params["funding"] == "fiat", do: "fiat", else: "credits"
base = %{
"target_type" => params["target_type"],
"target_ref" => params["target_ref"],
"title" => params["title"],
"description" => params["description"],
"funding" => funding,
"expires_days" => params["expires_days"]
}
case funding do
"fiat" -> Map.put(base, "amount_cents", dollars_to_cents(params["amount"]))
"credits" -> Map.put(base, "credit_amount", parse_integer(params["amount"]))
end
end
defp dollars_to_cents(nil), do: nil
defp dollars_to_cents(value) when is_binary(value) do
case Float.parse(String.trim(value)) do
{dollars, ""} -> round(dollars * 100)
_invalid -> nil
end
end
defp parse_integer(nil), do: nil
defp parse_integer(value) when is_binary(value) do
case Integer.parse(String.trim(value)) do
{integer, ""} -> integer
_invalid -> nil
end
end
defp create_error_message(:insufficient_credits),
do: "Not enough credits to fund this contract."
defp create_error_message(:target_not_found),
do: "That target was not found. Check the reference and try again."
defp create_error_message(:target_not_public),
do: "Contracts can only target publicly visible repositories and findings."
defp create_error_message(:unauthorized),
do: "Only active accounts can post contracts."
defp create_error_message(%Ecto.Changeset{} = changeset) do
details =
changeset
|> Ecto.Changeset.traverse_errors(fn {message, _meta} -> message end)
|> Enum.map_join("; ", fn {field, messages} -> "#{field} #{Enum.join(messages, ", ")}" end)
"The contract could not be created: #{details}."
end
defp create_error_message(_reason),
do: "The contract could not be created. 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 class="border-b-2 border-strong pb-6">
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink">
Post a contract
</h1>
</div>
<.form
for={@form}
id="bounty-form"
phx-change="validate"
phx-submit="create"
class="mt-6 space-y-5"
>
<.input
field={@form[:target_type]}
type="select"
label="Target type"
options={@target_types}
/>
<div :if={@target_type == "repository"}>
<div :if={@selected_repository} class="flex items-center gap-3">
<span class="font-mono text-xs uppercase tracking-[0.12em] text-ink-faint">Target</span>
<span
id="bounty-selected-repository"
class="min-w-0 flex-1 truncate border-2 border-strong bg-panel px-3 py-2 font-mono text-xs text-ink"
>
{@selected_repository}
</span>
<button
type="button"
phx-click="clear_repository"
class="cursor-pointer font-mono text-[11px] uppercase tracking-[0.12em] text-signal hover:underline"
>
Change
</button>
</div>
<div :if={is_nil(@selected_repository)}>
<.input
field={@form[:repo_query]}
type="text"
label="Repository"
placeholder="Search listed repositories"
phx-debounce="200"
autocomplete="off"
/>
<ul
:if={@repo_results != []}
id="bounty-repo-results"
class="mt-2 divide-y divide-rule border-2 border-strong bg-ground"
>
<li :for={repository <- @repo_results}>
<button
type="button"
phx-click="pick_repository"
phx-value-ref={repository_ref(repository)}
class="flex w-full cursor-pointer items-baseline gap-2 px-3 py-2 text-left transition hover:bg-panel"
>
<span class="min-w-0 flex-1 truncate font-mono text-xs text-ink">
{repository.owner}/{repository.name}
</span>
<span class="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
{repository.host}
</span>
</button>
</li>
</ul>
<p
:if={@repo_results == [] and not blank?(@form.params["repo_query"])}
class="mt-2 font-mono text-[11px] text-ink-faint"
>
No listed repository matches.
</p>
</div>
</div>
<.input
:if={@target_type == "infestation"}
field={@form[:target_ref]}
type="select"
label="Infestation"
options={@infestation_options}
prompt="Choose an infestation"
/>
<div :if={@target_type == "finding"}>
<.input
field={@form[:target_ref]}
type="text"
label="Finding"
placeholder="Public id from the finding page"
autocomplete="off"
/>
<p class="mt-2 font-mono text-[11px] leading-5 text-ink-faint">
The id in the finding's URL, <code>/findings/&lt;id&gt;</code>.
</p>
</div>
<input
:if={@target_type == "repository"}
type="hidden"
name="bounty[target_ref]"
value={@form.params["target_ref"] || ""}
/>
<.input field={@form[:title]} type="text" label="Title" required maxlength="200" />
<.input
field={@form[:description]}
type="textarea"
label="What should the hunter do?"
rows="5"
required
/>
<.input
field={@form[:funding]}
type="select"
label="Fund with"
options={@fundings}
/>
<.input
field={@form[:amount]}
type="text"
label={if @funding == "fiat", do: "Amount (USD)", else: "Amount (credits)"}
placeholder={if @funding == "fiat", do: "250", else: "500"}
required
/>
<p
:if={@funding == "credits"}
id="bounty-credit-balance"
class="-mt-3 font-mono text-[11px] leading-5 text-ink-faint"
>
Your balance: <span class="text-ink">{@credit_balance} credits</span>
· platform take rate applies at settlement.
</p>
<p
:if={@funding == "fiat"}
class="-mt-3 font-mono text-[11px] leading-5 text-ink-faint"
>
Held by Stripe until the work is accepted · platform take rate applies at settlement.
</p>
<button
id="bounty-create-button"
type="submit"
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"
>
Post contract
</button>
</.form>
</Layouts.page>
</Layouts.app>
"""
end
end

View 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