diff --git a/lib/pearl/accounts/user.ex b/lib/pearl/accounts/user.ex index f9dba67..ed359bb 100644 --- a/lib/pearl/accounts/user.ex +++ b/lib/pearl/accounts/user.ex @@ -44,7 +44,12 @@ defmodule Pearl.Accounts.User do field :type, Ecto.Enum, values: [:attendee, :staff, :company], default: :attendee field :allows_marketing, :boolean, default: false field :cv, Uploaders.CV.Type + field :notes, :string + field :university, :string + field :city, :string + field :dietary_restrictions, :string + # has_one :ticket, Pearl.Tickets.Ticket, on_delete: :delete_all has_one :attendee, Attendee, on_delete: :delete_all has_one :staff, Staff, on_delete: :delete_all, on_replace: :update has_one :company, Company, on_delete: :delete_all diff --git a/lib/pearl/catalog.ex b/lib/pearl/catalog.ex new file mode 100644 index 0000000..f1dc604 --- /dev/null +++ b/lib/pearl/catalog.ex @@ -0,0 +1,103 @@ +defmodule Pearl.Catalog do + @moduledoc """ + Listas para preencher selects de formulários. + """ + + def universities do + [ + # Universidades Públicas + "Universidade de Lisboa", + "Universidade do Porto", + "Universidade de Coimbra", + "Universidade Nova de Lisboa", + "Universidade do Minho", + "Universidade de Aveiro", + "Universidade da Beira Interior", + "Universidade de Évora", + "Universidade do Algarve", + "Universidade de Trás-os-Montes e Alto Douro", + "Universidade da Madeira", + "Universidade dos Açores", + "Universidade Aberta", + "ISCTE - Instituto Universitário de Lisboa", + + # Institutos Politécnicos + "Instituto Politécnico de Lisboa", + "Instituto Politécnico do Porto", + "Instituto Politécnico de Coimbra", + "Instituto Politécnico de Leiria", + "Instituto Politécnico de Setúbal", + "Instituto Politécnico de Viseu", + "Instituto Politécnico de Santarém", + "Instituto Politécnico de Viana do Castelo", + "Instituto Politécnico de Castelo Branco", + "Instituto Politécnico de Beja", + "Instituto Politécnico de Bragança", + "Instituto Politécnico da Guarda", + "Instituto Politécnico de Portalegre", + "Instituto Politécnico de Tomar", + "Instituto Politécnico do Cávado e do Ave", + + # Privadas + "Universidade Católica Portuguesa", + "Universidade Lusófona", + "Universidade Lusíada", + "Universidade Fernando Pessoa", + "Universidade Europeia", + "Universidade Autónoma de Lisboa", + "Universidade Portucalense", + "Universidade Atlântica", + + # Outra + "Outra / Externo" + ] + end + + def cities do + # Lista simplificada dos principais concelhos/cidades para não ser gigante + [ + "Lisboa", + "Porto", + "Vila Nova de Gaia", + "Amadora", + "Braga", + "Funchal", + "Coimbra", + "Setúbal", + "Almada", + "Agualva-Cacém", + "Queluz", + "Rio Tinto", + "Barreiro", + "Aveiro", + "Viseu", + "Odivelas", + "Leiria", + "Guimarães", + "Faro", + "Matosinhos", + "Loures", + "Póvoa de Varzim", + "Maia", + "Évora", + "Portimão", + "Viana do Castelo", + "Castelo Branco", + "Covilhã", + "Guarda", + "Vila Real", + "Ponta Delgada", + "Santarém", + "Figueira da Foz", + "Caldas da Rainha", + "Torres Vedras", + "Vila Franca de Xira", + "Valongo", + "Gondomar", + "Vila do Conde", + "Barcelos", + "Outra" + ] + |> Enum.sort() + end +end diff --git a/lib/pearl_web/components/core_components.ex b/lib/pearl_web/components/core_components.ex index fa839ac..b366fe3 100644 --- a/lib/pearl_web/components/core_components.ex +++ b/lib/pearl_web/components/core_components.ex @@ -248,35 +248,16 @@ defmodule PearlWeb.CoreComponents do @doc """ Renders an input with label and error messages. - - A `Phoenix.HTML.FormField` may be passed as argument, - which is used to retrieve the input name, id, and values. - Otherwise all attributes may be passed explicitly. - - ## Types - - This function accepts all HTML input types, considering that: - - * You may also set `type="select"` to render a ` - - {Phoenix.HTML.Form.options_for_select(@options, @value)} - - <.error :for={msg <- @errors}>{msg} +
+ + <.error :for={msg <- @errors}>{msg} +
""" end @@ -358,74 +344,69 @@ defmodule PearlWeb.CoreComponents do ~H"""
<.label for={@id}>{@label} - - <.error :for={msg <- @errors}>{msg} +
+ + <.error :for={msg <- @errors}>{msg} +
""" end - def input(%{type: "handle"} = assigns) do + def input(assigns) do ~H"""
<.label for={@id}>{@label} -
- @ +
+ <.error :for={msg <- @errors}>{msg}
- <.error :for={msg <- @errors}>{msg}
""" end - # All other inputs text, datetime-local, url, password, etc. are handled here... - def input(assigns) do - ~H""" -
- <.label for={@id}>{@label} - - <.error :for={msg <- @errors}>{msg} -
- """ + defp input_class(:default) do + "mt-2 block w-full rounded-lg text-dark focus:ring-0 sm:text-sm sm:leading-6 bg-surface border-0" + end + + defp input_class(:flushed) do + "block w-full px-0 py-2 border-0 border-b border-dark-muted/30 focus:border-primary focus:ring-0 bg-transparent text-dark placeholder-dark-muted/60 focus:outline-none transition-colors sm:text-sm" + end + + defp input_border(:default, errors) do + if errors == [] do + "border-transparent focus:border-primary" + else + "border-danger-400 focus:border-danger-400" + end + end + + defp input_border(:flushed, errors) do + if errors == [] do + "" + else + "border-danger-500 focus:border-danger-500 text-danger-500 placeholder-danger-300" + end end @doc """ diff --git a/lib/pearl_web/components/registration_components.ex b/lib/pearl_web/components/registration_components.ex new file mode 100644 index 0000000..f104696 --- /dev/null +++ b/lib/pearl_web/components/registration_components.ex @@ -0,0 +1,98 @@ +defmodule PearlWeb.RegistrationComponents do + @moduledoc """ + Provides UI components specific to the user registration flow. + + This module includes the main layout for the registration page (`registration_layout`), + the progress stepper (`step_bar`), and other visual elements used exclusively + during the attendee sign-up flow. + """ + use PearlWeb, :component + + import PearlWeb.Landing.Components.Navbar + import PearlWeb.Landing.Components.Footer + + slot :sidebar, required: true + slot :header, required: false + slot :inner_block, required: true + + def registration_layout(assigns) do + ~H""" + <.navbar + pages={PearlWeb.Config.landing_pages()} + registrations_open?={Pearl.Event.registrations_open?()} + current_user={Map.get(assigns, :current_user)} + /> + +
+
+
+ {render_slot(@header)} +
+ +
+ + +
+ {render_slot(@inner_block)} +
+
+
+
+ + <.footer> + <:tip :if={ + Map.get(assigns, :current_page, nil) in [:home, :schedule, :speakers, :faqs] and + Pearl.Event.get_feature_flag("challenges_enabled") + }> + Have you checked out the + <.link class="underline" navigate={~p"/challenges"}>challenges + yet? <.link href="https://www.youtube.com/watch?v=xvFZjo5PgG0" target="_blank">🏆 + + + """ + end + + attr :current_step, :integer, required: true + + def step_bar(assigns) do + assigns = + assign(assigns, :steps, [ + {1, "Tipo de bilhete"}, + {2, "Dados pessoais"}, + {3, "Precauções"}, + {4, "Informações"}, + {5, "Verificação"} + ]) + + ~H""" +
+

Inscrição

+ +
+ <%= for {index, label} <- @steps do %> +
+ + {index} + + {label} +
+ <% end %> +
+
+ """ + end +end diff --git a/lib/pearl_web/live/auth/user_registration_live.ex b/lib/pearl_web/live/auth/user_registration_live.ex index 6dcadb6..5e537fd 100644 --- a/lib/pearl_web/live/auth/user_registration_live.ex +++ b/lib/pearl_web/live/auth/user_registration_live.ex @@ -1,36 +1,186 @@ defmodule PearlWeb.UserRegistrationLive do - use PearlWeb, :landing_view + use PearlWeb, :live_view alias Pearl.Accounts alias Pearl.Accounts.User + alias Phoenix.HTML.Form - import PearlWeb.Components.Button + import PearlWeb.RegistrationComponents + import PearlWeb.CoreComponents def mount(_params, _session, socket) do changeset = Accounts.change_user_registration(%User{}) - socket = - socket - |> assign(trigger_submit: false, errors: false) - |> assign_form(changeset) + default_ticket_id = :general + selected_ticket = get_ticket_config(default_ticket_id) - {:ok, socket, temporary_assigns: [form: nil]} + months = [ + {"Janeiro", 1}, + {"Fevereiro", 2}, + {"Março", 3}, + {"Abril", 4}, + {"Maio", 5}, + {"Junho", 6}, + {"Julho", 7}, + {"Agosto", 8}, + {"Setembro", 9}, + {"Outubro", 10}, + {"Novembro", 11}, + {"Dezembro", 12} + ] + + current_year = Date.utc_today().year + years = current_year..(current_year - 100) + + days = 1..31 + + {:ok, + socket + |> assign(trigger_submit: false, check_errors: false) + |> assign_form(changeset) + |> assign(:step, 1) + |> assign(:total_price, selected_ticket.price) + |> assign(:selected_ticket, selected_ticket) + |> assign(:current_user, nil) + |> assign(:registrations_open?, true) + |> assign(:universities, Pearl.Catalog.universities()) + |> assign(:cities, Pearl.Catalog.cities()) + |> assign(:pages, []) + |> assign(:months, months) + |> assign(:years, years) + |> assign(:days, days)} + end + + defp get_ticket_config(id) do + # Com os tickets o Enrico deve fazer isto mais seamless no futuro + tickets = %{ + general: %{ + id: :general, + name: "Passe Geral", + price: "XX,00", + # Ícones e Cores MAybe fazer isto mais "simples" era melhor + sidebar_icons: [ + %{icon: "hero-ticket", bg: "#FF5A87", rounded: "rounded-l-md"}, + %{icon: "hero-cake", bg: "#D9B568", rounded: ""}, + %{icon: "hero-sparkles", bg: "#A3C982", rounded: ""}, + %{icon: "hero-home", bg: "#8AB5C9", rounded: "rounded-r-md"}, + %{icon: "hero-star", bg: "#8FA3AD", rounded: "ml-1 rounded-[50%]"} + ], + # Lista de Benefícios Texto e Cor do certinho + benefits: [ + %{text: "Entrada nos 4 dias do evento", color: "text-[#FF5A87]"}, + %{text: "Acesso a todas as atividades", color: "text-[#FF5A87]"}, + %{text: "Coffee Break de manhã e de tarde", color: "text-[#D9B568]"}, + %{text: "Almoço e Jantar incluídos", color: "text-[#A3C982]"}, + %{text: "3 noites de alojamento", color: "text-[#8AB5C9]"}, + %{text: "Transporte alojamento-evento", color: "text-[#8AB5C9]"}, + %{text: "Pequeno almoço", color: "text-[#8AB5C9]"} + ] + } + # depois adciona-se mais aqui + } + + Map.get(tickets, id, tickets.general) + end + + defp get_initials(name) do + case String.split(name || "", " ", trim: true) do + [] -> nil + [single] -> String.slice(single, 0, 1) + names -> "#{String.first(List.first(names))}#{String.first(List.last(names))}" + end + end + + def handle_event("next_step", params, socket) do + user_params = params["user"] || %{} + + changeset = + socket.assigns.form.source + |> User.registration_changeset(user_params) + + new_step = socket.assigns.step + 1 + {:noreply, socket |> assign(:step, new_step) |> assign_form(changeset)} + end + + def handle_event("prev_step", _, socket) do + new_step = max(1, socket.assigns.step - 1) + {:noreply, assign(socket, step: new_step)} end def handle_event("validate", %{"user" => user_params}, socket) do changeset = Accounts.change_user_registration(%User{}, user_params) - {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + days_range = calculate_days_range(user_params["birth_date"]) + + {:noreply, + socket + |> assign(:days, days_range) + |> assign_form(Map.put(changeset, :action, :validate))} end + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.register_attendee_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}")) + + changeset = Accounts.change_user_registration(user) + {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} + end + end + + def step_header(step, form \\ nil) + + def step_header(1, _), + do: "Bem-vindo ao ENEI, para começar, escolhe o tipo de bilhete que desejas adquirir!" + + def step_header(2, _), do: "Ótimo! Agora precisamos de alguns dados pessoais teus." + + def step_header(3, form) do + first_name = + (Form.input_value(form, :name) || "") + |> String.split(" ", trim: true) + |> List.first() + |> Kernel.||("Participante") + + "Olá, #{first_name}! Se tiveres alguma incapacidade (motora, visual, auditiva) ou alergia alimentar, pedimos que nos informes." + end + + def step_header(4, _), + do: + "Ok! Para finalizar, precisamos de saber mais alguns detalhes, e também temos algumas curiosidades." + + def step_header(5, _), + do: + "Já temos tudo. Confirma se está tudo certo e verifica o teu email com o código que enviamos. A seguir, serás redirecionado para o pagamento." + + def step_header(_, _), do: "" + defp assign_form(socket, %Ecto.Changeset{} = changeset) do - form = to_form(changeset, as: "user") + assign(socket, :form, to_form(changeset, as: "user")) + end - if changeset.valid? do - socket - |> assign(form: form, errors: false) - else - assign(socket, form: form, errors: true) + defp calculate_days_range(%{"year" => y, "month" => m}) when y != "" and m != "" do + year = String.to_integer(y) + month = String.to_integer(m) + + case Date.new(year, month, 1) do + {:ok, date} -> 1..Date.days_in_month(date) + _ -> 1..31 end end + + defp calculate_days_range(%{"month" => m}) when m != "" do + month = String.to_integer(m) + + case Date.new(2024, month, 1) do + {:ok, date} -> 1..Date.days_in_month(date) + _ -> 1..31 + end + end + + defp calculate_days_range(_), do: 1..31 end diff --git a/lib/pearl_web/live/auth/user_registration_live.html.heex b/lib/pearl_web/live/auth/user_registration_live.html.heex index c209525..046de76 100644 --- a/lib/pearl_web/live/auth/user_registration_live.html.heex +++ b/lib/pearl_web/live/auth/user_registration_live.html.heex @@ -1,91 +1,266 @@ -
-
- -
- <.header class="text-center px-4"> -

