defmodule PhoenixKit.Modules.Shop.Web.Imports do @moduledoc """ Admin LiveView for managing Shopify CSV imports. Features: - File upload with drag-and-drop - Import history table with statistics - Real-time progress tracking via PubSub - Retry failed imports """ use PhoenixKitWeb, :live_view alias PhoenixKit.Modules.Shop alias PhoenixKit.Modules.Shop.ImportLog alias PhoenixKit.Modules.Shop.Translations alias PhoenixKit.Modules.Shop.Workers.CSVImportWorker alias PhoenixKit.PubSub.Manager alias PhoenixKit.Utils.Routes require Logger @impl true def mount(_params, _session, socket) do if connected?(socket) do # Subscribe to import updates Manager.subscribe("shop:imports") end # Get current language for import current_language = Translations.default_language() socket = socket |> assign(:page_title, "CSV Import") |> assign(:imports, list_imports()) |> assign(:current_import, nil) |> assign(:import_progress, nil) |> assign(:current_language, current_language) |> allow_upload(:csv_file, accept: ~w(.csv), max_file_size: 50_000_000, max_entries: 1, auto_upload: true, progress: &handle_progress/3 ) {:ok, socket} end @impl true def handle_event("validate", _params, socket) do {:noreply, socket} end @impl true def handle_event("cancel_upload", %{"ref" => ref}, socket) do {:noreply, cancel_upload(socket, :csv_file, ref)} end @impl true def handle_event("start_import", _params, socket) do user = socket.assigns.phoenix_kit_current_scope.user # Consume uploaded file case consume_uploaded_entries(socket, :csv_file, fn %{path: path}, entry -> # Copy to persistent location dest_dir = Path.join(System.tmp_dir!(), "shop_imports") File.mkdir_p!(dest_dir) dest_path = Path.join(dest_dir, "#{System.system_time(:millisecond)}_#{entry.client_name}") File.cp!(path, dest_path) {:ok, {dest_path, entry.client_name}} end) do [{dest_path, filename}] -> # Create import log case Shop.create_import_log(%{ filename: filename, file_path: dest_path, user_id: user.id, options: %{} }) do {:ok, import_log} -> # Enqueue Oban job with language language = socket.assigns.current_language %{import_log_id: import_log.id, path: dest_path, language: language} |> CSVImportWorker.new() |> Oban.insert() # Subscribe to this specific import Manager.subscribe("shop:import:#{import_log.id}") socket = socket |> assign(:current_import, import_log) |> assign(:import_progress, %{percent: 0, current: 0, total: 0}) |> assign(:imports, list_imports()) |> put_flash(:info, "Import started: #{filename}") {:noreply, socket} {:error, _changeset} -> {:noreply, put_flash(socket, :error, "Failed to create import log")} end [] -> {:noreply, put_flash(socket, :error, "Please select a CSV file first")} end end @impl true def handle_event("retry_import", %{"id" => id}, socket) do case Shop.get_import_log(id) do nil -> {:noreply, put_flash(socket, :error, "Import not found")} import_log -> if import_log.status == "failed" && import_log.file_path && File.exists?(import_log.file_path) do # Reset import log status {:ok, updated_log} = Shop.update_import_log(import_log, %{status: "pending", error_details: []}) # Re-enqueue job with language language = socket.assigns.current_language %{import_log_id: updated_log.id, path: import_log.file_path, language: language} |> CSVImportWorker.new() |> Oban.insert() # Subscribe to updates Manager.subscribe("shop:import:#{updated_log.id}") socket = socket |> assign(:current_import, updated_log) |> assign(:import_progress, %{percent: 0, current: 0, total: 0}) |> assign(:imports, list_imports()) |> put_flash(:info, "Retrying import: #{import_log.filename}") {:noreply, socket} else {:noreply, put_flash(socket, :error, "Cannot retry: file no longer exists")} end end end @impl true def handle_event("delete_import", %{"id" => id}, socket) do case Shop.get_import_log(id) do nil -> {:noreply, put_flash(socket, :error, "Import not found")} import_log -> case Shop.delete_import_log(import_log) do {:ok, _} -> socket = socket |> assign(:imports, list_imports()) |> put_flash(:info, "Import log deleted") {:noreply, socket} {:error, _} -> {:noreply, put_flash(socket, :error, "Failed to delete import log")} end end end # Handle PubSub messages @impl true def handle_info({:import_started, %{total: total}}, socket) do socket = socket |> assign(:import_progress, %{percent: 0, current: 0, total: total}) |> assign(:imports, list_imports()) {:noreply, socket} end @impl true def handle_info({:import_progress, progress}, socket) do {:noreply, assign(socket, :import_progress, progress)} end @impl true def handle_info({:import_complete, _stats}, socket) do socket = socket |> assign(:current_import, nil) |> assign(:import_progress, nil) |> assign(:imports, list_imports()) |> put_flash(:info, "Import completed successfully!") {:noreply, socket} end @impl true def handle_info({:import_failed, %{reason: reason}}, socket) do socket = socket |> assign(:current_import, nil) |> assign(:import_progress, nil) |> assign(:imports, list_imports()) |> put_flash(:error, "Import failed: #{reason}") {:noreply, socket} end # Catch-all for other messages @impl true def handle_info(_message, socket) do {:noreply, socket} end defp handle_progress(:csv_file, entry, socket) do if entry.done? do {:noreply, socket} else {:noreply, socket} end end defp list_imports do Shop.list_import_logs(limit: 20, order_by: [desc: :inserted_at]) end @impl true def render(assigns) do ~H"""
<%!-- Header --%>
<.link navigate={Routes.path("/admin/shop")} class="btn btn-outline btn-primary btn-sm shrink-0" > <.icon name="hero-arrow-left" class="w-4 h-4 mr-2" /> Back

