defmodule LocalspotWeb.AdminLive.Import do use LocalspotWeb, :live_view alias Localspot.Businesses.Import @json_example """ { "businesses": [ { "name": "Business Name", "category": "category-slug", "street_address": "123 Main St", "city": "Columbus", "state": "OH", "zip_code": "43215", "hours": [{"day": 1, "opens_at": "09:00", "closes_at": "17:00"}], "photos": [{"url": "https://...", "primary": true}] } ] } """ @impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:page_title, "Import Businesses") |> assign(:uploaded_files, []) |> assign(:import_result, nil) |> assign(:preview_result, nil) |> assign(:importing, false) |> assign(:previewing, false) |> assign(:creating_categories, false) |> assign(:pending_file_content, nil) |> assign(:json_example, @json_example) |> allow_upload(:import_file, accept: ~w(.json), max_entries: 1, max_file_size: 10_000_000 )} end @impl true def handle_event("validate", _params, socket) do {:noreply, socket} end @impl true def handle_event("preview", _params, socket) do socket = assign(socket, :previewing, true) # Read and store file content for later import {file_content, preview_result} = consume_uploaded_entries(socket, :import_file, fn %{path: path}, _entry -> content = File.read!(path) {:ok, {content, Import.preview_json(content)}} end) |> case do [{content, result}] -> {content, result} [] -> {nil, {:error, :no_file}} end {:noreply, socket |> assign(:preview_result, preview_result) |> assign(:pending_file_content, file_content) |> assign(:previewing, false)} end @impl true def handle_event("create_categories", _params, socket) do socket = assign(socket, :creating_categories, true) case socket.assigns.preview_result do {:ok, %{missing_categories: missing_categories}} when missing_categories != [] -> {:ok, created} = Import.create_categories(missing_categories) # Re-preview to confirm categories were created new_preview = case socket.assigns.pending_file_content do nil -> {:error, :no_file} content -> Import.preview_json(content) end {:noreply, socket |> assign(:preview_result, new_preview) |> assign(:creating_categories, false) |> put_flash(:info, "Created #{created} category(ies)")} _ -> {:noreply, assign(socket, :creating_categories, false)} end end @impl true def handle_event("import", _params, socket) do socket = assign(socket, :importing, true) import_result = case socket.assigns.pending_file_content do nil -> {:error, :no_file} content -> Import.from_json(content) end {:noreply, socket |> assign(:import_result, import_result) |> assign(:preview_result, nil) |> assign(:pending_file_content, nil) |> assign(:importing, false)} end @impl true def handle_event("clear_result", _params, socket) do {:noreply, socket |> assign(:import_result, nil) |> assign(:preview_result, nil) |> assign(:pending_file_content, nil) |> allow_upload(:import_file, accept: ~w(.json), max_entries: 1, max_file_size: 10_000_000 )} end @impl true def handle_event("cancel_preview", _params, socket) do {:noreply, socket |> assign(:preview_result, nil) |> assign(:pending_file_content, nil) |> allow_upload(:import_file, accept: ~w(.json), max_entries: 1, max_file_size: 10_000_000 )} end @impl true def render(assigns) do ~H""" <.header> Import Businesses <:subtitle>Upload a JSON file to bulk import businesses
<.icon name="hero-information-circle" class="w-5 h-5" />

Upload a JSON file with the following structure:

{@json_example}
<%!-- File upload form (shown when no preview/result) --%>
<.form for={%{}} phx-change="validate" phx-submit="preview" class="space-y-4">
<.live_file_input upload={@uploads.import_file} class="hidden" />
<.icon name="hero-document-arrow-up" class="w-12 h-12 mx-auto text-base-content/40" />

Drag and drop a JSON file here, or

Max file size: 10MB

<.icon name="hero-document" class="w-6 h-6 text-primary" /> {entry.client_name} ({format_bytes(entry.client_size)})
{error_to_string(err)}
<%!-- Preview result (shown after file is analyzed) --%>
<% {:ok, preview} = @preview_result %>
<.icon name="hero-document-text" class="w-5 h-5" /> Found {preview.total} business(es) to import
<%!-- Missing categories warning --%>
0} class="alert alert-warning"> <.icon name="hero-exclamation-triangle" class="w-5 h-5" />

Missing Categories Detected

The following categories don't exist and need to be created:

  • {category}
<%!-- Create categories button (if missing) --%> <%!-- Import button (enabled only if no missing categories) --%>

0} class="text-sm text-base-content/60 mt-2"> You must create the missing categories before importing.

<.icon name="hero-x-circle" class="w-5 h-5" /> Preview failed: {format_error(@preview_result)}
<%!-- Import result --%>
<.icon name="hero-check-circle" class="w-5 h-5" /> Successfully imported {@import_result.imported} business(es)
0} class="alert alert-info"> <.icon name="hero-information-circle" class="w-5 h-5" /> Skipped {@import_result.skipped} duplicate(s)
0} class="alert alert-warning"> <.icon name="hero-exclamation-triangle" class="w-5 h-5" />

{length(@import_result.errors)} error(s) occurred:

  • Row {index}: {inspect(reason)}
<.link navigate={~p"/businesses"} class="btn btn-primary"> View Businesses
<.icon name="hero-x-circle" class="w-5 h-5" /> Import failed: {format_error(@import_result)}
""" end defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B" defp format_bytes(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB" defp format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB" defp error_to_string(:too_large), do: "File is too large (max 10MB)" defp error_to_string(:not_accepted), do: "Only JSON files are accepted" defp error_to_string(:too_many_files), do: "Only one file at a time" defp error_to_string(err), do: "Error: #{inspect(err)}" defp format_error({:error, {:file_error, reason}}), do: "Could not read file: #{inspect(reason)}" defp format_error({:error, {:json_error, _}}), do: "Invalid JSON format" defp format_error({:error, {:invalid_format, msg}}), do: msg defp format_error({:error, :no_file}), do: "No file selected" defp format_error({:error, reason}), do: inspect(reason) end