You've already forked algora-tv
							
							
				mirror of
				https://github.com/algora-io/tv.git
				synced 2025-10-30 23:07:56 +02:00 
			
		
		
		
	implement in-video ads & analytics (#59)
This commit is contained in:
		| @@ -29,4 +29,5 @@ node_modules | ||||
| /.local | ||||
| /priv/cache | ||||
| *.patch | ||||
| /.fly | ||||
| /.fly | ||||
| /xref* | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -41,4 +41,5 @@ npm-debug.log | ||||
| /.local | ||||
| /priv/cache | ||||
| *.patch | ||||
| /.fly | ||||
| /.fly | ||||
| /xref* | ||||
							
								
								
									
										4
									
								
								.iex.exs
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								.iex.exs
									
									
									
									
									
								
							| @@ -4,7 +4,3 @@ import Ecto.Changeset | ||||
| alias Algora.{Admin, Accounts, Library, Repo, Storage, Cache, ML, Shows} | ||||
|  | ||||
| IEx.configure(inspect: [charlists: :as_lists]) | ||||
|  | ||||
| if Code.ensure_loaded?(ExSync) && function_exported?(ExSync, :register_group_leader, 0) do | ||||
|   ExSync.register_group_leader() | ||||
| end | ||||
|   | ||||
| @@ -20,6 +20,13 @@ | ||||
|   } | ||||
| } | ||||
|  | ||||
| .font-display { | ||||
|   font-family: "Space Grotesk", sans-serif; | ||||
|   font-optical-sizing: auto; | ||||
|   font-weight: 400; | ||||
|   font-style: normal; | ||||
| } | ||||
|  | ||||
| .scrollbar-thin::-webkit-scrollbar { | ||||
|   width: 0.25em; | ||||
|   height: 0.25em; | ||||
|   | ||||
| @@ -152,8 +152,11 @@ const Hooks = { | ||||
|  | ||||
|       this.playerId = this.el.id; | ||||
|  | ||||
|       // TODO: remove this once we have a better way to handle autoplay | ||||
|       const autoplay = this.el.id.startsWith("analytics-") ? false : "any"; | ||||
|  | ||||
|       this.player = videojs(this.el, { | ||||
|         autoplay: "any", | ||||
|         autoplay: autoplay, | ||||
|         liveui: true, | ||||
|         html5: { | ||||
|           vhs: { | ||||
|   | ||||
| @@ -6,16 +6,15 @@ import Config | ||||
| # to provide built-in test partitioning in CI environment. | ||||
| # Run `mix help test` for more information. | ||||
| config :algora, Algora.Repo, | ||||
|   username: "postgres", | ||||
|   password: "postgres", | ||||
|   database: "algora_test#{System.get_env("MIX_TEST_PARTITION")}", | ||||
|   hostname: "localhost", | ||||
|   url: System.get_env("TEST_DATABASE_URL"), | ||||
|   show_sensitive_data_on_connection_error: true, | ||||
|   pool: Ecto.Adapters.SQL.Sandbox, | ||||
|   pool_size: 10 | ||||
|  | ||||
| config :algora, Algora.Repo.Local, | ||||
|   url: System.get_env("DATABASE_URL"), | ||||
|   url: System.get_env("TEST_DATABASE_URL"), | ||||
|   show_sensitive_data_on_connection_error: true, | ||||
|   pool: Ecto.Adapters.SQL.Sandbox, | ||||
|   pool_size: 10, | ||||
|   priv: "priv/repo" | ||||
|  | ||||
| @@ -27,7 +26,7 @@ config :algora, AlgoraWeb.Endpoint, | ||||
|   http: [ip: {127, 0, 0, 1}, port: 4002], | ||||
|   server: false | ||||
|  | ||||
|   config :algora, AlgoraWeb.Embed.Endpoint, | ||||
| config :algora, AlgoraWeb.Embed.Endpoint, | ||||
|   http: [ip: {127, 0, 0, 1}, port: 4003], | ||||
|   server: false | ||||
|  | ||||
|   | ||||
							
								
								
									
										132
									
								
								lib/algora/admin/generate_blurb_thumbnails.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								lib/algora/admin/generate_blurb_thumbnails.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,132 @@ | ||||
| defmodule Algora.Admin.GenerateBlurbThumbnails do | ||||
|   import Ecto.Query | ||||
|   alias Algora.Ads.ProductReview | ||||
|   alias Algora.{Clipper, Repo} | ||||
|  | ||||
|   def run do | ||||
|     for product_review <- fetch_product_reviews() do | ||||
|       :ok = process_product_review(product_review) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp fetch_product_reviews do | ||||
|     Repo.all( | ||||
|       from pr in ProductReview, | ||||
|         join: v in assoc(pr, :video), | ||||
|         preload: [video: v] | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp process_product_review(product_review) do | ||||
|     with {:ok, thumbnail_path} <- create_thumbnail(product_review), | ||||
|          {:ok, thumbnail_url} <- upload_thumbnail(thumbnail_path), | ||||
|          {:ok, _updated_review} <- update_product_review(product_review, thumbnail_url) do | ||||
|       IO.puts("Successfully processed ProductReview #{product_review.id}") | ||||
|     else | ||||
|       {:error, step, reason} -> | ||||
|         IO.puts( | ||||
|           "Error processing ProductReview #{product_review.id} at step #{step}: #{inspect(reason)}" | ||||
|         ) | ||||
|  | ||||
|         :error | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp create_thumbnail(product_review) do | ||||
|     video_path = generate_thumbnails(product_review) | ||||
|     output_path = "/tmp/thumbnail_#{product_review.id}.jpg" | ||||
|  | ||||
|     case Thumbnex.create_thumbnail(video_path, output_path, time: product_review.clip_from) do | ||||
|       :ok -> {:ok, output_path} | ||||
|       error -> {:error, :create_thumbnail, error} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp upload_thumbnail(file_path) do | ||||
|     uuid = Ecto.UUID.generate() | ||||
|     remote_path = "blurbs/#{uuid}.jpg" | ||||
|  | ||||
|     case Algora.Storage.upload_from_filename(file_path, remote_path, fn _ -> nil end, | ||||
|            content_type: "image/jpeg" | ||||
|          ) do | ||||
|       {:ok, _} -> | ||||
|         bucket = Algora.config([:buckets, :media]) | ||||
|         %{scheme: scheme, host: host} = Application.fetch_env!(:ex_aws, :s3) |> Enum.into(%{}) | ||||
|         thumbnail_url = "#{scheme}#{host}/#{bucket}/#{remote_path}" | ||||
|         {:ok, thumbnail_url} | ||||
|  | ||||
|       error -> | ||||
|         {:error, :upload_thumbnail, error} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp update_product_review(product_review, thumbnail_url) do | ||||
|     product_review | ||||
|     |> Ecto.Changeset.change(thumbnail_url: thumbnail_url) | ||||
|     |> Repo.update() | ||||
|     |> case do | ||||
|       {:ok, updated_review} -> {:ok, updated_review} | ||||
|       error -> {:error, :update_product_review, error} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def generate_thumbnails(product_review) do | ||||
|     # Generate clipped manifest | ||||
|     %{playlist: playlist, ss: _ss} = | ||||
|       Clipper.clip(product_review.video, product_review.clip_from, product_review.clip_to) | ||||
|  | ||||
|     # Find MediaInit and first Segment | ||||
|     {init_tag, segment_tag} = find_init_and_segment(playlist.timeline) | ||||
|  | ||||
|     # Download and concatenate files | ||||
|     video_path = download_and_concatenate(init_tag, segment_tag, product_review.video) | ||||
|  | ||||
|     video_path | ||||
|   end | ||||
|  | ||||
|   defp find_init_and_segment(timeline) do | ||||
|     init_tag = Enum.find(timeline, &match?(%ExM3U8.Tags.MediaInit{}, &1)) | ||||
|     segments = Enum.filter(timeline, &match?(%ExM3U8.Tags.Segment{}, &1)) | ||||
|  | ||||
|     segment_tag = | ||||
|       case segments do | ||||
|         [_, second | _] -> second | ||||
|         [first | _] -> first | ||||
|         _ -> nil | ||||
|       end | ||||
|  | ||||
|     {init_tag, segment_tag} | ||||
|   end | ||||
|  | ||||
|   defp download_and_concatenate(init_tag, segment_tag, video) do | ||||
|     temp_dir = Path.join(System.tmp_dir!(), video.uuid) | ||||
|     File.mkdir_p!(temp_dir) | ||||
|  | ||||
|     output_path = Path.join(temp_dir, "output.mp4") | ||||
|  | ||||
|     init_url = maybe_to_absolute(init_tag.uri, video) | ||||
|     segment_url = maybe_to_absolute(segment_tag.uri, video) | ||||
|  | ||||
|     init_path = download_file(init_url, Path.join(temp_dir, "init.mp4")) | ||||
|     segment_path = download_file(segment_url, Path.join(temp_dir, "segment.m4s")) | ||||
|  | ||||
|     ffmpeg_command = "ffmpeg -y -i \"concat:#{init_path}|#{segment_path}\" -c copy #{output_path}" | ||||
|     System.cmd("sh", ["-c", ffmpeg_command]) | ||||
|  | ||||
|     output_path | ||||
|   end | ||||
|  | ||||
|   defp download_file(url, path) do | ||||
|     {:ok, %{body: body}} = HTTPoison.get(url) | ||||
|     File.write!(path, body) | ||||
|     path | ||||
|   end | ||||
|  | ||||
|   defp maybe_to_absolute(uri, video) do | ||||
|     if URI.parse(uri).scheme do | ||||
|       uri | ||||
|     else | ||||
|       Algora.Clipper.to_absolute(:video, video.uuid, uri) | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										279
									
								
								lib/algora/ads.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										279
									
								
								lib/algora/ads.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,279 @@ | ||||
| defmodule Algora.Ads do | ||||
|   @moduledoc """ | ||||
|   The Ads context. | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Algora.Repo | ||||
|  | ||||
|   alias Algora.Ads.{Ad, Visit, Impression, Events, ContentMetrics, Appearance, ProductReview} | ||||
|  | ||||
|   @pubsub Algora.PubSub | ||||
|  | ||||
|   def display_duration, do: :timer.minutes(2) | ||||
|   def rotation_interval, do: :timer.minutes(30) | ||||
|  | ||||
|   def unsubscribe_to_ads() do | ||||
|     Phoenix.PubSub.unsubscribe(@pubsub, topic()) | ||||
|   end | ||||
|  | ||||
|   def subscribe_to_ads() do | ||||
|     Phoenix.PubSub.subscribe(@pubsub, topic()) | ||||
|   end | ||||
|  | ||||
|   defp topic(), do: "ads" | ||||
|  | ||||
|   defp broadcast!(topic, msg) do | ||||
|     Phoenix.PubSub.broadcast!(@pubsub, topic, {__MODULE__, msg}) | ||||
|   end | ||||
|  | ||||
|   def broadcast_ad_created!(ad) do | ||||
|     broadcast!(topic(), %Events.AdCreated{ad: ad}) | ||||
|   end | ||||
|  | ||||
|   def broadcast_ad_updated!(ad) do | ||||
|     broadcast!(topic(), %Events.AdUpdated{ad: ad}) | ||||
|   end | ||||
|  | ||||
|   def broadcast_ad_deleted!(ad) do | ||||
|     broadcast!(topic(), %Events.AdDeleted{ad: ad}) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of ads. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_ads() | ||||
|       [%Ad{}, ...] | ||||
|  | ||||
|   """ | ||||
|  | ||||
|   def list_ads do | ||||
|     Ad | ||||
|     |> order_by(asc: :id) | ||||
|     |> Repo.all() | ||||
|   end | ||||
|  | ||||
|   def list_active_ads do | ||||
|     Ad | ||||
|     |> where(verified: true, status: :active) | ||||
|     |> order_by(asc: :id) | ||||
|     |> Repo.all() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single ad. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Ad does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_ad!(123) | ||||
|       %Ad{} | ||||
|  | ||||
|       iex> get_ad!(456) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   def get_ad!(id), do: Repo.get!(Ad, id) | ||||
|  | ||||
|   def get_ad_by_slug!(slug) do | ||||
|     Repo.get_by!(Ad, slug: slug) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a ad. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> create_ad(%{field: value}) | ||||
|       {:ok, %Ad{}} | ||||
|  | ||||
|       iex> create_ad(%{field: bad_value}) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   def create_ad(attrs \\ %{}) do | ||||
|     %Ad{status: :active, verified: true} | ||||
|     |> Ad.changeset(attrs) | ||||
|     |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a ad. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_ad(ad, %{field: new_value}) | ||||
|       {:ok, %Ad{}} | ||||
|  | ||||
|       iex> update_ad(ad, %{field: bad_value}) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   def update_ad(%Ad{} = ad, attrs) do | ||||
|     ad | ||||
|     |> Ad.changeset(attrs) | ||||
|     |> Repo.update() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a ad. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_ad(ad) | ||||
|       {:ok, %Ad{}} | ||||
|  | ||||
|       iex> delete_ad(ad) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   def delete_ad(%Ad{} = ad) do | ||||
|     Repo.delete(ad) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Ecto.Changeset{}` for tracking ad changes. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_ad(ad) | ||||
|       %Ecto.Changeset{data: %Ad{}} | ||||
|  | ||||
|   """ | ||||
|   def change_ad(%Ad{} = ad, attrs \\ %{}) do | ||||
|     Ad.changeset(ad, attrs) | ||||
|   end | ||||
|  | ||||
|   def track_visit(attrs \\ %{}) do | ||||
|     %Visit{} | ||||
|     |> Visit.changeset(attrs) | ||||
|     |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   def track_impressions(attrs \\ %{}) do | ||||
|     %Impression{} | ||||
|     |> Impression.changeset(attrs) | ||||
|     |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   def get_current_index(ads) do | ||||
|     :os.system_time(:millisecond) | ||||
|     |> div(rotation_interval()) | ||||
|     |> rem(length(ads)) | ||||
|   end | ||||
|  | ||||
|   def rotate_ads(ads, index \\ nil) do | ||||
|     index = index || get_current_index(ads) | ||||
|     Enum.concat(Enum.drop(ads, index), Enum.take(ads, index)) | ||||
|   end | ||||
|  | ||||
|   def next_slot(time \\ DateTime.utc_now()) do | ||||
|     time | ||||
|     |> DateTime.truncate(:millisecond) | ||||
|     |> DateTime.add(ms_until_next_slot(time), :millisecond) | ||||
|   end | ||||
|  | ||||
|   def time_until_next_slot(time \\ DateTime.utc_now()) do | ||||
|     DateTime.diff(next_slot(time), time, :millisecond) | ||||
|   end | ||||
|  | ||||
|   defp ms_until_next_slot(time) do | ||||
|     rotation_interval() - rem(DateTime.to_unix(time, :millisecond), rotation_interval()) | ||||
|   end | ||||
|  | ||||
|   def list_content_metrics do | ||||
|     Repo.all(ContentMetrics) | ||||
|     |> Repo.preload(video: [:appearances, :product_reviews]) | ||||
|   end | ||||
|  | ||||
|   def create_content_metrics(attrs \\ %{}) do | ||||
|     %ContentMetrics{} | ||||
|     |> ContentMetrics.changeset(attrs) | ||||
|     |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   def change_content_metrics(%ContentMetrics{} = content_metrics, attrs \\ %{}) do | ||||
|     ContentMetrics.changeset(content_metrics, attrs) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single content_metrics. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the ContentMetrics does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_content_metrics!(123) | ||||
|       %ContentMetrics{} | ||||
|  | ||||
|       iex> get_content_metrics!(456) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   def get_content_metrics!(id) do | ||||
|     Repo.get!(ContentMetrics, id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a content_metrics. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_content_metrics(content_metrics, %{field: new_value}) | ||||
|       {:ok, %ContentMetrics{}} | ||||
|  | ||||
|       iex> update_content_metrics(content_metrics, %{field: bad_value}) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   def update_content_metrics(%ContentMetrics{} = content_metrics, attrs) do | ||||
|     content_metrics | ||||
|     |> ContentMetrics.changeset(attrs) | ||||
|     |> Repo.update() | ||||
|   end | ||||
|  | ||||
|   def create_appearance(attrs \\ %{}) do | ||||
|     %Appearance{} | ||||
|     |> Appearance.changeset(attrs) | ||||
|     |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   def change_appearance(%Appearance{} = appearance, attrs \\ %{}) do | ||||
|     Appearance.changeset(appearance, attrs) | ||||
|   end | ||||
|  | ||||
|   def create_product_review(attrs \\ %{}) do | ||||
|     %ProductReview{} | ||||
|     |> ProductReview.changeset(attrs) | ||||
|     |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   def change_product_review(%ProductReview{} = product_review, attrs \\ %{}) do | ||||
|     ProductReview.changeset(product_review, attrs) | ||||
|   end | ||||
|  | ||||
|   def list_appearances(ad) do | ||||
|     Appearance | ||||
|     |> where(ad_id: ^ad.id) | ||||
|     |> preload(video: :user) | ||||
|     |> Repo.all() | ||||
|   end | ||||
|  | ||||
|   def list_product_reviews(ad) do | ||||
|     ProductReview | ||||
|     |> where(ad_id: ^ad.id) | ||||
|     |> preload(video: :user) | ||||
|     |> Repo.all() | ||||
|   end | ||||
|  | ||||
|   def list_content_metrics(appearances) do | ||||
|     video_ids = Enum.map(appearances, & &1.video_id) | ||||
|  | ||||
|     ContentMetrics | ||||
|     |> where([cm], cm.video_id in ^video_ids) | ||||
|     |> Repo.all() | ||||
|   end | ||||
| end | ||||
							
								
								
									
										60
									
								
								lib/algora/ads/ad.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								lib/algora/ads/ad.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| defmodule Algora.Ads.Ad do | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|  | ||||
|   schema "ads" do | ||||
|     field :slug, :string | ||||
|     field :name, :string | ||||
|     field :status, Ecto.Enum, values: [:inactive, :active] | ||||
|     field :verified, :boolean, default: false | ||||
|     field :website_url, :string | ||||
|     field :composite_asset_url, :string | ||||
|     field :asset_url, :string | ||||
|     field :logo_url, :string | ||||
|     field :qrcode_url, :string | ||||
|     field :og_image_url, :string | ||||
|     field :start_date, :naive_datetime | ||||
|     field :end_date, :naive_datetime | ||||
|     field :total_budget, :integer | ||||
|     field :daily_budget, :integer | ||||
|     field :tech_stack, {:array, :string} | ||||
|     field :user_id, :id | ||||
|     field :border_color, :string | ||||
|     field :scheduled_for, :utc_datetime, virtual: true | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   def changeset(ad, attrs) do | ||||
|     ad | ||||
|     |> cast(attrs, [ | ||||
|       :slug, | ||||
|       :name, | ||||
|       :verified, | ||||
|       :website_url, | ||||
|       :composite_asset_url, | ||||
|       :asset_url, | ||||
|       :logo_url, | ||||
|       :qrcode_url, | ||||
|       :og_image_url, | ||||
|       :start_date, | ||||
|       :end_date, | ||||
|       :total_budget, | ||||
|       :daily_budget, | ||||
|       :tech_stack, | ||||
|       :status, | ||||
|       :border_color | ||||
|     ]) | ||||
|     |> validate_required([ | ||||
|       :slug, | ||||
|       :website_url, | ||||
|       :composite_asset_url, | ||||
|       :border_color | ||||
|     ]) | ||||
|     |> validate_format(:border_color, ~r/^#([0-9A-F]{3}){1,2}$/i, | ||||
|       message: "must be a valid hex color code" | ||||
|     ) | ||||
|     |> unique_constraint(:slug) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										23
									
								
								lib/algora/ads/appearance.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								lib/algora/ads/appearance.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| defmodule Algora.Ads.Appearance do | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Algora.Library.Video | ||||
|   alias Algora.Ads.Ad | ||||
|  | ||||
|   schema "ad_appearances" do | ||||
|     field :airtime, :integer | ||||
|  | ||||
|     belongs_to :ad, Ad | ||||
|     belongs_to :video, Video | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   def changeset(appearance, attrs) do | ||||
|     appearance | ||||
|     |> cast(attrs, [:airtime, :ad_id, :video_id]) | ||||
|     |> validate_required([:airtime, :ad_id, :video_id]) | ||||
|     |> foreign_key_constraint(:ad_id) | ||||
|     |> foreign_key_constraint(:video_id) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										37
									
								
								lib/algora/ads/content_metrics.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								lib/algora/ads/content_metrics.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| defmodule Algora.Ads.ContentMetrics do | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Algora.Library.Video | ||||
|  | ||||
|   schema "content_metrics" do | ||||
|     field :algora_stream_url, :string | ||||
|     field :twitch_stream_url, :string | ||||
|     field :youtube_video_url, :string | ||||
|     field :twitter_video_url, :string | ||||
|     field :twitch_avg_concurrent_viewers, :integer | ||||
|     field :twitch_views, :integer | ||||
|     field :youtube_views, :integer | ||||
|     field :twitter_views, :integer | ||||
|  | ||||
|     belongs_to :video, Video | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   def changeset(content_metrics, attrs) do | ||||
|     content_metrics | ||||
|     |> cast(attrs, [ | ||||
|       :algora_stream_url, | ||||
|       :twitch_stream_url, | ||||
|       :youtube_video_url, | ||||
|       :twitter_video_url, | ||||
|       :twitch_avg_concurrent_viewers, | ||||
|       :twitch_views, | ||||
|       :youtube_views, | ||||
|       :twitter_views, | ||||
|       :video_id | ||||
|     ]) | ||||
|     |> validate_required([:video_id]) | ||||
|     |> foreign_key_constraint(:video_id) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										13
									
								
								lib/algora/ads/events.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								lib/algora/ads/events.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| defmodule Algora.Ads.Events do | ||||
|   defmodule AdCreated do | ||||
|     defstruct ad: nil | ||||
|   end | ||||
|  | ||||
|   defmodule AdUpdated do | ||||
|     defstruct ad: nil | ||||
|   end | ||||
|  | ||||
|   defmodule AdDeleted do | ||||
|     defstruct ad: nil | ||||
|   end | ||||
| end | ||||
							
								
								
									
										20
									
								
								lib/algora/ads/impression.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								lib/algora/ads/impression.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| defmodule Algora.Ads.Impression do | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|  | ||||
|   schema "ad_impressions" do | ||||
|     field :duration, :integer | ||||
|     field :viewers_count, :integer | ||||
|     field :ad_id, :id | ||||
|     field :video_id, :id | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   def changeset(impression, attrs) do | ||||
|     impression | ||||
|     |> cast(attrs, [:duration, :viewers_count, :ad_id, :video_id]) | ||||
|     |> validate_required([:duration, :viewers_count, :ad_id]) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										25
									
								
								lib/algora/ads/product_review.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								lib/algora/ads/product_review.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| defmodule Algora.Ads.ProductReview do | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Algora.Library.Video | ||||
|   alias Algora.Ads.Ad | ||||
|  | ||||
|   schema "product_reviews" do | ||||
|     field :clip_from, :integer | ||||
|     field :clip_to, :integer | ||||
|     field :thumbnail_url, :string | ||||
|  | ||||
|     belongs_to :ad, Ad | ||||
|     belongs_to :video, Video | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   def changeset(product_review, attrs) do | ||||
|     product_review | ||||
|     |> cast(attrs, [:clip_from, :clip_to, :thumbnail_url, :ad_id, :video_id]) | ||||
|     |> validate_required([:clip_from, :clip_to, :ad_id, :video_id]) | ||||
|     |> foreign_key_constraint(:ad_id) | ||||
|     |> foreign_key_constraint(:video_id) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										18
									
								
								lib/algora/ads/visit.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								lib/algora/ads/visit.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| defmodule Algora.Ads.Visit do | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|  | ||||
|   schema "ad_visits" do | ||||
|     field :ad_id, :id | ||||
|     field :video_id, :id | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   def changeset(visit, attrs) do | ||||
|     visit | ||||
|     |> cast(attrs, [:ad_id, :video_id]) | ||||
|     |> validate_required([:ad_id]) | ||||
|   end | ||||
| end | ||||
| @@ -3,10 +3,10 @@ defmodule Algora.Clipper do | ||||
|  | ||||
|   defp bucket(), do: Algora.config([:buckets, :media]) | ||||
|  | ||||
|   defp to_absolute(:video, uuid, uri), | ||||
|   def to_absolute(:video, uuid, uri), | ||||
|     do: "#{Storage.endpoint_url()}/#{bucket()}/#{uuid}/#{uri}" | ||||
|  | ||||
|   defp to_absolute(:clip, uuid, uri), | ||||
|   def to_absolute(:clip, uuid, uri), | ||||
|     do: "#{Storage.endpoint_url()}/#{bucket()}/clips/#{uuid}/#{uri}" | ||||
|  | ||||
|   def clip(video, from, to) do | ||||
|   | ||||
							
								
								
									
										104
									
								
								lib/algora/contact.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								lib/algora/contact.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | ||||
| defmodule Algora.Contact do | ||||
|   @moduledoc """ | ||||
|   The Contact context. | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Algora.Repo | ||||
|  | ||||
|   alias Algora.Contact.Info | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of contact_info. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_contact_info() | ||||
|       [%Info{}, ...] | ||||
|  | ||||
|   """ | ||||
|   def list_contact_info do | ||||
|     Repo.all(Info) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single info. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Info does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_info!(123) | ||||
|       %Info{} | ||||
|  | ||||
|       iex> get_info!(456) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   def get_info!(id), do: Repo.get!(Info, id) | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a info. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> create_info(%{field: value}) | ||||
|       {:ok, %Info{}} | ||||
|  | ||||
|       iex> create_info(%{field: bad_value}) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   def create_info(attrs \\ %{}) do | ||||
|     %Info{} | ||||
|     |> Info.changeset(attrs) | ||||
|     |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a info. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_info(info, %{field: new_value}) | ||||
|       {:ok, %Info{}} | ||||
|  | ||||
|       iex> update_info(info, %{field: bad_value}) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   def update_info(%Info{} = info, attrs) do | ||||
|     info | ||||
|     |> Info.changeset(attrs) | ||||
|     |> Repo.update() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a info. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_info(info) | ||||
|       {:ok, %Info{}} | ||||
|  | ||||
|       iex> delete_info(info) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   def delete_info(%Info{} = info) do | ||||
|     Repo.delete(info) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Ecto.Changeset{}` for tracking info changes. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_info(info) | ||||
|       %Ecto.Changeset{data: %Info{}} | ||||
|  | ||||
|   """ | ||||
|   def change_info(%Info{} = info, attrs \\ %{}) do | ||||
|     Info.changeset(info, attrs) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										20
									
								
								lib/algora/contact/info.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								lib/algora/contact/info.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| defmodule Algora.Contact.Info do | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|  | ||||
|   schema "contact_info" do | ||||
|     field :email, :string | ||||
|     field :website_url, :string | ||||
|     field :revenue, :string | ||||
|     field :company_location, :string | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   def changeset(info, attrs) do | ||||
|     info | ||||
|     |> cast(attrs, [:email, :website_url, :revenue, :company_location]) | ||||
|     |> validate_required([:email]) | ||||
|   end | ||||
| end | ||||
| @@ -355,6 +355,23 @@ defmodule Algora.Library do | ||||
|     to_hhmmss(trunc(duration)) | ||||
|   end | ||||
|  | ||||
|   def from_hhmmss(timestamp) do | ||||
|     case String.split(timestamp, ":") do | ||||
|       [hours, minutes, seconds] -> | ||||
|         String.to_integer(hours) * 3600 + String.to_integer(minutes) * 60 + | ||||
|           String.to_integer(seconds) | ||||
|  | ||||
|       [minutes, seconds] -> | ||||
|         String.to_integer(minutes) * 60 + String.to_integer(seconds) | ||||
|  | ||||
|       [seconds] -> | ||||
|         String.to_integer(seconds) | ||||
|  | ||||
|       _ -> | ||||
|         raise ArgumentError, "Invalid time format. Expected hh:mm:ss" | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def unsubscribe_to_channel(%Channel{} = channel) do | ||||
|     Phoenix.PubSub.unsubscribe(@pubsub, topic(channel.user_id)) | ||||
|   end | ||||
| @@ -557,6 +574,21 @@ defmodule Algora.Library do | ||||
|     |> Repo.all() | ||||
|   end | ||||
|  | ||||
|   def list_all_videos(limit \\ 100) do | ||||
|     from(v in Video, | ||||
|       join: u in User, | ||||
|       on: v.user_id == u.id, | ||||
|       limit: ^limit, | ||||
|       select_merge: %{ | ||||
|         channel_handle: u.handle, | ||||
|         channel_name: u.name, | ||||
|         channel_avatar_url: u.avatar_url | ||||
|       } | ||||
|     ) | ||||
|     |> order_by_inserted(:desc) | ||||
|     |> Repo.all() | ||||
|   end | ||||
|  | ||||
|   def list_livestreams(limit \\ 100) do | ||||
|     from(v in Video, | ||||
|       join: u in User, | ||||
|   | ||||
| @@ -7,6 +7,7 @@ defmodule Algora.Library.Video do | ||||
|   alias Algora.Library.Video | ||||
|   alias Algora.Shows.Show | ||||
|   alias Algora.Chat.Message | ||||
|   alias Algora.Ads.{Appearance, ContentMetrics, ProductReview} | ||||
|  | ||||
|   @type t() :: %__MODULE__{} | ||||
|  | ||||
| @@ -37,6 +38,9 @@ defmodule Algora.Library.Video do | ||||
|     belongs_to :transmuxed_from, Video | ||||
|  | ||||
|     has_many :messages, Message | ||||
|     has_many :appearances, Appearance | ||||
|     has_many :content_metrics, ContentMetrics | ||||
|     has_many :product_reviews, ProductReview | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|   | ||||
| @@ -54,6 +54,12 @@ | ||||
|     </script> | ||||
|     <script defer data-domain="tv.algora.io" src="https://plausible.io/js/script.js"> | ||||
|     </script> | ||||
|     <link rel="preconnect" href="https://fonts.googleapis.com" /> | ||||
|     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | ||||
|     <link | ||||
|       href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" | ||||
|       rel="stylesheet" | ||||
|     /> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div class="fixed inset-0 bg-[radial-gradient(ellipse_at_top_left,_#1d1e3a_0%,_#050217_40%,_#050217_60%,_#1d1e3a_100%)] z-0"> | ||||
|   | ||||
| @@ -54,6 +54,12 @@ | ||||
|     </script> | ||||
|     <script defer data-domain="tv.algora.io" src="https://plausible.io/js/script.js"> | ||||
|     </script> | ||||
|     <link rel="preconnect" href="https://fonts.googleapis.com" /> | ||||
|     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | ||||
|     <link | ||||
|       href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" | ||||
|       rel="stylesheet" | ||||
|     /> | ||||
|   </head> | ||||
|   <body> | ||||
|     <%= @inner_content %> | ||||
|   | ||||
							
								
								
									
										40
									
								
								lib/algora_web/components/tech_icon.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								lib/algora_web/components/tech_icon.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| defmodule AlgoraWeb.Components.TechIcon do | ||||
|   use Phoenix.Component | ||||
|  | ||||
|   def tech_icon(assigns) do | ||||
|     ~H""" | ||||
|     <svg | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|       width="24" | ||||
|       height="24" | ||||
|       viewBox="0 0 24 24" | ||||
|       fill="none" | ||||
|       stroke="currentColor" | ||||
|       stroke-width="2" | ||||
|       stroke-linecap="round" | ||||
|       stroke-linejoin="round" | ||||
|     > | ||||
|       <%= case @tech do %> | ||||
|         <% "TypeScript" -> %> | ||||
|           <path stroke="none" d="M0 0h24v24H0z" fill="none" /> | ||||
|           <path d="M15 17.5c.32 .32 .754 .5 1.207 .5h.543c.69 0 1.25 -.56 1.25 -1.25v-.25a1.5 1.5 0 0 0 -1.5 -1.5a1.5 1.5 0 0 1 -1.5 -1.5v-.25c0 -.69 .56 -1.25 1.25 -1.25h.543c.453 0 .887 .18 1.207 .5" /> | ||||
|           <path d="M9 12h4" /> | ||||
|           <path d="M11 12v6" /> | ||||
|           <path d="M21 19v-14a2 2 0 0 0 -2 -2h-14a2 2 0 0 0 -2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2 -2z" /> | ||||
|         <% "PHP" -> %> | ||||
|           <path stroke="none" d="M0 0h24v24H0z" fill="none" /> | ||||
|           <path d="M12 12m-10 0a10 9 0 1 0 20 0a10 9 0 1 0 -20 0" /> | ||||
|           <path d="M5.5 15l.395 -1.974l.605 -3.026h1.32a1 1 0 0 1 .986 1.164l-.167 1a1 1 0 0 1 -.986 .836h-1.653" /> | ||||
|           <path d="M15.5 15l.395 -1.974l.605 -3.026h1.32a1 1 0 0 1 .986 1.164l-.167 1a1 1 0 0 1 -.986 .836h-1.653" /> | ||||
|           <path d="M12 7.5l-1 5.5" /> | ||||
|           <path d="M11.6 10h2.4l-.5 3" /> | ||||
|         <% _ -> %> | ||||
|           <path stroke="none" d="M0 0h24v24H0z" fill="none" /> | ||||
|           <path d="M7 8l-4 4l4 4" /> | ||||
|           <path d="M17 8l4 4l-4 4" /> | ||||
|           <path d="M14 4l-4 16" /> | ||||
|       <% end %> | ||||
|     </svg> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
							
								
								
									
										15
									
								
								lib/algora_web/controllers/ad_redirect_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								lib/algora_web/controllers/ad_redirect_controller.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| defmodule AlgoraWeb.AdRedirectController do | ||||
|   use AlgoraWeb, :controller | ||||
|   alias Algora.Ads | ||||
|  | ||||
|   def go(conn, %{"slug" => slug}) do | ||||
|     ad = Ads.get_ad_by_slug!(slug) | ||||
|  | ||||
|     ## TODO: log errors | ||||
|     Ads.track_visit(%{ad_id: ad.id}) | ||||
|  | ||||
|     conn | ||||
|     |> put_status(:found) | ||||
|     |> redirect(external: ad.website_url) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										174
									
								
								lib/algora_web/live/ad_live/analytics.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								lib/algora_web/live/ad_live/analytics.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,174 @@ | ||||
| defmodule AlgoraWeb.AdLive.Analytics do | ||||
|   use AlgoraWeb, :live_view | ||||
|   alias AlgoraWeb.Components.TechIcon | ||||
|   alias AlgoraWeb.RTMPDestinationIconComponent | ||||
|  | ||||
|   alias Algora.{Ads, Library} | ||||
|   alias AlgoraWeb.PlayerComponent | ||||
|  | ||||
|   @impl true | ||||
|   def mount(%{"slug" => slug}, _session, socket) do | ||||
|     ad = Ads.get_ad_by_slug!(slug) | ||||
|  | ||||
|     %{ | ||||
|       stats: stats, | ||||
|       appearances: appearances, | ||||
|       product_reviews: product_reviews, | ||||
|       product_review: product_review | ||||
|     } = fetch_ad_stats(ad) | ||||
|  | ||||
|     blurb = | ||||
|       if product_review, | ||||
|         do: %{ | ||||
|           video: Library.get_video!(product_review.video_id), | ||||
|           current_time: product_review.clip_from | ||||
|         } | ||||
|  | ||||
|     if connected?(socket) do | ||||
|       if blurb do | ||||
|         send_update(PlayerComponent, %{ | ||||
|           id: "analytics-player", | ||||
|           video: blurb.video, | ||||
|           current_user: socket.assigns.current_user, | ||||
|           current_time: blurb.current_time | ||||
|         }) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     {:ok, | ||||
|      socket | ||||
|      |> assign(ad: ad) | ||||
|      |> assign(stats: stats) | ||||
|      |> assign(appearances: appearances) | ||||
|      |> assign(product_review: product_review) | ||||
|      |> assign(product_reviews: product_reviews) | ||||
|      |> assign(blurb: blurb)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_info(_arg, socket) do | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(_params, _url, socket) do | ||||
|     socket = | ||||
|       cond do | ||||
|         socket.assigns.ad.og_image_url -> | ||||
|           assign(socket, :page_image, socket.assigns.ad.og_image_url) | ||||
|  | ||||
|         socket.assigns.product_review -> | ||||
|           assign(socket, :page_image, socket.assigns.product_review.thumbnail_url) | ||||
|  | ||||
|         true -> | ||||
|           socket | ||||
|       end | ||||
|  | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> assign(:page_title, socket.assigns.ad.name) | ||||
|      |> assign( | ||||
|        :page_description, | ||||
|        "Discover the appearances of #{socket.assigns.ad.name} in livestreams and videos" | ||||
|      )} | ||||
|   end | ||||
|  | ||||
|   defp fetch_ad_stats(ad) do | ||||
|     appearances = Ads.list_appearances(ad) | ||||
|     content_metrics = Ads.list_content_metrics(appearances) | ||||
|  | ||||
|     product_reviews = | ||||
|       Ads.list_product_reviews(ad) |> Enum.sort_by(&(&1.clip_to - &1.clip_from), :desc) | ||||
|  | ||||
|     twitch_views = Enum.reduce(content_metrics, 0, fn cm, acc -> acc + cm.twitch_views end) | ||||
|     youtube_views = Enum.reduce(content_metrics, 0, fn cm, acc -> acc + cm.youtube_views end) | ||||
|     twitter_views = Enum.reduce(content_metrics, 0, fn cm, acc -> acc + cm.twitter_views end) | ||||
|  | ||||
|     tech_stack_data = | ||||
|       appearances | ||||
|       |> group_data_by_tech_stack(content_metrics) | ||||
|       |> Enum.sort_by(fn {_, d} -> d.views end, :desc) | ||||
|  | ||||
|     product_review = List.first(product_reviews) | ||||
|  | ||||
|     views = | ||||
|       %{ | ||||
|         "Twitch" => twitch_views, | ||||
|         "YouTube" => youtube_views, | ||||
|         "Twitter" => twitter_views | ||||
|       } | ||||
|       |> Enum.sort_by(fn {_, v} -> v end, :desc) | ||||
|  | ||||
|     %{ | ||||
|       stats: %{ | ||||
|         views: views, | ||||
|         total_views: twitch_views + youtube_views + twitter_views, | ||||
|         airtime: calculate_total_airtime(appearances), | ||||
|         streams: length(appearances), | ||||
|         creators: length(Enum.uniq_by(appearances, & &1.video.user.id)), | ||||
|         tech_stack_data: tech_stack_data | ||||
|       }, | ||||
|       appearances: appearances, | ||||
|       product_reviews: product_reviews, | ||||
|       product_review: product_review | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   defp group_data_by_tech_stack(appearances, content_metrics) do | ||||
|     appearances | ||||
|     |> Enum.zip(content_metrics) | ||||
|     |> Enum.reduce(%{}, fn {appearance, metrics}, acc -> | ||||
|       tech_stack = get_tech_stack(appearance.video.user.id) | ||||
|       total_views = metrics.twitch_views + metrics.youtube_views + metrics.twitter_views | ||||
|       creator = appearance.video.user | ||||
|  | ||||
|       Map.update(acc, tech_stack, %{views: total_views, creators: [creator]}, fn existing -> | ||||
|         %{ | ||||
|           views: existing.views + total_views, | ||||
|           creators: [creator | existing.creators] |> Enum.uniq() | ||||
|         } | ||||
|       end) | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   # TODO: This is a hack, we need to get the tech stack from the user's profile | ||||
|   defp get_tech_stack(user_id) do | ||||
|     case user_id do | ||||
|       7 -> "TypeScript" | ||||
|       109 -> "TypeScript" | ||||
|       307 -> "PHP" | ||||
|       _ -> "Other" | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp calculate_total_airtime(appearances) do | ||||
|     appearances | ||||
|     |> Enum.reduce(0, fn appearance, acc -> acc + appearance.airtime end) | ||||
|     |> format_duration() | ||||
|   end | ||||
|  | ||||
|   defp format_duration(seconds) do | ||||
|     hours = div(seconds, 3600) | ||||
|     minutes = div(rem(seconds, 3600), 60) | ||||
|     remaining_seconds = rem(seconds, 60) | ||||
|  | ||||
|     cond do | ||||
|       hours > 0 -> "#{hours}h #{minutes}m #{remaining_seconds}s" | ||||
|       minutes > 0 -> "#{minutes}m #{remaining_seconds}s" | ||||
|       true -> "#{remaining_seconds}s" | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp format_number(number) when number >= 1_000_000 do | ||||
|     :io_lib.format("~.1fM", [number / 1_000_000]) |> to_string() | ||||
|   end | ||||
|  | ||||
|   defp format_number(number) when number >= 1_000 do | ||||
|     :io_lib.format("~.1fK", [number / 1_000]) |> to_string() | ||||
|   end | ||||
|  | ||||
|   defp format_number(number), do: to_string(number) | ||||
|  | ||||
|   defp tech_icon(assigns), do: TechIcon.tech_icon(assigns) | ||||
|   defp source_icon(assigns), do: RTMPDestinationIconComponent.icon(assigns) | ||||
| end | ||||
							
								
								
									
										311
									
								
								lib/algora_web/live/ad_live/analytics.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										311
									
								
								lib/algora_web/live/ad_live/analytics.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,311 @@ | ||||
| <div class="min-h-screen text-white max-w-7xl mx-auto font-display"> | ||||
|   <header class="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-4 px-4 gap-4"> | ||||
|     <div class="flex items-center gap-2 text-lg font-semibold"> | ||||
|       <img src={@ad.logo_url} class="h-12 w-12 rounded-full" /> | ||||
|       <a | ||||
|         href={@ad.website_url |> String.replace(~r/[?#].*$/, "")} | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|         class="truncate max-w-[300px]" | ||||
|       > | ||||
|         <span> | ||||
|           <%= @ad.website_url | ||||
|           |> String.replace(~r/[?#].*$/, "") | ||||
|           |> String.replace(~r/^https?:\/\//, "") | ||||
|           |> String.replace(~r/\/$/, "") %> | ||||
|         </span> | ||||
|       </a> | ||||
|     </div> | ||||
|     <img | ||||
|       src={@ad.composite_asset_url} | ||||
|       alt={@ad.website_url} | ||||
|       class="box-content max-h-16 max-w-screen aspect-[1092/135] object-cover border-[3px] rounded-md lg:rounded-xl" | ||||
|       style={"border-color: #{@ad.border_color || "#fff"}"} | ||||
|     /> | ||||
|     <div class="shrink-0 hidden sm:flex items-center gap-4"> | ||||
|       <div class="flex items-center gap-2"> | ||||
|         <button | ||||
|           disabled | ||||
|           class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 bg-white/5 ring-1 ring-white/10 text-white" | ||||
|         > | ||||
|           Edit | ||||
|         </button> | ||||
|         <button | ||||
|           disabled | ||||
|           type="button" | ||||
|           role="combobox" | ||||
|           aria-controls="radix-:r7:" | ||||
|           aria-expanded="false" | ||||
|           aria-autocomplete="none" | ||||
|           dir="ltr" | ||||
|           data-state="closed" | ||||
|           data-placeholder="" | ||||
|           class="flex h-10 w-full items-center justify-between rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 bg-white/5 ring-1 ring-white/10 text-white" | ||||
|         > | ||||
|           <span style="pointer-events: none;" class="mr-1">Last 30 days</span> | ||||
|           <svg | ||||
|             xmlns="http://www.w3.org/2000/svg" | ||||
|             width="24" | ||||
|             height="24" | ||||
|             viewBox="0 0 24 24" | ||||
|             fill="none" | ||||
|             stroke="currentColor" | ||||
|             stroke-width="2" | ||||
|             stroke-linecap="round" | ||||
|             stroke-linejoin="round" | ||||
|             class="lucide lucide-chevron-down h-4 w-4 opacity-50" | ||||
|             aria-hidden="true" | ||||
|           > | ||||
|             <path d="m6 9 6 6 6-6"></path> | ||||
|           </svg> | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </header> | ||||
|   <main class="space-y-4 px-4"> | ||||
|     <div | ||||
|       class="rounded-lg lg:rounded-2xl border text-card-foreground shadow-inner bg-white/5 ring-1 ring-white/10" | ||||
|       data-v0-t="card" | ||||
|     > | ||||
|       <div class="flex p-6 justify-center items-center"> | ||||
|         <div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center"> | ||||
|           <div> | ||||
|             <p class="text-xs text-gray-300 tracking-tight font-bold uppercase">Views</p> | ||||
|             <p class="text-2xl font-bold"><%= format_number(@stats.total_views) %></p> | ||||
|           </div> | ||||
|           <div> | ||||
|             <p class="text-xs text-gray-300 tracking-tight font-bold uppercase">Airtime</p> | ||||
|             <p class="text-2xl font-bold"><%= @stats.airtime %></p> | ||||
|           </div> | ||||
|           <div> | ||||
|             <p class="text-xs text-gray-300 tracking-tight font-bold uppercase">Streams</p> | ||||
|             <p class="text-2xl font-bold"><%= @stats.streams %></p> | ||||
|           </div> | ||||
|           <div> | ||||
|             <p class="text-xs text-gray-300 tracking-tight font-bold uppercase">Creators</p> | ||||
|             <p class="text-2xl font-bold"><%= @stats.creators %></p> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="grid sm:grid-cols-2 gap-4"> | ||||
|       <div | ||||
|         class="rounded-lg lg:rounded-2xl border text-card-foreground shadow-inner bg-white/5 ring-1 ring-white/10 p-6" | ||||
|         data-v0-t="card" | ||||
|       > | ||||
|         <h3 class="whitespace-nowrap text-xl font-semibold leading-none tracking-tight mb-3 ml-3"> | ||||
|           Top Sources | ||||
|         </h3> | ||||
|         <div> | ||||
|           <div class="relative w-full overflow-auto"> | ||||
|             <table class="w-full caption-bottom text-sm"> | ||||
|               <thead class="[&_tr]:border-b"> | ||||
|                 <tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> | ||||
|                   <th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0"> | ||||
|                     Source | ||||
|                   </th> | ||||
|                   <th class="h-12 px-4 align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 text-right"> | ||||
|                     Views | ||||
|                   </th> | ||||
|                 </tr> | ||||
|               </thead> | ||||
|               <tbody class="[&_tr:last-child]:border-0"> | ||||
|                 <%= for {source, views} <- @stats.views do %> | ||||
|                   <%= if views > 0 do %> | ||||
|                     <tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> | ||||
|                       <td class="p-4 align-middle [&:has([role=checkbox])]:pr-0 flex items-center gap-2"> | ||||
|                         <.source_icon icon={source |> String.downcase() |> String.to_atom()} /> | ||||
|                         <%= source %> | ||||
|                       </td> | ||||
|                       <td class="p-4 align-middle [&:has([role=checkbox])]:pr-0 text-right"> | ||||
|                         <%= format_number(views) %> | ||||
|                       </td> | ||||
|                     </tr> | ||||
|                   <% end %> | ||||
|                 <% end %> | ||||
|               </tbody> | ||||
|             </table> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div | ||||
|         class="rounded-lg lg:rounded-2xl border text-card-foreground shadow-inner bg-white/5 ring-1 ring-white/10 p-6" | ||||
|         data-v0-t="card" | ||||
|       > | ||||
|         <h3 class="whitespace-nowrap text-xl font-semibold leading-none tracking-tight mb-3 ml-3"> | ||||
|           Top Languages | ||||
|         </h3> | ||||
|         <div> | ||||
|           <div class="relative w-full overflow-auto"> | ||||
|             <table class="w-full caption-bottom text-sm"> | ||||
|               <thead class="[&_tr]:border-b"> | ||||
|                 <tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> | ||||
|                   <th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0"> | ||||
|                     Language | ||||
|                   </th> | ||||
|                   <th class="h-12 px-4 align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 text-center"> | ||||
|                     Creators | ||||
|                   </th> | ||||
|                   <th class="h-12 px-4 align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 text-right"> | ||||
|                     Views | ||||
|                   </th> | ||||
|                 </tr> | ||||
|               </thead> | ||||
|               <tbody class="[&_tr:last-child]:border-0"> | ||||
|                 <%= for {tech, data} <- @stats.tech_stack_data do %> | ||||
|                   <tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> | ||||
|                     <td class="p-4 align-middle [&:has([role=checkbox])]:pr-0 flex items-center gap-2"> | ||||
|                       <.tech_icon tech={tech} /> <%= tech %> | ||||
|                     </td> | ||||
|                     <td class="align-middle [&:has([role=checkbox])]:pr-0"> | ||||
|                       <div class="flex items-center justify-center -space-x-1"> | ||||
|                         <%= for creator <- Enum.take(data.creators, 5) do %> | ||||
|                           <img | ||||
|                             class="inline-block h-8 w-8 rounded-full ring-4 ring-[#16112f]" | ||||
|                             src={creator.avatar_url} | ||||
|                             alt={"Avatar of #{creator.name}"} | ||||
|                           /> | ||||
|                         <% end %> | ||||
|                       </div> | ||||
|                     </td> | ||||
|                     <td class="p-4 align-middle [&:has([role=checkbox])]:pr-0 text-right"> | ||||
|                       <%= format_number(data.views) %> | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                 <% end %> | ||||
|               </tbody> | ||||
|             </table> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div :if={@blurb.video} class="flex flex-col sm:flex-row items-center justify-between gap-4"> | ||||
|       <div class="w-full cursor-pointer"> | ||||
|         <.live_component module={PlayerComponent} id="analytics-player" /> | ||||
|       </div> | ||||
|       <.link | ||||
|         href={"/#{@blurb.video.channel_handle}/#{@blurb.video.id}"} | ||||
|         class="hidden sm:block w-full max-w-sm p-6 bg-gray-800/40 hover:bg-gray-800/60 overflow-hidden rounded-lg lg:rounded-2xl shadow-inner shadow-white/[10%] lg:border border-white/[15%] hover:border/white/[20%]" | ||||
|       > | ||||
|         <div class="flex items-center gap-4"> | ||||
|           <div class="relative h-20 w-20 shrink-0"> | ||||
|             <img | ||||
|               src={@blurb.video.channel_avatar_url} | ||||
|               alt={@blurb.video.channel_handle} | ||||
|               class="w-full h-full p-1 rounded-full" | ||||
|             /> | ||||
|           </div> | ||||
|           <div> | ||||
|             <div class="text-3xl font-semibold"><%= @blurb.video.channel_name %></div> | ||||
|             <div class="font-medium text-gray-300">@<%= @blurb.video.channel_handle %></div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="pt-4 font-medium text-gray-100"><%= @blurb.video.title %></div> | ||||
|         <div class="pt-1 text-sm font-medium text-gray-300"> | ||||
|           Streamed on <%= @blurb.video.inserted_at |> Calendar.strftime("%b %d, %Y") %> | ||||
|         </div> | ||||
|       </.link> | ||||
|     </div> | ||||
|  | ||||
|     <div | ||||
|       class="rounded-lg lg:rounded-2xl border text-card-foreground shadow-inner bg-white/5 ring-1 ring-white/10 p-6" | ||||
|       data-v0-t="card" | ||||
|     > | ||||
|       <h3 class="whitespace-nowrap text-xl font-semibold leading-none tracking-tight mb-3 ml-3"> | ||||
|         Appearances | ||||
|       </h3> | ||||
|       <div> | ||||
|         <div class="relative w-full overflow-auto"> | ||||
|           <table class="w-full caption-bottom text-sm"> | ||||
|             <thead class="[&_tr]:border-b"> | ||||
|               <tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> | ||||
|                 <th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0"> | ||||
|                   Livestream | ||||
|                 </th> | ||||
|                 <th class="h-12 px-4 align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 text-right"> | ||||
|                   Airtime | ||||
|                 </th> | ||||
|               </tr> | ||||
|             </thead> | ||||
|             <tbody class="[&_tr:last-child]:border-0"> | ||||
|               <%= for product_review <- @product_reviews do %> | ||||
|                 <tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> | ||||
|                   <td class="p-4 align-middle [&:has([role=checkbox])]:pr-0"> | ||||
|                     <.link | ||||
|                       navigate={"/#{product_review.video.user.handle}/#{product_review.video.id}?t=#{product_review.clip_from}"} | ||||
|                       class="flex flex-col sm:flex-row items-start sm:items-center gap-2" | ||||
|                     > | ||||
|                       <img | ||||
|                         src={product_review.thumbnail_url} | ||||
|                         class="h-16 sm:h-24 object-cover aspect-video rounded-lg" | ||||
|                       /> | ||||
|                       <div class="space-y-1"> | ||||
|                         <div class="text-sm text-gray-100"> | ||||
|                           <%= product_review.video.user.name %> • <%= product_review.video.title %> | ||||
|                         </div> | ||||
|                         <div class="text-xs text-gray-300"> | ||||
|                           Streamed on <%= product_review.video.inserted_at | ||||
|                           |> Calendar.strftime("%b %d, %Y") %> | ||||
|                         </div> | ||||
|                         <div class="text-xs text-purple-300"> | ||||
|                           <%= Library.to_hhmmss(product_review.clip_from) %> - <%= Library.to_hhmmss( | ||||
|                             product_review.clip_to | ||||
|                           ) %> | ||||
|                         </div> | ||||
|                       </div> | ||||
|                     </.link> | ||||
|                   </td> | ||||
|                   <td class="p-4 align-middle [&:has([role=checkbox])]:pr-0 text-right"> | ||||
|                     <%= format_duration(product_review.clip_to - product_review.clip_from) %> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               <% end %> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </main> | ||||
|  | ||||
|   <div class="relative isolate overflow-hidden"> | ||||
|     <div class="px-6 py-12 sm:py-24 sm:px-6 lg:px-8"> | ||||
|       <div class="mx-auto max-w-4xl text-center"> | ||||
|         <h1 class="mt-10 text-4xl font-bold tracking-tight text-white sm:text-5xl"> | ||||
|           Your most successful <span class="text-green-300">ad campaign</span> | ||||
|           <br class="hidden sm:inline" />is just a few livestreams away | ||||
|         </h1> | ||||
|         <p class="mt-6 text-xl leading-8 tracking-tight text-gray-300"> | ||||
|           Stand out in front of millions on Twitch, YouTube and X with in-video livestream ads | ||||
|         </p> | ||||
|         <div class="mt-10 flex items-center justify-center gap-x-6"> | ||||
|           <.link | ||||
|             href="https://cal.com/ioannisflo" | ||||
|             class="rounded-md bg-white px-3.5 py-2.5 text-2xl font-semibold text-gray-900 shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" | ||||
|           > | ||||
|             Let's talk | ||||
|           </.link> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <svg | ||||
|       viewBox="0 0 1024 1024" | ||||
|       class="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-x-1/2 [mask-image:radial-gradient(closest-side,white,transparent)]" | ||||
|       aria-hidden="true" | ||||
|     > | ||||
|       <circle | ||||
|         cx="512" | ||||
|         cy="512" | ||||
|         r="512" | ||||
|         fill="url(#8d958450-c69f-4251-94bc-4e091a323369)" | ||||
|         fill-opacity="0.7" | ||||
|       /> | ||||
|       <defs> | ||||
|         <radialGradient id="8d958450-c69f-4251-94bc-4e091a323369"> | ||||
|           <stop stop-color="#6366f1" /> | ||||
|           <stop offset="1" stop-color="#3b82f6" /> | ||||
|         </radialGradient> | ||||
|       </defs> | ||||
|     </svg> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										625
									
								
								lib/algora_web/live/ad_live/content_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										625
									
								
								lib/algora_web/live/ad_live/content_live.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,625 @@ | ||||
| defmodule AlgoraWeb.ContentLive do | ||||
|   use AlgoraWeb, :live_view | ||||
|  | ||||
|   import Ecto.Changeset | ||||
|  | ||||
|   alias Algora.Ads | ||||
|   alias Algora.Ads.{ContentMetrics, Appearance, ProductReview} | ||||
|   alias Algora.Library | ||||
|   alias AlgoraWeb.LayoutComponent | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket) do | ||||
|     content_metrics = Ads.list_content_metrics() | ||||
|     ads = Ads.list_ads() | ||||
|     videos = Library.list_all_videos(1000) | ||||
|  | ||||
|     {:ok, | ||||
|      socket | ||||
|      |> assign(:ads, ads) | ||||
|      |> assign(:videos, videos) | ||||
|      |> assign(:content_metrics, content_metrics) | ||||
|      |> assign(:new_content_metrics_form, to_form(Ads.change_content_metrics(%ContentMetrics{}))) | ||||
|      |> assign(:new_appearance_form, to_form(Ads.change_appearance(%Appearance{}))) | ||||
|      |> assign(:new_product_review_form, to_form(Ads.change_product_review(%ProductReview{}))) | ||||
|      |> assign(:show_appearance_modal, false) | ||||
|      |> assign(:show_product_review_modal, false) | ||||
|      |> assign(:show_content_metrics_modal, false) | ||||
|      |> assign(:editing_content_metrics, nil) | ||||
|      |> assign(:uploaded_files, []) | ||||
|      |> allow_upload(:product_review_thumbnail, | ||||
|        accept: accept(), | ||||
|        max_file_size: max_file_size() * 1_000_000, | ||||
|        max_entries: 1, | ||||
|        auto_upload: true, | ||||
|        progress: &handle_progress/3 | ||||
|      )} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div class="max-w-5xl mx-auto space-y-6 p-6"> | ||||
|       <%= for content_metric <- @content_metrics do %> | ||||
|         <div class="bg-white/5 p-6 ring-1 ring-white/15 rounded-lg space-y-4"> | ||||
|           <div class="flex justify-between items-start"> | ||||
|             <div> | ||||
|               <div class="text-lg font-semibold"><%= content_metric.video.title %></div> | ||||
|               <div class="text-sm text-gray-400"> | ||||
|                 <%= Calendar.strftime(content_metric.video.inserted_at, "%b %d, %Y, %I:%M %p UTC") %> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="flex items-center gap-6 text-sm font-display"> | ||||
|               <.link href={content_metric.twitch_stream_url} class="flex items-center gap-2"> | ||||
|                 <svg | ||||
|                   xmlns="http://www.w3.org/2000/svg" | ||||
|                   width="24" | ||||
|                   height="24" | ||||
|                   viewBox="0 0 24 24" | ||||
|                   fill="none" | ||||
|                   stroke="currentColor" | ||||
|                   stroke-width="2" | ||||
|                   stroke-linecap="round" | ||||
|                   stroke-linejoin="round" | ||||
|                   class="icon icon-tabler icons-tabler-outline icon-tabler-brand-twitch" | ||||
|                 > | ||||
|                   <path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 5v11a1 1 0 0 0 1 1h2v4l4 -4h5.584c.266 0 .52 -.105 .707 -.293l2.415 -2.414c.187 -.188 .293 -.442 .293 -.708v-8.585a1 1 0 0 0 -1 -1h-14a1 1 0 0 0 -1 1z" /><path d="M16 8l0 4" /><path d="M12 8l0 4" /> | ||||
|                 </svg> | ||||
|                 <%= content_metric.twitch_avg_concurrent_viewers || 0 %> CCV / <%= content_metric.twitch_views || | ||||
|                   0 %> Views | ||||
|               </.link> | ||||
|               <.link href={content_metric.youtube_video_url} class="flex items-center gap-2"> | ||||
|                 <svg | ||||
|                   xmlns="http://www.w3.org/2000/svg" | ||||
|                   width="24" | ||||
|                   height="24" | ||||
|                   viewBox="0 0 24 24" | ||||
|                   fill="none" | ||||
|                   stroke="currentColor" | ||||
|                   stroke-width="2" | ||||
|                   stroke-linecap="round" | ||||
|                   stroke-linejoin="round" | ||||
|                   class="icon icon-tabler icons-tabler-outline icon-tabler-brand-youtube" | ||||
|                 > | ||||
|                   <path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M2 8a4 4 0 0 1 4 -4h12a4 4 0 0 1 4 4v8a4 4 0 0 1 -4 4h-12a4 4 0 0 1 -4 -4v-8z" /><path d="M10 9l5 3l-5 3z" /> | ||||
|                 </svg> | ||||
|                 <%= content_metric.youtube_views || 0 %> Views | ||||
|               </.link> | ||||
|               <.link href={content_metric.twitter_video_url} class="flex items-center gap-2"> | ||||
|                 <svg | ||||
|                   xmlns="http://www.w3.org/2000/svg" | ||||
|                   width="24" | ||||
|                   height="24" | ||||
|                   viewBox="0 0 24 24" | ||||
|                   fill="none" | ||||
|                   stroke="currentColor" | ||||
|                   stroke-width="2" | ||||
|                   stroke-linecap="round" | ||||
|                   stroke-linejoin="round" | ||||
|                   class="icon icon-tabler icons-tabler-outline icon-tabler-brand-x" | ||||
|                 > | ||||
|                   <path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 4l11.733 16h4.267l-11.733 -16z" /><path d="M4 20l6.768 -6.768m2.46 -2.46l6.772 -6.772" /> | ||||
|                 </svg> | ||||
|                 <%= content_metric.twitter_views || 0 %> Views | ||||
|               </.link> | ||||
|             </div> | ||||
|           </div> | ||||
|           <table class="w-full ring-1 ring-white/5 bg-gray-950/40 rounded-lg"> | ||||
|             <thead> | ||||
|               <tr> | ||||
|                 <th class="text-sm px-6 py-3 text-left">Ad</th> | ||||
|                 <th class="text-sm px-6 py-3 text-right">Airtime</th> | ||||
|                 <th class="text-sm px-6 py-3 text-right">Blurb</th> | ||||
|                 <th class="text-sm px-6 py-3 text-left">Thumbnail</th> | ||||
|               </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|               <%= for ad_id <- Enum.uniq(Enum.map(content_metric.video.appearances, & &1.ad_id) ++ Enum.map(content_metric.video.product_reviews, & &1.ad_id)) do %> | ||||
|                 <% ad = Ads.get_ad!(ad_id) %> | ||||
|                 <tr> | ||||
|                   <td class="text-sm px-6 py-3"><%= ad.slug %></td> | ||||
|                   <td class="text-sm px-6 py-3 text-right tabular-nums"> | ||||
|                     <%= Enum.map_join( | ||||
|                       Enum.filter(content_metric.video.appearances, &(&1.ad_id == ad_id)), | ||||
|                       ", ", | ||||
|                       &Library.to_hhmmss(&1.airtime) | ||||
|                     ) %> | ||||
|                   </td> | ||||
|                   <td class="text-sm px-6 py-3 text-right tabular-nums"> | ||||
|                     <%= Enum.map_join( | ||||
|                       Enum.filter(content_metric.video.product_reviews, &(&1.ad_id == ad_id)), | ||||
|                       ", ", | ||||
|                       &"#{Library.to_hhmmss(&1.clip_from)} - #{Library.to_hhmmss(&1.clip_to)}" | ||||
|                     ) %> | ||||
|                   </td> | ||||
|                   <td class="text-sm px-6 py-3"> | ||||
|                     <%= Enum.map_join( | ||||
|                       Enum.filter(content_metric.video.product_reviews, &(&1.ad_id == ad_id)), | ||||
|                       ", ", | ||||
|                       & &1.thumbnail_url | ||||
|                     ) %> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               <% end %> | ||||
|             </tbody> | ||||
|           </table> | ||||
|  | ||||
|           <div class="flex space-x-4"> | ||||
|             <.button phx-click="open_appearance_modal" phx-value-video_id={content_metric.video_id}> | ||||
|               Add airtime | ||||
|             </.button> | ||||
|             <.button | ||||
|               phx-click="open_product_review_modal" | ||||
|               phx-value-video_id={content_metric.video_id} | ||||
|             > | ||||
|               Add blurb | ||||
|             </.button> | ||||
|             <.button phx-click="edit_content_metrics" phx-value-id={content_metric.id}> | ||||
|               Edit metrics | ||||
|             </.button> | ||||
|           </div> | ||||
|         </div> | ||||
|       <% end %> | ||||
|  | ||||
|       <div class="bg-white/5 p-6 ring-1 ring-white/15 rounded-lg"> | ||||
|         <.button phx-click="open_content_metrics_modal">Add content</.button> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <.modal | ||||
|       :if={@show_appearance_modal} | ||||
|       id="appearance-modal" | ||||
|       show | ||||
|       on_cancel={JS.patch(~p"/admin/content")} | ||||
|     > | ||||
|       <.header>Add Airtime</.header> | ||||
|       <.simple_form for={@new_appearance_form} phx-submit="save_appearance"> | ||||
|         <%= hidden_input(@new_appearance_form, :video_id) %> | ||||
|         <.input | ||||
|           field={@new_appearance_form[:ad_id]} | ||||
|           type="select" | ||||
|           label="Ad" | ||||
|           prompt="Select an ad" | ||||
|           options={Enum.map(@ads, fn ad -> {ad.slug, ad.id} end)} | ||||
|         /> | ||||
|         <.input field={@new_appearance_form[:airtime]} label="Airtime" placeholder="hh:mm:ss" /> | ||||
|         <div /> | ||||
|         <div /> | ||||
|         <.button type="submit">Submit</.button> | ||||
|       </.simple_form> | ||||
|     </.modal> | ||||
|  | ||||
|     <.modal | ||||
|       :if={@show_product_review_modal} | ||||
|       id="product-review-modal" | ||||
|       show | ||||
|       on_cancel={JS.patch(~p"/admin/content")} | ||||
|     > | ||||
|       <.header>Add Blurb</.header> | ||||
|       <.simple_form | ||||
|         for={@new_product_review_form} | ||||
|         phx-change="validate_product_review" | ||||
|         phx-submit="save_product_review" | ||||
|       > | ||||
|         <%= hidden_input(@new_product_review_form, :video_id) %> | ||||
|         <.input | ||||
|           field={@new_product_review_form[:ad_id]} | ||||
|           type="select" | ||||
|           label="Ad" | ||||
|           prompt="Select an ad" | ||||
|           options={Enum.map(@ads, fn ad -> {ad.slug, ad.id} end)} | ||||
|         /> | ||||
|         <.input | ||||
|           field={@new_product_review_form[:clip_from]} | ||||
|           type="text" | ||||
|           label="Clip From" | ||||
|           placeholder="hh:mm:ss" | ||||
|         /> | ||||
|         <.input | ||||
|           field={@new_product_review_form[:clip_to]} | ||||
|           type="text" | ||||
|           label="Clip To" | ||||
|           placeholder="hh:mm:ss" | ||||
|         /> | ||||
|         <div class="shrink-0"> | ||||
|           <label for="show_title" class="block text-sm font-semibold leading-6 text-gray-100 mb-2"> | ||||
|             Thumbnail | ||||
|           </label> | ||||
|           <div | ||||
|             id="show_image" | ||||
|             phx-drop-target={@uploads.product_review_thumbnail.ref} | ||||
|             class="relative" | ||||
|           > | ||||
|             <.live_file_input | ||||
|               upload={@uploads.product_review_thumbnail} | ||||
|               class="absolute inset-0 opacity-0 cursor-pointer" | ||||
|             /> | ||||
|             <img | ||||
|               :if={@new_product_review_form[:thumbnail_url].value} | ||||
|               src={@new_product_review_form[:thumbnail_url].value} | ||||
|               class="h-[180px] aspect-video object-cover rounded-lg" | ||||
|             /> | ||||
|             <div | ||||
|               :if={!@new_product_review_form[:thumbnail_url].value} | ||||
|               class="h-[180px] aspect-video bg-white/10 rounded-lg" | ||||
|             > | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <%= hidden_input(@new_product_review_form, :thumbnail_url) %> | ||||
|         <%= for err <- upload_errors(@uploads.product_review_thumbnail) do %> | ||||
|           <p class="alert alert-danger"><%= error_to_string(err) %></p> | ||||
|         <% end %> | ||||
|         <.button type="submit">Submit</.button> | ||||
|       </.simple_form> | ||||
|     </.modal> | ||||
|  | ||||
|     <.modal | ||||
|       :if={@show_content_metrics_modal} | ||||
|       id="content-metrics-modal" | ||||
|       show | ||||
|       on_cancel={JS.patch(~p"/admin/content")} | ||||
|     > | ||||
|       <.header>Add content</.header> | ||||
|       <.simple_form for={@new_content_metrics_form} phx-submit="save_content_metrics"> | ||||
|         <.input | ||||
|           field={@new_content_metrics_form[:video_id]} | ||||
|           type="select" | ||||
|           label="Video" | ||||
|           options={ | ||||
|             Enum.map(@videos, fn video -> | ||||
|               {"#{video.title} (#{Calendar.strftime(video.inserted_at, "%b %d, %Y, %I:%M %p UTC")})", | ||||
|                video.id} | ||||
|             end) | ||||
|           } | ||||
|           prompt="Select a video" | ||||
|           phx-change="video_selected" | ||||
|         /> | ||||
|         <.input | ||||
|           field={@new_content_metrics_form[:algora_stream_url]} | ||||
|           type="text" | ||||
|           label="Algora URL" | ||||
|           phx-change="url_entered" | ||||
|           phx-debounce="300" | ||||
|         /> | ||||
|         <div class="grid grid-cols-3 gap-4"> | ||||
|           <.input | ||||
|             field={@new_content_metrics_form[:twitch_stream_url]} | ||||
|             type="text" | ||||
|             label="Twitch URL" | ||||
|           /> | ||||
|           <.input | ||||
|             field={@new_content_metrics_form[:youtube_video_url]} | ||||
|             type="text" | ||||
|             label="YouTube URL" | ||||
|           /> | ||||
|           <.input | ||||
|             field={@new_content_metrics_form[:twitter_video_url]} | ||||
|             type="text" | ||||
|             label="Twitter URL" | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <.input | ||||
|           field={@new_content_metrics_form[:twitch_avg_concurrent_viewers]} | ||||
|           type="number" | ||||
|           label="Twitch Average CCV" | ||||
|         /> | ||||
|  | ||||
|         <div class="grid grid-cols-3 gap-4"> | ||||
|           <.input field={@new_content_metrics_form[:twitch_views]} type="number" label="Twitch Views" /> | ||||
|           <.input | ||||
|             field={@new_content_metrics_form[:youtube_views]} | ||||
|             type="number" | ||||
|             label="YouTube Views" | ||||
|           /> | ||||
|           <.input | ||||
|             field={@new_content_metrics_form[:twitter_views]} | ||||
|             type="number" | ||||
|             label="Twitter Views" | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <.button type="submit">Submit</.button> | ||||
|       </.simple_form> | ||||
|     </.modal> | ||||
|  | ||||
|     <.modal | ||||
|       :if={@editing_content_metrics} | ||||
|       id="edit-content-metrics-modal" | ||||
|       show | ||||
|       on_cancel={JS.patch(~p"/admin/content")} | ||||
|     > | ||||
|       <.header>Edit metrics</.header> | ||||
|       <.simple_form for={@new_content_metrics_form} phx-submit="update_content_metrics"> | ||||
|         <%= hidden_input(@new_content_metrics_form, :id) %> | ||||
|         <.input | ||||
|           field={@new_content_metrics_form[:video_id]} | ||||
|           type="select" | ||||
|           label="Video" | ||||
|           options={ | ||||
|             Enum.map(@videos, fn video -> | ||||
|               {"#{video.title} (#{Calendar.strftime(video.inserted_at, "%b %d, %Y, %I:%M %p UTC")})", | ||||
|                video.id} | ||||
|             end) | ||||
|           } | ||||
|           prompt="Select a video" | ||||
|           phx-change="video_selected" | ||||
|         /> | ||||
|         <.input | ||||
|           field={@new_content_metrics_form[:algora_stream_url]} | ||||
|           type="text" | ||||
|           label="Algora URL" | ||||
|           phx-change="url_entered" | ||||
|           phx-debounce="300" | ||||
|         /> | ||||
|         <div class="grid grid-cols-3 gap-4"> | ||||
|           <.input | ||||
|             field={@new_content_metrics_form[:twitch_stream_url]} | ||||
|             type="text" | ||||
|             label="Twitch URL" | ||||
|           /> | ||||
|           <.input | ||||
|             field={@new_content_metrics_form[:youtube_video_url]} | ||||
|             type="text" | ||||
|             label="YouTube URL" | ||||
|           /> | ||||
|           <.input | ||||
|             field={@new_content_metrics_form[:twitter_video_url]} | ||||
|             type="text" | ||||
|             label="Twitter URL" | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <.input | ||||
|           field={@new_content_metrics_form[:twitch_avg_concurrent_viewers]} | ||||
|           type="number" | ||||
|           label="Twitch Average CCV" | ||||
|         /> | ||||
|  | ||||
|         <div class="grid grid-cols-3 gap-4"> | ||||
|           <.input field={@new_content_metrics_form[:twitch_views]} type="number" label="Twitch Views" /> | ||||
|           <.input | ||||
|             field={@new_content_metrics_form[:youtube_views]} | ||||
|             type="number" | ||||
|             label="YouTube Views" | ||||
|           /> | ||||
|           <.input | ||||
|             field={@new_content_metrics_form[:twitter_views]} | ||||
|             type="number" | ||||
|             label="Twitter Views" | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <.button type="submit">Update</.button> | ||||
|       </.simple_form> | ||||
|     </.modal> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("save_content_metrics", %{"content_metrics" => params}, socket) do | ||||
|     case Ads.create_content_metrics(params) do | ||||
|       {:ok, _content_metrics} -> | ||||
|         content_metrics = Ads.list_content_metrics() | ||||
|  | ||||
|         {:noreply, | ||||
|          socket | ||||
|          |> assign(:content_metrics, content_metrics) | ||||
|          |> assign( | ||||
|            :new_content_metrics_form, | ||||
|            to_form(Ads.change_content_metrics(%ContentMetrics{})) | ||||
|          ) | ||||
|          |> assign(:show_content_metrics_modal, false)} | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         {:noreply, assign(socket, :new_content_metrics_form, to_form(changeset))} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("save_appearance", %{"appearance" => params}, socket) do | ||||
|     params = Map.update!(params, "airtime", &Library.from_hhmmss/1) | ||||
|  | ||||
|     case Ads.create_appearance(params) do | ||||
|       {:ok, _appearance} -> | ||||
|         content_metrics = Ads.list_content_metrics() | ||||
|  | ||||
|         {:noreply, | ||||
|          socket | ||||
|          |> assign(:content_metrics, content_metrics) | ||||
|          |> assign(:new_appearance_form, to_form(Ads.change_appearance(%Appearance{}))) | ||||
|          |> assign(:show_appearance_modal, false)} | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         {:noreply, assign(socket, :new_appearance_form, to_form(changeset))} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("validate_product_review", _params, socket) do | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("save_product_review", %{"product_review" => params}, socket) do | ||||
|     params = Map.update!(params, "clip_from", &Library.from_hhmmss/1) | ||||
|     params = Map.update!(params, "clip_to", &Library.from_hhmmss/1) | ||||
|  | ||||
|     case Ads.create_product_review(params) do | ||||
|       {:ok, _product_review} -> | ||||
|         content_metrics = Ads.list_content_metrics() | ||||
|  | ||||
|         {:noreply, | ||||
|          socket | ||||
|          |> assign(:content_metrics, content_metrics) | ||||
|          |> assign(:new_product_review_form, to_form(Ads.change_product_review(%ProductReview{}))) | ||||
|          |> assign(:show_product_review_modal, false)} | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         {:noreply, assign(socket, :new_product_review_form, to_form(changeset))} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("open_appearance_modal", %{"video_id" => video_id}, socket) do | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> assign(:show_appearance_modal, true) | ||||
|      |> assign( | ||||
|        :new_appearance_form, | ||||
|        to_form(Ads.change_appearance(%Appearance{video_id: String.to_integer(video_id)})) | ||||
|      )} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("open_product_review_modal", %{"video_id" => video_id}, socket) do | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> assign(:show_product_review_modal, true) | ||||
|      |> assign( | ||||
|        :new_product_review_form, | ||||
|        to_form(Ads.change_product_review(%ProductReview{video_id: String.to_integer(video_id)})) | ||||
|      )} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("open_content_metrics_modal", _params, socket) do | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> assign(:show_content_metrics_modal, true) | ||||
|      |> assign(:new_content_metrics_form, to_form(Ads.change_content_metrics(%ContentMetrics{})))} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("video_selected", %{"content_metrics" => %{"video_id" => video_id}}, socket) do | ||||
|     video = | ||||
|       if video_id != "", | ||||
|         do: Enum.find(socket.assigns.videos, &(&1.id == String.to_integer(video_id))) | ||||
|  | ||||
|     url = | ||||
|       if video, do: "#{AlgoraWeb.Endpoint.url()}/#{video.channel_handle}/#{video_id}", else: "" | ||||
|  | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> assign( | ||||
|        :new_content_metrics_form, | ||||
|        to_form( | ||||
|          Ads.change_content_metrics(%ContentMetrics{video_id: video_id, algora_stream_url: url}) | ||||
|        ) | ||||
|      )} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("url_entered", %{"content_metrics" => %{"algora_stream_url" => url}}, socket) do | ||||
|     video = | ||||
|       Enum.find( | ||||
|         socket.assigns.videos, | ||||
|         &(url == "#{AlgoraWeb.Endpoint.url()}/#{&1.channel_handle}/#{&1.id}") | ||||
|       ) | ||||
|  | ||||
|     video_id = if video, do: video.id, else: nil | ||||
|  | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> assign( | ||||
|        :new_content_metrics_form, | ||||
|        to_form( | ||||
|          Ads.change_content_metrics(%ContentMetrics{video_id: video_id, algora_stream_url: url}) | ||||
|        ) | ||||
|      )} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("edit_content_metrics", %{"id" => id}, socket) do | ||||
|     content_metrics = Ads.get_content_metrics!(id) | ||||
|     changeset = Ads.change_content_metrics(content_metrics) | ||||
|  | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> assign(:editing_content_metrics, content_metrics) | ||||
|      |> assign(:new_content_metrics_form, to_form(changeset)) | ||||
|      |> assign(:show_content_metrics_modal, true)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("update_content_metrics", %{"content_metrics" => params}, socket) do | ||||
|     case Ads.update_content_metrics(socket.assigns.editing_content_metrics, params) do | ||||
|       {:ok, _updated_content_metrics} -> | ||||
|         content_metrics = Ads.list_content_metrics() | ||||
|  | ||||
|         {:noreply, | ||||
|          socket | ||||
|          |> assign(:content_metrics, content_metrics) | ||||
|          |> assign(:editing_content_metrics, nil) | ||||
|          |> assign(:show_content_metrics_modal, false)} | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         {:noreply, assign(socket, :new_content_metrics_form, to_form(changeset))} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def handle_event("cancel-upload", %{"ref" => ref}, socket) do | ||||
|     {:noreply, cancel_upload(socket, :product_review_thumbnail, ref)} | ||||
|   end | ||||
|  | ||||
|   defp handle_progress(:product_review_thumbnail, entry, socket) do | ||||
|     if entry.done? do | ||||
|       form = | ||||
|         consume_uploaded_entry(socket, entry, fn %{path: path} = _meta -> | ||||
|           uuid = Ecto.UUID.generate() | ||||
|           remote_path = "blurbs/#{uuid}" | ||||
|  | ||||
|           content_type = ExMarcel.MimeType.for({:path, path}) | ||||
|  | ||||
|           {:ok, _} = | ||||
|             Algora.Storage.upload_from_filename(path, remote_path, fn _ -> nil end, | ||||
|               content_type: content_type | ||||
|             ) | ||||
|  | ||||
|           bucket = Algora.config([:buckets, :media]) | ||||
|           %{scheme: scheme, host: host} = Application.fetch_env!(:ex_aws, :s3) |> Enum.into(%{}) | ||||
|  | ||||
|           # {:ok, form} = | ||||
|           #   Ecto.Changeset.apply_action(socket.assigns.new_product_review_form.source, :update) | ||||
|  | ||||
|           form = | ||||
|             socket.assigns.new_product_review_form.source | ||||
|             |> put_change( | ||||
|               :thumbnail_url, | ||||
|               "#{scheme}#{host}/#{bucket}/#{remote_path}" | ||||
|             ) | ||||
|             |> to_form() | ||||
|  | ||||
|           {:ok, form} | ||||
|         end) | ||||
|  | ||||
|       {:noreply, socket |> assign(:new_product_review_form, form)} | ||||
|     else | ||||
|       {:noreply, socket} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(params, _url, socket) do | ||||
|     LayoutComponent.hide_modal() | ||||
|     {:noreply, socket |> apply_action(socket.assigns.live_action, params)} | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :show, _params) do | ||||
|     socket | ||||
|     |> assign(:page_title, "Content") | ||||
|     |> assign(:page_description, "Content") | ||||
|   end | ||||
|  | ||||
|   defp error_to_string(:too_large) do | ||||
|     "Only images up to #{max_file_size()} MB are allowed." | ||||
|   end | ||||
|  | ||||
|   defp error_to_string(:not_accepted) do | ||||
|     "Uploaded file is not a valid image. Only #{accept() |> Enum.intersperse(", ") |> Enum.join()} files are allowed." | ||||
|   end | ||||
|  | ||||
|   defp max_file_size, do: 10 | ||||
|   defp accept, do: ~w(.png .jpg .jpeg .gif) | ||||
| end | ||||
							
								
								
									
										113
									
								
								lib/algora_web/live/ad_live/form_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								lib/algora_web/live/ad_live/form_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | ||||
| defmodule AlgoraWeb.AdLive.FormComponent do | ||||
|   use AlgoraWeb, :live_component | ||||
|  | ||||
|   alias Algora.Ads | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div> | ||||
|       <.header class="mb-8"> | ||||
|         <%= @title %> | ||||
|         <:subtitle>Use this form to manage ad records in your database.</:subtitle> | ||||
|       </.header> | ||||
|  | ||||
|       <.simple_form | ||||
|         for={@form} | ||||
|         id="ad-form" | ||||
|         phx-target={@myself} | ||||
|         phx-change="validate" | ||||
|         phx-submit="save" | ||||
|       > | ||||
|         <div class="relative"> | ||||
|           <div class="absolute text-sm start-0 flex items-center ps-3 top-10 mt-px pointer-events-none text-gray-400"> | ||||
|             tv.algora.io/go/ | ||||
|           </div> | ||||
|           <.input field={@form[:slug]} type="text" label="QR Code URL" class="ps-[6.75rem]" /> | ||||
|         </div> | ||||
|         <.input field={@form[:website_url]} type="text" label="Website URL" /> | ||||
|         <.input field={@form[:composite_asset_url]} type="text" label="Asset URL" /> | ||||
|         <.input | ||||
|           field={@form[:border_color]} | ||||
|           type="text" | ||||
|           label="Border color" | ||||
|           style={"color: #{valid_color(@form[:border_color].value)}"} | ||||
|         /> | ||||
|         <:actions> | ||||
|           <.button phx-disable-with="Saving...">Save Ad</.button> | ||||
|         </:actions> | ||||
|       </.simple_form> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def update(%{ad: ad} = assigns, socket) do | ||||
|     changeset = Ads.change_ad(ad) | ||||
|  | ||||
|     {:ok, | ||||
|      socket | ||||
|      |> assign(assigns) | ||||
|      |> assign_form(changeset)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("validate", %{"ad" => ad_params}, socket) do | ||||
|     changeset = | ||||
|       socket.assigns.ad | ||||
|       |> Ads.change_ad(ad_params) | ||||
|       |> Map.put(:action, :validate) | ||||
|  | ||||
|     {:noreply, assign_form(socket, changeset)} | ||||
|   end | ||||
|  | ||||
|   def handle_event("save", %{"ad" => ad_params}, socket) do | ||||
|     save_ad(socket, socket.assigns.action, ad_params) | ||||
|   end | ||||
|  | ||||
|   defp save_ad(socket, :edit, ad_params) do | ||||
|     case Ads.update_ad(socket.assigns.ad, ad_params) do | ||||
|       {:ok, ad} -> | ||||
|         notify_parent({:saved, ad}) | ||||
|         Ads.broadcast_ad_updated!(ad) | ||||
|  | ||||
|         {:noreply, | ||||
|          socket | ||||
|          |> put_flash(:info, "Ad updated successfully") | ||||
|          |> push_patch(to: socket.assigns.patch)} | ||||
|  | ||||
|       {:error, %Ecto.Changeset{} = changeset} -> | ||||
|         {:noreply, assign_form(socket, changeset)} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp save_ad(socket, :new, ad_params) do | ||||
|     case Ads.create_ad(ad_params) do | ||||
|       {:ok, ad} -> | ||||
|         notify_parent({:saved, ad}) | ||||
|         Ads.broadcast_ad_created!(ad) | ||||
|  | ||||
|         {:noreply, | ||||
|          socket | ||||
|          |> put_flash(:info, "Ad created successfully") | ||||
|          |> push_patch(to: socket.assigns.patch)} | ||||
|  | ||||
|       {:error, %Ecto.Changeset{} = changeset} -> | ||||
|         {:noreply, assign_form(socket, changeset)} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp assign_form(socket, %Ecto.Changeset{} = changeset) do | ||||
|     assign(socket, :form, to_form(changeset)) | ||||
|   end | ||||
|  | ||||
|   defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) | ||||
|  | ||||
|   defp valid_color(color) do | ||||
|     if is_valid_hex_color?(color), do: color, else: "inherit" | ||||
|   end | ||||
|  | ||||
|   defp is_valid_hex_color?(nil), do: false | ||||
|  | ||||
|   defp is_valid_hex_color?(color), do: color =~ ~r/^#([0-9A-F]{3}){1,2}$/i | ||||
| end | ||||
							
								
								
									
										95
									
								
								lib/algora_web/live/ad_live/index.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								lib/algora_web/live/ad_live/index.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | ||||
| defmodule AlgoraWeb.AdLive.Index do | ||||
|   use AlgoraWeb, :live_view | ||||
|  | ||||
|   alias Algora.Ads | ||||
|   alias Algora.Ads.Ad | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket) do | ||||
|     if connected?(socket) do | ||||
|       schedule_next_rotation() | ||||
|     end | ||||
|  | ||||
|     next_slot = Ads.next_slot() | ||||
|  | ||||
|     ads = | ||||
|       Ads.list_active_ads() | ||||
|       |> Ads.rotate_ads() | ||||
|       |> Enum.with_index(-1) | ||||
|       |> Enum.map(fn {ad, index} -> | ||||
|         %{ | ||||
|           ad | ||||
|           | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond) | ||||
|         } | ||||
|       end) | ||||
|  | ||||
|     {:ok, | ||||
|      socket | ||||
|      |> stream(:ads, ads) | ||||
|      |> assign(:next_slot, Ads.next_slot())} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(params, _url, socket) do | ||||
|     {:noreply, apply_action(socket, socket.assigns.live_action, params)} | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :edit, %{"id" => id}) do | ||||
|     socket | ||||
|     |> assign(:page_title, "Edit Ad") | ||||
|     |> assign(:ad, Ads.get_ad!(id)) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :new, _params) do | ||||
|     socket | ||||
|     |> assign(:page_title, "New Ad") | ||||
|     |> assign(:ad, %Ad{}) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :index, _params) do | ||||
|     socket | ||||
|     |> assign(:page_title, "Listing Ads") | ||||
|     |> assign(:ad, nil) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_info({AlgoraWeb.AdLive.FormComponent, {:saved, ad}}, socket) do | ||||
|     {:noreply, stream_insert(socket, :ads, ad)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_info(:rotate_ads, socket) do | ||||
|     schedule_next_rotation() | ||||
|  | ||||
|     next_slot = Ads.next_slot() | ||||
|  | ||||
|     rotated_ads = | ||||
|       socket.assigns.ads | ||||
|       |> Ads.rotate_ads(1) | ||||
|       |> Enum.with_index(-1) | ||||
|       |> Enum.map(fn {ad, index} -> | ||||
|         %{ | ||||
|           ad | ||||
|           | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond) | ||||
|         } | ||||
|       end) | ||||
|  | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> stream(:ads, rotated_ads) | ||||
|      |> assign(:next_slot, Ads.next_slot())} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("delete", %{"id" => id}, socket) do | ||||
|     ad = Ads.get_ad!(id) | ||||
|     {:ok, _} = Ads.delete_ad(ad) | ||||
|     Ads.broadcast_ad_deleted!(ad) | ||||
|  | ||||
|     {:noreply, stream_delete(socket, :ads, ad)} | ||||
|   end | ||||
|  | ||||
|   defp schedule_next_rotation do | ||||
|     Process.send_after(self(), :rotate_ads, Ads.time_until_next_slot()) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										71
									
								
								lib/algora_web/live/ad_live/index.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								lib/algora_web/live/ad_live/index.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| <.header class="pl-4 pr-6"> | ||||
|   Active Ads | ||||
|   <:actions> | ||||
|     <.link patch={~p"/ads/new"}> | ||||
|       <.button>New Ad</.button> | ||||
|     </.link> | ||||
|   </:actions> | ||||
| </.header> | ||||
|  | ||||
| <.table id="ads" rows={@streams.ads} row_click={fn {_id, ad} -> JS.navigate(~p"/ads/#{ad}") end}> | ||||
|   <:col :let={{_id, ad}} label=""> | ||||
|     <a href={ad.website_url} rel="noopener noreferrer" class="flex mb-3 font-medium"> | ||||
|       <div class="grid grid-cols-3 w-[1092px]"> | ||||
|         <div class="text-base text-gray-200"> | ||||
|           <%= String.replace(ad.website_url, ~r/^https?:\/\//, "") %> | ||||
|         </div> | ||||
|         <div class="mx-auto font-mono text-gray-200"> | ||||
|           <%= Calendar.strftime(ad.scheduled_for, "%I:%M:%S %p UTC") %> | ||||
|         </div> | ||||
|         <div class="ml-auto flex items-center gap-1 text-base text-gray-300"> | ||||
|           <svg | ||||
|             xmlns="http://www.w3.org/2000/svg" | ||||
|             width="24" | ||||
|             height="24" | ||||
|             viewBox="0 0 24 24" | ||||
|             fill="none" | ||||
|             stroke="currentColor" | ||||
|             stroke-width="2" | ||||
|             stroke-linecap="round" | ||||
|             stroke-linejoin="round" | ||||
|             class="h-4 w-4" | ||||
|           > | ||||
|             <path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 17l0 .01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7l0 .01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7l0 .01" /><path d="M14 14l3 0" /><path d="M20 14l0 .01" /><path d="M14 14l0 3" /><path d="M14 20l3 0" /><path d="M17 17l3 0" /><path d="M20 17l0 3" /> | ||||
|           </svg> | ||||
|           algora.tv/go/<%= ad.slug %> | ||||
|         </div> | ||||
|       </div> | ||||
|     </a> | ||||
|     <img | ||||
|       src={ad.composite_asset_url} | ||||
|       alt={ad.website_url} | ||||
|       class="box-content w-[1092px] h-[135px] object-cover border-[4px] rounded-xl transition-opacity duration-1000" | ||||
|       style={"border-color: #{ad.border_color || "#fff"}"} | ||||
|     /> | ||||
|   </:col> | ||||
|   <:action :let={{_id, ad}}> | ||||
|     <div class="sr-only"> | ||||
|       <.link navigate={~p"/ads/#{ad}"}>Show</.link> | ||||
|     </div> | ||||
|     <.link patch={~p"/ads/#{ad}/edit"}>Edit</.link> | ||||
|   </:action> | ||||
|   <:action :let={{id, ad}}> | ||||
|     <.link | ||||
|       phx-click={JS.push("delete", value: %{id: ad.id}) |> hide("##{id}")} | ||||
|       data-confirm="Are you sure?" | ||||
|     > | ||||
|       Delete | ||||
|     </.link> | ||||
|   </:action> | ||||
| </.table> | ||||
|  | ||||
| <.modal :if={@live_action in [:new, :edit]} id="ad-modal" show on_cancel={JS.patch(~p"/ads")}> | ||||
|   <.live_component | ||||
|     module={AlgoraWeb.AdLive.FormComponent} | ||||
|     id={@ad.id || :new} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     ad={@ad} | ||||
|     patch={~p"/ads"} | ||||
|   /> | ||||
| </.modal> | ||||
							
								
								
									
										68
									
								
								lib/algora_web/live/ad_live/schedule.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								lib/algora_web/live/ad_live/schedule.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| defmodule AlgoraWeb.AdLive.Schedule do | ||||
|   use AlgoraWeb, :live_view | ||||
|  | ||||
|   alias Algora.Ads | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket) do | ||||
|     if connected?(socket) do | ||||
|       schedule_next_rotation() | ||||
|     end | ||||
|  | ||||
|     next_slot = Ads.next_slot() | ||||
|  | ||||
|     ads = | ||||
|       Ads.list_active_ads() | ||||
|       |> Ads.rotate_ads() | ||||
|       |> Enum.with_index(-1) | ||||
|       |> Enum.map(fn {ad, index} -> | ||||
|         %{ | ||||
|           ad | ||||
|           | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond) | ||||
|         } | ||||
|       end) | ||||
|  | ||||
|     {:ok, | ||||
|      socket | ||||
|      |> stream(:ads, ads) | ||||
|      |> assign(:next_slot, Ads.next_slot())} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(params, _url, socket) do | ||||
|     {:noreply, apply_action(socket, socket.assigns.live_action, params)} | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :schedule, _params) do | ||||
|     socket | ||||
|     |> assign(:page_title, "Ads schedule") | ||||
|     |> assign(:ad, nil) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_info(:rotate_ads, socket) do | ||||
|     schedule_next_rotation() | ||||
|  | ||||
|     next_slot = Ads.next_slot() | ||||
|  | ||||
|     rotated_ads = | ||||
|       socket.assigns.ads | ||||
|       |> Ads.rotate_ads(1) | ||||
|       |> Enum.with_index(-1) | ||||
|       |> Enum.map(fn {ad, index} -> | ||||
|         %{ | ||||
|           ad | ||||
|           | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond) | ||||
|         } | ||||
|       end) | ||||
|  | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> stream(:ads, rotated_ads) | ||||
|      |> assign(:next_slot, Ads.next_slot())} | ||||
|   end | ||||
|  | ||||
|   defp schedule_next_rotation do | ||||
|     Process.send_after(self(), :rotate_ads, Ads.time_until_next_slot()) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										41
									
								
								lib/algora_web/live/ad_live/schedule.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								lib/algora_web/live/ad_live/schedule.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| <.header class="pl-4 pr-6"> | ||||
