diff --git a/assets/css/components/field.css b/assets/css/components/field.css
index 6a2f958f..2b49bb09 100644
--- a/assets/css/components/field.css
+++ b/assets/css/components/field.css
@@ -129,7 +129,7 @@
/* Text */
.safira-text-input {
- @apply block w-full rounded-md text-dark border border-lightShade placeholder:text-darkMuted focus:outline-2 focus:border-lightShade ring-0 focus:outline-dark focus:outline-offset-2 dark:outline-darkShade dark:bg-dark dark:text-light dark:placeholder-lightMuted dark:focus:border-darkShade dark:border-darkShade focus:ring-0 dark:focus:outline-light;
+ @apply block w-full rounded-md text-dark border border-lightShade placeholder:text-darkMuted focus:outline-2 focus:border-lightShade ring-0 focus:outline-dark focus:outline-offset-2 dark:outline-darkShade dark:bg-dark dark:text-light dark:placeholder-lightMuted dark:focus:border-darkShade dark:border-darkShade focus:ring-0 dark:focus:outline-light dark:[color-scheme:dark];
}
/* Code */
@@ -196,3 +196,29 @@
.safira-form-help-text {
@apply mt-2 text-sm text-gray-500 dark:text-gray-400;
}
+
+/* Multiselect */
+
+.safira-multiselect-dropdown {
+ @apply absolute z-50 border border-t-0 border-lightShade dark:border-darkShade mt-1 rounded-b-md pb-1 bg-light dark:bg-dark;
+}
+
+.safira-multiselect-dropdown-option {
+ @apply mx-1 my-1 px-3 py-1 hover:bg-lightShade/40 dark:hover:bg-darkShade cursor-pointer rounded-md text-sm;
+}
+
+.safira-multiselect-dropdown-option-selected {
+ @apply opacity-60;
+}
+
+.safira-multiselect-dropdown-tags-container {
+ @apply absolute flex mt-12 gap-1 flex-wrap;
+}
+
+.safira-multiselect-dropdown-tag {
+ @apply text-xs flex items-center border border-lightShade dark:border-darkShade pl-2 rounded-md z-30;
+}
+
+.safira-multiselect-dropdown-tag-remove {
+ @apply cursor-pointer dark:opacity-80 scale-75;
+}
\ No newline at end of file
diff --git a/assets/js/app.js b/assets/js/app.js
index 8209d75f..7ebc74dc 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -21,13 +21,15 @@ import "phoenix_html"
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
+import live_select from "live_select"
import { QrScanner, Wheel, Confetti, Sorting } from "./hooks";
let Hooks = {
QrScanner: QrScanner,
Wheel: Wheel,
Confetti: Confetti,
- Sorting: Sorting
+ Sorting: Sorting,
+ ...live_select
};
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
diff --git a/lib/safira/accounts/roles/permissions.ex b/lib/safira/accounts/roles/permissions.ex
index 4d7e7676..69da4280 100644
--- a/lib/safira/accounts/roles/permissions.ex
+++ b/lib/safira/accounts/roles/permissions.ex
@@ -14,6 +14,7 @@ defmodule Safira.Accounts.Roles.Permissions do
"badges" => ["show", "edit", "delete", "give", "revoke", "give_without_restrictions"],
"minigames" => ["show", "edit", "simulate"],
"spotlights" => ["edit"],
+ "schedule" => ["edit"],
"statistics" => ["show"],
"mailer" => ["send"]
}
diff --git a/lib/safira/activities.ex b/lib/safira/activities.ex
new file mode 100644
index 00000000..4828f52a
--- /dev/null
+++ b/lib/safira/activities.ex
@@ -0,0 +1,377 @@
+defmodule Safira.Activities do
+ @moduledoc """
+ The Activities context.
+ """
+
+ use Safira.Context
+
+ alias Safira.Activities.{Activity, ActivityCategory, Speaker}
+
+ @doc """
+ Returns the list of activities.
+
+ ## Examples
+
+ iex> list_activities()
+ [%Activity{}, ...]
+
+ """
+ def list_activities do
+ Activity
+ |> preload(:speakers)
+ |> Repo.all()
+ end
+
+ def list_activities(opts) when is_list(opts) do
+ Activity
+ |> apply_filters(opts)
+ |> preload(:speakers)
+ |> Repo.all()
+ end
+
+ def list_activities(params) do
+ Activity
+ |> preload(:speakers)
+ |> Flop.validate_and_run(params, for: Activity)
+ end
+
+ def list_activities(%{} = params, opts) when is_list(opts) do
+ Activity
+ |> preload(:speakers)
+ |> apply_filters(opts)
+ |> Flop.validate_and_run(params, for: Activity)
+ end
+
+ @doc """
+ Gets a single activity.
+
+ Raises `Ecto.NoResultsError` if the Activity does not exist.
+
+ ## Examples
+
+ iex> get_activity!(123)
+ %Activity{}
+
+ iex> get_activity!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_activity!(id) do
+ Activity
+ |> preload(:speakers)
+ |> Repo.get!(id)
+ end
+
+ @doc """
+ Creates a activity.
+
+ ## Examples
+
+ iex> create_activity(%{field: value})
+ {:ok, %Activity{}}
+
+ iex> create_activity(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_activity(attrs \\ %{}) do
+ %Activity{}
+ |> Activity.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Updates a activity.
+
+ ## Examples
+
+ iex> update_activity(activity, %{field: new_value})
+ {:ok, %Activity{}}
+
+ iex> update_activity(activity, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_activity(%Activity{} = activity, attrs) do
+ activity
+ |> Activity.changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Updates an activity's speakers.
+
+ ## Examples
+
+ iex> upsert_activity_speakers(activity, [1, 2, 3])
+ {:ok, %Activity{}}
+
+ iex> upsert_activity_speakers(activity, [1, 2, 3])
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def upsert_activity_speakers(%Activity{} = activity, speaker_ids) do
+ ids = speaker_ids || []
+
+ speakers =
+ Speaker
+ |> where([s], s.id in ^ids)
+ |> Repo.all()
+
+ activity
+ |> Activity.changeset_update_speakers(speakers)
+ |> Repo.update()
+ end
+
+ @doc """
+ Deletes a activity.
+
+ ## Examples
+
+ iex> delete_activity(activity)
+ {:ok, %Activity{}}
+
+ iex> delete_activity(activity)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_activity(%Activity{} = activity) do
+ Repo.delete(activity)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking activity changes.
+
+ ## Examples
+
+ iex> change_activity(activity)
+ %Ecto.Changeset{data: %Activity{}}
+
+ """
+ def change_activity(%Activity{} = activity, attrs \\ %{}) do
+ Activity.changeset(activity, attrs)
+ end
+
+ @doc """
+ Returns the list of activity_categories.
+
+ ## Examples
+
+ iex> list_activity_categories()
+ [%ActivityCategory{}, ...]
+
+ """
+ def list_activity_categories do
+ Repo.all(ActivityCategory)
+ end
+
+ @doc """
+ Gets a single activity_category.
+
+ Raises `Ecto.NoResultsError` if the Activity category does not exist.
+
+ ## Examples
+
+ iex> get_activity_category!(123)
+ %ActivityCategory{}
+
+ iex> get_activity_category!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_activity_category!(id), do: Repo.get!(ActivityCategory, id)
+
+ @doc """
+ Creates a activity_category.
+
+ ## Examples
+
+ iex> create_activity_category(%{field: value})
+ {:ok, %ActivityCategory{}}
+
+ iex> create_activity_category(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_activity_category(attrs \\ %{}) do
+ %ActivityCategory{}
+ |> ActivityCategory.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Updates a activity_category.
+
+ ## Examples
+
+ iex> update_activity_category(activity_category, %{field: new_value})
+ {:ok, %ActivityCategory{}}
+
+ iex> update_activity_category(activity_category, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_activity_category(%ActivityCategory{} = activity_category, attrs) do
+ activity_category
+ |> ActivityCategory.changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Deletes a activity_category.
+
+ ## Examples
+
+ iex> delete_activity_category(activity_category)
+ {:ok, %ActivityCategory{}}
+
+ iex> delete_activity_category(activity_category)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_activity_category(%ActivityCategory{} = activity_category) do
+ Repo.delete(activity_category)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking activity_category changes.
+
+ ## Examples
+
+ iex> change_activity_category(activity_category)
+ %Ecto.Changeset{data: %ActivityCategory{}}
+
+ """
+ def change_activity_category(%ActivityCategory{} = activity_category, attrs \\ %{}) do
+ ActivityCategory.changeset(activity_category, attrs)
+ end
+
+ @doc """
+ Returns the list of speakers.
+
+ ## Examples
+
+ iex> list_speakers()
+ [%Speaker{}, ...]
+
+ """
+ def list_speakers do
+ Repo.all(Speaker)
+ end
+
+ def list_speakers(opts) when is_list(opts) do
+ Speaker
+ |> apply_filters(opts)
+ |> Repo.all()
+ end
+
+ def list_speakers(params) do
+ Speaker
+ |> Flop.validate_and_run(params, for: Speaker)
+ end
+
+ def list_speakers(%{} = params, opts) when is_list(opts) do
+ Speaker
+ |> apply_filters(opts)
+ |> Flop.validate_and_run(params, for: Speaker)
+ end
+
+ @doc """
+ Gets a single speaker.
+
+ Raises `Ecto.NoResultsError` if the Speaker does not exist.
+
+ ## Examples
+
+ iex> get_speaker!(123)
+ %Speaker{}
+
+ iex> get_speaker!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_speaker!(id), do: Repo.get!(Speaker, id)
+
+ @doc """
+ Creates a speaker.
+
+ ## Examples
+
+ iex> create_speaker(%{field: value})
+ {:ok, %Speaker{}}
+
+ iex> create_speaker(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_speaker(attrs \\ %{}) do
+ %Speaker{}
+ |> Speaker.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Updates a speaker.
+
+ ## Examples
+
+ iex> update_speaker(speaker, %{field: new_value})
+ {:ok, %Speaker{}}
+
+ iex> update_speaker(speaker, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_speaker(%Speaker{} = speaker, attrs) do
+ speaker
+ |> Speaker.changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Updates a speaker picture.
+
+ ## Examples
+
+ iex> update_speaker_picture(speaker, %{picture: image})
+ {:ok, %Speaker{}}
+
+ iex> update_speaker_picture(speaker, %{picture: bad_image})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_speaker_picture(%Speaker{} = speaker, attrs) do
+ speaker
+ |> Speaker.picture_changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Deletes a speaker.
+
+ ## Examples
+
+ iex> delete_speaker(speaker)
+ {:ok, %Speaker{}}
+
+ iex> delete_speaker(speaker)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_speaker(%Speaker{} = speaker) do
+ Repo.delete(speaker)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking speaker changes.
+
+ ## Examples
+
+ iex> change_speaker(speaker)
+ %Ecto.Changeset{data: %Speaker{}}
+
+ """
+ def change_speaker(%Speaker{} = speaker, attrs \\ %{}) do
+ Speaker.changeset(speaker, attrs)
+ end
+end
diff --git a/lib/safira/activities/activity.ex b/lib/safira/activities/activity.ex
new file mode 100644
index 00000000..13d704f4
--- /dev/null
+++ b/lib/safira/activities/activity.ex
@@ -0,0 +1,111 @@
+defmodule Safira.Activities.Activity do
+ @moduledoc """
+ Activities scheduled for the event.
+ """
+ use Safira.Schema
+
+ alias Safira.Event
+
+ @required_fields ~w(title date time_start time_end)a
+ @optional_fields ~w(description category_id location has_enrolments max_enrolments)a
+
+ @derive {
+ Flop.Schema,
+ filterable: [:title],
+ sortable: [:timestamp],
+ default_limit: 11,
+ adapter_opts: [
+ compound_fields: [
+ timestamp: [:date, :time_start]
+ ]
+ ],
+ default_order: %{
+ order_by: [:timestamp],
+ order_directions: [:asc]
+ }
+ }
+
+ schema "activities" do
+ field :title, :string
+ field :description, :string
+ field :location, :string
+ field :date, :date
+ field :time_start, :time
+ field :time_end, :time
+ field :has_enrolments, :boolean, default: false
+ field :max_enrolments, :integer, default: 0
+
+ belongs_to :category, Safira.Activities.ActivityCategory
+
+ many_to_many :speakers, Safira.Activities.Speaker,
+ join_through: "activities_speakers",
+ on_replace: :delete
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc false
+ def changeset(activity, attrs) do
+ activity
+ |> cast(attrs, @required_fields ++ @optional_fields)
+ |> validate_required(@required_fields)
+ |> validate_activity_date()
+ |> validate_activity_times()
+ end
+
+ @doc false
+ def changeset_update_speakers(activity, speakers) do
+ activity
+ |> cast(%{}, @required_fields ++ @optional_fields)
+ |> put_assoc(:speakers, speakers)
+ end
+
+ def validate_activity_date(activity) do
+ event_start = Event.get_event_start_date()
+ event_end = Event.get_event_end_date()
+ date = get_field(activity, :date)
+
+ # Validate if the activity's date is within the event's start and end date
+ cond do
+ date == nil ->
+ activity
+
+ Date.compare(date, event_start) == :lt ->
+ Ecto.Changeset.add_error(
+ activity,
+ :date,
+ "must be after or in the event's start date (#{Date.to_string(event_start)})"
+ )
+
+ Date.compare(date, event_end) == :gt ->
+ Ecto.Changeset.add_error(
+ activity,
+ :date,
+ "must be before or in the event's end date (#{Date.to_string(event_end)})"
+ )
+
+ true ->
+ activity
+ end
+ end
+
+ def validate_activity_times(activity) do
+ time_start = get_field(activity, :time_start)
+ time_end = get_field(activity, :time_end)
+
+ # Validate if the activity's time_end is after time_start
+ if time_start != nil and time_end != nil do
+ if Time.compare(time_end, time_start) in [:lt] do
+ activity
+ |> Ecto.Changeset.add_error(
+ :time_end,
+ "must be after the activity's start time (#{Time.to_string(time_start)})"
+ )
+ else
+ activity
+ end
+ else
+ activity
+ end
+ end
+end
diff --git a/lib/safira/activities/activity_category.ex b/lib/safira/activities/activity_category.ex
new file mode 100644
index 00000000..2f092526
--- /dev/null
+++ b/lib/safira/activities/activity_category.ex
@@ -0,0 +1,21 @@
+defmodule Safira.Activities.ActivityCategory do
+ @moduledoc """
+ Categories for activities.
+ """
+ use Safira.Schema
+
+ @required_fields ~w(name)a
+
+ schema "activity_categories" do
+ field :name, :string
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc false
+ def changeset(activity_category, attrs) do
+ activity_category
+ |> cast(attrs, @required_fields)
+ |> validate_required(@required_fields)
+ end
+end
diff --git a/lib/safira/activities/speaker.ex b/lib/safira/activities/speaker.ex
new file mode 100644
index 00000000..069681f9
--- /dev/null
+++ b/lib/safira/activities/speaker.ex
@@ -0,0 +1,98 @@
+defmodule Safira.Activities.Speaker do
+ @moduledoc """
+ Speakers participate in the event's activities.
+ """
+ use Safira.Schema
+
+ alias Safira.Activities
+
+ @required_fields ~w(name company title)a
+ @optional_fields ~w(biography highlighted)a
+
+ @derive {
+ Flop.Schema,
+ filterable: [:name], sortable: [:name, :company], default_limit: 4
+ }
+
+ schema "speakers" do
+ field :name, :string
+ field :title, :string
+ field :picture, Uploaders.Speaker.Type
+ field :company, :string
+ field :biography, :string
+ field :highlighted, :boolean, default: false
+
+ embeds_one :socials, Activities.Speaker.Socials
+
+ many_to_many :activities, Activities.Activity,
+ join_through: "activities_speakers",
+ on_replace: :delete
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc false
+ def changeset(speaker, attrs) do
+ speaker
+ |> cast(attrs, @required_fields ++ @optional_fields)
+ |> cast_embed(:socials)
+ |> validate_required(@required_fields)
+ end
+
+ @doc false
+ def picture_changeset(speaker, attrs) do
+ speaker
+ |> cast_attachments(attrs, [:picture])
+ end
+end
+
+defmodule Safira.Activities.Speaker.Socials do
+ @moduledoc """
+ Social media handles for speakers.
+ """
+ use Safira.Schema
+
+ embedded_schema do
+ field :github, :string
+ field :linkedin, :string
+ field :website, :string
+ field :x, :string
+ end
+
+ @doc false
+ def changeset(socials, attrs) do
+ socials
+ |> cast(attrs, [:github, :linkedin, :website, :x])
+ |> validate_url(:website)
+ |> validate_github()
+ |> validate_linkedin()
+ |> validate_x()
+ end
+
+ def validate_github(changeset) do
+ changeset
+ |> validate_format(
+ :github,
+ ~r/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i,
+ message: "not a valid github handle"
+ )
+ end
+
+ def validate_linkedin(changeset) do
+ changeset
+ |> validate_format(
+ :linkedin,
+ ~r/^[a-zA-Z0-9]{3,100}$/,
+ message: "not a valid linkedin handle"
+ )
+ end
+
+ def validate_x(changeset) do
+ changeset
+ |> validate_format(
+ :x,
+ ~r/^[A-Za-z0-9_]{4,15}$/,
+ message: "not a valid x handle"
+ )
+ end
+end
diff --git a/lib/safira/companies.ex b/lib/safira/companies.ex
index 071ebd3a..ea3b21e2 100644
--- a/lib/safira/companies.ex
+++ b/lib/safira/companies.ex
@@ -5,7 +5,7 @@ defmodule Safira.Companies do
use Safira.Context
- alias Safira.Companies.Company
+ alias Safira.Companies.{Company, Tier}
@doc """
Returns the list of companies.
@@ -141,8 +141,6 @@ defmodule Safira.Companies do
Company.changeset(company, attrs)
end
- alias Safira.Companies.Tier
-
@doc """
Returns the list of tiers.
diff --git a/lib/safira/companies/company.ex b/lib/safira/companies/company.ex
index d1e57d4d..33af89d7 100644
--- a/lib/safira/companies/company.ex
+++ b/lib/safira/companies/company.ex
@@ -45,8 +45,8 @@ defmodule Safira.Companies.Company do
end
@doc false
- def image_changeset(badge, attrs) do
- badge
+ def image_changeset(company, attrs) do
+ company
|> cast_attachments(attrs, [:logo])
end
end
diff --git a/lib/safira/event.ex b/lib/safira/event.ex
new file mode 100644
index 00000000..fb1bad0a
--- /dev/null
+++ b/lib/safira/event.ex
@@ -0,0 +1,78 @@
+defmodule Safira.Event do
+ @moduledoc """
+ The event context.
+ """
+ alias Safira.Constants
+
+ @doc """
+ Returns the event's start date.
+ If the date is not set, it will be set to today's date by default.
+
+ ## Examples
+
+ iex> get_event_start_date()
+ ~D[2025-02-11]
+ """
+ def get_event_start_date do
+ case Constants.get("event_start_date") do
+ {:ok, date} ->
+ ensure_date(date)
+
+ {:error, _} ->
+ # If the date is not set, set it to today's date by default
+ today = Timex.today()
+ change_event_start_date(today)
+ today
+ end
+ end
+
+ @doc """
+ Returns the event's end date.
+ If the date is not set, it will be set to today's date by default.
+
+ ## Examples
+
+ iex> get_event_end_date()
+ ~D[2025-02-14]
+ """
+ def get_event_end_date do
+ case Constants.get("event_end_date") do
+ {:ok, date} ->
+ ensure_date(date)
+
+ {:error, _} ->
+ # If the date is not set, set it to today's date by default
+ today = Timex.today()
+ change_event_end_date(today)
+ today
+ end
+ end
+
+ @doc """
+ Changes the event's start date.
+
+ ## Examples
+
+ iex> change_event_start_date(~D[2025-02-11])
+ :ok
+ """
+ def change_event_start_date(date) do
+ Constants.set("event_start_date", date)
+ end
+
+ @doc """
+ Changes the event's end date.
+
+ ## Examples
+
+ iex> change_event_end_date(~D[2025-02-14])
+ :ok
+ """
+ def change_event_end_date(date) do
+ Constants.set("event_end_date", date)
+ end
+
+ defp ensure_date(string) when is_binary(string), do: Date.from_iso8601!(string)
+
+ defp ensure_date(date), do: date
+end
diff --git a/lib/safira/schema.ex b/lib/safira/schema.ex
index d3b81373..1063992e 100644
--- a/lib/safira/schema.ex
+++ b/lib/safira/schema.ex
@@ -17,7 +17,7 @@ defmodule Safira.Schema do
def validate_url(changeset, field) do
changeset
|> validate_format(
- :url,
+ field,
~r/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/,
message: "must start with http:// or https:// and have a valid domain"
)
diff --git a/lib/safira/uploaders/speaker.ex b/lib/safira/uploaders/speaker.ex
new file mode 100644
index 00000000..e753cb14
--- /dev/null
+++ b/lib/safira/uploaders/speaker.ex
@@ -0,0 +1,28 @@
+defmodule Safira.Uploaders.Speaker do
+ @moduledoc """
+ Speaker image uploader.
+ """
+ use Safira.Uploader
+
+ alias Safira.Activities.Speaker
+
+ @versions [:original]
+ @extension_whitelist ~w(.jpg .jpeg .png)
+
+ def validate({file, _}) do
+ file_extension = file.file_name |> Path.extname() |> String.downcase()
+ Enum.member?(extension_whitelist(), file_extension)
+ end
+
+ def storage_dir(_, {_file, %Speaker{} = speaker}) do
+ "uploads/activities/speakers/#{speaker.id}"
+ end
+
+ def filename(version, _) do
+ version
+ end
+
+ def extension_whitelist do
+ @extension_whitelist
+ end
+end
diff --git a/lib/safira_web/components/forms.ex b/lib/safira_web/components/forms.ex
index 4e2c2431..788735f5 100644
--- a/lib/safira_web/components/forms.ex
+++ b/lib/safira_web/components/forms.ex
@@ -4,6 +4,8 @@ defmodule SafiraWeb.Components.Forms do
"""
use Phoenix.Component
+ import LiveSelect
+
alias Phoenix.HTML
@input_types ~w(
@@ -483,4 +485,63 @@ defmodule SafiraWeb.Components.Forms do
bin |> String.replace("_", " ") |> :string.titlecase()
end
+
+ attr :id, :any, default: nil, doc: "The id of the input. If not provided, it will be generated."
+ attr :name, :any, doc: "The name of the input. If not provided, it will be generated."
+ attr :class, :string, default: nil, doc: "The class to be added to the input."
+
+ attr :errors, :list,
+ default: [],
+ doc: "A list of erros to be displayed. If not provided, it will be generated."
+
+ attr :wrapper_class, :string, default: nil, doc: "The wrapper div class."
+ attr :label_class, :string, default: nil, doc: "Extra class for the label."
+ attr :help_text, :string, default: nil, doc: "Context/help for the input."
+
+ attr :required, :boolean,
+ default: false,
+ doc:
+ "If the input is required. In positive cases, it will add the `required` attribute to the input and a `*` to the label."
+
+ attr :target, :any, default: nil, doc: "The target for the live select component."
+
+ attr :field, HTML.FormField,
+ doc: "A form field struct retrieved from the form, for example: `@form[:email]`."
+
+ attr :rest, :global,
+ include: ~w(value_mapper placeholder),
+ doc: "Any other attribute to be added to the input."
+
+ def field_multiselect(assigns) do
+ ~H"""
+ <.field_wrapper
+ errors={@errors}
+ name={Map.get(assigns, :name, @field.name)}
+ class={@wrapper_class}
+ >
+ <.field_label required={@required} for={@id} class={@label_class}>
+ <%= humanize(@field.field) %>
+
+
+ <.live_select
+ id={assigns.id || @field.id}
+ mode={:tags}
+ field={@field}
+ phx-target={@target}
+ container_class={"#{@wrapper_class}"}
+ text_input_class="safira-text-input"
+ dropdown_class="safira-multiselect-dropdown"
+ option_class="safira-multiselect-dropdown-option"
+ selected_option_class="safira-multiselect-dropdown-option-selected"
+ tags_container_class="safira-multiselect-dropdown-tags-container"
+ tag_class="safira-multiselect-dropdown-tag"
+ clear_tag_button_class="safira-multiselect-dropdown-tag-remove"
+ {@rest}
+ />
+
+ <.field_error :for={msg <- @errors}><%= msg %>
+ <.field_help_text help_text={@help_text} />
+
+ """
+ end
end
diff --git a/lib/safira_web/components/image_uploader.ex b/lib/safira_web/components/image_uploader.ex
index 2f8dabcd..606c71f3 100644
--- a/lib/safira_web/components/image_uploader.ex
+++ b/lib/safira_web/components/image_uploader.ex
@@ -26,7 +26,7 @@ defmodule SafiraWeb.Components.ImageUploader do
<% else %>
<.icon name={@icon} class="w-12 h-12" />
-
<%= gettext("Upload a file or drag and drop") %>
+
<%= gettext("Upload a file or drag and drop.") %>
<% end %>
diff --git a/lib/safira_web/components/table.ex b/lib/safira_web/components/table.ex
index 12514824..978e9323 100644
--- a/lib/safira_web/components/table.ex
+++ b/lib/safira_web/components/table.ex
@@ -86,7 +86,7 @@ defmodule SafiraWeb.Components.Table do
- <.pagination meta={@meta} params={@params} />
+ <.pagination :if={@meta.total_pages > 1} meta={@meta} params={@params} />
"""
end
diff --git a/lib/safira_web/components/table_search.ex b/lib/safira_web/components/table_search.ex
index 31ccbe4e..5da9c5b9 100644
--- a/lib/safira_web/components/table_search.ex
+++ b/lib/safira_web/components/table_search.ex
@@ -10,6 +10,7 @@ defmodule SafiraWeb.Components.TableSearch do
attr :field, :atom, required: true
attr :path, :string, required: true
attr :placeholder, :string, default: gettext("Search")
+ attr :class, :string, default: ""
def table_search(assigns) do
~H"""
@@ -20,6 +21,7 @@ defmodule SafiraWeb.Components.TableSearch do
field={@field}
path={@path}
placeholder={@placeholder}
+ class={@class}
/>
"""
end
@@ -47,7 +49,7 @@ defmodule SafiraWeb.Components.TableSearchLiveComponent do
name="search[query]"
spellcheck="false"
placeholder={@placeholder}
- class="block w-80 p-2 ps-10 text-sm text-dark border border-lightShade rounded-md placeholder:text-darkMuted focus:outline-2 focus:border-lightShade ring-0 focus:outline-dark focus:outline-offset-2 dark:outline-darkShade dark:bg-dark dark:text-light dark:placeholder-lightMuted dark:focus:border-darkShade dark:focus:border-darkShade dark:border-darkShade focus:ring-0 dark:focus:outline-light"
+ class={"block w-80 p-2 ps-10 text-sm text-dark border border-lightShade rounded-md placeholder:text-darkMuted focus:outline-2 focus:border-lightShade ring-0 focus:outline-dark focus:outline-offset-2 dark:outline-darkShade dark:bg-dark dark:text-light dark:placeholder-lightMuted dark:focus:border-darkShade dark:focus:border-darkShade dark:border-darkShade focus:ring-0 dark:focus:outline-light #{@class}"}
/>
diff --git a/lib/safira_web/config.ex b/lib/safira_web/config.ex
index c9fab043..0acf081a 100644
--- a/lib/safira_web/config.ex
+++ b/lib/safira_web/config.ex
@@ -104,6 +104,13 @@ defmodule SafiraWeb.Config do
url: "/dashboard/spotlights",
scope: %{"spotlights" => ["edit"]}
},
+ %{
+ key: :schedule,
+ title: "Schedule",
+ icon: "hero-calendar-days",
+ url: "/dashboard/schedule/activities",
+ scope: %{"schedule" => ["edit"]}
+ },
%{
key: :statistics,
title: "Statistics",
diff --git a/lib/safira_web/live/backoffice/product_live/form_component.ex b/lib/safira_web/live/backoffice/product_live/form_component.ex
index bd6f5a68..a76cc75c 100644
--- a/lib/safira_web/live/backoffice/product_live/form_component.ex
+++ b/lib/safira_web/live/backoffice/product_live/form_component.ex
@@ -4,8 +4,7 @@ defmodule SafiraWeb.Backoffice.ProductLive.FormComponent do
alias Safira.Store
alias Safira.Uploaders.Product
- import SafiraWeb.Components.ImageUploader
- import SafiraWeb.Components.Forms
+ import SafiraWeb.Components.{Forms, ImageUploader}
@impl true
def render(assigns) do
diff --git a/lib/safira_web/live/backoffice/schedule_live/activity_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/activity_live/form_component.ex
new file mode 100644
index 00000000..b232b445
--- /dev/null
+++ b/lib/safira_web/live/backoffice/schedule_live/activity_live/form_component.ex
@@ -0,0 +1,211 @@
+defmodule SafiraWeb.Backoffice.ScheduleLive.ActivityLive.FormComponent do
+ use SafiraWeb, :live_component
+
+ alias Safira.Activities
+ alias Safira.Activities.Speaker
+ import SafiraWeb.Components.Forms
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.page title={@title} subtitle={gettext("Activities that happen troughout the event.")}>
+ <.simple_form
+ for={@form}
+ id="activity-form"
+ phx-target={@myself}
+ phx-change="validate"
+ phx-submit="save"
+ >
+
+
+ <.field field={@form[:title]} type="text" label="Title" required wrapper_class="w-full" />
+ <.field field={@form[:location]} type="text" label="Location" wrapper_class="w-full" />
+
+ <.field field={@form[:description]} type="textarea" label="Description" />
+
+ <.field
+ field={@form[:date]}
+ type="date"
+ label="Date"
+ required
+ wrapper_class="col-span-2"
+ />
+ <.field
+ field={@form[:time_start]}
+ type="time"
+ label="Start"
+ required
+ wrapper_class="col-span-1"
+ />
+ <.field
+ field={@form[:time_end]}
+ type="time"
+ label="End"
+ required
+ wrapper_class="col-span-1"
+ />
+
+
+ <.field
+ field={@form[:category_id]}
+ type="select"
+ label="Category"
+ options={categories_options(@categories)}
+ wrapper_class="w-full"
+ />
+ <.field_multiselect
+ id="speakers"
+ field={@form[:speakers]}
+ target={@myself}
+ value_mapper={&value_mapper/1}
+ wrapper_class="w-full"
+ placeholder={gettext("Search for speakers")}
+ />
+
+
+
+
+ <.label>
+ <%= gettext("Enrolments") %>
+
+
+ <%= gettext(
+ "Enable enrolments to allow participants to sign up for this activity."
+ ) %>
+
+ <.field
+ field={@form[:has_enrolments]}
+ type="switch"
+ label=""
+ wrapper_class="w-full pt-3"
+ />
+
+ <.field
+ :if={@enrolments_active}
+ field={@form[:max_enrolments]}
+ type="number"
+ label="Max enrolments"
+ wrapper_class="w-full mt-12"
+ />
+
+
+
+ <:actions>
+ <.button phx-disable-with="Saving...">Save Activity
+
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(socket) do
+ {:ok, socket}
+ end
+
+ @impl true
+ def update(%{activity: activity} = assigns, socket) do
+ {:ok,
+ socket
+ |> assign(assigns)
+ |> assign(:enrolments_active, activity.has_enrolments)
+ |> assign_new(:form, fn ->
+ to_form(Activities.change_activity(activity))
+ end)}
+ end
+
+ @impl true
+ def handle_event("validate", %{"activity" => activity_params}, socket) do
+ activity =
+ if Map.has_key?(activity_params, "speakers") do
+ socket.assigns.activity
+ else
+ Map.put(socket.assigns.activity, :speakers, [])
+ end
+
+ changeset = Activities.change_activity(activity, activity_params)
+
+ {:noreply,
+ assign(socket,
+ form: to_form(changeset, action: :validate),
+ enrolments_active: activity_params["has_enrolments"] != "false"
+ )}
+ end
+
+ def handle_event("save", %{"activity" => activity_params}, socket) do
+ save_activity(socket, socket.assigns.action, activity_params)
+ end
+
+ @impl true
+ def handle_event("live_select_change", %{"text" => text, "id" => live_select_id}, socket) do
+ case Activities.list_speakers(%{
+ "filters" => %{"1" => %{"field" => "name", "op" => "ilike_or", "value" => text}}
+ }) do
+ {:ok, {speakers, _meta}} ->
+ send_update(LiveSelect.Component,
+ id: live_select_id,
+ options: speakers |> Enum.map(&{&1.name, &1.id})
+ )
+
+ {:noreply, socket}
+
+ {:error, _} ->
+ {:noreply, socket}
+ end
+ end
+
+ defp save_activity(socket, :edit, activity_params) do
+ case Activities.update_activity(socket.assigns.activity, activity_params) do
+ {:ok, _activity} ->
+ case Activities.upsert_activity_speakers(
+ socket.assigns.activity,
+ activity_params["speakers"]
+ ) do
+ {:ok, _activity} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Activity updated successfully")
+ |> push_patch(to: socket.assigns.patch)}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ defp save_activity(socket, :new, activity_params) do
+ case Activities.create_activity(activity_params) do
+ {:ok, activity} ->
+ case Activities.upsert_activity_speakers(
+ Map.put(activity, :speakers, []),
+ activity_params["speakers"]
+ ) do
+ {:ok, _activity} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Activity created successfully")
+ |> push_patch(to: socket.assigns.patch)}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ defp categories_options(categories) do
+ [{"None", nil}] ++
+ Enum.map(categories, &{&1.name, &1.id})
+ end
+
+ defp value_mapper(%Speaker{} = speaker), do: {speaker.name, speaker.id}
+
+ defp value_mapper(id), do: id
+end
diff --git a/lib/safira_web/live/backoffice/schedule_live/category_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/category_live/form_component.ex
new file mode 100644
index 00000000..a4d774d4
--- /dev/null
+++ b/lib/safira_web/live/backoffice/schedule_live/category_live/form_component.ex
@@ -0,0 +1,81 @@
+defmodule SafiraWeb.Backoffice.ScheduleLive.CategoryLive.FormComponent do
+ use SafiraWeb, :live_component
+
+ alias Safira.Activities
+ import SafiraWeb.Components.Forms
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.page title={@title} subtitle={gettext("Categories group scheduled activities.")}>
+ <.simple_form
+ for={@form}
+ id="category-form"
+ phx-target={@myself}
+ phx-change="validate"
+ phx-submit="save"
+ >
+
+ <.field field={@form[:name]} type="text" label="Name" required />
+
+ <:actions>
+ <.button phx-disable-with="Saving...">Save Category
+
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(socket) do
+ {:ok, socket}
+ end
+
+ @impl true
+ def update(%{category: category} = assigns, socket) do
+ {:ok,
+ socket
+ |> assign(assigns)
+ |> assign_new(:form, fn ->
+ to_form(Activities.change_activity_category(category))
+ end)}
+ end
+
+ @impl true
+ def handle_event("validate", %{"activity_category" => category_params}, socket) do
+ changeset = Activities.change_activity_category(socket.assigns.category, category_params)
+ {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
+ end
+
+ def handle_event("save", %{"activity_category" => category_params}, socket) do
+ save_category(socket, socket.assigns.action, category_params)
+ end
+
+ defp save_category(socket, :categories_edit, category_params) do
+ case Activities.update_activity_category(socket.assigns.category, category_params) do
+ {:ok, _category} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Activity category updated successfully")
+ |> push_patch(to: socket.assigns.patch)}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ defp save_category(socket, :categories_new, category_params) do
+ case Activities.create_activity_category(category_params) do
+ {:ok, _category} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Activity category created successfully")
+ |> push_patch(to: socket.assigns.patch)}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+end
diff --git a/lib/safira_web/live/backoffice/schedule_live/category_live/index.ex b/lib/safira_web/live/backoffice/schedule_live/category_live/index.ex
new file mode 100644
index 00000000..956f35ce
--- /dev/null
+++ b/lib/safira_web/live/backoffice/schedule_live/category_live/index.ex
@@ -0,0 +1,68 @@
+defmodule SafiraWeb.Backoffice.ScheduleLive.CategoryLive.Index do
+ use SafiraWeb, :live_component
+
+ alias Safira.Activities
+
+ import SafiraWeb.Components.EnsurePermissions
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.page title={@title}>
+ <:actions>
+ <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}>
+ <.link navigate={~p"/dashboard/schedule/activities/categories/new"}>
+ <.button>New Category
+
+
+
+
+ - category.id}
+ class="even:bg-lightShade/20 dark:even:bg-darkShade/20 py-4 px-4 flex flex-row justify-between"
+ >
+
+ <%= category.name %>
+
+
+ <.ensure_permissions user={@current_user} permissions={%{"companies" => ["edit"]}}>
+ <.link navigate={~p"/dashboard/schedule/activities/categories/#{category.id}/edit"}>
+ <.icon name="hero-pencil" class="w-5 h-5" />
+
+ <.link
+ phx-click={
+ JS.push("delete", value: %{id: category.id})
+ |> hide("##{"category-" <> category.id}")
+ }
+ data-confirm="Are you sure?"
+ phx-target={@myself}
+ >
+ <.icon name="hero-trash" class="w-5 h-5" />
+
+
+
+
+
+
+ <%= gettext("No activity categories found") %>
+
+
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(socket) do
+ {:ok,
+ socket
+ |> stream(:categories, Activities.list_activity_categories())}
+ end
+end
diff --git a/lib/safira_web/live/backoffice/schedule_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/form_component.ex
new file mode 100644
index 00000000..0af69aa6
--- /dev/null
+++ b/lib/safira_web/live/backoffice/schedule_live/form_component.ex
@@ -0,0 +1,104 @@
+defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do
+ @moduledoc false
+ use SafiraWeb, :live_component
+
+ import SafiraWeb.Components.Forms
+
+ alias Ecto.Changeset
+ alias Safira.Event
+
+ def render(assigns) do
+ ~H"""
+
+ <.page title={@title} subtitle={gettext("Configures the event's dates.")}>
+
+ <.form
+ id="event-config-form"
+ for={@form}
+ phx-submit="save"
+ phx-change="validate"
+ phx-target={@myself}
+ >
+
+ <.field
+ field={@form[:event_start_date]}
+ name="event_start_date"
+ label="Start date"
+ type="date"
+ />
+ <.field
+ field={@form[:event_end_date]}
+ name="event_end_date"
+ label="End date"
+ type="date"
+ />
+
+
+ <.button phx-disable-with="Saving...">Save Configuration
+
+
+
+
+
+ """
+ end
+
+ def mount(socket) do
+ {:ok,
+ socket
+ |> assign(
+ form:
+ to_form(
+ %{
+ "event_start_date" => Event.get_event_start_date(),
+ "event_end_date" => Event.get_event_end_date()
+ },
+ as: :wheel_configuration
+ )
+ )}
+ end
+
+ def handle_event("validate", params, socket) do
+ changeset = validate_configuration(params["event_start_date"], params["event_end_date"])
+
+ {:noreply,
+ assign(socket, form: to_form(changeset, action: :validate, as: :event_configuration))}
+ end
+
+ def handle_event("save", params, socket) do
+ if valid_config?(params) do
+ Event.change_event_start_date(params["event_start_date"] |> Date.from_iso8601!())
+ Event.change_event_end_date(params["event_end_date"] |> Date.from_iso8601!())
+ {:noreply, socket |> push_patch(to: ~p"/dashboard/schedule/activities/")}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ defp validate_configuration(event_start_date, event_end_date) do
+ {%{}, %{event_start_date: :date, event_end_date: :date}}
+ |> Changeset.cast(%{event_start_date: event_start_date, event_end_date: event_end_date}, [
+ :event_start_date,
+ :event_end_date
+ ])
+ |> Changeset.validate_required([:event_start_date])
+ |> Changeset.validate_required([:event_end_date])
+ |> validate_date_is_after()
+ end
+
+ defp valid_config?(params) do
+ validation = validate_configuration(params["event_start_date"], params["event_end_date"])
+ validation.errors == []
+ end
+
+ defp validate_date_is_after(changeset) do
+ start_date = Changeset.get_field(changeset, :event_start_date)
+ end_date = Changeset.get_field(changeset, :event_end_date)
+
+ if Date.compare(start_date, end_date) == :gt do
+ Changeset.add_error(changeset, :event_start_date, "cannot be later than the end date")
+ else
+ changeset
+ end
+ end
+end
diff --git a/lib/safira_web/live/backoffice/schedule_live/index.ex b/lib/safira_web/live/backoffice/schedule_live/index.ex
new file mode 100644
index 00000000..c6ced199
--- /dev/null
+++ b/lib/safira_web/live/backoffice/schedule_live/index.ex
@@ -0,0 +1,122 @@
+defmodule SafiraWeb.Backoffice.ScheduleLive.Index do
+ alias Safira.Activities.Speaker
+ use SafiraWeb, :backoffice_view
+
+ import SafiraWeb.Components.{Table, TableSearch}
+
+ alias Safira.Activities
+ alias Safira.Activities.{Activity, ActivityCategory, Speaker}
+
+ on_mount {SafiraWeb.StaffRoles, index: %{"schedule" => ["edit"]}}
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok, socket}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ case Activities.list_activities(
+ if socket.assigns.live_action != :speakers do
+ params
+ else
+ %{}
+ end,
+ preloads: [:category]
+ ) do
+ {:ok, {activities, meta}} ->
+ {:noreply,
+ socket
+ |> assign(:current_page, :schedule)
+ |> assign(:meta, meta)
+ |> assign(:params, params)
+ |> stream(:activities, activities, reset: true)
+ |> apply_action(socket.assigns.live_action, params)}
+
+ {:error, _} ->
+ {:noreply, socket}
+ end
+ end
+
+ defp apply_action(socket, :edit, %{"id" => id}) do
+ socket
+ |> assign(:page_title, "Edit Activity")
+ |> assign(:activity, Activities.get_activity!(id))
+ |> assign(:categories, Activities.list_activity_categories())
+ end
+
+ defp apply_action(socket, :new, _params) do
+ socket
+ |> assign(:page_title, "New Activity")
+ |> assign(:activity, %Activity{speakers: []})
+ |> assign(:categories, Activities.list_activity_categories())
+ end
+
+ defp apply_action(socket, :index, _params) do
+ socket
+ |> assign(:page_title, "Listing Activities")
+ end
+
+ defp apply_action(socket, :categories_edit, %{"id" => id}) do
+ socket
+ |> assign(:page_title, "Edit Category")
+ |> assign(:category, Activities.get_activity_category!(id))
+ end
+
+ defp apply_action(socket, :categories_new, _params) do
+ socket
+ |> assign(:page_title, "New Category")
+ |> assign(:category, %ActivityCategory{})
+ end
+
+ defp apply_action(socket, :categories, _params) do
+ socket
+ |> assign(:page_title, "Listing Categories")
+ end
+
+ defp apply_action(socket, :speakers_edit, %{"id" => id}) do
+ socket
+ |> assign(:page_title, "Edit Speaker")
+ |> assign(:speaker, Activities.get_speaker!(id))
+ end
+
+ defp apply_action(socket, :speakers_new, _params) do
+ socket
+ |> assign(:page_title, "New Speaker")
+ |> assign(:speaker, %Speaker{})
+ end
+
+ defp apply_action(socket, :edit_schedule, _params) do
+ socket
+ |> assign(:page_title, "Event Calendar Configuration")
+ end
+
+ defp apply_action(socket, :speakers, params) do
+ case Activities.list_speakers(params) do
+ {:ok, {speakers, meta}} ->
+ socket
+ |> assign(:page_title, "Listing Speakers")
+ |> assign(:speakers_meta, meta)
+ |> assign(:params, params)
+ |> stream(:speakers, speakers, reset: true)
+
+ {:error, _} ->
+ {:ok, socket}
+ end
+ end
+
+ @impl true
+ def handle_event("delete", %{"id" => id}, socket) do
+ activity = Activities.get_activity!(id)
+
+ {:ok, _} = Activities.delete_activity(activity)
+
+ {:noreply, stream_delete(socket, :activities, activity)}
+ end
+
+ defp formatted_activity_times(activity) do
+ format = "{h24}:{m}"
+
+ "#{activity.time_start |> Timex.format!(format)} - #{activity.time_end |> Timex.format!(format)}"
+ end
+end
diff --git a/lib/safira_web/live/backoffice/schedule_live/index.html.heex b/lib/safira_web/live/backoffice/schedule_live/index.html.heex
new file mode 100644
index 00000000..8670da7b
--- /dev/null
+++ b/lib/safira_web/live/backoffice/schedule_live/index.html.heex
@@ -0,0 +1,181 @@
+<.page title="Schedule">
+ <:actions>
+
+ <.table_search
+ id="schedule-table-name-search"
+ params={@params}
+ field={:title}
+ path={~p"/dashboard/schedule/activities"}
+ placeholder={gettext("Search for activities")}
+ />
+ <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}>
+ <.link patch={~p"/dashboard/schedule/activities/new"}>
+ <.button>New Activity
+
+
+
+ <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}>
+ <.link patch={~p"/dashboard/schedule/edit"}>
+ <.button>
+ <.icon name="hero-calendar-days" class="w-5 h-5" />
+
+
+
+
+ <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}>
+ <.link patch={~p"/dashboard/schedule/activities/speakers"}>
+ <.button>
+ <.icon name="hero-user" class="w-5 h-5" />
+
+
+
+
+ <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}>
+ <.link patch={~p"/dashboard/schedule/activities/categories"}>
+ <.button>
+ <.icon name="hero-tag" class="w-5 h-5" />
+
+
+
+
+
+
+
+ <.table id="activities-table" items={@streams.activities} meta={@meta} params={@params}>
+ <:col :let={{_id, activity}} sortable field={:title} label="Title">
+ <%= activity.title %>
+
+ <:col :let={{_id, activity}} sortable field={:timestamp} label="Date">
+ <%= Timex.format!(activity.date, "{D}/{M}/{YYYY}") %>
+
+ <:col :let={{_id, activity}} field={:time} label="Time">
+ <%= formatted_activity_times(activity) %>
+
+ <:col :let={{_id, activity}} field={:category} label="Category">
+ <%= if activity.category do %>
+ <%= activity.category.name %>
+ <% else %>
+
-
+ <% end %>
+
+ <:action :let={{id, activity}}>
+ <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}>
+
+ <.link patch={~p"/dashboard/schedule/activities/#{activity.id}/edit"}>
+ <.icon name="hero-pencil" class="w-5 h-5" />
+
+ <.link
+ phx-click={JS.push("delete", value: %{id: activity.id}) |> hide("##{id}")}
+ data-confirm="Are you sure?"
+ >
+ <.icon name="hero-trash" class="w-5 h-5" />
+
+
+
+
+
+
+
+
+<.modal
+ :if={@live_action in [:edit, :new]}
+ id="activites-modal"
+ show
+ on_cancel={JS.navigate(~p"/dashboard/schedule/activities")}
+>
+ <.live_component
+ module={SafiraWeb.Backoffice.ScheduleLive.ActivityLive.FormComponent}
+ id={@activity.id || :new}
+ title={@page_title}
+ current_user={@current_user}
+ action={@live_action}
+ activity={@activity}
+ categories={@categories}
+ patch={~p"/dashboard/schedule/activities"}
+ />
+
+
+<.modal
+ :if={@live_action in [:speakers]}
+ id="speakers-modal"
+ show
+ on_cancel={JS.patch(~p"/dashboard/schedule/activities")}
+>
+ <.live_component
+ module={SafiraWeb.Backoffice.ScheduleLive.SpeakerLive.Index}
+ id="speakers"
+ title={@page_title}
+ current_user={@current_user}
+ action={@live_action}
+ params={@params}
+ streams={@streams}
+ meta={@speakers_meta}
+ patch={~p"/dashboard/schedule/activities"}
+ />
+
+
+<.modal
+ :if={@live_action in [:speakers_edit, :speakers_new]}
+ id="speakers-modal"
+ show
+ on_cancel={JS.navigate(~p"/dashboard/schedule/activities/speakers")}
+>
+ <.live_component
+ module={SafiraWeb.Backoffice.ScheduleLive.SpeakerLive.FormComponent}
+ id={@speaker.id || :new}
+ title={@page_title}
+ current_user={@current_user}
+ action={@live_action}
+ speaker={@speaker}
+ patch={~p"/dashboard/schedule/activities/speakers"}
+ />
+
+
+<.modal
+ :if={@live_action in [:categories]}
+ id="categories-modal"
+ show
+ on_cancel={JS.patch(~p"/dashboard/schedule/activities")}
+>
+ <.live_component
+ module={SafiraWeb.Backoffice.ScheduleLive.CategoryLive.Index}
+ id="categories"
+ title={@page_title}
+ current_user={@current_user}
+ action={@live_action}
+ patch={~p"/dashboard/schedule/activities"}
+ />
+
+
+<.modal
+ :if={@live_action in [:categories_edit, :categories_new]}
+ id="categories-modal"
+ show
+ on_cancel={JS.navigate(~p"/dashboard/schedule/activities/categories")}
+>
+ <.live_component
+ module={SafiraWeb.Backoffice.ScheduleLive.CategoryLive.FormComponent}
+ id={@category.id || :new}
+ title={@page_title}
+ current_user={@current_user}
+ action={@live_action}
+ category={@category}
+ patch={~p"/dashboard/schedule/activities/categories"}
+ />
+
+
+<.modal
+ :if={@live_action in [:edit_schedule]}
+ id="schedule-config-modal"
+ show
+ on_cancel={JS.patch(~p"/dashboard/schedule/activities")}
+>
+ <.live_component
+ module={SafiraWeb.Backoffice.ScheduleLive.FormComponent}
+ id="schedule-config"
+ title={@page_title}
+ current_user={@current_user}
+ action={@live_action}
+ patch={~p"/dashboard/schedule/activities"}
+ />
+
diff --git a/lib/safira_web/live/backoffice/schedule_live/speaker_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/speaker_live/form_component.ex
new file mode 100644
index 00000000..3c412e7e
--- /dev/null
+++ b/lib/safira_web/live/backoffice/schedule_live/speaker_live/form_component.ex
@@ -0,0 +1,168 @@
+defmodule SafiraWeb.Backoffice.ScheduleLive.SpeakerLive.FormComponent do
+ alias Safira.Activities
+ use SafiraWeb, :live_component
+
+ alias Safira.Uploaders.Speaker
+
+ import SafiraWeb.Components.{Forms, ImageUploader}
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.page title={@title} subtitle={gettext("Speakers participate in the event's activities.")}>
+ <.simple_form
+ for={@form}
+ id="speaker-form"
+ phx-target={@myself}
+ phx-change="validate"
+ phx-submit="save"
+ >
+
+ <.field field={@form[:name]} type="text" label="Name" required />
+
+ <.field
+ field={@form[:company]}
+ type="text"
+ label="Company"
+ wrapper_class="w-full"
+ required
+ />
+ <.field field={@form[:title]} type="text" label="Title" wrapper_class="w-full" required />
+
+ <.field field={@form[:biography]} type="textarea" label="Biography" />
+
+
+ <.inputs_for :let={socials_form} field={@form[:socials]}>
+ <.field
+ field={socials_form[:github]}
+ type="text"
+ label="GitHub"
+ wrapper_class="w-full"
+ />
+ <.field
+ field={socials_form[:linkedin]}
+ type="text"
+ label="LinkedIn"
+ wrapper_class="w-full"
+ />
+ <.field
+ field={socials_form[:website]}
+ type="text"
+ label="Website"
+ wrapper_class="w-full"
+ />
+ <.field field={socials_form[:x]} type="text" label="X" wrapper_class="w-full" />
+
+ <.field
+ field={@form[:highlighted]}
+ type="switch"
+ label="Highlighted"
+ wrapper_class="w-full"
+ />
+
+
+ <.label>
+ <%= gettext("Picture") %>
+
+ <.image_uploader
+ class="w-full aspect-square"
+ upload={@uploads.picture}
+ icon="hero-user"
+ image={Uploaders.Speaker.url({@speaker.picture, @speaker}, :original)}
+ />
+
+
+
+ <:actions>
+ <.button phx-disable-with="Saving...">Save Speaker
+
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(socket) do
+ {:ok,
+ socket
+ |> assign(:uploaded_files, [])
+ |> allow_upload(:picture,
+ accept: Speaker.extension_whitelist(),
+ max_entries: 1
+ )}
+ end
+
+ @impl true
+ def update(%{speaker: speaker} = assigns, socket) do
+ {:ok,
+ socket
+ |> assign(assigns)
+ |> assign_new(:form, fn ->
+ to_form(Activities.change_speaker(speaker))
+ end)}
+ end
+
+ @impl true
+ def handle_event("validate", %{"speaker" => speaker_params}, socket) do
+ changeset = Activities.change_speaker(socket.assigns.speaker, speaker_params)
+
+ {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
+ end
+
+ def handle_event("save", %{"speaker" => speaker_params}, socket) do
+ save_speaker(socket, socket.assigns.action, speaker_params)
+ end
+
+ defp save_speaker(socket, :speakers_edit, speaker_params) do
+ case Activities.update_speaker(socket.assigns.speaker, speaker_params) do
+ {:ok, speaker} ->
+ case consume_picture_data(speaker, socket) do
+ {:ok, _speaker} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Speaker updated successfully")
+ |> push_patch(to: socket.assigns.patch)}
+ end
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ defp save_speaker(socket, :speakers_new, speaker_params) do
+ case Activities.create_speaker(speaker_params) do
+ {:ok, speaker} ->
+ case consume_picture_data(speaker, socket) do
+ {:ok, _speaker} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Speaker created successfully")
+ |> push_patch(to: socket.assigns.patch)}
+ end
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ defp consume_picture_data(speaker, socket) do
+ consume_uploaded_entries(socket, :picture, fn %{path: path}, entry ->
+ Activities.update_speaker_picture(speaker, %{
+ "picture" => %Plug.Upload{
+ content_type: entry.client_type,
+ filename: entry.client_name,
+ path: path
+ }
+ })
+ end)
+ |> case do
+ [{:ok, speaker}] ->
+ {:ok, speaker}
+
+ _errors ->
+ {:ok, speaker}
+ end
+ end
+end
diff --git a/lib/safira_web/live/backoffice/schedule_live/speaker_live/index.ex b/lib/safira_web/live/backoffice/schedule_live/speaker_live/index.ex
new file mode 100644
index 00000000..a4668d55
--- /dev/null
+++ b/lib/safira_web/live/backoffice/schedule_live/speaker_live/index.ex
@@ -0,0 +1,89 @@
+defmodule SafiraWeb.Backoffice.ScheduleLive.SpeakerLive.Index do
+ use SafiraWeb, :live_component
+
+ alias Safira.Activities
+ alias Safira.Uploaders
+
+ import SafiraWeb.Components.{EnsurePermissions, Table, TableSearch}
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.page title={@title}>
+ <:actions>
+ <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}>
+ <.link navigate={~p"/dashboard/schedule/activities/speakers/new"}>
+ <.button>New Speaker
+
+
+
+
+ <.table_search
+ id="speaker-table-name-search"
+ params={@params}
+ field={:name}
+ path={~p"/dashboard/schedule/activities/speakers"}
+ placeholder={gettext("Search for speakers")}
+ class="w-full"
+ />
+ <.table id="speakers-table" items={@streams.speakers} meta={@meta} params={@params}>
+ <:col :let={{_id, speaker}} sortable field={:name} label="Name">
+
+ <%= if speaker.picture do %>
+
+ <% else %>
+
String.slice(0..2)}.png"}
+ />
+ <% end %>
+
+
<%= speaker.name %>
+
<%= speaker.title %>
+
+
+
+ <:col :let={{_id, speaker}} sortable field={:company} label="Company">
+ <%= speaker.company %>
+
+ <:action :let={{id, speaker}}>
+ <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}>
+
+ <.link patch={~p"/dashboard/schedule/activities/speakers/#{speaker.id}/edit"}>
+ <.icon name="hero-pencil" class="w-5 h-5" />
+
+ <.link
+ phx-click={
+ JS.push("delete", value: %{id: speaker.id}, target: @myself) |> hide("##{id}")
+ }
+ data-confirm="Are you sure?"
+ >
+ <.icon name="hero-trash" class="w-5 h-5" />
+
+
+
+
+
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(socket) do
+ {:ok, socket}
+ end
+
+ @impl true
+ def handle_event("delete", %{"id" => id}, socket) do
+ speaker = Activities.get_speaker!(id)
+ {:ok, _} = Activities.delete_speaker(speaker)
+
+ {:noreply, stream_delete(socket, :speakers, speaker)}
+ end
+end
diff --git a/lib/safira_web/router.ex b/lib/safira_web/router.ex
index cf3a31ce..3da0b0de 100644
--- a/lib/safira_web/router.ex
+++ b/lib/safira_web/router.ex
@@ -124,6 +124,28 @@ defmodule SafiraWeb.Router do
end
end
+ scope "/schedule", ScheduleLive do
+ live "/edit", Index, :edit_schedule
+
+ scope "/activities" do
+ live "/", Index, :index
+ live "/new", Index, :new
+ live "/:id/edit", Index, :edit
+
+ scope "/speakers" do
+ live "/", Index, :speakers
+ live "/new", Index, :speakers_new
+ live "/:id/edit", Index, :speakers_edit
+ end
+
+ scope "/categories" do
+ live "/", Index, :categories
+ live "/new", Index, :categories_new
+ live "/:id/edit", Index, :categories_edit
+ end
+ end
+ end
+
scope "/store/products", ProductLive do
live "/", Index, :index
live "/new", Index, :new
diff --git a/mix.exs b/mix.exs
index 97eef8e4..4a5c8a67 100644
--- a/mix.exs
+++ b/mix.exs
@@ -59,6 +59,7 @@ defmodule Safira.MixProject do
{:qrcode_ex, "~> 0.1.1"},
{:cachex, "~> 3.6"},
{:credo, "~> 1.6", only: [:dev, :test], runtime: false},
+ {:faker, "~> 0.18.0"},
# frontend
{:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
@@ -70,6 +71,7 @@ defmodule Safira.MixProject do
compile: false,
depth: 1},
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
+ {:live_select, "~> 1.4"},
# monitoring
{:telemetry_metrics, "~> 1.0"},
diff --git a/mix.lock b/mix.lock
index 681eb027..9229f2c6 100644
--- a/mix.lock
+++ b/mix.lock
@@ -17,6 +17,7 @@
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
"eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"},
"expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"},
+ "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"},
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
"finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"},
"floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"},
@@ -28,6 +29,7 @@
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.3", "d3f984eeb96fe53b85d20e0b049f03e57d075b5acda3ac8d465c969a2536c17b", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9a90e868927f7c777689baa16d86f4d0e086d968db5c05d917ccff6d443e58a3"},
"jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"},
+ "live_select": {:hex, :live_select, "1.4.3", "ec9706952f589d8e2e6f98a0e1633c5b51ab5b807d503bd0d9622a26c999fb9a", [:mix], [{:ecto, "~> 3.8", [hex: :ecto, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.6.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "58f7d702b0f786c73d31e60a342c0a49afaf56ca5a6a078b51babf3490465220"},
"lua": {:hex, :lua, "0.0.14", "0f9f2b44271debdf855efe87583f73e874c4daec1e920c45a73d1fa8e3c2f9a8", [:mix], [{:luerl, "~> 1.2", [hex: :luerl, repo: "hexpm", optional: false]}], "hexpm", "9bd39736c349dd47541a5619925f00d7cf3f2a6d3d33248b80f9eac81f5850d3"},
"luerl": {:hex, :luerl, "1.2.0", "60f05f4240f0e7c148ddb79b67b8ff972734aad237aa74c83d0748b8214c8ef0", [:rebar3], [], "hexpm", "9cafd4f6094ff0f5a9d278fd81d60d3e026c820bdfb6cacd4b1bd909f21b525d"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
@@ -40,6 +42,7 @@
"phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"},
"phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"},
+ "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"},
diff --git a/priv/repo/migrations/20241028131617_create_activity_categories.exs b/priv/repo/migrations/20241028131617_create_activity_categories.exs
new file mode 100644
index 00000000..b1fb112e
--- /dev/null
+++ b/priv/repo/migrations/20241028131617_create_activity_categories.exs
@@ -0,0 +1,14 @@
+defmodule Safira.Repo.Migrations.CreateActivityCategories do
+ use Ecto.Migration
+
+ def change do
+ create table(:activity_categories, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string, null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create unique_index(:activity_categories, [:name])
+ end
+end
diff --git a/priv/repo/migrations/20241028131718_create_activities.exs b/priv/repo/migrations/20241028131718_create_activities.exs
new file mode 100644
index 00000000..0542d29e
--- /dev/null
+++ b/priv/repo/migrations/20241028131718_create_activities.exs
@@ -0,0 +1,20 @@
+defmodule Safira.Repo.Migrations.CreateActivities do
+ use Ecto.Migration
+
+ def change do
+ create table(:activities, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :title, :string, null: false
+ add :description, :text
+ add :location, :string
+ add :date, :date, null: false
+ add :time_start, :time, null: false
+ add :time_end, :time, null: false
+ add :has_enrolments, :boolean, default: false, null: false
+ add :max_enrolments, :integer, default: 0, null: false
+ add :category_id, references(:activity_categories, on_delete: :nothing, type: :binary_id)
+
+ timestamps(type: :utc_datetime)
+ end
+ end
+end
diff --git a/priv/repo/migrations/20241102191718_create_speakers.exs b/priv/repo/migrations/20241102191718_create_speakers.exs
new file mode 100644
index 00000000..55d5dbbb
--- /dev/null
+++ b/priv/repo/migrations/20241102191718_create_speakers.exs
@@ -0,0 +1,18 @@
+defmodule Safira.Repo.Migrations.CreateSpeakers do
+ use Ecto.Migration
+
+ def change do
+ create table(:speakers, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string
+ add :picture, :string
+ add :company, :string
+ add :title, :string
+ add :biography, :text
+ add :highlighted, :boolean, default: false, null: false
+ add :socials, :map
+
+ timestamps(type: :utc_datetime)
+ end
+ end
+end
diff --git a/priv/repo/migrations/20241103215838_create_activities_speakers.exs b/priv/repo/migrations/20241103215838_create_activities_speakers.exs
new file mode 100644
index 00000000..7b3b4570
--- /dev/null
+++ b/priv/repo/migrations/20241103215838_create_activities_speakers.exs
@@ -0,0 +1,16 @@
+defmodule Safira.Repo.Migrations.CreateActivitiesSpeakers do
+ use Ecto.Migration
+
+ def change do
+ create table(:activities_speakers, primary_key: false) do
+ add :activity_id, references(:activities, type: :binary_id, on_delete: :delete_all),
+ primary_key: true
+
+ add :speaker_id, references(:speakers, type: :binary_id, on_delete: :delete_all),
+ primary_key: true
+ end
+
+ create index(:activities_speakers, [:activity_id])
+ create index(:activities_speakers, [:speaker_id])
+ end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index 02712b4b..a74aa58a 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -15,7 +15,8 @@ defmodule Safira.Repo.Seeds do
"store.exs",
"vault.exs",
"prizes.exs",
- "companies.exs"
+ "companies.exs",
+ "activities.exs"
]
|> Enum.each(fn file ->
Code.require_file("#{@seeds_dir}/#{file}")
diff --git a/priv/repo/seeds/activities.exs b/priv/repo/seeds/activities.exs
new file mode 100644
index 00000000..6cacdb69
--- /dev/null
+++ b/priv/repo/seeds/activities.exs
@@ -0,0 +1,154 @@
+defmodule Safira.Repo.Seeds.Activities do
+ alias Safira.Repo
+
+ alias Safira.Activities
+ alias Safira.Activities.{Activity, ActivityCategory, Speaker}
+ alias Safira.Event
+
+ def run do
+ seed_event_schedule_config()
+
+ case Activities.list_activity_categories() do
+ [] ->
+ seed_categories()
+ _ ->
+ Mix.shell().error("Found categories, aborting seeding categories.")
+ end
+
+ case Activities.list_speakers() do
+ [] ->
+ seed_speakers()
+ _ ->
+ Mix.shell().error("Found speakers, aborting seeding speakers.")
+ end
+
+ case Activities.list_activities() do
+ [] ->
+ seed_activities()
+ _ ->
+ Mix.shell().error("Found activities, aborting seeding activities.")
+ end
+ end
+
+ def seed_event_schedule_config do
+ event_start_date = next_first_tuesday_of_february()
+ Event.change_event_start_date(event_start_date)
+ Event.change_event_end_date(Date.add(event_start_date, 3))
+ end
+
+ def seed_categories do
+ categories = [
+ %{
+ name: "Talk"
+ },
+ %{
+ name: "Pitch"
+ },
+ %{
+ name: "Workshop"
+ },
+ %{
+ name: "Break"
+ }
+ ]
+
+ for category <- categories do
+ changeset = ActivityCategory.changeset(%ActivityCategory{}, category)
+
+ case Repo.insert(changeset) do
+ {:ok, _} -> :ok
+ {:error, changeset} ->
+ Mix.shell().error("Failed to insert category: #{category.name}")
+ Mix.shell().error(Kernel.inspect(changeset.errors))
+ end
+ end
+ end
+
+ def seed_speakers do
+ for i <- 1..30 do
+ first_name = Faker.Person.first_name()
+ last_name = Faker.Person.last_name()
+ handle = "#{first_name}#{last_name}" |> String.downcase()
+
+ speaker = %{
+ name: "#{first_name} #{last_name}",
+ title: Faker.Person.title(),
+ company: Faker.Company.name(),
+ biography: Faker.Lorem.paragraph(3),
+ highlighted: i > 24,
+ socials: %{
+ x: handle |> String.slice(0..14),
+ linkedin: handle,
+ github: handle,
+ website: Faker.Internet.url()
+ }
+ }
+
+ changeset = Speaker.changeset(%Speaker{}, speaker)
+
+ case Repo.insert(changeset) do
+ {:ok, _} -> :ok
+ {:error, changeset} ->
+ Mix.shell().error("Failed to insert speaker: #{speaker.name}")
+ Mix.shell().error(Kernel.inspect(changeset.errors))
+ end
+ end
+ end
+
+ def seed_activities do
+ categories = (Activities.list_activity_categories() |> Enum.map(&(&1.id))) ++ [nil]
+ speakers = Activities.list_speakers() |> Enum.map(&(&1.id))
+
+ for day <- 0..3 do
+ for i <- 0..5 do
+ time_start = ~T[09:00:00] |> Time.add(i * 2, :hour)
+ time_end = time_start |> Time.add(1, :hour)
+
+ activity = %{
+ title: Faker.Company.bs() |> String.capitalize(),
+ location: "CP#{:rand.uniform(4)} - #{Enum.random(["A", "B"])}#{:rand.uniform(2)}",
+ date: next_first_tuesday_of_february() |> Date.shift(day: day),
+ time_start: time_start,
+ time_end: time_end,
+ description: Faker.Lorem.paragraph(3),
+ category_id: Enum.random(categories),
+ }
+
+ changeset = Activities.change_activity(%Activity{}, activity)
+
+ case Repo.insert(changeset) do
+ {:ok, activity} ->
+ speaker_ids = Enum.take_random(speakers, :rand.uniform(3))
+ Activities.upsert_activity_speakers(Map.put(activity, :speakers, []), speaker_ids)
+ {:error, changeset} ->
+ Mix.shell().error("Failed to insert activity: #{activity.title}")
+ Mix.shell().error(Kernel.inspect(changeset.errors))
+ end
+ end
+ end
+ end
+
+ def next_first_tuesday_of_february do
+ today = Date.utc_today()
+ {year, _, _} = Date.to_erl(today)
+
+ # Determine if we need to check this year or next year
+ target_year =
+ if today > Date.from_iso8601!("#{year}-02-01") do
+ year + 1
+ else
+ year
+ end
+
+ # Find the first day of February for the target year
+ february_first = Date.from_iso8601!("#{target_year}-02-01")
+
+ # Calculate how many days to add to reach the first Tuesday
+ days_to_add = rem(9 - Date.day_of_week(february_first), 7)
+
+ # Add the days to February 1st to get the first Tuesday
+ Date.add(february_first, days_to_add)
+ end
+end
+
+Safira.Repo.Seeds.Activities.run()
diff --git a/test/safira/activities_test.exs b/test/safira/activities_test.exs
new file mode 100644
index 00000000..4b0997b3
--- /dev/null
+++ b/test/safira/activities_test.exs
@@ -0,0 +1,242 @@
+defmodule Safira.ActivitiesTest do
+ use Safira.DataCase
+
+ alias Safira.Activities
+ alias Safira.Event
+
+ describe "activities" do
+ alias Safira.Activities.Activity
+
+ import Safira.ActivitiesFixtures
+
+ @invalid_attrs %{
+ date: nil,
+ description: nil,
+ title: nil,
+ location: nil,
+ time_start: nil,
+ time_end: nil,
+ has_enrolments: nil
+ }
+
+ test "list_activities/0 returns all activities" do
+ activity = activity_fixture()
+ assert Activities.list_activities() == [activity]
+ end
+
+ test "get_activity!/1 returns the activity with given id" do
+ activity = activity_fixture()
+ assert Activities.get_activity!(activity.id) == activity
+ end
+
+ test "create_activity/1 with valid data creates a activity" do
+ Event.change_event_start_date(~D[2024-10-27])
+ Event.change_event_end_date(~D[2024-10-27])
+
+ valid_attrs = %{
+ date: ~D[2024-10-27],
+ description: "some description",
+ title: "some title",
+ location: "some location",
+ time_start: ~T[14:00:00],
+ time_end: ~T[14:00:00],
+ has_enrolments: true
+ }
+
+ assert {:ok, %Activity{} = activity} = Activities.create_activity(valid_attrs)
+ assert activity.date == ~D[2024-10-27]
+ assert activity.description == "some description"
+ assert activity.title == "some title"
+ assert activity.location == "some location"
+ assert activity.time_start == ~T[14:00:00]
+ assert activity.time_end == ~T[14:00:00]
+ assert activity.has_enrolments == true
+ end
+
+ test "create_activity/1 with invalid data returns error changeset" do
+ assert {:error, %Ecto.Changeset{}} = Activities.create_activity(@invalid_attrs)
+ end
+
+ test "update_activity/2 with valid data updates the activity" do
+ activity = activity_fixture()
+
+ Event.change_event_start_date(~D[2024-10-28])
+ Event.change_event_end_date(~D[2024-10-28])
+
+ update_attrs = %{
+ date: ~D[2024-10-28],
+ description: "some updated description",
+ title: "some updated title",
+ location: "some updated location",
+ time_start: ~T[15:01:01],
+ time_end: ~T[15:01:01],
+ has_enrolments: false
+ }
+
+ assert {:ok, %Activity{} = activity} = Activities.update_activity(activity, update_attrs)
+ assert activity.date == ~D[2024-10-28]
+ assert activity.description == "some updated description"
+ assert activity.title == "some updated title"
+ assert activity.location == "some updated location"
+ assert activity.time_start == ~T[15:01:01]
+ assert activity.time_end == ~T[15:01:01]
+ assert activity.has_enrolments == false
+ end
+
+ test "update_activity/2 with invalid data returns error changeset" do
+ activity = activity_fixture()
+ assert {:error, %Ecto.Changeset{}} = Activities.update_activity(activity, @invalid_attrs)
+ assert activity == Activities.get_activity!(activity.id)
+ end
+
+ test "delete_activity/1 deletes the activity" do
+ activity = activity_fixture()
+ assert {:ok, %Activity{}} = Activities.delete_activity(activity)
+ assert_raise Ecto.NoResultsError, fn -> Activities.get_activity!(activity.id) end
+ end
+
+ test "change_activity/1 returns a activity changeset" do
+ activity = activity_fixture()
+ assert %Ecto.Changeset{} = Activities.change_activity(activity)
+ end
+ end
+
+ describe "activity_categories" do
+ alias Safira.Activities.ActivityCategory
+
+ import Safira.ActivitiesFixtures
+
+ @invalid_attrs %{name: nil}
+
+ test "list_activity_categories/0 returns all activity_categories" do
+ activity_category = activity_category_fixture()
+ assert Activities.list_activity_categories() == [activity_category]
+ end
+
+ test "get_activity_category!/1 returns the activity_category with given id" do
+ activity_category = activity_category_fixture()
+ assert Activities.get_activity_category!(activity_category.id) == activity_category
+ end
+
+ test "create_activity_category/1 with valid data creates a activity_category" do
+ valid_attrs = %{name: "some name"}
+
+ assert {:ok, %ActivityCategory{} = activity_category} =
+ Activities.create_activity_category(valid_attrs)
+
+ assert activity_category.name == "some name"
+ end
+
+ test "create_activity_category/1 with invalid data returns error changeset" do
+ assert {:error, %Ecto.Changeset{}} = Activities.create_activity_category(@invalid_attrs)
+ end
+
+ test "update_activity_category/2 with valid data updates the activity_category" do
+ activity_category = activity_category_fixture()
+ update_attrs = %{name: "some updated name"}
+
+ assert {:ok, %ActivityCategory{} = activity_category} =
+ Activities.update_activity_category(activity_category, update_attrs)
+
+ assert activity_category.name == "some updated name"
+ end
+
+ test "update_activity_category/2 with invalid data returns error changeset" do
+ activity_category = activity_category_fixture()
+
+ assert {:error, %Ecto.Changeset{}} =
+ Activities.update_activity_category(activity_category, @invalid_attrs)
+
+ assert activity_category == Activities.get_activity_category!(activity_category.id)
+ end
+
+ test "delete_activity_category/1 deletes the activity_category" do
+ activity_category = activity_category_fixture()
+ assert {:ok, %ActivityCategory{}} = Activities.delete_activity_category(activity_category)
+
+ assert_raise Ecto.NoResultsError, fn ->
+ Activities.get_activity_category!(activity_category.id)
+ end
+ end
+
+ test "change_activity_category/1 returns a activity_category changeset" do
+ activity_category = activity_category_fixture()
+ assert %Ecto.Changeset{} = Activities.change_activity_category(activity_category)
+ end
+ end
+
+ describe "speakers" do
+ alias Safira.Activities.Speaker
+
+ import Safira.ActivitiesFixtures
+
+ @invalid_attrs %{name: nil, title: nil, company: nil, biography: nil, highlighted: nil}
+
+ test "list_speakers/0 returns all speakers" do
+ speaker = speaker_fixture()
+ assert Activities.list_speakers() == [speaker]
+ end
+
+ test "get_speaker!/1 returns the speaker with given id" do
+ speaker = speaker_fixture()
+ assert Activities.get_speaker!(speaker.id) == speaker
+ end
+
+ test "create_speaker/1 with valid data creates a speaker" do
+ valid_attrs = %{
+ name: "some name",
+ title: "some title",
+ company: "some company",
+ biography: "some biography",
+ highlighted: true
+ }
+
+ assert {:ok, %Speaker{} = speaker} = Activities.create_speaker(valid_attrs)
+ assert speaker.name == "some name"
+ assert speaker.title == "some title"
+ assert speaker.company == "some company"
+ assert speaker.biography == "some biography"
+ assert speaker.highlighted == true
+ end
+
+ test "create_speaker/1 with invalid data returns error changeset" do
+ assert {:error, %Ecto.Changeset{}} = Activities.create_speaker(@invalid_attrs)
+ end
+
+ test "update_speaker/2 with valid data updates the speaker" do
+ speaker = speaker_fixture()
+
+ update_attrs = %{
+ name: "some updated name",
+ title: "some updated title",
+ company: "some updated company",
+ biography: "some updated biography",
+ highlighted: false
+ }
+
+ assert {:ok, %Speaker{} = speaker} = Activities.update_speaker(speaker, update_attrs)
+ assert speaker.name == "some updated name"
+ assert speaker.title == "some updated title"
+ assert speaker.company == "some updated company"
+ assert speaker.biography == "some updated biography"
+ assert speaker.highlighted == false
+ end
+
+ test "update_speaker/2 with invalid data returns error changeset" do
+ speaker = speaker_fixture()
+ assert {:error, %Ecto.Changeset{}} = Activities.update_speaker(speaker, @invalid_attrs)
+ assert speaker == Activities.get_speaker!(speaker.id)
+ end
+
+ test "delete_speaker/1 deletes the speaker" do
+ speaker = speaker_fixture()
+ assert {:ok, %Speaker{}} = Activities.delete_speaker(speaker)
+ assert_raise Ecto.NoResultsError, fn -> Activities.get_speaker!(speaker.id) end
+ end
+
+ test "change_speaker/1 returns a speaker changeset" do
+ speaker = speaker_fixture()
+ assert %Ecto.Changeset{} = Activities.change_speaker(speaker)
+ end
+ end
+end
diff --git a/test/support/fixtures/activities_fixtures.ex b/test/support/fixtures/activities_fixtures.ex
new file mode 100644
index 00000000..78ea3c86
--- /dev/null
+++ b/test/support/fixtures/activities_fixtures.ex
@@ -0,0 +1,62 @@
+defmodule Safira.ActivitiesFixtures do
+ @moduledoc """
+ This module defines test helpers for creating
+ entities via the `Safira.Activities` context.
+ """
+ alias Safira.Event
+
+ @doc """
+ Generate a activity.
+ """
+ def activity_fixture(attrs \\ %{}) do
+ Event.change_event_start_date(~D[2024-10-27])
+ Event.change_event_end_date(~D[2024-10-27])
+
+ {:ok, activity} =
+ attrs
+ |> Enum.into(%{
+ date: ~D[2024-10-27],
+ description: "some description",
+ has_enrolments: true,
+ location: "some location",
+ time_end: ~T[14:00:00],
+ time_start: ~T[14:00:00],
+ title: "some title"
+ })
+ |> Safira.Activities.create_activity()
+
+ Map.put(activity, :speakers, [])
+ end
+
+ @doc """
+ Generate a activity_category.
+ """
+ def activity_category_fixture(attrs \\ %{}) do
+ {:ok, activity_category} =
+ attrs
+ |> Enum.into(%{
+ name: "some name"
+ })
+ |> Safira.Activities.create_activity_category()
+
+ activity_category
+ end
+
+ @doc """
+ Generate a speaker.
+ """
+ def speaker_fixture(attrs \\ %{}) do
+ {:ok, speaker} =
+ attrs
+ |> Enum.into(%{
+ biography: "some biography",
+ company: "some company",
+ highlighted: true,
+ name: "some name",
+ title: "some title"
+ })
+ |> Safira.Activities.create_speaker()
+
+ speaker
+ end
+end