CSV Import

Import products from Shopify CSV files

<%!-- Upload Card --%>

<.icon name="hero-cloud-arrow-up" class="w-6 h-6" /> Upload CSV File

<%= if @current_import do %> <%!-- Import in Progress --%>
<.icon name="hero-arrow-path" class="w-6 h-6 animate-spin" />

Import in Progress

{@current_import.filename}

<%= if @import_progress do %>

{@import_progress.current} / {@import_progress.total} products ({@import_progress.percent}%)

<% end %>
<% else %> <%!-- File Upload Zone --%>
<.live_file_input upload={@uploads.csv_file} class="hidden" />
<%!-- Upload Progress --%> <%= for entry <- @uploads.csv_file.entries do %>
{entry.client_name}
<%= for err <- upload_errors(@uploads.csv_file, entry) do %>

{error_to_string(err)}

<% end %>
<% end %> <%!-- Start Import Button --%> <%= if length(@uploads.csv_file.entries) > 0 do %> <% entry = List.first(@uploads.csv_file.entries) %> <%= if entry.done? do %> <% end %> <% end %>
<% end %>
<%!-- Import History --%>

<.icon name="hero-clock" class="w-6 h-6" /> Import History

<%= if Enum.empty?(@imports) do %>
<.icon name="hero-inbox" class="w-12 h-12 mx-auto mb-2 opacity-50" />

No imports yet

<% else %>
<%= for import <- @imports do %> <% end %>
File Status Progress Results Date
{import.filename} <.status_badge status={import.status} /> <%= if import.status == "processing" do %> <% else %> {ImportLog.progress_percent(import)}% <% end %> {import.imported_count} new {import.updated_count} updated <%= if import.error_count > 0 do %> {import.error_count} errors <% end %> {format_datetime(import.inserted_at)}
<%= if import.status == "failed" do %> <% end %> <%= if import.status in ["completed", "failed"] do %> <% end %>
<% end %>
<%!-- Info Alert --%>
<.icon name="hero-information-circle" class="w-6 h-6" />

About CSV Import

  • Only 3D printed products are imported (decals and other items are filtered)
  • Products are automatically categorized based on title
  • Existing products with the same handle are updated
  • Import runs in the background - you can leave this page
""" end defp status_badge(assigns) do ~H""" "badge-neutral" "processing" -> "badge-info" "completed" -> "badge-success" "failed" -> "badge-error" _ -> "badge-ghost" end ]}> {@status} """ end defp format_datetime(nil), do: "-" defp format_datetime(datetime) do Calendar.strftime(datetime, "%b %d, %Y %H:%M") end defp error_to_string(:too_large), do: "File is too large (max 50MB)" defp error_to_string(:not_accepted), do: "Only CSV files are accepted" defp error_to_string(:too_many_files), do: "Only one file at a time" defp error_to_string(err), do: inspect(err) end