- Skip businesses that already exist (by slug) - Track skipped count in import results - Display skipped count in admin import UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
207 lines
7.1 KiB
Elixir
207 lines
7.1 KiB
Elixir
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(:importing, false)
|
|
|> 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("import", _params, socket) do
|
|
socket = assign(socket, :importing, true)
|
|
|
|
result =
|
|
consume_uploaded_entries(socket, :import_file, fn %{path: path}, _entry ->
|
|
{:ok, Import.from_file(path)}
|
|
end)
|
|
|
|
import_result =
|
|
case result do
|
|
[{:ok, result}] -> result
|
|
[{:error, reason}] -> {:error, reason}
|
|
[] -> {:error, :no_file}
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:import_result, import_result)
|
|
|> assign(:importing, false)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_result", _params, socket) do
|
|
{:noreply, assign(socket, :import_result, nil)}
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash}>
|
|
<.header>
|
|
Import Businesses
|
|
<:subtitle>Upload a JSON file to bulk import businesses</:subtitle>
|
|
</.header>
|
|
|
|
<div class="mt-8 max-w-2xl">
|
|
<div class="alert alert-info mb-6">
|
|
<.icon name="hero-information-circle" class="w-5 h-5" />
|
|
<div>
|
|
<p>Upload a JSON file with the following structure:</p>
|
|
<pre class="mt-2 text-xs bg-base-300 p-2 rounded overflow-x-auto"><code>{@json_example}</code></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<.form for={%{}} phx-change="validate" phx-submit="import" class="space-y-4">
|
|
<div
|
|
class="border-2 border-dashed border-base-300 rounded-lg p-8 text-center"
|
|
phx-drop-target={@uploads.import_file.ref}
|
|
>
|
|
<.live_file_input upload={@uploads.import_file} class="hidden" />
|
|
|
|
<div :if={@uploads.import_file.entries == []}>
|
|
<.icon name="hero-document-arrow-up" class="w-12 h-12 mx-auto text-base-content/40" />
|
|
<p class="mt-2 text-base-content/60">
|
|
Drag and drop a JSON file here, or
|
|
<label for={@uploads.import_file.ref} class="link link-primary cursor-pointer">
|
|
browse
|
|
</label>
|
|
</p>
|
|
<p class="text-sm text-base-content/40 mt-1">Max file size: 10MB</p>
|
|
</div>
|
|
|
|
<div
|
|
:for={entry <- @uploads.import_file.entries}
|
|
class="flex items-center justify-between"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<.icon name="hero-document" class="w-6 h-6 text-primary" />
|
|
<span>{entry.client_name}</span>
|
|
<span class="text-sm text-base-content/60">
|
|
({format_bytes(entry.client_size)})
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-sm"
|
|
phx-click="cancel-upload"
|
|
phx-value-ref={entry.ref}
|
|
>
|
|
<.icon name="hero-x-mark" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div :for={err <- upload_errors(@uploads.import_file)} class="text-error text-sm mt-2">
|
|
{error_to_string(err)}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
class="btn btn-primary w-full"
|
|
disabled={@uploads.import_file.entries == [] or @importing}
|
|
>
|
|
<span :if={@importing} class="loading loading-spinner loading-sm"></span>
|
|
<span :if={!@importing}>
|
|
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Import Businesses
|
|
</span>
|
|
<span :if={@importing}>Importing...</span>
|
|
</button>
|
|
</.form>
|
|
|
|
<div :if={@import_result} class="mt-6">
|
|
<div :if={match?(%{imported: _, skipped: _, errors: _}, @import_result)} class="space-y-4">
|
|
<div class="alert alert-success">
|
|
<.icon name="hero-check-circle" class="w-5 h-5" />
|
|
<span>Successfully imported {@import_result.imported} business(es)</span>
|
|
</div>
|
|
|
|
<div :if={@import_result.skipped > 0} class="alert alert-info">
|
|
<.icon name="hero-information-circle" class="w-5 h-5" />
|
|
<span>Skipped {@import_result.skipped} duplicate(s)</span>
|
|
</div>
|
|
|
|
<div :if={length(@import_result.errors) > 0} class="alert alert-warning">
|
|
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
|
<div>
|
|
<p>{length(@import_result.errors)} error(s) occurred:</p>
|
|
<ul class="list-disc list-inside mt-2 text-sm">
|
|
<li :for={{:error, index, reason} <- @import_result.errors}>
|
|
Row {index}: {inspect(reason)}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<.link navigate={~p"/businesses"} class="btn btn-primary">
|
|
View Businesses
|
|
</.link>
|
|
<button type="button" class="btn btn-ghost" phx-click="clear_result">
|
|
Import More
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div :if={match?({:error, _}, @import_result)} class="alert alert-error">
|
|
<.icon name="hero-x-circle" class="w-5 h-5" />
|
|
<span>Import failed: {format_error(@import_result)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
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
|