refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic CSS classes. Add layout primitives (stack, row, cluster, grid), extract JS transition helpers into transitions.css, and refactor core_components, layouts, settings, newsletter, order_show, providers, and theme editor templates. Utility occurrences reduced from 290+ to 127 across admin files. All 1679 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
edef628214
commit
b7ec41b0cf
@ -8,9 +8,11 @@
|
||||
@import "./theme-layer2-attributes.css";
|
||||
@import "./theme-semantic.css";
|
||||
|
||||
/* Admin components, icons, and utilities */
|
||||
/* Admin components, layout, icons, and transitions */
|
||||
@import "./admin/components.css";
|
||||
@import "./admin/layout.css";
|
||||
@import "./admin/icons.css";
|
||||
@import "./admin/transitions.css";
|
||||
@import "./admin/utilities.css";
|
||||
|
||||
/* LiveView loading state variants */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
36
assets/css/admin/layout.css
Normal file
36
assets/css/admin/layout.css
Normal file
@ -0,0 +1,36 @@
|
||||
/* Admin layout primitives — composable building blocks mirroring shop/layout.css.
|
||||
Each primitive does one layout job. Combine freely via CSS custom properties. */
|
||||
|
||||
@layer admin {
|
||||
/* Vertical stack with consistent gap */
|
||||
.admin-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--admin-stack-gap, 1rem);
|
||||
}
|
||||
|
||||
/* Horizontal flex row, no wrap — toolbars, inline groups, header bars */
|
||||
.admin-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--admin-row-gap, 0.5rem);
|
||||
}
|
||||
|
||||
/* Horizontal flex-wrap cluster — tags, badges, button groups */
|
||||
.admin-cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--admin-cluster-gap, 0.5rem);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Intrinsic responsive grid — cards, media thumbnails */
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(min(var(--admin-grid-min, 16rem), 100%), 1fr)
|
||||
);
|
||||
gap: var(--admin-grid-gap, 1rem);
|
||||
}
|
||||
}
|
||||
80
assets/css/admin/transitions.css
Normal file
80
assets/css/admin/transitions.css
Normal file
@ -0,0 +1,80 @@
|
||||
/* Transition and animation utilities for admin pages.
|
||||
These are required by Phoenix JS.show/JS.hide in core_components.ex
|
||||
and by various admin UI interactions (spinners, hover effects, etc.).
|
||||
|
||||
Placed in @layer admin so they integrate with the cascade. */
|
||||
|
||||
@layer admin {
|
||||
|
||||
/* ── Transition properties ── */
|
||||
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, text-decoration-color,
|
||||
fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.transition-colors {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.transition-transform {
|
||||
transition-property: transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.transition-\[left\] {
|
||||
transition-property: left;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* ── Duration overrides ── */
|
||||
|
||||
.duration-200 { transition-duration: 200ms; }
|
||||
.duration-300 { transition-duration: 300ms; }
|
||||
|
||||
/* ── Easing overrides ── */
|
||||
|
||||
.ease-in { transition-timing-function: cubic-bezier(0.4, 0, 1, 1); }
|
||||
.ease-out { transition-timing-function: cubic-bezier(0, 0, 0.2, 1); }
|
||||
|
||||
/* ── Opacity states (JS.show / JS.hide) ── */
|
||||
|
||||
.opacity-0 { opacity: 0; }
|
||||
.opacity-100 { opacity: 1; }
|
||||
|
||||
/* ── Transform states (JS.show / JS.hide) ── */
|
||||
|
||||
.translate-y-4 { translate: 0 1rem; }
|
||||
.translate-y-0 { translate: 0 0; }
|
||||
.scale-95 { scale: 0.95; }
|
||||
.scale-100 { scale: 1; }
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:translate-y-0 { translate: 0 0; }
|
||||
.sm\:scale-95 { scale: 0.95; }
|
||||
.sm\:scale-100 { scale: 1; }
|
||||
}
|
||||
|
||||
/* ── Spin animation ── */
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.animate-spin { animation: spin 1s linear infinite; }
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.motion-safe\:animate-spin { animation: spin 1s linear infinite; }
|
||||
}
|
||||
|
||||
} /* @layer admin */
|
||||
@ -51,17 +51,17 @@ defmodule BerrypodWeb.CoreComponents do
|
||||
{@rest}
|
||||
>
|
||||
<div class={[
|
||||
"admin-alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||
"admin-alert",
|
||||
@kind == :info && "admin-alert-info",
|
||||
@kind == :error && "admin-alert-error"
|
||||
]}>
|
||||
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<p :if={@title} class="font-semibold">{@title}</p>
|
||||
<p :if={@title} class="admin-alert-title">{@title}</p>
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<div class="admin-alert-spacer" />
|
||||
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
@ -271,7 +271,7 @@ defmodule BerrypodWeb.CoreComponents do
|
||||
# Helper used by inputs to generate form errors
|
||||
defp error(assigns) do
|
||||
~H"""
|
||||
<p class="admin-error mt-1.5 flex gap-2 items-center text-sm">
|
||||
<p class="admin-error">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
@ -287,16 +287,19 @@ defmodule BerrypodWeb.CoreComponents do
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
|
||||
<header class={[
|
||||
"admin-header",
|
||||
@actions != [] && "admin-header-with-actions"
|
||||
]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8">
|
||||
<h1 class="admin-header-title">
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="text-sm text-base-content/70">
|
||||
<p :if={@subtitle != []} class="admin-header-subtitle">
|
||||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none">{render_slot(@actions)}</div>
|
||||
<div class="admin-header-actions">{render_slot(@actions)}</div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
@ -352,8 +355,8 @@ defmodule BerrypodWeb.CoreComponents do
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<div class="flex gap-4">
|
||||
<td :if={@action != []} class="admin-table-actions">
|
||||
<div class="admin-table-actions-row">
|
||||
<%= for action <- @action do %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
<% end %>
|
||||
@ -385,7 +388,7 @@ defmodule BerrypodWeb.CoreComponents do
|
||||
<ul class="admin-list">
|
||||
<li :for={item <- @item} class="admin-list-row">
|
||||
<div class="admin-list-grow">
|
||||
<div class="font-bold">{item.title}</div>
|
||||
<div class="admin-list-title">{item.title}</div>
|
||||
<div>{render_slot(item)}</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@ -35,8 +35,8 @@ defmodule BerrypodWeb.Layouts do
|
||||
|
||||
def app(assigns) do
|
||||
~H"""
|
||||
<main class="px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-lg flex flex-col gap-4">
|
||||
<main class="app-main">
|
||||
<div class="app-container">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
@ -108,11 +108,11 @@ defmodule BerrypodWeb.Layouts do
|
||||
"""
|
||||
def theme_toggle(assigns) do
|
||||
~H"""
|
||||
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
|
||||
<div class="theme-toggle">
|
||||
<div class="theme-toggle-indicator absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0" />
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
class="theme-toggle-btn"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="system"
|
||||
>
|
||||
@ -120,7 +120,7 @@ defmodule BerrypodWeb.Layouts do
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
class="theme-toggle-btn"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="light"
|
||||
>
|
||||
@ -128,7 +128,7 @@ defmodule BerrypodWeb.Layouts do
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
class="theme-toggle-btn"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="dark"
|
||||
>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<div class="admin-layout h-full">
|
||||
<div class="admin-layout">
|
||||
<input id="admin-drawer" type="checkbox" class="admin-layout-toggle" />
|
||||
|
||||
<%!-- main content area --%>
|
||||
@ -23,15 +23,15 @@
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
<p>
|
||||
Email delivery isn't set up yet — customers won't receive order confirmations or shipping updates.
|
||||
<.link navigate={~p"/admin/settings/email"} class="underline font-medium">
|
||||
<.link navigate={~p"/admin/settings/email"} class="admin-link">
|
||||
Configure email
|
||||
</.link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%!-- page content --%>
|
||||
<main class="flex-1 p-4 sm:p-6 lg:p-8">
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<main class="admin-main">
|
||||
<div class="admin-container">
|
||||
{@inner_content}
|
||||
</div>
|
||||
</main>
|
||||
@ -42,17 +42,17 @@
|
||||
<label for="admin-drawer" class="admin-sidebar-overlay" aria-label="Close navigation"></label>
|
||||
<aside class="admin-sidebar">
|
||||
<%!-- sidebar header --%>
|
||||
<div class="p-4 border-b border-base-300">
|
||||
<.link navigate={~p"/admin"} class="text-lg font-bold tracking-tight">
|
||||
<div class="admin-sidebar-header">
|
||||
<.link navigate={~p"/admin"} class="admin-brand">
|
||||
Berrypod
|
||||
</.link>
|
||||
<p class="text-xs text-base-content/60 mt-0.5 truncate">
|
||||
<p class="admin-text-secondary truncate" style="margin-top: 0.125rem;">
|
||||
{@current_scope.user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%!-- nav links --%>
|
||||
<nav class="flex-1 p-2" aria-label="Admin navigation">
|
||||
<nav class="admin-sidebar-nav" aria-label="Admin navigation">
|
||||
<div class="admin-nav-group">
|
||||
<span class="admin-nav-heading">Shop</span>
|
||||
<ul class="admin-nav">
|
||||
@ -191,7 +191,7 @@
|
||||
</nav>
|
||||
|
||||
<%!-- sidebar footer --%>
|
||||
<div class="p-2 border-t border-base-300">
|
||||
<div class="admin-sidebar-footer">
|
||||
<ul class="admin-nav">
|
||||
<li>
|
||||
<.link href={~p"/"}>
|
||||
|
||||
@ -129,7 +129,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
<:subtitle>Manage subscribers and email campaigns</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="flex gap-2 mt-6 mb-6 border-b border-base-200">
|
||||
<div class="admin-tabs">
|
||||
<.tab_link label="Overview" tab="overview" active={@tab} />
|
||||
<.tab_link
|
||||
label="Subscribers"
|
||||
@ -179,17 +179,10 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
~H"""
|
||||
<.link
|
||||
patch={~p"/admin/newsletter?tab=#{@tab}"}
|
||||
class={[
|
||||
"px-3 py-2 text-sm font-medium border-b-2 -mb-px",
|
||||
if(@tab == @active,
|
||||
do: "border-base-content text-base-content",
|
||||
else:
|
||||
"border-transparent text-base-content/60 hover:text-base-content hover:border-base-300"
|
||||
)
|
||||
]}
|
||||
class={["admin-tab", @tab == @active && "admin-tab-active"]}
|
||||
>
|
||||
{@label}
|
||||
<span :if={@count} class="ml-1 text-xs text-base-content/40">{@count}</span>
|
||||
<span :if={@count} class="admin-tab-count">{@count}</span>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
@ -201,52 +194,47 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
|
||||
defp overview_tab(assigns) do
|
||||
~H"""
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-4 p-4 rounded-lg border border-base-200">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium">Newsletter signups</h3>
|
||||
<p class="text-sm text-base-content/60 mt-0.5">
|
||||
<div class="admin-stack" style="--admin-stack-gap: 1.5rem;">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-body admin-row" style="--admin-row-gap: 1rem;">
|
||||
<div style="flex: 1;">
|
||||
<h3 style="font-weight: 500;">Newsletter signups</h3>
|
||||
<p class="admin-section-desc" style="margin-top: 0.125rem;">
|
||||
When enabled, the newsletter signup block on your shop pages will collect email addresses with double opt-in confirmation.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
phx-click="toggle_enabled"
|
||||
class={[
|
||||
"inline-flex items-center shrink-0 cursor-pointer rounded-full transition-colors",
|
||||
if(@newsletter_enabled, do: "bg-green-600", else: "bg-base-300")
|
||||
]}
|
||||
style="width: 2.75rem; height: 1.5rem; padding: 0.125rem;"
|
||||
class={["admin-switch", if(@newsletter_enabled, do: "admin-switch-on", else: "admin-switch-off")]}
|
||||
role="switch"
|
||||
aria-checked={to_string(@newsletter_enabled)}
|
||||
aria-label="Toggle newsletter signups"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block rounded-full bg-white shadow transition-transform"
|
||||
style={"width: 1.25rem; height: 1.25rem; transform: translateX(#{if @newsletter_enabled, do: "1.25rem", else: "0"})"}
|
||||
/>
|
||||
<span class={["admin-switch-thumb", @newsletter_enabled && "admin-switch-thumb-on"]} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="p-4 rounded-lg border border-base-200 text-center">
|
||||
<p class="text-2xl font-bold">{@status_counts["confirmed"] || 0}</p>
|
||||
<p class="text-sm text-base-content/60">Confirmed</p>
|
||||
<div class="admin-stats-grid">
|
||||
<div class="admin-stat-card">
|
||||
<p class="admin-stat-value">{@status_counts["confirmed"] || 0}</p>
|
||||
<p class="admin-stat-label">Confirmed</p>
|
||||
</div>
|
||||
<div class="p-4 rounded-lg border border-base-200 text-center">
|
||||
<p class="text-2xl font-bold">{@status_counts["pending"] || 0}</p>
|
||||
<p class="text-sm text-base-content/60">Pending</p>
|
||||
<div class="admin-stat-card">
|
||||
<p class="admin-stat-value">{@status_counts["pending"] || 0}</p>
|
||||
<p class="admin-stat-label">Pending</p>
|
||||
</div>
|
||||
<div class="p-4 rounded-lg border border-base-200 text-center">
|
||||
<p class="text-2xl font-bold">{@status_counts["unsubscribed"] || 0}</p>
|
||||
<p class="text-sm text-base-content/60">Unsubscribed</p>
|
||||
<div class="admin-stat-card">
|
||||
<p class="admin-stat-value">{@status_counts["unsubscribed"] || 0}</p>
|
||||
<p class="admin-stat-label">Unsubscribed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<.link navigate={~p"/admin/newsletter?tab=subscribers"} class="text-sm font-medium underline">
|
||||
<div class="admin-row" style="--admin-row-gap: 0.75rem;">
|
||||
<.link navigate={~p"/admin/newsletter?tab=subscribers"} class="admin-link" style="font-weight: 500;">
|
||||
View subscribers
|
||||
</.link>
|
||||
<.link navigate={~p"/admin/newsletter?tab=campaigns"} class="text-sm font-medium underline">
|
||||
<.link navigate={~p"/admin/newsletter?tab=campaigns"} class="admin-link" style="font-weight: 500;">
|
||||
View campaigns
|
||||
</.link>
|
||||
</div>
|
||||
@ -266,8 +254,8 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
defp subscribers_tab(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<div class="admin-row" style="justify-content: space-between; margin-bottom: 1rem;">
|
||||
<div class="admin-cluster">
|
||||
<.filter_pill
|
||||
status="all"
|
||||
label="All"
|
||||
@ -298,7 +286,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<form phx-change="search_subscribers" class="mb-4">
|
||||
<form phx-change="search_subscribers" style="margin-bottom: 1rem;">
|
||||
<.input
|
||||
name="search"
|
||||
value={@search}
|
||||
@ -323,7 +311,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
phx-click="delete_subscriber"
|
||||
phx-value-id={sub.id}
|
||||
data-confirm="Permanently delete this subscriber? This cannot be undone."
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
class="admin-link-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@ -337,10 +325,10 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
params={%{"tab" => "subscribers"}}
|
||||
/>
|
||||
|
||||
<div :if={@subscriber_count == 0} class="text-center py-12 text-base-content/60">
|
||||
<.icon name="hero-envelope" class="size-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No subscribers yet</p>
|
||||
<p class="text-sm mt-1">Subscribers will appear here when people sign up via your shop.</p>
|
||||
<div :if={@subscriber_count == 0} class="admin-empty-state">
|
||||
<.icon name="hero-envelope" class="admin-empty-state-icon" />
|
||||
<p class="admin-empty-state-title">No subscribers yet</p>
|
||||
<p class="admin-empty-state-text">Subscribers will appear here when people sign up via your shop.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
@ -355,7 +343,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
defp campaigns_tab(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="flex justify-end mb-4">
|
||||
<div style="display: flex; justify-content: flex-end; margin-bottom: 1rem;">
|
||||
<.link navigate={~p"/admin/newsletter/campaigns/new"} class="admin-btn admin-btn-primary">
|
||||
<.icon name="hero-plus" class="size-4" /> New campaign
|
||||
</.link>
|
||||
@ -378,7 +366,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
phx-click="delete_campaign"
|
||||
phx-value-id={c.id}
|
||||
data-confirm="Delete this draft campaign?"
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
class="admin-link-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@ -392,10 +380,10 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
params={%{"tab" => "campaigns"}}
|
||||
/>
|
||||
|
||||
<div :if={@campaign_count == 0} class="text-center py-12 text-base-content/60">
|
||||
<.icon name="hero-megaphone" class="size-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No campaigns yet</p>
|
||||
<p class="text-sm mt-1">Create your first campaign to reach your subscribers.</p>
|
||||
<div :if={@campaign_count == 0} class="admin-empty-state">
|
||||
<.icon name="hero-megaphone" class="admin-empty-state-icon" />
|
||||
<p class="admin-empty-state-title">No campaigns yet</p>
|
||||
<p class="admin-empty-state-text">Create your first campaign to reach your subscribers.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
@ -434,31 +422,25 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
|
||||
defp subscriber_status(%{status: "confirmed"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1 text-sm text-green-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span> Confirmed
|
||||
</span>
|
||||
<span class="admin-status-dot admin-status-dot-green">Confirmed</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subscriber_status(%{status: "pending"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1 text-sm text-amber-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span> Pending
|
||||
</span>
|
||||
<span class="admin-status-dot admin-status-dot-amber">Pending</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subscriber_status(%{status: "unsubscribed"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1 text-sm text-base-content/50">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-base-300"></span> Unsubscribed
|
||||
</span>
|
||||
<span class="admin-status-dot admin-status-dot-muted">Unsubscribed</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subscriber_status(assigns) do
|
||||
~H"""
|
||||
<span class="text-sm text-base-content/50">{@status}</span>
|
||||
<span class="admin-status-dot admin-status-dot-muted">{@status}</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@ -466,47 +448,37 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
||||
|
||||
defp campaign_status(%{status: "draft"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-base-content/60">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-base-300"></span> Draft
|
||||
</span>
|
||||
<span class="admin-status-dot admin-status-dot-muted">Draft</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "scheduled"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-blue-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span> Scheduled
|
||||
</span>
|
||||
<span class="admin-status-dot admin-status-dot-blue">Scheduled</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "sending"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-amber-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span> Sending
|
||||
</span>
|
||||
<span class="admin-status-dot admin-status-dot-amber">Sending</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "sent"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-green-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span> Sent
|
||||
</span>
|
||||
<span class="admin-status-dot admin-status-dot-green">Sent</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "cancelled"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-red-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-500"></span> Cancelled
|
||||
</span>
|
||||
<span class="admin-status-dot admin-status-dot-red">Cancelled</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(assigns) do
|
||||
~H"""
|
||||
<span class="text-sm text-base-content/50">{@status}</span>
|
||||
<span class="admin-status-dot admin-status-dot-muted">{@status}</span>
|
||||
"""
|
||||
end
|
||||
|
||||
|
||||
@ -39,19 +39,16 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
<.link
|
||||
navigate={~p"/admin/orders"}
|
||||
class="text-sm font-normal text-base-content/60 hover:underline"
|
||||
>
|
||||
<.link navigate={~p"/admin/orders"} class="admin-link-subtle" style="font-weight: 400;">
|
||||
← Orders
|
||||
</.link>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<span class="text-2xl font-bold">{@order.order_number}</span>
|
||||
<div class="admin-row" style="--admin-row-gap: 0.75rem; margin-top: 0.25rem;">
|
||||
<span style="font-size: 1.5rem; font-weight: 700;">{@order.order_number}</span>
|
||||
<.status_badge status={@order.payment_status} />
|
||||
</div>
|
||||
</.header>
|
||||
|
||||
<div class="grid gap-6 mt-6 lg:grid-cols-2">
|
||||
<div class="admin-grid" style="--admin-grid-min: 20rem; --admin-grid-gap: 1.5rem; margin-top: 1.5rem;">
|
||||
<%!-- order info --%>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-body">
|
||||
@ -63,7 +60,7 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
<.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>
|
||||
<code style="font-size: 0.75rem;">{@order.stripe_payment_intent_id}</code>
|
||||
</:item>
|
||||
<:item title="Currency">{String.upcase(@order.currency)}</:item>
|
||||
</.list>
|
||||
@ -99,20 +96,20 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
</:item>
|
||||
</.list>
|
||||
<% else %>
|
||||
<p class="text-base-content/60 text-sm">No shipping address provided</p>
|
||||
<p class="admin-section-desc" style="margin-top: 0;">No shipping address provided</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- timeline --%>
|
||||
<div class="admin-card mt-6">
|
||||
<div class="admin-card" style="margin-top: 1.5rem;">
|
||||
<div class="admin-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="admin-row" style="justify-content: space-between;">
|
||||
<h3 class="admin-card-title">Timeline</h3>
|
||||
<.fulfilment_badge status={@order.fulfilment_status} />
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2 mb-4">
|
||||
<div class="admin-row" style="margin-top: 0.5rem; margin-bottom: 1rem;">
|
||||
<button
|
||||
:if={can_submit?(@order)}
|
||||
phx-click="submit_to_provider"
|
||||
@ -133,16 +130,17 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
</div>
|
||||
<div
|
||||
:if={@order.tracking_number not in [nil, ""]}
|
||||
class="flex items-center gap-2 mb-4 text-sm"
|
||||
class="admin-row"
|
||||
style="margin-bottom: 1rem; font-size: 0.875rem;"
|
||||
>
|
||||
<.icon name="hero-truck-mini" class="size-4 text-base-content/60" />
|
||||
<span class="font-medium">{@order.tracking_number}</span>
|
||||
<span class="admin-text-secondary"><.icon name="hero-truck-mini" class="size-4" /></span>
|
||||
<span style="font-weight: 500;">{@order.tracking_number}</span>
|
||||
<a
|
||||
:if={@order.tracking_url not in [nil, ""]}
|
||||
href={@order.tracking_url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-primary hover:underline"
|
||||
class="admin-link"
|
||||
>
|
||||
Track shipment →
|
||||
</a>
|
||||
@ -152,7 +150,7 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
</div>
|
||||
|
||||
<%!-- line items --%>
|
||||
<div class="admin-card mt-6">
|
||||
<div class="admin-card" style="margin-top: 1.5rem;">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Items</h3>
|
||||
<table class="admin-table admin-table-zebra">
|
||||
@ -160,28 +158,28 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
<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>
|
||||
<th style="text-align: end;">Qty</th>
|
||||
<th style="text-align: end;">Unit price</th>
|
||||
<th style="text-align: end;">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>
|
||||
<td style="text-align: end;">{item.quantity}</td>
|
||||
<td style="text-align: end;">{Cart.format_price(item.unit_price)}</td>
|
||||
<td style="text-align: end;">{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>
|
||||
<td colspan="4" style="text-align: end; font-weight: 500;">Subtotal</td>
|
||||
<td style="text-align: end; font-weight: 500;">{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 style="font-size: 1.125rem;">
|
||||
<td colspan="4" style="text-align: end; font-weight: 700;">Total</td>
|
||||
<td style="text-align: end; font-weight: 700;">{Cart.format_price(@order.total)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@ -236,7 +234,7 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
|
||||
defp order_timeline(assigns) do
|
||||
~H"""
|
||||
<div :if={@entries == []} class="text-sm text-base-content/60 py-4">
|
||||
<div :if={@entries == []} class="admin-section-desc" style="padding-block: 1rem; margin-top: 0;">
|
||||
No activity recorded yet.
|
||||
</div>
|
||||
<ol :if={@entries != []} class="admin-timeline" id="order-timeline">
|
||||
@ -279,76 +277,40 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
end
|
||||
|
||||
defp fulfilment_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
{color, icon} =
|
||||
case assigns.status do
|
||||
"submitted" ->
|
||||
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
|
||||
|
||||
"processing" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
|
||||
|
||||
"shipped" ->
|
||||
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
|
||||
|
||||
"delivered" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"cancelled" ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-no-symbol-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-minus-circle-mini"}
|
||||
"submitted" -> {"admin-status-pill-blue", "hero-paper-airplane-mini"}
|
||||
"processing" -> {"admin-status-pill-amber", "hero-cog-6-tooth-mini"}
|
||||
"shipped" -> {"admin-status-pill-purple", "hero-truck-mini"}
|
||||
"delivered" -> {"admin-status-pill-green", "hero-check-circle-mini"}
|
||||
"failed" -> {"admin-status-pill-red", "hero-x-circle-mini"}
|
||||
"cancelled" -> {"admin-status-pill-zinc", "hero-no-symbol-mini"}
|
||||
_ -> {"admin-status-pill-zinc", "hero-minus-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
assigns = assign(assigns, color: color, 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
|
||||
]}>
|
||||
<span class={["admin-status-pill", @color]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
{color, icon} =
|
||||
case assigns.status do
|
||||
"paid" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"pending" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-clock-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"refunded" ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-arrow-uturn-left-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-question-mark-circle-mini"}
|
||||
"paid" -> {"admin-status-pill-green", "hero-check-circle-mini"}
|
||||
"pending" -> {"admin-status-pill-amber", "hero-clock-mini"}
|
||||
"failed" -> {"admin-status-pill-red", "hero-x-circle-mini"}
|
||||
"refunded" -> {"admin-status-pill-zinc", "hero-arrow-uturn-left-mini"}
|
||||
_ -> {"admin-status-pill-zinc", "hero-question-mark-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
assigns = assign(assigns, color: color, 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
|
||||
]}>
|
||||
<span class={["admin-status-pill", @color]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
|
||||
@ -4,18 +4,16 @@
|
||||
else: "#{@provider.name} settings"}
|
||||
</.header>
|
||||
|
||||
<div class="max-w-xl mt-6">
|
||||
<div class="admin-form-narrow">
|
||||
<%= if @live_action == :new do %>
|
||||
<div class="prose prose-sm mb-6">
|
||||
<p>
|
||||
<p class="admin-text-secondary" style="margin-bottom: 1.5rem;">
|
||||
{@provider.name} 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 API key from {@provider.name}:</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-base-content/80">
|
||||
<div class="admin-callout">
|
||||
<p class="admin-callout-title">Get your API key from {@provider.name}:</p>
|
||||
<ol class="admin-callout-list">
|
||||
<li>
|
||||
<a href={@provider.login_url} target="_blank" rel="noopener" class="admin-link">
|
||||
Log in to {@provider.name}
|
||||
@ -44,7 +42,7 @@
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="admin-inline-group">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||
@ -58,27 +56,27 @@
|
||||
{if @testing, do: "Checking...", else: "Check connection"}
|
||||
</button>
|
||||
|
||||
<div :if={@test_result} class="text-sm">
|
||||
<%= if @test_result do %>
|
||||
<%= case @test_result do %>
|
||||
<% {:ok, _info} -> %>
|
||||
<span class="text-success flex items-center gap-1">
|
||||
<span class="admin-status-success">
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
Connected to {connection_name(@test_result) || @provider.name}
|
||||
</span>
|
||||
<% {:error, reason} -> %>
|
||||
<span class="text-error flex items-center gap-1">
|
||||
<span class="admin-status-error">
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{format_error(reason)}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @live_action == :edit do %>
|
||||
<.input field={@form[:enabled]} type="checkbox" label="Connection enabled" />
|
||||
<% end %>
|
||||
|
||||
<div class="flex gap-2 mt-6">
|
||||
<div class="admin-form-actions">
|
||||
<.button type="submit" disabled={@testing}>
|
||||
{if @live_action == :new,
|
||||
do: "Connect to #{@provider.name}",
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<:actions>
|
||||
<div class="admin-dropdown">
|
||||
<div tabindex="0" role="button" class="admin-btn admin-btn-primary">
|
||||
<.icon name="hero-plus" class="size-4 mr-1" /> Connect provider
|
||||
<.icon name="hero-plus" class="size-4" /> Connect provider
|
||||
</div>
|
||||
<ul tabindex="0" class="admin-dropdown-content">
|
||||
<li :for={provider <- @available_providers}>
|
||||
@ -14,14 +14,15 @@
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div id="connections" phx-update="stream" class="mt-6 space-y-4">
|
||||
<div id="connections-empty" class="hidden only:block text-center py-12">
|
||||
<.icon name="hero-cube" class="size-16 mx-auto mb-4 text-base-content/30" />
|
||||
<h2 class="text-xl font-medium">Connect a print-on-demand provider</h2>
|
||||
<p class="mt-2 text-base-content/60 max-w-md mx-auto">
|
||||
<div id="connections" phx-update="stream" class="admin-stack" style="margin-top: 1.5rem;">
|
||||
<div id="connections-empty" class="hidden only:block">
|
||||
<div class="admin-empty-state">
|
||||
<.icon name="hero-cube" class="admin-empty-state-icon" />
|
||||
<h2 class="admin-empty-state-title">Connect a print-on-demand provider</h2>
|
||||
<p class="admin-empty-state-text">
|
||||
Connect your account to import products and start selling.
|
||||
</p>
|
||||
<div class="flex justify-center gap-3 mt-6">
|
||||
<div class="admin-empty-state-actions">
|
||||
<.button
|
||||
:for={provider <- @available_providers}
|
||||
navigate={~p"/admin/providers/new?type=#{provider.type}"}
|
||||
@ -30,6 +31,7 @@
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:for={{dom_id, connection} <- @streams.connections}
|
||||
@ -37,21 +39,21 @@
|
||||
class="admin-card"
|
||||
>
|
||||
<div class="admin-card-body">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="admin-card-row">
|
||||
<div class="admin-card-content">
|
||||
<div class="admin-row" style="--admin-row-gap: 0.5rem;">
|
||||
<.status_indicator status={connection.sync_status} enabled={connection.enabled} />
|
||||
<h3 class="font-semibold text-lg">
|
||||
<h3 class="admin-card-title" style="margin-bottom: 0; font-size: 1.125rem;">
|
||||
{provider_name(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">
|
||||
<p class="admin-card-subtitle">{connection.name}</p>
|
||||
<div class="admin-card-meta">
|
||||
<.connection_info connection={connection} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="admin-card-toolbar">
|
||||
<.link
|
||||
navigate={~p"/admin/providers/#{connection.id}/edit"}
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
|
||||
@ -276,15 +276,15 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl">
|
||||
<div class="admin-settings">
|
||||
<.header>
|
||||
Settings
|
||||
</.header>
|
||||
|
||||
<%!-- Shop status --%>
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Shop status</h2>
|
||||
<section class="admin-section">
|
||||
<div class="admin-section-header">
|
||||
<h2 class="admin-section-title">Shop status</h2>
|
||||
<%= if @site_live do %>
|
||||
<.status_pill color="green">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Live
|
||||
@ -293,22 +293,19 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
<.status_pill color="zinc">Offline</.status_pill>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
<p class="admin-section-desc">
|
||||
<%= 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">
|
||||
<div class="admin-section-body">
|
||||
<button
|
||||
phx-click="toggle_site_live"
|
||||
class={[
|
||||
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold shadow-xs",
|
||||
if(@site_live,
|
||||
do: "bg-base-200 text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset",
|
||||
else: "bg-green-600 text-white hover:bg-green-500"
|
||||
)
|
||||
"admin-btn admin-btn-sm",
|
||||
if(@site_live, do: "admin-btn-outline", else: "admin-btn-primary")
|
||||
]}
|
||||
>
|
||||
<%= if @site_live do %>
|
||||
@ -321,9 +318,9 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
</section>
|
||||
|
||||
<%!-- Payments --%>
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Payments</h2>
|
||||
<section class="admin-section">
|
||||
<div class="admin-section-header">
|
||||
<h2 class="admin-section-title">Payments</h2>
|
||||
<%= case @stripe_status do %>
|
||||
<% :connected -> %>
|
||||
<.status_pill color="green">
|
||||
@ -354,9 +351,9 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
</section>
|
||||
|
||||
<%!-- Products --%>
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Products</h2>
|
||||
<section class="admin-section">
|
||||
<div class="admin-section-header">
|
||||
<h2 class="admin-section-title">Products</h2>
|
||||
<%= if @provider do %>
|
||||
<.status_pill color="green">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Connected
|
||||
@ -369,15 +366,12 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
<%= if @provider do %>
|
||||
<.provider_connected provider={@provider} />
|
||||
<% else %>
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-base-content/60">
|
||||
<div class="admin-section-body">
|
||||
<p class="admin-section-desc" style="margin-top: 0;">
|
||||
Connect a print-on-demand provider to import products into your shop.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<.link
|
||||
navigate={~p"/admin/providers"}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-base-content px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-base-content/80"
|
||||
>
|
||||
<div class="admin-section-body">
|
||||
<.link navigate={~p"/admin/providers"} class="admin-btn admin-btn-primary admin-btn-sm">
|
||||
<.icon name="hero-plus-mini" class="size-4" /> Connect a provider
|
||||
</.link>
|
||||
</div>
|
||||
@ -386,9 +380,9 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
</section>
|
||||
|
||||
<%!-- Cart recovery --%>
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Cart recovery</h2>
|
||||
<section class="admin-section">
|
||||
<div class="admin-section-header">
|
||||
<h2 class="admin-section-title">Cart recovery</h2>
|
||||
<%= if @cart_recovery_enabled do %>
|
||||
<.status_pill color="green">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> On
|
||||
@ -397,42 +391,35 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
<.status_pill color="zinc">Off</.status_pill>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
<p class="admin-section-desc">
|
||||
When on, customers who entered their email at Stripe checkout but didn't complete
|
||||
payment receive a single plain-text recovery email one hour later.
|
||||
No tracking pixels. One email, never more.
|
||||
</p>
|
||||
<%= if @cart_recovery_enabled do %>
|
||||
<p class="mt-2 text-sm text-amber-700">
|
||||
<p class="admin-section-desc" style="color: #b45309;">
|
||||
Make sure your privacy policy mentions that a single recovery email may be sent,
|
||||
and that customers can unsubscribe at any time.
|
||||
</p>
|
||||
<% end %>
|
||||
<div class="mt-4">
|
||||
<div class="admin-section-body">
|
||||
<button
|
||||
phx-click="toggle_cart_recovery"
|
||||
class={[
|
||||
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold shadow-xs",
|
||||
if(@cart_recovery_enabled,
|
||||
do: "bg-base-200 text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset",
|
||||
else: "bg-base-content text-white hover:bg-base-content/80"
|
||||
)
|
||||
"admin-btn admin-btn-sm",
|
||||
if(@cart_recovery_enabled, do: "admin-btn-outline", else: "admin-btn-primary")
|
||||
]}
|
||||
>
|
||||
<%= if @cart_recovery_enabled do %>
|
||||
Turn off
|
||||
<% else %>
|
||||
Turn on
|
||||
<% end %>
|
||||
{if @cart_recovery_enabled, do: "Turn off", else: "Turn on"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Account --%>
|
||||
<section class="mt-10">
|
||||
<h2 class="text-lg font-semibold">Account</h2>
|
||||
<section class="admin-section">
|
||||
<h2 class="admin-section-title">Account</h2>
|
||||
|
||||
<div class="mt-4 space-y-6">
|
||||
<div class="admin-stack" style="margin-top: 1rem; --admin-stack-gap: 1.5rem;">
|
||||
<.form
|
||||
for={@email_form}
|
||||
id="email_form"
|
||||
@ -446,12 +433,12 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<.button phx-disable-with="Saving...">Change email</.button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<div class="border-t border-base-200 pt-6">
|
||||
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 1.5rem;">
|
||||
<.form
|
||||
for={@password_form}
|
||||
id="password_form"
|
||||
@ -481,7 +468,7 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
label="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<.button phx-disable-with="Saving...">Change password</.button>
|
||||
</div>
|
||||
</.form>
|
||||
@ -490,17 +477,14 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
</section>
|
||||
|
||||
<%!-- Advanced --%>
|
||||
<section class="mt-10 pb-10">
|
||||
<h2 class="text-lg font-semibold">Advanced</h2>
|
||||
<section class="admin-section" style="padding-bottom: 2.5rem;">
|
||||
<h2 class="admin-section-title">Advanced</h2>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<.link
|
||||
href={~p"/admin/dashboard"}
|
||||
class="text-sm text-base-content/60 hover:text-base-content"
|
||||
>
|
||||
<div class="admin-stack" style="margin-top: 1rem; --admin-stack-gap: 0.5rem;">
|
||||
<.link href={~p"/admin/dashboard"} class="admin-link-subtle">
|
||||
<.icon name="hero-chart-bar" class="size-4 inline" /> System dashboard
|
||||
</.link>
|
||||
<.link href={~p"/admin/errors"} class="text-sm text-base-content/60 hover:text-base-content">
|
||||
<.link href={~p"/admin/errors"} class="admin-link-subtle">
|
||||
<.icon name="hero-bug-ant" class="size-4 inline" /> Error tracker
|
||||
</.link>
|
||||
</div>
|
||||
@ -515,21 +499,17 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp status_pill(assigns) do
|
||||
classes =
|
||||
modifier =
|
||||
case assigns.color do
|
||||
"green" -> "bg-green-50 text-green-700 ring-green-600/20"
|
||||
"amber" -> "bg-amber-50 text-amber-700 ring-amber-600/20"
|
||||
"zinc" -> "bg-base-200/50 text-base-content/60 ring-base-content/10"
|
||||
_ -> "bg-base-200/50 text-base-content/60 ring-base-content/10"
|
||||
"green" -> "admin-status-pill-green"
|
||||
"amber" -> "admin-status-pill-amber"
|
||||
_ -> "admin-status-pill-zinc"
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :classes, classes)
|
||||
assigns = assign(assigns, :modifier, modifier)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@classes
|
||||
]}>
|
||||
<span class={["admin-status-pill", @modifier]}>
|
||||
{render_slot(@inner_block)}
|
||||
</span>
|
||||
"""
|
||||
@ -548,38 +528,38 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
|> assign(:provider_label, String.capitalize(conn.provider_type))
|
||||
|
||||
~H"""
|
||||
<div class="mt-4">
|
||||
<dl class="text-sm">
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Provider</dt>
|
||||
<dd class="text-base-content">{@provider_label}</dd>
|
||||
<div class="admin-section-body">
|
||||
<dl class="admin-dl">
|
||||
<div class="admin-dl-row">
|
||||
<dt class="admin-dl-term">Provider</dt>
|
||||
<dd class="admin-dl-value">{@provider_label}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Shop</dt>
|
||||
<dd class="text-base-content">{@connection.name}</dd>
|
||||
<div class="admin-dl-row">
|
||||
<dt class="admin-dl-term">Shop</dt>
|
||||
<dd class="admin-dl-value">{@connection.name}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Products</dt>
|
||||
<dd class="text-base-content">{@product_count}</dd>
|
||||
<div class="admin-dl-row">
|
||||
<dt class="admin-dl-term">Products</dt>
|
||||
<dd class="admin-dl-value">{@product_count}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Last synced</dt>
|
||||
<dd class="text-base-content">
|
||||
<div class="admin-dl-row">
|
||||
<dt class="admin-dl-term">Last synced</dt>
|
||||
<dd class="admin-dl-value">
|
||||
<%= if @connection.last_synced_at do %>
|
||||
{format_relative_time(@connection.last_synced_at)}
|
||||
<% else %>
|
||||
<span class="text-amber-600">Never</span>
|
||||
<span style="color: #d97706;">Never</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<div class="admin-cluster" style="margin-top: 1rem;">
|
||||
<button
|
||||
phx-click="sync"
|
||||
phx-value-id={@connection.id}
|
||||
disabled={@syncing}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-base-200 px-3 py-1.5 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
|
||||
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||
>
|
||||
<.icon
|
||||
name="hero-arrow-path"
|
||||
@ -589,7 +569,7 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
</button>
|
||||
<.link
|
||||
navigate={~p"/admin/providers/#{@connection.id}/edit"}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-base-200 px-3 py-1.5 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
|
||||
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||
>
|
||||
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
|
||||
</.link>
|
||||
@ -597,7 +577,8 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
phx-click="delete_connection"
|
||||
phx-value-id={@connection.id}
|
||||
data-confirm={"Disconnect from #{@provider_label}? Your synced products will remain in your shop."}
|
||||
class="text-sm text-red-600 hover:text-red-800 px-2 py-1.5"
|
||||
class="admin-link-danger"
|
||||
style="padding: 0.375rem 0.5rem;"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
@ -608,22 +589,17 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
|
||||
defp stripe_setup_form(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-base-content/60">
|
||||
<div class="admin-section-body">
|
||||
<p class="admin-section-desc" style="margin-top: 0;">
|
||||
To accept payments, connect your Stripe account by entering your secret key.
|
||||
You can find it in your
|
||||
<a
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-base-content underline"
|
||||
>
|
||||
<a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noopener" class="admin-link">
|
||||
Stripe dashboard
|
||||
</a>
|
||||
under Developers → API keys.
|
||||
</p>
|
||||
|
||||
<.form for={@connect_form} phx-submit="connect_stripe" class="mt-6">
|
||||
<.form for={@connect_form} phx-submit="connect_stripe" style="margin-top: 1.5rem;">
|
||||
<.input
|
||||
field={@connect_form[:api_key]}
|
||||
type="password"
|
||||
@ -631,11 +607,11 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
autocomplete="off"
|
||||
placeholder="sk_test_... or sk_live_..."
|
||||
/>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
<p class="admin-text-tertiary" style="margin-top: 0.25rem;">
|
||||
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">
|
||||
<div class="admin-section-body">
|
||||
<.button phx-disable-with="Connecting...">
|
||||
Connect Stripe
|
||||
</.button>
|
||||
@ -647,40 +623,40 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
|
||||
defp stripe_connected_view(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4 space-y-4">
|
||||
<dl class="text-sm">
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">API key</dt>
|
||||
<dd><code class="text-base-content">{@stripe_api_key_hint}</code></dd>
|
||||
<div class="admin-stack" style="margin-top: 1rem;">
|
||||
<dl class="admin-dl">
|
||||
<div class="admin-dl-row">
|
||||
<dt class="admin-dl-term">API key</dt>
|
||||
<dd class="admin-dl-value"><code>{@stripe_api_key_hint}</code></dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Webhook URL</dt>
|
||||
<dd><code class="text-base-content text-xs break-all">{@stripe_webhook_url}</code></dd>
|
||||
<div class="admin-dl-row">
|
||||
<dt class="admin-dl-term">Webhook URL</dt>
|
||||
<dd class="admin-dl-value"><code style="font-size: 0.75rem; word-break: break-all;">{@stripe_webhook_url}</code></dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Webhook secret</dt>
|
||||
<dd>
|
||||
<div class="admin-dl-row">
|
||||
<dt class="admin-dl-term">Webhook secret</dt>
|
||||
<dd class="admin-dl-value">
|
||||
<%= if @stripe_has_signing_secret do %>
|
||||
<code class="text-base-content">{@stripe_signing_secret_hint}</code>
|
||||
<code>{@stripe_signing_secret_hint}</code>
|
||||
<% else %>
|
||||
<span class="text-amber-600">Not set</span>
|
||||
<span style="color: #d97706;">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">
|
||||
<div class="admin-info-box admin-info-box-amber">
|
||||
<p>
|
||||
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">
|
||||
<pre>stripe listen --forward-to localhost:4000/webhooks/stripe</pre>
|
||||
<p style="margin-top: 0.5rem; font-size: 0.75rem; color: #b45309;">
|
||||
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">
|
||||
<.form for={@secret_form} phx-submit="save_signing_secret" style="margin-top: 0.5rem;">
|
||||
<.input
|
||||
field={@secret_form[:signing_secret]}
|
||||
type="password"
|
||||
@ -688,16 +664,13 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
autocomplete="off"
|
||||
placeholder="whsec_..."
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<.button phx-disable-with="Saving...">Save signing secret</.button>
|
||||
</div>
|
||||
</.form>
|
||||
<% else %>
|
||||
<div class="border-t border-base-200 pt-3">
|
||||
<button
|
||||
phx-click="toggle_stripe_advanced"
|
||||
class="flex items-center gap-1 text-sm text-base-content/60 hover:text-base-content"
|
||||
>
|
||||
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 0.75rem;">
|
||||
<button phx-click="toggle_stripe_advanced" class="admin-link-subtle admin-row" style="--admin-row-gap: 0.25rem;">
|
||||
<.icon
|
||||
name={if @advanced_open, do: "hero-chevron-down-mini", else: "hero-chevron-right-mini"}
|
||||
class="size-4"
|
||||
@ -705,8 +678,8 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
</button>
|
||||
|
||||
<%= if @advanced_open do %>
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-base-content/60 mb-3">
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<p class="admin-text-tertiary" style="margin-bottom: 0.75rem;">
|
||||
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">
|
||||
@ -717,7 +690,7 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
autocomplete="off"
|
||||
placeholder="whsec_..."
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<.button phx-disable-with="Saving...">Save signing secret</.button>
|
||||
</div>
|
||||
</.form>
|
||||
@ -726,11 +699,11 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="border-t border-base-200 pt-4">
|
||||
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 1rem;">
|
||||
<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"
|
||||
class="admin-link-danger"
|
||||
>
|
||||
Disconnect Stripe
|
||||
</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user