restructure LiveView directories: admin/, shop/, auth/
Consolidates admin_live/, theme_live/, provider_live/ into admin/ (with theme/ and providers/ subdirs). Renames shop_live/ to shop/ and user_live/ to auth/. Updates all module names, router refs, test files, CSS source paths, and dialyzer ignore. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
323
lib/simpleshop_theme_web/live/admin/order_show.ex
Normal file
323
lib/simpleshop_theme_web/live/admin/order_show.ex
Normal file
@@ -0,0 +1,323 @@
|
||||
defmodule SimpleshopThemeWeb.Admin.OrderShow do
|
||||
use SimpleshopThemeWeb, :live_view
|
||||
|
||||
alias SimpleshopTheme.Orders
|
||||
alias SimpleshopTheme.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"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.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="card bg-base-100 shadow-sm border border-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-base">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="card bg-base-100 shadow-sm border border-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-base">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="card bg-base-100 shadow-sm border border-base-200 mt-6">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="card-title text-base">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="link link-primary">
|
||||
{@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="btn btn-primary 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="btn btn-ghost btn-sm"
|
||||
>
|
||||
<.icon name="hero-arrow-path-mini" class="size-4" /> Refresh status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- line items --%>
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-base">Items</h3>
|
||||
<table class="table 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>
|
||||
</Layouts.app>
|
||||
"""
|
||||
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-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-no-symbol-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/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-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-arrow-uturn-left-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/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
|
||||
204
lib/simpleshop_theme_web/live/admin/orders.ex
Normal file
204
lib/simpleshop_theme_web/live/admin/orders.ex
Normal file
@@ -0,0 +1,204 @@
|
||||
defmodule SimpleshopThemeWeb.Admin.Orders do
|
||||
use SimpleshopThemeWeb, :live_view
|
||||
|
||||
alias SimpleshopTheme.Orders
|
||||
alias SimpleshopTheme.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"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.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>
|
||||
</Layouts.app>
|
||||
"""
|
||||
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={[
|
||||
"btn btn-sm",
|
||||
@active && "btn-primary",
|
||||
!@active && "btn-ghost"
|
||||
]}
|
||||
>
|
||||
{@label}
|
||||
<span :if={@count > 0} class="badge 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-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-arrow-uturn-left-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/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-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-no-symbol-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/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
|
||||
134
lib/simpleshop_theme_web/live/admin/providers/form.ex
Normal file
134
lib/simpleshop_theme_web/live/admin/providers/form.ex
Normal file
@@ -0,0 +1,134 @@
|
||||
defmodule SimpleshopThemeWeb.Admin.Providers.Form do
|
||||
use SimpleshopThemeWeb, :live_view
|
||||
|
||||
alias SimpleshopTheme.Products
|
||||
alias SimpleshopTheme.Products.ProviderConnection
|
||||
alias SimpleshopTheme.Providers
|
||||
|
||||
@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
|
||||
socket
|
||||
|> assign(:page_title, "Connect to Printify")
|
||||
|> assign(:connection, %ProviderConnection{provider_type: "printify"})
|
||||
|> 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, "Printify settings")
|
||||
|> 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)
|
||||
|
||||
# Use pending_api_key from validation, or fall back to existing encrypted key
|
||||
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: "printify",
|
||||
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
|
||||
params =
|
||||
params
|
||||
|> Map.put("provider_type", "printify")
|
||||
|> maybe_add_shop_config(socket.assigns.test_result)
|
||||
|> maybe_add_name(socket.assigns.test_result)
|
||||
|
||||
case Products.create_provider_connection(params) do
|
||||
{:ok, _connection} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Connected to Printify!")
|
||||
|> push_navigate(to: ~p"/admin/providers")}
|
||||
|
||||
{: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/providers")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
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")
|
||||
end
|
||||
|
||||
defp encrypt_api_key(api_key) do
|
||||
case SimpleshopTheme.Vault.encrypt(api_key) do
|
||||
{:ok, encrypted} -> encrypted
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp format_error(:no_api_key), do: "Please enter your connection key"
|
||||
defp format_error(:unauthorized), do: "That key doesn't seem to be valid"
|
||||
defp format_error(:timeout), do: "Couldn't reach Printify - 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
|
||||
104
lib/simpleshop_theme_web/live/admin/providers/form.html.heex
Normal file
104
lib/simpleshop_theme_web/live/admin/providers/form.html.heex
Normal file
@@ -0,0 +1,104 @@
|
||||
<Layouts.app flash={@flash}>
|
||||
<.header>
|
||||
{if @live_action == :new, do: "Connect to Printify", else: "Printify settings"}
|
||||
</.header>
|
||||
|
||||
<div class="max-w-xl mt-6">
|
||||
<%= if @live_action == :new do %>
|
||||
<div class="prose prose-sm mb-6">
|
||||
<p>
|
||||
Printify 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>
|
||||
|
||||
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
|
||||
<p class="font-medium mb-2">Get your connection 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="link"
|
||||
>
|
||||
Log in to Printify
|
||||
</a>
|
||||
(or <a
|
||||
href="https://printify.com/app/auth/register"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="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>
|
||||
<% end %>
|
||||
|
||||
<.form for={@form} id="provider-form" phx-change="validate" phx-submit="save">
|
||||
<input type="hidden" name="provider_connection[provider_type]" value="printify" />
|
||||
|
||||
<.input
|
||||
field={@form[:api_key]}
|
||||
type="password"
|
||||
label="Printify connection 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="btn btn-outline 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 {info.shop_name}
|
||||
</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 Printify", else: "Save changes"}
|
||||
</.button>
|
||||
<.link navigate={~p"/admin/providers"} class="btn btn-ghost">
|
||||
Cancel
|
||||
</.link>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
98
lib/simpleshop_theme_web/live/admin/providers/index.ex
Normal file
98
lib/simpleshop_theme_web/live/admin/providers/index.ex
Normal file
@@ -0,0 +1,98 @@
|
||||
defmodule SimpleshopThemeWeb.Admin.Providers.Index do
|
||||
use SimpleshopThemeWeb, :live_view
|
||||
|
||||
alias SimpleshopTheme.Products
|
||||
alias SimpleshopTheme.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
|
||||
@@ -0,0 +1,81 @@
|
||||
<Layouts.app flash={@flash}>
|
||||
<.header>
|
||||
Providers
|
||||
<:actions>
|
||||
<.button navigate={~p"/admin/providers/new"}>
|
||||
<.icon name="hero-plus" class="size-4 mr-1" /> Connect Printify
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div id="connections" phx-update="stream" class="mt-6 space-y-4">
|
||||
<div 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 your Printify account</h2>
|
||||
<p class="mt-2 text-base-content/60 max-w-md mx-auto">
|
||||
Printify handles printing and shipping for you. Connect your account
|
||||
to import your products and start selling.
|
||||
</p>
|
||||
<.button navigate={~p"/admin/providers/new"} class="mt-6">
|
||||
Connect to Printify
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:for={{dom_id, connection} <- @streams.connections}
|
||||
id={dom_id}
|
||||
class="card bg-base-100 shadow-sm border border-base-200"
|
||||
>
|
||||
<div class="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="btn btn-ghost btn-sm"
|
||||
>
|
||||
Settings
|
||||
</.link>
|
||||
<button
|
||||
phx-click="delete"
|
||||
phx-value-id={connection.id}
|
||||
data-confirm="Disconnect from Printify? Your synced products will remain in your shop."
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-4 pt-4 border-t border-base-200">
|
||||
<button
|
||||
phx-click="sync"
|
||||
phx-value-id={connection.id}
|
||||
disabled={connection.sync_status == "syncing"}
|
||||
class="btn btn-outline 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>
|
||||
</Layouts.app>
|
||||
339
lib/simpleshop_theme_web/live/admin/settings.ex
Normal file
339
lib/simpleshop_theme_web/live/admin/settings.ex
Normal file
@@ -0,0 +1,339 @@
|
||||
defmodule SimpleshopThemeWeb.Admin.Settings do
|
||||
use SimpleshopThemeWeb, :live_view
|
||||
|
||||
alias SimpleshopTheme.Settings
|
||||
alias SimpleshopTheme.Stripe.Setup, as: StripeSetup
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Settings")
|
||||
|> assign(:site_live, Settings.site_live?())
|
||||
|> assign_stripe_state()}
|
||||
end
|
||||
|
||||
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(:advanced_open, false)
|
||||
|> assign(:connecting, false)
|
||||
end
|
||||
|
||||
@impl true
|
||||
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_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
|
||||
|
||||
def handle_event("toggle_advanced", _params, socket) do
|
||||
{:noreply, assign(socket, :advanced_open, !socket.assigns.advanced_open)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="max-w-2xl">
|
||||
<.header>
|
||||
Settings
|
||||
<:subtitle>Shop status, payment providers, and API keys</:subtitle>
|
||||
</.header>
|
||||
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Shop status</h2>
|
||||
<%= if @site_live do %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Live
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-zinc-50 px-2 py-1 text-xs font-medium text-zinc-600 ring-1 ring-zinc-500/10 ring-inset">
|
||||
Offline
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-zinc-600">
|
||||
<%= 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-zinc-100 text-zinc-700 hover:bg-zinc-200 ring-1 ring-zinc-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>
|
||||
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Stripe</h2>
|
||||
<%= case @stripe_status do %>
|
||||
<% :connected -> %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Connected
|
||||
</span>
|
||||
<% :connected_localhost -> %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-600/20 ring-inset">
|
||||
<.icon name="hero-exclamation-triangle-mini" class="size-3" /> Dev mode
|
||||
</span>
|
||||
<% :not_configured -> %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-zinc-50 px-2 py-1 text-xs font-medium text-zinc-600 ring-1 ring-zinc-500/10 ring-inset">
|
||||
Not connected
|
||||
</span>
|
||||
<% 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={@advanced_open}
|
||||
/>
|
||||
<% end %>
|
||||
</section>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
defp stripe_setup_form(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-zinc-600">
|
||||
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-zinc-900 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-zinc-500 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-zinc-500 w-28 shrink-0">API key</dt>
|
||||
<dd><code class="text-zinc-700">{@stripe_api_key_hint}</code></dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-zinc-500 w-28 shrink-0">Webhook URL</dt>
|
||||
<dd><code class="text-zinc-700 text-xs break-all">{@stripe_webhook_url}</code></dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-zinc-500 w-28 shrink-0">Webhook secret</dt>
|
||||
<dd>
|
||||
<%= if @stripe_has_signing_secret do %>
|
||||
<code class="text-zinc-700">{@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-zinc-200 pt-3">
|
||||
<button
|
||||
phx-click="toggle_advanced"
|
||||
class="flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-700"
|
||||
>
|
||||
<.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-zinc-500 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-zinc-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
|
||||
end
|
||||
469
lib/simpleshop_theme_web/live/admin/theme/index.ex
Normal file
469
lib/simpleshop_theme_web/live/admin/theme/index.ex
Normal file
@@ -0,0 +1,469 @@
|
||||
defmodule SimpleshopThemeWeb.Admin.Theme.Index do
|
||||
use SimpleshopThemeWeb, :live_view
|
||||
|
||||
alias SimpleshopTheme.Settings
|
||||
alias SimpleshopTheme.Media
|
||||
alias SimpleshopTheme.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,
|
||||
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"<SimpleshopThemeWeb.PageTemplates.home {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :collection} = assigns) do
|
||||
assigns = preview_assigns(assigns)
|
||||
~H"<SimpleshopThemeWeb.PageTemplates.collection {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :pdp} = assigns) do
|
||||
product = List.first(assigns.preview_data.products)
|
||||
option_types = product[:option_types] || []
|
||||
variants = 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.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"<SimpleshopThemeWeb.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.price * item.quantity end)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(:cart_page_items, cart_items)
|
||||
|> assign(:cart_page_subtotal, subtotal)
|
||||
|
||||
~H"<SimpleshopThemeWeb.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"<SimpleshopThemeWeb.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"<SimpleshopThemeWeb.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"<SimpleshopThemeWeb.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"<SimpleshopThemeWeb.PageTemplates.content {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :contact} = assigns) do
|
||||
assigns = preview_assigns(assigns)
|
||||
~H"<SimpleshopThemeWeb.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"<SimpleshopThemeWeb.PageTemplates.error {assigns} />"
|
||||
end
|
||||
|
||||
defp build_gallery_images(product) do
|
||||
[product.image_url, product.hover_image_url, product.image_url, product.hover_image_url]
|
||||
end
|
||||
end
|
||||
1176
lib/simpleshop_theme_web/live/admin/theme/index.html.heex
Normal file
1176
lib/simpleshop_theme_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