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:
parent
22d3e36ed5
commit
ae6cf209aa
@ -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 {
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 →
|
||||
</.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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 →
|
||||
</.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
|
||||
|
||||
|
||||
@ -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"
|
||||
>
|
||||
↗
|
||||
</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 %>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
← 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">
|
||||
|
||||
@ -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>
|
||||
"""
|
||||
|
||||
@ -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">
|
||||
← 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>
|
||||
|
||||
@ -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">
|
||||
← 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>
|
||||
|
||||
@ -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">
|
||||
← 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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
"""
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %>
|
||||
|
||||
@ -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 → 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?"
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user