|   Ads schedule | ||||
| </.header> | ||||
|  | ||||
| <.table id="ads" rows={@streams.ads}> | ||||
|   <:col :let={{_id, ad}} label=""> | ||||
|     <a href={ad.website_url} rel="noopener noreferrer" class="flex mb-3 font-medium"> | ||||
|       <div class="grid grid-cols-3 w-[1092px]"> | ||||
|         <div class="text-base text-gray-200"> | ||||
|           <%= String.replace(ad.website_url, ~r/^https?:\/\//, "") %> | ||||
|         </div> | ||||
|         <div class="mx-auto font-mono text-gray-200"> | ||||
|           <%= Calendar.strftime(ad.scheduled_for, "%I:%M:%S %p UTC") %> | ||||
|         </div> | ||||
|         <div class="ml-auto flex items-center gap-1 text-base text-gray-300"> | ||||
|           <svg | ||||
|             xmlns="http://www.w3.org/2000/svg" | ||||
|             width="24" | ||||
|             height="24" | ||||
|             viewBox="0 0 24 24" | ||||
|             fill="none" | ||||
|             stroke="currentColor" | ||||
|             stroke-width="2" | ||||
|             stroke-linecap="round" | ||||
|             stroke-linejoin="round" | ||||
|             class="h-4 w-4" | ||||
|           > | ||||
|             <path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 17l0 .01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7l0 .01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7l0 .01" /><path d="M14 14l3 0" /><path d="M20 14l0 .01" /><path d="M14 14l0 3" /><path d="M14 20l3 0" /><path d="M17 17l3 0" /><path d="M20 17l0 3" /> | ||||
|           </svg> | ||||
|           algora.tv/go/<%= ad.slug %> | ||||
|         </div> | ||||
|       </div> | ||||
|     </a> | ||||
|     <img | ||||
|       src={ad.composite_asset_url} | ||||
|       alt={ad.website_url} | ||||
|       class="box-content w-[1092px] h-[135px] object-cover border-[4px] rounded-xl transition-opacity duration-1000" | ||||
|       style={"border-color: #{ad.border_color || "#fff"}"} | ||||
|     /> | ||||
|   </:col> | ||||
| </.table> | ||||
							
								
								
									
										21
									
								
								lib/algora_web/live/ad_live/show.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								lib/algora_web/live/ad_live/show.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| defmodule AlgoraWeb.AdLive.Show do | ||||
|   use AlgoraWeb, :live_view | ||||
|  | ||||
|   alias Algora.Ads | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(%{"id" => id}, _, socket) do | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> assign(:page_title, page_title(socket.assigns.live_action)) | ||||
|      |> assign(:ad, Ads.get_ad!(id))} | ||||
|   end | ||||
|  | ||||
|   defp page_title(:show), do: "Show Ad" | ||||
|   defp page_title(:edit), do: "Edit Ad" | ||||
| end | ||||
							
								
								
									
										37
									
								
								lib/algora_web/live/ad_live/show.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								lib/algora_web/live/ad_live/show.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| <.header> | ||||
|   Ad <%= @ad.id %> | ||||
|   <:subtitle>This is a ad record from your database.</:subtitle> | ||||
|   <:actions> | ||||
|     <.link patch={~p"/ads/#{@ad}/show/edit"} phx-click={JS.push_focus()}> | ||||
|       <.button>Edit ad</.button> | ||||
|     </.link> | ||||
|   </:actions> | ||||
| </.header> | ||||
|  | ||||
| <.list> | ||||
|   <:item title="Verified"><%= @ad.verified %></:item> | ||||
|   <:item title="Website url"><%= @ad.website_url %></:item> | ||||
|   <:item title="Composite asset url"><%= @ad.composite_asset_url %></:item> | ||||
|   <:item title="Asset url"><%= @ad.asset_url %></:item> | ||||
|   <:item title="Logo url"><%= @ad.logo_url %></:item> | ||||
|   <:item title="Qrcode url"><%= @ad.qrcode_url %></:item> | ||||
|   <:item title="Start date"><%= @ad.start_date %></:item> | ||||
|   <:item title="End date"><%= @ad.end_date %></:item> | ||||
|   <:item title="Total budget"><%= @ad.total_budget %></:item> | ||||
|   <:item title="Daily budget"><%= @ad.daily_budget %></:item> | ||||
|   <:item title="Tech stack"><%= @ad.tech_stack %></:item> | ||||
|   <:item title="Status"><%= @ad.status %></:item> | ||||
| </.list> | ||||
|  | ||||
| <.back navigate={~p"/ads"}>Back to ads</.back> | ||||
|  | ||||
| <.modal :if={@live_action == :edit} id="ad-modal" show on_cancel={JS.patch(~p"/ads/#{@ad}")}> | ||||
|   <.live_component | ||||
|     module={AlgoraWeb.AdLive.FormComponent} | ||||
|     id={@ad.id} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     ad={@ad} | ||||
|     patch={~p"/ads/#{@ad}"} | ||||
|   /> | ||||
| </.modal> | ||||
							
								
								
									
										146
									
								
								lib/algora_web/live/ad_overlay_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								lib/algora_web/live/ad_overlay_live.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,146 @@ | ||||
| defmodule AlgoraWeb.AdOverlayLive do | ||||
|   use AlgoraWeb, :live_view | ||||
|   require Logger | ||||
|  | ||||
|   alias AlgoraWeb.{LayoutComponent, Presence} | ||||
|   alias Algora.{Accounts, Library, Ads} | ||||
|  | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <%= if @ads && length(@ads) > 0 do %> | ||||
|       <div class="relative"> | ||||
|         <img | ||||
|           src={@current_ad.composite_asset_url} | ||||
|           alt={@current_ad.website_url} | ||||
|           class={"box-content w-[1092px] h-[135px] object-cover border-[4px] rounded-xl transition-opacity duration-1000 #{if @show_ad, do: "opacity-100", else: "opacity-0"}"} | ||||
|           style={"border-color: #{@current_ad.border_color || "#fff"}"} | ||||
|         /> | ||||
|         <img | ||||
|           src={@next_ad.composite_asset_url} | ||||
|           alt={@next_ad.website_url} | ||||
|           class="absolute top-0 left-0 opacity-0 pointer-events-none" | ||||
|         /> | ||||
|       </div> | ||||
|     <% end %> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   def mount(%{"channel_handle" => channel_handle} = params, _session, socket) do | ||||
|     channel = | ||||
|       Accounts.get_user_by!(handle: channel_handle) | ||||
|       |> Library.get_channel!() | ||||
|  | ||||
|     ads = Ads.list_active_ads() | ||||
|     current_ad_index = Ads.get_current_index(ads) | ||||
|     current_ad = Enum.at(ads, current_ad_index) | ||||
|     {next_ad, next_index} = get_next_ad(ads, current_ad_index) | ||||
|  | ||||
|     if connected?(socket) do | ||||
|       Ads.subscribe_to_ads() | ||||
|       schedule_next_ad() | ||||
|     end | ||||
|  | ||||
|     {:ok, | ||||
|      socket | ||||
|      |> assign(:channel, channel) | ||||
|      |> assign(:ads, ads) | ||||
|      |> assign(:current_ad_index, current_ad_index) | ||||
|      |> assign(:current_ad, current_ad) | ||||
|      |> assign(:next_ad, next_ad) | ||||
|      |> assign(:next_ad_index, next_index) | ||||
|      |> assign(:show_ad, Map.has_key?(params, "test")) | ||||
|      |> assign(:test_mode, Map.has_key?(params, "test"))} | ||||
|   end | ||||
|  | ||||
|   def handle_params(params, _url, socket) do | ||||
|     LayoutComponent.hide_modal() | ||||
|     {:noreply, socket |> apply_action(socket.assigns.live_action, params)} | ||||
|   end | ||||
|  | ||||
|   def handle_info(:toggle_ad, %{assigns: %{test_mode: true}} = socket), do: {:noreply, socket} | ||||
|  | ||||
|   def handle_info(:toggle_ad, socket) do | ||||
|     case socket.assigns.show_ad do | ||||
|       true -> | ||||
|         schedule_next_ad() | ||||
|         {:noreply, assign(socket, :show_ad, false)} | ||||
|  | ||||
|       false -> | ||||
|         track_impressions(socket.assigns.next_ad, socket.assigns.channel.handle) | ||||
|         Process.send_after(self(), :toggle_ad, Ads.display_duration()) | ||||
|  | ||||
|         {new_next_ad, new_next_index} = | ||||
|           get_next_ad(socket.assigns.ads, socket.assigns.next_ad_index) | ||||
|  | ||||
|         {:noreply, | ||||
|          socket | ||||
|          |> assign(:show_ad, true) | ||||
|          |> assign(:current_ad, socket.assigns.next_ad) | ||||
|          |> assign(:current_ad_index, socket.assigns.next_ad_index) | ||||
|          |> assign(:next_ad, new_next_ad) | ||||
|          |> assign(:next_ad_index, new_next_index)} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def handle_info({Ads, %Ads.Events.AdCreated{}}, socket) do | ||||
|     update_ads_state(socket) | ||||
|   end | ||||
|  | ||||
|   def handle_info({Ads, %Ads.Events.AdDeleted{}}, socket) do | ||||
|     update_ads_state(socket) | ||||
|   end | ||||
|  | ||||
|   def handle_info({Ads, %Ads.Events.AdUpdated{}}, socket) do | ||||
|     update_ads_state(socket) | ||||
|   end | ||||
|  | ||||
|   def handle_info(_arg, socket), do: {:noreply, socket} | ||||
|  | ||||
|   defp apply_action(socket, :show, params) do | ||||
|     channel_name = params["channel_handle"] | ||||
|  | ||||
|     socket | ||||
|     |> assign(:page_title, channel_name) | ||||
|     |> assign(:page_description, "Watch #{channel_name} on Algora TV") | ||||
|   end | ||||
|  | ||||
|   defp schedule_next_ad do | ||||
|     Process.send_after(self(), :toggle_ad, Ads.time_until_next_slot()) | ||||
|   end | ||||
|  | ||||
|   defp track_impressions(nil, _channel_handle), do: :ok | ||||
|  | ||||
|   defp track_impressions(ad, channel_handle) do | ||||
|     viewers_count = | ||||
|       Presence.list_online_users(channel_handle) | ||||
|       |> Enum.flat_map(fn %{metas: metas} -> metas end) | ||||
|       |> Enum.filter(fn meta -> meta.id != channel_handle end) | ||||
|       |> length() | ||||
|  | ||||
|     Ads.track_impressions(%{ | ||||
|       ad_id: ad.id, | ||||
|       duration: Ads.display_duration(), | ||||
|       viewers_count: viewers_count | ||||
|     }) | ||||
|   end | ||||
|  | ||||
|   defp get_next_ad(ads, current_index) do | ||||
|     next_index = rem(current_index + 1, length(ads)) | ||||
|     {Enum.at(ads, next_index), next_index} | ||||
|   end | ||||
|  | ||||
|   defp update_ads_state(socket) do | ||||
|     ads = Ads.list_active_ads() | ||||
|     current_ad_index = Ads.get_current_index(ads) | ||||
|     current_ad = Enum.at(ads, current_ad_index) | ||||
|     {next_ad, next_index} = get_next_ad(ads, current_ad_index) | ||||
|  | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> assign(:ads, ads) | ||||
|      |> assign(:current_ad_index, current_ad_index) | ||||
|      |> assign(:current_ad, current_ad) | ||||
|      |> assign(:next_ad, next_ad) | ||||
|      |> assign(:next_ad_index, next_index)} | ||||
|   end | ||||
| end | ||||
							
								
								
									
										381
									
								
								lib/algora_web/live/partner_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										381
									
								
								lib/algora_web/live/partner_live.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,381 @@ | ||||
| defmodule AlgoraWeb.PartnerLive do | ||||
|   use AlgoraWeb, :live_view | ||||
|   require Logger | ||||
|  | ||||
|   alias AlgoraWeb.LayoutComponent | ||||
|   alias Algora.Contact | ||||
|   alias Algora.Contact.Info | ||||
|  | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div class="bg-gray-950 font-display"> | ||||
|       <main> | ||||
|         <!-- Hero section --> | ||||
|         <div class="relative isolate overflow-hidden"> | ||||
|           <svg | ||||
|             class="absolute inset-0 -z-10 h-full w-full stroke-white/10 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]" | ||||
|             aria-hidden="true" | ||||
|           > | ||||
|             <defs> | ||||
|               <pattern | ||||
|                 id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc" | ||||
|                 width="200" | ||||
|                 height="200" | ||||
|                 x="50%" | ||||
|                 y="-1" | ||||
|                 patternUnits="userSpaceOnUse" | ||||
|               > | ||||
|                 <path d="M.5 200V.5H200" fill="none" /> | ||||
|               </pattern> | ||||
|             </defs> | ||||
|             <svg x="50%" y="-1" class="overflow-visible fill-gray-800/20"> | ||||
|               <path | ||||
|                 d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z" | ||||
|                 stroke-width="0" | ||||
|               /> | ||||
|             </svg> | ||||
|             <rect | ||||
|               width="100%" | ||||
|               height="100%" | ||||
|               stroke-width="0" | ||||
|               fill="url(#983e3e4c-de6d-4c3f-8d64-b9761d1534cc)" | ||||
|             /> | ||||
|           </svg> | ||||
|           <div | ||||
|             class="absolute left-[calc(50%-4rem)] top-10 -z-10 transform blur-3xl sm:left-[calc(50%-18rem)] lg:left-48 lg:top-[calc(50%-30rem)] xl:left-[calc(50%-24rem)]" | ||||
|             aria-hidden="true" | ||||
|           > | ||||
|             <div | ||||
|               class="aspect-[1108/632] w-[69.25rem] bg-gradient-to-r from-[#80caff] to-[#4f46e5] opacity-20" | ||||
|               style="clip-path: polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)" | ||||
|             > | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="mx-auto px-6 pb-24 pt-10 sm:pb-40 lg:flex lg:px-20 lg:pt-40 gap-8"> | ||||
|             <div class="mx-auto max-w-3xl flex-shrink-0 lg:mx-0 lg:ml-auto lg:max-w-lg lg:pt-8"> | ||||
|               <.logo /> | ||||
|  | ||||
|               <h1 class="mt-10 text-4xl font-bold tracking-tight text-white sm:text-5xl"> | ||||
|                 Your most successful<br class="hidden sm:inline" /> | ||||
|                 <span class="text-green-300">ad campaign</span> | ||||
|                 is just<br class="hidden sm:inline" /> a few livestreams away | ||||
|               </h1> | ||||
|               <p class="mt-6 text-xl leading-8 tracking-tight text-gray-300"> | ||||
|                 In-video livestream ads that help you stand out<br class="hidden sm:inline" /> | ||||
|                 in front of millions on Twitch, YouTube and X. | ||||
|               </p> | ||||
|               <div class="hidden lg:mt-10 lg:flex items-center gap-x-6"> | ||||
|                 <a | ||||
|                   href="#contact-form" | ||||
|                   class="rounded-md bg-indigo-500 px-3.5 py-2.5 text-lg font-semibold text-white shadow-sm hover:bg-indigo-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-400" | ||||
|                 > | ||||
|                   Get started | ||||
|                 </a> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="mx-auto max-w-2xl xl:max-w-3xl aspect-[1456/756] w-full h-full flex-shrink-0 mt-12 flex lg:mt-6 xl:mt-0"> | ||||
|               <img | ||||
|                 src={~p"/images/partner-demo.png"} | ||||
|                 alt="Demo" | ||||
|                 class="w-full h-full shrink-0 rounded-xl shadow-2xl ring-1 ring-white/10" | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <!-- Feature section --> | ||||
|         <div class="mt-16 sm:mt-28"> | ||||
|           <div class="mx-auto max-w-7xl px-6 lg:px-8"> | ||||
|             <div class="mx-auto max-w-4xl sm:text-center"> | ||||
|               <h2 class="mt-2 text-4xl font-bold tracking-tight text-white sm:text-5xl"> | ||||
|                 Influencer Marketing on Autopilot | ||||
|               </h2> | ||||
|               <p class="mt-6 text-xl sm:text-2xl leading-8 tracking-tight text-gray-300"> | ||||
|                 Distribute your ad creatives to the most engaged tech audience in-video and measure success with our comprehensive analytics | ||||
|               </p> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="relative overflow-hidden pt-16"> | ||||
|             <div class="mx-auto max-w-7xl px-6 lg:px-8"> | ||||
|               <img | ||||
|                 src={~p"/images/analytics.png"} | ||||
|                 alt="Analytics" | ||||
|                 class="rounded-xl shadow-2xl ring-1 ring-white/10" | ||||
|                 width="1648" | ||||
|                 height="800" | ||||
|               /> | ||||
|               <div class="relative" aria-hidden="true"> | ||||
|                 <div class="absolute -inset-x-20 bottom-0 bg-gradient-to-t from-gray-950 pt-[20%]"> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="mx-auto mt-16 max-w-7xl px-6 sm:mt-20 md:mt-24 lg:px-8"> | ||||
|             <dl class="mx-auto grid max-w-2xl grid-cols-1 gap-x-6 gap-y-10 text-base leading-7 text-gray-300 sm:grid-cols-2 lg:mx-0 lg:max-w-none lg:grid-cols-3 lg:gap-x-8 lg:gap-y-16"> | ||||
|               <div class="relative pl-9"> | ||||
|                 <dt class="inline font-semibold text-white"> | ||||
|                   <svg | ||||
|                     xmlns="http://www.w3.org/2000/svg" | ||||
|                     width="24" | ||||
|                     height="24" | ||||
|                     viewBox="0 0 24 24" | ||||
|                     fill="none" | ||||
|                     stroke="currentColor" | ||||
|                     stroke-width="2" | ||||
|                     stroke-linecap="round" | ||||
|                     stroke-linejoin="round" | ||||
|                     class="absolute left-0 top-0 h-6 w-6 text-indigo-500" | ||||
|                   > | ||||
|                     <path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" /><path d="M7 9l5 -5l5 5" /><path d="M12 4l0 12" /> | ||||
|                   </svg> | ||||
|                   Easy Upload | ||||
|                 </dt> | ||||
|                 <dd class="inline"> | ||||
|                   Upload your ad creatives quickly and easily through our intuitive interface. | ||||
|                 </dd> | ||||
|               </div> | ||||
|               <div class="relative pl-9"> | ||||
|                 <dt class="inline font-semibold text-white"> | ||||
|                   <svg | ||||
|                     xmlns="http://www.w3.org/2000/svg" | ||||
|                     width="24" | ||||
|                     height="24" | ||||
|                     viewBox="0 0 24 24" | ||||
|                     fill="none" | ||||
|                     stroke="currentColor" | ||||
|                     stroke-width="2" | ||||
|                     stroke-linecap="round" | ||||
|                     stroke-linejoin="round" | ||||
|                     class="absolute left-0 top-0 h-6 w-6 text-indigo-500" | ||||
|                   > | ||||
|                     <path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M12 7a5 5 0 1 0 5 5" /><path d="M13 3.055a9 9 0 1 0 7.941 7.945" /><path d="M15 6v3h3l3 -3h-3v-3z" /><path d="M15 9l-3 3" /> | ||||
|                   </svg> | ||||
|                   Targeted Audience Reach | ||||
|                 </dt> | ||||
|                 <dd class="inline"> | ||||
|                   Connect with a highly engaged, tech-focused audience across multiple streaming platforms. | ||||
|                 </dd> | ||||
|               </div> | ||||
|               <div class="relative pl-9"> | ||||
|                 <dt class="inline font-semibold text-white"> | ||||
|                   <svg | ||||
|                     xmlns="http://www.w3.org/2000/svg" | ||||
|                     width="24" | ||||
|                     height="24" | ||||
|                     viewBox="0 0 24 24" | ||||
|                     fill="none" | ||||
|                     stroke="currentColor" | ||||
|                     stroke-width="2" | ||||
|                     stroke-linecap="round" | ||||
|                     stroke-linejoin="round" | ||||
|                     class="absolute left-0 top-0 h-6 w-6 text-indigo-500" | ||||
|                   > | ||||
|                     <path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M5 5m0 1a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1z" /><path d="M9 9h6v6h-6z" /><path d="M3 10h2" /><path d="M3 14h2" /><path d="M10 3v2" /><path d="M14 3v2" /><path d="M21 10h-2" /><path d="M21 14h-2" /><path d="M14 21v-2" /><path d="M10 21v-2" /> | ||||
|                   </svg> | ||||
|                   Automated Placement | ||||
|                 </dt> | ||||
|                 <dd class="inline"> | ||||
|                   Our system automatically places your ads in relevant tech content. | ||||
|                 </dd> | ||||
|               </div> | ||||
|               <div class="relative pl-9"> | ||||
|                 <dt class="inline font-semibold text-white"> | ||||
|                   <svg | ||||
|                     xmlns="http://www.w3.org/2000/svg" | ||||
|                     width="24" | ||||
|                     height="24" | ||||
|                     viewBox="0 0 24 24" | ||||
|                     fill="none" | ||||
|                     stroke="currentColor" | ||||
|                     stroke-width="2" | ||||
|                     stroke-linecap="round" | ||||
|                     stroke-linejoin="round" | ||||
|                     class="absolute left-0 top-0 h-6 w-6 text-indigo-500" | ||||
|                   > | ||||
|                     <path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M10 13a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1" /><path d="M15 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M17 10h2a2 2 0 0 1 2 2v1" /><path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M3 13v-1a2 2 0 0 1 2 -2h2" /> | ||||
|                   </svg> | ||||
|                   Wide Reach | ||||
|                 </dt> | ||||
|                 <dd class="inline"> | ||||
|                   Access a vast network of tech-savvy viewers through our platform. | ||||
|                 </dd> | ||||
|               </div> | ||||
|               <div class="relative pl-9"> | ||||
|                 <dt class="inline font-semibold text-white"> | ||||
|                   <svg | ||||
|                     xmlns="http://www.w3.org/2000/svg" | ||||
|                     width="24" | ||||
|                     height="24" | ||||
|                     viewBox="0 0 24 24" | ||||
|                     fill="none" | ||||
|                     stroke="currentColor" | ||||
|                     stroke-width="2" | ||||
|                     stroke-linecap="round" | ||||
|                     stroke-linejoin="round" | ||||
|                     class="absolute left-0 top-0 h-6 w-6 text-indigo-500" | ||||
|                   > | ||||
|                     <path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 3v18h18" /><path d="M20 18v3" /><path d="M16 16v5" /><path d="M12 13v8" /><path d="M8 16v5" /><path d="M3 11c6 0 5 -5 9 -5s3 5 9 5" /> | ||||
|                   </svg> | ||||
|                   Detailed Analytics | ||||
|                 </dt> | ||||
|                 <dd class="inline"> | ||||
|                   Get comprehensive insights into your ad performance with our powerful analytics tools. | ||||
|                 </dd> | ||||
|               </div> | ||||
|               <div class="relative pl-9"> | ||||
|                 <dt class="inline font-semibold text-white"> | ||||
|                   <svg | ||||
|                     xmlns="http://www.w3.org/2000/svg" | ||||
|                     width="24" | ||||
|                     height="24" | ||||
|                     viewBox="0 0 24 24" | ||||
|                     fill="none" | ||||
|                     stroke="currentColor" | ||||
|                     stroke-width="2" | ||||
|                     stroke-linecap="round" | ||||
|                     stroke-linejoin="round" | ||||
|                     class="absolute left-0 top-0 h-6 w-6 text-indigo-500" | ||||
|                   > | ||||
|                     <path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M17 8v-3a1 1 0 0 0 -1 -1h-10a2 2 0 0 0 0 4h12a1 1 0 0 1 1 1v3m0 4v3a1 1 0 0 1 -1 1h-12a2 2 0 0 1 -2 -2v-12" /><path d="M20 12v4h-4a2 2 0 0 1 0 -4h4" /> | ||||
|                   </svg> | ||||
|                   Smart Spending | ||||
|                 </dt> | ||||
|                 <dd class="inline"> | ||||
|                   Get more bang for your buck with our clever, targeted ad approach. | ||||
|                 </dd> | ||||
|               </div> | ||||
|             </dl> | ||||
|           </div> | ||||
|         </div> | ||||
|         <!-- Contact form --> | ||||
|         <div class="isolate px-6 pt-24 sm:pt-32 pb-12 lg:px-8"> | ||||
|           <div | ||||
|             id="contact-form" | ||||
|             class="mx-auto relative px-12 py-12 ring-1 ring-purple-400 max-w-4xl rounded-xl shadow-lg overflow-hidden bg-white/5" | ||||
|           > | ||||
|             <div | ||||
|               class="absolute inset-x-0 -z-10 transform overflow-hidden blur-3xl" | ||||
|               aria-hidden="true" | ||||
|             > | ||||
|               <div | ||||
|                 class="relative -z-10 aspect-[1155/678] w-[36.125rem] max-w-none rotate-[30deg] bg-gradient-to-tr from-[#80caff] to-[#4f46e5] opacity-10 sm:w-[72.1875rem]" | ||||
|                 style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)" | ||||
|               > | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="text-center"> | ||||
|               <h2 class="text-4xl font-bold tracking-tight text-white sm:text-5xl">Work With Us</h2> | ||||
|               <div class="w-96 h-0.5 mx-auto my-4 bg-gradient-to-r from-[#120f22] via-purple-400 to-[#120f22]"> | ||||
|               </div> | ||||
|               <p class="mt-2 text-xl sm:text-2xl tracking-tight leading-8 text-gray-300"> | ||||
|                 We only partner with 1-2 new clients per month. Your application reaches our CEO's inbox faster than the speed of light. | ||||
|               </p> | ||||
|             </div> | ||||
|             <.form for={@form} phx-submit="save" action="#" class="pt-8"> | ||||
|               <div class="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2"> | ||||
|                 <div class="sm:col-span-2"> | ||||
|                   <.input field={@form[:email]} type="email" label="What is your email address?" /> | ||||
|                 </div> | ||||
|                 <div class="sm:col-span-2"> | ||||
|                   <.input field={@form[:website_url]} type="text" label="What is your website?" /> | ||||
|                 </div> | ||||
|                 <div class="sm:col-span-2"> | ||||
|                   <.input field={@form[:revenue]} type="text" label="What is your revenue?" /> | ||||
|                 </div> | ||||
|                 <div class="sm:col-span-2"> | ||||
|                   <.input | ||||
|                     field={@form[:company_location]} | ||||
|                     type="text" | ||||
|                     label="Where is your company based?" | ||||
|                   /> | ||||
|                 </div> | ||||
|               </div> | ||||
|               <div class="mt-10"> | ||||
|                 <.button | ||||
|                   phx-disable-with="Sending..." | ||||
|                   class=" w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-600 active:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-400" | ||||
|                 > | ||||
|                   Let's talk | ||||
|                 </.button> | ||||
|               </div> | ||||
|             </.form> | ||||
|           </div> | ||||
|         </div> | ||||
|       </main> | ||||
|       <!-- Footer --> | ||||
|       <footer aria-labelledby="footer-heading" class="relative"> | ||||
|         <h2 id="footer-heading" class="sr-only">Footer</h2> | ||||
|         <div class="mx-auto max-w-7xl px-6 pb-12 pt-4 lg:px-8"> | ||||
|           <div class="border-t border-white/10 pt-12 md:flex md:items-center md:justify-between"> | ||||
|             <div class="flex space-x-6 md:order-2"> | ||||
|               <a href="https://twitter.com/algoraio" class="text-gray-500 hover:text-gray-400"> | ||||
|                 <span class="sr-only">X</span> | ||||
|                 <svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"> | ||||
|                   <path d="M13.6823 10.6218L20.2391 3H18.6854L12.9921 9.61788L8.44486 3H3.2002L10.0765 13.0074L3.2002 21H4.75404L10.7663 14.0113L15.5685 21H20.8131L13.6819 10.6218H13.6823ZM11.5541 13.0956L10.8574 12.0991L5.31391 4.16971H7.70053L12.1742 10.5689L12.8709 11.5655L18.6861 19.8835H16.2995L11.5541 13.096V13.0956Z" /> | ||||
|                 </svg> | ||||
|               </a> | ||||
|               <a href="https://github.com/algora-io/tv" class="text-gray-500 hover:text-gray-400"> | ||||
|                 <span class="sr-only">GitHub</span> | ||||
|                 <svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"> | ||||
|                   <path | ||||
|                     fill-rule="evenodd" | ||||
|                     d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" | ||||
|                     clip-rule="evenodd" | ||||
|                   /> | ||||
|                 </svg> | ||||
|               </a> | ||||
|               <a href="https://www.youtube.com/@algora-io" class="text-gray-500 hover:text-gray-400"> | ||||
|                 <span class="sr-only">YouTube</span> | ||||
|                 <svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"> | ||||
|                   <path | ||||
|                     fill-rule="evenodd" | ||||
|                     d="M19.812 5.418c.861.23 1.538.907 1.768 1.768C21.998 8.746 22 12 22 12s0 3.255-.418 4.814a2.504 2.504 0 0 1-1.768 1.768c-1.56.419-7.814.419-7.814.419s-6.255 0-7.814-.419a2.505 2.505 0 0 1-1.768-1.768C2 15.255 2 12 2 12s0-3.255.417-4.814a2.507 2.507 0 0 1 1.768-1.768C5.744 5 11.998 5 11.998 5s6.255 0 7.814.418ZM15.194 12 10 15V9l5.194 3Z" | ||||
|                     clip-rule="evenodd" | ||||
|                   /> | ||||
|                 </svg> | ||||
|               </a> | ||||
|             </div> | ||||
|             <div class="mt-8 md:mt-0 md:order-1"> | ||||
|               <.logo /> | ||||
|               <p class="mt-2 text-lg leading-5 text-gray-200 tracking-tight"> | ||||
|                 We work with elite tech startups to provide elite advertising. | ||||
|               </p> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </footer> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> assign(:form, to_form(Contact.change_info(%Info{})))} | ||||
|   end | ||||
|  | ||||
|   def handle_event("save", %{"info" => info_params}, socket) do | ||||
|     case Contact.create_info(info_params) do | ||||
|       {:ok, _info} -> | ||||
|         {:noreply, | ||||
|          socket | ||||
|          |> put_flash(:info, "Thank you for your interest. We'll be in touch soon!") | ||||
|          |> assign(:form, to_form(Contact.change_info(%Info{})))} | ||||
|  | ||||
|       {:error, %Ecto.Changeset{} = changeset} -> | ||||
|         {:noreply, assign(socket, form: to_form(changeset))} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def handle_params(params, _url, socket) do | ||||
|     LayoutComponent.hide_modal() | ||||
|     {:noreply, socket |> apply_action(socket.assigns.live_action, params)} | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :show, _params) do | ||||
|     socket | ||||
|     |> assign(:page_title, "Partner") | ||||
|     |> assign( | ||||
|       :page_description, | ||||
|       "In-video livestream ads that help you stand out in front of millions on Twitch, YouTube and X." | ||||
|     ) | ||||
|     |> assign(:page_image, "#{AlgoraWeb.Endpoint.url()}/images/og/partner.png") | ||||
|   end | ||||
| end | ||||
| @@ -110,9 +110,11 @@ defmodule AlgoraWeb.ShowLive.FormComponent do | ||||
|         consume_uploaded_entry(socket, entry, fn %{path: path} = _meta -> | ||||
|           remote_path = "shows/#{socket.assigns.show.id}/cover/#{System.os_time(:second)}" | ||||
|  | ||||
|           content_type = ExMarcel.MimeType.for({:path, path}) | ||||
|  | ||||
|           {:ok, _} = | ||||
|             Algora.Storage.upload_from_filename(path, remote_path, fn _ -> nil end, | ||||
|               content_type: "image/jpeg" | ||||
|               content_type: content_type | ||||
|             ) | ||||
|  | ||||
|           bucket = Algora.config([:buckets, :media]) | ||||
|   | ||||
| @@ -56,6 +56,13 @@ defmodule AlgoraWeb.Router do | ||||
|     get "/:channel_handle/embed", EmbedPopoutController, :get | ||||
|     get "/:channel_handle/:video_id/embed", EmbedPopoutController, :get_by_id | ||||
|  | ||||
|     live_session :ads, | ||||
|       layout: {AlgoraWeb.Layouts, :live_bare}, | ||||
|       root_layout: {AlgoraWeb.Layouts, :root_embed} do | ||||
|       live "/partner", PartnerLive, :show | ||||
|       live "/:channel_handle/ads", AdOverlayLive, :show | ||||
|     end | ||||
|  | ||||
|     live_session :chat, | ||||
|       layout: {AlgoraWeb.Layouts, :live_chat}, | ||||
|       root_layout: {AlgoraWeb.Layouts, :root_embed} do | ||||
| @@ -75,8 +82,16 @@ defmodule AlgoraWeb.Router do | ||||
|   scope "/", AlgoraWeb do | ||||
|     pipe_through :browser | ||||
|  | ||||
|     get "/go/:slug", AdRedirectController, :go | ||||
|  | ||||
|     delete "/auth/logout", OAuthCallbackController, :sign_out | ||||
|  | ||||
|     live_session :schedule, | ||||
|       on_mount: [{AlgoraWeb.UserAuth, :current_user}, AlgoraWeb.Nav] do | ||||
|       live "/ads/schedule", AdLive.Schedule, :schedule | ||||
|       live "/analytics/:slug", AdLive.Analytics, :show | ||||
|     end | ||||
|  | ||||
|     live_session :admin, | ||||
|       on_mount: [ | ||||
|         {AlgoraWeb.UserAuth, :ensure_authenticated}, | ||||
| @@ -85,6 +100,14 @@ defmodule AlgoraWeb.Router do | ||||
|       ] do | ||||
|       live "/shows", ShowLive.Index, :index | ||||
|       live "/shows/new", ShowLive.Index, :new | ||||
|  | ||||
|       live "/ads", AdLive.Index, :index | ||||
|       live "/ads/new", AdLive.Index, :new | ||||
|       live "/ads/:id/edit", AdLive.Index, :edit | ||||
|       live "/ads/:id", AdLive.Show, :show | ||||
|       live "/ads/:id/show/edit", AdLive.Show, :edit | ||||
|  | ||||
|       live "/admin/content", ContentLive, :show | ||||
|     end | ||||
|  | ||||
|     live_session :authenticated, | ||||
|   | ||||
							
								
								
									
										1
									
								
								mix.exs
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								mix.exs
									
									
									
									
									
								
							| @@ -44,7 +44,6 @@ defmodule Algora.MixProject do | ||||
|       {:ex_m3u8, "~> 0.9.0"}, | ||||
|       {:ex_marcel, "~> 0.1.0"}, | ||||
|       {:exla, ">= 0.0.0"}, | ||||
|       {:exsync, "~> 0.2", only: :dev}, | ||||
|       {:ffmpex, "~> 0.10.0"}, | ||||
|       {:finch, "~> 0.18"}, | ||||
|       {:floki, ">= 0.30.0", only: :test}, | ||||
|   | ||||
							
								
								
									
										26
									
								
								priv/repo/migrations/20240729183655_create_ads.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								priv/repo/migrations/20240729183655_create_ads.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| defmodule Algora.Repo.Migrations.CreateAds do | ||||
|   use Ecto.Migration | ||||
|  | ||||
|   def change do | ||||
|     create table(:ads) do | ||||
|       add :verified, :boolean, default: false, null: false | ||||
|       add :website_url, :string | ||||
|       add :composite_asset_url, :string | ||||
|       add :asset_url, :string | ||||
|       add :logo_url, :string | ||||
|       add :qrcode_url, :string | ||||
|       add :start_date, :naive_datetime | ||||
|       add :end_date, :naive_datetime | ||||
|       add :total_budget, :integer | ||||
|       add :daily_budget, :integer | ||||
|       add :tech_stack, {:array, :string} | ||||
|       add :click_count, :integer | ||||
|       add :status, :string | ||||
|       add :user_id, references(:users, on_delete: :nothing) | ||||
|  | ||||
|       timestamps() | ||||
|     end | ||||
|  | ||||
|     create index(:ads, [:user_id]) | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,17 @@ | ||||
| defmodule Algora.Repo.Migrations.CreateAdImpressions do | ||||
|   use Ecto.Migration | ||||
|  | ||||
|   def change do | ||||
|     create table(:ad_impressions) do | ||||
|       add :duration, :integer | ||||
|       add :viewers_count, :integer | ||||
|       add :ad_id, references(:ads, on_delete: :nothing) | ||||
|       add :video_id, references(:videos, on_delete: :nothing) | ||||
|  | ||||
|       timestamps() | ||||
|     end | ||||
|  | ||||
|     create index(:ad_impressions, [:ad_id]) | ||||
|     create index(:ad_impressions, [:video_id]) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										19
									
								
								priv/repo/migrations/20240730000856_create_ad_visits.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								priv/repo/migrations/20240730000856_create_ad_visits.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| defmodule Algora.Repo.Migrations.CreateAdVisits do | ||||
|   use Ecto.Migration | ||||
|  | ||||
|   def change do | ||||
|     create table(:ad_visits) do | ||||
|       add :ad_id, references(:ads, on_delete: :nothing) | ||||
|       add :video_id, references(:videos, on_delete: :nothing) | ||||
|  | ||||
|       timestamps() | ||||
|     end | ||||
|  | ||||
|     alter table(:ads) do | ||||
|       remove :click_count | ||||
|     end | ||||
|  | ||||
|     create index(:ad_visits, [:ad_id]) | ||||
|     create index(:ad_visits, [:video_id]) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										14
									
								
								priv/repo/migrations/20240730160846_create_contact_info.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								priv/repo/migrations/20240730160846_create_contact_info.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| defmodule Algora.Repo.Migrations.CreateContactInfo do | ||||
|   use Ecto.Migration | ||||
|  | ||||
|   def change do | ||||
|     create table(:contact_info) do | ||||
|       add :email, :string | ||||
|       add :website_url, :string | ||||
|       add :revenue, :string | ||||
|       add :company_location, :string | ||||
|  | ||||
|       timestamps() | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										11
									
								
								priv/repo/migrations/20240802162318_add_slug_to_ads.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								priv/repo/migrations/20240802162318_add_slug_to_ads.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| defmodule Algora.Repo.Local.Migrations.AddSlugToAds do | ||||
|   use Ecto.Migration | ||||
|  | ||||
|   def change do | ||||
|     alter table(:ads) do | ||||
|       add :slug, :string | ||||
|     end | ||||
|  | ||||
|     create unique_index(:ads, [:slug]) | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,9 @@ | ||||
| defmodule Algora.Repo.Local.Migrations.AddBorderColorToAds do | ||||
|   use Ecto.Migration | ||||
|  | ||||
|   def change do | ||||
|     alter table(:ads) do | ||||
|       add :border_color, :string | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,45 @@ | ||||
| defmodule Algora.Repo.Migrations.CreateAdRelatedTables do | ||||
|   use Ecto.Migration | ||||
|  | ||||
|   def change do | ||||
|     create table(:product_reviews) do | ||||
|       add :clip_from, :integer, null: false | ||||
|       add :clip_to, :integer, null: false | ||||
|       add :thumbnail_url, :string | ||||
|       add :ad_id, references(:ads, on_delete: :nothing), null: false | ||||
|       add :video_id, references(:videos, on_delete: :nothing), null: false | ||||
|  | ||||
|       timestamps() | ||||
|     end | ||||
|  | ||||
|     create index(:product_reviews, [:ad_id]) | ||||
|     create index(:product_reviews, [:video_id]) | ||||
|  | ||||
|     create table(:ad_appearances) do | ||||
|       add :airtime, :integer, null: false | ||||
|       add :ad_id, references(:ads, on_delete: :nothing), null: false | ||||
|       add :video_id, references(:videos, on_delete: :nothing), null: false | ||||
|  | ||||
|       timestamps() | ||||
|     end | ||||
|  | ||||
|     create index(:ad_appearances, [:ad_id]) | ||||
|     create index(:ad_appearances, [:video_id]) | ||||
|  | ||||
|     create table(:content_metrics) do | ||||
|       add :algora_stream_url, :string | ||||
|       add :twitch_stream_url, :string | ||||
|       add :youtube_video_url, :string | ||||
|       add :twitter_video_url, :string | ||||
|       add :twitch_avg_concurrent_viewers, :integer | ||||
|       add :twitch_views, :integer | ||||
|       add :youtube_views, :integer | ||||
|       add :twitter_views, :integer | ||||
|       add :video_id, references(:videos, on_delete: :nothing), null: false | ||||
|  | ||||
|       timestamps() | ||||
|     end | ||||
|  | ||||
|     create index(:content_metrics, [:video_id]) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										11
									
								
								priv/repo/migrations/20240814021502_add_name_to_ads.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								priv/repo/migrations/20240814021502_add_name_to_ads.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| defmodule Algora.Repo.Local.Migrations.AddNameToAds do | ||||
|   use Ecto.Migration | ||||
|  | ||||
|   def change do | ||||
|     alter table(:ads) do | ||||
|       add :name, :string | ||||
|     end | ||||
|  | ||||
|     execute "UPDATE ads SET name = INITCAP(slug)" | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,17 @@ | ||||
| defmodule Algora.Repo.Local.Migrations.UpdateContentMetricsFields do | ||||
|   use Ecto.Migration | ||||
|  | ||||
|   def change do | ||||
|     execute "UPDATE content_metrics SET twitch_avg_concurrent_viewers = COALESCE(twitch_avg_concurrent_viewers, 0)" | ||||
|     execute "UPDATE content_metrics SET twitch_views = COALESCE(twitch_views, 0)" | ||||
|     execute "UPDATE content_metrics SET youtube_views = COALESCE(youtube_views, 0)" | ||||
|     execute "UPDATE content_metrics SET twitter_views = COALESCE(twitter_views, 0)" | ||||
|  | ||||
|     alter table(:content_metrics) do | ||||
|       modify :twitch_avg_concurrent_viewers, :integer, null: false, default: 0, from: :integer | ||||
|       modify :twitch_views, :integer, null: false, default: 0, from: :integer | ||||
|       modify :youtube_views, :integer, null: false, default: 0, from: :integer | ||||
|       modify :twitter_views, :integer, null: false, default: 0, from: :integer | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,9 @@ | ||||
| defmodule Algora.Repo.Local.Migrations.AddOgImageUrlToAds do | ||||
|   use Ecto.Migration | ||||
|  | ||||
|   def change do | ||||
|     alter table(:ads) do | ||||
|       add :og_image_url, :string | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										
											BIN
										
									
								
								priv/static/images/analytics.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								priv/static/images/analytics.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 259 KiB | 
							
								
								
									
										
											BIN
										
									
								
								priv/static/images/og/partner.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								priv/static/images/og/partner.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 329 KiB | 
							
								
								
									
										
											BIN
										
									
								
								priv/static/images/partner-demo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								priv/static/images/partner-demo.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 870 KiB | 
| @@ -7,10 +7,6 @@ import Ecto.Changeset | ||||
| alias Algora.{Accounts, Library, Repo, Storage, Cache, ML} | ||||
|  | ||||
| IEx.configure(inspect: [charlists: :as_lists]) | ||||
|  | ||||
| if Code.ensure_loaded?(ExSync) && function_exported?(ExSync, :register_group_leader, 0) do | ||||
|   ExSync.register_group_leader() | ||||
| end | ||||
| ``` | ||||
|  | ||||
| ## Section | ||||
|   | ||||
							
								
								
									
										33
									
								
								test/algora/ads_test.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								test/algora/ads_test.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| defmodule Algora.AdsTest do | ||||
|   use Algora.DataCase | ||||
|  | ||||
|   alias Algora.Ads | ||||
|  | ||||
|   describe "next_slot/1" do | ||||
|     test "returns the next 10-minute slot" do | ||||
|       # Test case 1: Exactly at the start of a slot | ||||
|       time = ~U[2024-08-03 10:00:00.000Z] | ||||
|       assert Ads.next_slot(time) == ~U[2024-08-03 10:10:00.000Z] | ||||
|  | ||||
|       # Test case 2: In the middle of a slot | ||||
|       time = ~U[2024-08-03 10:05:30.123Z] | ||||
|       assert Ads.next_slot(time) == ~U[2024-08-03 10:10:00.000Z] | ||||
|  | ||||
|       # Test case 3: Just before the next slot | ||||
|       time = ~U[2024-08-03 10:09:59.999Z] | ||||
|       assert Ads.next_slot(time) == ~U[2024-08-03 10:10:00.000Z] | ||||
|  | ||||
|       # Test case 4: Crossing an hour boundary | ||||
|       time = ~U[2024-08-03 10:55:00.123Z] | ||||
|       assert Ads.next_slot(time) == ~U[2024-08-03 11:00:00.000Z] | ||||
|  | ||||
|       # Test case 5: Crossing a day boundary | ||||
|       time = ~U[2024-08-03 23:55:00.123Z] | ||||
|       assert Ads.next_slot(time) == ~U[2024-08-04 00:00:00.000Z] | ||||
|     end | ||||
|  | ||||
|     test "uses current time when no argument is provided" do | ||||
|       assert Ads.next_slot() == Ads.next_slot(DateTime.utc_now()) | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										38
									
								
								test/support/conn_case.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								test/support/conn_case.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| defmodule AlgoraWeb.ConnCase do | ||||
|   @moduledoc """ | ||||
|   This module defines the test case to be used by | ||||
|   tests that require setting up a connection. | ||||
|  | ||||
|   Such tests rely on `Phoenix.ConnTest` and also | ||||
|   import other functionality to make it easier | ||||
|   to build common data structures and query the data layer. | ||||
|  | ||||
|   Finally, if the test case interacts with the database, | ||||
|   we enable the SQL sandbox, so changes done to the database | ||||
|   are reverted at the end of every test. If you are using | ||||
|   PostgreSQL, you can even run database tests asynchronously | ||||
|   by setting `use AlgoraWeb.ConnCase, async: true`, although | ||||
|   this option is not recommended for other databases. | ||||
|   """ | ||||
|  | ||||
|   use ExUnit.CaseTemplate | ||||
|  | ||||
|   using do | ||||
|     quote do | ||||
|       # The default endpoint for testing | ||||
|       @endpoint AlgoraWeb.Endpoint | ||||
|  | ||||
|       use AlgoraWeb, :verified_routes | ||||
|  | ||||
|       # Import conveniences for testing with connections | ||||
|       import Plug.Conn | ||||
|       import Phoenix.ConnTest | ||||
|       import AlgoraWeb.ConnCase | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   setup tags do | ||||
|     Algora.DataCase.setup_sandbox(tags) | ||||
|     {:ok, conn: Phoenix.ConnTest.build_conn()} | ||||
|   end | ||||
| end | ||||
							
								
								
									
										58
									
								
								test/support/data_case.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								test/support/data_case.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| defmodule Algora.DataCase do | ||||
|   @moduledoc """ | ||||
|   This module defines the setup for tests requiring | ||||
|   access to the application's data layer. | ||||
|  | ||||
|   You may define functions here to be used as helpers in | ||||
|   your tests. | ||||
|  | ||||
|   Finally, if the test case interacts with the database, | ||||
|   we enable the SQL sandbox, so changes done to the database | ||||
|   are reverted at the end of every test. If you are using | ||||
|   PostgreSQL, you can even run database tests asynchronously | ||||
|   by setting `use Algora.DataCase, async: true`, although | ||||
|   this option is not recommended for other databases. | ||||
|   """ | ||||
|  | ||||
|   use ExUnit.CaseTemplate | ||||
|  | ||||
|   using do | ||||
|     quote do | ||||
|       alias Algora.Repo | ||||
|  | ||||
|       import Ecto | ||||
|       import Ecto.Changeset | ||||
|       import Ecto.Query | ||||
|       import Algora.DataCase | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   setup tags do | ||||
|     Algora.DataCase.setup_sandbox(tags) | ||||
|     :ok | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Sets up the sandbox based on the test tags. | ||||
|   """ | ||||
|   def setup_sandbox(tags) do | ||||
|     pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Algora.Repo, shared: not tags[:async]) | ||||
|     on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   A helper that transforms changeset errors into a map of messages. | ||||
|  | ||||
|       assert {:error, changeset} = Accounts.create_user(%{password: "short"}) | ||||
|       assert "password is too short" in errors_on(changeset).password | ||||
|       assert %{password: ["password is too short"]} = errors_on(changeset) | ||||
|  | ||||
|   """ | ||||
|   def errors_on(changeset) do | ||||
|     Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> | ||||
|       Regex.replace(~r"%{(\w+)}", message, fn _, key -> | ||||
|         opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() | ||||
|       end) | ||||
|     end) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										2
									
								
								test/test_helper.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								test/test_helper.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| ExUnit.start() | ||||
| Ecto.Adapters.SQL.Sandbox.mode(Algora.Repo, :manual) | ||||
		Reference in New Issue
	
	Block a user