rename project from SimpleshopTheme to Berrypod
All modules, configs, paths, and references updated. 836 tests pass, zero warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
787
lib/berrypod_web/live/admin/dashboard.ex
Normal file
787
lib/berrypod_web/live/admin/dashboard.ex
Normal file
@@ -0,0 +1,787 @@
|
||||
defmodule BerrypodWeb.Admin.Dashboard do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.{Cart, Orders, Products, Settings, Setup}
|
||||
alias Berrypod.Products.ProviderConnection
|
||||
alias Berrypod.Providers
|
||||
alias Berrypod.Stripe.Setup, as: StripeSetup
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
status = Setup.setup_status()
|
||||
status_counts = Orders.count_orders_by_status()
|
||||
paid_count = Map.get(status_counts, "paid", 0)
|
||||
recent_orders = Orders.list_orders(status: "paid") |> Enum.take(5)
|
||||
|
||||
conn = Products.get_provider_connection_by_type("printify")
|
||||
|
||||
if conn && connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Berrypod.PubSub, "sync:#{conn.id}")
|
||||
end
|
||||
|
||||
active_step = determine_active_step(status)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Dashboard")
|
||||
|> assign(:setup, status)
|
||||
|> assign(:active_step, active_step)
|
||||
# Printify state
|
||||
|> assign(:printify_conn, conn)
|
||||
|> assign(:printify_form, to_form(%{"api_key" => ""}, as: :printify))
|
||||
|> assign(:printify_testing, false)
|
||||
|> assign(:printify_test_result, nil)
|
||||
|> assign(:printify_saving, false)
|
||||
|> assign(:pending_api_key, nil)
|
||||
|> assign(:sync_status, conn && conn.sync_status)
|
||||
# Stripe state
|
||||
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|
||||
|> assign(:stripe_connecting, false)
|
||||
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
|
||||
# Celebration
|
||||
|> assign(:just_went_live, false)
|
||||
# Stats
|
||||
|> assign(:paid_count, paid_count)
|
||||
|> assign(:revenue, Orders.total_revenue())
|
||||
|> assign(:recent_orders, recent_orders)}
|
||||
end
|
||||
|
||||
# -- Step determination --
|
||||
|
||||
defp determine_active_step(status) do
|
||||
cond do
|
||||
!status.printify_connected -> :printify
|
||||
!status.products_synced -> :printify
|
||||
!status.stripe_connected -> :stripe
|
||||
!status.site_live -> :go_live
|
||||
true -> :complete
|
||||
end
|
||||
end
|
||||
|
||||
# -- Events: Printify --
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_printify", %{"printify" => params}, socket) do
|
||||
{:noreply, assign(socket, pending_api_key: params["api_key"])}
|
||||
end
|
||||
|
||||
def handle_event("test_printify", _params, socket) do
|
||||
api_key = socket.assigns.pending_api_key
|
||||
|
||||
if api_key in [nil, ""] do
|
||||
{:noreply, assign(socket, printify_test_result: {:error, :no_api_key})}
|
||||
else
|
||||
socket = assign(socket, printify_testing: true, printify_test_result: nil)
|
||||
|
||||
temp_conn = %ProviderConnection{
|
||||
provider_type: "printify",
|
||||
api_key_encrypted: encrypt_api_key(api_key)
|
||||
}
|
||||
|
||||
result = Providers.test_connection(temp_conn)
|
||||
|
||||
{:noreply, assign(socket, printify_testing: false, printify_test_result: result)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("connect_printify", %{"printify" => %{"api_key" => api_key}}, socket) do
|
||||
if api_key == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please enter your Printify API token")}
|
||||
else
|
||||
socket = assign(socket, printify_saving: true)
|
||||
|
||||
params =
|
||||
%{"api_key" => api_key, "provider_type" => "printify"}
|
||||
|> maybe_add_shop_config(socket.assigns.printify_test_result)
|
||||
|> maybe_add_name(socket.assigns.printify_test_result)
|
||||
|
||||
case Products.create_provider_connection(params) do
|
||||
{:ok, connection} ->
|
||||
Products.enqueue_sync(connection)
|
||||
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Berrypod.PubSub, "sync:#{connection.id}")
|
||||
end
|
||||
|
||||
status = %{socket.assigns.setup | printify_connected: true}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:printify_saving, false)
|
||||
|> assign(:printify_conn, connection)
|
||||
|> assign(:sync_status, "syncing")
|
||||
|> assign(:setup, status)
|
||||
|> put_flash(:info, "Connected to Printify! Syncing products...")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:printify_saving, false)
|
||||
|> put_flash(:error, "Failed to save connection")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("retry_sync", _params, socket) do
|
||||
conn = socket.assigns.printify_conn
|
||||
|
||||
if conn do
|
||||
Products.enqueue_sync(conn)
|
||||
{:noreply, assign(socket, sync_status: "syncing")}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# -- Events: Stripe --
|
||||
|
||||
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
||||
if api_key == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")}
|
||||
else
|
||||
socket = assign(socket, stripe_connecting: true)
|
||||
|
||||
case StripeSetup.connect(api_key) do
|
||||
{:ok, _result} ->
|
||||
status = %{socket.assigns.setup | stripe_connected: true, can_go_live: true}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:stripe_connecting, false)
|
||||
|> assign(:setup, status)
|
||||
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
|
||||
|> assign(:active_step, :go_live)
|
||||
|> put_flash(:info, "Stripe connected")}
|
||||
|
||||
{:error, message} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:stripe_connecting, false)
|
||||
|> put_flash(:error, "Stripe connection failed: #{message}")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# -- Events: Go live --
|
||||
|
||||
def handle_event("go_live", _params, socket) do
|
||||
{:ok, _} = Settings.set_site_live(true)
|
||||
status = %{socket.assigns.setup | site_live: true}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:setup, status)
|
||||
|> assign(:just_went_live, true)}
|
||||
end
|
||||
|
||||
# -- Events: Step navigation --
|
||||
|
||||
def handle_event("toggle_step", %{"step" => step}, socket) do
|
||||
step = String.to_existing_atom(step)
|
||||
|
||||
new_active =
|
||||
if socket.assigns.active_step == step do
|
||||
determine_active_step(socket.assigns.setup)
|
||||
else
|
||||
step
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, active_step: new_active)}
|
||||
end
|
||||
|
||||
# -- PubSub: Sync progress --
|
||||
|
||||
@impl true
|
||||
def handle_info({:sync_status, "completed", product_count}, socket) do
|
||||
status = %{
|
||||
socket.assigns.setup
|
||||
| products_synced: true,
|
||||
product_count: product_count
|
||||
}
|
||||
|
||||
active_step = if status.stripe_connected, do: :go_live, else: :stripe
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:setup, status)
|
||||
|> assign(:sync_status, "completed")
|
||||
|> assign(:active_step, active_step)
|
||||
|> put_flash(:info, "#{product_count} products synced")}
|
||||
end
|
||||
|
||||
def handle_info({:sync_status, "failed"}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:sync_status, "failed")
|
||||
|> put_flash(:error, "Product sync failed — try again")}
|
||||
end
|
||||
|
||||
def handle_info({:sync_status, status}, socket) do
|
||||
{:noreply, assign(socket, sync_status: status)}
|
||||
end
|
||||
|
||||
# -- Render --
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Dashboard
|
||||
</.header>
|
||||
|
||||
<%!-- Celebration state --%>
|
||||
<.celebration :if={@just_went_live} />
|
||||
|
||||
<%!-- Setup stepper (when not live and not celebrating) --%>
|
||||
<.setup_stepper
|
||||
:if={!@setup.site_live and !@just_went_live}
|
||||
setup={@setup}
|
||||
active_step={@active_step}
|
||||
printify_conn={@printify_conn}
|
||||
printify_form={@printify_form}
|
||||
printify_testing={@printify_testing}
|
||||
printify_test_result={@printify_test_result}
|
||||
printify_saving={@printify_saving}
|
||||
sync_status={@sync_status}
|
||||
stripe_form={@stripe_form}
|
||||
stripe_connecting={@stripe_connecting}
|
||||
stripe_api_key_hint={@stripe_api_key_hint}
|
||||
/>
|
||||
|
||||
<%!-- Stats --%>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
|
||||
<.stat_card
|
||||
label="Orders"
|
||||
value={@paid_count}
|
||||
icon="hero-shopping-bag"
|
||||
href={~p"/admin/orders"}
|
||||
/>
|
||||
<.stat_card
|
||||
label="Revenue"
|
||||
value={format_revenue(@revenue)}
|
||||
icon="hero-banknotes"
|
||||
href={~p"/admin/orders"}
|
||||
/>
|
||||
<.stat_card
|
||||
label="Products"
|
||||
value={@setup.product_count}
|
||||
icon="hero-cube"
|
||||
href={~p"/admin/products"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Recent orders --%>
|
||||
<section class="mt-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">Recent orders</h2>
|
||||
<.link
|
||||
navigate={~p"/admin/orders"}
|
||||
class="text-sm text-base-content/60 hover:text-base-content"
|
||||
>
|
||||
View all →
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%= if @recent_orders == [] do %>
|
||||
<div class="rounded-lg border border-base-200 p-8 text-center text-base-content/60">
|
||||
<.icon name="hero-inbox" class="size-10 mx-auto mb-3 text-base-content/30" />
|
||||
<p class="font-medium">No orders yet</p>
|
||||
<p class="text-sm mt-1">Orders will appear here once customers check out.</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-base-200 text-left text-base-content/60">
|
||||
<th class="pb-2 font-medium">Order</th>
|
||||
<th class="pb-2 font-medium">Date</th>
|
||||
<th class="pb-2 font-medium">Customer</th>
|
||||
<th class="pb-2 font-medium text-right">Total</th>
|
||||
<th class="pb-2 font-medium">Fulfilment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
:for={order <- @recent_orders}
|
||||
class="border-b border-base-200 hover:bg-base-200/50 cursor-pointer"
|
||||
phx-click={JS.navigate(~p"/admin/orders/#{order}")}
|
||||
>
|
||||
<td class="py-2.5 font-medium">{order.order_number}</td>
|
||||
<td class="py-2.5 text-base-content/60">{format_date(order.inserted_at)}</td>
|
||||
<td class="py-2.5 text-base-content/60">{order.customer_email || "—"}</td>
|
||||
<td class="py-2.5 text-right">{Cart.format_price(order.total)}</td>
|
||||
<td class="py-2.5"><.fulfilment_pill status={order.fulfilment_status} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# Setup stepper components
|
||||
# ==========================================================================
|
||||
|
||||
attr :setup, :map, required: true
|
||||
attr :active_step, :atom, required: true
|
||||
attr :printify_conn, :any, required: true
|
||||
attr :printify_form, :any, required: true
|
||||
attr :printify_testing, :boolean, required: true
|
||||
attr :printify_test_result, :any, required: true
|
||||
attr :printify_saving, :boolean, required: true
|
||||
attr :sync_status, :string, required: true
|
||||
attr :stripe_form, :any, required: true
|
||||
attr :stripe_connecting, :boolean, required: true
|
||||
attr :stripe_api_key_hint, :string, required: true
|
||||
|
||||
defp setup_stepper(assigns) do
|
||||
~H"""
|
||||
<div class="mt-6">
|
||||
<ol class="relative" aria-label="Setup steps">
|
||||
<%!-- Step 1: Printify --%>
|
||||
<.setup_step
|
||||
step={:printify}
|
||||
number={1}
|
||||
title="Connect to Printify"
|
||||
active_step={@active_step}
|
||||
done={@setup.printify_connected and @setup.products_synced}
|
||||
last={false}
|
||||
next_done={@setup.stripe_connected}
|
||||
>
|
||||
<:summary :if={@setup.printify_connected and @setup.products_synced}>
|
||||
Connected · {@setup.product_count} products synced
|
||||
</:summary>
|
||||
<:content>
|
||||
<.printify_step_content
|
||||
setup={@setup}
|
||||
printify_conn={@printify_conn}
|
||||
printify_form={@printify_form}
|
||||
printify_testing={@printify_testing}
|
||||
printify_test_result={@printify_test_result}
|
||||
printify_saving={@printify_saving}
|
||||
sync_status={@sync_status}
|
||||
/>
|
||||
</:content>
|
||||
</.setup_step>
|
||||
|
||||
<%!-- Step 2: Stripe --%>
|
||||
<.setup_step
|
||||
step={:stripe}
|
||||
number={2}
|
||||
title="Connect Stripe"
|
||||
active_step={@active_step}
|
||||
done={@setup.stripe_connected}
|
||||
last={false}
|
||||
next_done={@setup.site_live}
|
||||
>
|
||||
<:summary :if={@setup.stripe_connected}>
|
||||
Connected · {@stripe_api_key_hint}
|
||||
</:summary>
|
||||
<:content>
|
||||
<.stripe_step_content
|
||||
stripe_form={@stripe_form}
|
||||
stripe_connecting={@stripe_connecting}
|
||||
/>
|
||||
</:content>
|
||||
</.setup_step>
|
||||
|
||||
<%!-- Step 3: Go live --%>
|
||||
<.setup_step
|
||||
step={:go_live}
|
||||
number={3}
|
||||
title="Go live"
|
||||
active_step={@active_step}
|
||||
done={@setup.site_live}
|
||||
last={true}
|
||||
next_done={false}
|
||||
>
|
||||
<:content>
|
||||
<.go_live_step_content setup={@setup} />
|
||||
</:content>
|
||||
</.setup_step>
|
||||
</ol>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :step, :atom, required: true
|
||||
attr :number, :integer, required: true
|
||||
attr :title, :string, required: true
|
||||
attr :active_step, :atom, required: true
|
||||
attr :done, :boolean, required: true
|
||||
attr :last, :boolean, required: true
|
||||
attr :next_done, :boolean, required: true
|
||||
|
||||
slot :summary
|
||||
slot :content, required: true
|
||||
|
||||
defp setup_step(assigns) do
|
||||
is_active = assigns.active_step == assigns.step
|
||||
is_clickable = assigns.done
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:is_active, is_active)
|
||||
|> assign(:is_clickable, is_clickable)
|
||||
|
||||
~H"""
|
||||
<li class="relative pl-10 pb-8 last:pb-0" aria-current={@is_active && "step"}>
|
||||
<%!-- Connector line --%>
|
||||
<div
|
||||
:if={!@last}
|
||||
class={[
|
||||
"absolute left-[0.9375rem] top-8 -bottom-0 w-0.5",
|
||||
if(@done, do: "bg-green-500", else: "bg-base-300")
|
||||
]}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<%!-- Step circle --%>
|
||||
<div class={[
|
||||
"absolute left-0 top-0 flex size-8 items-center justify-center rounded-full text-sm font-semibold ring-4 ring-base-100",
|
||||
cond do
|
||||
@done -> "bg-green-500 text-white"
|
||||
@is_active -> "bg-base-content text-white"
|
||||
true -> "bg-base-200 text-base-content/40"
|
||||
end
|
||||
]}>
|
||||
<%= if @done do %>
|
||||
<.icon name="hero-check-mini" class="size-5" />
|
||||
<% else %>
|
||||
{@number}
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Step header --%>
|
||||
<%= if @is_clickable do %>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 text-left"
|
||||
phx-click="toggle_step"
|
||||
phx-value-step={@step}
|
||||
aria-expanded={to_string(@is_active)}
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-base-content">{@title}</h3>
|
||||
<.icon
|
||||
name={if @is_active, do: "hero-chevron-up-mini", else: "hero-chevron-down-mini"}
|
||||
class="size-4 text-base-content/40"
|
||||
/>
|
||||
</button>
|
||||
<% else %>
|
||||
<h3 class={[
|
||||
"text-sm font-semibold",
|
||||
if(@is_active, do: "text-base-content", else: "text-base-content/40")
|
||||
]}>
|
||||
{@title}
|
||||
</h3>
|
||||
<% end %>
|
||||
|
||||
<%!-- Collapsed summary for completed steps --%>
|
||||
<p :if={@done and !@is_active and @summary != []} class="text-sm text-base-content/60 mt-0.5">
|
||||
{render_slot(@summary)}
|
||||
</p>
|
||||
|
||||
<%!-- Expanded content --%>
|
||||
<div :if={@is_active} class="mt-3">
|
||||
{render_slot(@content)}
|
||||
</div>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Printify step content --
|
||||
|
||||
attr :setup, :map, required: true
|
||||
attr :printify_conn, :any, required: true
|
||||
attr :printify_form, :any, required: true
|
||||
attr :printify_testing, :boolean, required: true
|
||||
attr :printify_test_result, :any, required: true
|
||||
attr :printify_saving, :boolean, required: true
|
||||
attr :sync_status, :string, required: true
|
||||
|
||||
defp printify_step_content(assigns) do
|
||||
~H"""
|
||||
<%!-- Not yet connected: show form --%>
|
||||
<div :if={!@setup.printify_connected}>
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
Connect your Printify account to import products.
|
||||
Get an API token from <a
|
||||
href="https://printify.com/app/account/connections"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-base-content underline"
|
||||
>
|
||||
Printify → Account → Connections
|
||||
</a>.
|
||||
</p>
|
||||
|
||||
<.form
|
||||
for={@printify_form}
|
||||
phx-change="validate_printify"
|
||||
phx-submit="connect_printify"
|
||||
>
|
||||
<.input
|
||||
field={@printify_form[:api_key]}
|
||||
type="password"
|
||||
label="Printify API token"
|
||||
placeholder="Paste your token here"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="test_printify"
|
||||
disabled={@printify_testing}
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset disabled:opacity-50"
|
||||
>
|
||||
<.icon
|
||||
name={if @printify_testing, do: "hero-arrow-path", else: "hero-signal"}
|
||||
class={if @printify_testing, do: "size-4 animate-spin", else: "size-4"}
|
||||
/>
|
||||
{if @printify_testing, do: "Checking...", else: "Check connection"}
|
||||
</button>
|
||||
<.button type="submit" disabled={@printify_saving or @printify_testing}>
|
||||
{if @printify_saving, do: "Connecting...", else: "Connect to Printify"}
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<.printify_test_feedback :if={@printify_test_result} result={@printify_test_result} />
|
||||
</.form>
|
||||
</div>
|
||||
|
||||
<%!-- Connected, syncing --%>
|
||||
<div
|
||||
:if={@setup.printify_connected and @sync_status == "syncing"}
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="size-4 animate-spin text-base-content/40" />
|
||||
<span class="text-base-content/60">Syncing products from Printify...</span>
|
||||
</div>
|
||||
|
||||
<%!-- Connected, sync failed --%>
|
||||
<div :if={@setup.printify_connected and @sync_status == "failed"}>
|
||||
<p class="text-sm text-red-600 mb-2">Product sync failed.</p>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="retry_sync"
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="size-4" /> Try again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Connected, synced (shown when user expands a completed step) --%>
|
||||
<div :if={@setup.printify_connected and @setup.products_synced}>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{@setup.product_count} products synced from Printify.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :result, :any, required: true
|
||||
|
||||
defp printify_test_feedback(assigns) do
|
||||
~H"""
|
||||
<div class="mt-2 text-sm">
|
||||
<%= case @result do %>
|
||||
<% {:ok, info} -> %>
|
||||
<span class="text-green-600 flex items-center gap-1">
|
||||
<.icon name="hero-check-circle" class="size-4" /> Connected to {info.shop_name}
|
||||
</span>
|
||||
<% {:error, reason} -> %>
|
||||
<span class="text-red-600 flex items-center gap-1">
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{format_printify_error(reason)}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Stripe step content --
|
||||
|
||||
attr :stripe_form, :any, required: true
|
||||
attr :stripe_connecting, :boolean, required: true
|
||||
|
||||
defp stripe_step_content(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
Enter your Stripe secret key to accept payments.
|
||||
Find it in your
|
||||
<a
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-base-content underline"
|
||||
>
|
||||
Stripe dashboard
|
||||
</a>
|
||||
under Developers → API keys.
|
||||
</p>
|
||||
|
||||
<.form for={@stripe_form} phx-submit="connect_stripe">
|
||||
<.input
|
||||
field={@stripe_form[:api_key]}
|
||||
type="password"
|
||||
label="Secret key"
|
||||
autocomplete="off"
|
||||
placeholder="sk_test_... or sk_live_..."
|
||||
/>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
Starts with <code>sk_test_</code> or <code>sk_live_</code>. Encrypted at rest.
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Connecting...">
|
||||
{if @stripe_connecting, do: "Connecting...", else: "Connect Stripe"}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Go live step content --
|
||||
|
||||
attr :setup, :map, required: true
|
||||
|
||||
defp go_live_step_content(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
Your shop is ready. Visitors currently see a "coming soon" page —
|
||||
hit the button to make it live.
|
||||
</p>
|
||||
<button
|
||||
phx-click="go_live"
|
||||
disabled={!@setup.can_go_live}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-green-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<.icon name="hero-rocket-launch" class="size-5" /> Go live
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Celebration --
|
||||
|
||||
defp celebration(assigns) do
|
||||
~H"""
|
||||
<div class="mt-6 rounded-lg border border-green-200 bg-green-50 p-6 text-center">
|
||||
<.icon name="hero-check-badge" class="size-12 mx-auto text-green-600 mb-3" />
|
||||
<h2 class="text-lg font-semibold text-green-900">Your shop is live!</h2>
|
||||
<p class="text-sm text-green-700 mt-1 mb-4">
|
||||
Customers can now browse and buy from your shop.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-2 justify-center">
|
||||
<.link
|
||||
navigate={~p"/"}
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white hover:bg-green-500"
|
||||
>
|
||||
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/admin/theme"}
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-100 px-3 py-2 text-sm font-medium text-base-content ring-1 ring-base-300 ring-inset hover:bg-base-200/50"
|
||||
>
|
||||
<.icon name="hero-paint-brush-mini" class="size-4" /> Customise theme
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# Stats components
|
||||
# ==========================================================================
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :value, :any, required: true
|
||||
attr :icon, :string, required: true
|
||||
attr :href, :string, required: true
|
||||
|
||||
defp stat_card(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
navigate={@href}
|
||||
class="rounded-lg border border-base-200 p-4 hover:border-base-300 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-base-200 p-2">
|
||||
<.icon name={@icon} class="size-5 text-base-content/60" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{@value}</p>
|
||||
<p class="text-sm text-base-content/60">{@label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
defp fulfilment_pill(assigns) do
|
||||
{color, label} =
|
||||
case assigns.status do
|
||||
"unfulfilled" -> {"bg-base-200 text-base-content/60", "unfulfilled"}
|
||||
"submitted" -> {"bg-blue-50 text-blue-700", "submitted"}
|
||||
"processing" -> {"bg-amber-50 text-amber-700", "processing"}
|
||||
"shipped" -> {"bg-purple-50 text-purple-700", "shipped"}
|
||||
"delivered" -> {"bg-green-50 text-green-700", "delivered"}
|
||||
"failed" -> {"bg-red-50 text-red-700", "failed"}
|
||||
_ -> {"bg-base-200 text-base-content/60", assigns.status || "—"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, color: color, label: label)
|
||||
|
||||
~H"""
|
||||
<span class={["inline-flex rounded-full px-2 py-0.5 text-xs font-medium", @color]}>
|
||||
{@label}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# Helpers
|
||||
# ==========================================================================
|
||||
|
||||
defp format_revenue(amount_pence) when is_integer(amount_pence) do
|
||||
Cart.format_price(amount_pence)
|
||||
end
|
||||
|
||||
defp format_revenue(_), do: "£0.00"
|
||||
|
||||
defp format_date(datetime) do
|
||||
Calendar.strftime(datetime, "%d %b %Y")
|
||||
end
|
||||
|
||||
defp encrypt_api_key(api_key) do
|
||||
case Berrypod.Vault.encrypt(api_key) do
|
||||
{:ok, encrypted} -> encrypted
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_shop_config(params, {:ok, %{shop_id: shop_id}}) do
|
||||
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
|
||||
Map.put(params, "config", config)
|
||||
end
|
||||
|
||||
defp maybe_add_shop_config(params, _), do: params
|
||||
|
||||
defp maybe_add_name(params, {:ok, %{shop_name: shop_name}}) when is_binary(shop_name) do
|
||||
Map.put_new(params, "name", shop_name)
|
||||
end
|
||||
|
||||
defp maybe_add_name(params, _), do: Map.put_new(params, "name", "Printify")
|
||||
|
||||
defp format_printify_error(:no_api_key), do: "Please enter your API token"
|
||||
defp format_printify_error(:unauthorized), do: "That token doesn't seem to be valid"
|
||||
defp format_printify_error(:timeout), do: "Couldn't reach Printify — try again"
|
||||
defp format_printify_error({:http_error, _code}), do: "Something went wrong — try again"
|
||||
defp format_printify_error(error) when is_binary(error), do: error
|
||||
defp format_printify_error(_), do: "Connection failed — check your token and try again"
|
||||
end
|
||||
325
lib/berrypod_web/live/admin/order_show.ex
Normal file
325
lib/berrypod_web/live/admin/order_show.ex
Normal file
@@ -0,0 +1,325 @@
|
||||
defmodule BerrypodWeb.Admin.OrderShow do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Cart
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
case Orders.get_order(id) do
|
||||
nil ->
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:error, "Order not found")
|
||||
|> push_navigate(to: ~p"/admin/orders")
|
||||
|
||||
{:ok, socket}
|
||||
|
||||
order ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, order.order_number)
|
||||
|> assign(:order, order)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
<.link
|
||||
navigate={~p"/admin/orders"}
|
||||
class="text-sm font-normal text-base-content/60 hover:underline"
|
||||
>
|
||||
← Orders
|
||||
</.link>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<span class="text-2xl font-bold">{@order.order_number}</span>
|
||||
<.status_badge status={@order.payment_status} />
|
||||
</div>
|
||||
</.header>
|
||||
|
||||
<div class="grid gap-6 mt-6 lg:grid-cols-2">
|
||||
<%!-- order info --%>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Order details</h3>
|
||||
<.list>
|
||||
<:item title="Date">{format_date(@order.inserted_at)}</:item>
|
||||
<:item title="Customer">{@order.customer_email || "—"}</:item>
|
||||
<:item title="Payment status">
|
||||
<.status_badge status={@order.payment_status} />
|
||||
</:item>
|
||||
<:item :if={@order.stripe_payment_intent_id} title="Stripe payment">
|
||||
<code class="text-xs">{@order.stripe_payment_intent_id}</code>
|
||||
</:item>
|
||||
<:item title="Currency">{String.upcase(@order.currency)}</:item>
|
||||
</.list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- shipping address --%>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Shipping address</h3>
|
||||
<%= if @order.shipping_address != %{} do %>
|
||||
<.list>
|
||||
<:item :if={@order.shipping_address["name"]} title="Name">
|
||||
{@order.shipping_address["name"]}
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["line1"]} title="Address">
|
||||
{@order.shipping_address["line1"]}
|
||||
<span :if={@order.shipping_address["line2"]}>
|
||||
<br />{@order.shipping_address["line2"]}
|
||||
</span>
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["city"]} title="City">
|
||||
{@order.shipping_address["city"]}
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["state"] not in [nil, ""]} title="State">
|
||||
{@order.shipping_address["state"]}
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["postal_code"]} title="Postcode">
|
||||
{@order.shipping_address["postal_code"]}
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["country"]} title="Country">
|
||||
{@order.shipping_address["country"]}
|
||||
</:item>
|
||||
</.list>
|
||||
<% else %>
|
||||
<p class="text-base-content/60 text-sm">No shipping address provided</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- fulfilment --%>
|
||||
<div class="admin-card mt-6">
|
||||
<div class="admin-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="admin-card-title">Fulfilment</h3>
|
||||
<.fulfilment_badge status={@order.fulfilment_status} />
|
||||
</div>
|
||||
<.list>
|
||||
<:item :if={@order.provider_order_id} title="Provider order ID">
|
||||
<code class="text-xs">{@order.provider_order_id}</code>
|
||||
</:item>
|
||||
<:item :if={@order.provider_status} title="Provider status">
|
||||
{@order.provider_status}
|
||||
</:item>
|
||||
<:item :if={@order.submitted_at} title="Submitted">
|
||||
{format_date(@order.submitted_at)}
|
||||
</:item>
|
||||
<:item :if={@order.tracking_number} title="Tracking">
|
||||
<%= if @order.tracking_url do %>
|
||||
<a href={@order.tracking_url} target="_blank" class="admin-link">
|
||||
{@order.tracking_number}
|
||||
</a>
|
||||
<% else %>
|
||||
{@order.tracking_number}
|
||||
<% end %>
|
||||
</:item>
|
||||
<:item :if={@order.shipped_at} title="Shipped">
|
||||
{format_date(@order.shipped_at)}
|
||||
</:item>
|
||||
<:item :if={@order.delivered_at} title="Delivered">
|
||||
{format_date(@order.delivered_at)}
|
||||
</:item>
|
||||
<:item :if={@order.fulfilment_error} title="Error">
|
||||
<span class="text-error text-sm">{@order.fulfilment_error}</span>
|
||||
</:item>
|
||||
</.list>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
:if={can_submit?(@order)}
|
||||
phx-click="submit_to_provider"
|
||||
class="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
<.icon name="hero-paper-airplane-mini" class="size-4" />
|
||||
{if @order.fulfilment_status == "failed",
|
||||
do: "Retry submission",
|
||||
else: "Submit to provider"}
|
||||
</button>
|
||||
<button
|
||||
:if={can_refresh?(@order)}
|
||||
phx-click="refresh_status"
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
>
|
||||
<.icon name="hero-arrow-path-mini" class="size-4" /> Refresh status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- line items --%>
|
||||
<div class="admin-card mt-6">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Items</h3>
|
||||
<table class="admin-table admin-table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Variant</th>
|
||||
<th class="text-right">Qty</th>
|
||||
<th class="text-right">Unit price</th>
|
||||
<th class="text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={item <- @order.items}>
|
||||
<td>{item.product_name}</td>
|
||||
<td>{item.variant_title}</td>
|
||||
<td class="text-right">{item.quantity}</td>
|
||||
<td class="text-right">{Cart.format_price(item.unit_price)}</td>
|
||||
<td class="text-right">{Cart.format_price(item.unit_price * item.quantity)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" class="text-right font-medium">Subtotal</td>
|
||||
<td class="text-right font-medium">{Cart.format_price(@order.subtotal)}</td>
|
||||
</tr>
|
||||
<tr class="text-lg">
|
||||
<td colspan="4" class="text-right font-bold">Total</td>
|
||||
<td class="text-right font-bold">{Cart.format_price(@order.total)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit_to_provider", _params, socket) do
|
||||
order = socket.assigns.order
|
||||
|
||||
case Orders.submit_to_provider(order) do
|
||||
{:ok, updated} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:order, updated)
|
||||
|> put_flash(:info, "Order submitted to provider")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _reason} ->
|
||||
order = Orders.get_order(order.id)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:order, order)
|
||||
|> put_flash(:error, order.fulfilment_error || "Submission failed")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("refresh_status", _params, socket) do
|
||||
order = socket.assigns.order
|
||||
|
||||
case Orders.refresh_fulfilment_status(order) do
|
||||
{:ok, updated} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:order, updated)
|
||||
|> put_flash(:info, "Status refreshed")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to refresh: #{inspect(reason)}")}
|
||||
end
|
||||
end
|
||||
|
||||
defp can_submit?(order) do
|
||||
order.payment_status == "paid" and order.fulfilment_status in ["unfulfilled", "failed"]
|
||||
end
|
||||
|
||||
defp can_refresh?(order) do
|
||||
not is_nil(order.provider_order_id) and
|
||||
order.fulfilment_status in ["submitted", "processing", "shipped"]
|
||||
end
|
||||
|
||||
defp fulfilment_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
case assigns.status do
|
||||
"submitted" ->
|
||||
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
|
||||
|
||||
"processing" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
|
||||
|
||||
"shipped" ->
|
||||
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
|
||||
|
||||
"delivered" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"cancelled" ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-no-symbol-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-minus-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
case assigns.status do
|
||||
"paid" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"pending" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-clock-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"refunded" ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-arrow-uturn-left-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-question-mark-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_date(datetime) do
|
||||
Calendar.strftime(datetime, "%d %b %Y %H:%M")
|
||||
end
|
||||
end
|
||||
206
lib/berrypod_web/live/admin/orders.ex
Normal file
206
lib/berrypod_web/live/admin/orders.ex
Normal file
@@ -0,0 +1,206 @@
|
||||
defmodule BerrypodWeb.Admin.Orders do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Cart
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
counts = Orders.count_orders_by_status()
|
||||
orders = Orders.list_orders()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Orders")
|
||||
|> assign(:status_filter, "all")
|
||||
|> assign(:status_counts, counts)
|
||||
|> assign(:order_count, length(orders))
|
||||
|> stream(:orders, orders)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter", %{"status" => status}, socket) do
|
||||
orders = Orders.list_orders(status: status)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:status_filter, status)
|
||||
|> assign(:order_count, length(orders))
|
||||
|> stream(:orders, orders, reset: true)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Orders
|
||||
</.header>
|
||||
|
||||
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
|
||||
<.filter_tab
|
||||
status="all"
|
||||
label="All"
|
||||
count={total_count(@status_counts)}
|
||||
active={@status_filter}
|
||||
/>
|
||||
<.filter_tab
|
||||
status="paid"
|
||||
label="Paid"
|
||||
count={@status_counts["paid"]}
|
||||
active={@status_filter}
|
||||
/>
|
||||
<.filter_tab
|
||||
status="pending"
|
||||
label="Pending"
|
||||
count={@status_counts["pending"]}
|
||||
active={@status_filter}
|
||||
/>
|
||||
<.filter_tab
|
||||
status="failed"
|
||||
label="Failed"
|
||||
count={@status_counts["failed"]}
|
||||
active={@status_filter}
|
||||
/>
|
||||
<.filter_tab
|
||||
status="refunded"
|
||||
label="Refunded"
|
||||
count={@status_counts["refunded"]}
|
||||
active={@status_filter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.table
|
||||
:if={@order_count > 0}
|
||||
id="orders"
|
||||
rows={@streams.orders}
|
||||
row_item={fn {_id, order} -> order end}
|
||||
row_click={fn {_id, order} -> JS.navigate(~p"/admin/orders/#{order}") end}
|
||||
>
|
||||
<:col :let={order} label="Order">{order.order_number}</:col>
|
||||
<:col :let={order} label="Date">{format_date(order.inserted_at)}</:col>
|
||||
<:col :let={order} label="Customer">{order.customer_email || "—"}</:col>
|
||||
<:col :let={order} label="Total">{Cart.format_price(order.total)}</:col>
|
||||
<:col :let={order} label="Status"><.status_badge status={order.payment_status} /></:col>
|
||||
<:col :let={order} label="Fulfilment">
|
||||
<.fulfilment_badge status={order.fulfilment_status} />
|
||||
</:col>
|
||||
</.table>
|
||||
|
||||
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60">
|
||||
<.icon name="hero-inbox" class="size-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No orders yet</p>
|
||||
<p class="text-sm mt-1">Orders will appear here once customers check out.</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp filter_tab(assigns) do
|
||||
count = assigns[:count] || 0
|
||||
active = assigns.active == assigns.status
|
||||
|
||||
assigns = assign(assigns, count: count, active: active)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
phx-click="filter"
|
||||
phx-value-status={@status}
|
||||
class={[
|
||||
"admin-btn admin-btn-sm",
|
||||
@active && "admin-btn-primary",
|
||||
!@active && "admin-btn-ghost"
|
||||
]}
|
||||
>
|
||||
{@label}
|
||||
<span :if={@count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
case assigns.status do
|
||||
"paid" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"pending" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-clock-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"refunded" ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-arrow-uturn-left-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-question-mark-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_date(datetime) do
|
||||
Calendar.strftime(datetime, "%d %b %Y %H:%M")
|
||||
end
|
||||
|
||||
defp fulfilment_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
case assigns.status do
|
||||
"submitted" ->
|
||||
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
|
||||
|
||||
"processing" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
|
||||
|
||||
"shipped" ->
|
||||
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
|
||||
|
||||
"delivered" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"cancelled" ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-no-symbol-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-minus-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp total_count(counts) do
|
||||
counts |> Map.values() |> Enum.sum()
|
||||
end
|
||||
end
|
||||
345
lib/berrypod_web/live/admin/product_show.ex
Normal file
345
lib/berrypod_web/live/admin/product_show.ex
Normal file
@@ -0,0 +1,345 @@
|
||||
defmodule BerrypodWeb.Admin.ProductShow do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.{Product, ProductImage, ProductVariant}
|
||||
alias Berrypod.Cart
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
case Products.get_product(id, preload: [:provider_connection, images: :image, variants: []]) do
|
||||
nil ->
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:error, "Product not found")
|
||||
|> push_navigate(to: ~p"/admin/products")
|
||||
|
||||
{:ok, socket}
|
||||
|
||||
product ->
|
||||
form =
|
||||
product
|
||||
|> Product.storefront_changeset(%{})
|
||||
|> to_form(as: "product")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, product.title)
|
||||
|> assign(:product, product)
|
||||
|> assign(:form, form)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_storefront", %{"product" => params}, socket) do
|
||||
form =
|
||||
socket.assigns.product
|
||||
|> Product.storefront_changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: "product")
|
||||
|
||||
{:noreply, assign(socket, :form, form)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_storefront", %{"product" => params}, socket) do
|
||||
case Products.update_storefront(socket.assigns.product, params) do
|
||||
{:ok, updated} ->
|
||||
product = %{
|
||||
updated
|
||||
| provider_connection: socket.assigns.product.provider_connection,
|
||||
images: socket.assigns.product.images,
|
||||
variants: socket.assigns.product.variants
|
||||
}
|
||||
|
||||
form =
|
||||
product
|
||||
|> Product.storefront_changeset(%{})
|
||||
|> to_form(as: "product")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:product, product)
|
||||
|> assign(:form, form)
|
||||
|> put_flash(:info, "Product updated")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset, as: "product"))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("resync", _params, socket) do
|
||||
product = socket.assigns.product
|
||||
|
||||
if product.provider_connection do
|
||||
Products.enqueue_sync(product.provider_connection)
|
||||
|
||||
{:noreply, put_flash(socket, :info, "Sync queued for #{product.provider_connection.name}")}
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, "No provider connection")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
<.link
|
||||
navigate={~p"/admin/products"}
|
||||
class="text-sm font-normal text-base-content/60 hover:underline"
|
||||
>
|
||||
← Products
|
||||
</.link>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<span class="text-2xl font-bold">{@product.title}</span>
|
||||
<.visibility_badge visible={@product.visible} />
|
||||
<.status_badge status={@product.status} />
|
||||
</div>
|
||||
<:actions>
|
||||
<.link
|
||||
:if={provider_edit_url(@product)}
|
||||
href={provider_edit_url(@product)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
>
|
||||
Edit on {provider_label(@product)}
|
||||
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" />
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/products/#{@product.slug}"}
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
>
|
||||
View on shop <.icon name="hero-arrow-top-right-on-square-mini" class="size-4" />
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<%!-- images + details --%>
|
||||
<div class="grid gap-6 mt-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
<div
|
||||
:for={image <- sorted_images(@product)}
|
||||
class="aspect-square rounded bg-base-200 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={ProductImage.url(image, 400)}
|
||||
alt={image.alt || @product.title}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p :if={@product.images == []} class="text-base-content/40 text-sm">No images</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Details</h3>
|
||||
<.list>
|
||||
<:item :if={@product.provider_connection} title="Provider">
|
||||
{provider_label(@product)} via {@product.provider_connection.name}
|
||||
</:item>
|
||||
<:item title="Category">{@product.category || "—"}</:item>
|
||||
<:item title="Price">{Cart.format_price(@product.cheapest_price)}</:item>
|
||||
<:item title="Variants">{length(@product.variants)}</:item>
|
||||
<:item title="Images">{length(@product.images)}</:item>
|
||||
<:item title="Created">{format_date(@product.inserted_at)}</:item>
|
||||
<:item
|
||||
:if={@product.provider_connection && @product.provider_connection.last_synced_at}
|
||||
title="Last synced"
|
||||
>
|
||||
{format_date(@product.provider_connection.last_synced_at)}
|
||||
</:item>
|
||||
</.list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- storefront controls --%>
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Storefront controls</h3>
|
||||
<.form
|
||||
for={@form}
|
||||
phx-submit="save_storefront"
|
||||
phx-change="validate_storefront"
|
||||
class="flex flex-wrap gap-4 items-end"
|
||||
>
|
||||
<label class="w-auto">
|
||||
<span class="text-xs mb-0.5">Visibility</span>
|
||||
<select
|
||||
name="product[visible]"
|
||||
class="admin-select admin-select-sm"
|
||||
aria-label="Visibility"
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={@form[:visible].value == true || @form[:visible].value == "true"}
|
||||
>
|
||||
Visible
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={@form[:visible].value == false || @form[:visible].value == "false"}
|
||||
>
|
||||
Hidden
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="w-auto flex-1 min-w-48">
|
||||
<span class="text-xs mb-0.5">Category</span>
|
||||
<input
|
||||
type="text"
|
||||
name="product[category]"
|
||||
value={@form[:category].value}
|
||||
class="admin-input admin-input-sm"
|
||||
placeholder="e.g. Apparel"
|
||||
/>
|
||||
</label>
|
||||
<.button type="submit" class="admin-btn-sm admin-btn-primary">Save</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- variants --%>
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Variants ({length(@product.variants)})</h3>
|
||||
<.table id="variants" rows={@product.variants}>
|
||||
<:col :let={variant} label="Options">{ProductVariant.options_title(variant)}</:col>
|
||||
<:col :let={variant} label="SKU">{variant.sku || "—"}</:col>
|
||||
<:col :let={variant} label="Price">{Cart.format_price(variant.price)}</:col>
|
||||
<:col :let={variant} label="Cost">
|
||||
{if variant.cost, do: Cart.format_price(variant.cost), else: "—"}
|
||||
</:col>
|
||||
<:col :let={variant} label="Profit">
|
||||
{if ProductVariant.profit(variant),
|
||||
do: Cart.format_price(ProductVariant.profit(variant)),
|
||||
else: "—"}
|
||||
</:col>
|
||||
<:col :let={variant} label="Available">
|
||||
<.icon
|
||||
:if={variant.is_enabled && variant.is_available}
|
||||
name="hero-check-circle-mini"
|
||||
class="size-5 text-green-600"
|
||||
/>
|
||||
<.icon
|
||||
:if={!variant.is_enabled || !variant.is_available}
|
||||
name="hero-x-circle-mini"
|
||||
class="size-5 text-base-content/30"
|
||||
/>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- provider data --%>
|
||||
<div
|
||||
:if={@product.provider_connection}
|
||||
class="card bg-base-100 shadow-sm border border-base-200 mt-6"
|
||||
>
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Provider data</h3>
|
||||
<.list>
|
||||
<:item title="Provider">
|
||||
{provider_label(@product)} via {@product.provider_connection.name}
|
||||
</:item>
|
||||
<:item title="Provider product ID">{@product.provider_product_id}</:item>
|
||||
<:item title="Status">{@product.status}</:item>
|
||||
<:item title="Sync status">{@product.provider_connection.sync_status}</:item>
|
||||
</.list>
|
||||
<div class="mt-4">
|
||||
<button phx-click="resync" class="admin-btn admin-btn-outline admin-btn-sm">
|
||||
<.icon name="hero-arrow-path" class="size-4" /> Re-sync
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp sorted_images(product) do
|
||||
(product.images || []) |> Enum.sort_by(& &1.position)
|
||||
end
|
||||
|
||||
defp visibility_badge(assigns) do
|
||||
{bg, text, ring, label} =
|
||||
if assigns.visible do
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "visible"}
|
||||
else
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10", "hidden"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, label: label)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
{@label}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_badge(assigns) do
|
||||
{bg, text, ring} =
|
||||
case assigns.status do
|
||||
"active" -> {"bg-green-50", "text-green-700", "ring-green-600/20"}
|
||||
"draft" -> {"bg-amber-50", "text-amber-700", "ring-amber-600/20"}
|
||||
"archived" -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"}
|
||||
_ -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
{@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp provider_label(%{provider_connection: %{provider_type: "printify"}}), do: "Printify"
|
||||
defp provider_label(%{provider_connection: %{provider_type: "printful"}}), do: "Printful"
|
||||
defp provider_label(%{provider_connection: %{provider_type: type}}), do: String.capitalize(type)
|
||||
defp provider_label(_), do: nil
|
||||
|
||||
defp provider_edit_url(%{
|
||||
provider_connection: %{provider_type: "printify", config: config},
|
||||
provider_product_id: pid
|
||||
}) do
|
||||
shop_id = config["shop_id"]
|
||||
if shop_id && pid, do: "https://printify.com/app/editor/#{shop_id}/#{pid}"
|
||||
end
|
||||
|
||||
defp provider_edit_url(%{
|
||||
provider_connection: %{provider_type: "printful"},
|
||||
provider_product_id: pid
|
||||
}) do
|
||||
if pid, do: "https://www.printful.com/dashboard/sync/update?id=#{pid}"
|
||||
end
|
||||
|
||||
defp provider_edit_url(_), do: nil
|
||||
|
||||
defp format_date(nil), do: "—"
|
||||
defp format_date(datetime), do: Calendar.strftime(datetime, "%d %b %Y %H:%M")
|
||||
end
|
||||
285
lib/berrypod_web/live/admin/products.ex
Normal file
285
lib/berrypod_web/live/admin/products.ex
Normal file
@@ -0,0 +1,285 @@
|
||||
defmodule BerrypodWeb.Admin.Products do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.{Product, ProductImage}
|
||||
alias Berrypod.Cart
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
connections = Products.list_provider_connections()
|
||||
categories = Products.list_all_categories()
|
||||
products = Products.list_products_admin()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Products")
|
||||
|> assign(:connections, connections)
|
||||
|> assign(:categories, categories)
|
||||
|> assign(:product_count, length(products))
|
||||
|> assign(:provider_filter, "all")
|
||||
|> assign(:category_filter, "all")
|
||||
|> assign(:visibility_filter, "all")
|
||||
|> assign(:stock_filter, "all")
|
||||
|> assign(:sort, "newest")
|
||||
|> stream(:products, products)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter", params, socket) do
|
||||
provider_filter = params["provider"] || socket.assigns.provider_filter
|
||||
category_filter = params["category"] || socket.assigns.category_filter
|
||||
visibility_filter = params["visibility"] || socket.assigns.visibility_filter
|
||||
stock_filter = params["stock"] || socket.assigns.stock_filter
|
||||
sort = params["sort"] || socket.assigns.sort
|
||||
|
||||
opts =
|
||||
[]
|
||||
|> maybe_add_filter(:provider_connection_id, provider_filter)
|
||||
|> maybe_add_filter(:category, category_filter)
|
||||
|> maybe_add_visibility(visibility_filter)
|
||||
|> maybe_add_stock(stock_filter)
|
||||
|> Keyword.put(:sort, sort)
|
||||
|
||||
products = Products.list_products_admin(opts)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:provider_filter, provider_filter)
|
||||
|> assign(:category_filter, category_filter)
|
||||
|> assign(:visibility_filter, visibility_filter)
|
||||
|> assign(:stock_filter, stock_filter)
|
||||
|> assign(:sort, sort)
|
||||
|> assign(:product_count, length(products))
|
||||
|> stream(:products, products, reset: true)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_visibility", %{"id" => id}, socket) do
|
||||
product =
|
||||
Products.get_product(id, preload: [:provider_connection, images: :image, variants: []])
|
||||
|
||||
case Products.toggle_visibility(product) do
|
||||
{:ok, updated} ->
|
||||
updated = %{
|
||||
updated
|
||||
| provider_connection: product.provider_connection,
|
||||
images: product.images,
|
||||
variants: product.variants
|
||||
}
|
||||
|
||||
{:noreply, stream_insert(socket, :products, updated)}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Could not update visibility")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Products
|
||||
<:subtitle>{@product_count} products</:subtitle>
|
||||
</.header>
|
||||
|
||||
<form phx-change="filter" class="flex gap-2 mt-6 mb-4 flex-wrap items-end">
|
||||
<.filter_select
|
||||
:if={length(@connections) > 1}
|
||||
name="provider"
|
||||
label="Provider"
|
||||
value={@provider_filter}
|
||||
options={[{"All providers", "all"}] ++ Enum.map(@connections, &{&1.name, &1.id})}
|
||||
/>
|
||||
<.filter_select
|
||||
:if={@categories != []}
|
||||
name="category"
|
||||
label="Category"
|
||||
value={@category_filter}
|
||||
options={[{"All categories", "all"}] ++ Enum.map(@categories, &{&1, &1})}
|
||||
/>
|
||||
<.filter_select
|
||||
name="visibility"
|
||||
label="Visibility"
|
||||
value={@visibility_filter}
|
||||
options={[{"All", "all"}, {"Visible", "visible"}, {"Hidden", "hidden"}]}
|
||||
/>
|
||||
<.filter_select
|
||||
name="stock"
|
||||
label="Stock"
|
||||
value={@stock_filter}
|
||||
options={[{"All", "all"}, {"In stock", "in_stock"}, {"Out of stock", "out_of_stock"}]}
|
||||
/>
|
||||
<.filter_select
|
||||
name="sort"
|
||||
label="Sort"
|
||||
value={@sort}
|
||||
options={[
|
||||
{"Newest", "newest"},
|
||||
{"Name A\u2013Z", "name_asc"},
|
||||
{"Name Z\u2013A", "name_desc"},
|
||||
{"Price: low\u2013high", "price_asc"},
|
||||
{"Price: high\u2013low", "price_desc"}
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<.table
|
||||
:if={@product_count > 0}
|
||||
id="products"
|
||||
rows={@streams.products}
|
||||
row_item={fn {_id, product} -> product end}
|
||||
row_click={fn {_id, product} -> JS.navigate(~p"/admin/products/#{product}") end}
|
||||
>
|
||||
<:col :let={product} label="">
|
||||
<.product_thumbnail product={product} />
|
||||
</:col>
|
||||
<:col :let={product} label="Product">
|
||||
<div class="font-medium">
|
||||
<.link navigate={~p"/admin/products/#{product}"} class="hover:underline">
|
||||
{product.title}
|
||||
</.link>
|
||||
</div>
|
||||
<.provider_badge :if={product.provider_connection} connection={product.provider_connection} />
|
||||
</:col>
|
||||
<:col :let={product} label="Category">
|
||||
{product.category || "—"}
|
||||
</:col>
|
||||
<:col :let={product} label="Price">
|
||||
<span :if={product.on_sale} class="text-red-600 text-xs font-medium mr-1">Sale</span>
|
||||
{Cart.format_price(product.cheapest_price)}
|
||||
</:col>
|
||||
<:col :let={product} label="Stock">
|
||||
<.stock_badge in_stock={product.in_stock} />
|
||||
</:col>
|
||||
<:col :let={product} label="Variants">
|
||||
{length(product.variants)}
|
||||
</:col>
|
||||
<:col :let={product} label="Visible">
|
||||
<button
|
||||
phx-click="toggle_visibility"
|
||||
phx-value-id={product.id}
|
||||
aria-pressed={to_string(product.visible)}
|
||||
aria-label={"Toggle visibility for #{product.title}"}
|
||||
class={[
|
||||
"admin-btn admin-btn-ghost admin-btn-sm",
|
||||
product.visible && "text-green-600",
|
||||
!product.visible && "text-base-content/30"
|
||||
]}
|
||||
>
|
||||
<.icon :if={product.visible} name="hero-eye" class="size-5" />
|
||||
<.icon :if={!product.visible} name="hero-eye-slash" class="size-5" />
|
||||
</button>
|
||||
</:col>
|
||||
</.table>
|
||||
|
||||
<div :if={@product_count == 0} class="text-center py-12 text-base-content/60">
|
||||
<.icon name="hero-cube" class="size-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No products yet</p>
|
||||
<p class="text-sm mt-1">
|
||||
<.link navigate={~p"/admin/providers"} class="admin-link">
|
||||
Connect a provider
|
||||
</.link>
|
||||
to sync your products.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper components
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp filter_select(assigns) do
|
||||
~H"""
|
||||
<label class="w-auto">
|
||||
<span class="text-xs mb-0.5">{@label}</span>
|
||||
<select name={@name} class="admin-select admin-select-sm" aria-label={@label}>
|
||||
<option :for={{label, value} <- @options} value={value} selected={value == @value}>
|
||||
{label}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
||||
defp product_thumbnail(assigns) do
|
||||
image = Product.primary_image(assigns.product)
|
||||
|
||||
url =
|
||||
if image do
|
||||
ProductImage.thumbnail_url(image)
|
||||
end
|
||||
|
||||
alt = (image && image.alt) || assigns.product.title
|
||||
|
||||
assigns = assign(assigns, url: url, alt: alt)
|
||||
|
||||
~H"""
|
||||
<div class="w-10 h-10 rounded bg-base-200 overflow-hidden flex-shrink-0">
|
||||
<img :if={@url} src={@url} alt={@alt} class="w-full h-full object-cover" loading="lazy" />
|
||||
<div :if={!@url} class="w-full h-full flex items-center justify-center">
|
||||
<.icon name="hero-photo" class="size-5 text-base-content/30" />
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp provider_badge(assigns) do
|
||||
label =
|
||||
case assigns.connection.provider_type do
|
||||
"printify" -> "Printify"
|
||||
"printful" -> "Printful"
|
||||
other -> String.capitalize(other)
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :label, label)
|
||||
|
||||
~H"""
|
||||
<span class="inline-flex items-center rounded-full bg-base-200 px-1.5 py-0.5 text-xs text-base-content/60 mt-0.5">
|
||||
{@label}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp stock_badge(assigns) do
|
||||
{bg, text, ring, label} =
|
||||
if assigns.in_stock do
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "In stock"}
|
||||
else
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "Out of stock"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, label: label)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
{@label}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp maybe_add_filter(opts, _key, "all"), do: opts
|
||||
defp maybe_add_filter(opts, key, value), do: Keyword.put(opts, key, value)
|
||||
|
||||
defp maybe_add_visibility(opts, "visible"), do: Keyword.put(opts, :visible, true)
|
||||
defp maybe_add_visibility(opts, "hidden"), do: Keyword.put(opts, :visible, false)
|
||||
defp maybe_add_visibility(opts, _), do: opts
|
||||
|
||||
defp maybe_add_stock(opts, "in_stock"), do: Keyword.put(opts, :in_stock, true)
|
||||
defp maybe_add_stock(opts, "out_of_stock"), do: Keyword.put(opts, :in_stock, false)
|
||||
defp maybe_add_stock(opts, _), do: opts
|
||||
end
|
||||
163
lib/berrypod_web/live/admin/providers/form.ex
Normal file
163
lib/berrypod_web/live/admin/providers/form.ex
Normal file
@@ -0,0 +1,163 @@
|
||||
defmodule BerrypodWeb.Admin.Providers.Form do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.ProviderConnection
|
||||
alias Berrypod.Providers
|
||||
|
||||
@supported_types ~w(printify printful)
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
{:ok, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, params) do
|
||||
provider_type = validated_type(params["type"])
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Connect to #{provider_label(provider_type)}")
|
||||
|> assign(:provider_type, provider_type)
|
||||
|> assign(:connection, %ProviderConnection{provider_type: provider_type})
|
||||
|> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{})))
|
||||
|> assign(:testing, false)
|
||||
|> assign(:test_result, nil)
|
||||
|> assign(:pending_api_key, nil)
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
connection = Products.get_provider_connection!(id)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "#{provider_label(connection.provider_type)} settings")
|
||||
|> assign(:provider_type, connection.provider_type)
|
||||
|> assign(:connection, connection)
|
||||
|> assign(:form, to_form(ProviderConnection.changeset(connection, %{})))
|
||||
|> assign(:testing, false)
|
||||
|> assign(:test_result, nil)
|
||||
|> assign(:pending_api_key, nil)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"provider_connection" => params}, socket) do
|
||||
form =
|
||||
socket.assigns.connection
|
||||
|> ProviderConnection.changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
# Store api_key separately since changeset encrypts it immediately
|
||||
{:noreply, assign(socket, form: form, pending_api_key: params["api_key"])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("test_connection", _params, socket) do
|
||||
socket = assign(socket, testing: true, test_result: nil)
|
||||
|
||||
api_key =
|
||||
socket.assigns[:pending_api_key] ||
|
||||
ProviderConnection.get_api_key(socket.assigns.connection)
|
||||
|
||||
if api_key && api_key != "" do
|
||||
temp_conn = %ProviderConnection{
|
||||
provider_type: socket.assigns.provider_type,
|
||||
api_key_encrypted: encrypt_api_key(api_key)
|
||||
}
|
||||
|
||||
result = Providers.test_connection(temp_conn)
|
||||
{:noreply, assign(socket, testing: false, test_result: result)}
|
||||
else
|
||||
{:noreply, assign(socket, testing: false, test_result: {:error, :no_api_key})}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"provider_connection" => params}, socket) do
|
||||
save_connection(socket, socket.assigns.live_action, params)
|
||||
end
|
||||
|
||||
defp save_connection(socket, :new, params) do
|
||||
provider_type = socket.assigns.provider_type
|
||||
|
||||
params =
|
||||
params
|
||||
|> Map.put("provider_type", provider_type)
|
||||
|> maybe_add_config(provider_type, socket.assigns.test_result)
|
||||
|> maybe_add_name(provider_type, socket.assigns.test_result)
|
||||
|
||||
case Products.create_provider_connection(params) do
|
||||
{:ok, _connection} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Connected to #{provider_label(provider_type)}!")
|
||||
|> push_navigate(to: ~p"/admin/settings")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_connection(socket, :edit, params) do
|
||||
case Products.update_provider_connection(socket.assigns.connection, params) do
|
||||
{:ok, _connection} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Settings saved")
|
||||
|> push_navigate(to: ~p"/admin/settings")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
# Printify returns shop_id, Printful returns store_id
|
||||
defp maybe_add_config(params, "printify", {:ok, %{shop_id: shop_id}}) do
|
||||
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
|
||||
Map.put(params, "config", config)
|
||||
end
|
||||
|
||||
defp maybe_add_config(params, "printful", {:ok, %{store_id: store_id}}) do
|
||||
config = Map.get(params, "config", %{}) |> Map.put("store_id", to_string(store_id))
|
||||
Map.put(params, "config", config)
|
||||
end
|
||||
|
||||
defp maybe_add_config(params, _type, _result), do: params
|
||||
|
||||
defp maybe_add_name(params, "printify", {:ok, %{shop_name: name}}) when is_binary(name) do
|
||||
Map.put_new(params, "name", name)
|
||||
end
|
||||
|
||||
defp maybe_add_name(params, "printful", {:ok, %{store_name: name}}) when is_binary(name) do
|
||||
Map.put_new(params, "name", name)
|
||||
end
|
||||
|
||||
defp maybe_add_name(params, type, _result) do
|
||||
Map.put_new(params, "name", provider_label(type))
|
||||
end
|
||||
|
||||
defp encrypt_api_key(api_key) do
|
||||
case Berrypod.Vault.encrypt(api_key) do
|
||||
{:ok, encrypted} -> encrypted
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp validated_type(type) when type in @supported_types, do: type
|
||||
defp validated_type(_), do: "printify"
|
||||
|
||||
# Shared helpers used by the template
|
||||
|
||||
defp provider_label("printful"), do: "Printful"
|
||||
defp provider_label(_), do: "Printify"
|
||||
|
||||
defp connection_name({:ok, %{shop_name: name}}), do: name
|
||||
defp connection_name({:ok, %{store_name: name}}), do: name
|
||||
defp connection_name(_), do: nil
|
||||
|
||||
defp format_error(:no_api_key), do: "Please enter your API key"
|
||||
defp format_error(:unauthorized), do: "That key doesn't seem to be valid"
|
||||
defp format_error(:timeout), do: "Couldn't reach the provider - try again"
|
||||
defp format_error({:http_error, _code}), do: "Something went wrong - try again"
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_), do: "Connection failed - check your key and try again"
|
||||
end
|
||||
135
lib/berrypod_web/live/admin/providers/form.html.heex
Normal file
135
lib/berrypod_web/live/admin/providers/form.html.heex
Normal file
@@ -0,0 +1,135 @@
|
||||
<.header>
|
||||
{if @live_action == :new,
|
||||
do: "Connect to #{provider_label(@provider_type)}",
|
||||
else: "#{provider_label(@provider_type)} settings"}
|
||||
</.header>
|
||||
|
||||
<div class="max-w-xl mt-6">
|
||||
<%= if @live_action == :new do %>
|
||||
<div class="prose prose-sm mb-6">
|
||||
<p>
|
||||
{provider_label(@provider_type)} is a print-on-demand service that prints and ships products for you.
|
||||
Connect your account to automatically import your products into your shop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= if @provider_type == "printify" do %>
|
||||
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
|
||||
<p class="font-medium mb-2">Get your API key from Printify:</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-base-content/80">
|
||||
<li>
|
||||
<a
|
||||
href="https://printify.com/app/auth/login"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-link"
|
||||
>
|
||||
Log in to Printify
|
||||
</a>
|
||||
(or <a
|
||||
href="https://printify.com/app/auth/register"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-link"
|
||||
>create a free account</a>)
|
||||
</li>
|
||||
<li>Click <strong>Account</strong> (top right)</li>
|
||||
<li>Select <strong>Connections</strong> from the dropdown</li>
|
||||
<li>Find <strong>API tokens</strong> and click <strong>Generate</strong></li>
|
||||
<li>
|
||||
Enter a name (e.g. "My Shop"), keep <strong>all scopes</strong>
|
||||
selected, and click <strong>Generate token</strong>
|
||||
</li>
|
||||
<li>Click <strong>Copy to clipboard</strong> and paste it below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
|
||||
<p class="font-medium mb-2">Get your API key from Printful:</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-base-content/80">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.printful.com/auth/login"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-link"
|
||||
>
|
||||
Log in to Printful
|
||||
</a>
|
||||
(or <a
|
||||
href="https://www.printful.com/auth/signup"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-link"
|
||||
>create a free account</a>)
|
||||
</li>
|
||||
<li>Go to <strong>Settings</strong> → <strong>API access</strong></li>
|
||||
<li>Click <strong>Create API key</strong></li>
|
||||
<li>Give it a name and select <strong>all scopes</strong></li>
|
||||
<li>Copy the token and paste it below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<.form for={@form} id="provider-form" phx-change="validate" phx-submit="save">
|
||||
<input type="hidden" name="provider_connection[provider_type]" value={@provider_type} />
|
||||
|
||||
<.input
|
||||
field={@form[:api_key]}
|
||||
type="password"
|
||||
label={"#{provider_label(@provider_type)} API key"}
|
||||
placeholder={
|
||||
if @live_action == :edit,
|
||||
do: "Leave blank to keep current key",
|
||||
else: "Paste your key here"
|
||||
}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||
phx-click="test_connection"
|
||||
disabled={@testing}
|
||||
>
|
||||
<.icon
|
||||
name={if @testing, do: "hero-arrow-path", else: "hero-signal"}
|
||||
class={if @testing, do: "size-4 animate-spin", else: "size-4"}
|
||||
/>
|
||||
{if @testing, do: "Checking...", else: "Check connection"}
|
||||
</button>
|
||||
|
||||
<div :if={@test_result} class="text-sm">
|
||||
<%= case @test_result do %>
|
||||
<% {:ok, _info} -> %>
|
||||
<span class="text-success flex items-center gap-1">
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
Connected to {connection_name(@test_result) || provider_label(@provider_type)}
|
||||
</span>
|
||||
<% {:error, reason} -> %>
|
||||
<span class="text-error flex items-center gap-1">
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{format_error(reason)}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @live_action == :edit do %>
|
||||
<.input field={@form[:enabled]} type="checkbox" label="Connection enabled" />
|
||||
<% end %>
|
||||
|
||||
<div class="flex gap-2 mt-6">
|
||||
<.button type="submit" disabled={@testing}>
|
||||
{if @live_action == :new,
|
||||
do: "Connect to #{provider_label(@provider_type)}",
|
||||
else: "Save changes"}
|
||||
</.button>
|
||||
<.link navigate={~p"/admin/providers"} class="admin-btn admin-btn-ghost">
|
||||
Cancel
|
||||
</.link>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
98
lib/berrypod_web/live/admin/providers/index.ex
Normal file
98
lib/berrypod_web/live/admin/providers/index.ex
Normal file
@@ -0,0 +1,98 @@
|
||||
defmodule BerrypodWeb.Admin.Providers.Index do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.ProviderConnection
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
connections = Products.list_provider_connections()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Provider connections")
|
||||
|> stream(:connections, connections)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
connection = Products.get_provider_connection!(id)
|
||||
{:ok, _} = Products.delete_provider_connection(connection)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream_delete(:connections, connection)
|
||||
|> put_flash(:info, "Provider connection deleted")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sync", %{"id" => id}, socket) do
|
||||
connection = Products.get_provider_connection!(id)
|
||||
|
||||
case Products.enqueue_sync(connection) do
|
||||
{:ok, _job} ->
|
||||
# Update the connection status in the stream
|
||||
updated = %{connection | sync_status: "syncing"}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream_insert(:connections, updated)
|
||||
|> put_flash(:info, "Sync started for #{connection.name}")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to start sync")}
|
||||
end
|
||||
end
|
||||
|
||||
# Function components for the template
|
||||
|
||||
attr :status, :string, required: true
|
||||
attr :enabled, :boolean, required: true
|
||||
|
||||
defp status_indicator(assigns) do
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex size-3 rounded-full",
|
||||
cond do
|
||||
not @enabled -> "bg-base-content/30"
|
||||
@status == "syncing" -> "bg-warning animate-pulse"
|
||||
@status == "completed" -> "bg-success"
|
||||
@status == "failed" -> "bg-error"
|
||||
true -> "bg-base-content/30"
|
||||
end
|
||||
]} />
|
||||
"""
|
||||
end
|
||||
|
||||
attr :connection, ProviderConnection, required: true
|
||||
|
||||
defp connection_info(assigns) do
|
||||
product_count = Products.count_products_for_connection(assigns.connection.id)
|
||||
assigns = assign(assigns, :product_count, product_count)
|
||||
|
||||
~H"""
|
||||
<span>
|
||||
<.icon name="hero-cube" class="size-4 inline" />
|
||||
{@product_count} {if @product_count == 1, do: "product", else: "products"}
|
||||
</span>
|
||||
<span :if={@connection.last_synced_at}>
|
||||
<.icon name="hero-clock" class="size-4 inline" />
|
||||
Last synced {format_relative_time(@connection.last_synced_at)}
|
||||
</span>
|
||||
<span :if={!@connection.last_synced_at} class="text-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="size-4 inline" /> Never synced
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_relative_time(datetime) do
|
||||
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
||||
|
||||
cond do
|
||||
diff < 60 -> "just now"
|
||||
diff < 3600 -> "#{div(diff, 60)} min ago"
|
||||
diff < 86400 -> "#{div(diff, 3600)} hours ago"
|
||||
true -> "#{div(diff, 86400)} days ago"
|
||||
end
|
||||
end
|
||||
end
|
||||
94
lib/berrypod_web/live/admin/providers/index.html.heex
Normal file
94
lib/berrypod_web/live/admin/providers/index.html.heex
Normal file
@@ -0,0 +1,94 @@
|
||||
<.header>
|
||||
Providers
|
||||
<:actions>
|
||||
<div class="admin-dropdown">
|
||||
<div tabindex="0" role="button" class="admin-btn admin-btn-primary">
|
||||
<.icon name="hero-plus" class="size-4 mr-1" /> Connect provider
|
||||
</div>
|
||||
<ul tabindex="0" class="admin-dropdown-content">
|
||||
<li>
|
||||
<.link navigate={~p"/admin/providers/new?type=printify"}>Printify</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate={~p"/admin/providers/new?type=printful"}>Printful</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div id="connections" phx-update="stream" class="mt-6 space-y-4">
|
||||
<div id="connections-empty" class="hidden only:block text-center py-12">
|
||||
<.icon name="hero-cube" class="size-16 mx-auto mb-4 text-base-content/30" />
|
||||
<h2 class="text-xl font-medium">Connect a print-on-demand provider</h2>
|
||||
<p class="mt-2 text-base-content/60 max-w-md mx-auto">
|
||||
Connect your Printify or Printful account to import products
|
||||
and start selling.
|
||||
</p>
|
||||
<div class="flex justify-center gap-3 mt-6">
|
||||
<.button navigate={~p"/admin/providers/new?type=printify"}>
|
||||
Connect Printify
|
||||
</.button>
|
||||
<.button navigate={~p"/admin/providers/new?type=printful"} class="admin-btn-outline">
|
||||
Connect Printful
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:for={{dom_id, connection} <- @streams.connections}
|
||||
id={dom_id}
|
||||
class="admin-card"
|
||||
>
|
||||
<div class="admin-card-body">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<.status_indicator status={connection.sync_status} enabled={connection.enabled} />
|
||||
<h3 class="font-semibold text-lg">
|
||||
{String.capitalize(connection.provider_type)}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-base-content/70 mt-1">{connection.name}</p>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-sm text-base-content/60">
|
||||
<.connection_info connection={connection} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<.link
|
||||
navigate={~p"/admin/providers/#{connection.id}/edit"}
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
>
|
||||
Settings
|
||||
</.link>
|
||||
<button
|
||||
phx-click="delete"
|
||||
phx-value-id={connection.id}
|
||||
data-confirm={"Disconnect from #{String.capitalize(connection.provider_type)}? Your synced products will remain in your shop."}
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm text-error"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card-actions">
|
||||
<button
|
||||
phx-click="sync"
|
||||
phx-value-id={connection.id}
|
||||
disabled={connection.sync_status == "syncing"}
|
||||
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||
>
|
||||
<.icon
|
||||
name="hero-arrow-path"
|
||||
class={
|
||||
if connection.sync_status == "syncing", do: "size-4 animate-spin", else: "size-4"
|
||||
}
|
||||
/>
|
||||
{if connection.sync_status == "syncing", do: "Syncing...", else: "Sync products"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
691
lib/berrypod_web/live/admin/settings.ex
Normal file
691
lib/berrypod_web/live/admin/settings.ex
Normal file
@@ -0,0 +1,691 @@
|
||||
defmodule BerrypodWeb.Admin.Settings do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Accounts
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Settings
|
||||
alias Berrypod.Stripe.Setup, as: StripeSetup
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Settings")
|
||||
|> assign(:site_live, Settings.site_live?())
|
||||
|> assign_stripe_state()
|
||||
|> assign_products_state()
|
||||
|> assign_account_state(user)}
|
||||
end
|
||||
|
||||
# -- Stripe assigns --
|
||||
|
||||
defp assign_stripe_state(socket) do
|
||||
has_key = Settings.has_secret?("stripe_api_key")
|
||||
has_signing = Settings.has_secret?("stripe_signing_secret")
|
||||
|
||||
status =
|
||||
cond do
|
||||
!has_key -> :not_configured
|
||||
has_key && StripeSetup.localhost?() -> :connected_localhost
|
||||
true -> :connected
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:stripe_status, status)
|
||||
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
|
||||
|> assign(:stripe_signing_secret_hint, Settings.secret_hint("stripe_signing_secret"))
|
||||
|> assign(:stripe_webhook_url, StripeSetup.webhook_url())
|
||||
|> assign(:stripe_has_signing_secret, has_signing)
|
||||
|> assign(:connect_form, to_form(%{"api_key" => ""}, as: :stripe))
|
||||
|> assign(:secret_form, to_form(%{"signing_secret" => ""}, as: :webhook))
|
||||
|> assign(:stripe_advanced_open, false)
|
||||
|> assign(:connecting, false)
|
||||
end
|
||||
|
||||
# -- Products assigns --
|
||||
|
||||
defp assign_products_state(socket) do
|
||||
connections = Products.list_provider_connections()
|
||||
|
||||
connection_info =
|
||||
case connections do
|
||||
[conn | _] ->
|
||||
product_count = Products.count_products_for_connection(conn.id)
|
||||
%{connection: conn, product_count: product_count}
|
||||
|
||||
[] ->
|
||||
nil
|
||||
end
|
||||
|
||||
assign(socket, :provider, connection_info)
|
||||
end
|
||||
|
||||
# -- Account assigns --
|
||||
|
||||
defp assign_account_state(socket, user) do
|
||||
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
|
||||
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
|
||||
|
||||
socket
|
||||
|> assign(:current_email, user.email)
|
||||
|> assign(:email_form, to_form(email_changeset))
|
||||
|> assign(:password_form, to_form(password_changeset))
|
||||
|> assign(:trigger_submit, false)
|
||||
end
|
||||
|
||||
# -- Events: shop status --
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_site_live", _params, socket) do
|
||||
new_value = !socket.assigns.site_live
|
||||
{:ok, _} = Settings.set_site_live(new_value)
|
||||
|
||||
message = if new_value, do: "Shop is now live", else: "Shop taken offline"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:site_live, new_value)
|
||||
|> put_flash(:info, message)}
|
||||
end
|
||||
|
||||
# -- Events: Stripe --
|
||||
|
||||
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
||||
if api_key == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")}
|
||||
else
|
||||
socket = assign(socket, :connecting, true)
|
||||
|
||||
case StripeSetup.connect(api_key) do
|
||||
{:ok, :webhook_created} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(:info, "Stripe connected and webhook endpoint created")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:ok, :localhost} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(
|
||||
:info,
|
||||
"API key saved. Enter a webhook signing secret below for local testing."
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, message} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:connecting, false)
|
||||
|> put_flash(:error, "Stripe connection failed: #{message}")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("disconnect_stripe", _params, socket) do
|
||||
StripeSetup.disconnect()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(:info, "Stripe disconnected")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("save_signing_secret", %{"webhook" => %{"signing_secret" => secret}}, socket) do
|
||||
if secret == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please enter a signing secret")}
|
||||
else
|
||||
StripeSetup.save_signing_secret(secret)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(:info, "Webhook signing secret saved")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_stripe_advanced", _params, socket) do
|
||||
{:noreply, assign(socket, :stripe_advanced_open, !socket.assigns.stripe_advanced_open)}
|
||||
end
|
||||
|
||||
# -- Events: products --
|
||||
|
||||
def handle_event("sync", %{"id" => id}, socket) do
|
||||
connection = Products.get_provider_connection!(id)
|
||||
|
||||
case Products.enqueue_sync(connection) do
|
||||
{:ok, _job} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_products_state()
|
||||
|> put_flash(:info, "Sync started for #{connection.name}")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to start sync")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete_connection", %{"id" => id}, socket) do
|
||||
connection = Products.get_provider_connection!(id)
|
||||
{:ok, _} = Products.delete_provider_connection(connection)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_products_state()
|
||||
|> put_flash(:info, "Provider connection deleted")}
|
||||
end
|
||||
|
||||
# -- Events: account --
|
||||
|
||||
def handle_event("validate_email", %{"user" => user_params}, socket) do
|
||||
email_form =
|
||||
socket.assigns.current_scope.user
|
||||
|> Accounts.change_user_email(user_params, validate_unique: false)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, email_form: email_form)}
|
||||
end
|
||||
|
||||
def handle_event("update_email", %{"user" => user_params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
unless Accounts.sudo_mode?(user) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Please log in again to change account settings.")
|
||||
|> redirect(to: ~p"/users/log-in")}
|
||||
else
|
||||
case Accounts.change_user_email(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
Accounts.deliver_user_update_email_instructions(
|
||||
Ecto.Changeset.apply_action!(changeset, :insert),
|
||||
user.email,
|
||||
&url(~p"/users/settings/confirm-email/#{&1}")
|
||||
)
|
||||
|
||||
info = "A link to confirm your email change has been sent to the new address."
|
||||
{:noreply, put_flash(socket, :info, info)}
|
||||
|
||||
changeset ->
|
||||
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_password", %{"user" => user_params}, socket) do
|
||||
password_form =
|
||||
socket.assigns.current_scope.user
|
||||
|> Accounts.change_user_password(user_params, hash_password: false)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, password_form: password_form)}
|
||||
end
|
||||
|
||||
def handle_event("update_password", %{"user" => user_params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
unless Accounts.sudo_mode?(user) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Please log in again to change account settings.")
|
||||
|> redirect(to: ~p"/users/log-in")}
|
||||
else
|
||||
case Accounts.change_user_password(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
|
||||
|
||||
changeset ->
|
||||
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# -- Render --
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl">
|
||||
<.header>
|
||||
Settings
|
||||
</.header>
|
||||
|
||||
<%!-- Shop status --%>
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Shop status</h2>
|
||||
<%= if @site_live do %>
|
||||
<.status_pill color="green">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Live
|
||||
</.status_pill>
|
||||
<% else %>
|
||||
<.status_pill color="zinc">Offline</.status_pill>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
<%= if @site_live do %>
|
||||
Your shop is visible to the public.
|
||||
<% else %>
|
||||
Your shop is offline. Visitors see a "coming soon" page.
|
||||
<% end %>
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
phx-click="toggle_site_live"
|
||||
class={[
|
||||
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold shadow-xs",
|
||||
if(@site_live,
|
||||
do: "bg-base-200 text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset",
|
||||
else: "bg-green-600 text-white hover:bg-green-500"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= if @site_live do %>
|
||||
<.icon name="hero-eye-slash-mini" class="size-4" /> Take offline
|
||||
<% else %>
|
||||
<.icon name="hero-eye-mini" class="size-4" /> Go live
|
||||
<% end %>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Payments --%>
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Payments</h2>
|
||||
<%= case @stripe_status do %>
|
||||
<% :connected -> %>
|
||||
<.status_pill color="green">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Connected
|
||||
</.status_pill>
|
||||
<% :connected_localhost -> %>
|
||||
<.status_pill color="amber">
|
||||
<.icon name="hero-exclamation-triangle-mini" class="size-3" /> Dev mode
|
||||
</.status_pill>
|
||||
<% :not_configured -> %>
|
||||
<.status_pill color="zinc">Not connected</.status_pill>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @stripe_status == :not_configured do %>
|
||||
<.stripe_setup_form connect_form={@connect_form} connecting={@connecting} />
|
||||
<% else %>
|
||||
<.stripe_connected_view
|
||||
stripe_status={@stripe_status}
|
||||
stripe_api_key_hint={@stripe_api_key_hint}
|
||||
stripe_webhook_url={@stripe_webhook_url}
|
||||
stripe_signing_secret_hint={@stripe_signing_secret_hint}
|
||||
stripe_has_signing_secret={@stripe_has_signing_secret}
|
||||
secret_form={@secret_form}
|
||||
advanced_open={@stripe_advanced_open}
|
||||
/>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<%!-- Products --%>
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Products</h2>
|
||||
<%= if @provider do %>
|
||||
<.status_pill color="green">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Connected
|
||||
</.status_pill>
|
||||
<% else %>
|
||||
<.status_pill color="zinc">Not connected</.status_pill>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @provider do %>
|
||||
<.provider_connected provider={@provider} />
|
||||
<% else %>
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-base-content/60">
|
||||
Connect a print-on-demand provider to import products into your shop.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<.link
|
||||
navigate={~p"/admin/providers"}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-base-content px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-base-content/80"
|
||||
>
|
||||
<.icon name="hero-plus-mini" class="size-4" /> Connect a provider
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<%!-- Account --%>
|
||||
<section class="mt-10">
|
||||
<h2 class="text-lg font-semibold">Account</h2>
|
||||
|
||||
<div class="mt-4 space-y-6">
|
||||
<.form
|
||||
for={@email_form}
|
||||
id="email_form"
|
||||
phx-submit="update_email"
|
||||
phx-change="validate_email"
|
||||
>
|
||||
<.input
|
||||
field={@email_form[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Saving...">Change email</.button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<div class="border-t border-base-200 pt-6">
|
||||
<.form
|
||||
for={@password_form}
|
||||
id="password_form"
|
||||
action={~p"/users/update-password"}
|
||||
method="post"
|
||||
phx-change="validate_password"
|
||||
phx-submit="update_password"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<input
|
||||
name={@password_form[:email].name}
|
||||
type="hidden"
|
||||
id="hidden_user_email"
|
||||
autocomplete="username"
|
||||
value={@current_email}
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password]}
|
||||
type="password"
|
||||
label="New password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password_confirmation]}
|
||||
type="password"
|
||||
label="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Saving...">Change password</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Advanced --%>
|
||||
<section class="mt-10 pb-10">
|
||||
<h2 class="text-lg font-semibold">Advanced</h2>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<.link
|
||||
href={~p"/admin/dashboard"}
|
||||
class="text-sm text-base-content/60 hover:text-base-content"
|
||||
>
|
||||
<.icon name="hero-chart-bar" class="size-4 inline" /> System dashboard
|
||||
</.link>
|
||||
<.link href={~p"/admin/errors"} class="text-sm text-base-content/60 hover:text-base-content">
|
||||
<.icon name="hero-bug-ant" class="size-4 inline" /> Error tracker
|
||||
</.link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Function components --
|
||||
|
||||
attr :color, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp status_pill(assigns) do
|
||||
classes =
|
||||
case assigns.color do
|
||||
"green" -> "bg-green-50 text-green-700 ring-green-600/20"
|
||||
"amber" -> "bg-amber-50 text-amber-700 ring-amber-600/20"
|
||||
"zinc" -> "bg-base-200/50 text-base-content/60 ring-base-content/10"
|
||||
_ -> "bg-base-200/50 text-base-content/60 ring-base-content/10"
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :classes, classes)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@classes
|
||||
]}>
|
||||
{render_slot(@inner_block)}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :provider, :map, required: true
|
||||
|
||||
defp provider_connected(assigns) do
|
||||
conn = assigns.provider.connection
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:connection, conn)
|
||||
|> assign(:product_count, assigns.provider.product_count)
|
||||
|> assign(:syncing, conn.sync_status == "syncing")
|
||||
|> assign(:provider_label, String.capitalize(conn.provider_type))
|
||||
|
||||
~H"""
|
||||
<div class="mt-4">
|
||||
<dl class="text-sm">
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Provider</dt>
|
||||
<dd class="text-base-content">{@provider_label}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Shop</dt>
|
||||
<dd class="text-base-content">{@connection.name}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Products</dt>
|
||||
<dd class="text-base-content">{@product_count}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Last synced</dt>
|
||||
<dd class="text-base-content">
|
||||
<%= if @connection.last_synced_at do %>
|
||||
{format_relative_time(@connection.last_synced_at)}
|
||||
<% else %>
|
||||
<span class="text-amber-600">Never</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
phx-click="sync"
|
||||
phx-value-id={@connection.id}
|
||||
disabled={@syncing}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-base-200 px-3 py-1.5 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
|
||||
>
|
||||
<.icon
|
||||
name="hero-arrow-path"
|
||||
class={if @syncing, do: "size-4 animate-spin", else: "size-4"}
|
||||
/>
|
||||
{if @syncing, do: "Syncing...", else: "Sync products"}
|
||||
</button>
|
||||
<.link
|
||||
navigate={~p"/admin/providers/#{@connection.id}/edit"}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-base-200 px-3 py-1.5 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
|
||||
>
|
||||
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
|
||||
</.link>
|
||||
<button
|
||||
phx-click="delete_connection"
|
||||
phx-value-id={@connection.id}
|
||||
data-confirm={"Disconnect from #{@provider_label}? Your synced products will remain in your shop."}
|
||||
class="text-sm text-red-600 hover:text-red-800 px-2 py-1.5"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp stripe_setup_form(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-base-content/60">
|
||||
To accept payments, connect your Stripe account by entering your secret key.
|
||||
You can find it in your
|
||||
<a
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-base-content underline"
|
||||
>
|
||||
Stripe dashboard
|
||||
</a>
|
||||
under Developers → API keys.
|
||||
</p>
|
||||
|
||||
<.form for={@connect_form} phx-submit="connect_stripe" class="mt-6">
|
||||
<.input
|
||||
field={@connect_form[:api_key]}
|
||||
type="password"
|
||||
label="Secret key"
|
||||
autocomplete="off"
|
||||
placeholder="sk_test_... or sk_live_..."
|
||||
/>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
Starts with <code>sk_test_</code> (test mode) or <code>sk_live_</code> (live mode).
|
||||
This key is encrypted at rest in the database.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<.button phx-disable-with="Connecting...">
|
||||
Connect Stripe
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp stripe_connected_view(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4 space-y-4">
|
||||
<dl class="text-sm">
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">API key</dt>
|
||||
<dd><code class="text-base-content">{@stripe_api_key_hint}</code></dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Webhook URL</dt>
|
||||
<dd><code class="text-base-content text-xs break-all">{@stripe_webhook_url}</code></dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Webhook secret</dt>
|
||||
<dd>
|
||||
<%= if @stripe_has_signing_secret do %>
|
||||
<code class="text-base-content">{@stripe_signing_secret_hint}</code>
|
||||
<% else %>
|
||||
<span class="text-amber-600">Not set</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<%= if @stripe_status == :connected_localhost do %>
|
||||
<div class="rounded-md bg-amber-50 p-4 ring-1 ring-amber-600/10 ring-inset">
|
||||
<p class="text-sm text-amber-800">
|
||||
Stripe can't reach localhost for webhooks. For local testing, run the Stripe CLI:
|
||||
</p>
|
||||
<pre class="mt-2 rounded bg-amber-100 p-2 text-xs text-amber-900 overflow-x-auto">stripe listen --forward-to localhost:4000/webhooks/stripe</pre>
|
||||
<p class="mt-2 text-xs text-amber-700">
|
||||
The CLI will output a signing secret starting with <code>whsec_</code>. Enter it below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.form for={@secret_form} phx-submit="save_signing_secret" class="mt-2">
|
||||
<.input
|
||||
field={@secret_form[:signing_secret]}
|
||||
type="password"
|
||||
label="Webhook signing secret"
|
||||
autocomplete="off"
|
||||
placeholder="whsec_..."
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Saving...">Save signing secret</.button>
|
||||
</div>
|
||||
</.form>
|
||||
<% else %>
|
||||
<div class="border-t border-base-200 pt-3">
|
||||
<button
|
||||
phx-click="toggle_stripe_advanced"
|
||||
class="flex items-center gap-1 text-sm text-base-content/60 hover:text-base-content"
|
||||
>
|
||||
<.icon
|
||||
name={if @advanced_open, do: "hero-chevron-down-mini", else: "hero-chevron-right-mini"}
|
||||
class="size-4"
|
||||
/> Advanced
|
||||
</button>
|
||||
|
||||
<%= if @advanced_open do %>
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-base-content/60 mb-3">
|
||||
Override the webhook signing secret if you need to use a custom endpoint or the Stripe CLI.
|
||||
</p>
|
||||
<.form for={@secret_form} phx-submit="save_signing_secret">
|
||||
<.input
|
||||
field={@secret_form[:signing_secret]}
|
||||
type="password"
|
||||
label="Webhook signing secret"
|
||||
autocomplete="off"
|
||||
placeholder="whsec_..."
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Saving...">Save signing secret</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="border-t border-base-200 pt-4">
|
||||
<button
|
||||
phx-click="disconnect_stripe"
|
||||
data-confirm="This will remove your Stripe API key and delete the webhook endpoint. Are you sure?"
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Disconnect Stripe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_relative_time(datetime) do
|
||||
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
||||
|
||||
cond do
|
||||
diff < 60 -> "just now"
|
||||
diff < 3600 -> "#{div(diff, 60)} min ago"
|
||||
diff < 86400 -> "#{div(diff, 3600)} hours ago"
|
||||
true -> "#{div(diff, 86400)} days ago"
|
||||
end
|
||||
end
|
||||
end
|
||||
482
lib/berrypod_web/live/admin/theme/index.ex
Normal file
482
lib/berrypod_web/live/admin/theme/index.ex
Normal file
@@ -0,0 +1,482 @@
|
||||
defmodule BerrypodWeb.Admin.Theme.Index do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Settings
|
||||
alias Berrypod.Media
|
||||
alias Berrypod.Theme.{CSSGenerator, Presets, PreviewData}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
theme_settings = Settings.get_theme_settings()
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
preview_data = %{
|
||||
products: PreviewData.products(),
|
||||
cart_items: PreviewData.cart_items(),
|
||||
testimonials: PreviewData.testimonials(),
|
||||
categories: PreviewData.categories()
|
||||
}
|
||||
|
||||
logo_image = Media.get_logo()
|
||||
header_image = Media.get_header()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:preview_page, :home)
|
||||
|> assign(:presets_with_descriptions, Presets.all_with_descriptions())
|
||||
|> assign(:active_preset, active_preset)
|
||||
|> assign(:preview_data, preview_data)
|
||||
|> assign(:logo_image, logo_image)
|
||||
|> assign(:header_image, header_image)
|
||||
|> assign(:customise_open, false)
|
||||
|> assign(:sidebar_collapsed, false)
|
||||
|> assign(:cart_drawer_open, false)
|
||||
|> allow_upload(:logo_upload,
|
||||
accept: ~w(.png .jpg .jpeg .webp .svg),
|
||||
max_entries: 1,
|
||||
max_file_size: 2_000_000,
|
||||
auto_upload: true,
|
||||
progress: &handle_progress/3
|
||||
)
|
||||
|> allow_upload(:header_upload,
|
||||
accept: ~w(.png .jpg .jpeg .webp),
|
||||
max_entries: 1,
|
||||
max_file_size: 5_000_000,
|
||||
auto_upload: true,
|
||||
progress: &handle_progress/3
|
||||
)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp handle_progress(:logo_upload, entry, socket) do
|
||||
if entry.done? do
|
||||
consume_uploaded_entries(socket, :logo_upload, fn %{path: path}, entry ->
|
||||
case Media.upload_from_entry(path, entry, "logo") do
|
||||
{:ok, image} ->
|
||||
Settings.update_theme_settings(%{logo_image_id: image.id})
|
||||
{:ok, image}
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
[image | _] ->
|
||||
{:noreply, assign(socket, :logo_image, image)}
|
||||
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_progress(:header_upload, entry, socket) do
|
||||
if entry.done? do
|
||||
consume_uploaded_entries(socket, :header_upload, fn %{path: path}, entry ->
|
||||
case Media.upload_from_entry(path, entry, "header") do
|
||||
{:ok, image} ->
|
||||
Settings.update_theme_settings(%{header_image_id: image.id})
|
||||
{:ok, image}
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
[image | _] ->
|
||||
{:noreply, assign(socket, :header_image, image)}
|
||||
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("apply_preset", %{"preset" => preset_name}, socket) do
|
||||
preset_atom = String.to_existing_atom(preset_name)
|
||||
|
||||
case Settings.apply_preset(preset_atom) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:active_preset, preset_atom)
|
||||
|> put_flash(:info, "Applied #{preset_name} preset")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to apply preset")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("change_preview_page", %{"page" => page_name}, socket) do
|
||||
page_atom = String.to_existing_atom(page_name)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:preview_page, page_atom)
|
||||
|> push_event("scroll-preview-top", %{})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_setting", %{"field" => field, "setting_value" => value}, socket) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
attrs = %{field_atom => value}
|
||||
|
||||
case Settings.update_theme_settings(attrs) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:active_preset, active_preset)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_setting", %{"field" => field} = params, socket) do
|
||||
# For phx-change events from select/input elements, the value comes from the name attribute
|
||||
value = params[field] || params["#{field}_text"] || params["value"]
|
||||
|
||||
if value do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
attrs = %{field_atom => value}
|
||||
|
||||
case Settings.update_theme_settings(attrs) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:active_preset, active_preset)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_color", %{"field" => field, "value" => value}, socket) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
attrs = %{field_atom => value}
|
||||
|
||||
case Settings.update_theme_settings(attrs) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:active_preset, active_preset)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_setting", %{"field" => field}, socket) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
current_value = Map.get(socket.assigns.theme_settings, field_atom)
|
||||
attrs = %{field_atom => !current_value}
|
||||
|
||||
case Settings.update_theme_settings(attrs) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:active_preset, active_preset)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_theme", _params, socket) do
|
||||
socket = put_flash(socket, :info, "Theme saved successfully")
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("remove_logo", _params, socket) do
|
||||
if logo = socket.assigns.logo_image do
|
||||
Media.delete_image(logo)
|
||||
end
|
||||
|
||||
Settings.update_theme_settings(%{logo_image_id: nil})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:logo_image, nil)
|
||||
|> put_flash(:info, "Logo removed")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("remove_header", _params, socket) do
|
||||
if header = socket.assigns.header_image do
|
||||
Media.delete_image(header)
|
||||
end
|
||||
|
||||
Settings.update_theme_settings(%{header_image_id: nil})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:header_image, nil)
|
||||
|> put_flash(:info, "Header image removed")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_upload", %{"ref" => ref, "upload" => upload_name}, socket) do
|
||||
upload_atom = String.to_existing_atom(upload_name)
|
||||
{:noreply, cancel_upload(socket, upload_atom, ref)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_customise", _params, socket) do
|
||||
{:noreply, assign(socket, :customise_open, !socket.assigns.customise_open)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_sidebar", _params, socket) do
|
||||
{:noreply, assign(socket, :sidebar_collapsed, !socket.assigns.sidebar_collapsed)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_cart_drawer", _params, socket) do
|
||||
{:noreply, assign(socket, :cart_drawer_open, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close_cart_drawer", _params, socket) do
|
||||
{:noreply, assign(socket, :cart_drawer_open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("noop", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def error_to_string(:too_large), do: "File is too large"
|
||||
def error_to_string(:too_many_files), do: "Too many files"
|
||||
def error_to_string(:not_accepted), do: "File type not accepted"
|
||||
def error_to_string(err), do: inspect(err)
|
||||
|
||||
defp preview_assigns(assigns) do
|
||||
assign(assigns, %{
|
||||
mode: :preview,
|
||||
products: assigns.preview_data.products,
|
||||
categories: assigns.preview_data.categories,
|
||||
cart_items: PreviewData.cart_drawer_items(),
|
||||
cart_count: 2,
|
||||
cart_subtotal: "£72.00"
|
||||
})
|
||||
end
|
||||
|
||||
# Preview page component — delegates to shared PageTemplates with preview-specific assigns
|
||||
attr :page, :atom, required: true
|
||||
attr :preview_data, :map, required: true
|
||||
attr :theme_settings, :map, required: true
|
||||
attr :logo_image, :any, required: true
|
||||
attr :header_image, :any, required: true
|
||||
attr :cart_drawer_open, :boolean, default: false
|
||||
|
||||
defp preview_page(%{page: :home} = assigns) do
|
||||
assigns = preview_assigns(assigns)
|
||||
~H"<BerrypodWeb.PageTemplates.home {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :collection} = assigns) do
|
||||
assigns = preview_assigns(assigns)
|
||||
~H"<BerrypodWeb.PageTemplates.collection {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :pdp} = assigns) do
|
||||
product = List.first(assigns.preview_data.products)
|
||||
option_types = Map.get(product, :option_types) || []
|
||||
variants = Map.get(product, :variants) || []
|
||||
|
||||
{selected_options, selected_variant} =
|
||||
case variants do
|
||||
[first | _] -> {first.options, first}
|
||||
[] -> {%{}, nil}
|
||||
end
|
||||
|
||||
available_options =
|
||||
Enum.reduce(option_types, %{}, fn opt, acc ->
|
||||
values = Enum.map(opt.values, & &1.title)
|
||||
Map.put(acc, opt.name, values)
|
||||
end)
|
||||
|
||||
display_price =
|
||||
if selected_variant, do: selected_variant.price, else: product.cheapest_price
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(:product, product)
|
||||
|> assign(:gallery_images, build_gallery_images(product))
|
||||
|> assign(:related_products, Enum.slice(assigns.preview_data.products, 1, 4))
|
||||
|> assign(:option_types, option_types)
|
||||
|> assign(:selected_options, selected_options)
|
||||
|> assign(:available_options, available_options)
|
||||
|> assign(:display_price, display_price)
|
||||
|> assign(:quantity, 1)
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.pdp {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :cart} = assigns) do
|
||||
cart_items = assigns.preview_data.cart_items
|
||||
|
||||
subtotal =
|
||||
Enum.reduce(cart_items, 0, fn item, acc ->
|
||||
acc + item.product.cheapest_price * item.quantity
|
||||
end)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(:cart_page_items, cart_items)
|
||||
|> assign(:cart_page_subtotal, subtotal)
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.cart {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :about} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(%{
|
||||
active_page: "about",
|
||||
hero_title: "About the studio",
|
||||
hero_description: "Your story goes here – this is sample content for the demo shop",
|
||||
hero_background: :sunken,
|
||||
image_src: "/mockups/night-sky-blanket-3",
|
||||
image_alt: "Night sky blanket draped over a chair",
|
||||
content_blocks: PreviewData.about_content()
|
||||
})
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :delivery} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(%{
|
||||
active_page: "delivery",
|
||||
hero_title: "Delivery & returns",
|
||||
hero_description: "Everything you need to know about shipping and returns",
|
||||
content_blocks: PreviewData.delivery_content()
|
||||
})
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :privacy} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(%{
|
||||
active_page: "privacy",
|
||||
hero_title: "Privacy policy",
|
||||
hero_description: "How we handle your personal information",
|
||||
content_blocks: PreviewData.privacy_content()
|
||||
})
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :terms} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(%{
|
||||
active_page: "terms",
|
||||
hero_title: "Terms of service",
|
||||
hero_description: "The legal bits",
|
||||
content_blocks: PreviewData.terms_content()
|
||||
})
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :contact} = assigns) do
|
||||
assigns = preview_assigns(assigns)
|
||||
~H"<BerrypodWeb.PageTemplates.contact {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :error} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(%{
|
||||
error_code: "404",
|
||||
error_title: "Page Not Found",
|
||||
error_description:
|
||||
"Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved."
|
||||
})
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.error {assigns} />"
|
||||
end
|
||||
|
||||
defp build_gallery_images(product) do
|
||||
alias Berrypod.Products.ProductImage
|
||||
|
||||
(Map.get(product, :images) || [])
|
||||
|> Enum.sort_by(& &1.position)
|
||||
|> Enum.map(fn img -> ProductImage.url(img, 1200) end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> case do
|
||||
[] -> []
|
||||
urls -> urls
|
||||
end
|
||||
end
|
||||
end
|
||||
1183
lib/berrypod_web/live/admin/theme/index.html.heex
Normal file
1183
lib/berrypod_web/live/admin/theme/index.html.heex
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user