defmodule PhoenixKit.Modules.Shop.Web.Imports do @moduledoc """ Admin LiveView for managing CSV product imports. Supports multiple CSV formats (Shopify, Prom.ua, etc.) via the ImportFormat behaviour. Format is auto-detected from file headers after upload. Features: - Multi-step import wizard with format-aware steps - File upload with drag-and-drop - Option mapping UI for formats that require it (e.g. Shopify) - Direct import for formats that don't (e.g. Prom.ua) - 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.Import.{CSVAnalyzer, FormatDetector} alias PhoenixKit.Modules.Shop.ImportLog alias PhoenixKit.Modules.Shop.Options alias PhoenixKit.Modules.Shop.Services.ImageMigration 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") # Subscribe to image migration updates Manager.subscribe("shop:image_migration:batch") # Subscribe to any active imports (processing status) subscribe_to_active_imports() end # Language selection for import enabled_languages = Translations.enabled_languages() current_language = Translations.default_language() show_language_selector = length(enabled_languages) > 1 # Get image migration stats migration_stats = ImageMigration.migration_stats() # Get global options for mapping UI global_options = Options.get_enabled_global_options() # Load import configs for filter selection import_configs = Shop.list_import_configs(active_only: true) default_config = Shop.get_default_import_config() socket = socket |> assign(:page_title, "CSV Import") |> assign(:imports, list_imports()) |> assign(:current_import, nil) |> assign(:import_progress, nil) |> assign(:current_language, current_language) |> assign(:enabled_languages, enabled_languages) |> assign(:show_language_selector, show_language_selector) |> assign(:download_images, false) |> assign(:skip_empty_categories, true) |> assign(:migration_stats, migration_stats) |> assign(:migration_in_progress, migration_stats.in_progress > 0) |> assign(:import_configs, import_configs) |> assign(:selected_config, default_config) |> assign(:selected_config_id, if(default_config, do: default_config.id)) # Multi-step wizard state |> assign(:import_step, :upload) |> assign(:format_mod, nil) |> assign(:format_name, nil) |> assign(:csv_analysis, nil) |> assign(:option_mappings, []) |> assign(:uploaded_file_path, nil) |> assign(:uploaded_filename, nil) |> assign(:confirm_product_count, nil) |> assign(:global_options, global_options) |> 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 # Multi-step: consume upload, detect format, then route to appropriate step case consume_uploaded_entries(socket, :csv_file, fn %{path: path}, entry -> 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}] -> # Detect format from file headers case FormatDetector.detect(dest_path) do {:ok, format_mod} -> if format_mod.requires_option_mapping?() do # Shopify path: analyze CSV, show mapping UI handle_mapping_format(socket, dest_path, filename, format_mod) else # Prom.ua path: skip configure, go to confirm handle_direct_format(socket, dest_path, filename, format_mod) end {:error, :unknown_format} -> File.rm(dest_path) {:noreply, put_flash(socket, :error, "Unrecognized CSV format")} {:error, _reason} -> File.rm(dest_path) {:noreply, put_flash(socket, :error, "Failed to read CSV file headers")} end [] -> {:noreply, put_flash(socket, :error, "Please select a CSV file first")} end end @impl true def handle_event("confirm_import", _params, socket) do # Direct import from confirm step (no option mappings) run_import_with_mappings(socket, []) end @impl true def handle_event("skip_mapping", _params, socket) do # Skip mapping step and run import directly run_import_with_mappings(socket, []) end @impl true def handle_event("run_import", _params, socket) do # Run import with current mappings mappings = socket.assigns.option_mappings run_import_with_mappings(socket, mappings) end @impl true def handle_event("update_mapping", %{"index" => index_str} = params, socket) do index = String.to_integer(index_str) mappings = socket.assigns.option_mappings updated_mapping = mappings |> Enum.at(index) |> update_mapping_from_params(params) updated_mappings = List.replace_at(mappings, index, updated_mapping) {:noreply, assign(socket, :option_mappings, updated_mappings)} end @impl true def handle_event("toggle_auto_add", %{"index" => index_str}, socket) do index = String.to_integer(index_str) mappings = socket.assigns.option_mappings updated_mapping = mappings |> Enum.at(index) |> Map.update!(:auto_add, &(!&1)) updated_mappings = List.replace_at(mappings, index, updated_mapping) {:noreply, assign(socket, :option_mappings, updated_mappings)} end @impl true def handle_event("back_to_upload", _params, socket) do if socket.assigns.uploaded_file_path do File.rm(socket.assigns.uploaded_file_path) end socket = socket |> assign(:import_step, :upload) |> assign(:format_mod, nil) |> assign(:format_name, nil) |> assign(:csv_analysis, nil) |> assign(:option_mappings, []) |> assign(:uploaded_file_path, nil) |> assign(:uploaded_filename, nil) |> assign(:confirm_product_count, nil) {:noreply, socket} end @impl true def handle_event("retry_import", %{"id" => id}, socket) do case parse_id(id) do {:ok, import_id} -> do_retry_import(import_id, socket) :error -> {:noreply, put_flash(socket, :error, "Invalid import ID")} end end @impl true def handle_event("delete_import", %{"id" => id}, socket) do case parse_id(id) do {:ok, import_id} -> do_delete_import(import_id, socket) :error -> {:noreply, put_flash(socket, :error, "Invalid import ID")} end end @impl true def handle_event("toggle_download_images", _params, socket) do {:noreply, assign(socket, :download_images, not socket.assigns.download_images)} end @impl true def handle_event("toggle_skip_empty_categories", _params, socket) do {:noreply, assign(socket, :skip_empty_categories, not socket.assigns.skip_empty_categories)} end @impl true def handle_event("select_language", %{"language" => lang}, socket) do {:noreply, assign(socket, :current_language, lang)} end @impl true def handle_event("select_config", %{"config_id" => ""}, socket) do socket = socket |> assign(:selected_config, nil) |> assign(:selected_config_id, nil) |> maybe_reanalyze_csv() {:noreply, socket} end @impl true def handle_event("select_config", %{"config_id" => id_str}, socket) do case Integer.parse(id_str) do {id, ""} -> config = Enum.find(socket.assigns.import_configs, &(&1.id == id)) socket = socket |> assign(:selected_config, config) |> assign(:selected_config_id, if(config, do: config.id)) |> maybe_reanalyze_csv() {:noreply, socket} _ -> {:noreply, socket} end end @impl true def handle_event("start_image_migration", _params, socket) do user = socket.assigns.phoenix_kit_current_scope.user {:ok, count} = ImageMigration.queue_all_migrations(user.id) socket = socket |> assign(:migration_in_progress, true) |> assign(:migration_stats, ImageMigration.migration_stats()) |> put_flash(:info, "Started migration for #{count} products") {:noreply, socket} end @impl true def handle_event("cancel_image_migration", _params, socket) do case ImageMigration.cancel_pending_migrations() do {:ok, count} -> socket = socket |> assign(:migration_in_progress, false) |> assign(:migration_stats, ImageMigration.migration_stats()) |> put_flash(:info, "Cancelled #{count} pending migration jobs") {:noreply, socket} end end @impl true def handle_event("refresh_migration_stats", _params, socket) do stats = ImageMigration.migration_stats() socket = socket |> assign(:migration_stats, stats) |> assign(:migration_in_progress, stats.in_progress > 0) {:noreply, socket} 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(:import_step, :upload) |> assign(:format_mod, nil) |> assign(:format_name, nil) |> assign(:csv_analysis, nil) |> assign(:option_mappings, []) |> assign(:uploaded_file_path, nil) |> assign(:uploaded_filename, nil) |> assign(:confirm_product_count, 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(:import_step, :upload) |> assign(:format_mod, nil) |> assign(:format_name, nil) |> assign(:csv_analysis, nil) |> assign(:option_mappings, []) |> assign(:uploaded_file_path, nil) |> assign(:uploaded_filename, nil) |> assign(:confirm_product_count, nil) |> assign(:imports, list_imports()) |> put_flash(:error, "Import failed: #{reason}") {:noreply, socket} end # Image migration PubSub handlers @impl true def handle_info({:migration_started, %{total: total}}, socket) do Logger.info("Image migration started for #{total} products") socket = socket |> assign(:migration_in_progress, true) |> assign(:migration_stats, ImageMigration.migration_stats()) {:noreply, socket} end @impl true def handle_info( {:product_migrated, %{product_id: _product_id, images_migrated: _count}}, socket ) do # Update stats on each product completion stats = ImageMigration.migration_stats() socket = socket |> assign(:migration_stats, stats) |> assign(:migration_in_progress, stats.in_progress > 0) {:noreply, socket} end @impl true def handle_info({:migration_cancelled, %{cancelled: count}}, socket) do Logger.info("Image migration cancelled: #{count} jobs") socket = socket |> assign(:migration_in_progress, false) |> assign(:migration_stats, ImageMigration.migration_stats()) {: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 # Subscribe to any imports currently in "processing" status defp subscribe_to_active_imports do Shop.list_import_logs(limit: 10, order_by: [desc: :inserted_at]) |> Enum.filter(&(&1.status == "processing")) |> Enum.each(fn import_log -> Manager.subscribe("shop:import:#{import_log.id}") end) end # Parse ID from phx-value (comes as string from the template) # Supports both integer IDs and UUID strings defp parse_id(id) when is_integer(id), do: {:ok, id} defp parse_id(id) when is_binary(id) do case Integer.parse(id) do {int, ""} -> {:ok, int} _ -> if match?({:ok, _}, Ecto.UUID.cast(id)), do: {:ok, id}, else: :error end end defp parse_id(_), do: :error defp do_retry_import(import_id, socket) do case Shop.get_import_log(import_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 and config_id language = socket.assigns.current_language config_id = get_in(import_log.options, ["config_id"]) worker_args = %{ import_log_id: updated_log.id, path: import_log.file_path, language: language } worker_args = if config_id, do: Map.put(worker_args, :config_id, config_id), else: worker_args worker_args |> 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 defp do_delete_import(import_id, socket) do case Shop.get_import_log(import_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 # Run import with given mappings defp run_import_with_mappings(socket, mappings) do user = socket.assigns.phoenix_kit_current_scope.user dest_path = socket.assigns.uploaded_file_path filename = socket.assigns.uploaded_filename # Add new values to global options if auto_add enabled add_new_values_to_global_options(mappings, socket.assigns.global_options) # Convert mappings to format expected by worker worker_mappings = convert_mappings_for_worker(mappings) # Create import log with config_id config_id = socket.assigns.selected_config_id case Shop.create_import_log(%{ filename: filename, file_path: dest_path, user_id: user.id, user_uuid: user.uuid, options: %{"option_mappings" => worker_mappings, "config_id" => config_id} }) do {:ok, import_log} -> # Enqueue Oban job with language, mappings, config_id, and download_images option language = socket.assigns.current_language download_images = socket.assigns.download_images skip_empty_categories = socket.assigns[:skip_empty_categories] || false worker_args = %{ import_log_id: import_log.id, path: dest_path, language: language, option_mappings: worker_mappings, download_images: download_images, skip_empty_categories: skip_empty_categories } worker_args = if config_id, do: Map.put(worker_args, :config_id, config_id), else: worker_args worker_args |> 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()) |> assign(:import_step, :importing) |> put_flash(:info, "Import started: #{filename}") {:noreply, socket} {:error, _changeset} -> {:noreply, put_flash(socket, :error, "Failed to create import log")} end end # Build initial mappings by matching CSV options to global options defp build_initial_mappings(csv_options, global_options) do Enum.map(csv_options, fn csv_opt -> # Try to find matching global option matching_global = find_matching_global_option(csv_opt.name, global_options) # Compare values if we have a match comparison = if matching_global do CSVAnalyzer.compare_with_global_option(csv_opt.values, matching_global) else %{existing: [], new: csv_opt.values} end %{ csv_name: csv_opt.name, csv_position: csv_opt.position, csv_values: csv_opt.values, source_key: if(matching_global, do: matching_global["key"], else: nil), slot_key: normalize_slot_key(csv_opt.name), label: csv_opt.name, auto_add: false, new_values: comparison.new, existing_values: comparison.existing, global_option: matching_global } end) end # Find global option that might match the CSV option name defp find_matching_global_option(csv_name, global_options) do # Normalize names for comparison normalized_csv = normalize_for_comparison(csv_name) Enum.find(global_options, fn opt -> opt_key = opt["key"] opt_label = get_option_label(opt) normalized_csv == normalize_for_comparison(opt_key) or normalized_csv == normalize_for_comparison(opt_label) or String.contains?(normalized_csv, normalize_for_comparison(opt_key)) end) end defp get_option_label(%{"label" => label}) when is_binary(label), do: label defp get_option_label(%{"label" => label}) when is_map(label), do: Map.get(label, "en", "") defp get_option_label(_), do: "" defp normalize_for_comparison(str) when is_binary(str) do str |> String.downcase() |> String.replace(~r/[\s_-]+/, "") end defp normalize_for_comparison(_), do: "" defp normalize_slot_key(name) do name |> String.downcase() |> String.replace(~r/\s+/, "_") |> String.replace(~r/[^a-z0-9_]/, "") end # Update mapping from form params defp update_mapping_from_params(mapping, params) do mapping |> maybe_update(:source_key, params["source_key"]) |> maybe_update(:slot_key, params["slot_key"]) |> maybe_update(:label, params["label"]) end defp maybe_update(map, _key, nil), do: map defp maybe_update(map, _key, ""), do: map defp maybe_update(map, key, value), do: Map.put(map, key, value) # Add new values to global options for mappings with auto_add enabled defp add_new_values_to_global_options(mappings, _global_options) do # Log all mappings to see auto_add state Logger.info("add_new_values_to_global_options: #{length(mappings)} mappings") eligible = mappings |> Enum.filter(fn m -> m.auto_add && m.source_key && m.new_values != [] end) Logger.info("Eligible mappings with auto_add=true: #{length(eligible)}") Enum.each(eligible, fn mapping -> Logger.info("Adding #{length(mapping.new_values)} values to #{mapping.source_key}") Enum.each(mapping.new_values, fn value -> result = Options.add_value_to_global_option(mapping.source_key, value) Logger.debug("Added #{value} to #{mapping.source_key}: #{inspect(result)}") end) end) end # Convert UI mappings to worker format defp convert_mappings_for_worker(mappings) do mappings |> Enum.filter(fn m -> m.source_key != nil end) |> Enum.map(fn m -> %{ "csv_name" => m.csv_name, "slot_key" => m.slot_key, "source_key" => m.source_key, "label" => m.label, "auto_add" => m.auto_add } end) end # Handle format that requires option mapping (Shopify) defp handle_mapping_format(socket, dest_path, filename, format_mod) do case safe_analyze_csv(dest_path, socket.assigns.selected_config) do {:ok, analysis} -> initial_mappings = build_initial_mappings(analysis.options, socket.assigns.global_options) socket = socket |> assign(:format_mod, format_mod) |> assign(:format_name, FormatDetector.format_name(format_mod)) |> assign(:uploaded_file_path, dest_path) |> assign(:uploaded_filename, filename) |> assign(:csv_analysis, analysis) |> assign(:option_mappings, initial_mappings) |> assign(:import_step, :configure) {:noreply, socket} {:error, message} -> File.rm(dest_path) {:noreply, put_flash(socket, :error, message)} end end # Handle format that doesn't require option mapping (Prom.ua) defp handle_direct_format(socket, dest_path, filename, format_mod) do product_count = try do format_mod.count(dest_path, nil) rescue _ -> 0 end socket = socket |> assign(:format_mod, format_mod) |> assign(:format_name, FormatDetector.format_name(format_mod)) |> assign(:uploaded_file_path, dest_path) |> assign(:uploaded_filename, filename) |> assign(:confirm_product_count, product_count) |> assign(:import_step, :confirm) {:noreply, socket} end # Re-analyze CSV when config changes during configure step defp maybe_reanalyze_csv(socket) do with :configure <- socket.assigns.import_step, path when is_binary(path) <- socket.assigns[:uploaded_file_path], {:ok, analysis} <- safe_analyze_csv(path, socket.assigns.selected_config) do initial_mappings = build_initial_mappings(analysis.options, socket.assigns.global_options) socket |> assign(:csv_analysis, analysis) |> assign(:option_mappings, initial_mappings) else _ -> socket end end # Safe CSV analysis with error handling defp safe_analyze_csv(path, config) do CSVAnalyzer.analyze_options(path, config) |> then(&{:ok, &1}) rescue e in NimbleCSV.ParseError -> message = parse_csv_error(e.message) {:error, message} e -> Logger.error("CSV analysis failed: #{inspect(e)}") {:error, "Failed to parse CSV file. Please check the file format."} end defp parse_csv_error(message) do cond do String.contains?(message, "unexpected escape character") -> "CSV format error: The file contains incorrectly escaped quotes. " <> "Please export directly from Shopify using 'Export > CSV for Excel'." String.contains?(message, "reached the end of file") -> "CSV format error: The file has unclosed quotes. " <> "Please re-export from Shopify or check for corrupted data." true -> "CSV parse error: #{String.slice(message, 0, 100)}" end end @impl true def render(assigns) do ~H"""
<.admin_page_header back={Routes.path("/admin/shop")}>

