add admin sidebar layout with responsive drawer navigation

- New admin root + child layouts with daisyUI drawer sidebar
- AdminLayoutHook tracks current path for active nav highlighting
- Split router into :admin, :admin_theme, :user_settings live_sessions
- Theme editor stays full-screen with back link to admin
- Admin bar on shop pages for logged-in users (mount_current_scope)
- Strip Layouts.app wrapper from admin LiveViews
- Remove nav from root.html.heex (now only serves auth pages)
- 9 new layout tests covering sidebar, active state, theme editor, admin bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-12 08:35:22 +00:00
parent deea04885f
commit 26d3bd782a
17 changed files with 756 additions and 541 deletions

View File

@ -0,0 +1,19 @@
defmodule SimpleshopThemeWeb.AdminLayoutHook do
@moduledoc """
LiveView on_mount hook that assigns the current path for admin sidebar navigation.
"""
import Phoenix.Component
def on_mount(:assign_current_path, _params, _session, socket) do
socket =
socket
|> assign(:current_path, "")
|> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,
uri,
socket ->
{:cont, assign(socket, :current_path, URI.parse(uri).path)}
end)
{:cont, socket}
end
end

View File

@ -35,35 +35,8 @@ defmodule SimpleshopThemeWeb.Layouts do
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<img src={~p"/images/logo.svg"} width="36" />
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
<div class="flex-none">
<ul class="flex flex-column px-1 space-x-4 items-center">
<li>
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
</li>
<li>
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
</li>
<li>
<.theme_toggle />
</li>
<li>
<a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
Get Started <span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl flex flex-col gap-4">
<main class="px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto max-w-lg flex flex-col gap-4">
{render_slot(@inner_block)}
</div>
</main>
@ -72,6 +45,11 @@ defmodule SimpleshopThemeWeb.Layouts do
"""
end
@doc false
def admin_nav_active?(current_path, link_path) do
if String.starts_with?(current_path, link_path), do: "active", else: nil
end
@doc """
Shows the flash group with standard titles and content.

View File

