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:
jamey 2026-03-01 17:15:25 +00:00
parent edef628214
commit b7ec41b0cf
13 changed files with 2661 additions and 1643 deletions

View File

@ -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

View 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);
}
}

View 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 */

View File

@ -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>

View File

@ -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"
>

View File

@ -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"/"}>

View File

@ -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">
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;"
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"})"}
/>
</button>
</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>
<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>
<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-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={["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={["admin-switch-thumb", @newsletter_enabled && "admin-switch-thumb-on"]} />
</button>
</div>
</div>
<div class="flex gap-3">
<.link navigate={~p"/admin/newsletter?tab=subscribers"} class="text-sm font-medium underline">
<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="admin-stat-card">
<p class="admin-stat-value">{@status_counts["pending"] || 0}</p>
<p class="admin-stat-label">Pending</p>
</div>
<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="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

View File

@ -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;">
&larr; 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 &rarr;
</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>
"""

View File

@ -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>
{@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>
<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 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}",

View File

@ -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,20 +14,22 @@
</:actions>
</.header>
<div id="connections" phx-update="stream" class="mt-6 space-y-4">
<div id="connections-empty" class="hidden only:block text-center py-12">
<.icon name="hero-cube" class="size-16 mx-auto mb-4 text-base-content/30" />
<h2 class="text-xl font-medium">Connect a print-on-demand provider</h2>
<p class="mt-2 text-base-content/60 max-w-md mx-auto">
Connect your account to import products and start selling.
</p>
<div class="flex justify-center gap-3 mt-6">
<.button
:for={provider <- @available_providers}
navigate={~p"/admin/providers/new?type=#{provider.type}"}
>
Connect {provider.name}
</.button>
<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="admin-empty-state-actions">
<.button
:for={provider <- @available_providers}
navigate={~p"/admin/providers/new?type=#{provider.type}"}
>
Connect {provider.name}
</.button>
</div>
</div>
</div>
@ -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"

View File

@ -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 &rarr; 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