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"""
Import products from CSV files
<%= if @format_name do %>
{@format_name}
<% end %>
Migrate product images from external CDN URLs to the Storage module for better control and reliability.
{progress_percent}% complete ({@migration_stats.migrated}/{@migration_stats.total})
No imports yetCSV Import
<% else %>
<%!-- 3-step wizard for formats with option mapping --%>
<% 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 %>
<.icon name="hero-photo" class="w-6 h-6" /> Image Migration
<.icon name="hero-clock" class="w-6 h-6" /> Import History
<%= if Enum.empty?(@imports) do %>
<%= for import <- @imports do %>
File
Status
Progress
Results
Date
<% end %>
<.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)}
About CSV Import
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 %>Position {@mapping.csv_position} · {@mapping.csv_values |> length()} values
<.icon name="hero-exclamation-triangle" class="w-4 h-4 inline" /> {length(@mapping.new_values)} new values not in global option
File: {@uploaded_filename}
Found {@confirm_product_count} products to import
{@import_progress.current} / {@import_progress.total} products ({@import_progress.percent}%)
Preparing import...
<% end %>