Event Registration

- <:subtitle> - {gettext("Already registered?")} - <.link navigate={~p"/users/log_in"} class="font-semibold text-accent hover:underline"> - Log in - - {gettext("to your account now")} - - - - <.form - for={@form} - id="registration_form" - phx-change="validate" - phx-trigger-action={@trigger_submit} - action={~p"/users/register"} - class="mx-auto w-90 flex flex-col px-6 sm:px-0 sm:grid grid-cols-2 gap-4 py-10" - > - <.input - field={@form[:name]} - type="text" - label="Name" - placeholder="John Doe" - autocomplete="off" - required - /> - <.input - field={@form[:handle]} - type="handle" - label="Username" - autocomplete="off" - required - placeholder="johndoe" - /> - <.input - field={@form[:email]} - type="email" - label="Email" - required - wrapper_class="col-span-2" - placeholder="john.doe@cesium.pt" - /> - <.input field={@form[:password]} type="password" label="Password" required /> - <.input - field={@form[:password_confirmation]} - type="password" - label="Confirm Password" - required - /> - - <.label class="col-span-2 pt-4"> -
-
- <.input name="consent" label="" type="checkbox" value={false} required /> -
+<.registration_layout> + <:header> + <.step_bar current_step={@step} /> + -
- {gettext("I have read and agree to the ")} - <.link - href={~p"/docs/privacy_policy.pdf"} - target="_blank" - class="font-semibold text-accent hover:underline" - > - {gettext("Privacy Policy.")} - + <:sidebar> +
+

