complete admin CSS refactor: delete utilities.css, add layout primitives

- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
  primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
  semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
  order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
  chart dimensions) and one JS.toggle target

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-03-01 21:40:21 +00:00
parent 22d3e36ed5
commit ae6cf209aa
26 changed files with 1343 additions and 1247 deletions

View File

@ -8,10 +8,11 @@
@import "./theme-layer2-attributes.css"; @import "./theme-layer2-attributes.css";
@import "./theme-semantic.css"; @import "./theme-semantic.css";
/* Admin components, icons, and utilities */ /* Admin components, layout, icons, transitions */
@import "./admin/components.css"; @import "./admin/components.css";
@import "./admin/layout.css";
@import "./admin/icons.css"; @import "./admin/icons.css";
@import "./admin/utilities.css"; @import "./admin/transitions.css";
/* LiveView loading state variants */ /* LiveView loading state variants */
.phx-click-loading, .phx-submit-loading, .phx-change-loading { .phx-click-loading, .phx-submit-loading, .phx-change-loading {

View File

@ -252,6 +252,37 @@
flex: none; flex: none;
} }
/* ── Filter / tab row (below page header) ── */
.admin-filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.admin-filter-row-end {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.5rem;
margin-top: 1.5rem;
margin-bottom: 1rem;
}
/* ── Back link (breadcrumb-style return link) ── */
.admin-back-link {
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 400;
color: color-mix(in oklch, var(--t-text-primary) 60%, transparent);
&:hover { text-decoration: underline; }
}
/* ── Cards ── */ /* ── Cards ── */
.admin-card { .admin-card {
@ -619,6 +650,14 @@
border: 1px solid color-mix(in oklch, var(--t-status-error) 25%, var(--t-surface-base)); border: 1px solid color-mix(in oklch, var(--t-status-error) 25%, var(--t-surface-base));
} }
.admin-alert-close {
align-self: flex-start;
cursor: pointer;
opacity: 0.4;
&:hover { opacity: 0.7; }
}
.admin-banner-warning { .admin-banner-warning {
display: flex; display: flex;
align-items: center; align-items: center;
@ -2699,6 +2738,10 @@
opacity: 0.7; opacity: 0.7;
} }
.media-card-size {
font-size: 0.75rem;
}
.media-card-no-alt { .media-card-no-alt {
display: flex; display: flex;
align-items: center; align-items: center;
@ -3081,6 +3124,114 @@
color: color-mix(in oklch, var(--t-text-primary) 50%, transparent); color: color-mix(in oklch, var(--t-text-primary) 50%, transparent);
} }
/* ── Product thumbnail ── */
.admin-thumbnail {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.25rem;
background-color: var(--t-surface-sunken);
overflow: hidden;
flex-shrink: 0;
}
.admin-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.admin-thumbnail-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* ── Provider badge (small metadata pill) ── */
.admin-provider-badge {
display: inline-flex;
align-items: center;
border-radius: 9999px;
background-color: var(--t-surface-sunken);
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
color: color-mix(in oklch, var(--t-text-primary) 60%, transparent);
margin-top: 0.125rem;
}
/* ── Sale tag ── */
.admin-sale-tag {
color: #dc2626;
font-size: 0.75rem;
font-weight: 500;
margin-inline-end: 0.25rem;
}
/* ── Product show layout ── */
.admin-product-grid {
display: grid;
gap: 1.5rem;
margin-top: 1.5rem;
@media (min-width: 1024px) { grid-template-columns: 2fr 1fr; }
}
.admin-product-image-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
@media (min-width: 640px) { grid-template-columns: repeat(4, 1fr); }
}
.admin-product-image-tile {
aspect-ratio: 1;
border-radius: 0.25rem;
background-color: var(--t-surface-sunken);
overflow: hidden;
& img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
/* ── Provider status indicator ── */
.admin-provider-dot {
display: inline-flex;
width: 0.75rem;
height: 0.75rem;
border-radius: 9999px;
}
.admin-provider-dot-idle { background: color-mix(in oklch, var(--t-text-primary) 30%, transparent); }
.admin-provider-dot-syncing { background: var(--t-status-warning); animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.admin-provider-dot-ok { background: var(--t-status-success); }
.admin-provider-dot-error { background: var(--t-status-error); }
@keyframes pulse {
50% { opacity: 0.5; }
}
/* ── Nav editor section heading ── */
.admin-nav-section-heading {
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: color-mix(in oklch, var(--t-text-primary) 50%, transparent);
margin-bottom: 0.75rem;
}
/* -- Activity feed -- */ /* -- Activity feed -- */
.admin-activity-row { .admin-activity-row {
@ -3100,6 +3251,10 @@
margin-top: 0.125rem; margin-top: 0.125rem;
} }
.admin-activity-icon-error { color: #ef4444; }
.admin-activity-icon-warning { color: #f59e0b; }
.admin-activity-icon-ok { color: #22c55e; }
.admin-activity-body { .admin-activity-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -3125,6 +3280,29 @@
white-space: nowrap; white-space: nowrap;
} }
.admin-activity-link {
font-size: 0.75rem;
line-height: 1rem;
color: var(--t-accent);
margin-inline-start: 0.25rem;
&:hover { text-decoration: underline; }
}
/* Stream empty state (works with only:block pattern) */
.admin-stream-empty {
display: none;
text-align: center;
padding-block: 2rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: color-mix(in oklch, var(--t-text-primary) 60%, transparent);
}
.admin-stream-empty:only-child {
display: block;
}
/* ── Generic admin helpers ── */ /* ── Generic admin helpers ── */
/* Main content area with responsive padding */ /* Main content area with responsive padding */
@ -3150,6 +3328,8 @@
.admin-sidebar-header { .admin-sidebar-header {
padding: 1rem; padding: 1rem;
border-bottom: 1px solid var(--t-border-default); border-bottom: 1px solid var(--t-border-default);
& .admin-text-secondary { margin-top: 0.125rem; }
} }
/* Sidebar footer (view shop, log out) */ /* Sidebar footer (view shop, log out) */
@ -3843,4 +4023,713 @@
color: var(--t-text-primary); color: var(--t-text-primary);
} }
/* Section-level spacing (lighter alternative to theme-panel — no background) */
.theme-section { margin-bottom: 1.5rem; }
/* Block-level label (theme-slider-label inline by default, sometimes needs block) */
.theme-block-label { display: block; margin-bottom: 0.5rem; }
/* Small text for checkbox/toggle labels in the sidebar */
.theme-check-text {
font-size: 0.875rem;
color: color-mix(in oklch, var(--t-text-primary) 80%, transparent);
}
/* Spacing between sub-controls within a panel */
.theme-subfield { margin-top: 0.75rem; }
.theme-subfield-sm { margin-top: 0.5rem; }
/* Customise accordion inner padding */
.theme-customise-body { padding-top: 1rem; }
/* Collapse button pulled flush to sidebar edge inside header */
.theme-header .theme-collapse-btn { margin: -0.25rem -0.5rem 0 0; }
/* Last group in a customise section (no border, tighter margin than theme-group) */
.theme-group-flush { margin-bottom: 1rem; }
/* ── Content width containers ── */
.admin-content-narrow { max-width: 32rem; }
.admin-content-medium { max-width: 42rem; }
/* ── Badge count (inline in tab/filter buttons) ── */
.admin-badge-count { margin-inline-start: 0.25rem; }
/* ── Help and warning text ── */
.admin-help-text {
font-size: 0.875rem;
color: color-mix(in oklch, var(--t-text-primary) 60%, transparent);
}
.admin-warning-text {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #b45309;
}
/* ── Callout warning (amber env-locked) ── */
.admin-callout-warning {
margin-top: 1.5rem;
border-radius: 0.375rem;
background: #fffbeb;
padding: 1rem;
box-shadow: inset 0 0 0 1px rgb(217 119 6 / 0.1);
}
.admin-callout-warning-body {
display: flex;
gap: 0.75rem;
}
.admin-callout-warning-icon {
color: #d97706;
flex-shrink: 0;
margin-top: 0.125rem;
}
.admin-callout-warning-title {
font-size: 0.875rem;
font-weight: 500;
color: #92400e;
}
.admin-callout-warning-desc {
margin-top: 0.25rem;
font-size: 0.875rem;
color: #b45309;
}
/* ── Section heading variants ── */
.admin-section-heading {
font-size: 1.125rem;
font-weight: 600;
}
.admin-section-subheading {
font-size: 1rem;
font-weight: 600;
}
/* ── Section with border separator ── */
.admin-section-bordered {
margin-top: 2rem;
border-top: 1px solid var(--t-surface-sunken);
padding-top: 1.5rem;
}
/* ── Email adapter config ── */
.admin-adapter-config {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-adapter-link {
font-weight: 400;
}
/* ── Campaign form ── */
.admin-campaign-actions {
display: flex;
align-items: center;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid var(--t-surface-sunken);
}
.admin-btn-success {
background-color: var(--color-green-600);
}
.admin-preview-summary {
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.admin-preview-body {
margin-top: 0.5rem;
padding: 1rem;
background: var(--t-surface-sunken);
border-radius: 0.5rem;
font-size: 0.875rem;
white-space: pre-wrap;
overflow: auto;
max-height: 16rem;
}
/* ── Navigation editor ── */
.admin-nav-layout {
margin-top: 1.5rem;
max-width: 40rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.admin-nav-empty {
font-size: 0.875rem;
color: color-mix(in oklch, var(--t-text-primary) 50%, transparent);
padding: 1rem 0;
}
.admin-nav-actions {
margin-top: 0.75rem;
display: flex;
gap: 0.5rem;
}
.nav-editor-dropdown-wrap {
position: relative;
}
.nav-editor-dropdown-slug {
font-size: 0.75rem;
color: color-mix(in oklch, var(--t-text-primary) 40%, transparent);
}
/* ── Page editor ── */
.admin-editor-badges {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* ── Product show ── */
.admin-product-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.25rem;
}
.admin-product-title {
font-size: 1.5rem;
font-weight: 700;
}
.admin-card-spaced {
margin-top: 1.5rem;
}
/* ── Products table ── */
.admin-product-name {
font-weight: 500;
}
/* ── Visibility / availability icon colours ── */
.admin-icon-positive { color: #16a34a; }
.admin-icon-muted {
color: color-mix(in oklch, var(--t-text-primary) 30%, transparent);
}
/* ── Filter select label & wrapper ── */
.admin-filter-label {
font-size: 0.75rem;
margin-bottom: 0.125rem;
display: block;
}
.admin-filter-select {
width: auto;
}
.admin-filter-select-wide {
width: auto;
flex: 1;
min-width: 12rem;
}
/* ── Table row clickable ── */
.admin-table-row-clickable { cursor: pointer; }
/* ── Tab actions bar (right-aligned above table) ── */
.admin-tab-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
/* ── Filter row with space-between ── */
.admin-filter-row-between {
justify-content: space-between;
}
/* ── Form layout ── */
.admin-form-stack {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 32rem;
}
.admin-form-sub {
display: flex;
flex-direction: column;
gap: 1rem;
padding-inline-start: 1.5rem;
}
/* ── Inline input that fills remaining space ── */
.admin-input-fill { flex: 1; }
/* ── Source list (dead links) ── */
.admin-source-list {
list-style: none;
padding: 0;
margin: 0;
& li + li { margin-top: 0.25rem; }
}
/* ── Truncated cell ── */
.admin-cell-truncate { max-width: 20rem; }
/* ── Text weight modifier ── */
.admin-text-medium { font-weight: 500; }
/* ── Read-only actions (campaign form) ── */
.admin-readonly-actions {
padding-top: 1rem;
border-top: 1px solid var(--t-surface-sunken);
}
/* ── Row modifiers ── */
.admin-row-between { justify-content: space-between; }
.admin-row-sm { --admin-row-gap: 0.25rem; }
.admin-row-xl { --admin-row-gap: 1rem; }
/* ── Bold text weight ── */
.admin-text-bold { font-weight: 700; }
/* ── Semantic colour text ── */
.admin-text-warning { color: var(--t-status-warning, #d97706); }
.admin-text-error { color: var(--t-status-error, #ef4444); }
/* ── Right-aligned table cells ── */
.admin-cell-end { text-align: end; }
.admin-cell-numeric { text-align: end; font-variant-numeric: tabular-nums; }
/* ── Small inline code ── */
.admin-code-sm { font-size: 0.75rem; }
.admin-code-break { font-size: 0.75rem; word-break: break-all; }
/* ── Section body flush (no top margin) ── */
.admin-section-desc-flush { margin-top: 0; }
/* ── Separator (border-top with compact padding) ── */
.admin-separator {
border-top: 1px solid var(--t-surface-sunken);
padding-top: 0.75rem;
}
.admin-separator-lg {
border-top: 1px solid var(--t-surface-sunken);
padding-top: 1rem;
}
.admin-separator-xl {
border-top: 1px solid var(--t-surface-sunken);
padding-top: 1.5rem;
}
/* ── Button body (compact top margin for form buttons) ── */
.admin-form-actions-sm { margin-top: 0.75rem; }
/* ── Flex fill (take remaining space) ── */
.admin-fill { flex: 1; }
/* ── Larger input (prominent fields like shop name) ── */
.admin-input-lg { padding: 0.75rem 1rem; font-size: 1rem; }
/*
Analytics
*/
.analytics-periods {
display: flex;
gap: 0.25rem;
margin-top: 1rem;
align-items: center;
}
.analytics-export { margin-left: auto; }
.analytics-filters {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
margin-top: 1rem;
}
.analytics-filter-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background: var(--color-base-200, #e5e5e5);
border-radius: 0.25rem;
}
.analytics-filter-remove {
cursor: pointer;
opacity: 0.6;
line-height: 1;
}
.analytics-tab-heading {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.analytics-tab-heading-spaced {
font-size: 0.875rem;
font-weight: 600;
margin: 1.5rem 0 0.75rem;
}
.analytics-chart-card { padding: 1rem; }
.analytics-tab-bar {
display: flex;
gap: 0.25rem;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.analytics-tab-panel {
margin-top: 0.75rem;
padding: 1rem;
}
.analytics-empty {
text-align: center;
padding: 1.5rem;
color: color-mix(in oklch, var(--color-base-content) 40%, transparent);
}
/* Chart layout */
.analytics-chart-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0 0.5rem;
position: relative;
}
.analytics-tooltip {
display: none;
position: absolute;
top: -1.75rem;
z-index: 10;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
background: var(--color-base-content, #1e1e1e);
color: var(--color-base-100, #fff);
border-radius: 0.25rem;
pointer-events: none;
font-variant-numeric: tabular-nums;
}
.analytics-y-labels {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
height: 8rem;
font-size: 0.75rem;
color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
}
.analytics-chart-area {
position: relative;
height: 8rem;
}
.analytics-gridline-mid {
position: absolute;
top: 50%;
left: 0;
right: 0;
border-top: 1px dashed color-mix(in oklch, var(--color-base-content) 12%, transparent);
}
.analytics-gridline-bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-top: 1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent);
}
.analytics-bars {
display: flex;
align-items: flex-end;
height: 100%;
}
.analytics-bar {
flex: 1;
background: var(--color-primary, #4f46e5);
opacity: 0.8;
border-radius: 1px 1px 0 0;
min-width: 0;
}
.analytics-x-labels {
display: flex;
justify-content: space-between;
padding-top: 0.25rem;
font-size: 0.75rem;
color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
}
.analytics-delta {
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
}
/* Funnel */
.analytics-funnel {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.analytics-funnel-step {
display: flex;
align-items: center;
gap: 0.75rem;
}
.analytics-funnel-label {
width: 7rem;
font-size: 0.8125rem;
text-align: right;
flex-shrink: 0;
}
.analytics-funnel-bar {
height: 2rem;
background: var(--color-primary, #4f46e5);
border-radius: 0.25rem;
display: flex;
align-items: center;
padding-left: 0.5rem;
}
.analytics-funnel-value {
font-size: 0.75rem;
font-weight: 600;
color: white;
}
.analytics-funnel-rate {
font-size: 0.8125rem;
font-weight: 600;
}
.analytics-funnel-summary {
margin-top: 0.75rem;
font-size: 0.875rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* ── Stat card (icon + value + label) ── */
.admin-stat-card-body {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
}
.admin-stat-icon {
background: var(--color-base-200, #e5e5e5);
border-radius: 0.5rem;
padding: 0.5rem;
flex-shrink: 0;
}
.admin-stat-value-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
/*
Dashboard
*/
.dashboard-section { margin-top: 2rem; }
.dashboard-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.dashboard-view-all {
font-size: 0.875rem;
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
}
.dashboard-recent-orders { overflow-x: auto; }
.dashboard-stat-link {
display: block;
text-decoration: none;
color: inherit;
}
.setup-complete-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
}
.dashboard-empty-orders {
border: 1px solid var(--color-base-200, #e5e5e5);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
}
.dashboard-empty-icon {
margin: 0 auto 0.75rem;
width: 2.5rem;
opacity: 0.3;
}
/*
Order show
*/
.order-detail-grid {
margin-top: 1.5rem;
&.admin-grid {
--admin-grid-min: 20rem;
--admin-grid-gap: 1.5rem;
}
}
.order-timeline-actions {
margin-top: 0.5rem;
margin-bottom: 1rem;
}
.order-tracking {
margin-bottom: 1rem;
font-size: 0.875rem;
}
.order-timeline-empty {
padding-block: 1rem;
margin-top: 0;
}
.order-total-row { font-size: 1.125rem; }
/* ── Foundational utilities ── */
/* Classes that can't be replaced with inline styles because they're
passed via class attributes to components like <.icon> */
.size-3 { width: 0.75rem; height: 0.75rem; }
.size-4 { width: 1rem; height: 1rem; }
.size-5 { width: 1.25rem; height: 1.25rem; }
.size-6 { width: 1.5rem; height: 1.5rem; }
.size-8 { width: 2rem; height: 2rem; }
.size-10 { width: 2.5rem; height: 2.5rem; }
.size-12 { width: 3rem; height: 3rem; }
.shrink-0 { flex-shrink: 0; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ml-1 { margin-inline-start: 0.25rem; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.animate-spin { animation: spin 1s linear infinite; }
@media (prefers-reduced-motion: reduce) { .animate-spin { animation: none; } }
@media (prefers-reduced-motion: no-preference) {
.motion-safe\:animate-spin { animation: spin 1s linear infinite; }
}
/* Phoenix LiveView JS transition utilities */
.transition-all { transition-property: all; }
.ease-out { transition-timing-function: cubic-bezier(0, 0, 0.2, 1); }
.ease-in { transition-timing-function: cubic-bezier(0.4, 0, 1, 1); }
.duration-200 { transition-duration: 200ms; }
.duration-300 { transition-duration: 300ms; }
.opacity-0 { opacity: 0; }
.opacity-100 { opacity: 1; }
.translate-y-0 { translate: 0 0; }
.translate-y-4 { translate: 0 1rem; }
@media (min-width: 640px) {
.sm\:translate-y-0 { translate: 0 0; }
.sm\:scale-95 { scale: 0.95; }
.sm\:scale-100 { scale: 1; }
}
} /* @layer admin */ } /* @layer admin */

View File

@ -24,6 +24,15 @@
align-items: center; align-items: center;
} }
/* Stack gap variants */
.admin-stack-sm { --admin-stack-gap: 0.5rem; }
.admin-stack-md { --admin-stack-gap: 0.75rem; }
.admin-stack-lg { --admin-stack-gap: 1.5rem; }
.admin-stack-xl { --admin-stack-gap: 2rem; }
/* Row gap variants */
.admin-row-lg { --admin-row-gap: 0.75rem; }
/* Intrinsic responsive grid — cards, media thumbnails */ /* Intrinsic responsive grid — cards, media thumbnails */
.admin-grid { .admin-grid {
display: grid; display: grid;

View File

@ -1,701 +0,0 @@
/* Admin utility classes replaces Tailwind utilities for admin/auth pages.
Only classes actually used in templates are defined here. */
/* ========================================
Shadow/Ring foundation
======================================== */
:where(html) {
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-ring-color: currentColor;
--tw-ring-inset: ;
}
/* ========================================
Display
======================================== */
.hidden { display: none; }
.block { display: block; }
.inline { display: inline; }
.inline-flex { display: inline-flex; }
.flex { display: flex; }
.grid { display: grid; }
/* ========================================
Position
======================================== */
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.inset-0 { inset: 0; }
.inset-y-0 { top: 0; bottom: 0; }
.left-0 { left: 0; }
.right-0 { right: 0; }
.left-\[40rem\] { left: 40rem; }
.-right-1\.5 { right: -0.375rem; }
.-top-1\.5 { top: -0.375rem; }
.z-0 { z-index: 0; }
/* ========================================
Flexbox
======================================== */
.flex-1 { flex: 1 1 0%; }
.flex-none { flex: none; }
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
.flex-wrap { flex-wrap: wrap; }
.flex-shrink-0, .shrink-0 { flex-shrink: 0; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.self-start { align-self: flex-start; }
/* ========================================
Grid
======================================== */
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
/* ========================================
Gap
======================================== */
.gap-1 { gap: 0.25rem; }
.gap-1\.5 { gap: 0.375rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.gap-\[14px\] { gap: 14px; }
.gap-x-4 { column-gap: 1rem; }
.gap-x-6 { column-gap: 1.5rem; }
.gap-y-1 { row-gap: 0.25rem; }
.gap-y-4 { row-gap: 1rem; }
/* ========================================
Padding
======================================== */
.p-0 { padding: 0; }
.p-1 { padding: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.p-8 { padding: 2rem; }
.px-1\.5 { padding-inline: 0.375rem; }
.px-2 { padding-inline: 0.5rem; }
.px-3 { padding-inline: 0.75rem; }
.px-4 { padding-inline: 1rem; }
.px-6 { padding-inline: 1.5rem; }
.px-\[14px\] { padding-inline: 14px; }
.py-0\.5 { padding-block: 0.125rem; }
.py-1 { padding-block: 0.25rem; }
.py-1\.5 { padding-block: 0.375rem; }
.py-2 { padding-block: 0.5rem; }
.py-2\.5 { padding-block: 0.625rem; }
.py-3 { padding-block: 0.75rem; }
.py-4 { padding-block: 1rem; }
.py-10 { padding-block: 2.5rem; }
.py-12 { padding-block: 3rem; }
.py-\[5px\] { padding-block: 5px; }
.py-\[10px\] { padding-block: 10px; }
.pb-2 { padding-bottom: 0.5rem; }
.pb-6 { padding-bottom: 1.5rem; }
.pb-8 { padding-bottom: 2rem; }
.pb-10 { padding-bottom: 2.5rem; }
.pl-10 { padding-inline-start: 2.5rem; }
.pt-3 { padding-top: 0.75rem; }
.pt-4 { padding-top: 1rem; }
.pt-6 { padding-top: 1.5rem; }
/* ========================================
Margin
======================================== */
.mt-0\.5 { margin-top: 0.125rem; }
.mt-1 { margin-top: 0.25rem; }
.mt-1\.5 { margin-top: 0.375rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: 1rem; }
.mt-6 { margin-top: 1.5rem; }
.mt-8 { margin-top: 2rem; }
.mt-10 { margin-top: 2.5rem; }
.-mt-1 { margin-top: -0.25rem; }
.-mb-px { margin-bottom: -1px; }
.mb-0\.5 { margin-bottom: 0.125rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.ml-1 { margin-inline-start: 0.25rem; }
.ml-3 { margin-inline-start: 0.75rem; }
.mr-1 { margin-inline-end: 0.25rem; }
.-mr-2 { margin-inline-end: -0.5rem; }
.mx-auto { margin-inline: auto; }
.-mx-2 { margin-inline: -0.5rem; }
.-my-0\.5 { margin-block: -0.125rem; }
/* ========================================
Space between (lobotomised owl)
======================================== */
.space-y-1 > :not(:first-child) { margin-top: 0.25rem; }
.space-y-4 > :not(:first-child) { margin-top: 1rem; }
.space-y-6 > :not(:first-child) { margin-top: 1.5rem; }
/* ========================================
Sizing
======================================== */
.w-0 { width: 0; }
.w-1\.5 { width: 0.375rem; }
.w-3 { width: 0.75rem; }
.w-4 { width: 1rem; }
.w-5 { width: 1.25rem; }
.w-6 { width: 1.5rem; }
.w-9 { width: 2.25rem; }
.w-10 { width: 2.5rem; }
.w-12 { width: 3rem; }
.w-16 { width: 4rem; }
.w-28 { width: 7rem; }
.w-1\/3 { width: 33.333333%; }
.w-auto { width: auto; }
.w-fit { width: fit-content; }
.w-80 { width: 20rem; }
.w-full { width: 100%; }
.w-\[14px\] { width: 14px; }
.w-\[18px\] { width: 18px; }
.h-1\.5 { height: 0.375rem; }
.h-3 { height: 0.75rem; }
.h-4 { height: 1rem; }
.h-5 { height: 1.25rem; }
.h-6 { height: 1.5rem; }
.h-9 { height: 2.25rem; }
.h-10 { height: 2.5rem; }
.h-12 { height: 3rem; }
.h-full { height: 100%; }
.h-\[14px\] { height: 14px; }
.h-\[18px\] { height: 18px; }
.h-\[60px\] { height: 60px; }
.size-3 { width: 0.75rem; height: 0.75rem; }
.size-4 { width: 1rem; height: 1rem; }
.size-5 { width: 1.25rem; height: 1.25rem; }
.size-6 { width: 1.5rem; height: 1.5rem; }
.size-10 { width: 2.5rem; height: 2.5rem; }
.size-12 { width: 3rem; height: 3rem; }
.size-16 { width: 4rem; height: 4rem; }
.min-h-0 { min-height: 0; }
.min-h-screen { min-height: 100vh; }
.min-w-0 { min-width: 0; }
.min-w-48 { min-width: 12rem; }
.max-h-full { max-height: 100%; }
.max-h-64 { max-height: 16rem; }
.max-w-80 { max-width: 20rem; }
.max-w-sm { max-width: 24rem; }
.max-w-md { max-width: 28rem; }
.max-w-lg { max-width: 32rem; }
.max-w-xl { max-width: 36rem; }
.max-w-2xl { max-width: 42rem; }
.max-w-5xl { max-width: 64rem; }
.max-w-full { max-width: 100%; }
.max-w-\[1200px\] { max-width: 1200px; }
.aspect-square { aspect-ratio: 1 / 1; }
/* ========================================
Typography
======================================== */
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-base { font-size: 1rem; line-height: 1.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-\[2rem\] { font-size: 2rem; }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.text-left { text-align: start; }
.text-center { text-align: center; }
.text-right { text-align: end; }
.text-balance { text-wrap: balance; }
.text-wrap { text-wrap: wrap; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.whitespace-pre-wrap { white-space: pre-wrap; }
.break-all { word-break: break-all; }
.uppercase { text-transform: uppercase; }
.capitalize { text-transform: capitalize; }
.underline { text-decoration-line: underline; }
.leading-6 { line-height: 1.5rem; }
.leading-7 { line-height: 1.75rem; }
.leading-8 { line-height: 2rem; }
.leading-10 { line-height: 2.5rem; }
.leading-none { line-height: 1; }
.leading-relaxed { line-height: 1.625; }
.tracking-tight { letter-spacing: -0.025em; }
.tracking-tighter { letter-spacing: -0.05em; }
.tracking-wider { letter-spacing: 0.05em; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* ========================================
Colours theme tokens
======================================== */
.bg-base-100 { background-color: var(--t-surface-base); }
.bg-base-200 { background-color: var(--t-surface-sunken); }
.bg-base-300 { background-color: var(--t-border-default); }
.bg-base-content { background-color: var(--t-text-primary); }
.bg-primary { background-color: var(--t-accent); }
.bg-white { background-color: #fff; }
.text-base-100 { color: var(--t-surface-base); }
.text-base-content { color: var(--t-text-primary); }
.text-base-content\/30 { color: color-mix(in oklch, var(--t-text-primary) 30%, transparent); }
.text-base-content\/40 { color: color-mix(in oklch, var(--t-text-primary) 40%, transparent); }
.text-base-content\/50 { color: color-mix(in oklch, var(--t-text-primary) 50%, transparent); }
.text-base-content\/60 { color: color-mix(in oklch, var(--t-text-primary) 60%, transparent); }
.text-base-content\/70 { color: color-mix(in oklch, var(--t-text-primary) 70%, transparent); }
.text-base-content\/80 { color: color-mix(in oklch, var(--t-text-primary) 80%, transparent); }
.text-error { color: var(--t-status-error); }
.text-success { color: var(--t-status-success); }
.text-white { color: #fff; }
.text-brand { color: var(--t-accent); }
.fill-base-content\/40 { fill: color-mix(in oklch, var(--t-text-primary) 40%, transparent); }
/* ========================================
Colours status palette
======================================== */
.bg-green-50 { background-color: #f0fdf4; }
.bg-green-500 { background-color: #22c55e; }
.bg-green-600 { background-color: #16a34a; }
.text-green-600 { color: #16a34a; }
.text-green-700 { color: #15803d; }
.text-green-900 { color: #14532d; }
.border-green-200 { border-color: #bbf7d0; }
.bg-red-50 { background-color: #fef2f2; }
.bg-red-500 { background-color: #ef4444; }
.text-red-600 { color: #dc2626; }
.text-red-700 { color: #b91c1c; }
.bg-amber-50 { background-color: #fffbeb; }
.bg-amber-100 { background-color: #fef3c7; }
.bg-amber-500 { background-color: #f59e0b; }
.text-amber-600 { color: #d97706; }
.text-amber-700 { color: #b45309; }
.text-amber-800 { color: #92400e; }
.text-amber-900 { color: #78350f; }
.bg-blue-50 { background-color: #eff6ff; }
.bg-blue-500 { background-color: #3b82f6; }
.text-blue-700 { color: #1d4ed8; }
.bg-purple-50 { background-color: #faf5ff; }
.text-purple-700 { color: #7e22ce; }
/* Arbitrary background colours (macOS traffic lights in theme editor) */
.bg-\[\#ff5f57\] { background-color: #ff5f57; }
.bg-\[\#ffbd2e\] { background-color: #ffbd2e; }
.bg-\[\#28c940\] { background-color: #28c940; }
/* ========================================
Borders
======================================== */
.border { border: 1px solid; }
.border-0 { border-width: 0; }
.border-1 { border-width: 1px; }
.border-2 { border-width: 2px; }
.border-b { border-bottom: 1px solid; }
.border-t { border-top: 1px solid; }
.border-t-0 { border-top-width: 0; }
.border-dashed { border-style: dashed; }
.border-transparent { border-color: transparent; }
.border-base-200 { border-color: var(--t-surface-sunken); }
.border-base-300 { border-color: var(--t-border-default); }
.border-base-content\/20 { border-color: color-mix(in oklch, var(--t-text-primary) 20%, transparent); }
.border-b-base-content\/30 { border-bottom-color: color-mix(in oklch, var(--t-text-primary) 30%, transparent); }
/* Arbitrary border colours (macOS traffic lights) */
.border-\[\#e14640\] { border-color: #e14640; }
.border-\[\#dfa123\] { border-color: #dfa123; }
.border-\[\#1aab29\] { border-color: #1aab29; }
/* ========================================
Border radius
======================================== */
.rounded { border-radius: 0.25rem; }
.rounded-md { border-radius: 0.375rem; }
.rounded-lg { border-radius: 0.5rem; }
.rounded-xl { border-radius: 0.75rem; }
.rounded-full { border-radius: 9999px; }
.rounded-b-lg {
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
.rounded-t-\[10px\] {
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.rounded-box { border-radius: var(--t-radius-card, 0.5rem); }
/* ========================================
Ring (outline via box-shadow)
======================================== */
.ring-0 {
--tw-ring-shadow: 0 0 #0000;
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-1 {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 1px var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-inset { --tw-ring-inset: inset; }
.ring-base-300 { --tw-ring-color: var(--t-border-default); }
.ring-green-600\/20 { --tw-ring-color: rgb(22 163 74 / 0.2); }
.ring-red-600\/20 { --tw-ring-color: rgb(220 38 38 / 0.2); }
.ring-amber-600\/10 { --tw-ring-color: rgb(217 119 6 / 0.1); }
.ring-blue-600\/20 { --tw-ring-color: rgb(37 99 235 / 0.2); }
.ring-purple-600\/20 { --tw-ring-color: rgb(147 51 234 / 0.2); }
/* ========================================
Shadow
======================================== */
.shadow {
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-xs {
--tw-shadow: 0 1px rgb(0 0 0 / 0.05);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-sm {
--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
/* ========================================
Overflow
======================================== */
.overflow-auto { overflow: auto; }
.overflow-hidden { overflow: hidden; }
.overflow-x-auto { overflow-x: auto; }
/* ========================================
Object fit
======================================== */
.object-cover { object-fit: cover; }
.object-contain { object-fit: contain; }
/* ========================================
Opacity
======================================== */
.opacity-40 { opacity: 0.4; }
.opacity-75 { opacity: 0.75; }
/* ========================================
Cursor
======================================== */
.cursor-pointer { cursor: pointer; }
.pointer-events-none { pointer-events: none; }
/* ========================================
Gradient
======================================== */
.bg-gradient-to-b {
--tw-gradient-from: transparent;
--tw-gradient-to: transparent;
background-image: linear-gradient(to bottom, var(--tw-gradient-from), var(--tw-gradient-to));
}
.from-base-300 { --tw-gradient-from: var(--t-border-default); }
.to-base-300\/80 { --tw-gradient-to: color-mix(in oklch, var(--t-border-default) 80%, transparent); }
/* ========================================
Filter
======================================== */
.brightness-200 { filter: brightness(2); }
/* ========================================
Transitions & animation
======================================== */
.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;
}
@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; }
}
/* ========================================
Hover states
======================================== */
.hover\:bg-base-200:hover { background-color: var(--t-surface-sunken); }
.hover\:bg-base-200\/50:hover { background-color: color-mix(in oklch, var(--t-surface-sunken) 50%, transparent); }
.hover\:bg-base-300:hover { background-color: var(--t-border-default); }
.hover\:bg-base-content\/80:hover { background-color: color-mix(in oklch, var(--t-text-primary) 80%, transparent); }
.hover\:bg-green-500:hover { background-color: #22c55e; }
.hover\:border-base-300:hover { border-color: var(--t-border-default); }
.hover\:border-base-content\/40:hover { border-color: color-mix(in oklch, var(--t-text-primary) 40%, transparent); }
.hover\:opacity-100:hover { opacity: 1; }
.hover\:text-base-content:hover { color: var(--t-text-primary); }
.hover\:text-base-content\/70:hover { color: color-mix(in oklch, var(--t-text-primary) 70%, transparent); }
.hover\:text-base-content\/80:hover { color: color-mix(in oklch, var(--t-text-primary) 80%, transparent); }
.hover\:text-red-800:hover { color: #991b1b; }
.hover\:underline:hover { text-decoration-line: underline; }
.hover\:cursor-pointer:hover { cursor: pointer; }
/* ========================================
Focus states
======================================== */
.focus\:border-primary:focus { border-color: var(--t-accent); }
.focus\:outline-none:focus { outline: 2px solid transparent; outline-offset: 2px; }
.focus\:ring-1:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 1px var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.focus\:ring-primary\/20:focus { --tw-ring-color: color-mix(in oklch, var(--t-accent) 20%, transparent); }
/* ========================================
Disabled states
======================================== */
.disabled\:cursor-not-allowed:disabled { cursor: not-allowed; }
.disabled\:opacity-50:disabled { opacity: 0.5; }
/* ========================================
Group states
======================================== */
.group { /* marker class — no styles */ }
.group:hover .group-hover\:bg-base-300 { background-color: var(--t-border-default); }
.group:hover .group-hover\:fill-base-content { fill: var(--t-text-primary); }
.group:hover .group-hover\:opacity-70 { opacity: 0.7; }
.group:hover .group-hover\:text-base-content { color: var(--t-text-primary); }
.group[open] .group-open\:rotate-180 { transform: rotate(180deg); }
/* ========================================
Pseudo-class selectors
======================================== */
.only\:block:only-child { display: block; }
.last\:pb-0:last-child { padding-bottom: 0; }
.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker { display: none; }
/* ========================================
Responsive: sm (min-width: 640px)
======================================== */
@media (min-width: 640px) {
.sm\:flex-col { flex-direction: column; }
.sm\:flex-row { flex-direction: row; }
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.sm\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.sm\:p-6 { padding: 1.5rem; }
.sm\:px-6 { padding-inline: 1.5rem; }
.sm\:py-6 { padding-block: 1.5rem; }
.sm\:py-28 { padding-block: 7rem; }
.sm\:w-auto { width: auto; }
.sm\:w-96 { width: 24rem; }
.sm\:max-w-96 { max-width: 24rem; }
/* JS transition scale (used in show/hide) */
.sm\:translate-y-0 { translate: 0 0; }
.sm\:scale-95 { scale: 0.95; }
.sm\:scale-100 { scale: 1; }
.sm\:group-hover\:scale-105 { /* defined below with group:hover */ }
}
.group:hover .sm\:group-hover\:scale-105 {
@media (min-width: 640px) { scale: 1.05; }
}
/* ========================================
Responsive: lg (min-width: 1024px)
======================================== */
@media (min-width: 1024px) {
.lg\:block { display: block; }
.lg\:col-span-2 { grid-column: span 2 / span 2; }
.lg\:flex-row { flex-direction: row; }
.lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.lg\:h-screen { height: 100vh; }
.lg\:mx-0 { margin-inline: 0; }
.lg\:p-8 { padding: 2rem; }
.lg\:px-8 { padding-inline: 2rem; }
}
/* ========================================
Responsive: xl (min-width: 1280px)
======================================== */
@media (min-width: 1280px) {
.xl\:left-\[50rem\] { left: 50rem; }
.xl\:px-28 { padding-inline: 7rem; }
.xl\:py-32 { padding-block: 8rem; }
}
/* ========================================
JS transition helpers (used by core_components show/hide)
======================================== */
.translate-y-4 { translate: 0 1rem; }
.translate-y-0 { translate: 0 0; }
.opacity-0 { opacity: 0; }
.opacity-100 { opacity: 1; }
.scale-95 { scale: 0.95; }
.scale-100 { scale: 1; }
/* Duration classes used by JS.transition */
.duration-200 { transition-duration: 200ms; }
.duration-300 { transition-duration: 300ms; }
.ease-in { transition-timing-function: cubic-bezier(0.4, 0, 1, 1); }
.ease-out { transition-timing-function: cubic-bezier(0, 0, 0.2, 1); }
/* ========================================
DaisyUI component stubs (used in Phoenix home page and theme toggle)
======================================== */
.card {
border-radius: var(--t-radius-card, 0.5rem);
overflow: hidden;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: var(--t-radius-button, 0.25rem);
padding-inline: 0.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
border: 1px solid currentColor;
width: fit-content;
}
.badge-sm { font-size: 0.75rem; line-height: 1rem; padding-inline: 0.375rem; }
.badge-warning {
border-color: var(--t-status-warning);
color: var(--t-status-warning-content);
background-color: var(--t-status-warning);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 600;
border-radius: var(--t-radius-input, 0.25rem);
border: 1px solid transparent;
cursor: pointer;
transition: background-color 150ms, border-color 150ms;
}
.btn-primary {
background-color: var(--t-accent);
color: var(--t-text-inverse);
}
.btn-primary:hover {
filter: brightness(0.9);
}
/* ========================================
Prose (minimal typography block for instructions)
======================================== */
.prose {
line-height: 1.75;
}
.prose p { margin-top: 1em; margin-bottom: 1em; }
.prose :first-child { margin-top: 0; }
.prose :last-child { margin-bottom: 0; }
.prose a { color: var(--t-accent); text-decoration: underline; }
.prose strong { font-weight: 600; }
.prose-sm { font-size: 0.875rem; line-height: 1.5; }
/* ========================================
List styles
======================================== */
.list-decimal { list-style-type: decimal; }
.list-inside { list-style-position: inside; }
.list-none { list-style-type: none; }

View File

@ -62,8 +62,8 @@ defmodule BerrypodWeb.CoreComponents do
<p>{msg}</p> <p>{msg}</p>
</div> </div>
<div class="admin-alert-spacer" /> <div class="admin-alert-spacer" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}> <button type="button" class="admin-alert-close" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" /> <.icon name="hero-x-mark" class="size-5" />
</button> </button>
</div> </div>
</div> </div>
@ -351,7 +351,7 @@ defmodule BerrypodWeb.CoreComponents do
<td <td
:for={col <- @col} :for={col <- @col}
phx-click={@row_click && @row_click.(row)} phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"} class={@row_click && "admin-table-row-clickable"}
> >
{render_slot(col, @row_item.(row))} {render_slot(col, @row_item.(row))}
</td> </td>

View File

@ -46,7 +46,7 @@
<.link navigate={~p"/admin"} class="admin-brand"> <.link navigate={~p"/admin"} class="admin-brand">
Berrypod Berrypod
</.link> </.link>
<p class="admin-text-secondary truncate" style="margin-top: 0.125rem;"> <p class="admin-text-secondary truncate">
{@current_scope.user.email} {@current_scope.user.email}
</p> </p>
</div> </div>

View File

@ -132,7 +132,7 @@ defmodule BerrypodWeb.Admin.Activity do
</.header> </.header>
<%!-- tabs --%> <%!-- tabs --%>
<div class="flex gap-2 mt-6 mb-4 flex-wrap"> <div class="admin-filter-row">
<button <button
phx-click="tab" phx-click="tab"
phx-value-tab="all" phx-value-tab="all"
@ -156,7 +156,7 @@ defmodule BerrypodWeb.Admin.Activity do
Needs attention Needs attention
<span <span
:if={@attention_count > 0} :if={@attention_count > 0}
class="admin-badge admin-badge-sm admin-badge-warning ml-1" class="admin-badge admin-badge-sm admin-badge-warning admin-badge-count"
> >
{@attention_count} {@attention_count}
</span> </span>
@ -164,31 +164,36 @@ defmodule BerrypodWeb.Admin.Activity do
</div> </div>
<%!-- category chips + search --%> <%!-- category chips + search --%>
<div class="flex flex-wrap items-center gap-2 mb-4"> <div class="admin-filter-row admin-filter-row-between">
<.category_chip category={nil} active={@category} label="All" /> <div class="admin-cluster">
<.category_chip category="orders" active={@category} label="Orders" /> <.category_chip category={nil} active={@category} label="All" />
<.category_chip category="syncs" active={@category} label="Syncs" /> <.category_chip category="orders" active={@category} label="Orders" />
<.category_chip category="emails" active={@category} label="Emails" /> <.category_chip category="syncs" active={@category} label="Syncs" />
<.category_chip category="carts" active={@category} label="Carts" /> <.category_chip category="emails" active={@category} label="Emails" />
<div class="ml-auto"> <.category_chip category="carts" active={@category} label="Carts" />
<.form for={%{}} phx-submit="search" as={:search} class="flex gap-2">
<input
type="text"
name="search[query]"
value={@search}
placeholder="Search by order number"
class="admin-input admin-input-sm"
/>
<button type="submit" class="admin-btn admin-btn-sm admin-btn-ghost">
<.icon name="hero-magnifying-glass-mini" class="size-4" />
</button>
</.form>
</div> </div>
<.form
for={%{}}
phx-submit="search"
as={:search}
class="admin-row"
>
<input
type="text"
name="search[query]"
value={@search}
placeholder="Search by order number"
class="admin-input admin-input-sm"
/>
<button type="submit" class="admin-btn admin-btn-sm admin-btn-ghost">
<.icon name="hero-magnifying-glass-mini" class="size-4" />
</button>
</.form>
</div> </div>
<%!-- entries --%> <%!-- entries --%>
<div id="activity-entries" phx-update="stream"> <div id="activity-entries" phx-update="stream">
<div id="activity-empty" class="hidden only:block text-sm text-base-content/60 py-8 text-center"> <div id="activity-empty" class="admin-stream-empty">
No activity to show. No activity to show.
</div> </div>
<div :for={{dom_id, entry} <- @streams.entries} id={dom_id} class="admin-activity-row"> <div :for={{dom_id, entry} <- @streams.entries} id={dom_id} class="admin-activity-row">
@ -201,7 +206,7 @@ defmodule BerrypodWeb.Admin.Activity do
<.link <.link
:if={entry.order_id} :if={entry.order_id}
navigate={~p"/admin/orders/#{entry.order_id}"} navigate={~p"/admin/orders/#{entry.order_id}"}
class="text-primary hover:underline text-xs ml-1" class="admin-activity-link"
> >
View order &rarr; View order &rarr;
</.link> </.link>
@ -336,9 +341,9 @@ defmodule BerrypodWeb.Admin.Activity do
defp activity_icon("warning"), do: "hero-exclamation-triangle-mini" defp activity_icon("warning"), do: "hero-exclamation-triangle-mini"
defp activity_icon(_), do: "hero-check-circle-mini" defp activity_icon(_), do: "hero-check-circle-mini"
defp activity_icon_class("error"), do: "text-red-500" defp activity_icon_class("error"), do: "admin-activity-icon-error"
defp activity_icon_class("warning"), do: "text-amber-500" defp activity_icon_class("warning"), do: "admin-activity-icon-warning"
defp activity_icon_class(_), do: "text-green-500" defp activity_icon_class(_), do: "admin-activity-icon-ok"
defp format_event_type(event_type) do defp format_event_type(event_type) do
event_type event_type

View File

@ -144,10 +144,7 @@ defmodule BerrypodWeb.Admin.Analytics do
<.header>Analytics</.header> <.header>Analytics</.header>
<%!-- Period selector --%> <%!-- Period selector --%>
<div <div class="analytics-periods">
class="analytics-periods"
style="display: flex; gap: 0.25rem; margin-top: 1rem; align-items: center;"
>
<button <button
:for={period <- ["today", "7d", "30d", "12m"]} :for={period <- ["today", "7d", "30d", "12m"]}
phx-click={ phx-click={
@ -162,8 +159,7 @@ defmodule BerrypodWeb.Admin.Analytics do
id="analytics-export-link" id="analytics-export-link"
data-period={@period} data-period={@period}
href={export_url(@period, @filters)} href={export_url(@period, @filters)}
class="admin-btn admin-btn-sm" class="admin-btn admin-btn-sm analytics-export"
style="margin-left: auto;"
phx-hook="AnalyticsExport" phx-hook="AnalyticsExport"
> >
Export CSV Export CSV
@ -174,7 +170,7 @@ defmodule BerrypodWeb.Admin.Analytics do
<div <div
:if={@filters != %{}} :if={@filters != %{}}
id="analytics-filters" id="analytics-filters"
style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-top: 1rem;" class="analytics-filters"
> >
<.filter_chip <.filter_chip
:for={{dim, val} <- @filters} :for={{dim, val} <- @filters}
@ -184,15 +180,14 @@ defmodule BerrypodWeb.Admin.Analytics do
<button <button
:if={map_size(@filters) > 1} :if={map_size(@filters) > 1}
phx-click="clear_filters" phx-click="clear_filters"
class="admin-btn admin-btn-sm" class="admin-btn admin-btn-sm admin-text-secondary"
style="font-size: 0.75rem;"
> >
Clear all Clear all
</button> </button>
</div> </div>
<%!-- Stat cards --%> <%!-- Stat cards --%>
<div class="admin-stats-grid" style="margin-top: 1.5rem;"> <div class="admin-stats-grid admin-card-spaced">
<.stat_card <.stat_card
label="Unique visitors" label="Unique visitors"
value={format_number(@visitors)} value={format_number(@visitors)}
@ -221,15 +216,15 @@ defmodule BerrypodWeb.Admin.Analytics do
</div> </div>
<%!-- Visitor trend chart --%> <%!-- Visitor trend chart --%>
<div class="admin-card" style="margin-top: 1.5rem; padding: 1rem;"> <div class="admin-card admin-card-spaced analytics-chart-card">
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;"> <h3 class="analytics-tab-heading">
Visitors over time Visitors over time
</h3> </h3>
<.bar_chart data={@trend_data} mode={@trend_mode} /> <.bar_chart data={@trend_data} mode={@trend_mode} />
</div> </div>
<%!-- Detail tabs --%> <%!-- Detail tabs --%>
<div style="display: flex; gap: 0.25rem; margin-top: 1.5rem; flex-wrap: wrap;"> <div class="analytics-tab-bar">
<button <button
:for={ :for={
tab <- [ tab <- [
@ -249,7 +244,7 @@ defmodule BerrypodWeb.Admin.Analytics do
</div> </div>
<%!-- Tab content --%> <%!-- Tab content --%>
<div class="admin-card" style="margin-top: 0.75rem; padding: 1rem;"> <div class="admin-card analytics-tab-panel">
<.tab_content tab={@tab} {assigns} /> <.tab_content tab={@tab} {assigns} />
</div> </div>
""" """
@ -266,18 +261,16 @@ defmodule BerrypodWeb.Admin.Analytics do
defp stat_card(assigns) do defp stat_card(assigns) do
~H""" ~H"""
<div class="admin-card"> <div class="admin-card">
<div style="display: flex; align-items: center; gap: 0.75rem; padding: 1rem;"> <div class="admin-stat-card-body">
<div style="background: var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 0.5rem;"> <div class="admin-stat-icon">
<.icon name={@icon} class="size-5" /> <.icon name={@icon} class="size-5" />
</div> </div>
<div> <div>
<div style="display: flex; align-items: baseline; gap: 0.5rem;"> <div class="admin-stat-value-row">
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p> <p class="admin-stat-value">{@value}</p>
<.delta_badge :if={@delta != nil} delta={@delta} invert={@invert} /> <.delta_badge :if={@delta != nil} delta={@delta} invert={@invert} />
</div> </div>
<p style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"> <p class="admin-stat-label">{@label}</p>
{@label}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -289,26 +282,24 @@ defmodule BerrypodWeb.Admin.Analytics do
defp delta_badge(%{delta: :new} = assigns) do defp delta_badge(%{delta: :new} = assigns) do
~H""" ~H"""
<span style="font-size: 0.75rem; font-weight: 500; color: color-mix(in oklch, var(--color-base-content) 50%, transparent); white-space: nowrap;"> <span class="analytics-delta admin-text-secondary">new</span>
new
</span>
""" """
end end
defp delta_badge(assigns) do defp delta_badge(assigns) do
{color, arrow} = {color_class, arrow} =
cond do cond do
assigns.delta > 0 && !assigns.invert -> {"var(--t-status-success, #22c55e)", ""} assigns.delta > 0 && !assigns.invert -> {"admin-icon-positive", ""}
assigns.delta > 0 && assigns.invert -> {"var(--t-status-error, #ef4444)", ""} assigns.delta > 0 && assigns.invert -> {"admin-text-error", ""}
assigns.delta < 0 && !assigns.invert -> {"var(--t-status-error, #ef4444)", ""} assigns.delta < 0 && !assigns.invert -> {"admin-text-error", ""}
assigns.delta < 0 && assigns.invert -> {"var(--t-status-success, #22c55e)", ""} assigns.delta < 0 && assigns.invert -> {"admin-icon-positive", ""}
true -> {"color-mix(in oklch, var(--color-base-content) 40%, transparent)", ""} true -> {"admin-text-secondary", ""}
end end
assigns = assign(assigns, color: color, arrow: arrow) assigns = assign(assigns, color_class: color_class, arrow: arrow)
~H""" ~H"""
<span style={"font-size: 0.75rem; font-weight: 500; color: #{@color}; white-space: nowrap;"}> <span class={["analytics-delta", @color_class]}>
{@arrow} {if abs(@delta) > 999, do: ">999%", else: "#{abs(@delta)}%"} {@arrow} {if abs(@delta) > 999, do: ">999%", else: "#{abs(@delta)}%"}
</span> </span>
""" """
@ -326,13 +317,13 @@ defmodule BerrypodWeb.Admin.Analytics do
<span <span
data-filter-dimension={@dimension} data-filter-dimension={@dimension}
data-filter-value={@value} data-filter-value={@value}
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--color-base-200, #e5e5e5); border-radius: 0.25rem;" class="analytics-filter-chip"
> >
{@label} {@label}
<button <button
phx-click="remove_filter" phx-click="remove_filter"
phx-value-dimension={@dimension} phx-value-dimension={@dimension}
style="cursor: pointer; opacity: 0.6; line-height: 1;" class="analytics-filter-remove"
aria-label={"Remove #{@label} filter"} aria-label={"Remove #{@label} filter"}
> >
<.icon name="hero-x-mark" class="size-3" /> <.icon name="hero-x-mark" class="size-3" />
@ -430,59 +421,42 @@ defmodule BerrypodWeb.Admin.Analytics do
) )
~H""" ~H"""
<div <div :if={@data == []} class="analytics-empty">
:if={@data == []}
style="text-align: center; padding: 2rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
No data for this period No data for this period
</div> </div>
<div <div
:if={@data != []} :if={@data != []}
id="analytics-chart" id="analytics-chart"
phx-hook="ChartTooltip" phx-hook="ChartTooltip"
style="display: grid; grid-template-columns: auto 1fr; gap: 0 0.5rem; position: relative;" class="analytics-chart-grid"
> >
<%!-- Tooltip --%> <%!-- Tooltip --%>
<div <div data-tooltip class="analytics-tooltip"></div>
data-tooltip
style="display: none; position: absolute; top: -1.75rem; z-index: 10; padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 500; white-space: nowrap; background: var(--color-base-content, #1e1e1e); color: var(--color-base-100, #fff); border-radius: 0.25rem; pointer-events: none; font-variant-numeric: tabular-nums;"
>
</div>
<%!-- Row 1: Y-axis labels + chart --%> <%!-- Row 1: Y-axis labels + chart --%>
<div <div class="analytics-y-labels">
class="analytics-y-labels"
style="display: flex; flex-direction: column; justify-content: space-between; align-items: flex-end; height: 8rem;"
>
<span>{format_number(@scale_max)}</span> <span>{format_number(@scale_max)}</span>
<span>{format_number(@scale_mid)}</span> <span>{format_number(@scale_mid)}</span>
<span>0</span> <span>0</span>
</div> </div>
<div style="position: relative; height: 8rem;"> <div class="analytics-chart-area">
<%!-- Gridlines --%> <%!-- Gridlines --%>
<div style="position: absolute; top: 50%; left: 0; right: 0; border-top: 1px dashed color-mix(in oklch, var(--color-base-content) 12%, transparent);"> <div class="analytics-gridline-mid"></div>
</div> <div class="analytics-gridline-bottom"></div>
<div style="position: absolute; bottom: 0; left: 0; right: 0; border-top: 1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent);">
</div>
<%!-- Bars container --%> <%!-- Bars container --%>
<div <div data-bars class="analytics-bars" style={"gap: #{@bar_gap}px;"}>
data-bars
style={"display: flex; align-items: flex-end; height: 100%; gap: #{@bar_gap}px;"}
>
<div <div
:for={bar <- @bars} :for={bar <- @bars}
data-label={bar.label} data-label={bar.label}
data-visitors={bar.visitors} data-visitors={bar.visitors}
style={"flex: 1; height: #{max(bar.height_pct, 0.5)}%; background: var(--color-primary, #4f46e5); opacity: 0.8; border-radius: 1px 1px 0 0; min-width: 0;"} class="analytics-bar"
style={"height: #{max(bar.height_pct, 0.5)}%;"}
> >
</div> </div>
</div> </div>
</div> </div>
<%!-- Row 2: empty cell + X-axis labels --%> <%!-- Row 2: empty cell + X-axis labels --%>
<div></div> <div></div>
<div <div class="analytics-x-labels">
class="analytics-x-labels"
style="display: flex; justify-content: space-between; padding-top: 0.25rem;"
>
<span :for={bar <- @x_labels}>{bar.label}</span> <span :for={bar <- @x_labels}>{bar.label}</span>
</div> </div>
</div> </div>
@ -519,7 +493,7 @@ defmodule BerrypodWeb.Admin.Analytics do
defp tab_content(%{tab: "pages"} = assigns) do defp tab_content(%{tab: "pages"} = assigns) do
~H""" ~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Top pages</h3> <h3 class="analytics-tab-heading">Top pages</h3>
<.detail_table <.detail_table
rows={@top_pages} rows={@top_pages}
empty_message="No page data yet" empty_message="No page data yet"
@ -529,7 +503,7 @@ defmodule BerrypodWeb.Admin.Analytics do
%{label: "Pageviews", key: :pageviews, align: :right} %{label: "Pageviews", key: :pageviews, align: :right}
]} ]}
/> />
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Entry pages</h3> <h3 class="analytics-tab-heading-spaced">Entry pages</h3>
<.detail_table <.detail_table
rows={@entry_pages} rows={@entry_pages}
empty_message="No entry page data yet" empty_message="No entry page data yet"
@ -538,7 +512,7 @@ defmodule BerrypodWeb.Admin.Analytics do
%{label: "Sessions", key: :sessions, align: :right} %{label: "Sessions", key: :sessions, align: :right}
]} ]}
/> />
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Exit pages</h3> <h3 class="analytics-tab-heading-spaced">Exit pages</h3>
<.detail_table <.detail_table
rows={@exit_pages} rows={@exit_pages}
empty_message="No exit page data yet" empty_message="No exit page data yet"
@ -552,7 +526,7 @@ defmodule BerrypodWeb.Admin.Analytics do
defp tab_content(%{tab: "sources"} = assigns) do defp tab_content(%{tab: "sources"} = assigns) do
~H""" ~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Top sources</h3> <h3 class="analytics-tab-heading">Top sources</h3>
<.detail_table <.detail_table
rows={@top_sources} rows={@top_sources}
empty_message="No referrer data yet" empty_message="No referrer data yet"
@ -561,7 +535,7 @@ defmodule BerrypodWeb.Admin.Analytics do
%{label: "Visitors", key: :visitors, align: :right} %{label: "Visitors", key: :visitors, align: :right}
]} ]}
/> />
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Top referrers</h3> <h3 class="analytics-tab-heading-spaced">Top referrers</h3>
<.detail_table <.detail_table
rows={@top_referrers} rows={@top_referrers}
empty_message="No referrer data yet" empty_message="No referrer data yet"
@ -582,7 +556,7 @@ defmodule BerrypodWeb.Admin.Analytics do
assigns = assign(assigns, :country_rows, rows) assigns = assign(assigns, :country_rows, rows)
~H""" ~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Countries</h3> <h3 class="analytics-tab-heading">Countries</h3>
<.detail_table <.detail_table
rows={@country_rows} rows={@country_rows}
empty_message="No country data yet" empty_message="No country data yet"
@ -596,7 +570,7 @@ defmodule BerrypodWeb.Admin.Analytics do
defp tab_content(%{tab: "devices"} = assigns) do defp tab_content(%{tab: "devices"} = assigns) do
~H""" ~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Browsers</h3> <h3 class="analytics-tab-heading">Browsers</h3>
<.detail_table <.detail_table
rows={@browsers} rows={@browsers}
empty_message="No browser data yet" empty_message="No browser data yet"
@ -605,9 +579,7 @@ defmodule BerrypodWeb.Admin.Analytics do
%{label: "Visitors", key: :visitors, align: :right} %{label: "Visitors", key: :visitors, align: :right}
]} ]}
/> />
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;"> <h3 class="analytics-tab-heading-spaced">Operating systems</h3>
Operating systems
</h3>
<.detail_table <.detail_table
rows={@oses} rows={@oses}
empty_message="No OS data yet" empty_message="No OS data yet"
@ -616,7 +588,7 @@ defmodule BerrypodWeb.Admin.Analytics do
%{label: "Visitors", key: :visitors, align: :right} %{label: "Visitors", key: :visitors, align: :right}
]} ]}
/> />
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Screen sizes</h3> <h3 class="analytics-tab-heading-spaced">Screen sizes</h3>
<.detail_table <.detail_table
rows={@screen_sizes} rows={@screen_sizes}
empty_message="No screen data yet" empty_message="No screen data yet"
@ -630,9 +602,7 @@ defmodule BerrypodWeb.Admin.Analytics do
defp tab_content(%{tab: "funnel"} = assigns) do defp tab_content(%{tab: "funnel"} = assigns) do
~H""" ~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;"> <h3 class="analytics-tab-heading">Conversion funnel</h3>
Conversion funnel
</h3>
<.funnel_chart funnel={@funnel} revenue={@revenue} /> <.funnel_chart funnel={@funnel} revenue={@revenue} />
""" """
end end
@ -645,18 +615,15 @@ defmodule BerrypodWeb.Admin.Analytics do
defp detail_table(assigns) do defp detail_table(assigns) do
~H""" ~H"""
<div <div :if={@rows == []} class="analytics-empty">
:if={@rows == []}
style="text-align: center; padding: 1.5rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
{@empty_message} {@empty_message}
</div> </div>
<table :if={@rows != []} class="admin-table" style="width: 100%;"> <table :if={@rows != []} class="admin-table">
<thead> <thead>
<tr> <tr>
<th <th
:for={col <- @columns} :for={col <- @columns}
style={col[:align] == :right && "text-align: right;"} class={col[:align] == :right && "admin-cell-end"}
> >
{col.label} {col.label}
</th> </th>
@ -666,12 +633,11 @@ defmodule BerrypodWeb.Admin.Analytics do
<tr :for={row <- @rows}> <tr :for={row <- @rows}>
<td <td
:for={col <- @columns} :for={col <- @columns}
style={col[:align] == :right && "text-align: right; font-variant-numeric: tabular-nums;"} class={col[:align] == :right && "admin-cell-numeric"}
> >
<%= if col[:filter] do %> <%= if col[:filter] do %>
<span <span
class="admin-link" class="admin-link"
style="cursor: pointer;"
phx-click="add_filter" phx-click="add_filter"
phx-value-dimension={elem(col.filter, 0)} phx-value-dimension={elem(col.filter, 0)}
phx-value-value={Map.get(row, elem(col.filter, 1))} phx-value-value={Map.get(row, elem(col.filter, 1))}
@ -721,35 +687,25 @@ defmodule BerrypodWeb.Admin.Analytics do
assigns = assign(assigns, steps: steps_with_rates, conversion_rate: conversion_rate) assigns = assign(assigns, steps: steps_with_rates, conversion_rate: conversion_rate)
~H""" ~H"""
<div <div :if={@funnel.product_views == 0} class="analytics-empty">
:if={@funnel.product_views == 0}
style="text-align: center; padding: 1.5rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
No funnel data yet No funnel data yet
</div> </div>
<div :if={@funnel.product_views > 0} style="display: flex; flex-direction: column; gap: 0.5rem;"> <div :if={@funnel.product_views > 0} class="analytics-funnel">
<div :for={step <- @steps} style="display: flex; align-items: center; gap: 0.75rem;"> <div :for={step <- @steps} class="analytics-funnel-step">
<div style="width: 7rem; font-size: 0.8125rem; text-align: right; flex-shrink: 0;"> <div class="analytics-funnel-label">{step.label}</div>
{step.label} <div
</div> class="analytics-funnel-bar"
<div style={"flex: 0 0 #{step.width_pct}%; height: 2rem; background: var(--color-primary, #4f46e5); border-radius: 0.25rem; opacity: #{1 - step.index * 0.15}; display: flex; align-items: center; padding-left: 0.5rem;"}> style={"flex: 0 0 #{step.width_pct}%; opacity: #{1 - step.index * 0.15};"}
<span style="font-size: 0.75rem; font-weight: 600; color: white;">
{format_number(step.count)}
</span>
</div>
<span
:if={step.index > 0}
style="font-size: 0.8125rem; font-weight: 600;"
> >
<span class="analytics-funnel-value">{format_number(step.count)}</span>
</div>
<span :if={step.index > 0} class="analytics-funnel-rate">
{step.overall_rate}% {step.overall_rate}%
</span> </span>
</div> </div>
<div style="margin-top: 0.75rem; font-size: 0.875rem; display: flex; gap: 0.5rem; flex-wrap: wrap;"> <div class="analytics-funnel-summary">
<span style="font-weight: 600;">{@conversion_rate}% overall conversion</span> <span class="admin-text-bold">{@conversion_rate}% overall conversion</span>
<span <span :if={@revenue > 0} class="admin-text-secondary">
:if={@revenue > 0}
style="color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
>
· Revenue: {Cart.format_price(@revenue)} · Revenue: {Cart.format_price(@revenue)}
</span> </span>
</div> </div>

View File

@ -63,11 +63,11 @@ defmodule BerrypodWeb.Admin.Dashboard do
</.header> </.header>
<%!-- Celebration after go-live --%> <%!-- Celebration after go-live --%>
<div :if={@just_went_live} class="setup-complete" style="margin-top: 1.5rem;"> <div :if={@just_went_live} class="setup-complete admin-card-spaced">
<.icon name="hero-check-badge" class="setup-complete-icon" /> <.icon name="hero-check-badge" class="setup-complete-icon" />
<h2>Your shop is live!</h2> <h2>Your shop is live!</h2>
<p>Customers can now browse and buy from your shop.</p> <p>Customers can now browse and buy from your shop.</p>
<div style="display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap;"> <div class="setup-complete-actions">
<.link href={~p"/"} class="admin-btn admin-btn-primary"> <.link href={~p"/"} class="admin-btn admin-btn-primary">
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop <.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
</.link> </.link>
@ -103,36 +103,33 @@ defmodule BerrypodWeb.Admin.Dashboard do
</div> </div>
<%!-- Recent orders --%> <%!-- Recent orders --%>
<section style="margin-top: 2rem;"> <section class="dashboard-section">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;"> <div class="dashboard-section-header">
<h2 style="font-size: 1.125rem; font-weight: 600;">Recent orders</h2> <h2 class="admin-section-heading">Recent orders</h2>
<.link <.link navigate={~p"/admin/orders"} class="dashboard-view-all">
navigate={~p"/admin/orders"}
style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
>
View all &rarr; View all &rarr;
</.link> </.link>
</div> </div>
<%= if @recent_orders == [] do %> <%= if @recent_orders == [] do %>
<div style="border: 1px solid var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 2rem; text-align: center; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"> <div class="dashboard-empty-orders">
<div style="margin: 0 auto 0.75rem; width: 2.5rem; opacity: 0.3;"> <div class="dashboard-empty-icon">
<.icon name="hero-inbox" class="size-10" /> <.icon name="hero-inbox" class="size-10" />
</div> </div>
<p style="font-weight: 500;">No orders yet</p> <p class="admin-text-medium">No orders yet</p>
<p style="font-size: 0.875rem; margin-top: 0.25rem;"> <p class="admin-help-text">
Orders will appear here once customers check out. Orders will appear here once customers check out.
</p> </p>
</div> </div>
<% else %> <% else %>
<div style="overflow-x: auto;"> <div class="dashboard-recent-orders">
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr> <tr>
<th>Order</th> <th>Order</th>
<th>Date</th> <th>Date</th>
<th>Customer</th> <th>Customer</th>
<th style="text-align: right;">Total</th> <th class="admin-cell-end">Total</th>
<th>Fulfilment</th> <th>Fulfilment</th>
</tr> </tr>
</thead> </thead>
@ -140,12 +137,12 @@ defmodule BerrypodWeb.Admin.Dashboard do
<tr <tr
:for={order <- @recent_orders} :for={order <- @recent_orders}
phx-click={JS.navigate(~p"/admin/orders/#{order}")} phx-click={JS.navigate(~p"/admin/orders/#{order}")}
style="cursor: pointer;" class="admin-table-row-clickable"
> >
<td style="font-weight: 500;">{order.order_number}</td> <td class="admin-text-medium">{order.order_number}</td>
<td>{format_date(order.inserted_at)}</td> <td>{format_date(order.inserted_at)}</td>
<td>{order.customer_email || ""}</td> <td>{order.customer_email || ""}</td>
<td style="text-align: right;">{Cart.format_price(order.total)}</td> <td class="admin-cell-end">{Cart.format_price(order.total)}</td>
<td><.fulfilment_pill status={order.fulfilment_status} /></td> <td><.fulfilment_pill status={order.fulfilment_status} /></td>
</tr> </tr>
</tbody> </tbody>
@ -185,7 +182,7 @@ defmodule BerrypodWeb.Admin.Dashboard do
|> assign(:can_go_live, can_go_live) |> assign(:can_go_live, can_go_live)
~H""" ~H"""
<div class="admin-checklist" style="margin-top: 1.5rem;"> <div class="admin-checklist admin-card-spaced">
<div class="admin-checklist-header"> <div class="admin-checklist-header">
<h2 class="admin-checklist-title">Launch checklist</h2> <h2 class="admin-checklist-title">Launch checklist</h2>
<div class="admin-checklist-progress"> <div class="admin-checklist-progress">
@ -252,20 +249,14 @@ defmodule BerrypodWeb.Admin.Dashboard do
defp stat_card(assigns) do defp stat_card(assigns) do
~H""" ~H"""
<.link <.link navigate={@href} class="admin-card dashboard-stat-link">
navigate={@href} <div class="admin-stat-card-body">
class="admin-card" <div class="admin-stat-icon">
style="display: block; text-decoration: none;"
>
<div style="display: flex; align-items: center; gap: 0.75rem; padding: 1rem;">
<div style="background: var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 0.5rem;">
<.icon name={@icon} class="size-5" /> <.icon name={@icon} class="size-5" />
</div> </div>
<div> <div>
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p> <p class="admin-stat-value">{@value}</p>
<p style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"> <p class="admin-stat-label">{@label}</p>
{@label}
</p>
</div> </div>
</div> </div>
</.link> </.link>
@ -273,23 +264,21 @@ defmodule BerrypodWeb.Admin.Dashboard do
end end
defp fulfilment_pill(assigns) do defp fulfilment_pill(assigns) do
{color, label} = {color_class, label} =
case assigns.status do case assigns.status do
"unfulfilled" -> {"var(--color-base-200, #e5e5e5)", "unfulfilled"} "unfulfilled" -> {"admin-status-pill-zinc", "unfulfilled"}
"submitted" -> {"#dbeafe", "submitted"} "submitted" -> {"admin-status-pill-blue", "submitted"}
"processing" -> {"#fef3c7", "processing"} "processing" -> {"admin-status-pill-amber", "processing"}
"shipped" -> {"#f3e8ff", "shipped"} "shipped" -> {"admin-status-pill-purple", "shipped"}
"delivered" -> {"#dcfce7", "delivered"} "delivered" -> {"admin-status-pill-green", "delivered"}
"failed" -> {"#fee2e2", "failed"} "failed" -> {"admin-status-pill-red", "failed"}
_ -> {"var(--color-base-200, #e5e5e5)", assigns.status || ""} _ -> {"admin-status-pill-zinc", assigns.status || ""}
end end
assigns = assign(assigns, color: color, label: label) assigns = assign(assigns, color_class: color_class, label: label)
~H""" ~H"""
<span style={"display: inline-flex; border-radius: 9999px; padding: 0.125rem 0.5rem; font-size: 0.75rem; font-weight: 500; background: #{@color};"}> <span class={["admin-status-pill", @color_class]}>{@label}</span>
{@label}
</span>
""" """
end end

View File

@ -200,7 +200,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="max-w-2xl"> <div class="admin-content-medium">
<.header> <.header>
Email settings Email settings
<:subtitle> <:subtitle>
@ -211,14 +211,16 @@ defmodule BerrypodWeb.Admin.EmailSettings do
</.header> </.header>
<%= if @env_locked do %> <%= if @env_locked do %>
<div class="mt-6 rounded-md bg-amber-50 p-4 ring-1 ring-amber-600/10 ring-inset"> <div class="admin-callout-warning">
<div class="flex gap-3"> <div class="admin-callout-warning-body">
<.icon name="hero-lock-closed" class="size-5 text-amber-600 shrink-0 mt-0.5" /> <span class="admin-callout-warning-icon">
<.icon name="hero-lock-closed" class="size-5" />
</span>
<div> <div>
<p class="text-sm font-medium text-amber-800"> <p class="admin-callout-warning-title">
Controlled by environment variables Controlled by environment variables
</p> </p>
<p class="mt-1 text-sm text-amber-700"> <p class="admin-callout-warning-desc">
Email is configured via <code>SMTP_HOST</code> and related env vars. Email is configured via <code>SMTP_HOST</code> and related env vars.
Remove them to configure email from this page instead. Remove them to configure email from this page instead.
</p> </p>
@ -227,7 +229,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
</div> </div>
<% end %> <% end %>
<section class="mt-8"> <section class="admin-section">
<.form for={@form} phx-change="change_adapter" phx-submit="save"> <.form for={@form} phx-change="change_adapter" phx-submit="save">
<div id="email-provider-cards" phx-hook="CardRadioScroll"> <div id="email-provider-cards" phx-hook="CardRadioScroll">
<.card_radio_group <.card_radio_group
@ -244,24 +246,24 @@ defmodule BerrypodWeb.Admin.EmailSettings do
<% selected = @adapter_key == adapter.key %> <% selected = @adapter_key == adapter.key %>
<div <div
id={"adapter-config-#{adapter.key}"} id={"adapter-config-#{adapter.key}"}
class="mt-6 space-y-4" class="admin-adapter-config"
hidden={!selected} hidden={!selected}
data-adapter={adapter.key} data-adapter={adapter.key}
> >
<div> <div>
<h3 class="text-base font-semibold"> <h3 class="admin-section-subheading">
{adapter.name} {adapter.name}
<a <a
:if={adapter.url} :if={adapter.url}
href={adapter.url} href={adapter.url}
target="_blank" target="_blank"
rel="noopener" rel="noopener"
class="text-sm font-normal text-base-content/50 hover:text-base-content/80" class="admin-link-subtle admin-adapter-link"
> >
&nearr; &nearr;
</a> </a>
</h3> </h3>
<p class="text-sm text-base-content/60">{adapter.description}</p> <p class="admin-section-desc">{adapter.description}</p>
</div> </div>
<.input <.input
name="email[from_address]" name="email[from_address]"
@ -279,7 +281,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
/> />
<% end %> <% end %>
<%= unless @env_locked do %> <%= unless @env_locked do %>
<div class="flex items-center gap-3"> <div class="admin-row admin-row-lg">
<.button phx-disable-with="Saving..." disabled={!selected}> <.button phx-disable-with="Saving..." disabled={!selected}>
Save settings Save settings
</.button> </.button>
@ -288,7 +290,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
type="button" type="button"
phx-click="disconnect" phx-click="disconnect"
data-confirm="Remove email configuration? Transactional emails will stop being sent." data-confirm="Remove email configuration? Transactional emails will stop being sent."
class="text-sm text-red-600 hover:text-red-800" class="admin-link-danger"
> >
Disconnect Disconnect
</button> </button>
@ -301,17 +303,17 @@ defmodule BerrypodWeb.Admin.EmailSettings do
</section> </section>
<%= if @email_configured do %> <%= if @email_configured do %>
<section class="mt-8 border-t border-base-200 pt-6"> <section class="admin-section-bordered">
<h2 class="text-lg font-semibold">Test email</h2> <h2 class="admin-section-heading">Test email</h2>
<p class="mt-1 text-sm text-base-content/60"> <p class="admin-help-text">
Send a test email to <strong>{@current_scope.user.email}</strong> Send a test email to <strong>{@current_scope.user.email}</strong>
to verify delivery works. to verify delivery works.
</p> </p>
<div class="mt-4"> <div class="admin-section-body">
<button <button
phx-click="send_test" phx-click="send_test"
disabled={@sending_test} disabled={@sending_test}
class="inline-flex items-center gap-2 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset" class="admin-btn admin-btn-outline"
> >
<.icon name="hero-paper-airplane" class="size-4" /> <.icon name="hero-paper-airplane" class="size-4" />
{if @sending_test, do: "Sending...", else: "Send test email"} {if @sending_test, do: "Sending...", else: "Send test email"}
@ -341,7 +343,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
disabled={@disabled} disabled={@disabled}
/> />
<%= if @value && !@disabled do %> <%= if @value && !@disabled do %>
<p class="text-xs text-base-content/60 mt-1"> <p class="admin-help-text">
Current: <code>{@value}</code> leave blank to keep existing value Current: <code>{@value}</code> leave blank to keep existing value
</p> </p>
<% end %> <% end %>

View File

@ -260,7 +260,7 @@ defmodule BerrypodWeb.Admin.Media do
name="value" name="value"
value={@upload_alt} value={@upload_alt}
placeholder="Alt text (recommended)" placeholder="Alt text (recommended)"
class="admin-input flex-1" class="admin-input admin-input-fill"
phx-debounce="200" phx-debounce="200"
/> />
</div> </div>
@ -272,12 +272,12 @@ defmodule BerrypodWeb.Admin.Media do
</div> </div>
<% end %> <% end %>
<%= for err <- upload_errors(@uploads.media_upload) do %> <%= for err <- upload_errors(@uploads.media_upload) do %>
<p class="text-error text-sm">{Phoenix.Naming.humanize(err)}</p> <p class="admin-error">{Phoenix.Naming.humanize(err)}</p>
<% end %> <% end %>
</div> </div>
<%!-- filter bar --%> <%!-- filter bar --%>
<div class="flex gap-2 mt-6 mb-4 flex-wrap items-center"> <div class="admin-filter-row">
<form phx-change="filter_type" class="contents"> <form phx-change="filter_type" class="contents">
<select name="type" class="admin-select"> <select name="type" class="admin-select">
<option value="" selected={is_nil(@filter_type)}>All types</option> <option value="" selected={is_nil(@filter_type)}>All types</option>
@ -294,7 +294,7 @@ defmodule BerrypodWeb.Admin.Media do
value={@filter_search} value={@filter_search}
phx-keyup="filter_search" phx-keyup="filter_search"
phx-debounce="300" phx-debounce="300"
class="admin-input flex-1" class="admin-input admin-input-fill"
/> />
<button <button
phx-click="toggle_orphans" phx-click="toggle_orphans"
@ -342,7 +342,7 @@ defmodule BerrypodWeb.Admin.Media do
<span class="media-card-filename" title={image.filename}>{image.filename}</span> <span class="media-card-filename" title={image.filename}>{image.filename}</span>
<div class="media-card-meta"> <div class="media-card-meta">
<span class={type_badge_class(image.image_type)}>{image.image_type}</span> <span class={type_badge_class(image.image_type)}>{image.image_type}</span>
<span class="text-xs">{format_file_size(image.file_size)}</span> <span class="media-card-size">{format_file_size(image.file_size)}</span>
</div> </div>
<span <span
:if={!image.alt || image.alt == ""} :if={!image.alt || image.alt == ""}
@ -419,14 +419,14 @@ defmodule BerrypodWeb.Admin.Media do
<div class="media-detail-actions"> <div class="media-detail-actions">
<%= if @confirm_delete do %> <%= if @confirm_delete do %>
<p class="text-sm text-error"> <p class="admin-error">
<%= if @selected_usages != [] do %> <%= if @selected_usages != [] do %>
This image is in use. Deleting it may break pages. This image is in use. Deleting it may break pages.
<% else %> <% else %>
Are you sure? Are you sure?
<% end %> <% end %>
</p> </p>
<div class="flex gap-2"> <div class="admin-row">
<button phx-click="delete_image" class="admin-btn admin-btn-sm admin-btn-danger"> <button phx-click="delete_image" class="admin-btn admin-btn-sm admin-btn-danger">
Yes, delete Yes, delete
</button> </button>
@ -437,7 +437,7 @@ defmodule BerrypodWeb.Admin.Media do
<% else %> <% else %>
<button <button
phx-click="confirm_delete" phx-click="confirm_delete"
class="admin-btn admin-btn-sm admin-btn-ghost text-error" class="admin-btn admin-btn-sm admin-btn-ghost admin-text-error"
> >
<.icon name="hero-trash" class="size-4" /> Delete image <.icon name="hero-trash" class="size-4" /> Delete image
</button> </button>

View File

@ -111,11 +111,10 @@ defmodule BerrypodWeb.Admin.Navigation do
<:subtitle>Configure the links in your shop header and footer.</:subtitle> <:subtitle>Configure the links in your shop header and footer.</:subtitle>
</.header> </.header>
<p :if={@dirty} class="admin-badge admin-badge-warning mt-4"> <div class="admin-nav-layout">
Unsaved changes <p :if={@dirty} class="admin-badge admin-badge-warning">
</p> Unsaved changes
</p>
<div class="mt-6 space-y-8" style="max-width: 40rem;">
<.nav_section <.nav_section
title="Header navigation" title="Header navigation"
section="header" section="header"
@ -130,10 +129,10 @@ defmodule BerrypodWeb.Admin.Navigation do
custom_pages={@custom_pages} custom_pages={@custom_pages}
/> />
<div class="flex gap-3"> <div class="admin-row admin-row-lg">
<button <button
phx-click="save" phx-click="save"
class={["admin-btn admin-btn-primary", !@dirty && "opacity-50"]} class="admin-btn admin-btn-primary"
disabled={!@dirty} disabled={!@dirty}
> >
Save Save
@ -153,11 +152,11 @@ defmodule BerrypodWeb.Admin.Navigation do
defp nav_section(assigns) do defp nav_section(assigns) do
~H""" ~H"""
<section> <section>
<h3 class="text-sm font-semibold uppercase tracking-wider text-base-content/50 mb-3"> <h3 class="admin-nav-section-heading">
{@title} {@title}
</h3> </h3>
<div class="space-y-2"> <div class="admin-stack admin-stack-sm">
<div :if={@items != []} class="nav-editor-labels" aria-hidden="true"> <div :if={@items != []} class="nav-editor-labels" aria-hidden="true">
<span>Label</span> <span>Label</span>
<span>Path</span> <span>Path</span>
@ -228,11 +227,14 @@ defmodule BerrypodWeb.Admin.Navigation do
</div> </div>
</div> </div>
<div :if={@items == []} class="text-sm text-base-content/50 py-4"> <div
:if={@items == []}
class="admin-nav-empty"
>
No items yet. No items yet.
</div> </div>
<div class="mt-3 flex gap-2"> <div class="admin-nav-actions">
<button <button
phx-click="add_item" phx-click="add_item"
phx-value-section={@section} phx-value-section={@section}
@ -240,7 +242,7 @@ defmodule BerrypodWeb.Admin.Navigation do
> >
<.icon name="hero-plus" class="size-4" /> Add link <.icon name="hero-plus" class="size-4" /> Add link
</button> </button>
<div :if={@custom_pages != []} class="relative" id={"add-page-#{@section}"}> <div :if={@custom_pages != []} class="nav-editor-dropdown-wrap" id={"add-page-#{@section}"}>
<button <button
phx-click={Phoenix.LiveView.JS.toggle(to: "#page-menu-#{@section}")} phx-click={Phoenix.LiveView.JS.toggle(to: "#page-menu-#{@section}")}
class="admin-btn admin-btn-sm admin-btn-outline" class="admin-btn admin-btn-sm admin-btn-outline"
@ -260,7 +262,9 @@ defmodule BerrypodWeb.Admin.Navigation do
class="nav-editor-dropdown-item" class="nav-editor-dropdown-item"
> >
{page.title} {page.title}
<span class="text-xs text-base-content/40">/{page.slug}</span> <span class="nav-editor-dropdown-slug">
/{page.slug}
</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -194,12 +194,12 @@ defmodule BerrypodWeb.Admin.Newsletter do
defp overview_tab(assigns) do defp overview_tab(assigns) do
~H""" ~H"""
<div class="admin-stack" style="--admin-stack-gap: 1.5rem;"> <div class="admin-stack admin-stack-lg">
<div class="admin-card"> <div class="admin-card">
<div class="admin-card-body admin-row" style="--admin-row-gap: 1rem;"> <div class="admin-card-body admin-row admin-row-xl">
<div style="flex: 1;"> <div class="admin-input-fill">
<h3 style="font-weight: 500;">Newsletter signups</h3> <h3 class="admin-text-medium">Newsletter signups</h3>
<p class="admin-section-desc" style="margin-top: 0.125rem;"> <p class="admin-section-desc">
When enabled, the newsletter signup block on your shop pages will collect email addresses with double opt-in confirmation. When enabled, the newsletter signup block on your shop pages will collect email addresses with double opt-in confirmation.
</p> </p>
</div> </div>
@ -233,18 +233,16 @@ defmodule BerrypodWeb.Admin.Newsletter do
</div> </div>
</div> </div>
<div class="admin-row" style="--admin-row-gap: 0.75rem;"> <div class="admin-row admin-row-lg">
<.link <.link
navigate={~p"/admin/newsletter?tab=subscribers"} navigate={~p"/admin/newsletter?tab=subscribers"}
class="admin-link" class="admin-link admin-text-medium"
style="font-weight: 500;"
> >
View subscribers View subscribers
</.link> </.link>
<.link <.link
navigate={~p"/admin/newsletter?tab=campaigns"} navigate={~p"/admin/newsletter?tab=campaigns"}
class="admin-link" class="admin-link admin-text-medium"
style="font-weight: 500;"
> >
View campaigns View campaigns
</.link> </.link>
@ -265,7 +263,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
defp subscribers_tab(assigns) do defp subscribers_tab(assigns) do
~H""" ~H"""
<div> <div>
<div class="admin-row" style="justify-content: space-between; margin-bottom: 1rem;"> <div class="admin-filter-row admin-filter-row-between">
<div class="admin-cluster"> <div class="admin-cluster">
<.filter_pill <.filter_pill
status="all" status="all"
@ -297,7 +295,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
</.link> </.link>
</div> </div>
<form phx-change="search_subscribers" style="margin-bottom: 1rem;"> <form phx-change="search_subscribers" class="admin-filter-row">
<.input <.input
name="search" name="search"
value={@search} value={@search}
@ -356,7 +354,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
defp campaigns_tab(assigns) do defp campaigns_tab(assigns) do
~H""" ~H"""
<div> <div>
<div style="display: flex; justify-content: flex-end; margin-bottom: 1rem;"> <div class="admin-tab-actions">
<.link navigate={~p"/admin/newsletter/campaigns/new"} class="admin-btn admin-btn-primary"> <.link navigate={~p"/admin/newsletter/campaigns/new"} class="admin-btn admin-btn-primary">
<.icon name="hero-plus" class="size-4" /> New campaign <.icon name="hero-plus" class="size-4" /> New campaign
</.link> </.link>
@ -426,7 +424,9 @@ defmodule BerrypodWeb.Admin.Newsletter do
]} ]}
> >
{@label} {@label}
<span :if={@count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span> <span :if={@count > 0} class="admin-badge admin-badge-sm admin-badge-count">
{@count}
</span>
</button> </button>
""" """
end end

View File

@ -156,9 +156,9 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
</:subtitle> </:subtitle>
</.header> </.header>
<div class="mt-6 max-w-2xl"> <div class="admin-content-medium admin-section">
<.form for={@form} phx-change="validate" phx-submit="save_draft"> <.form for={@form} phx-change="validate" phx-submit="save_draft">
<div class="space-y-4"> <div class="admin-stack">
<.input <.input
field={@form[:subject]} field={@form[:subject]}
type="text" type="text"
@ -178,14 +178,17 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
placeholder="Hello!\n\nYour newsletter content here.\n\nUnsubscribe: {{unsubscribe_url}}" placeholder="Hello!\n\nYour newsletter content here.\n\nUnsubscribe: {{unsubscribe_url}}"
/> />
<p :if={!readonly?(@campaign)} class="text-sm text-base-content/60"> <p
:if={!readonly?(@campaign)}
class="admin-help-text"
>
Use <code>{"{{unsubscribe_url}}"}</code> Use <code>{"{{unsubscribe_url}}"}</code>
to insert the unsubscribe link. This is required for GDPR compliance. to insert the unsubscribe link. This is required for GDPR compliance.
</p> </p>
<p <p
:if={missing_unsubscribe_url?(@form[:body].value) && !readonly?(@campaign)} :if={missing_unsubscribe_url?(@form[:body].value) && !readonly?(@campaign)}
class="flex items-center gap-2 text-sm text-amber-700" class="admin-warning-text"
> >
<.icon name="hero-exclamation-triangle" class="size-4 shrink-0" /> Body is missing <.icon name="hero-exclamation-triangle" class="size-4 shrink-0" /> Body is missing
<code>{"{{unsubscribe_url}}"}</code> <code>{"{{unsubscribe_url}}"}</code>
@ -193,15 +196,17 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
</p> </p>
<%= if @form[:body].value && @form[:body].value != "" do %> <%= if @form[:body].value && @form[:body].value != "" do %>
<details class="mt-4"> <details>
<summary class="text-sm font-medium cursor-pointer">Preview</summary> <summary class="admin-preview-summary">
<pre class="mt-2 p-4 bg-base-200 rounded-lg text-sm whitespace-pre-wrap overflow-auto max-h-64">{preview_body(@form[:body].value)}</pre> Preview
</summary>
<pre class="admin-preview-body">{preview_body(@form[:body].value)}</pre>
</details> </details>
<% end %> <% end %>
<div <div
:if={!readonly?(@campaign)} :if={!readonly?(@campaign)}
class="flex items-center gap-3 pt-4 border-t border-base-200" class="admin-campaign-actions"
> >
<.button type="submit"> <.button type="submit">
Save draft Save draft
@ -219,8 +224,7 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
type="button" type="button"
phx-click="send_now" phx-click="send_now"
data-confirm={"Send this campaign to #{@subscriber_count} subscribers now?"} data-confirm={"Send this campaign to #{@subscriber_count} subscribers now?"}
class="admin-btn admin-btn-primary" class="admin-btn admin-btn-primary admin-btn-success"
style="background-color: var(--color-green-600)"
disabled={@subscriber_count == 0} disabled={@subscriber_count == 0}
> >
<.icon name="hero-paper-airplane" class="size-4" /> Send now <.icon name="hero-paper-airplane" class="size-4" /> Send now
@ -234,7 +238,7 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
</.link> </.link>
</div> </div>
<div :if={readonly?(@campaign)} class="pt-4 border-t border-base-200"> <div :if={readonly?(@campaign)} class="admin-readonly-actions">
<.link navigate={~p"/admin/newsletter?tab=campaigns"} class="admin-btn admin-btn-ghost"> <.link navigate={~p"/admin/newsletter?tab=campaigns"} class="admin-btn admin-btn-ghost">
<.icon name="hero-arrow-left" class="size-4" /> Back to campaigns <.icon name="hero-arrow-left" class="size-4" /> Back to campaigns
</.link> </.link>

View File

@ -39,19 +39,16 @@ defmodule BerrypodWeb.Admin.OrderShow do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<.header> <.header>
<.link navigate={~p"/admin/orders"} class="admin-link-subtle" style="font-weight: 400;"> <.link navigate={~p"/admin/orders"} class="admin-back-link">
&larr; Orders &larr; Orders
</.link> </.link>
<div class="admin-row" style="--admin-row-gap: 0.75rem; margin-top: 0.25rem;"> <div class="admin-product-header">
<span style="font-size: 1.5rem; font-weight: 700;">{@order.order_number}</span> <span class="admin-product-title">{@order.order_number}</span>
<.status_badge status={@order.payment_status} /> <.status_badge status={@order.payment_status} />
</div> </div>
</.header> </.header>
<div <div class="admin-grid order-detail-grid">
class="admin-grid"
style="--admin-grid-min: 20rem; --admin-grid-gap: 1.5rem; margin-top: 1.5rem;"
>
<%!-- order info --%> <%!-- order info --%>
<div class="admin-card"> <div class="admin-card">
<div class="admin-card-body"> <div class="admin-card-body">
@ -63,7 +60,7 @@ defmodule BerrypodWeb.Admin.OrderShow do
<.status_badge status={@order.payment_status} /> <.status_badge status={@order.payment_status} />
</:item> </:item>
<:item :if={@order.stripe_payment_intent_id} title="Stripe payment"> <:item :if={@order.stripe_payment_intent_id} title="Stripe payment">
<code style="font-size: 0.75rem;">{@order.stripe_payment_intent_id}</code> <code class="admin-code-sm">{@order.stripe_payment_intent_id}</code>
</:item> </:item>
<:item title="Currency">{String.upcase(@order.currency)}</:item> <:item title="Currency">{String.upcase(@order.currency)}</:item>
</.list> </.list>
@ -99,20 +96,20 @@ defmodule BerrypodWeb.Admin.OrderShow do
</:item> </:item>
</.list> </.list>
<% else %> <% else %>
<p class="admin-section-desc" style="margin-top: 0;">No shipping address provided</p> <p class="admin-section-desc admin-section-desc-flush">No shipping address provided</p>
<% end %> <% end %>
</div> </div>
</div> </div>
</div> </div>
<%!-- timeline --%> <%!-- timeline --%>
<div class="admin-card" style="margin-top: 1.5rem;"> <div class="admin-card admin-card-spaced">
<div class="admin-card-body"> <div class="admin-card-body">
<div class="admin-row" style="justify-content: space-between;"> <div class="admin-row admin-row-between">
<h3 class="admin-card-title">Timeline</h3> <h3 class="admin-card-title">Timeline</h3>
<.fulfilment_badge status={@order.fulfilment_status} /> <.fulfilment_badge status={@order.fulfilment_status} />
</div> </div>
<div class="admin-row" style="margin-top: 0.5rem; margin-bottom: 1rem;"> <div class="admin-row order-timeline-actions">
<button <button
:if={can_submit?(@order)} :if={can_submit?(@order)}
phx-click="submit_to_provider" phx-click="submit_to_provider"
@ -133,11 +130,10 @@ defmodule BerrypodWeb.Admin.OrderShow do
</div> </div>
<div <div
:if={@order.tracking_number not in [nil, ""]} :if={@order.tracking_number not in [nil, ""]}
class="admin-row" class="admin-row order-tracking"
style="margin-bottom: 1rem; font-size: 0.875rem;"
> >
<span class="admin-text-secondary"><.icon name="hero-truck-mini" class="size-4" /></span> <span class="admin-text-secondary"><.icon name="hero-truck-mini" class="size-4" /></span>
<span style="font-weight: 500;">{@order.tracking_number}</span> <span class="admin-text-medium">{@order.tracking_number}</span>
<a <a
:if={@order.tracking_url not in [nil, ""]} :if={@order.tracking_url not in [nil, ""]}
href={@order.tracking_url} href={@order.tracking_url}
@ -153,7 +149,7 @@ defmodule BerrypodWeb.Admin.OrderShow do
</div> </div>
<%!-- line items --%> <%!-- line items --%>
<div class="admin-card" style="margin-top: 1.5rem;"> <div class="admin-card admin-card-spaced">
<div class="admin-card-body"> <div class="admin-card-body">
<h3 class="admin-card-title">Items</h3> <h3 class="admin-card-title">Items</h3>
<table class="admin-table admin-table-zebra"> <table class="admin-table admin-table-zebra">
@ -161,28 +157,28 @@ defmodule BerrypodWeb.Admin.OrderShow do
<tr> <tr>
<th>Product</th> <th>Product</th>
<th>Variant</th> <th>Variant</th>
<th style="text-align: end;">Qty</th> <th class="admin-cell-end">Qty</th>
<th style="text-align: end;">Unit price</th> <th class="admin-cell-end">Unit price</th>
<th style="text-align: end;">Total</th> <th class="admin-cell-end">Total</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr :for={item <- @order.items}> <tr :for={item <- @order.items}>
<td>{item.product_name}</td> <td>{item.product_name}</td>
<td>{item.variant_title}</td> <td>{item.variant_title}</td>
<td style="text-align: end;">{item.quantity}</td> <td class="admin-cell-numeric">{item.quantity}</td>
<td style="text-align: end;">{Cart.format_price(item.unit_price)}</td> <td class="admin-cell-numeric">{Cart.format_price(item.unit_price)}</td>
<td style="text-align: end;">{Cart.format_price(item.unit_price * item.quantity)}</td> <td class="admin-cell-numeric">{Cart.format_price(item.unit_price * item.quantity)}</td>
</tr> </tr>
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colspan="4" style="text-align: end; font-weight: 500;">Subtotal</td> <td colspan="4" class="admin-cell-end admin-text-medium">Subtotal</td>
<td style="text-align: end; font-weight: 500;">{Cart.format_price(@order.subtotal)}</td> <td class="admin-cell-end admin-text-medium">{Cart.format_price(@order.subtotal)}</td>
</tr> </tr>
<tr style="font-size: 1.125rem;"> <tr class="order-total-row">
<td colspan="4" style="text-align: end; font-weight: 700;">Total</td> <td colspan="4" class="admin-cell-end admin-text-bold">Total</td>
<td style="text-align: end; font-weight: 700;">{Cart.format_price(@order.total)}</td> <td class="admin-cell-end admin-text-bold">{Cart.format_price(@order.total)}</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@ -237,7 +233,7 @@ defmodule BerrypodWeb.Admin.OrderShow do
defp order_timeline(assigns) do defp order_timeline(assigns) do
~H""" ~H"""
<div :if={@entries == []} class="admin-section-desc" style="padding-block: 1rem; margin-top: 0;"> <div :if={@entries == []} class="admin-section-desc order-timeline-empty">
No activity recorded yet. No activity recorded yet.
</div> </div>
<ol :if={@entries != []} class="admin-timeline" id="order-timeline"> <ol :if={@entries != []} class="admin-timeline" id="order-timeline">

View File

@ -46,7 +46,7 @@ defmodule BerrypodWeb.Admin.Orders do
Orders Orders
</.header> </.header>
<div class="flex gap-2 mt-6 mb-4 flex-wrap"> <div class="admin-filter-row">
<.filter_tab <.filter_tab
status="all" status="all"
label="All" label="All"
@ -98,10 +98,10 @@ defmodule BerrypodWeb.Admin.Orders do
<.admin_pagination :if={@order_count > 0} page={@pagination} patch={~p"/admin/orders"} /> <.admin_pagination :if={@order_count > 0} page={@pagination} patch={~p"/admin/orders"} />
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60"> <div :if={@order_count == 0} class="admin-empty-state">
<.icon name="hero-inbox" class="size-12 mx-auto mb-4" /> <.icon name="hero-inbox" class="admin-empty-state-icon" />
<p class="text-lg font-medium">No orders yet</p> <p class="admin-empty-state-title">No orders yet</p>
<p class="text-sm mt-1">Orders will appear here once customers check out.</p> <p class="admin-empty-state-text">Orders will appear here once customers check out.</p>
</div> </div>
""" """
end end
@ -123,41 +123,27 @@ defmodule BerrypodWeb.Admin.Orders do
]} ]}
> >
{@label} {@label}
<span :if={@count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span> <span :if={@count > 0} class="admin-badge admin-badge-sm admin-badge-count">
{@count}
</span>
</button> </button>
""" """
end end
defp status_badge(assigns) do defp status_badge(assigns) do
{bg, text, ring, icon} = {color, icon} =
case assigns.status do case assigns.status do
"paid" -> "paid" -> {"green", "hero-check-circle-mini"}
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"} "pending" -> {"amber", "hero-clock-mini"}
"failed" -> {"red", "hero-x-circle-mini"}
"pending" -> "refunded" -> {"zinc", "hero-arrow-uturn-left-mini"}
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-clock-mini"} _ -> {"zinc", "hero-question-mark-circle-mini"}
"failed" ->
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
"refunded" ->
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
"hero-arrow-uturn-left-mini"}
_ ->
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
"hero-question-mark-circle-mini"}
end end
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon) assigns = assign(assigns, color: color, icon: icon)
~H""" ~H"""
<span class={[ <span class={["admin-status-pill", "admin-status-pill-#{@color}"]}>
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
<.icon name={@icon} class="size-3" /> {@status} <.icon name={@icon} class="size-3" /> {@status}
</span> </span>
""" """
@ -168,41 +154,21 @@ defmodule BerrypodWeb.Admin.Orders do
end end
defp fulfilment_badge(assigns) do defp fulfilment_badge(assigns) do
{bg, text, ring, icon} = {color, icon} =
case assigns.status do case assigns.status do
"submitted" -> "submitted" -> {"blue", "hero-paper-airplane-mini"}
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"} "processing" -> {"amber", "hero-cog-6-tooth-mini"}
"shipped" -> {"purple", "hero-truck-mini"}
"processing" -> "delivered" -> {"green", "hero-check-circle-mini"}
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"} "failed" -> {"red", "hero-x-circle-mini"}
"cancelled" -> {"zinc", "hero-no-symbol-mini"}
"shipped" -> _ -> {"zinc", "hero-minus-circle-mini"}
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
"delivered" ->
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
"failed" ->
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
"cancelled" ->
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
"hero-no-symbol-mini"}
_ ->
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
"hero-minus-circle-mini"}
end end
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon) assigns = assign(assigns, color: color, icon: icon)
~H""" ~H"""
<span class={[ <span class={["admin-status-pill", "admin-status-pill-#{@color}"]}>
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
<.icon name={@icon} class="size-3" /> {@status} <.icon name={@icon} class="size-3" /> {@status}
</span> </span>
""" """

View File

@ -118,10 +118,7 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<.link <.link navigate={~p"/admin/pages"} class="admin-back-link">
navigate={~p"/admin/pages"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
&larr; Pages &larr; Pages
</.link> </.link>
@ -153,8 +150,7 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do
id="custom-page-form" id="custom-page-form"
phx-change="validate" phx-change="validate"
phx-submit="save" phx-submit="save"
class="mt-6 space-y-6" class="admin-form-stack admin-section"
style="max-width: 32rem;"
> >
<.input field={@form[:title]} label="Title" /> <.input field={@form[:title]} label="Title" />
<.input field={@form[:slug]} label="URL slug" /> <.input field={@form[:slug]} label="URL slug" />
@ -167,12 +163,15 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do
<.input field={@form[:published]} type="checkbox" label="Published" /> <.input field={@form[:published]} type="checkbox" label="Published" />
<.input field={@form[:show_in_nav]} type="checkbox" label="Show in navigation" /> <.input field={@form[:show_in_nav]} type="checkbox" label="Show in navigation" />
<div :if={@form[:show_in_nav].value == true} class="space-y-4 pl-6"> <div
:if={@form[:show_in_nav].value == true}
class="admin-form-sub"
>
<.input field={@form[:nav_label]} label="Nav label" /> <.input field={@form[:nav_label]} label="Nav label" />
<.input field={@form[:nav_position]} type="number" label="Nav position" /> <.input field={@form[:nav_position]} type="number" label="Nav position" />
</div> </div>
<div class="flex gap-3"> <div class="admin-row admin-row-lg">
<.button type="submit" phx-disable-with="Saving..."> <.button type="submit" phx-disable-with="Saving...">
{if @live_action == :new, do: "Create page", else: "Save settings"} {if @live_action == :new, do: "Create page", else: "Save settings"}
</.button> </.button>

View File

@ -521,10 +521,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div id="page-editor" phx-hook="EditorKeyboard" data-dirty={to_string(@dirty)}> <div id="page-editor" phx-hook="EditorKeyboard" data-dirty={to_string(@dirty)}>
<.link <.link navigate={~p"/admin/pages"} class="admin-back-link">
navigate={~p"/admin/pages"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
&larr; Pages &larr; Pages
</.link> </.link>
<.header> <.header>
@ -552,7 +549,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</button> </button>
<button <button
phx-click="undo" phx-click="undo"
class={["admin-btn admin-btn-sm admin-btn-ghost", @history == [] && "opacity-30"]} class="admin-btn admin-btn-sm admin-btn-ghost"
disabled={@history == []} disabled={@history == []}
aria-label="Undo" aria-label="Undo"
> >
@ -560,7 +557,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</button> </button>
<button <button
phx-click="redo" phx-click="redo"
class={["admin-btn admin-btn-sm admin-btn-ghost", @future == [] && "opacity-30"]} class="admin-btn admin-btn-sm admin-btn-ghost"
disabled={@future == []} disabled={@future == []}
aria-label="Redo" aria-label="Redo"
> >
@ -584,7 +581,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</button> </button>
<button <button
phx-click="save" phx-click="save"
class={["admin-btn admin-btn-sm admin-btn-primary", !@dirty && "opacity-50"]} class="admin-btn admin-btn-sm admin-btn-primary"
disabled={!@dirty} disabled={!@dirty}
> >
Save Save
@ -598,7 +595,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</div> </div>
<%!-- Status badges --%> <%!-- Status badges --%>
<div class="mt-4 flex gap-2 flex-wrap"> <div class="admin-editor-badges">
<p :if={@dirty} class="admin-badge admin-badge-warning"> <p :if={@dirty} class="admin-badge admin-badge-warning">
Unsaved changes Unsaved changes
</p> </p>

View File

@ -89,14 +89,11 @@ defmodule BerrypodWeb.Admin.ProductShow do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<.header> <.header>
<.link <.link navigate={~p"/admin/products"} class="admin-back-link">
navigate={~p"/admin/products"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
&larr; Products &larr; Products
</.link> </.link>
<div class="flex items-center gap-3 mt-1"> <div class="admin-product-header">
<span class="text-2xl font-bold">{@product.title}</span> <span class="admin-product-title">{@product.title}</span>
<.visibility_badge visible={@product.visible} /> <.visibility_badge visible={@product.visible} />
<.status_badge status={@product.status} /> <.status_badge status={@product.status} />
</div> </div>
@ -121,22 +118,26 @@ defmodule BerrypodWeb.Admin.ProductShow do
</.header> </.header>
<%!-- images + details --%> <%!-- images + details --%>
<div class="grid gap-6 mt-6 lg:grid-cols-3"> <div class="admin-product-grid">
<div class="lg:col-span-2"> <div>
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2"> <div class="admin-product-image-grid">
<div <div
:for={image <- sorted_images(@product)} :for={image <- sorted_images(@product)}
class="aspect-square rounded bg-base-200 overflow-hidden" class="admin-product-image-tile"
> >
<img <img
src={ProductImage.url(image, 400)} src={ProductImage.url(image, 400)}
alt={image.alt || @product.title} alt={image.alt || @product.title}
class="w-full h-full object-cover"
loading="lazy" loading="lazy"
/> />
</div> </div>
</div> </div>
<p :if={@product.images == []} class="text-base-content/40 text-sm">No images</p> <p
:if={@product.images == []}
class="admin-help-text"
>
No images
</p>
</div> </div>
<div class="admin-card"> <div class="admin-card">
@ -163,17 +164,19 @@ defmodule BerrypodWeb.Admin.ProductShow do
</div> </div>
<%!-- storefront controls --%> <%!-- storefront controls --%>
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6"> <div class="admin-card admin-card-spaced">
<div class="admin-card-body"> <div class="admin-card-body">
<h3 class="admin-card-title">Storefront controls</h3> <h3 class="admin-card-title">Storefront controls</h3>
<.form <.form
for={@form} for={@form}
phx-submit="save_storefront" phx-submit="save_storefront"
phx-change="validate_storefront" phx-change="validate_storefront"
class="flex flex-wrap gap-4 items-end" class="admin-filter-row-end"
> >
<label class="w-auto"> <label class="admin-filter-select">
<span class="text-xs mb-0.5">Visibility</span> <span class="admin-filter-label">
Visibility
</span>
<select <select
name="product[visible]" name="product[visible]"
class="admin-select admin-select-sm" class="admin-select admin-select-sm"
@ -193,8 +196,8 @@ defmodule BerrypodWeb.Admin.ProductShow do
</option> </option>
</select> </select>
</label> </label>
<label class="w-auto flex-1 min-w-48"> <label class="admin-filter-select-wide">
<span class="text-xs mb-0.5">Category</span> <span class="admin-filter-label">Category</span>
<input <input
type="text" type="text"
name="product[category]" name="product[category]"
@ -209,7 +212,7 @@ defmodule BerrypodWeb.Admin.ProductShow do
</div> </div>
<%!-- variants --%> <%!-- variants --%>
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6"> <div class="admin-card admin-card-spaced">
<div class="admin-card-body"> <div class="admin-card-body">
<h3 class="admin-card-title">Variants ({length(@product.variants)})</h3> <h3 class="admin-card-title">Variants ({length(@product.variants)})</h3>
<.table id="variants" rows={@product.variants}> <.table id="variants" rows={@product.variants}>
@ -225,26 +228,25 @@ defmodule BerrypodWeb.Admin.ProductShow do
else: ""} else: ""}
</:col> </:col>
<:col :let={variant} label="Available"> <:col :let={variant} label="Available">
<.icon <span
:if={variant.is_enabled && variant.is_available} :if={variant.is_enabled && variant.is_available}
name="hero-check-circle-mini" class="admin-icon-positive"
class="size-5 text-green-600" >
/> <.icon name="hero-check-circle-mini" class="size-5" />
<.icon </span>
<span
:if={!variant.is_enabled || !variant.is_available} :if={!variant.is_enabled || !variant.is_available}
name="hero-x-circle-mini" class="admin-icon-muted"
class="size-5 text-base-content/30" >
/> <.icon name="hero-x-circle-mini" class="size-5" />
</span>
</:col> </:col>
</.table> </.table>
</div> </div>
</div> </div>
<%!-- provider data --%> <%!-- provider data --%>
<div <div :if={@product.provider_connection} class="admin-card admin-card-spaced">
:if={@product.provider_connection}
class="card bg-base-100 shadow-sm border border-base-200 mt-6"
>
<div class="admin-card-body"> <div class="admin-card-body">
<h3 class="admin-card-title">Provider data</h3> <h3 class="admin-card-title">Provider data</h3>
<.list> <.list>
@ -255,7 +257,7 @@ defmodule BerrypodWeb.Admin.ProductShow do
<:item title="Status">{@product.status}</:item> <:item title="Status">{@product.status}</:item>
<:item title="Sync status">{@product.provider_connection.sync_status}</:item> <:item title="Sync status">{@product.provider_connection.sync_status}</:item>
</.list> </.list>
<div class="mt-4"> <div class="admin-section-body">
<button phx-click="resync" class="admin-btn admin-btn-outline admin-btn-sm"> <button phx-click="resync" class="admin-btn admin-btn-outline admin-btn-sm">
<.icon name="hero-arrow-path" class="size-4" /> Re-sync <.icon name="hero-arrow-path" class="size-4" /> Re-sync
</button> </button>
@ -274,47 +276,32 @@ defmodule BerrypodWeb.Admin.ProductShow do
end end
defp visibility_badge(assigns) do defp visibility_badge(assigns) do
{bg, text, ring, label} = {color, label} =
if assigns.visible do if assigns.visible do
{"bg-green-50", "text-green-700", "ring-green-600/20", "visible"} {"green", "visible"}
else else
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10", "hidden"} {"zinc", "hidden"}
end end
assigns = assign(assigns, bg: bg, text: text, ring: ring, label: label) assigns = assign(assigns, color: color, label: label)
~H""" ~H"""
<span class={[ <span class={["admin-status-pill", "admin-status-pill-#{@color}"]}>{@label}</span>
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
{@label}
</span>
""" """
end end
defp status_badge(assigns) do defp status_badge(assigns) do
{bg, text, ring} = color =
case assigns.status do case assigns.status do
"active" -> {"bg-green-50", "text-green-700", "ring-green-600/20"} "active" -> "green"
"draft" -> {"bg-amber-50", "text-amber-700", "ring-amber-600/20"} "draft" -> "amber"
"archived" -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"} _ -> "zinc"
_ -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"}
end end
assigns = assign(assigns, bg: bg, text: text, ring: ring) assigns = assign(assigns, :color, color)
~H""" ~H"""
<span class={[ <span class={["admin-status-pill", "admin-status-pill-#{@color}"]}>{@status}</span>
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
{@status}
</span>
""" """
end end

View File

@ -90,7 +90,7 @@ defmodule BerrypodWeb.Admin.Products do
<:subtitle>{@product_count} products</:subtitle> <:subtitle>{@product_count} products</:subtitle>
</.header> </.header>
<form phx-change="filter" class="flex gap-2 mt-6 mb-4 flex-wrap items-end"> <form phx-change="filter" class="admin-filter-row-end">
<.filter_select <.filter_select
:if={length(@connections) > 1} :if={length(@connections) > 1}
name="provider" name="provider"
@ -142,8 +142,8 @@ defmodule BerrypodWeb.Admin.Products do
<.product_thumbnail product={product} /> <.product_thumbnail product={product} />
</:col> </:col>
<:col :let={product} label="Product"> <:col :let={product} label="Product">
<div class="font-medium"> <div class="admin-product-name">
<.link navigate={~p"/admin/products/#{product}"} class="hover:underline"> <.link navigate={~p"/admin/products/#{product}"} class="admin-link">
{product.title} {product.title}
</.link> </.link>
</div> </div>
@ -153,7 +153,7 @@ defmodule BerrypodWeb.Admin.Products do
{product.category || ""} {product.category || ""}
</:col> </:col>
<:col :let={product} label="Price"> <:col :let={product} label="Price">
<span :if={product.on_sale} class="text-red-600 text-xs font-medium mr-1">Sale</span> <span :if={product.on_sale} class="admin-sale-tag">Sale</span>
{Cart.format_price(product.cheapest_price)} {Cart.format_price(product.cheapest_price)}
</:col> </:col>
<:col :let={product} label="Stock"> <:col :let={product} label="Stock">
@ -170,8 +170,7 @@ defmodule BerrypodWeb.Admin.Products do
aria-label={"Toggle visibility for #{product.title}"} aria-label={"Toggle visibility for #{product.title}"}
class={[ class={[
"admin-btn admin-btn-ghost admin-btn-sm", "admin-btn admin-btn-ghost admin-btn-sm",
product.visible && "text-green-600", if(product.visible, do: "admin-icon-positive", else: "admin-icon-muted")
!product.visible && "text-base-content/30"
]} ]}
> >
<.icon :if={product.visible} name="hero-eye" class="size-5" /> <.icon :if={product.visible} name="hero-eye" class="size-5" />
@ -182,10 +181,10 @@ defmodule BerrypodWeb.Admin.Products do
<.admin_pagination :if={@product_count > 0} page={@pagination} patch={~p"/admin/products"} /> <.admin_pagination :if={@product_count > 0} page={@pagination} patch={~p"/admin/products"} />
<div :if={@product_count == 0} class="text-center py-12 text-base-content/60"> <div :if={@product_count == 0} class="admin-empty-state">
<.icon name="hero-cube" class="size-12 mx-auto mb-4" /> <.icon name="hero-cube" class="admin-empty-state-icon" />
<p class="text-lg font-medium">No products yet</p> <p class="admin-empty-state-title">No products yet</p>
<p class="text-sm mt-1"> <p class="admin-empty-state-text">
<.link navigate={~p"/admin/providers"} class="admin-link"> <.link navigate={~p"/admin/providers"} class="admin-link">
Connect a provider Connect a provider
</.link> </.link>
@ -201,8 +200,8 @@ defmodule BerrypodWeb.Admin.Products do
defp filter_select(assigns) do defp filter_select(assigns) do
~H""" ~H"""
<label class="w-auto"> <label class="admin-filter-select">
<span class="text-xs mb-0.5">{@label}</span> <span class="admin-filter-label">{@label}</span>
<select name={@name} class="admin-select admin-select-sm" aria-label={@label}> <select name={@name} class="admin-select admin-select-sm" aria-label={@label}>
<option :for={{label, value} <- @options} value={value} selected={value == @value}> <option :for={{label, value} <- @options} value={value} selected={value == @value}>
{label} {label}
@ -225,10 +224,13 @@ defmodule BerrypodWeb.Admin.Products do
assigns = assign(assigns, url: url, alt: alt) assigns = assign(assigns, url: url, alt: alt)
~H""" ~H"""
<div class="w-10 h-10 rounded bg-base-200 overflow-hidden flex-shrink-0"> <div class="admin-thumbnail">
<img :if={@url} src={@url} alt={@alt} class="w-full h-full object-cover" loading="lazy" /> <img :if={@url} src={@url} alt={@alt} loading="lazy" />
<div :if={!@url} class="w-full h-full flex items-center justify-center"> <div
<.icon name="hero-photo" class="size-5 text-base-content/30" /> :if={!@url}
class="admin-thumbnail-placeholder admin-icon-muted"
>
<.icon name="hero-photo" class="size-5" />
</div> </div>
</div> </div>
""" """
@ -245,31 +247,22 @@ defmodule BerrypodWeb.Admin.Products do
assigns = assign(assigns, :label, label) assigns = assign(assigns, :label, label)
~H""" ~H"""
<span class="inline-flex items-center rounded-full bg-base-200 px-1.5 py-0.5 text-xs text-base-content/60 mt-0.5"> <span class="admin-provider-badge">{@label}</span>
{@label}
</span>
""" """
end end
defp stock_badge(assigns) do defp stock_badge(assigns) do
{bg, text, ring, label} = {color, label} =
if assigns.in_stock do if assigns.in_stock do
{"bg-green-50", "text-green-700", "ring-green-600/20", "In stock"} {"green", "In stock"}
else else
{"bg-red-50", "text-red-700", "ring-red-600/20", "Out of stock"} {"red", "Out of stock"}
end end
assigns = assign(assigns, bg: bg, text: text, ring: ring, label: label) assigns = assign(assigns, color: color, label: label)
~H""" ~H"""
<span class={[ <span class={["admin-status-pill", "admin-status-pill-#{@color}"]}>{@label}</span>
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
{@label}
</span>
""" """
end end

View File

@ -6,7 +6,7 @@
<div class="admin-form-narrow"> <div class="admin-form-narrow">
<%= if @live_action == :new do %> <%= if @live_action == :new do %>
<p class="admin-text-secondary" style="margin-bottom: 1.5rem;"> <p class="admin-section-desc">
{@provider.name} is a print-on-demand service that prints and ships products for you. {@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. Connect your account to automatically import your products into your shop.
</p> </p>

View File

@ -54,13 +54,13 @@ defmodule BerrypodWeb.Admin.Providers.Index do
defp status_indicator(assigns) do defp status_indicator(assigns) do
~H""" ~H"""
<span class={[ <span class={[
"inline-flex size-3 rounded-full", "admin-provider-dot",
cond do cond do
not @enabled -> "bg-base-content/30" not @enabled -> "admin-provider-dot-idle"
@status == "syncing" -> "bg-warning animate-pulse" @status == "syncing" -> "admin-provider-dot-syncing"
@status == "completed" -> "bg-success" @status == "completed" -> "admin-provider-dot-ok"
@status == "failed" -> "bg-error" @status == "failed" -> "admin-provider-dot-error"
true -> "bg-base-content/30" true -> "admin-provider-dot-idle"
end end
]} /> ]} />
""" """
@ -81,7 +81,7 @@ defmodule BerrypodWeb.Admin.Providers.Index do
<.icon name="hero-clock" class="size-4 inline" /> <.icon name="hero-clock" class="size-4 inline" />
Last synced {format_relative_time(@connection.last_synced_at)} Last synced {format_relative_time(@connection.last_synced_at)}
</span> </span>
<span :if={!@connection.last_synced_at} class="text-warning"> <span :if={!@connection.last_synced_at} class="admin-text-warning">
<.icon name="hero-exclamation-triangle" class="size-4 inline" /> Never synced <.icon name="hero-exclamation-triangle" class="size-4 inline" /> Never synced
</span> </span>
""" """

View File

@ -14,7 +14,7 @@
</:actions> </:actions>
</.header> </.header>
<div id="connections" phx-update="stream" class="admin-stack" style="margin-top: 1.5rem;"> <div id="connections" phx-update="stream" class="admin-stack admin-card-spaced">
<div id="connections-empty" class="hidden only:block"> <div id="connections-empty" class="hidden only:block">
<div class="admin-empty-state"> <div class="admin-empty-state">
<.icon name="hero-cube" class="admin-empty-state-icon" /> <.icon name="hero-cube" class="admin-empty-state-icon" />
@ -41,9 +41,9 @@
<div class="admin-card-body"> <div class="admin-card-body">
<div class="admin-card-row"> <div class="admin-card-row">
<div class="admin-card-content"> <div class="admin-card-content">
<div class="admin-row" style="--admin-row-gap: 0.5rem;"> <div class="admin-row">
<.status_indicator status={connection.sync_status} enabled={connection.enabled} /> <.status_indicator status={connection.sync_status} enabled={connection.enabled} />
<h3 class="admin-card-title" style="margin-bottom: 0; font-size: 1.125rem;"> <h3 class="admin-card-title admin-section-heading admin-section-desc-flush">
{provider_name(connection.provider_type)} {provider_name(connection.provider_type)}
</h3> </h3>
</div> </div>
@ -64,7 +64,7 @@
phx-click="delete" phx-click="delete"
phx-value-id={connection.id} phx-value-id={connection.id}
data-confirm={"Disconnect from #{provider_name(connection.provider_type)}? Your synced products will remain in your shop."} data-confirm={"Disconnect from #{provider_name(connection.provider_type)}? Your synced products will remain in your shop."}
class="admin-btn admin-btn-ghost admin-btn-sm text-error" class="admin-btn admin-btn-ghost admin-btn-sm admin-text-error"
> >
Disconnect Disconnect
</button> </button>

View File

@ -199,7 +199,7 @@ defmodule BerrypodWeb.Admin.Redirects do
Redirects Redirects
</.header> </.header>
<div class="flex gap-2 mt-6 mb-4 flex-wrap"> <div class="admin-filter-row">
<.tab_button <.tab_button
tab="redirects" tab="redirects"
label="Active" label="Active"
@ -261,7 +261,10 @@ defmodule BerrypodWeb.Admin.Redirects do
<td><code>{redirect.from_path}</code></td> <td><code>{redirect.from_path}</code></td>
<td><code>{redirect.to_path}</code></td> <td><code>{redirect.to_path}</code></td>
<td> <td>
<span class={"badge badge-#{source_colour(redirect.source)}"}> <span class={[
"admin-badge admin-badge-sm",
"admin-badge-#{source_colour(redirect.source)}"
]}>
{redirect.source} {redirect.source}
</span> </span>
</td> </td>
@ -315,7 +318,7 @@ defmodule BerrypodWeb.Admin.Redirects do
<td>{broken_url.recent_404_count}</td> <td>{broken_url.recent_404_count}</td>
<td>{Calendar.strftime(broken_url.first_seen_at, "%d %b %Y")}</td> <td>{Calendar.strftime(broken_url.first_seen_at, "%d %b %Y")}</td>
<td>{Calendar.strftime(broken_url.last_seen_at, "%d %b %Y")}</td> <td>{Calendar.strftime(broken_url.last_seen_at, "%d %b %Y")}</td>
<td class="flex gap-2"> <td class="admin-table-actions-row">
<button <button
phx-click="redirect_broken_url" phx-click="redirect_broken_url"
phx-value-path={broken_url.path} phx-value-path={broken_url.path}
@ -347,7 +350,7 @@ defmodule BerrypodWeb.Admin.Redirects do
defp dead_links_table(assigns) do defp dead_links_table(assigns) do
~H""" ~H"""
<div class="flex justify-end mb-4"> <div class="admin-tab-actions">
<button phx-click="check_all_links" class="admin-btn admin-btn-sm admin-btn-ghost"> <button phx-click="check_all_links" class="admin-btn admin-btn-sm admin-btn-ghost">
Check all Check all
</button> </button>
@ -370,16 +373,19 @@ defmodule BerrypodWeb.Admin.Redirects do
</thead> </thead>
<tbody id="dead-links-table" phx-update="stream"> <tbody id="dead-links-table" phx-update="stream">
<tr :for={{dom_id, dead_link} <- @streams.dead_links} id={dom_id}> <tr :for={{dom_id, dead_link} <- @streams.dead_links} id={dom_id}>
<td class="max-w-xs truncate"><code>{dead_link.url}</code></td> <td class="admin-cell-truncate truncate"><code>{dead_link.url}</code></td>
<td> <td>
<span class={"badge badge-#{dead_link_type_colour(dead_link.url_type)}"}> <span class={[
"admin-badge admin-badge-sm",
"admin-badge-#{dead_link_type_colour(dead_link.url_type)}"
]}>
{dead_link.url_type} {dead_link.url_type}
</span> </span>
</td> </td>
<td>{format_dead_link_error(dead_link)}</td> <td>{format_dead_link_error(dead_link)}</td>
<td><.dead_link_sources url={dead_link.url} /></td> <td><.dead_link_sources url={dead_link.url} /></td>
<td>{Calendar.strftime(dead_link.last_checked_at, "%d %b %Y %H:%M")}</td> <td>{Calendar.strftime(dead_link.last_checked_at, "%d %b %Y %H:%M")}</td>
<td class="flex gap-2"> <td class="admin-table-actions-row">
<button <button
phx-click="recheck_dead_link" phx-click="recheck_dead_link"
phx-value-id={dead_link.id} phx-value-id={dead_link.id}
@ -412,7 +418,7 @@ defmodule BerrypodWeb.Admin.Redirects do
defp create_form(assigns) do defp create_form(assigns) do
~H""" ~H"""
<.form for={@form} phx-submit="create_redirect" style="max-width: 32rem;"> <.form for={@form} phx-submit="create_redirect" class="admin-content-narrow">
<.input <.input
field={@form[:from_path]} field={@form[:from_path]}
label="From path" label="From path"
@ -453,7 +459,12 @@ defmodule BerrypodWeb.Admin.Redirects do
]} ]}
> >
{@label} {@label}
<span :if={@count && @count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span> <span
:if={@count && @count > 0}
class="admin-badge admin-badge-sm admin-badge-count"
>
{@count}
</span>
</button> </button>
""" """
end end
@ -477,11 +488,11 @@ defmodule BerrypodWeb.Admin.Redirects do
<% [] -> %> <% [] -> %>
<span></span> <span></span>
<% [source] -> %> <% [source] -> %>
<.link navigate={source.edit_path} class="underline">{source.label}</.link> <.link navigate={source.edit_path} class="admin-link">{source.label}</.link>
<% sources -> %> <% sources -> %>
<ul class="list-none p-0 m-0 space-y-1"> <ul class="admin-source-list">
<li :for={source <- sources}> <li :for={source <- sources}>
<.link navigate={source.edit_path} class="underline">{source.label}</.link> <.link navigate={source.edit_path} class="admin-link">{source.label}</.link>
</li> </li>
</ul> </ul>
<% end %> <% end %>

View File

@ -367,7 +367,7 @@ defmodule BerrypodWeb.Admin.Settings do
<.provider_connected provider={@provider} /> <.provider_connected provider={@provider} />
<% else %> <% else %>
<div class="admin-section-body"> <div class="admin-section-body">
<p class="admin-section-desc" style="margin-top: 0;"> <p class="admin-section-desc admin-section-desc-flush">
Connect a print-on-demand provider to import products into your shop. Connect a print-on-demand provider to import products into your shop.
</p> </p>
<div class="admin-section-body"> <div class="admin-section-body">
@ -397,7 +397,7 @@ defmodule BerrypodWeb.Admin.Settings do
No tracking pixels. One email, never more. No tracking pixels. One email, never more.
</p> </p>
<%= if @cart_recovery_enabled do %> <%= if @cart_recovery_enabled do %>
<p class="admin-section-desc" style="color: #b45309;"> <p class="admin-section-desc admin-text-warning">
Make sure your privacy policy mentions that a single recovery email may be sent, Make sure your privacy policy mentions that a single recovery email may be sent,
and that customers can unsubscribe at any time. and that customers can unsubscribe at any time.
</p> </p>
@ -419,7 +419,7 @@ defmodule BerrypodWeb.Admin.Settings do
<section class="admin-section"> <section class="admin-section">
<h2 class="admin-section-title">Account</h2> <h2 class="admin-section-title">Account</h2>
<div class="admin-stack" style="margin-top: 1rem; --admin-stack-gap: 1.5rem;"> <div class="admin-stack admin-stack-lg admin-section-body">
<.form <.form
for={@email_form} for={@email_form}
id="email_form" id="email_form"
@ -433,12 +433,12 @@ defmodule BerrypodWeb.Admin.Settings do
autocomplete="username" autocomplete="username"
required required
/> />
<div style="margin-top: 0.75rem;"> <div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Change email</.button> <.button phx-disable-with="Saving...">Change email</.button>
</div> </div>
</.form> </.form>
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 1.5rem;"> <div class="admin-separator-xl">
<.form <.form
for={@password_form} for={@password_form}
id="password_form" id="password_form"
@ -468,7 +468,7 @@ defmodule BerrypodWeb.Admin.Settings do
label="Confirm new password" label="Confirm new password"
autocomplete="new-password" autocomplete="new-password"
/> />
<div style="margin-top: 0.75rem;"> <div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Change password</.button> <.button phx-disable-with="Saving...">Change password</.button>
</div> </div>
</.form> </.form>
@ -477,10 +477,10 @@ defmodule BerrypodWeb.Admin.Settings do
</section> </section>
<%!-- Advanced --%> <%!-- Advanced --%>
<section class="admin-section" style="padding-bottom: 2.5rem;"> <section class="admin-section">
<h2 class="admin-section-title">Advanced</h2> <h2 class="admin-section-title">Advanced</h2>
<div class="admin-stack" style="margin-top: 1rem; --admin-stack-gap: 0.5rem;"> <div class="admin-stack admin-stack-sm admin-section-body">
<.link href={~p"/admin/dashboard"} class="admin-link-subtle"> <.link href={~p"/admin/dashboard"} class="admin-link-subtle">
<.icon name="hero-chart-bar" class="size-4 inline" /> System dashboard <.icon name="hero-chart-bar" class="size-4 inline" /> System dashboard
</.link> </.link>
@ -548,13 +548,13 @@ defmodule BerrypodWeb.Admin.Settings do
<%= if @connection.last_synced_at do %> <%= if @connection.last_synced_at do %>
{format_relative_time(@connection.last_synced_at)} {format_relative_time(@connection.last_synced_at)}
<% else %> <% else %>
<span style="color: #d97706;">Never</span> <span class="admin-text-warning">Never</span>
<% end %> <% end %>
</dd> </dd>
</div> </div>
</dl> </dl>
<div class="admin-cluster" style="margin-top: 1rem;"> <div class="admin-cluster admin-section-body">
<button <button
phx-click="sync" phx-click="sync"
phx-value-id={@connection.id} phx-value-id={@connection.id}
@ -578,7 +578,6 @@ defmodule BerrypodWeb.Admin.Settings do
phx-value-id={@connection.id} phx-value-id={@connection.id}
data-confirm={"Disconnect from #{@provider_label}? Your synced products will remain in your shop."} data-confirm={"Disconnect from #{@provider_label}? Your synced products will remain in your shop."}
class="admin-link-danger" class="admin-link-danger"
style="padding: 0.375rem 0.5rem;"
> >
Disconnect Disconnect
</button> </button>
@ -590,7 +589,7 @@ defmodule BerrypodWeb.Admin.Settings do
defp stripe_setup_form(assigns) do defp stripe_setup_form(assigns) do
~H""" ~H"""
<div class="admin-section-body"> <div class="admin-section-body">
<p class="admin-section-desc" style="margin-top: 0;"> <p class="admin-section-desc admin-section-desc-flush">
To accept payments, connect your Stripe account by entering your secret key. To accept payments, connect your Stripe account by entering your secret key.
You can find it in your You can find it in your
<a <a
@ -604,7 +603,7 @@ defmodule BerrypodWeb.Admin.Settings do
under Developers &rarr; API keys. under Developers &rarr; API keys.
</p> </p>
<.form for={@connect_form} phx-submit="connect_stripe" style="margin-top: 1.5rem;"> <.form for={@connect_form} phx-submit="connect_stripe" class="admin-card-spaced">
<.input <.input
field={@connect_form[:api_key]} field={@connect_form[:api_key]}
type="password" type="password"
@ -612,7 +611,7 @@ defmodule BerrypodWeb.Admin.Settings do
autocomplete="off" autocomplete="off"
placeholder="sk_test_... or sk_live_..." placeholder="sk_test_... or sk_live_..."
/> />
<p class="admin-text-tertiary" style="margin-top: 0.25rem;"> <p class="admin-help-text">
Starts with <code>sk_test_</code> (test mode) or <code>sk_live_</code> (live mode). Starts with <code>sk_test_</code> (test mode) or <code>sk_live_</code> (live mode).
This key is encrypted at rest in the database. This key is encrypted at rest in the database.
</p> </p>
@ -628,7 +627,7 @@ defmodule BerrypodWeb.Admin.Settings do
defp stripe_connected_view(assigns) do defp stripe_connected_view(assigns) do
~H""" ~H"""
<div class="admin-stack" style="margin-top: 1rem;"> <div class="admin-stack admin-section-body">
<dl class="admin-dl"> <dl class="admin-dl">
<div class="admin-dl-row"> <div class="admin-dl-row">
<dt class="admin-dl-term">API key</dt> <dt class="admin-dl-term">API key</dt>
@ -637,7 +636,7 @@ defmodule BerrypodWeb.Admin.Settings do
<div class="admin-dl-row"> <div class="admin-dl-row">
<dt class="admin-dl-term">Webhook URL</dt> <dt class="admin-dl-term">Webhook URL</dt>
<dd class="admin-dl-value"> <dd class="admin-dl-value">
<code style="font-size: 0.75rem; word-break: break-all;">{@stripe_webhook_url}</code> <code class="admin-code-break">{@stripe_webhook_url}</code>
</dd> </dd>
</div> </div>
<div class="admin-dl-row"> <div class="admin-dl-row">
@ -646,7 +645,7 @@ defmodule BerrypodWeb.Admin.Settings do
<%= if @stripe_has_signing_secret do %> <%= if @stripe_has_signing_secret do %>
<code>{@stripe_signing_secret_hint}</code> <code>{@stripe_signing_secret_hint}</code>
<% else %> <% else %>
<span style="color: #d97706;">Not set</span> <span class="admin-text-warning">Not set</span>
<% end %> <% end %>
</dd> </dd>
</div> </div>
@ -658,12 +657,12 @@ defmodule BerrypodWeb.Admin.Settings do
Stripe can't reach localhost for webhooks. For local testing, run the Stripe CLI: Stripe can't reach localhost for webhooks. For local testing, run the Stripe CLI:
</p> </p>
<pre>stripe listen --forward-to localhost:4000/webhooks/stripe</pre> <pre>stripe listen --forward-to localhost:4000/webhooks/stripe</pre>
<p style="margin-top: 0.5rem; font-size: 0.75rem; color: #b45309;"> <p class="admin-help-text admin-text-warning">
The CLI will output a signing secret starting with <code>whsec_</code>. Enter it below. The CLI will output a signing secret starting with <code>whsec_</code>. Enter it below.
</p> </p>
</div> </div>
<.form for={@secret_form} phx-submit="save_signing_secret" style="margin-top: 0.5rem;"> <.form for={@secret_form} phx-submit="save_signing_secret">
<.input <.input
field={@secret_form[:signing_secret]} field={@secret_form[:signing_secret]}
type="password" type="password"
@ -671,16 +670,15 @@ defmodule BerrypodWeb.Admin.Settings do
autocomplete="off" autocomplete="off"
placeholder="whsec_..." placeholder="whsec_..."
/> />
<div style="margin-top: 0.75rem;"> <div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Save signing secret</.button> <.button phx-disable-with="Saving...">Save signing secret</.button>
</div> </div>
</.form> </.form>
<% else %> <% else %>
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 0.75rem;"> <div class="admin-separator">
<button <button
phx-click="toggle_stripe_advanced" phx-click="toggle_stripe_advanced"
class="admin-link-subtle admin-row" class="admin-link-subtle admin-row admin-row-sm"
style="--admin-row-gap: 0.25rem;"
> >
<.icon <.icon
name={if @advanced_open, do: "hero-chevron-down-mini", else: "hero-chevron-right-mini"} name={if @advanced_open, do: "hero-chevron-down-mini", else: "hero-chevron-right-mini"}
@ -689,8 +687,8 @@ defmodule BerrypodWeb.Admin.Settings do
</button> </button>
<%= if @advanced_open do %> <%= if @advanced_open do %>
<div style="margin-top: 0.75rem;"> <div class="admin-form-actions-sm">
<p class="admin-text-tertiary" style="margin-bottom: 0.75rem;"> <p class="admin-text-tertiary">
Override the webhook signing secret if you need to use a custom endpoint or the Stripe CLI. Override the webhook signing secret if you need to use a custom endpoint or the Stripe CLI.
</p> </p>
<.form for={@secret_form} phx-submit="save_signing_secret"> <.form for={@secret_form} phx-submit="save_signing_secret">
@ -701,7 +699,7 @@ defmodule BerrypodWeb.Admin.Settings do
autocomplete="off" autocomplete="off"
placeholder="whsec_..." placeholder="whsec_..."
/> />
<div style="margin-top: 0.75rem;"> <div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Save signing secret</.button> <.button phx-disable-with="Saving...">Save signing secret</.button>
</div> </div>
</.form> </.form>
@ -710,7 +708,7 @@ defmodule BerrypodWeb.Admin.Settings do
</div> </div>
<% end %> <% end %>
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 1rem;"> <div class="admin-separator-lg">
<button <button
phx-click="disconnect_stripe" phx-click="disconnect_stripe"
data-confirm="This will remove your Stripe API key and delete the webhook endpoint. Are you sure?" data-confirm="This will remove your Stripe API key and delete the webhook endpoint. Are you sure?"

View File

@ -40,7 +40,7 @@
<!-- Header --> <!-- Header -->
<div class="theme-header"> <div class="theme-header">
<div style="flex: 1;"> <div class="admin-fill">
<h1 class="theme-title">Theme Studio</h1> <h1 class="theme-title">Theme Studio</h1>
<p class="theme-subtitle"> <p class="theme-subtitle">
One theme, infinite possibilities. Every combination is designed to work beautifully. One theme, infinite possibilities. Every combination is designed to work beautifully.
@ -50,7 +50,6 @@
type="button" type="button"
phx-click="toggle_sidebar" phx-click="toggle_sidebar"
class="theme-collapse-btn" class="theme-collapse-btn"
style="margin: -0.25rem -0.5rem 0 0;"
aria-label="Collapse sidebar" aria-label="Collapse sidebar"
aria-expanded="true" aria-expanded="true"
aria-controls="theme-sidebar" aria-controls="theme-sidebar"
@ -69,7 +68,7 @@
</div> </div>
<!-- Site Name --> <!-- Site Name -->
<div class="theme-field" style="margin-bottom: 1.5rem;"> <div class="theme-section">
<label class="theme-section-label">Shop name</label> <label class="theme-section-label">Shop name</label>
<form phx-change="update_setting" phx-value-field="site_name"> <form phx-change="update_setting" phx-value-field="site_name">
<input <input
@ -77,18 +76,17 @@
name="site_name" name="site_name"
value={@theme_settings.site_name} value={@theme_settings.site_name}
placeholder="Your shop name" placeholder="Your shop name"
class="admin-input" class="admin-input admin-input-lg"
style="padding: 0.75rem 1rem; font-size: 1rem;"
/> />
</form> </form>
</div> </div>
<!-- Branding Section --> <!-- Branding Section -->
<div class="theme-panel"> <div class="theme-panel">
<label class="theme-section-label" style="margin-bottom: 1rem;">Logo & header</label> <label class="theme-section-label">Logo & header</label>
<!-- Logo Mode Radio Cards --> <!-- Logo Mode Radio Cards -->
<div class="admin-stack" style="--admin-stack-gap: 0.5rem; margin-bottom: 1rem;"> <div class="admin-stack admin-stack-sm theme-field">
<%= for {value, title, desc} <- [ <%= for {value, title, desc} <- [
{"text-only", "Shop name only", "Your name in the heading font"}, {"text-only", "Shop name only", "Your name in the heading font"},
{"logo-text", "Logo + shop name", "Your logo image with name beside it"}, {"logo-text", "Logo + shop name", "Your logo image with name beside it"},
@ -124,7 +122,7 @@
]}> ]}>
</span> </span>
</span> </span>
<div style="flex: 1;"> <div class="admin-fill">
<div class="theme-radio-title">{title}</div> <div class="theme-radio-title">{title}</div>
<div class="admin-text-secondary">{desc}</div> <div class="admin-text-secondary">{desc}</div>
</div> </div>
@ -135,11 +133,11 @@
<!-- Logo Upload (for logo-text and logo-only modes) --> <!-- Logo Upload (for logo-text and logo-only modes) -->
<%= if @theme_settings.logo_mode in ["logo-text", "logo-only"] do %> <%= if @theme_settings.logo_mode in ["logo-text", "logo-only"] do %>
<div class="theme-subsection"> <div class="theme-subsection">
<span class="theme-slider-label" style="display: block; margin-bottom: 0.5rem;"> <span class="theme-slider-label theme-block-label">
Upload logo (SVG or PNG) Upload logo (SVG or PNG)
</span> </span>
<div class="admin-row" style="--admin-row-gap: 0.75rem;"> <div class="admin-row admin-row-lg">
<form phx-change="noop" phx-submit="noop" style="flex: 1;"> <form phx-change="noop" phx-submit="noop" class="admin-fill">
<label class="theme-upload-label"> <label class="theme-upload-label">
<span>Choose file...</span> <span>Choose file...</span>
<.live_file_input upload={@uploads.logo_upload} class="hidden" /> <.live_file_input upload={@uploads.logo_upload} class="hidden" />
@ -163,17 +161,16 @@
<form <form
phx-change="update_image_alt" phx-change="update_image_alt"
phx-value-image-id={@logo_image.id} phx-value-image-id={@logo_image.id}
style="margin-top: 0.5rem;" class="theme-subfield-sm"
> >
<label class="admin-row" style="--admin-row-gap: 0.5rem;"> <label class="admin-row">
<span class="admin-text-secondary shrink-0">Alt text</span> <span class="admin-text-secondary shrink-0">Alt text</span>
<input <input
type="text" type="text"
name="alt" name="alt"
value={@logo_image.alt || ""} value={@logo_image.alt || ""}
placeholder="Describe this image" placeholder="Describe this image"
class="admin-input admin-input-sm" class="admin-input admin-input-sm admin-fill"
style="flex: 1;"
phx-debounce="blur" phx-debounce="blur"
/> />
</label> </label>
@ -221,7 +218,7 @@
<form <form
phx-change="update_setting" phx-change="update_setting"
phx-value-field="logo_size" phx-value-field="logo_size"
style="margin-top: 0.75rem;" class="theme-subfield"
> >
<div class="theme-slider-header"> <div class="theme-slider-header">
<span class="theme-slider-label">Logo size</span> <span class="theme-slider-label">Logo size</span>
@ -234,13 +231,12 @@
value={@theme_settings.logo_size} value={@theme_settings.logo_size}
name="logo_size" name="logo_size"
class="admin-range" class="admin-range"
style="width: 100%;"
/> />
</form> </form>
<!-- SVG Recolor Toggle (only for SVG logos) --> <!-- SVG Recolor Toggle (only for SVG logos) -->
<%= if @logo_image.is_svg do %> <%= if @logo_image.is_svg do %>
<div style="margin-top: 0.75rem;"> <div class="theme-subfield">
<label class="admin-toggle-label"> <label class="admin-toggle-label">
<input <input
type="checkbox" type="checkbox"
@ -261,8 +257,7 @@
phx-change="update_color" phx-change="update_color"
phx-value-field="logo_color" phx-value-field="logo_color"
phx-hook="ColorSync" phx-hook="ColorSync"
class="theme-color-row" class="theme-color-row theme-subfield-sm"
style="margin-top: 0.5rem;"
> >
<input <input
type="color" type="color"
@ -283,12 +278,12 @@
<!-- Site Icon / Favicon --> <!-- Site Icon / Favicon -->
<div class="theme-panel"> <div class="theme-panel">
<label class="theme-section-label">Site icon</label> <label class="theme-section-label">Site icon</label>
<p class="admin-text-tertiary" style="margin-bottom: 1rem;"> <p class="admin-text-tertiary theme-field">
Your icon appears in browser tabs and on home screens. Your icon appears in browser tabs and on home screens.
</p> </p>
<!-- Use logo as icon toggle --> <!-- Use logo as icon toggle -->
<label class="admin-toggle-label" style="margin-bottom: 1rem;"> <label class="admin-toggle-label theme-field">
<input <input
type="checkbox" type="checkbox"
checked={@theme_settings.use_logo_as_icon} checked={@theme_settings.use_logo_as_icon}
@ -301,12 +296,12 @@
<!-- Icon upload (only when not using logo) --> <!-- Icon upload (only when not using logo) -->
<%= if !@theme_settings.use_logo_as_icon do %> <%= if !@theme_settings.use_logo_as_icon do %>
<div style="padding-top: 0.75rem; border-top: 1px solid var(--t-border-default);"> <div class="admin-separator">
<span class="theme-slider-label" style="display: block; margin-bottom: 0.5rem;"> <span class="theme-slider-label theme-block-label">
Upload icon (PNG or SVG, 512×512+) Upload icon (PNG or SVG, 512×512+)
</span> </span>
<div class="admin-row" style="--admin-row-gap: 0.75rem;"> <div class="admin-row admin-row-lg">
<form phx-change="noop" phx-submit="noop" style="flex: 1;"> <form phx-change="noop" phx-submit="noop" class="admin-fill">
<label class="theme-upload-label"> <label class="theme-upload-label">
<span>Choose file...</span> <span>Choose file...</span>
<.live_file_input upload={@uploads.icon_upload} class="hidden" /> <.live_file_input upload={@uploads.icon_upload} class="hidden" />
@ -369,9 +364,9 @@
<% end %> <% end %>
<!-- Short name --> <!-- Short name -->
<div class="theme-subsection" style="padding-top: 0.75rem;"> <div class="theme-subfield">
<form phx-change="update_setting" phx-value-field="favicon_short_name"> <form phx-change="update_setting" phx-value-field="favicon_short_name">
<div class="theme-slider-header" style="margin-bottom: 0.25rem;"> <div class="theme-slider-header">
<span class="theme-slider-label">Short name</span> <span class="theme-slider-label">Short name</span>
<span class="admin-text-tertiary">Home screen label</span> <span class="admin-text-tertiary">Home screen label</span>
</div> </div>
@ -387,7 +382,7 @@
</div> </div>
<!-- Icon background colour --> <!-- Icon background colour -->
<div style="margin-top: 0.75rem;"> <div class="theme-subfield">
<form <form
id="icon-bg-color-form" id="icon-bg-color-form"
phx-change="update_color" phx-change="update_color"
@ -402,8 +397,8 @@
class="theme-color-swatch theme-color-swatch-sm" class="theme-color-swatch theme-color-swatch-sm"
/> />
<div> <div>
<span class="theme-slider-label" style="display: block;">Icon background</span> <span class="theme-slider-label theme-block-label">Icon background</span>
<span class="theme-slider-value" style="font-size: 0.75rem;"> <span class="theme-slider-value">
{@theme_settings.icon_background_color} {@theme_settings.icon_background_color}
</span> </span>
</div> </div>
@ -412,7 +407,7 @@
</div> </div>
<!-- Header Background Toggle --> <!-- Header Background Toggle -->
<div style="margin-bottom: 1.5rem;"> <div class="theme-section">
<label class="admin-toggle-label"> <label class="admin-toggle-label">
<input <input
type="checkbox" type="checkbox"
@ -424,7 +419,7 @@
} }
class="admin-toggle admin-toggle-sm" class="admin-toggle admin-toggle-sm"
/> />
<span style="font-size: 0.875rem; color: color-mix(in oklch, var(--t-text-primary) 80%, transparent);"> <span class="theme-check-text">
Header background image Header background image
</span> </span>
</label> </label>
@ -433,7 +428,7 @@
<!-- Header Image Upload (only when enabled) --> <!-- Header Image Upload (only when enabled) -->
<%= if @theme_settings.header_background_enabled do %> <%= if @theme_settings.header_background_enabled do %>
<div class="theme-panel"> <div class="theme-panel">
<span class="theme-slider-label" style="display: block; margin-bottom: 0.5rem;"> <span class="theme-slider-label theme-block-label">
Upload header image Upload header image
</span> </span>
<form phx-change="noop" phx-submit="noop"> <form phx-change="noop" phx-submit="noop">
@ -461,17 +456,16 @@
<form <form
phx-change="update_image_alt" phx-change="update_image_alt"
phx-value-image-id={@header_image.id} phx-value-image-id={@header_image.id}
style="margin-top: 0.5rem;" class="theme-subfield-sm"
> >
<label class="admin-row" style="--admin-row-gap: 0.5rem;"> <label class="admin-row">
<span class="admin-text-secondary shrink-0">Alt text</span> <span class="admin-text-secondary shrink-0">Alt text</span>
<input <input
type="text" type="text"
name="alt" name="alt"
value={@header_image.alt || ""} value={@header_image.alt || ""}
placeholder="Describe this image" placeholder="Describe this image"
class="admin-input admin-input-sm" class="admin-input admin-input-sm admin-fill"
style="flex: 1;"
phx-debounce="blur" phx-debounce="blur"
/> />
</label> </label>
@ -484,7 +478,7 @@
</form> </form>
<!-- Header Image Controls --> <!-- Header Image Controls -->
<div class="admin-stack" style="--admin-stack-gap: 0.75rem; margin-top: 0.75rem;"> <div class="admin-stack admin-stack-md theme-subfield">
<form phx-change="update_setting" phx-value-field="header_zoom"> <form phx-change="update_setting" phx-value-field="header_zoom">
<div class="theme-slider-header"> <div class="theme-slider-header">
<span class="theme-slider-label">Zoom</span> <span class="theme-slider-label">Zoom</span>
@ -497,7 +491,6 @@
value={@theme_settings.header_zoom} value={@theme_settings.header_zoom}
name="header_zoom" name="header_zoom"
class="admin-range" class="admin-range"
style="width: 100%;"
/> />
</form> </form>
<form phx-change="update_setting" phx-value-field="header_position_x"> <form phx-change="update_setting" phx-value-field="header_position_x">
@ -512,7 +505,6 @@
value={@theme_settings.header_position_x} value={@theme_settings.header_position_x}
name="header_position_x" name="header_position_x"
class="admin-range" class="admin-range"
style="width: 100%;"
/> />
</form> </form>
<form phx-change="update_setting" phx-value-field="header_position_y"> <form phx-change="update_setting" phx-value-field="header_position_y">
@ -527,7 +519,6 @@
value={@theme_settings.header_position_y} value={@theme_settings.header_position_y}
name="header_position_y" name="header_position_y"
class="admin-range" class="admin-range"
style="width: 100%;"
/> />
</form> </form>
</div> </div>
@ -565,7 +556,7 @@
<% end %> <% end %>
<!-- Presets Section --> <!-- Presets Section -->
<div style="margin-bottom: 1.5rem;"> <div class="theme-section">
<label class="theme-section-label">Start with a preset</label> <label class="theme-section-label">Start with a preset</label>
<div class="theme-presets"> <div class="theme-presets">
<%= for {preset_name, description} <- @presets_with_descriptions do %> <%= for {preset_name, description} <- @presets_with_descriptions do %>
@ -586,7 +577,7 @@
</div> </div>
<!-- Accent Colors --> <!-- Accent Colors -->
<div style="margin-bottom: 1.5rem;"> <div class="theme-section">
<label class="theme-section-label">Accent colour</label> <label class="theme-section-label">Accent colour</label>
<form <form
id="accent-color-form" id="accent-color-form"
@ -607,7 +598,7 @@
</form> </form>
</div> </div>
<div style="margin-bottom: 1.5rem;"> <div class="theme-section">
<label class="theme-section-label">Hover colour</label> <label class="theme-section-label">Hover colour</label>
<form <form
id="secondary-accent-color-form" id="secondary-accent-color-form"
@ -628,7 +619,7 @@
</form> </form>
</div> </div>
<div style="margin-bottom: 1.5rem;"> <div class="theme-section">
<label class="theme-section-label">Sale colour</label> <label class="theme-section-label">Sale colour</label>
<form <form
id="sale-color-form" id="sale-color-form"
@ -668,7 +659,7 @@
</svg> </svg>
</summary> </summary>
<div style="padding-top: 1rem;"> <div class="theme-customise-body">
<!-- Typography Group --> <!-- Typography Group -->
<div class="theme-group"> <div class="theme-group">
<div class="theme-group-header"> <div class="theme-group-header">
@ -867,7 +858,7 @@
phx-value-field="announcement_bar" phx-value-field="announcement_bar"
class="admin-checkbox admin-checkbox-sm" class="admin-checkbox admin-checkbox-sm"
/> />
<span style="font-size: 0.875rem;">Announcement bar</span> <span class="theme-check-text">Announcement bar</span>
</label> </label>
</div> </div>
@ -880,13 +871,13 @@
phx-value-field="sticky_header" phx-value-field="sticky_header"
class="admin-checkbox admin-checkbox-sm" class="admin-checkbox admin-checkbox-sm"
/> />
<span style="font-size: 0.875rem;">Sticky header</span> <span class="theme-check-text">Sticky header</span>
</label> </label>
</div> </div>
</div> </div>
<!-- Shape Group --> <!-- Shape Group -->
<div style="margin-bottom: 1rem;"> <div class="theme-group-flush">
<div class="theme-group-header"> <div class="theme-group-header">
<svg <svg
class="theme-group-icon" class="theme-group-icon"
@ -1045,7 +1036,7 @@
phx-value-field="hover_image" phx-value-field="hover_image"
class="admin-checkbox admin-checkbox-sm" class="admin-checkbox admin-checkbox-sm"
/> />
<span style="font-size: 0.875rem;">Second image on hover</span> <span class="theme-check-text">Second image on hover</span>
</label> </label>
</div> </div>
@ -1058,13 +1049,13 @@
phx-value-field="show_prices" phx-value-field="show_prices"
class="admin-checkbox admin-checkbox-sm" class="admin-checkbox admin-checkbox-sm"
/> />
<span style="font-size: 0.875rem;">Show prices</span> <span class="theme-check-text">Show prices</span>
</label> </label>
</div> </div>
</div> </div>
<!-- Product Page Group --> <!-- Product Page Group -->
<div style="margin-bottom: 1rem;"> <div class="theme-group-flush">
<div class="theme-group-header"> <div class="theme-group-header">
<svg <svg
class="theme-group-icon" class="theme-group-icon"
@ -1088,7 +1079,7 @@
phx-value-field="pdp_trust_badges" phx-value-field="pdp_trust_badges"
class="admin-checkbox admin-checkbox-sm" class="admin-checkbox admin-checkbox-sm"
/> />
<span style="font-size: 0.875rem;">Trust badges</span> <span class="theme-check-text">Trust badges</span>
</label> </label>
</div> </div>
@ -1101,7 +1092,7 @@
phx-value-field="pdp_reviews" phx-value-field="pdp_reviews"
class="admin-checkbox admin-checkbox-sm" class="admin-checkbox admin-checkbox-sm"
/> />
<span style="font-size: 0.875rem;">Reviews section</span> <span class="theme-check-text">Reviews section</span>
</label> </label>
</div> </div>
@ -1114,7 +1105,7 @@
phx-value-field="pdp_related_products" phx-value-field="pdp_related_products"
class="admin-checkbox admin-checkbox-sm" class="admin-checkbox admin-checkbox-sm"
/> />
<span style="font-size: 0.875rem;">Related products</span> <span class="theme-check-text">Related products</span>
</label> </label>
</div> </div>
</div> </div>