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-semantic.css";
/* Admin components, icons, and utilities */
/* Admin components, layout, icons, transitions */
@import "./admin/components.css";
@import "./admin/layout.css";
@import "./admin/icons.css";
@import "./admin/utilities.css";
@import "./admin/transitions.css";
/* LiveView loading state variants */
.phx-click-loading, .phx-submit-loading, .phx-change-loading {

View File

@ -252,6 +252,37 @@
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 ── */
.admin-card {
@ -619,6 +650,14 @@
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 {
display: flex;
align-items: center;
@ -2699,6 +2738,10 @@
opacity: 0.7;
}
.media-card-size {
font-size: 0.75rem;
}
.media-card-no-alt {
display: flex;
align-items: center;
@ -3081,6 +3124,114 @@
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 -- */
.admin-activity-row {
@ -3100,6 +3251,10 @@
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 {
flex: 1;
min-width: 0;
@ -3125,6 +3280,29 @@
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 ── */
/* Main content area with responsive padding */
@ -3150,6 +3328,8 @@
.admin-sidebar-header {
padding: 1rem;
border-bottom: 1px solid var(--t-border-default);
& .admin-text-secondary { margin-top: 0.125rem; }
}
/* Sidebar footer (view shop, log out) */
@ -3843,4 +4023,713 @@
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 */

View File

@ -24,6 +24,15 @@
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 */
.admin-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>
</div>
<div class="admin-alert-spacer" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
<button type="button" class="admin-alert-close" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
</div>
@ -351,7 +351,7 @@ defmodule BerrypodWeb.CoreComponents do
<td
:for={col <- @col}
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))}
</td>

View File

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

View File

@ -132,7 +132,7 @@ defmodule BerrypodWeb.Admin.Activity do
</.header>
<%!-- tabs --%>
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
<div class="admin-filter-row">
<button
phx-click="tab"
phx-value-tab="all"
@ -156,7 +156,7 @@ defmodule BerrypodWeb.Admin.Activity do
Needs attention
<span
: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}
</span>
@ -164,31 +164,36 @@ defmodule BerrypodWeb.Admin.Activity do
</div>
<%!-- category chips + search --%>
<div class="flex flex-wrap items-center gap-2 mb-4">
<.category_chip category={nil} active={@category} label="All" />
<.category_chip category="orders" active={@category} label="Orders" />
<.category_chip category="syncs" active={@category} label="Syncs" />
<.category_chip category="emails" active={@category} label="Emails" />
<.category_chip category="carts" active={@category} label="Carts" />
<div class="ml-auto">
<.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 class="admin-filter-row admin-filter-row-between">
<div class="admin-cluster">
<.category_chip category={nil} active={@category} label="All" />
<.category_chip category="orders" active={@category} label="Orders" />
<.category_chip category="syncs" active={@category} label="Syncs" />
<.category_chip category="emails" active={@category} label="Emails" />
<.category_chip category="carts" active={@category} label="Carts" />
</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>
<%!-- entries --%>
<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.
</div>
<div :for={{dom_id, entry} <- @streams.entries} id={dom_id} class="admin-activity-row">
@ -201,7 +206,7 @@ defmodule BerrypodWeb.Admin.Activity do
<.link
:if={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;
</.link>
@ -336,9 +341,9 @@ defmodule BerrypodWeb.Admin.Activity do
defp activity_icon("warning"), do: "hero-exclamation-triangle-mini"
defp activity_icon(_), do: "hero-check-circle-mini"
defp activity_icon_class("error"), do: "text-red-500"
defp activity_icon_class("warning"), do: "text-amber-500"
defp activity_icon_class(_), do: "text-green-500"
defp activity_icon_class("error"), do: "admin-activity-icon-error"
defp activity_icon_class("warning"), do: "admin-activity-icon-warning"
defp activity_icon_class(_), do: "admin-activity-icon-ok"
defp format_event_type(event_type) do
event_type

View File

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

View File

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

View File

@ -200,7 +200,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
@impl true
def render(assigns) do
~H"""
<div class="max-w-2xl">
<div class="admin-content-medium">
<.header>
Email settings
<:subtitle>
@ -211,14 +211,16 @@ defmodule BerrypodWeb.Admin.EmailSettings do
</.header>
<%= 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="flex gap-3">
<.icon name="hero-lock-closed" class="size-5 text-amber-600 shrink-0 mt-0.5" />
<div class="admin-callout-warning">
<div class="admin-callout-warning-body">
<span class="admin-callout-warning-icon">
<.icon name="hero-lock-closed" class="size-5" />
</span>
<div>
<p class="text-sm font-medium text-amber-800">
<p class="admin-callout-warning-title">
Controlled by environment variables
</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.
Remove them to configure email from this page instead.
</p>
@ -227,7 +229,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
</div>
<% end %>
<section class="mt-8">
<section class="admin-section">
<.form for={@form} phx-change="change_adapter" phx-submit="save">
<div id="email-provider-cards" phx-hook="CardRadioScroll">
<.card_radio_group
@ -244,24 +246,24 @@ defmodule BerrypodWeb.Admin.EmailSettings do
<% selected = @adapter_key == adapter.key %>
<div
id={"adapter-config-#{adapter.key}"}
class="mt-6 space-y-4"
class="admin-adapter-config"
hidden={!selected}
data-adapter={adapter.key}
>
<div>
<h3 class="text-base font-semibold">
<h3 class="admin-section-subheading">
{adapter.name}
<a
:if={adapter.url}
href={adapter.url}
target="_blank"
rel="noopener"
class="text-sm font-normal text-base-content/50 hover:text-base-content/80"
class="admin-link-subtle admin-adapter-link"
>
&nearr;
</a>
</h3>
<p class="text-sm text-base-content/60">{adapter.description}</p>
<p class="admin-section-desc">{adapter.description}</p>
</div>
<.input
name="email[from_address]"
@ -279,7 +281,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
/>
<% end %>
<%= 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}>
Save settings
</.button>
@ -288,7 +290,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
type="button"
phx-click="disconnect"
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
</button>
@ -301,17 +303,17 @@ defmodule BerrypodWeb.Admin.EmailSettings do
</section>
<%= if @email_configured do %>
<section class="mt-8 border-t border-base-200 pt-6">
<h2 class="text-lg font-semibold">Test email</h2>
<p class="mt-1 text-sm text-base-content/60">
<section class="admin-section-bordered">
<h2 class="admin-section-heading">Test email</h2>
<p class="admin-help-text">
Send a test email to <strong>{@current_scope.user.email}</strong>
to verify delivery works.
</p>
<div class="mt-4">
<div class="admin-section-body">
<button
phx-click="send_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" />
{if @sending_test, do: "Sending...", else: "Send test email"}
@ -341,7 +343,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
disabled={@disabled}
/>
<%= 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
</p>
<% end %>

View File

@ -260,7 +260,7 @@ defmodule BerrypodWeb.Admin.Media do
name="value"
value={@upload_alt}
placeholder="Alt text (recommended)"
class="admin-input flex-1"
class="admin-input admin-input-fill"
phx-debounce="200"
/>
</div>
@ -272,12 +272,12 @@ defmodule BerrypodWeb.Admin.Media do
</div>
<% end %>
<%= 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 %>
</div>
<%!-- 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">
<select name="type" class="admin-select">
<option value="" selected={is_nil(@filter_type)}>All types</option>
@ -294,7 +294,7 @@ defmodule BerrypodWeb.Admin.Media do
value={@filter_search}
phx-keyup="filter_search"
phx-debounce="300"
class="admin-input flex-1"
class="admin-input admin-input-fill"
/>
<button
phx-click="toggle_orphans"
@ -342,7 +342,7 @@ defmodule BerrypodWeb.Admin.Media do
<span class="media-card-filename" title={image.filename}>{image.filename}</span>
<div class="media-card-meta">
<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>
<span
:if={!image.alt || image.alt == ""}
@ -419,14 +419,14 @@ defmodule BerrypodWeb.Admin.Media do
<div class="media-detail-actions">
<%= if @confirm_delete do %>
<p class="text-sm text-error">
<p class="admin-error">
<%= if @selected_usages != [] do %>
This image is in use. Deleting it may break pages.
<% else %>
Are you sure?
<% end %>
</p>
<div class="flex gap-2">
<div class="admin-row">
<button phx-click="delete_image" class="admin-btn admin-btn-sm admin-btn-danger">
Yes, delete
</button>
@ -437,7 +437,7 @@ defmodule BerrypodWeb.Admin.Media do
<% else %>
<button
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
</button>

View File

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

View File

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

View File

@ -156,9 +156,9 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
</:subtitle>
</.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">
<div class="space-y-4">
<div class="admin-stack">
<.input
field={@form[:subject]}
type="text"
@ -178,14 +178,17 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
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>
to insert the unsubscribe link. This is required for GDPR compliance.
</p>
<p
: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
<code>{"{{unsubscribe_url}}"}</code>
@ -193,15 +196,17 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
</p>
<%= if @form[:body].value && @form[:body].value != "" do %>
<details class="mt-4">
<summary class="text-sm font-medium cursor-pointer">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>
<details>
<summary class="admin-preview-summary">
Preview
</summary>
<pre class="admin-preview-body">{preview_body(@form[:body].value)}</pre>
</details>
<% end %>
<div
:if={!readonly?(@campaign)}
class="flex items-center gap-3 pt-4 border-t border-base-200"
class="admin-campaign-actions"
>
<.button type="submit">
Save draft
@ -219,8 +224,7 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
type="button"
phx-click="send_now"
data-confirm={"Send this campaign to #{@subscriber_count} subscribers now?"}
class="admin-btn admin-btn-primary"
style="background-color: var(--color-green-600)"
class="admin-btn admin-btn-primary admin-btn-success"
disabled={@subscriber_count == 0}
>
<.icon name="hero-paper-airplane" class="size-4" /> Send now
@ -234,7 +238,7 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
</.link>
</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">
<.icon name="hero-arrow-left" class="size-4" /> Back to campaigns
</.link>

View File

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

View File

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

View File

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

View File

@ -521,10 +521,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
def render(assigns) do
~H"""
<div id="page-editor" phx-hook="EditorKeyboard" data-dirty={to_string(@dirty)}>
<.link
navigate={~p"/admin/pages"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
<.link navigate={~p"/admin/pages"} class="admin-back-link">
&larr; Pages
</.link>
<.header>
@ -552,7 +549,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</button>
<button
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 == []}
aria-label="Undo"
>
@ -560,7 +557,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</button>
<button
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 == []}
aria-label="Redo"
>
@ -584,7 +581,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</button>
<button
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}
>
Save
@ -598,7 +595,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</div>
<%!-- 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">
Unsaved changes
</p>