+ {step_header(@step, @form)} +

+ + <%= if @step == 1 do %> +
+ <%= for icon_config <- @selected_ticket.sidebar_icons do %> +
+ <.icon name={icon_config.icon} class="text-white w-6 h-6" /> +
+ <% end %>
-
- - <.label class="col-span-2 pb-8"> -
-
- <.input label="" type="checkbox" field={@form[:allows_marketing]} /> + <% else %> +
+
+ <% name_value = Phoenix.HTML.Form.input_value(@form, :name) %> + <% initials = get_initials(name_value) %> + <% has_initials = initials != nil %> + +
+ <.icon name="hero-user" class="text-white size-20" /> +
+ +
+ + {initials} + +
+
-
- {gettext("I want to receive marketing emails promoting the event.")} + <% end %> + +
+
+ +

Preço atual:

+

+ {@total_price} +

+
+

+ INCL. IVA +

- -
- <.action_button title="Register" subtitle="" class="mx-12" disabled={@errors} />
- -
+ + +
+ <.form + for={@form} + id="registration_form" + phx-change="validate" + phx-submit={if @step < 5, do: "next_step", else: "save"} + class="flex-1 flex flex-col justify-between min-h-[500px]" + > +
+ <%= case @step do %> + <% 1 -> %> +
+