CSV Import

Import products from CSV files <%= if @format_name do %> {@format_name} <% end %>

<%!-- Import Wizard Card --%>
<%!-- Wizard Steps Indicator --%> <%= if @format_mod && !@format_mod.requires_option_mapping?() do %> <%!-- 2-step wizard for formats without option mapping --%>
  • Upload
  • Confirm
  • Import
<% else %> <%!-- 3-step wizard for formats with option mapping --%>
  • Upload
  • Configure
  • Import
<% end %> <%= case @import_step do %> <% :upload -> %> <.render_upload_step uploads={@uploads} show_language_selector={@show_language_selector} enabled_languages={@enabled_languages} current_language={@current_language} download_images={@download_images} skip_empty_categories={@skip_empty_categories} import_configs={@import_configs} selected_config={@selected_config} selected_config_id={@selected_config_id} /> <% :configure -> %> <.render_configure_step csv_analysis={@csv_analysis} option_mappings={@option_mappings} global_options={@global_options} uploaded_filename={@uploaded_filename} format_name={@format_name} import_configs={@import_configs} selected_config={@selected_config} selected_config_id={@selected_config_id} /> <% :confirm -> %> <.render_confirm_step format_name={@format_name} uploaded_filename={@uploaded_filename} confirm_product_count={@confirm_product_count} download_images={@download_images} skip_empty_categories={@skip_empty_categories} /> <% :importing -> %> <.render_importing_step current_import={@current_import} import_progress={@import_progress} /> <% end %>
<%!-- Image Migration Card --%>