View File

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

View File

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

View File

@ -6,7 +6,7 @@
<div class="admin-form-narrow">
<%= 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.
Connect your account to automatically import your products into your shop.
</p>

View File

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

View File

@ -14,7 +14,7 @@
</:actions>
</.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 class="admin-empty-state">
<.icon name="hero-cube" class="admin-empty-state-icon" />
@ -41,9 +41,9 @@
<div class="admin-card-body">
<div class="admin-card-row">
<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} />
<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)}
</h3>
</div>
@ -64,7 +64,7 @@
phx-click="delete"
phx-value-id={connection.id}
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
</button>

View File

@ -199,7 +199,7 @@ defmodule BerrypodWeb.Admin.Redirects do
Redirects
</.header>
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
<div class="admin-filter-row">
<.tab_button
tab="redirects"
label="Active"
@ -261,7 +261,10 @@ defmodule BerrypodWeb.Admin.Redirects do
<td><code>{redirect.from_path}</code></td>
<td><code>{redirect.to_path}</code></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}
</span>
</td>
@ -315,7 +318,7 @@ defmodule BerrypodWeb.Admin.Redirects do
<td>{broken_url.recent_404_count}</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 class="flex gap-2">
<td class="admin-table-actions-row">
<button
phx-click="redirect_broken_url"
phx-value-path={broken_url.path}
@ -347,7 +350,7 @@ defmodule BerrypodWeb.Admin.Redirects do
defp dead_links_table(assigns) do
~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">
Check all
</button>
@ -370,16 +373,19 @@ defmodule BerrypodWeb.Admin.Redirects do
</thead>
<tbody id="dead-links-table" phx-update="stream">
<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>
<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}
</span>
</td>
<td>{format_dead_link_error(dead_link)}</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 class="flex gap-2">
<td class="admin-table-actions-row">
<button
phx-click="recheck_dead_link"
phx-value-id={dead_link.id}
@ -412,7 +418,7 @@ defmodule BerrypodWeb.Admin.Redirects do
defp create_form(assigns) do
~H"""
<.form for={@form} phx-submit="create_redirect" style="max-width: 32rem;">
<.form for={@form} phx-submit="create_redirect" class="admin-content-narrow">
<.input
field={@form[:from_path]}
label="From path"
@ -453,7 +459,12 @@ defmodule BerrypodWeb.Admin.Redirects do
]}
>
{@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>
"""
end
@ -477,11 +488,11 @@ defmodule BerrypodWeb.Admin.Redirects do
<% [] -> %>
<span></span>
<% [source] -> %>
<.link navigate={source.edit_path} class="underline">{source.label}</.link>
<.link navigate={source.edit_path} class="admin-link">{source.label}</.link>
<% sources -> %>
<ul class="list-none p-0 m-0 space-y-1">
<ul class="admin-source-list">
<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>
</ul>
<% end %>

View File

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

View File

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