@ -0,0 +1,102 @@
<div class="drawer lg:drawer-open h-full">
<input id="admin-drawer" type="checkbox" class="drawer-toggle" />
<%!-- main content area --%>
<div class="drawer-content flex flex-col min-h-screen">
<%!-- mobile header --%>
<header class="navbar bg-base-100 border-b border-base-200 lg:hidden">
<div class="flex-none">
<label for="admin-drawer" class="btn btn-square btn-ghost" aria-label="Open navigation">
<.icon name="hero-bars-3" class="size-5" />
</label>
</div>
<div class="flex-1">
<span class="text-lg font-semibold">SimpleShop</span>
</div>
<div class="flex-none">
<.link href={~p"/"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> Shop
</.link>
</div>
</header>
<%!-- page content --%>
<main class="flex-1 p-4 sm:p-6 lg:p-8">
<div class="mx-auto max-w-5xl">
{@inner_content}
</div>
</main>
</div>
<%!-- sidebar --%>
<div class="drawer-side z-40">
<label for="admin-drawer" class="drawer-overlay" aria-label="Close navigation"></label>
<aside class="bg-base-200 w-64 min-h-full flex flex-col">
<%!-- sidebar header --%>
<div class="p-4 border-b border-base-300">
<.link navigate={~p"/admin/orders"} class="text-lg font-bold tracking-tight">
SimpleShop
</.link>
<p class="text-xs text-base-content/60 mt-0.5 truncate">
{@current_scope.user.email}
</p>
</div>
<%!-- nav links --%>
<nav class="flex-1 p-2" aria-label="Admin navigation">
<ul class="menu gap-0.5">
<li>
<.link
navigate={~p"/admin/orders"}
class={admin_nav_active?(@current_path, "/admin/orders")}
>
<.icon name="hero-shopping-bag" class="size-5" /> Orders
</.link>
</li>
<li>
<.link
href={~p"/admin/theme"}
class={admin_nav_active?(@current_path, "/admin/theme")}
>
<.icon name="hero-paint-brush" class="size-5" /> Theme
</.link>
</li>
<li>
<.link
navigate={~p"/admin/providers"}
class={admin_nav_active?(@current_path, "/admin/providers")}
>
<.icon name="hero-cube" class="size-5" /> Providers
</.link>
</li>
<li>
<.link
navigate={~p"/admin/settings"}
class={admin_nav_active?(@current_path, "/admin/settings")}
>
<.icon name="hero-cog-6-tooth" class="size-5" /> Settings
</.link>
</li>
</ul>
</nav>
<%!-- sidebar footer --%>
<div class="p-2 border-t border-base-300">
<ul class="menu gap-0.5">
<li>
<.link href={~p"/"}>
<.icon name="hero-arrow-top-right-on-square" class="size-5" /> View shop
</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">
<.icon name="hero-arrow-right-start-on-rectangle" class="size-5" /> Log out
</.link>
</li>
</ul>
</div>
</aside>
</div>
</div>
<.flash_group flash={@flash} />

View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="Admin" suffix=" · SimpleShop">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static src={~p"/assets/js/app.js"}>
</script>
<script>
(() => {
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})();
</script>
</head>
<body class="h-full">
{@inner_content}
</body>
</html>

View File

@ -31,42 +31,6 @@
</script>
</head>
<body>
<nav class="w-full relative z-10 flex items-center px-4 sm:px-6 lg:px-8 justify-between">
<div>
<%= if @current_scope && assigns[:conn] && @conn.request_path == "/admin/theme" do %>
<.link href={~p"/"} class="text-sm hover:underline">&larr; View Shop</.link>
<% end %>
</div>
<ul class="menu menu-horizontal flex items-center gap-4">
<%= if @current_scope do %>
<li>
{@current_scope.user.email}
</li>
<li>
<.link href={~p"/admin/theme"}>Theme</.link>
</li>
<li>
<.link href={~p"/admin/orders"}>Orders</.link>
</li>
<li>
<.link href={~p"/admin/settings"}>Credentials</.link>
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
</nav>
{@inner_content}
</body>
</html>

View File

@ -1,2 +1,13 @@
<div
:if={assigns[:current_scope]}
style="background-color: var(--t-surface-raised, #f5f5f5); border-bottom: 1px solid var(--t-border-default, #e5e5e5); padding: 0.25rem 1rem; font-size: 0.75rem; text-align: right;"
>
<.link
href={~p"/admin/orders"}
style="color: var(--t-text-secondary, #666); text-decoration: none;"
>
Admin
</.link>
</div>
<.shop_flash_group flash={@flash} />
{@inner_content}

View File

@ -2,6 +2,6 @@ defmodule SimpleshopThemeWeb.AdminController do
use SimpleshopThemeWeb, :controller
def index(conn, _params) do
redirect(conn, to: ~p"/admin/theme")
redirect(conn, to: ~p"/admin/orders")
end
end

View File

@ -28,169 +28,167 @@ defmodule SimpleshopThemeWeb.Admin.OrderShow do
@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"
>
&larr; 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>
<.header>
<.link
navigate={~p"/admin/orders"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
&larr; 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>
<%!-- fulfilment --%>
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
<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">
<div class="flex items-center justify-between">
<h3 class="card-title text-base">Fulfilment</h3>
<.fulfilment_badge status={@order.fulfilment_status} />
</div>
<h3 class="card-title text-base">Order details</h3>
<.list>
<:item :if={@order.provider_order_id} title="Provider order ID">
<code class="text-xs">{@order.provider_order_id}</code>
<: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.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 :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 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">
<%!-- shipping address --%>
<div class="card bg-base-100 shadow-sm border border-base-200">
<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>
<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>
</Layouts.app>
</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>
"""
end

View File

@ -36,67 +36,65 @@ defmodule SimpleshopThemeWeb.Admin.Orders do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Orders
</.header>
<.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>
<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>
<.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>
<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

View File

@ -1,104 +1,102 @@
<Layouts.app flash={@flash}>
<.header>
{if @live_action == :new, do: "Connect to Printify", else: "Printify settings"}
</.header>
<.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="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 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 %>
<.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>
<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>

View File

@ -1,81 +1,79 @@
<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>
<.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 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">
<.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>
<.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="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"
<div class="flex items-center gap-2">
<.link
navigate={~p"/admin/providers/#{connection.id}/edit"}
class="btn btn-ghost 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"}
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>
</Layouts.app>
</div>

View File

@ -119,88 +119,86 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
@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>
<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} />
<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 %>
<.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}
/>
<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 %>
</section>
</div>
</Layouts.app>
</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>
"""
end

View File

@ -35,7 +35,14 @@
</button>
</div>
<% else %>
<!-- Header -->
<.link
href={~p"/admin/orders"}
class="inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-base-content mb-4"
>
<.icon name="hero-arrow-left-mini" class="size-4" /> Admin
</.link>
<!-- Header -->
<div class="mb-6 flex items-start justify-between gap-3">
<div class="flex-1">
<h1 class="text-xl font-semibold tracking-tight mb-2 text-base-content">

View File

@ -28,6 +28,10 @@ defmodule SimpleshopThemeWeb.Router do
plug SimpleshopThemeWeb.Plugs.LoadTheme
end
pipeline :admin do
plug :put_root_layout, html: {SimpleshopThemeWeb.Layouts, :admin_root}
end
# Public storefront (root level)
scope "/", SimpleshopThemeWeb do
pipe_through [:browser, :shop]
@ -43,6 +47,7 @@ defmodule SimpleshopThemeWeb.Router do
live_session :public_shop,
layout: {SimpleshopThemeWeb.Layouts, :shop},
on_mount: [
{SimpleshopThemeWeb.UserAuth, :mount_current_scope},
{SimpleshopThemeWeb.ThemeHook, :mount_theme},
{SimpleshopThemeWeb.ThemeHook, :require_site_live},
{SimpleshopThemeWeb.CartHook, :mount_cart}
@ -123,27 +128,46 @@ defmodule SimpleshopThemeWeb.Router do
## Authentication routes
# /admin redirects to theme editor (requires auth, will redirect to login if needed)
# /admin index redirect
scope "/admin", SimpleshopThemeWeb do
pipe_through [:browser, :require_authenticated_user]
get "/", AdminController, :index
end
# Admin pages with sidebar layout
scope "/admin", SimpleshopThemeWeb do
pipe_through [:browser, :require_authenticated_user, :admin]
live_session :admin,
layout: {SimpleshopThemeWeb.Layouts, :admin},
on_mount: [
{SimpleshopThemeWeb.UserAuth, :require_authenticated},
{SimpleshopThemeWeb.AdminLayoutHook, :assign_current_path}
] do
live "/orders", Admin.Orders, :index
live "/orders/:id", Admin.OrderShow, :show
live "/providers", Admin.Providers.Index, :index
live "/providers/new", Admin.Providers.Form, :new
live "/providers/:id/edit", Admin.Providers.Form, :edit
live "/settings", Admin.Settings, :index
end
# Theme editor: admin root layout but full-screen (no sidebar)
live_session :admin_theme,
on_mount: [{SimpleshopThemeWeb.UserAuth, :require_authenticated}] do
live "/theme", Admin.Theme.Index, :index
end
end
# User account settings
scope "/", SimpleshopThemeWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
live_session :user_settings,
on_mount: [{SimpleshopThemeWeb.UserAuth, :require_authenticated}] do
live "/users/settings", Auth.Settings, :edit
live "/users/settings/confirm-email/:token", Auth.Settings, :confirm_email
live "/admin/theme", Admin.Theme.Index, :index
live "/admin/providers", Admin.Providers.Index, :index
live "/admin/providers/new", Admin.Providers.Form, :new
live "/admin/providers/:id/edit", Admin.Providers.Form, :edit
live "/admin/orders", Admin.Orders, :index
live "/admin/orders/:id", Admin.OrderShow, :show
live "/admin/settings", Admin.Settings, :index
end
post "/users/update-password", UserSessionController, :update_password

View File

@ -20,12 +20,10 @@ defmodule SimpleshopThemeWeb.UserSessionControllerTest do
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request to an admin page and assert on the menu
# Now do a logged in request and assert on the page content
conn = get(conn, ~p"/users/settings")
response = html_response(conn, 200)
assert response =~ user.email
assert response =~ ~p"/users/settings"
assert response =~ ~p"/users/log-out"
end
test "logs the user in with remember me", %{conn: conn, user: user} do
@ -84,12 +82,10 @@ defmodule SimpleshopThemeWeb.UserSessionControllerTest do
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request to an admin page and assert on the menu
# Now do a logged in request and assert on the page content
conn = get(conn, ~p"/users/settings")
response = html_response(conn, 200)
assert response =~ user.email
assert response =~ ~p"/users/settings"
assert response =~ ~p"/users/log-out"
end
test "confirms unconfirmed user", %{conn: conn, unconfirmed_user: user} do
@ -108,12 +104,10 @@ defmodule SimpleshopThemeWeb.UserSessionControllerTest do
assert Accounts.get_user!(user.id).confirmed_at
# Now do a logged in request to an admin page and assert on the menu
# Now do a logged in request and assert on the page content
conn = get(conn, ~p"/users/settings")
response = html_response(conn, 200)
assert response =~ user.email
assert response =~ ~p"/users/settings"
assert response =~ ~p"/users/log-out"
end
test "redirects to login page when magic link is invalid", %{conn: conn} do

View File

@ -0,0 +1,91 @@
defmodule SimpleshopThemeWeb.Admin.LayoutTest do
use SimpleshopThemeWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import SimpleshopTheme.AccountsFixtures
setup do
user = user_fixture()
%{user: user}
end
describe "admin sidebar" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "renders sidebar nav links on admin pages", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/orders")
assert has_element?(view, ~s(a[href="/admin/orders"]), "Orders")
assert has_element?(view, ~s(a[href="/admin/theme"]), "Theme")
assert has_element?(view, ~s(a[href="/admin/providers"]), "Providers")
assert has_element?(view, ~s(a[href="/admin/settings"]), "Settings")
end
test "highlights active nav link for current page", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/orders")
assert has_element?(view, ~s(a.active[href="/admin/orders"]))
refute has_element?(view, ~s(a.active[href="/admin/settings"]))
end
test "highlights correct link on different pages", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
assert has_element?(view, ~s(a.active[href="/admin/settings"]))
refute has_element?(view, ~s(a.active[href="/admin/orders"]))
end
test "shows user email in sidebar", %{conn: conn, user: user} do
{:ok, _view, html} = live(conn, ~p"/admin/orders")
assert html =~ user.email
end
test "shows view shop and log out links", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/orders")
assert has_element?(view, ~s(a[href="/"]), "View shop")
assert has_element?(view, ~s(a[href="/users/log-out"]), "Log out")
end
end
describe "theme editor layout" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "does not render sidebar", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/theme")
refute html =~ "admin-drawer"
end
test "shows back link to admin", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
assert has_element?(view, ~s(a[href="/admin/orders"]), "Admin")
end
end
describe "admin bar on shop pages" do
setup do
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true)
:ok
end
test "shows admin link when logged in", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/")
assert html =~ ~s(href="/admin/orders")
end
test "does not show admin link when logged out", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/")
refute html =~ ~s(href="/admin/orders")
end
end
end

View File

@ -9,7 +9,7 @@ defmodule SimpleshopThemeWeb.Auth.LoginTest do
{:ok, _lv, html} = live(conn, ~p"/users/log-in")
assert html =~ "Log in"
assert html =~ "Register"
assert html =~ "Sign up"
assert html =~ "Log in with email"
end
end