<.icon name="hero-photo" class="w-6 h-6" /> Image Migration

Migrate product images from external CDN URLs to the Storage module for better control and reliability.

<%!-- Migration Stats --%>
Total Products
{@migration_stats.total}
with images
Migrated
{@migration_stats.migrated}
in Storage
Pending
{@migration_stats.pending}
legacy URLs
In Progress
{@migration_stats.in_progress}
jobs
<%= if @migration_stats.failed > 0 do %>
Failed
{@migration_stats.failed}
errors
<% end %>
<%!-- Progress Bar (when migration in progress) --%> <%= if @migration_in_progress and @migration_stats.total > 0 do %>
<% progress_percent = if @migration_stats.total > 0, do: round(@migration_stats.migrated / @migration_stats.total * 100), else: 0 %>

{progress_percent}% complete ({@migration_stats.migrated}/{@migration_stats.total})

<% end %> <%!-- Action Buttons --%>
<%= if @migration_in_progress do %> <% else %> <%= if @migration_stats.pending > 0 do %> <% else %> <% 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
<.link navigate={Routes.path("/admin/shop/imports/#{import.uuid}")} class="link link-hover" > {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)}
<.link navigate={Routes.path("/admin/shop/imports/#{import.uuid}")} class="btn btn-xs btn-ghost" title="View Details" > <.icon name="hero-eye" class="w-4 h-4" /> <%= 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

  • Supported formats: Shopify, Prom.ua (auto-detected from file headers)
  • Products are automatically categorized based on title or category name
  • Existing products with the same slug are updated
  • Import runs in the background — you can leave this page
