<.button
- navigate={~p"/onboarding/org"}
+ href={AlgoraWeb.Constants.get(:calendar_url)}
+ rel="noopener"
size="xl"
class="w-full text-lg drop-shadow-[0_1px_5px_#34d39980]"
>
@@ -554,7 +555,8 @@ defmodule AlgoraWeb.BountiesLive do
<.button
- navigate={~p"/onboarding/org"}
+ href={AlgoraWeb.Constants.get(:calendar_url)}
+ rel="noopener"
class="h-10 sm:h-14 rounded-md px-8 sm:px-12 text-sm sm:text-xl"
>
Companies
diff --git a/lib/algora_web/live/onboarding/org.ex b/lib/algora_web/live/onboarding/org.ex
index 5407b41b..211560ad 100644
--- a/lib/algora_web/live/onboarding/org.ex
+++ b/lib/algora_web/live/onboarding/org.ex
@@ -3,812 +3,218 @@ defmodule AlgoraWeb.Onboarding.OrgLive do
use AlgoraWeb, :live_view
use LiveSvelte.Components
- import Ecto.Changeset
-
- alias Algora.Accounts
- alias Algora.Accounts.User
- alias Algora.Organizations
- alias AlgoraWeb.Components.Wordmarks
- alias AlgoraWeb.LocalStore
- alias Phoenix.LiveView.AsyncResult
-
require Logger
- @steps [:tech_stack, :email, :preferences]
-
- # === SCHEMAS === #
-
- defmodule TechStackForm do
+ defmodule Form do
@moduledoc false
use Ecto.Schema
- import Ecto.Changeset
-
- @primary_key false
- embedded_schema do
- field :tech_stack, {:array, :string}
- end
-
- def init do
- to_form(TechStackForm.changeset(%TechStackForm{}, %{tech_stack: []}))
- end
-
- def changeset(form, attrs) do
- cast(form, attrs, [:tech_stack])
- end
- end
-
- defmodule EmailForm do
- @moduledoc false
- use Ecto.Schema
-
- import Ecto.Changeset
-
@primary_key false
+ @derive {Jason.Encoder, only: [:email, :job_description, :candidate_description]}
embedded_schema do
field :email, :string
- field :domain, :string
- end
-
- def init do
- to_form(EmailForm.changeset(%EmailForm{}, %{}))
- end
-
- def validate_domain_not_blacklisted(changeset) do
- domain = get_field(changeset, :domain)
-
- if not is_nil(domain) and Algora.Crawler.blacklisted?(domain) do
- add_error(changeset, :domain, "You can only use a company domain")
- else
- changeset
- end
+ field :job_description, :string
+ field :candidate_description, :string
end
- def validate_email_is_company_domain(changeset) do
- domain = get_field(changeset, :domain)
- email = get_field(changeset, :email)
-
- if is_nil(email) or is_nil(domain) do
- changeset
- else
- case String.split(email, "@") do
- [_, ^domain] ->
- changeset
-
- [_, _not_email_domain] ->
- add_error(changeset, :email, "Your email address must match your company domain")
- end
- end
- end
-
- def changeset(form, attrs) do
- form
- |> cast(attrs, [:email, :domain])
- |> validate_required([:email, :domain])
- |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must be a valid email address")
- |> validate_format(:domain, ~r/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/,
- message: "must be a valid domain"
- )
- |> validate_domain_not_blacklisted()
- end
- end
-
- defmodule VerificationForm do
- @moduledoc false
- use Ecto.Schema
-
- import Ecto.Changeset
-
- @primary_key false
- embedded_schema do
- field :code, :string
- end
-
- def init do
- to_form(VerificationForm.changeset(%VerificationForm{}, %{}))
- end
-
- def changeset(form, attrs) do
+ def changeset(form, attrs \\ %{}) do
form
- |> cast(attrs, [:code])
- |> validate_required([:code])
+ |> Ecto.Changeset.cast(attrs, [:email, :job_description, :candidate_description])
+ |> Ecto.Changeset.validate_required([:email, :job_description])
+ |> Ecto.Changeset.validate_format(:email, ~r/@/)
end
end
- defmodule PreferencesForm do
- @moduledoc false
- use Ecto.Schema
-
- import Ecto.Changeset
-
- @primary_key false
- embedded_schema do
- field :hiring, :boolean
- field :categories, {:array, :string}
- end
-
- def hiring_options do
- [{"Yes", "true"}, {"No", "false"}]
- end
-
- def categories_options do
- [
- {"Open source company", "open_source"},
- {"Closed source company", "closed_source"},
- {"Agency / consultancy / studio", "agency"},
- {"Non-profit / FOSS", "nonprofit"}
- ]
- end
-
- def init do
- to_form(PreferencesForm.changeset(%PreferencesForm{}, %{categories: []}))
- end
-
- def changeset(form, attrs) do
- cast(form, attrs, [:hiring, :categories])
- end
- end
-
- # === LIFECYCLE === #
-
+ @impl true
def mount(_params, _session, socket) do
- {:ok,
- socket
- |> assign(:ip_address, AlgoraWeb.Util.get_ip(socket))
- |> assign(:tech_stack_form, TechStackForm.init())
- |> assign(:email_form, EmailForm.init())
- |> assign(:verification_form, VerificationForm.init())
- |> assign(:preferences_form, PreferencesForm.init())
- |> assign(:step, Enum.at(@steps, 0))
- |> assign(:steps, @steps)
- |> assign(:code_sent?, false)
- |> assign(:code_valid?, nil)
- |> assign(:timezone, nil)
- |> assign(:secret, nil)
- |> assign(:user_metadata, AsyncResult.loading())
- |> assign_matching_devs()}
- end
-
- # === EVENT HANDLERS === #
-
- def handle_params(params, _uri, socket) do
- socket =
- LocalStore.init(socket,
- key: __MODULE__,
- checkpoint_url: ~p"/onboarding/org?#{%{checkpoint: "1"}}"
- )
-
- socket = if params["checkpoint"] == "1", do: LocalStore.subscribe(socket), else: socket
-
- {:noreply, socket}
- end
-
- def handle_event("restore_settings", params, socket) do
- {:noreply, LocalStore.restore(socket, params)}
- end
-
- def handle_event("prev_step", _, socket) do
- current_step_index = Enum.find_index(socket.assigns.steps, &(&1 == socket.assigns.step))
- prev_step = Enum.at(socket.assigns.steps, current_step_index - 1)
- {:noreply, assign(socket, :step, prev_step)}
- end
-
- def handle_event("submit_tech_stack", %{"tech_stack_form" => params}, socket) do
- tech_stack =
- Jason.decode!(params["tech_stack"]) ++
- case String.trim(params["tech_stack_input"]) do
- "" -> []
- tech_stack_input -> String.split(tech_stack_input, ",")
- end
-
- changeset =
- %TechStackForm{}
- |> TechStackForm.changeset(%{tech_stack: tech_stack})
- |> Map.put(:action, :validate)
-
- case changeset do
- %{valid?: true} ->
- {:noreply,
- socket
- |> LocalStore.assign_cached(:tech_stack_form, to_form(changeset))
- |> LocalStore.assign_cached(:step, :email)
- |> assign_matching_devs()}
-
- %{valid?: false} ->
- {:noreply, LocalStore.assign_cached(socket, :tech_stack_form, to_form(changeset))}
- end
- end
-
- def handle_event("submit_email", %{"email_form" => params}, socket) do
- changeset =
- %EmailForm{}
- |> EmailForm.changeset(params)
- |> Map.put(:action, :validate)
-
- case changeset do
- %{valid?: true} = changeset ->
- email = get_field(changeset, :email)
- {secret, code} = AlgoraWeb.UserAuth.generate_totp()
-
- {:ok, _} = Accounts.deliver_totp_signup_email(email, code)
-
- {:noreply,
- socket
- |> LocalStore.assign_cached(:secret, secret)
- |> LocalStore.assign_cached(:email_form, to_form(changeset))
- |> LocalStore.assign_cached(:code_sent?, true)
- |> assign_matching_devs()
- |> start_async(:fetch_metadata, fn -> Algora.Crawler.fetch_user_metadata(email) end)
- |> assign(:user_metadata, AsyncResult.loading())}
-
- %{valid?: false} = changeset ->
- {:noreply, LocalStore.assign_cached(socket, :email_form, to_form(changeset))}
- end
- end
-
- def handle_event("submit_preferences", params, socket) do
- changeset =
- %PreferencesForm{}
- |> PreferencesForm.changeset(params["preferences_form"] || %{})
- |> Map.put(:action, :validate)
-
- case changeset do
- %{valid?: true} ->
- # Get all the form data
- email = get_field(socket.assigns.email_form.source, :email)
- domain = get_field(socket.assigns.email_form.source, :domain)
- tech_stack = get_field(socket.assigns.tech_stack_form.source, :tech_stack)
- preferences = changeset.changes
-
- if socket.assigns.code_valid? do
- metadata =
- case socket.assigns.user_metadata do
- %AsyncResult{ok?: true, result: metadata} -> metadata
- _ -> %{}
- end
-
- org_name =
- case get_in(metadata, [:org, :display_name]) do
- nil ->
- domain
- |> String.split(".")
- |> List.first()
- |> String.capitalize()
-
- name ->
- name
- end
-
- org_handle =
- case get_in(metadata, [:org, :handle]) do
- nil ->
- domain
- |> String.split(".")
- |> List.first()
- |> String.downcase()
-
- handle ->
- handle
- end
-
- user_handle = Organizations.generate_handle_from_email(email)
-
- org_params =
- %{
- display_name: org_name,
- bio:
- get_in(metadata, [:org, :bio]) ||
- get_in(metadata, [:org, :og_description]) ||
- get_in(metadata, [:org, :og_title]),
- avatar_url: get_in(metadata, [:org, :avatar_url]) || get_in(metadata, [:org, :favicon_url]),
- handle: org_handle,
- domain: domain,
- og_title: get_in(metadata, [:org, :og_title]),
- og_image_url: get_in(metadata, [:org, :og_image_url]),
- tech_stack: tech_stack,
- hiring: get_in(preferences, [:hiring]),
- categories: get_in(preferences, [:categories]),
- website_url: get_in(metadata, [:org, :website_url]),
- twitter_url: get_in(metadata, [:org, :socials, :twitter]),
- github_url: get_in(metadata, [:org, :socials, :github]),
- youtube_url: get_in(metadata, [:org, :socials, :youtube]),
- twitch_url: get_in(metadata, [:org, :socials, :twitch]),
- discord_url: get_in(metadata, [:org, :socials, :discord]),
- slack_url: get_in(metadata, [:org, :socials, :slack]),
- linkedin_url: get_in(metadata, [:org, :socials, :linkedin])
- }
-
- user_params =
- %{
- email: email,
- display_name: user_handle,
- avatar_url: get_in(metadata, [:avatar_url]),
- handle: user_handle,
- tech_stack: tech_stack,
- timezone: socket.assigns.timezone
- }
-
- member_params =
- %{
- role: :admin
- }
-
- params =
- %{
- organization: org_params,
- user: user_params,
- member: member_params
- }
-
- socket =
- case Algora.Organizations.onboard_organization(params) do
- {:ok, _} ->
- redirect(socket, to: AlgoraWeb.UserAuth.generate_login_path(email))
-
- {:error, name, changeset, _created} ->
- Logger.error("error onboarding organization: #{inspect(name)} #{inspect(changeset)}")
-
- socket
- |> put_flash(:error, "Something went wrong. Please try again.")
- |> redirect(to: "/")
- end
-
- {:noreply, socket}
- else
- throttle()
-
- {:noreply,
- socket
- |> put_flash(:error, "Invalid verification code")
- |> LocalStore.assign_cached(:step, :email)}
- end
-
- %{valid?: false} ->
- {:noreply, LocalStore.assign_cached(socket, :preferences_form, to_form(changeset))}
- end
- end
-
- def handle_event("submit_verification", %{"verification_form" => params}, socket) do
- changeset =
- %VerificationForm{}
- |> VerificationForm.changeset(params)
- |> Map.put(:action, :validate)
-
- case changeset do
- %{valid?: true} = changeset ->
- code = get_field(changeset, :code)
-
- case AlgoraWeb.UserAuth.verify_totp(socket.assigns.ip_address, socket.assigns.secret, String.trim(code)) do
- :ok ->
- {:noreply,
- socket
- |> LocalStore.assign_cached(:verification_form, to_form(changeset))
- |> LocalStore.assign_cached(:code_valid?, true)
- |> LocalStore.assign_cached(:step, :preferences)}
-
- {:error, :rate_limit_exceeded} ->
- throttle()
- {:noreply, put_flash(socket, :error, "Too many attempts. Please try again later.")}
-
- {:error, :invalid_totp} ->
- throttle()
- {:noreply, put_flash(socket, :error, "Invalid verification code")}
- end
-
- %{valid?: false} = changeset ->
- {:noreply, LocalStore.assign_cached(socket, :verification_form, to_form(changeset))}
- end
- end
-
- def handle_event("tech_stack_changed", %{"tech_stack" => tech_stack}, socket) do
- changeset = TechStackForm.changeset(%TechStackForm{}, %{tech_stack: tech_stack})
-
- {:noreply,
- socket
- |> LocalStore.assign_cached(:tech_stack_form, to_form(changeset))
- |> assign_matching_devs()}
+ {:ok, assign(socket, :form, to_form(Form.changeset(%Form{}, %{})))}
end
- def handle_event("timezone_changed", %{"timezone" => timezone}, socket) do
- {:noreply, assign(socket, :timezone, timezone)}
- end
-
- # === PRIVATE HELPERS === #
-
- defp assign_matching_devs(socket) do
- tech_stack = get_field(socket.assigns.tech_stack_form.source, :tech_stack)
-
- matching_devs =
- Accounts.list_developers(
- limit: 5,
- sort_by_tech_stack: tech_stack,
- sort_by_country: socket.assigns.current_country,
- earnings_gt: Money.new!(200, "USD")
- )
-
- assign(socket, :matching_devs, matching_devs)
- end
-
- # === TEMPLATES === #
-
- defp main_content(%{step: :tech_stack} = assigns) do
- ~H"""
-
- <.form
- for={@tech_stack_form}
- phx-submit="submit_tech_stack"
- class="space-y-6"
- onkeydown="if(event.key === 'Enter') { event.preventDefault(); return false; }"
- >
-
-
- What's your tech stack?
-
-
- Enter a comma-separated list
-
-
- <.TechStack
- classes="mt-4 border-2 border-foreground/50"
- tech={get_field(@tech_stack_form.source, :tech_stack) || []}
- socket={@socket}
- form="tech_stack_form"
- />
-
- <.error :for={msg <- @tech_stack_form[:tech_stack].errors |> Enum.map(&translate_error(&1))}>
- {msg}
-
-
-
-
- <.button type="submit" variant="secondary">
- Skip
-
- <.button type="submit">
- Next
-
-
-
-
+ defp placeholder_text do
"""
- end
-
- defp main_content(%{step: :email, code_sent?: false} = assigns) do
- ~H"""
-
-
- Join with your team
-
-
- <.form for={@email_form} phx-submit="submit_email" class="space-y-6">
- <.input
- field={@email_form[:email]}
- label="Work Email"
- icon="tabler-mail"
- type="text"
- placeholder="you@company.com"
- class="w-full border-input bg-background"
- data-domain-target
- phx-hook="DeriveDomain"
- autocomplete="email"
- />
- <.input
- field={@email_form[:domain]}
- icon="tabler-at"
- label="Company Domain"
- helptext="This will let your teammates auto-join your org"
- type="text"
- placeholder="company.com"
- class="w-full border-input bg-background"
- data-domain-source
- />
-
- By continuing, you agree to Algora's
- <.link href={AlgoraWeb.Constants.get(:terms_url)} class="text-primary hover:underline">
- Terms of Service
-
- and <.link href={AlgoraWeb.Constants.get(:privacy_url)} class="text-primary hover:underline">Privacy Policy.
-
-
-
- <.button type="button" phx-click="prev_step" variant="secondary">
- <.icon name="tabler-arrow-left" class="mr-2 size-4" /> Previous
-
- <.button type="submit">
- Next <.icon name="tabler-arrow-right" class="ml-2 size-4" />
-
-
-
-
+ - GitHub looks like a green carpet, red flag if wearing suit in pfp
+ - Has contributions to open source inference engines (like vLLM)
+ - Posts regularly on X and LinkedIn
"""
end
- defp main_content(%{step: :email, code_sent?: true} = assigns) do
- ~H"""
-
-
-
- Verify your email
-
-
- We've sent a code to {get_field(@email_form.source, :email)}
-
-
-
- <.form for={@verification_form} phx-submit="submit_verification" class="space-y-6">
-
Verification Code
- <.input
- field={@verification_form[:code]}
- type="text"
- placeholder="Enter verification code"
- class="w-full border-input bg-background text-center text-xl sm:text-2xl tracking-widest"
- />
+ @impl true
+ def handle_event("submit", %{"form" => params}, socket) do
+ case %Form{} |> Form.changeset(params) |> Ecto.Changeset.apply_action(:save) do
+ {:ok, data} ->
+ Algora.Activities.alert(Jason.encode!(data), :critical)
+ {:noreply, put_flash(socket, :info, "We'll send you matching candidates within the next few hours.")}
- <%= if @code_valid? == false do %>
-
Invalid verification code
- <% end %>
-
-
- <.button type="submit">
- Next <.icon name="tabler-arrow-right" class="ml-2 size-4" />
-
-
-
-
-
-
- """
- end
-
- defp main_content(%{step: :preferences} = assigns) do
- ~H"""
-
- <.form for={@preferences_form} phx-submit="submit_preferences" class="space-y-8">
-
-
- Let's personalize your experience
-
-
- We'll use this information to match you with the best developers
-
-
-
-
-
-
Are you hiring full-time?
-
- We will match you with developers who are looking for full-time work
-
-
- <%= for {label, value} <- PreferencesForm.hiring_options() do %>
-
-
- <.input
- field={@preferences_form[:hiring]}
- type="radio"
- value={value}
- checked={to_string(get_field(@preferences_form.source, :hiring)) == value}
- />
-
-
- {label}
- <.icon
- name="tabler-check"
- class="invisible size-5 text-primary group-has-[:checked]:visible"
- />
-
-
- <% end %>
-
- <.error :for={msg <- @preferences_form[:hiring].errors |> Enum.map(&translate_error(&1))}>
- {msg}
-
-
-
-
-
- Which of the following best describes you?
-
-
- Select all that apply
-
-
- <%= for {label, value} <- PreferencesForm.categories_options() do %>
-
-
- <.input
- field={@preferences_form[:categories]}
- type="checkbox"
- value={value}
- checked={value in (get_field(@preferences_form.source, :categories) || [])}
- multiple
- />
-
-
- {label}
- <.icon
- name="tabler-check"
- class="invisible size-5 text-primary group-has-[:checked]:visible"
- />
-
-
- <% end %>
-
- <.error :for={
- msg <- @preferences_form[:categories].errors |> Enum.map(&translate_error(&1))
- }>
- {msg}
-
-
-
-
-
- <.button type="submit" variant="secondary">
- Skip
-
- <.button type="submit">
- Meet developers <.icon name="tabler-users" class="ml-2 size-4" />
-
-
-
-
- """
+ {:error, changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
end
+ @impl true
def render(assigns) do
~H"""
-
-
-
-
-
- {main_content(assigns)}
+
+
+
+
+ <.wordmark class="h-8 w-auto" />
+
+
-
- {sidebar_content(assigns)}
-
-
- {sidebar_content(%{assigns | step: :email})}
+
+
+
-
- <.Timezone socket={@socket} />
- """
- end
-
- defp sidebar_content(%{step: :email} = assigns) do
- ~H"""
-
-
- You're in good company
-
-
-
- """
- end
-
- defp sidebar_content(assigns) do
- ~H"""
-
-
- Matching Developers
-
- <%= for dev <- @matching_devs do %>
-
-
-
-
-
-
-
- {dev.name} {Algora.Misc.CountryEmojis.get(dev.country)}
-
-
@{User.handle(dev)}
-
-
-
Earned
-
- {Money.to_string!(dev.total_earned)}
-
-
-
-
-
- <%= for tech <- dev.tech_stack do %>
-
- {tech}
-
- <% end %>
-
-
+
+
+
+
+ © 2025 Algora PBC. All rights reserved.
+
+
+ <.link
+ class="w-full md:w-auto flex items-center justify-center gap-2 rounded-lg border border-gray-700 py-2 pl-2 pr-3.5 text-xs text-muted-foreground hover:text-foreground transition-colors hover:border-gray-600"
+ href={AlgoraWeb.Constants.get(:calendar_url)}
+ rel="noopener"
+ >
+ <.icon name="tabler-calendar-clock" class="size-4" />
+ Schedule a call
+
+ <.link
+ class="w-full md:w-auto flex items-center justify-center gap-2 rounded-lg border border-gray-700 py-2 pl-2 pr-3.5 text-xs text-muted-foreground hover:text-foreground transition-colors hover:border-gray-600"
+ href="tel:+16504202207"
+ >
+ <.icon name="tabler-phone" class="size-4" /> US +1 (650) 420-2207
+
+ <.link
+ class="w-full md:w-auto flex items-center justify-center gap-2 rounded-lg border border-gray-700 py-2 pl-2 pr-3.5 text-xs text-muted-foreground hover:text-foreground transition-colors hover:border-gray-600"
+ href="tel:+306973184144"
+ >
+ <.icon name="tabler-phone" class="size-4" /> EU +30 (697) 318-4144
+
- <% end %>
-
+
+
"""
end
-
- def handle_async(:fetch_metadata, {:ok, metadata}, socket) do
- {:noreply, LocalStore.assign_cached(socket, :user_metadata, AsyncResult.ok(socket.assigns.user_metadata, metadata))}
- end
-
- def handle_async(:fetch_metadata, {:exit, reason}, socket) do
- {:noreply, assign(socket, :user_metadata, AsyncResult.failed(socket.assigns.user_metadata, reason))}
- end
-
- defp throttle, do: :timer.sleep(1000)
end
diff --git a/lib/algora_web/live/platform_live.ex b/lib/algora_web/live/platform_live.ex
index 415ad38c..9fff70ae 100644
--- a/lib/algora_web/live/platform_live.ex
+++ b/lib/algora_web/live/platform_live.ex
@@ -471,7 +471,8 @@ defmodule AlgoraWeb.PlatformLive do
<.button
- navigate={~p"/onboarding/org"}
+ href={AlgoraWeb.Constants.get(:calendar_url)}
+ rel="noopener"
size="xl"
class="w-full text-lg drop-shadow-[0_1px_5px_#34d39980]"
>
@@ -833,7 +834,8 @@ defmodule AlgoraWeb.PlatformLive do
<.button
- navigate={~p"/onboarding/org"}
+ href={AlgoraWeb.Constants.get(:calendar_url)}
+ rel="noopener"
class="h-10 sm:h-14 rounded-md px-8 sm:px-12 text-sm sm:text-xl"
>
Companies