Benefícios deste bilhete:

+
    + <%= for benefit <- @selected_ticket.benefits do %> +
  • + <.icon name="hero-check" class={"w-5 h-5 #{benefit.color}"} /> + {benefit.text} +
  • + <% end %> +
+
+ +
+ +
+

+ ESPACO PARA OS TICKETS (╯°□°)╯︵ ┻━┻ +

+
+ <% 2 -> %> + <.input + field={@form[:name]} + label="Nome" + placeholder="Nome completo" + variant={:flushed} + wrapper_class="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-25 items-baseline" + required + /> + +
+ <.label for={@form[:email].id} class="text-dark">Email +
+ <.input + field={@form[:email]} + type="email" + placeholder="Endereço de email" + variant={:flushed} + required + label="" + /> +

+ <.icon name="hero-information-circle-mini" class="w-5 h-5" /> + Vamos verificar o teu email num passo seguinte. +

+
+
+ +
+ + + <% field = @form[:birth_date] %> + <% value = Phoenix.HTML.Form.input_value(@form, :birth_date) %> + +
+
+ +
+ +
+ +
+ +
+ +
+
+ <.error :for={msg <- field.errors}>{msg} +
+ +
+ +
+
+ <.input + field={@form[:phone_prefix]} + type="select" + options={[{"PT (+351)", "+351"}, {"ES (+34)", "+34"}]} + variant={:flushed} + label="" + /> +
+
+ <.input + field={@form[:phone_number]} + placeholder="Número de telefone" + variant={:flushed} + label="" + /> +
+
+
+ + <.input + field={@form[:university]} + type="select" + label="Universidade" + options={@universities} + prompt="Selecionar instituição" + variant={:flushed} + wrapper_class="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-25 items-baseline" + required + /> + + <.input + field={@form[:city]} + type="select" + label="Cidade" + options={@cities} + prompt="Selecionar cidade" + variant={:flushed} + wrapper_class="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-25 items-baseline" + required + /> + + <.input + field={@form[:nif]} + label="NIF" + placeholder="Número de Identificação Fiscal" + variant={:flushed} + wrapper_class="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-25 items-baseline" + /> + <% 3 -> %> + (╯°□°)╯︵ ┻━┻" + <% 4 -> %> + (╯°□°)╯︵ ┻━┻" + <% 5 -> %> + (╯°□°)╯︵ ┻━┻" + <% _ -> %> +
+ Passo {@step} desconhecido (╯°□°)╯︵ ┻━┻ +
+ <% end %> +
+ +
+ + + +
+ +
+ diff --git a/priv/repo/migrations/20251122152450_add_registration_fields_to_users.exs b/priv/repo/migrations/20251122152450_add_registration_fields_to_users.exs new file mode 100644 index 0000000..7cced6a --- /dev/null +++ b/priv/repo/migrations/20251122152450_add_registration_fields_to_users.exs @@ -0,0 +1,13 @@ +defmodule Pearl.Repo.Migrations.AddRegistrationFieldsToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + # add :ticket_type, :string TICKET POR IMPLEMENTAR + add :notes, :string + add :university, :string + add :city, :string + add :dietary_restrictions, :string + end + end +end diff --git a/test/pearl_web/live/user_login_live_test.exs b/test/pearl_web/live/user_login_live_test.exs index b0e81b2..2666bc0 100644 --- a/test/pearl_web/live/user_login_live_test.exs +++ b/test/pearl_web/live/user_login_live_test.exs @@ -63,7 +63,7 @@ defmodule PearlWeb.UserLoginLiveTest do |> render_click() |> follow_redirect(conn, ~p"/users/register") - assert login_html =~ "Register" + assert login_html =~ "inscrição" end test "redirects to forgot password page when the Forgot Password button is clicked", %{ diff --git a/test/pearl_web/live/user_registration_live_test.exs b/test/pearl_web/live/user_registration_live_test.exs index 8a9d9e6..4ca97ea 100644 --- a/test/pearl_web/live/user_registration_live_test.exs +++ b/test/pearl_web/live/user_registration_live_test.exs @@ -15,7 +15,7 @@ defmodule PearlWeb.UserRegistrationLiveTest do test "renders registration page", %{conn: conn} do {:ok, _lv, html} = live(conn, ~p"/users/register") - assert html =~ "Register" + assert html =~ "Inscrição" assert html =~ "Log in" end @@ -29,17 +29,20 @@ defmodule PearlWeb.UserRegistrationLiveTest do assert {:ok, _conn} = result end - test "renders errors for invalid data", %{conn: conn} do + test "Registration page renders errors for invalid data", %{conn: conn} do {:ok, lv, _html} = live(conn, ~p"/users/register") + lv + |> form("#registration_form", user: %{}) + |> render_submit() + result = lv |> element("#registration_form") - |> render_change(user: %{"email" => "with spaces", "password" => "too short"}) + |> render_change(user: %{"email" => "with spaces"}) - assert result =~ "Register" + assert result =~ "Inscrição" assert result =~ "must have the @ sign and no spaces" - assert result =~ "should be at least 12 character" end end