""" end # ============================================ # WIZARD STEP COMPONENTS # ============================================ defp render_upload_step(assigns) do ~H"""

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

<%!-- Language Selection --%> <%= if @show_language_selector do %>
<%= for lang <- @enabled_languages do %> <% end %>
<% else %>
<.icon name="hero-language" class="w-4 h-4" /> Import language: {String.upcase(@current_language)}
<% end %> <%!-- Import Config Selector --%> <%= if @import_configs != [] do %>
<%= if @selected_config do %>
<%= unless @selected_config.skip_filter do %> {length(@selected_config.include_keywords)} include {length(@selected_config.exclude_keywords)} exclude {length(@selected_config.category_rules)} category rules <% else %> Skip filter — all products imported <% end %>
<% end %>
<% end %> <%!-- 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 %> <%!-- Download Images Option --%>
<%!-- 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 defp render_configure_step(assigns) do ~H"""

<.icon name="hero-adjustments-horizontal" class="w-6 h-6" /> Configure Option Mappings <%= if @format_name do %> {@format_name} <% end %>

<%!-- Import Config Selector (allows changing filter at configure step) --%> <%= if @import_configs != [] do %>
<%= if @selected_config do %>
<%= unless @selected_config.skip_filter do %> {length(@selected_config.include_keywords)} include {length(@selected_config.exclude_keywords)} exclude {length(@selected_config.category_rules)} category rules <% else %> Skip filter — all products imported <% end %>
<% end %>
<% end %>
<.icon name="hero-information-circle" class="w-5 h-5" />

File: {@uploaded_filename}

Found {@csv_analysis.total_products} products with {@csv_analysis.total_variants} variants

<%= if @csv_analysis.total_skipped > 0 do %>

{@csv_analysis.total_skipped} products filtered out by import config

<% end %>
<%= if @option_mappings == [] do %>
<.icon name="hero-exclamation-triangle" class="w-5 h-5" /> No options found in CSV file. You can proceed with basic import.
<% else %>
<%= for {mapping, index} <- Enum.with_index(@option_mappings) do %> <.render_mapping_card mapping={mapping} index={index} global_options={@global_options} /> <% end %>
<% end %> <%!-- Action Buttons --%>
<%= if @option_mappings != [] do %> <% end %>
""" end defp render_mapping_card(assigns) do ~H"""
<%!-- CSV Option Info --%>

{@mapping.csv_name}

Position {@mapping.csv_position} · {@mapping.csv_values |> length()} values

<%= for value <- Enum.take(@mapping.csv_values, 5) do %> {value} <% end %> <%= if length(@mapping.csv_values) > 5 do %> +{length(@mapping.csv_values) - 5} more <% end %>
<%!-- Mapping Config --%>
<%= if @mapping.source_key do %> <% end %>
<%!-- New Values Warning --%> <%= if @mapping.source_key && @mapping.new_values != [] do %>

<.icon name="hero-exclamation-triangle" class="w-4 h-4 inline" /> {length(@mapping.new_values)} new values not in global option

<%= for value <- Enum.take(@mapping.new_values, 3) do %> {value} <% end %> <%= if length(@mapping.new_values) > 3 do %> +{length(@mapping.new_values) - 3} <% end %>
<% end %>
""" end defp render_confirm_step(assigns) do ~H"""

<.icon name="hero-check-circle" class="w-6 h-6" /> Confirm Import {@format_name}

<.icon name="hero-information-circle" class="w-5 h-5" />

File: {@uploaded_filename}

Found {@confirm_product_count} products to import

Import details:

""" end defp render_importing_step(assigns) do ~H"""

<.icon name="hero-arrow-path" class="w-6 h-6 animate-spin" /> Import in Progress

<%= if @current_import do %>

{@current_import.filename}

<%= if @import_progress do %>

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

<% else %>

Preparing import...

<% end %>
<% end %> """ end defp get_global_option_label(%{"label" => label}) when is_binary(label), do: label defp get_global_option_label(%{"label" => label}) when is_map(label), do: Map.get(label, "en", "") defp get_global_option_label(_), do: "" 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