add SEO enhancements: OG images, meta robots, FAQ block, image sitemap
All checks were successful
deploy / deploy (push) Successful in 4m59s
All checks were successful
deploy / deploy (push) Successful in 4m59s
- Per-page SEO controls: meta robots directives, focus keyword, OG image - Site-wide default OG image in admin settings - FAQ block type with FAQPage JSON-LD schema - Enhanced Organization JSON-LD with business info, contact, address - Image sitemap with product images - SEO preview panel with Google/social card mockups - SEO checklist with real-time scoring - Business info section in site editor - GSC integration scaffolding (OAuth, client, cache) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -757,6 +757,52 @@
|
|||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal-lg {
|
||||||
|
max-width: 40rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--t-surface-sunken);
|
||||||
|
|
||||||
|
& h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--t-text-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--t-surface-sunken);
|
||||||
|
color: var(--t-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Badge ── */
|
/* ── Badge ── */
|
||||||
|
|
||||||
.admin-badge {
|
.admin-badge {
|
||||||
@@ -6622,4 +6668,525 @@
|
|||||||
color: oklch(0.45 0.12 25);
|
color: oklch(0.45 0.12 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── SEO Preview ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.seo-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-preview-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-preview-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--admin-text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google search preview */
|
||||||
|
.seo-google-preview {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: oklch(0.99 0 0);
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-google-breadcrumb {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: oklch(0.35 0 0);
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-google-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: oklch(0.35 0.18 260);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-google-description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: oklch(0.35 0 0);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Social card preview */
|
||||||
|
.seo-social-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: oklch(0.99 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-social-image {
|
||||||
|
aspect-ratio: 1200 / 630;
|
||||||
|
background: oklch(0.95 0 0);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-social-image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--admin-text-faint);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-social-content {
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-social-domain {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--admin-text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-social-title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--admin-text);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-social-description {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Character counts */
|
||||||
|
.seo-char-counts {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-char-count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-char-label {
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-char-value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--admin-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-char-indicator {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: oklch(0.85 0.15 145);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-char-count[data-status="warning"] .seo-char-indicator {
|
||||||
|
background: oklch(0.75 0.15 85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-char-count[data-status="error"] .seo-char-indicator {
|
||||||
|
background: oklch(0.65 0.2 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SEO preview section in page settings */
|
||||||
|
.page-settings-seo-preview {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-settings-seo-summary {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--admin-text-soft);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "▸";
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-settings-seo-preview[open] .page-settings-seo-summary::after {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-settings-seo-content {
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── OG Image Picker ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.page-settings-og-image {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-settings-og-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-settings-og-thumb {
|
||||||
|
width: 120px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--t-surface-sunken);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-settings-og-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.og-picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.og-picker-item {
|
||||||
|
background: none;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.og-picker-item:hover {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.og-picker-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SEO Checklist ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.seo-checklist {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-score {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: oklch(0.98 0 0);
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-score[data-level="good"] {
|
||||||
|
background: oklch(0.97 0.03 145);
|
||||||
|
border-color: oklch(0.85 0.08 145);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-score[data-level="ok"] {
|
||||||
|
background: oklch(0.97 0.03 85);
|
||||||
|
border-color: oklch(0.85 0.08 85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-score[data-level="poor"] {
|
||||||
|
background: oklch(0.97 0.03 25);
|
||||||
|
border-color: oklch(0.85 0.08 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-score-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-score[data-level="good"] .seo-score-value {
|
||||||
|
color: oklch(0.45 0.15 145);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-score[data-level="ok"] .seo-score-value {
|
||||||
|
color: oklch(0.45 0.15 85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-score[data-level="poor"] .seo-score-value {
|
||||||
|
color: oklch(0.45 0.15 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-score-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-checks {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-check {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 0.25rem 0.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--admin-border);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-check-icon {
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
align-self: start;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-check[data-status="pass"] .seo-check-icon {
|
||||||
|
color: oklch(0.55 0.15 145);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-check[data-status="fail"] .seo-check-icon {
|
||||||
|
color: oklch(0.55 0.18 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-check[data-status="warning"] .seo-check-icon {
|
||||||
|
color: oklch(0.55 0.15 85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-check-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--admin-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seo-check-hint {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Google Search Console dashboard ── */
|
||||||
|
|
||||||
|
.gsc-dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-card {
|
||||||
|
background: var(--admin-surface);
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
|
||||||
|
h2, h3 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--admin-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-card-connect {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
max-width: 28rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-card-icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--admin-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-not-configured {
|
||||||
|
background: oklch(0.95 0.02 85);
|
||||||
|
border: 1px solid oklch(0.85 0.05 85);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: oklch(0.35 0.1 85);
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: oklch(0.98 0.01 85);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-site-selector {
|
||||||
|
background: var(--admin-surface);
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-site-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-site-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--admin-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-data-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-card-description {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--admin-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
color: var(--admin-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--admin-surface-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-th-num,
|
||||||
|
.gsc-num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-query {
|
||||||
|
max-width: 16rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-page {
|
||||||
|
max-width: 12rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-loading,
|
||||||
|
.gsc-no-site {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gsc-error {
|
||||||
|
color: oklch(0.55 0.18 25);
|
||||||
|
}
|
||||||
|
|
||||||
} /* @layer admin */
|
} /* @layer admin */
|
||||||
|
|||||||
@@ -3658,3 +3658,77 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── FAQ section ── */
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.faq-section {
|
||||||
|
padding-block: var(--t-space-section, 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-title {
|
||||||
|
font-family: var(--t-font-heading);
|
||||||
|
font-weight: var(--t-heading-weight);
|
||||||
|
font-size: var(--t-text-2xl, 1.5rem);
|
||||||
|
letter-spacing: var(--t-heading-tracking);
|
||||||
|
color: var(--t-text-primary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
border-top: 1px solid var(--t-border-subtle);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 1px solid var(--t-border-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-question {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 1rem 0;
|
||||||
|
font-size: var(--t-text-base, 1rem);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--t-text-primary);
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
&::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "+";
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--t-text-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item[open] .faq-question::after {
|
||||||
|
content: "−";
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-answer {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
color: var(--t-text-secondary);
|
||||||
|
font-size: var(--t-text-base, 1rem);
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
+ p {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
385
docs/PROJECT-OVERVIEW.md
Normal file
385
docs/PROJECT-OVERVIEW.md
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
# Berrypod: Project Overview
|
||||||
|
|
||||||
|
> Generated April 2026. For current status, see [PROGRESS.md](../PROGRESS.md).
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Framework | Phoenix 1.8, LiveView 1.1 |
|
||||||
|
| Language | Elixir 1.19, OTP 28 |
|
||||||
|
| Database | SQLite (via Ecto + ecto_sqlite3) |
|
||||||
|
| HTTP Server | Bandit |
|
||||||
|
| Background Jobs | Oban 2.19 |
|
||||||
|
| Payments | Stripe |
|
||||||
|
| Image Processing | Image (VIPS-based) |
|
||||||
|
| Asset Build | esbuild |
|
||||||
|
| Testing | ExUnit, Mox, LazyHTML |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── berrypod/ # Core business contexts
|
||||||
|
│ ├── accounts/ # User auth, TOTP
|
||||||
|
│ ├── products/ # Products, variants, provider connections
|
||||||
|
│ ├── orders/ # Orders, line items, abandoned carts
|
||||||
|
│ ├── media/ # Image upload, optimization
|
||||||
|
│ ├── pages/ # CMS pages, block editor
|
||||||
|
│ ├── theme/ # CSS generation, presets
|
||||||
|
│ ├── settings/ # Site configuration
|
||||||
|
│ ├── analytics/ # Privacy-first tracking
|
||||||
|
│ ├── providers/ # Printify/Printful abstraction
|
||||||
|
│ ├── shipping/ # Rates, country detection
|
||||||
|
│ ├── newsletter/ # Subscribers, campaigns
|
||||||
|
│ ├── reviews/ # Product reviews
|
||||||
|
│ ├── activity_log/ # Event logging
|
||||||
|
│ └── redirects/ # URL redirects, 404 tracking
|
||||||
|
└── berrypod_web/ # Web layer
|
||||||
|
├── live/admin/ # Admin LiveViews
|
||||||
|
├── live/shop/ # Shop LiveViews
|
||||||
|
├── components/ # UI components
|
||||||
|
├── controllers/ # HTTP controllers
|
||||||
|
└── plugs/ # Request middleware
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Business Contexts
|
||||||
|
|
||||||
|
### Products (`lib/berrypod/products/`)
|
||||||
|
- Products synced from POD providers (Printify/Printful)
|
||||||
|
- Variants with pricing, cost, availability tracking
|
||||||
|
- Image optimization pipeline (WebP conversion, AVIF/JPEG variants at 400/800/1200px)
|
||||||
|
- Denormalized fields: `cheapest_price`, `in_stock`, `on_sale`
|
||||||
|
- Status flow: draft → active → archived → discontinued
|
||||||
|
|
||||||
|
### Orders (`lib/berrypod/orders/`)
|
||||||
|
- Order numbers: `SS-YYMMDD-XXXX` format
|
||||||
|
- Payment tracking: pending → paid → failed → refunded
|
||||||
|
- Fulfilment: unfulfilled → submitted → processing → shipped → delivered
|
||||||
|
- Abandoned cart recovery (24hr inactivity triggers)
|
||||||
|
- Stripe session/payment intent tracking
|
||||||
|
|
||||||
|
### Media & Images (`lib/berrypod/media/`, `lib/berrypod/images/`)
|
||||||
|
- BLOB storage in SQLite
|
||||||
|
- Lossless WebP conversion on upload (26-41% smaller)
|
||||||
|
- Dominant color extraction for header images
|
||||||
|
- Async variant generation via Oban workers
|
||||||
|
- Usage tracking across products, pages, themes
|
||||||
|
|
||||||
|
### Pages (`lib/berrypod/pages/`)
|
||||||
|
- 14 system pages + unlimited custom CMS pages
|
||||||
|
- Block-based editor with **26 block types** (hero, text, gallery, grid, testimonials, etc.)
|
||||||
|
- ETS caching with DB fallback
|
||||||
|
- Auto-redirect creation when URLs change
|
||||||
|
- Navigation menu integration
|
||||||
|
|
||||||
|
### Theme (`lib/berrypod/theme/`)
|
||||||
|
- **3-layer CSS architecture:**
|
||||||
|
1. Primitives (CSS custom properties)
|
||||||
|
2. Attributes (theme-specific rules)
|
||||||
|
3. Semantic (component styles)
|
||||||
|
- 8 presets: Gallery, Studio, Boutique, etc.
|
||||||
|
- Instant switching via CSS variable injection (no reload)
|
||||||
|
- WCAG contrast checking
|
||||||
|
|
||||||
|
### Analytics (`lib/berrypod/analytics/`)
|
||||||
|
- Privacy-first: no cookies, no personal data, GDPR-friendly
|
||||||
|
- Visitor hashing with daily-rotating salt
|
||||||
|
- Event types: pageview, product_view, add_to_cart, checkout_start, purchase
|
||||||
|
- Metrics: unique visitors, bounce rate, top pages, sources, countries, devices
|
||||||
|
- E-commerce funnel tracking with revenue
|
||||||
|
|
||||||
|
### Providers (`lib/berrypod/providers/`)
|
||||||
|
- Behaviour-based abstraction layer
|
||||||
|
- **Printify**: 800+ products from 90+ print providers
|
||||||
|
- **Printful**: In-house production with warehousing
|
||||||
|
- Operations: test connection, fetch products, submit orders, track status, fetch shipping rates
|
||||||
|
|
||||||
|
### Reviews (`lib/berrypod/reviews/`)
|
||||||
|
- 1-5 star ratings with text + images (up to 3)
|
||||||
|
- Verified purchase badge
|
||||||
|
- Moderation workflow: pending → approved/rejected
|
||||||
|
- Rating denormalization to products
|
||||||
|
|
||||||
|
### Newsletter (`lib/berrypod/newsletter/`)
|
||||||
|
- Double opt-in with confirmation emails
|
||||||
|
- Plain text only (no tracking pixels)
|
||||||
|
- Campaign scheduling and bulk sending
|
||||||
|
- Global suppression list
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema (26 Schemas)
|
||||||
|
|
||||||
|
**Core entities:**
|
||||||
|
- `User`, `UserToken` - Authentication
|
||||||
|
- `Product`, `ProductImage`, `ProductVariant` - Catalog
|
||||||
|
- `ProviderConnection` - POD credentials (encrypted)
|
||||||
|
- `Order`, `OrderItem`, `AbandonedCart` - Commerce
|
||||||
|
- `Page`, `NavItem`, `SocialLink` - Content
|
||||||
|
- `Image`, `FaviconVariant` - Media
|
||||||
|
- `ShippingRate` - Rates by country/provider
|
||||||
|
- `Review` - Product reviews
|
||||||
|
- `Newsletter.Subscriber`, `Newsletter.Campaign` - Email
|
||||||
|
- `Analytics.Event` - Tracking
|
||||||
|
- `ActivityLog.Entry` - System events
|
||||||
|
- `Redirects.Redirect`, `BrokenUrl`, `DeadLink` - URLs
|
||||||
|
- `Setting` - Configuration (JSON, encrypted)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background Jobs (Oban)
|
||||||
|
|
||||||
|
### Scheduled (cron)
|
||||||
|
|
||||||
|
| Schedule | Worker | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| Every 30min | `FulfilmentStatusWorker` | Poll provider for order updates |
|
||||||
|
| Every 6hr | `ScheduledSyncWorker` | Product sync |
|
||||||
|
| Daily 3am | `RetentionWorker` | Delete old analytics |
|
||||||
|
| Daily 3:30am | `DeadLinkCheckerWorker` | Scan for broken links |
|
||||||
|
| Every 5min | `ScheduledCampaignWorker` | Send scheduled campaigns |
|
||||||
|
|
||||||
|
### On-demand
|
||||||
|
|
||||||
|
- `ProductSyncWorker` - Sync products from provider
|
||||||
|
- `ImageDownloadWorker` - Download product images
|
||||||
|
- `OptimizeWorker` - Generate image variants
|
||||||
|
- `OrderSubmissionWorker` - Submit to fulfilment
|
||||||
|
- `ReviewRequestWorker` - Send review request after delivery
|
||||||
|
- `CampaignSendWorker` - Newsletter campaigns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Layer
|
||||||
|
|
||||||
|
### Admin LiveViews (`/admin/*`)
|
||||||
|
- Dashboard, Analytics, Orders, Products, Providers
|
||||||
|
- Settings, Media, Pages, Newsletter, Reviews
|
||||||
|
- Activity log, Backup, Redirects
|
||||||
|
|
||||||
|
### Shop LiveViews (public)
|
||||||
|
- Home, Collections, Products, Cart, Checkout
|
||||||
|
- Contact (with order lookup), Search, Review forms
|
||||||
|
- Custom CMS pages via catch-all route `/:slug`
|
||||||
|
|
||||||
|
### Key Routes
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `/` | Shop home |
|
||||||
|
| `/collections/:slug` | Category browsing |
|
||||||
|
| `/products/:id` | Product detail |
|
||||||
|
| `/cart` | Shopping cart |
|
||||||
|
| `/admin` | Admin dashboard |
|
||||||
|
| `/admin/orders` | Order management |
|
||||||
|
| `/admin/pages` | Page editor |
|
||||||
|
| `/admin/media` | Media library |
|
||||||
|
| `/admin/analytics` | Analytics dashboard |
|
||||||
|
| `/admin/theme` | Theme editor |
|
||||||
|
| `/:slug` | Custom pages (catch-all) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Asset Pipeline
|
||||||
|
|
||||||
|
**No Tailwind** - Hand-written CSS with:
|
||||||
|
- `@layer` for cascade organization
|
||||||
|
- Native CSS nesting
|
||||||
|
- `oklch()` color function
|
||||||
|
- CSS custom properties for theming
|
||||||
|
|
||||||
|
**Bundles:**
|
||||||
|
- `berrypod` - Main JS
|
||||||
|
- `berrypod_shop_css` - Storefront styles
|
||||||
|
- `berrypod_admin_css` - Admin styles
|
||||||
|
- `berrypod_theme_css` - Dynamic theme CSS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### For Shop Owners
|
||||||
|
- Product sync from Printify/Printful with image optimization
|
||||||
|
- Block-based CMS page builder (26+ block types)
|
||||||
|
- Live theme editor with 8 presets
|
||||||
|
- Privacy-first analytics (no cookies, GDPR-friendly)
|
||||||
|
- Order management with fulfilment tracking
|
||||||
|
- Abandoned cart recovery emails
|
||||||
|
- Newsletter with double opt-in
|
||||||
|
- Product review system with moderation
|
||||||
|
- Activity log for system events
|
||||||
|
|
||||||
|
### For Customers
|
||||||
|
- Product browsing with collections/filters
|
||||||
|
- Session-based shopping cart
|
||||||
|
- Stripe checkout
|
||||||
|
- Order lookup via email
|
||||||
|
- Review submissions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security & Performance
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Vault encryption for secrets
|
||||||
|
- CSRF protection, secure headers
|
||||||
|
- TOTP two-factor auth for admin
|
||||||
|
- Webhook signature verification (Stripe, Printify, Printful)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- ETS caching (settings, theme CSS, pages, redirects)
|
||||||
|
- Streams for LiveView collections
|
||||||
|
- Batch analytics flushing
|
||||||
|
- Image variants with lazy generation
|
||||||
|
- Pagination throughout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outstanding Work
|
||||||
|
|
||||||
|
### Active Plans (Ready to Implement)
|
||||||
|
|
||||||
|
#### 1. Profit-Aware Pricing & Sales ([plan](plans/profit-aware-pricing.md)) ~16h
|
||||||
|
|
||||||
|
| Task | Est | Status |
|
||||||
|
|------|-----|--------|
|
||||||
|
| Fix Printful cost sync (catalog API cross-reference) | 45m | planned |
|
||||||
|
| Cost snapshot on orders (`unit_cost`, `gross_profit`) | 1.5h | planned |
|
||||||
|
| Exact Stripe fees from Balance Transaction API | 45m | planned |
|
||||||
|
| Tax toggle + Stripe Tax integration | 1.5h | planned |
|
||||||
|
| Admin profit dashboard (margins, P&L) | 3h | planned |
|
||||||
|
| Profit-aware price editor (live margin display) | 2h | planned |
|
||||||
|
| Sales & promotions (%, fixed, scoped, scheduled) | 3h | planned |
|
||||||
|
| Margin guard (prevent profit-killing discounts) | 1h | planned |
|
||||||
|
| Announcement bar for active sales | 1.5h | planned |
|
||||||
|
|
||||||
|
#### 2. Competitive Gaps - Phase 1: Core Commerce ([plan](plans/competitive-gaps.md)) ~17h
|
||||||
|
|
||||||
|
| Task | Est | Status |
|
||||||
|
|------|-----|--------|
|
||||||
|
| Customer authentication schema | 2h | planned |
|
||||||
|
| Customer auth flows (login, register, reset) | 3h | planned |
|
||||||
|
| Link orders to customers | 1.5h | planned |
|
||||||
|
| Customer account dashboard | 2h | planned |
|
||||||
|
| Saved addresses | 1.5h | planned |
|
||||||
|
| Guest checkout linking | 1h | planned |
|
||||||
|
| PayPal SDK integration | 2h | planned |
|
||||||
|
| PayPal checkout flow | 3h | planned |
|
||||||
|
| PayPal webhooks | 1.5h | planned |
|
||||||
|
|
||||||
|
#### 3. Competitive Gaps - Phase 2: Retention & Growth ~14h
|
||||||
|
|
||||||
|
| Task | Est | Status |
|
||||||
|
|------|-----|--------|
|
||||||
|
| Returns schema | 1.5h | planned |
|
||||||
|
| Return request flow | 2h | planned |
|
||||||
|
| Return admin | 2h | planned |
|
||||||
|
| Return policy settings | 1h | planned |
|
||||||
|
| Email sequence schema | 2h | planned |
|
||||||
|
| Sequence triggers & sending | 3h | planned |
|
||||||
|
| Sequence admin | 2h | planned |
|
||||||
|
| Customer data export (GDPR) | 1.5h | planned |
|
||||||
|
| Customer data deletion (GDPR) | 2h | planned |
|
||||||
|
|
||||||
|
#### 4. Competitive Gaps - Phase 3: Scale ~7h
|
||||||
|
|
||||||
|
| Task | Est | Status |
|
||||||
|
|------|-----|--------|
|
||||||
|
| Blog post type | 3h | planned |
|
||||||
|
| Staff accounts & RBAC | 4h | planned |
|
||||||
|
|
||||||
|
#### 5. SEO Enhancements ([plan](plans/seo-enhancements.md)) ~21h
|
||||||
|
|
||||||
|
| Task | Est | Status |
|
||||||
|
|------|-----|--------|
|
||||||
|
| Per-page noindex/nofollow + meta descriptions | 2h | planned |
|
||||||
|
| Enhanced Organization schema | 2h | planned |
|
||||||
|
| Image sitemap entries | 1h | planned |
|
||||||
|
| SEO preview panel (Google + social cards) | 4h | planned |
|
||||||
|
| Focus keyword & SEO score/checklist | 4h | planned |
|
||||||
|
| FAQ block with FAQPage schema | 2h | planned |
|
||||||
|
| Google Search Console OAuth integration | 6h | planned |
|
||||||
|
|
||||||
|
#### 6. Draft-then-Publish Workflow ([plan](plans/draft-publish-workflow.md)) ~31h
|
||||||
|
|
||||||
|
| Phase | Description | Est |
|
||||||
|
|-------|-------------|-----|
|
||||||
|
| 0 | Site-level publishing (coming soon → live) | 1.5h |
|
||||||
|
| 1 | Page versions and drafts (auto-save, publish/discard) | 7h |
|
||||||
|
| 2 | Theme drafts | 4h |
|
||||||
|
| 3 | Settings drafts | 4h |
|
||||||
|
| 4 | Version history and rollback (diff view, history panel) | 5.5h |
|
||||||
|
| 5 | Image soft delete and trash (usage check, restore, auto-purge) | 4.5h |
|
||||||
|
| 6 | Polish and pruning (conflict handling, retention worker) | 4.25h |
|
||||||
|
|
||||||
|
Key features: auto-save drafts, explicit publish, version history with rollback, image trash/recycle bin, tiered version retention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Platform/Business Items
|
||||||
|
|
||||||
|
| Task | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Platform/marketing site (brochure, pricing, sign-up) | planned |
|
||||||
|
| Separation of platform site vs AGPL open source core | planned |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Production Hardening
|
||||||
|
|
||||||
|
| Item | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Litestream / SQLite replication | Continuous backup to S3, point-in-time recovery |
|
||||||
|
| End-to-end & accessibility tests | Wallaby browser tests, WCAG 2.1 AA |
|
||||||
|
| Security monitoring | Paraxial.io for runtime security, bot detection, rate limiting |
|
||||||
|
| AGPL licensing | LICENSE file, contribution guidelines, release process |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| Multiple print providers | Route products to different providers based on cost/type |
|
||||||
|
| Product page improvements | Pre-checkout validation, cost monitoring, better gallery |
|
||||||
|
| Editable email templates | Admin UI for customizing transactional emails |
|
||||||
|
| Hosted platform infrastructure | Multi-tenancy, OAuth connect for providers/payments |
|
||||||
|
| Migration & export | Shopify/WooCommerce import, data export |
|
||||||
|
| Internationalisation | Multi-language (Gettext), currency formatting, RTL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary by Priority
|
||||||
|
|
||||||
|
**High priority (core commerce):**
|
||||||
|
1. Customer accounts + PayPal (~17h)
|
||||||
|
2. Profit-aware pricing + sales (~16h)
|
||||||
|
|
||||||
|
**Medium priority (polish):**
|
||||||
|
3. SEO enhancements (~21h)
|
||||||
|
4. Draft-then-publish workflow (~31h)
|
||||||
|
5. Returns system (~6.5h)
|
||||||
|
6. Email sequences (~7h)
|
||||||
|
|
||||||
|
**Lower priority (scale):**
|
||||||
|
7. Blog post type (3h)
|
||||||
|
8. Staff accounts & RBAC (4h)
|
||||||
|
9. Platform site (TBD)
|
||||||
|
|
||||||
|
**Total estimated remaining:** ~100-120h of planned work, plus the larger platform vision items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Philosophy
|
||||||
|
|
||||||
|
1. **"One theme, infinite variations"** — one solid foundation with curated customisation
|
||||||
|
2. **Constrained creativity** — limit choices to prevent poor design outcomes
|
||||||
|
3. **No professional photography required** — works with product mockups
|
||||||
|
4. **Mobile-first** — all features work on touch devices
|
||||||
|
5. **Ethical design** — no dark patterns or fake urgency
|
||||||
|
6. **Privacy-first** — cookie-free analytics, GDPR-compliant cart recovery, no tracking pixels
|
||||||
@@ -46,7 +46,9 @@ defmodule Berrypod.Application do
|
|||||||
# Page definition cache - loads page block lists into ETS
|
# Page definition cache - loads page block lists into ETS
|
||||||
Berrypod.Pages.PageCache,
|
Berrypod.Pages.PageCache,
|
||||||
# URL routes cache - custom slugs and prefixes
|
# URL routes cache - custom slugs and prefixes
|
||||||
BerrypodWeb.R
|
BerrypodWeb.R,
|
||||||
|
# Google Search Console data cache
|
||||||
|
Berrypod.GSC.Cache
|
||||||
]
|
]
|
||||||
|
|
||||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
|
|||||||
119
lib/berrypod/gsc/cache.ex
Normal file
119
lib/berrypod/gsc/cache.ex
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
defmodule Berrypod.GSC.Cache do
|
||||||
|
@moduledoc """
|
||||||
|
ETS-backed cache for Google Search Console data.
|
||||||
|
|
||||||
|
Caches query and page data fetched from GSC to avoid rate limits
|
||||||
|
and provide fast access for the dashboard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
@table_name :gsc_cache
|
||||||
|
@default_ttl :timer.hours(6)
|
||||||
|
|
||||||
|
# Client API
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets cached top queries data.
|
||||||
|
|
||||||
|
Returns `{:ok, data, updated_at}` if found and not expired, `:miss` otherwise.
|
||||||
|
"""
|
||||||
|
def get_top_queries do
|
||||||
|
get(:top_queries)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets cached top pages data.
|
||||||
|
|
||||||
|
Returns `{:ok, data, updated_at}` if found and not expired, `:miss` otherwise.
|
||||||
|
"""
|
||||||
|
def get_top_pages do
|
||||||
|
get(:top_pages)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Caches top queries data.
|
||||||
|
"""
|
||||||
|
def put_top_queries(data) do
|
||||||
|
put(:top_queries, data)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Caches top pages data.
|
||||||
|
"""
|
||||||
|
def put_top_pages(data) do
|
||||||
|
put(:top_pages, data)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns all cached data if not expired.
|
||||||
|
|
||||||
|
Returns `{:ok, %{top_queries: [...], top_pages: [...], updated_at: DateTime.t()}}` or `:miss`.
|
||||||
|
"""
|
||||||
|
def get_all do
|
||||||
|
with {:ok, queries, updated_at} <- get_top_queries(),
|
||||||
|
{:ok, pages, _} <- get_top_pages() do
|
||||||
|
{:ok, %{top_queries: queries, top_pages: pages, updated_at: updated_at}}
|
||||||
|
else
|
||||||
|
_ -> :miss
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Invalidates all cached data.
|
||||||
|
"""
|
||||||
|
def invalidate do
|
||||||
|
GenServer.call(__MODULE__, :invalidate)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the timestamp of the last cache update.
|
||||||
|
"""
|
||||||
|
def last_updated do
|
||||||
|
case :ets.lookup(@table_name, :top_queries) do
|
||||||
|
[{:top_queries, _data, updated_at, _expires_at}] -> updated_at
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Private helpers
|
||||||
|
|
||||||
|
defp get(key) do
|
||||||
|
case :ets.lookup(@table_name, key) do
|
||||||
|
[{^key, data, updated_at, expires_at}] ->
|
||||||
|
if DateTime.compare(DateTime.utc_now(), expires_at) == :lt do
|
||||||
|
{:ok, data, updated_at}
|
||||||
|
else
|
||||||
|
:miss
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:miss
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put(key, data) do
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
expires_at = DateTime.add(now, @default_ttl, :millisecond)
|
||||||
|
:ets.insert(@table_name, {key, data, now, expires_at})
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# GenServer callbacks
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_opts) do
|
||||||
|
table = :ets.new(@table_name, [:set, :named_table, :public, read_concurrency: true])
|
||||||
|
{:ok, %{table: table}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:invalidate, _from, state) do
|
||||||
|
:ets.delete_all_objects(@table_name)
|
||||||
|
{:reply, :ok, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
121
lib/berrypod/gsc/client.ex
Normal file
121
lib/berrypod/gsc/client.ex
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
defmodule Berrypod.GSC.Client do
|
||||||
|
@moduledoc """
|
||||||
|
Google Search Console API client.
|
||||||
|
|
||||||
|
Provides functions to query search analytics data from GSC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Berrypod.GSC.OAuth
|
||||||
|
|
||||||
|
@api_base "https://searchconsole.googleapis.com/webmasters/v3"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists all sites the authenticated user has access to.
|
||||||
|
|
||||||
|
Returns `{:ok, sites}` where sites is a list of maps with site_url keys.
|
||||||
|
"""
|
||||||
|
def list_sites do
|
||||||
|
with {:ok, token} <- OAuth.get_valid_token() do
|
||||||
|
case Req.get("#{@api_base}/sites", headers: auth_headers(token)) do
|
||||||
|
{:ok, %{status: 200, body: %{"siteEntry" => sites}}} ->
|
||||||
|
{:ok, Enum.map(sites, &Map.take(&1, ["siteUrl", "permissionLevel"]))}
|
||||||
|
|
||||||
|
{:ok, %{status: 200, body: _}} ->
|
||||||
|
{:ok, []}
|
||||||
|
|
||||||
|
{:ok, %{status: status, body: body}} ->
|
||||||
|
{:error, {:api_error, status, body}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Queries search analytics for the given site URL.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
- `:start_date` - Start date (default: 28 days ago)
|
||||||
|
- `:end_date` - End date (default: yesterday)
|
||||||
|
- `:dimensions` - List of dimensions (default: ["query"])
|
||||||
|
- `:row_limit` - Max rows to return (default: 25)
|
||||||
|
|
||||||
|
Returns `{:ok, rows}` where each row contains metrics for the dimension values.
|
||||||
|
"""
|
||||||
|
def query_search_analytics(site_url, opts \\ []) do
|
||||||
|
with {:ok, token} <- OAuth.get_valid_token() do
|
||||||
|
end_date = opts[:end_date] || Date.add(Date.utc_today(), -1)
|
||||||
|
start_date = opts[:start_date] || Date.add(end_date, -27)
|
||||||
|
dimensions = opts[:dimensions] || ["query"]
|
||||||
|
row_limit = opts[:row_limit] || 25
|
||||||
|
|
||||||
|
body = %{
|
||||||
|
startDate: Date.to_iso8601(start_date),
|
||||||
|
endDate: Date.to_iso8601(end_date),
|
||||||
|
dimensions: dimensions,
|
||||||
|
rowLimit: row_limit
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded_site = URI.encode(site_url, &URI.char_unreserved?/1)
|
||||||
|
url = "#{@api_base}/sites/#{encoded_site}/searchAnalytics/query"
|
||||||
|
|
||||||
|
case Req.post(url, json: body, headers: auth_headers(token)) do
|
||||||
|
{:ok, %{status: 200, body: %{"rows" => rows}}} ->
|
||||||
|
{:ok, format_rows(rows, dimensions)}
|
||||||
|
|
||||||
|
{:ok, %{status: 200, body: _}} ->
|
||||||
|
{:ok, []}
|
||||||
|
|
||||||
|
{:ok, %{status: status, body: body}} ->
|
||||||
|
{:error, {:api_error, status, body}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetches top queries for the site.
|
||||||
|
|
||||||
|
Returns the top queries by clicks with impressions, CTR, and position.
|
||||||
|
"""
|
||||||
|
def top_queries(site_url, opts \\ []) do
|
||||||
|
query_search_analytics(site_url, Keyword.merge(opts, dimensions: ["query"]))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetches top pages for the site.
|
||||||
|
|
||||||
|
Returns the top pages by clicks with impressions, CTR, and position.
|
||||||
|
"""
|
||||||
|
def top_pages(site_url, opts \\ []) do
|
||||||
|
query_search_analytics(site_url, Keyword.merge(opts, dimensions: ["page"]))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Private helpers
|
||||||
|
|
||||||
|
defp auth_headers(token) do
|
||||||
|
[{"authorization", "Bearer #{token}"}]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_rows(rows, dimensions) do
|
||||||
|
Enum.map(rows, fn row ->
|
||||||
|
keys = row["keys"] || []
|
||||||
|
|
||||||
|
dimension_values =
|
||||||
|
dimensions
|
||||||
|
|> Enum.zip(keys)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
%{
|
||||||
|
keys: dimension_values,
|
||||||
|
clicks: row["clicks"] || 0,
|
||||||
|
impressions: row["impressions"] || 0,
|
||||||
|
ctr: Float.round((row["ctr"] || 0) * 100, 2),
|
||||||
|
position: Float.round(row["position"] || 0, 1)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
186
lib/berrypod/gsc/oauth.ex
Normal file
186
lib/berrypod/gsc/oauth.ex
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
defmodule Berrypod.GSC.OAuth do
|
||||||
|
@moduledoc """
|
||||||
|
Google Search Console OAuth2 authentication.
|
||||||
|
|
||||||
|
Handles the OAuth flow for connecting to Google Search Console,
|
||||||
|
including token exchange and refresh.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Requires environment variables:
|
||||||
|
- GSC_CLIENT_ID: Google OAuth client ID
|
||||||
|
- GSC_CLIENT_SECRET: Google OAuth client secret
|
||||||
|
|
||||||
|
Tokens are stored encrypted in the Settings context:
|
||||||
|
- gsc_access_token
|
||||||
|
- gsc_refresh_token
|
||||||
|
- gsc_token_expires_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Berrypod.Settings
|
||||||
|
|
||||||
|
@token_url "https://oauth2.googleapis.com/token"
|
||||||
|
@auth_url "https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
|
@scope "https://www.googleapis.com/auth/webmasters.readonly"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the OAuth authorization URL for initiating the connection flow.
|
||||||
|
"""
|
||||||
|
def authorize_url do
|
||||||
|
case client_id() do
|
||||||
|
nil ->
|
||||||
|
{:error, :missing_client_id}
|
||||||
|
|
||||||
|
client_id ->
|
||||||
|
params = %{
|
||||||
|
client_id: client_id,
|
||||||
|
redirect_uri: redirect_uri(),
|
||||||
|
response_type: "code",
|
||||||
|
scope: @scope,
|
||||||
|
access_type: "offline",
|
||||||
|
prompt: "consent"
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, @auth_url <> "?" <> URI.encode_query(params)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Exchanges an authorization code for access and refresh tokens.
|
||||||
|
|
||||||
|
Stores the tokens encrypted in Settings on success.
|
||||||
|
"""
|
||||||
|
def exchange_code(code) do
|
||||||
|
body = %{
|
||||||
|
code: code,
|
||||||
|
client_id: client_id(),
|
||||||
|
client_secret: client_secret(),
|
||||||
|
redirect_uri: redirect_uri(),
|
||||||
|
grant_type: "authorization_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
case Req.post(@token_url, form: body) do
|
||||||
|
{:ok, %{status: 200, body: %{"access_token" => access_token} = response}} ->
|
||||||
|
store_tokens(access_token, response["refresh_token"], response["expires_in"])
|
||||||
|
{:ok, access_token}
|
||||||
|
|
||||||
|
{:ok, %{status: status, body: body}} ->
|
||||||
|
{:error, {:token_exchange_failed, status, body}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Refreshes the access token using the stored refresh token.
|
||||||
|
"""
|
||||||
|
def refresh_token do
|
||||||
|
case Settings.get_secret("gsc_refresh_token") do
|
||||||
|
nil ->
|
||||||
|
{:error, :no_refresh_token}
|
||||||
|
|
||||||
|
refresh_token ->
|
||||||
|
body = %{
|
||||||
|
refresh_token: refresh_token,
|
||||||
|
client_id: client_id(),
|
||||||
|
client_secret: client_secret(),
|
||||||
|
grant_type: "refresh_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
case Req.post(@token_url, form: body) do
|
||||||
|
{:ok, %{status: 200, body: %{"access_token" => access_token} = response}} ->
|
||||||
|
# Refresh tokens may be rotated, so store the new one if provided
|
||||||
|
new_refresh = response["refresh_token"] || refresh_token
|
||||||
|
store_tokens(access_token, new_refresh, response["expires_in"])
|
||||||
|
{:ok, access_token}
|
||||||
|
|
||||||
|
{:ok, %{status: status, body: body}} ->
|
||||||
|
{:error, {:refresh_failed, status, body}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a valid access token, refreshing if necessary.
|
||||||
|
|
||||||
|
Returns `{:ok, access_token}` or `{:error, reason}`.
|
||||||
|
"""
|
||||||
|
def get_valid_token do
|
||||||
|
case Settings.get_secret("gsc_access_token") do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_connected}
|
||||||
|
|
||||||
|
access_token ->
|
||||||
|
if token_expired?() do
|
||||||
|
refresh_token()
|
||||||
|
else
|
||||||
|
{:ok, access_token}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns whether GSC is connected (has stored tokens).
|
||||||
|
"""
|
||||||
|
def connected? do
|
||||||
|
Settings.has_secret?("gsc_refresh_token")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Disconnects from GSC by clearing stored tokens.
|
||||||
|
"""
|
||||||
|
def disconnect do
|
||||||
|
Settings.delete_setting("gsc_access_token")
|
||||||
|
Settings.delete_setting("gsc_refresh_token")
|
||||||
|
Settings.delete_setting("gsc_token_expires_at")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Private helpers
|
||||||
|
|
||||||
|
defp store_tokens(access_token, refresh_token, expires_in) do
|
||||||
|
Settings.put_secret("gsc_access_token", access_token)
|
||||||
|
|
||||||
|
if refresh_token do
|
||||||
|
Settings.put_secret("gsc_refresh_token", refresh_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
if expires_in do
|
||||||
|
# Store expiry as Unix timestamp
|
||||||
|
expires_at = System.system_time(:second) + expires_in
|
||||||
|
Settings.put_setting("gsc_token_expires_at", expires_at, "integer")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp token_expired? do
|
||||||
|
case Settings.get_setting("gsc_token_expires_at") do
|
||||||
|
nil ->
|
||||||
|
# If no expiry stored, assume it might be expired
|
||||||
|
true
|
||||||
|
|
||||||
|
expires_at when is_integer(expires_at) ->
|
||||||
|
# Refresh 5 minutes before actual expiry
|
||||||
|
System.system_time(:second) > expires_at - 300
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp client_id do
|
||||||
|
System.get_env("GSC_CLIENT_ID")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp client_secret do
|
||||||
|
System.get_env("GSC_CLIENT_SECRET")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp redirect_uri do
|
||||||
|
base_url = BerrypodWeb.Endpoint.url()
|
||||||
|
"#{base_url}/admin/gsc/callback"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -167,6 +167,8 @@ defmodule Berrypod.Media do
|
|||||||
nil
|
nil
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
def get_image(nil), do: nil
|
||||||
|
|
||||||
def get_image(id) do
|
def get_image(id) do
|
||||||
Repo.get(ImageSchema, id)
|
Repo.get(ImageSchema, id)
|
||||||
end
|
end
|
||||||
@@ -252,6 +254,39 @@ defmodule Berrypod.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the default OG (Open Graph) image for social sharing.
|
||||||
|
|
||||||
|
This is used as a fallback when pages don't have their own OG image set.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get_default_og_image()
|
||||||
|
%Image{}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_default_og_image do
|
||||||
|
alias Berrypod.Settings.SettingsCache
|
||||||
|
|
||||||
|
case SettingsCache.get_cached(:default_og) do
|
||||||
|
{:ok, image} ->
|
||||||
|
image
|
||||||
|
|
||||||
|
:miss ->
|
||||||
|
image =
|
||||||
|
Repo.one(
|
||||||
|
from i in ImageSchema,
|
||||||
|
where: i.image_type == "default_og",
|
||||||
|
order_by: [desc: i.inserted_at],
|
||||||
|
limit: 1,
|
||||||
|
select: struct(i, [:id, :image_type, :source_width])
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsCache.put_cached(:default_og, image)
|
||||||
|
image
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Deletes an image.
|
Deletes an image.
|
||||||
|
|
||||||
@@ -361,6 +396,26 @@ defmodule Berrypod.Media do
|
|||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc "Updates the image_type of an existing image."
|
||||||
|
def update_image_type(%ImageSchema{} = image, new_type) do
|
||||||
|
old_type = image.image_type
|
||||||
|
|
||||||
|
result =
|
||||||
|
image
|
||||||
|
|> Ecto.Changeset.change(image_type: new_type)
|
||||||
|
|> Repo.update()
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, updated} ->
|
||||||
|
invalidate_media_cache(old_type)
|
||||||
|
invalidate_media_cache(new_type)
|
||||||
|
{:ok, updated}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of places an image is referenced.
|
Returns a list of places an image is referenced.
|
||||||
|
|
||||||
@@ -533,5 +588,8 @@ defmodule Berrypod.Media do
|
|||||||
defp invalidate_media_cache("header"),
|
defp invalidate_media_cache("header"),
|
||||||
do: Berrypod.Settings.SettingsCache.invalidate_cached(:header)
|
do: Berrypod.Settings.SettingsCache.invalidate_cached(:header)
|
||||||
|
|
||||||
|
defp invalidate_media_cache("default_og"),
|
||||||
|
do: Berrypod.Settings.SettingsCache.invalidate_cached(:default_og)
|
||||||
|
|
||||||
defp invalidate_media_cache(_), do: :ok
|
defp invalidate_media_cache(_), do: :ok
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ defmodule Berrypod.Media.Image do
|
|||||||
:dominant_colors
|
:dominant_colors
|
||||||
])
|
])
|
||||||
|> validate_required([:image_type, :filename, :content_type, :file_size, :data])
|
|> validate_required([:image_type, :filename, :content_type, :file_size, :data])
|
||||||
|> validate_inclusion(:image_type, ~w(logo header product icon media review))
|
|> validate_inclusion(:image_type, ~w(logo header product icon media review default_og))
|
||||||
|> validate_number(:file_size, less_than: @max_file_size)
|
|> validate_number(:file_size, less_than: @max_file_size)
|
||||||
|> detect_svg()
|
|> detect_svg()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -292,6 +292,9 @@ defmodule Berrypod.Pages do
|
|||||||
type: page.type || "system",
|
type: page.type || "system",
|
||||||
published: page.published,
|
published: page.published,
|
||||||
meta_description: page.meta_description,
|
meta_description: page.meta_description,
|
||||||
|
meta_robots: page.meta_robots,
|
||||||
|
focus_keyword: page.focus_keyword,
|
||||||
|
og_image_id: page.og_image_id,
|
||||||
show_in_nav: page.show_in_nav,
|
show_in_nav: page.show_in_nav,
|
||||||
nav_label: page.nav_label,
|
nav_label: page.nav_label,
|
||||||
nav_position: page.nav_position,
|
nav_position: page.nav_position,
|
||||||
|
|||||||
@@ -241,6 +241,30 @@ defmodule Berrypod.Pages.BlockTypes do
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"faq" => %{
|
||||||
|
name: "FAQ section",
|
||||||
|
description: "Frequently asked questions with expandable answers",
|
||||||
|
icon: "hero-question-mark-circle",
|
||||||
|
allowed_on: :all,
|
||||||
|
settings_schema: [
|
||||||
|
%SettingsField{
|
||||||
|
key: "title",
|
||||||
|
label: "Section title",
|
||||||
|
type: :text,
|
||||||
|
default: "Frequently asked questions"
|
||||||
|
},
|
||||||
|
%SettingsField{
|
||||||
|
key: "items",
|
||||||
|
label: "Questions",
|
||||||
|
type: :repeater,
|
||||||
|
default: [],
|
||||||
|
item_schema: [
|
||||||
|
%SettingsField{key: "question", label: "Question", type: :text, default: ""},
|
||||||
|
%SettingsField{key: "answer", label: "Answer", type: :textarea, default: ""}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
# ── PDP blocks ──────────────────────────────────────────────────
|
# ── PDP blocks ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ defmodule Berrypod.Pages.Page do
|
|||||||
sitemap.xml robots.txt setup dev
|
sitemap.xml robots.txt setup dev
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@meta_robots_options [
|
||||||
|
"index, follow",
|
||||||
|
"noindex, follow",
|
||||||
|
"index, nofollow",
|
||||||
|
"noindex, nofollow"
|
||||||
|
]
|
||||||
|
|
||||||
schema "pages" do
|
schema "pages" do
|
||||||
field :slug, :string
|
field :slug, :string
|
||||||
field :title, :string
|
field :title, :string
|
||||||
@@ -25,10 +32,13 @@ defmodule Berrypod.Pages.Page do
|
|||||||
field :type, :string, default: "system"
|
field :type, :string, default: "system"
|
||||||
field :published, :boolean, default: true
|
field :published, :boolean, default: true
|
||||||
field :meta_description, :string
|
field :meta_description, :string
|
||||||
|
field :meta_robots, :string, default: "index, follow"
|
||||||
|
field :focus_keyword, :string
|
||||||
field :show_in_nav, :boolean, default: false
|
field :show_in_nav, :boolean, default: false
|
||||||
field :nav_label, :string
|
field :nav_label, :string
|
||||||
field :nav_position, :integer
|
field :nav_position, :integer
|
||||||
field :url_slug, :string
|
field :url_slug, :string
|
||||||
|
field :og_image_id, :binary_id
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
@@ -52,9 +62,11 @@ defmodule Berrypod.Pages.Page do
|
|||||||
|
|
||||||
def system_changeset(page, attrs) do
|
def system_changeset(page, attrs) do
|
||||||
page
|
page
|
||||||
|> cast(attrs, [:slug, :title, :blocks, :url_slug])
|
|> cast(attrs, [:slug, :title, :blocks, :url_slug, :meta_robots, :meta_description])
|
||||||
|> validate_required([:slug, :title, :blocks])
|
|> validate_required([:slug, :title, :blocks])
|
||||||
|> validate_inclusion(:slug, @system_slugs)
|
|> validate_inclusion(:slug, @system_slugs)
|
||||||
|
|> validate_inclusion(:meta_robots, @meta_robots_options)
|
||||||
|
|> validate_length(:meta_description, max: 300)
|
||||||
|> validate_url_slug()
|
|> validate_url_slug()
|
||||||
|> unique_constraint(:slug)
|
|> unique_constraint(:slug)
|
||||||
|> unique_constraint(:url_slug)
|
|> unique_constraint(:url_slug)
|
||||||
@@ -69,10 +81,13 @@ defmodule Berrypod.Pages.Page do
|
|||||||
:type,
|
:type,
|
||||||
:published,
|
:published,
|
||||||
:meta_description,
|
:meta_description,
|
||||||
|
:meta_robots,
|
||||||
|
:focus_keyword,
|
||||||
:show_in_nav,
|
:show_in_nav,
|
||||||
:nav_label,
|
:nav_label,
|
||||||
:nav_position,
|
:nav_position,
|
||||||
:url_slug
|
:url_slug,
|
||||||
|
:og_image_id
|
||||||
])
|
])
|
||||||
|> validate_required([:slug, :title])
|
|> validate_required([:slug, :title])
|
||||||
|> validate_format(:slug, ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
|
|> validate_format(:slug, ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
|
||||||
@@ -82,6 +97,8 @@ defmodule Berrypod.Pages.Page do
|
|||||||
|> validate_length(:slug, max: 100)
|
|> validate_length(:slug, max: 100)
|
||||||
|> validate_length(:title, max: 200)
|
|> validate_length(:title, max: 200)
|
||||||
|> validate_length(:meta_description, max: 300)
|
|> validate_length(:meta_description, max: 300)
|
||||||
|
|> validate_inclusion(:meta_robots, @meta_robots_options)
|
||||||
|
|> validate_length(:focus_keyword, max: 100)
|
||||||
|> validate_url_slug()
|
|> validate_url_slug()
|
||||||
|> put_defaults()
|
|> put_defaults()
|
||||||
|> unique_constraint(:slug)
|
|> unique_constraint(:slug)
|
||||||
@@ -131,6 +148,7 @@ defmodule Berrypod.Pages.Page do
|
|||||||
|
|
||||||
def system_slugs, do: @system_slugs
|
def system_slugs, do: @system_slugs
|
||||||
def reserved_paths, do: @reserved_paths
|
def reserved_paths, do: @reserved_paths
|
||||||
|
def meta_robots_options, do: @meta_robots_options
|
||||||
def system_slug?(slug), do: slug in @system_slugs
|
def system_slug?(slug), do: slug in @system_slugs
|
||||||
def reserved_path?(slug), do: slug in @reserved_paths
|
def reserved_path?(slug), do: slug in @reserved_paths
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -258,6 +258,21 @@ defmodule Berrypod.Products do
|
|||||||
|> Repo.preload(listing_preloads())
|
|> Repo.preload(listing_preloads())
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists visible products with all images preloaded (for sitemap generation).
|
||||||
|
|
||||||
|
Returns a minimal struct with slug and images (up to 5 per product).
|
||||||
|
"""
|
||||||
|
def list_products_for_sitemap do
|
||||||
|
pi_query = from(pi in ProductImage, where: pi.position <= 4, order_by: pi.position)
|
||||||
|
|
||||||
|
Product
|
||||||
|
|> where([p], p.visible == true and p.status == "active")
|
||||||
|
|> select([p], struct(p, [:slug, :title]))
|
||||||
|
|> Repo.all()
|
||||||
|
|> Repo.preload(images: {pi_query, image: image_preload_query()})
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Like `list_visible_products/1` but returns a `%Pagination{}` struct.
|
Like `list_visible_products/1` but returns a `%Pagination{}` struct.
|
||||||
|
|
||||||
|
|||||||
459
lib/berrypod/seo/analyser.ex
Normal file
459
lib/berrypod/seo/analyser.ex
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
defmodule Berrypod.SEO.Analyser do
|
||||||
|
@moduledoc """
|
||||||
|
SEO analysis functions for pages.
|
||||||
|
|
||||||
|
Provides recommendations based on a focus keyword and page content.
|
||||||
|
Pure functions, no side effects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@type check :: %{
|
||||||
|
id: atom(),
|
||||||
|
label: String.t(),
|
||||||
|
status: :pass | :fail | :warning,
|
||||||
|
message: String.t()
|
||||||
|
}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Analyses a page and returns a list of SEO checks.
|
||||||
|
|
||||||
|
Expects a map with:
|
||||||
|
- `:focus_keyword` - the keyword to optimise for
|
||||||
|
- `:title` - page title
|
||||||
|
- `:meta_description` - meta description
|
||||||
|
- `:slug` - URL slug
|
||||||
|
- `:blocks` - list of page blocks
|
||||||
|
"""
|
||||||
|
@spec analyse(map()) :: [check()]
|
||||||
|
def analyse(page) do
|
||||||
|
keyword = page[:focus_keyword]
|
||||||
|
|
||||||
|
if present?(keyword) do
|
||||||
|
[
|
||||||
|
check_keyword_in_title(keyword, page[:title]),
|
||||||
|
check_keyword_in_description(keyword, page[:meta_description]),
|
||||||
|
check_keyword_in_url(keyword, page[:slug]),
|
||||||
|
check_keyword_in_content(keyword, page[:blocks]),
|
||||||
|
check_title_length(page[:title]),
|
||||||
|
check_description_length(page[:meta_description]),
|
||||||
|
check_images_have_alt(page[:blocks]),
|
||||||
|
check_internal_links(page[:blocks])
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
id: :no_keyword,
|
||||||
|
label: "Focus keyword",
|
||||||
|
status: :warning,
|
||||||
|
message: "Set a focus keyword to get recommendations"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Calculates an SEO score from a list of checks.
|
||||||
|
|
||||||
|
Returns a percentage (0-100) based on passing checks.
|
||||||
|
"""
|
||||||
|
@spec score([check()]) :: integer()
|
||||||
|
def score(checks) do
|
||||||
|
total = length(checks)
|
||||||
|
passes = Enum.count(checks, &(&1.status == :pass))
|
||||||
|
|
||||||
|
if total > 0, do: round(passes / total * 100), else: 0
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the score level for display purposes.
|
||||||
|
"""
|
||||||
|
@spec score_level(integer()) :: :good | :ok | :poor
|
||||||
|
def score_level(score) when score >= 80, do: :good
|
||||||
|
def score_level(score) when score >= 50, do: :ok
|
||||||
|
def score_level(_), do: :poor
|
||||||
|
|
||||||
|
# ── Individual checks ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp check_keyword_in_title(keyword, title) do
|
||||||
|
if contains_keyword?(title, keyword) do
|
||||||
|
%{
|
||||||
|
id: :keyword_in_title,
|
||||||
|
label: "Keyword in title",
|
||||||
|
status: :pass,
|
||||||
|
message: "The focus keyword appears in the title"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
%{
|
||||||
|
id: :keyword_in_title,
|
||||||
|
label: "Keyword in title",
|
||||||
|
status: :fail,
|
||||||
|
message: "Add the focus keyword to your title"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_keyword_in_description(keyword, description) do
|
||||||
|
if contains_keyword?(description, keyword) do
|
||||||
|
%{
|
||||||
|
id: :keyword_in_description,
|
||||||
|
label: "Keyword in description",
|
||||||
|
status: :pass,
|
||||||
|
message: "The focus keyword appears in the meta description"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
%{
|
||||||
|
id: :keyword_in_description,
|
||||||
|
label: "Keyword in description",
|
||||||
|
status: :fail,
|
||||||
|
message: "Add the focus keyword to your meta description"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_keyword_in_url(keyword, slug) do
|
||||||
|
keyword_slug = slug_from_keyword(keyword)
|
||||||
|
|
||||||
|
if slug && String.contains?(String.downcase(slug), keyword_slug) do
|
||||||
|
%{
|
||||||
|
id: :keyword_in_url,
|
||||||
|
label: "Keyword in URL",
|
||||||
|
status: :pass,
|
||||||
|
message: "The focus keyword appears in the URL"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
%{
|
||||||
|
id: :keyword_in_url,
|
||||||
|
label: "Keyword in URL",
|
||||||
|
status: :warning,
|
||||||
|
message: "Consider adding the focus keyword to the URL"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_keyword_in_content(keyword, blocks) do
|
||||||
|
content = extract_text_content(blocks)
|
||||||
|
|
||||||
|
if contains_keyword?(content, keyword) do
|
||||||
|
%{
|
||||||
|
id: :keyword_in_content,
|
||||||
|
label: "Keyword in content",
|
||||||
|
status: :pass,
|
||||||
|
message: "The focus keyword appears in the page content"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
%{
|
||||||
|
id: :keyword_in_content,
|
||||||
|
label: "Keyword in content",
|
||||||
|
status: :fail,
|
||||||
|
message: "Add the focus keyword to your page content"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_title_length(title) do
|
||||||
|
length = String.length(title || "")
|
||||||
|
|
||||||
|
cond do
|
||||||
|
length == 0 ->
|
||||||
|
%{
|
||||||
|
id: :title_length,
|
||||||
|
label: "Title length",
|
||||||
|
status: :fail,
|
||||||
|
message: "Add a page title"
|
||||||
|
}
|
||||||
|
|
||||||
|
length <= 60 ->
|
||||||
|
%{
|
||||||
|
id: :title_length,
|
||||||
|
label: "Title length",
|
||||||
|
status: :pass,
|
||||||
|
message: "Title is a good length (#{length} characters)"
|
||||||
|
}
|
||||||
|
|
||||||
|
length <= 70 ->
|
||||||
|
%{
|
||||||
|
id: :title_length,
|
||||||
|
label: "Title length",
|
||||||
|
status: :warning,
|
||||||
|
message: "Title is slightly long (#{length} characters, aim for ≤60)"
|
||||||
|
}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
%{
|
||||||
|
id: :title_length,
|
||||||
|
label: "Title length",
|
||||||
|
status: :fail,
|
||||||
|
message: "Title is too long (#{length} characters, may be truncated)"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_description_length(description) do
|
||||||
|
length = String.length(description || "")
|
||||||
|
|
||||||
|
cond do
|
||||||
|
length == 0 ->
|
||||||
|
%{
|
||||||
|
id: :description_length,
|
||||||
|
label: "Description length",
|
||||||
|
status: :fail,
|
||||||
|
message: "Add a meta description"
|
||||||
|
}
|
||||||
|
|
||||||
|
length >= 120 and length <= 155 ->
|
||||||
|
%{
|
||||||
|
id: :description_length,
|
||||||
|
label: "Description length",
|
||||||
|
status: :pass,
|
||||||
|
message: "Description is a good length (#{length} characters)"
|
||||||
|
}
|
||||||
|
|
||||||
|
length >= 100 and length <= 160 ->
|
||||||
|
%{
|
||||||
|
id: :description_length,
|
||||||
|
label: "Description length",
|
||||||
|
status: :warning,
|
||||||
|
message: "Description could be improved (#{length} characters, aim for 120-155)"
|
||||||
|
}
|
||||||
|
|
||||||
|
length < 100 ->
|
||||||
|
%{
|
||||||
|
id: :description_length,
|
||||||
|
label: "Description length",
|
||||||
|
status: :warning,
|
||||||
|
message: "Description is short (#{length} characters, aim for 120-155)"
|
||||||
|
}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
%{
|
||||||
|
id: :description_length,
|
||||||
|
label: "Description length",
|
||||||
|
status: :fail,
|
||||||
|
message: "Description is too long (#{length} characters, may be truncated)"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_images_have_alt(blocks) do
|
||||||
|
image_blocks = extract_image_blocks(blocks)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
image_blocks == [] ->
|
||||||
|
%{
|
||||||
|
id: :images_have_alt,
|
||||||
|
label: "Image alt text",
|
||||||
|
status: :pass,
|
||||||
|
message: "No images to check"
|
||||||
|
}
|
||||||
|
|
||||||
|
all_have_alt?(image_blocks) ->
|
||||||
|
%{
|
||||||
|
id: :images_have_alt,
|
||||||
|
label: "Image alt text",
|
||||||
|
status: :pass,
|
||||||
|
message: "All images have alt text"
|
||||||
|
}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
missing = count_missing_alt(image_blocks)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: :images_have_alt,
|
||||||
|
label: "Image alt text",
|
||||||
|
status: :warning,
|
||||||
|
message: "#{missing} image(s) missing alt text"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_internal_links(blocks) do
|
||||||
|
links = extract_links(blocks)
|
||||||
|
internal_links = Enum.filter(links, &internal_link?/1)
|
||||||
|
|
||||||
|
if internal_links != [] do
|
||||||
|
%{
|
||||||
|
id: :internal_links,
|
||||||
|
label: "Internal links",
|
||||||
|
status: :pass,
|
||||||
|
message: "Page has #{length(internal_links)} internal link(s)"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
%{
|
||||||
|
id: :internal_links,
|
||||||
|
label: "Internal links",
|
||||||
|
status: :warning,
|
||||||
|
message: "Consider adding internal links to other pages"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp present?(nil), do: false
|
||||||
|
defp present?(""), do: false
|
||||||
|
defp present?(str) when is_binary(str), do: String.trim(str) != ""
|
||||||
|
defp present?(_), do: false
|
||||||
|
|
||||||
|
defp contains_keyword?(nil, _keyword), do: false
|
||||||
|
defp contains_keyword?(_text, nil), do: false
|
||||||
|
|
||||||
|
defp contains_keyword?(text, keyword) do
|
||||||
|
text = String.downcase(text)
|
||||||
|
keyword = String.downcase(String.trim(keyword))
|
||||||
|
String.contains?(text, keyword)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp slug_from_keyword(keyword) do
|
||||||
|
keyword
|
||||||
|
|> String.downcase()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.replace(~r/\s+/, "-")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_text_content(nil), do: ""
|
||||||
|
defp extract_text_content([]), do: ""
|
||||||
|
|
||||||
|
defp extract_text_content(blocks) when is_list(blocks) do
|
||||||
|
blocks
|
||||||
|
|> Enum.map(&extract_block_text/1)
|
||||||
|
|> Enum.join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_text(%{"type" => "hero", "settings" => settings}) do
|
||||||
|
[
|
||||||
|
settings["title"],
|
||||||
|
settings["heading"],
|
||||||
|
settings["headline"],
|
||||||
|
settings["description"],
|
||||||
|
settings["subheading"],
|
||||||
|
settings["subheadline"]
|
||||||
|
]
|
||||||
|
|> Enum.filter(&present?/1)
|
||||||
|
|> Enum.join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_text(%{"type" => "rich_text", "settings" => settings}) do
|
||||||
|
settings["content"] || ""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_text(%{"type" => "content_body", "settings" => settings}) do
|
||||||
|
settings["content"] || ""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_text(%{"type" => "heading", "settings" => settings}) do
|
||||||
|
settings["text"] || ""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_text(%{"type" => "text_columns", "settings" => settings}) do
|
||||||
|
col1 = settings["column1_content"] || ""
|
||||||
|
col2 = settings["column2_content"] || ""
|
||||||
|
"#{col1} #{col2}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_text(%{"type" => "faq", "settings" => settings}) do
|
||||||
|
items = settings["items"] || []
|
||||||
|
|
||||||
|
items
|
||||||
|
|> Enum.map(fn item -> "#{item["question"]} #{item["answer"]}" end)
|
||||||
|
|> Enum.join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_text(_block), do: ""
|
||||||
|
|
||||||
|
# Only extract blocks that have both an image field AND an alt text field
|
||||||
|
# Currently no block types have alt text fields, so this returns empty
|
||||||
|
# until alt text is added to the block schemas
|
||||||
|
defp extract_image_blocks(nil), do: []
|
||||||
|
defp extract_image_blocks([]), do: []
|
||||||
|
|
||||||
|
defp extract_image_blocks(blocks) when is_list(blocks) do
|
||||||
|
# Filter to blocks that have image_id set and support alt text
|
||||||
|
Enum.filter(blocks, fn block ->
|
||||||
|
settings = block["settings"] || %{}
|
||||||
|
has_image = present?(settings["image_id"])
|
||||||
|
# Only include if it has both image AND alt text field defined
|
||||||
|
has_image and has_alt_field?(block["type"])
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Block types that have an alt text field in their schema
|
||||||
|
defp has_alt_field?(_type), do: false
|
||||||
|
|
||||||
|
defp all_have_alt?(blocks) do
|
||||||
|
Enum.all?(blocks, fn block ->
|
||||||
|
settings = block["settings"] || %{}
|
||||||
|
present?(settings["alt_text"] || settings["image_alt"])
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp count_missing_alt(blocks) do
|
||||||
|
Enum.count(blocks, fn block ->
|
||||||
|
settings = block["settings"] || %{}
|
||||||
|
not present?(settings["alt_text"] || settings["image_alt"])
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_links(nil), do: []
|
||||||
|
defp extract_links([]), do: []
|
||||||
|
|
||||||
|
defp extract_links(blocks) when is_list(blocks) do
|
||||||
|
blocks
|
||||||
|
|> Enum.flat_map(&extract_block_links/1)
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_links(%{"type" => "hero", "settings" => settings}) do
|
||||||
|
[
|
||||||
|
settings["cta_href"],
|
||||||
|
settings["cta_url"],
|
||||||
|
settings["secondary_cta_href"],
|
||||||
|
settings["button1_url"],
|
||||||
|
settings["button2_url"]
|
||||||
|
]
|
||||||
|
|> Enum.filter(&present?/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_links(%{"type" => "cta", "settings" => settings}) do
|
||||||
|
[settings["cta_url"], settings["cta_href"]]
|
||||||
|
|> Enum.filter(&present?/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_links(%{"type" => "rich_text", "settings" => settings}) do
|
||||||
|
content = settings["content"] || ""
|
||||||
|
extract_href_links(content) ++ extract_markdown_links(content)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_links(%{"type" => "content_body", "settings" => settings}) do
|
||||||
|
content = settings["content"] || ""
|
||||||
|
extract_href_links(content) ++ extract_markdown_links(content)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_links(%{"type" => "button", "settings" => settings}) do
|
||||||
|
if present?(settings["url"]), do: [settings["url"]], else: []
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_links(%{"type" => "banner", "settings" => settings}) do
|
||||||
|
if present?(settings["button_url"]), do: [settings["button_url"]], else: []
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_block_links(_block), do: []
|
||||||
|
|
||||||
|
defp extract_href_links(content) do
|
||||||
|
~r/href=["']([^"']+)["']/
|
||||||
|
|> Regex.scan(content)
|
||||||
|
|> Enum.map(fn [_, url] -> url end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_markdown_links(content) do
|
||||||
|
~r/\[([^\]]+)\]\(([^)]+)\)/
|
||||||
|
|> Regex.scan(content)
|
||||||
|
|> Enum.map(fn [_, _text, url] -> url end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp internal_link?(url) when is_binary(url) do
|
||||||
|
String.starts_with?(url, "/") or
|
||||||
|
String.starts_with?(url, "#") or
|
||||||
|
not String.contains?(url, "://")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp internal_link?(_), do: false
|
||||||
|
end
|
||||||
@@ -443,6 +443,36 @@ defmodule Berrypod.Settings do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Business info ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets business info as a map.
|
||||||
|
|
||||||
|
Used for Organization JSON-LD schema and contact details.
|
||||||
|
|
||||||
|
Returns a map with keys like:
|
||||||
|
- "business_type" ("Organization" or "LocalBusiness")
|
||||||
|
- "business_phone"
|
||||||
|
- "business_email"
|
||||||
|
- "business_address" (map with street, city, region, postal_code, country)
|
||||||
|
"""
|
||||||
|
def get_business_info do
|
||||||
|
get_setting("business_info") || %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates business info fields.
|
||||||
|
|
||||||
|
Merges the provided attrs into existing business info.
|
||||||
|
"""
|
||||||
|
def put_business_info(attrs) when is_map(attrs) do
|
||||||
|
current = get_business_info()
|
||||||
|
# Stringify keys for consistency
|
||||||
|
stringified = Map.new(attrs, fn {k, v} -> {to_string(k), v} end)
|
||||||
|
updated = Map.merge(current, stringified)
|
||||||
|
put_setting("business_info", updated, "json")
|
||||||
|
end
|
||||||
|
|
||||||
# Private helpers
|
# Private helpers
|
||||||
|
|
||||||
defp fetch_setting(key) do
|
defp fetch_setting(key) do
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"Welcome to #{@site_name}"
|
"Welcome to #{@site_name}"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<%= if assigns[:meta_robots] && assigns[:meta_robots] != "index, follow" do %>
|
||||||
|
<meta name="robots" content={assigns[:meta_robots]} />
|
||||||
|
<% end %>
|
||||||
<!-- Favicon & PWA -->
|
<!-- Favicon & PWA -->
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="icon" href="/favicon-32x32.png" sizes="32x32" type="image/png" />
|
<link rel="icon" href="/favicon-32x32.png" sizes="32x32" type="image/png" />
|
||||||
|
|||||||
50
lib/berrypod_web/components/seo_checklist.ex
Normal file
50
lib/berrypod_web/components/seo_checklist.ex
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
defmodule BerrypodWeb.Components.SeoChecklist do
|
||||||
|
@moduledoc """
|
||||||
|
SEO checklist component showing focus keyword analysis results.
|
||||||
|
|
||||||
|
Displays a score and list of checks with pass/fail/warning status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
import BerrypodWeb.CoreComponents, only: [icon: 1]
|
||||||
|
|
||||||
|
alias Berrypod.SEO.Analyser
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the SEO checklist with score and checks.
|
||||||
|
"""
|
||||||
|
attr :page, :map, required: true
|
||||||
|
|
||||||
|
def seo_checklist(assigns) do
|
||||||
|
checks = Analyser.analyse(assigns.page)
|
||||||
|
score = Analyser.score(checks)
|
||||||
|
level = Analyser.score_level(score)
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:checks, checks)
|
||||||
|
|> assign(:score, score)
|
||||||
|
|> assign(:level, level)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="seo-checklist">
|
||||||
|
<div class="seo-score" data-level={@level}>
|
||||||
|
<span class="seo-score-value">{@score}%</span>
|
||||||
|
<span class="seo-score-label">SEO score</span>
|
||||||
|
</div>
|
||||||
|
<ul class="seo-checks">
|
||||||
|
<li :for={check <- @checks} class="seo-check" data-status={check.status}>
|
||||||
|
<.icon name={status_icon(check.status)} class="size-4 seo-check-icon" />
|
||||||
|
<span class="seo-check-label">{check.label}</span>
|
||||||
|
<span class="seo-check-hint">{check.message}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp status_icon(:pass), do: "hero-check-circle"
|
||||||
|
defp status_icon(:fail), do: "hero-x-circle"
|
||||||
|
defp status_icon(:warning), do: "hero-exclamation-triangle"
|
||||||
|
end
|
||||||
193
lib/berrypod_web/components/seo_preview.ex
Normal file
193
lib/berrypod_web/components/seo_preview.ex
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
defmodule BerrypodWeb.Components.SeoPreview do
|
||||||
|
@moduledoc """
|
||||||
|
SEO preview component showing how pages appear in search results and social cards.
|
||||||
|
|
||||||
|
Provides live-updating previews as users edit page titles and descriptions,
|
||||||
|
with character count indicators showing optimal lengths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the full SEO preview panel with Google and social previews.
|
||||||
|
"""
|
||||||
|
attr :title, :string, required: true
|
||||||
|
attr :description, :string, default: ""
|
||||||
|
attr :url, :string, required: true
|
||||||
|
attr :og_image, :string, default: nil
|
||||||
|
attr :site_name, :string, required: true
|
||||||
|
|
||||||
|
def seo_preview(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="seo-preview">
|
||||||
|
<.google_preview title={@title} description={@description} url={@url} site_name={@site_name} />
|
||||||
|
<.social_preview
|
||||||
|
title={@title}
|
||||||
|
description={@description}
|
||||||
|
url={@url}
|
||||||
|
image={@og_image}
|
||||||
|
site_name={@site_name}
|
||||||
|
/>
|
||||||
|
<.character_counts title={@title} description={@description} />
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Google search result preview mockup.
|
||||||
|
"""
|
||||||
|
attr :title, :string, required: true
|
||||||
|
attr :description, :string, default: ""
|
||||||
|
attr :url, :string, required: true
|
||||||
|
attr :site_name, :string, required: true
|
||||||
|
|
||||||
|
def google_preview(assigns) do
|
||||||
|
# Truncate title at ~60 chars, description at ~160 chars
|
||||||
|
truncated_title = truncate(assigns.title, 60)
|
||||||
|
truncated_desc = truncate(assigns.description || "", 160)
|
||||||
|
|
||||||
|
# Build breadcrumb-style URL
|
||||||
|
breadcrumb = build_breadcrumb(assigns.url, assigns.site_name)
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:truncated_title, truncated_title)
|
||||||
|
|> assign(:truncated_desc, truncated_desc)
|
||||||
|
|> assign(:breadcrumb, breadcrumb)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="seo-preview-section">
|
||||||
|
<div class="seo-preview-label">Google search preview</div>
|
||||||
|
<div class="seo-google-preview">
|
||||||
|
<div class="seo-google-breadcrumb">{@breadcrumb}</div>
|
||||||
|
<div class="seo-google-title">{@truncated_title}</div>
|
||||||
|
<div class="seo-google-description">{@truncated_desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Social media card preview (Facebook/Twitter style).
|
||||||
|
"""
|
||||||
|
attr :title, :string, required: true
|
||||||
|
attr :description, :string, default: ""
|
||||||
|
attr :url, :string, required: true
|
||||||
|
attr :image, :string, default: nil
|
||||||
|
attr :site_name, :string, required: true
|
||||||
|
|
||||||
|
def social_preview(assigns) do
|
||||||
|
truncated_title = truncate(assigns.title, 70)
|
||||||
|
truncated_desc = truncate(assigns.description || "", 100)
|
||||||
|
domain = extract_domain(assigns.url)
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:truncated_title, truncated_title)
|
||||||
|
|> assign(:truncated_desc, truncated_desc)
|
||||||
|
|> assign(:domain, domain)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="seo-preview-section">
|
||||||
|
<div class="seo-preview-label">Social card preview</div>
|
||||||
|
<div class="seo-social-preview">
|
||||||
|
<div class="seo-social-image">
|
||||||
|
<%= if @image do %>
|
||||||
|
<img src={@image} alt="" />
|
||||||
|
<% else %>
|
||||||
|
<div class="seo-social-image-placeholder">
|
||||||
|
<span>No image set</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="seo-social-content">
|
||||||
|
<div class="seo-social-domain">{@domain}</div>
|
||||||
|
<div class="seo-social-title">{@truncated_title}</div>
|
||||||
|
<div class="seo-social-description">{@truncated_desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Character count indicators for title and description.
|
||||||
|
"""
|
||||||
|
attr :title, :string, required: true
|
||||||
|
attr :description, :string, default: ""
|
||||||
|
|
||||||
|
def character_counts(assigns) do
|
||||||
|
title_len = String.length(assigns.title || "")
|
||||||
|
desc_len = String.length(assigns.description || "")
|
||||||
|
|
||||||
|
title_status = title_status(title_len)
|
||||||
|
desc_status = desc_status(desc_len)
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:title_len, title_len)
|
||||||
|
|> assign(:desc_len, desc_len)
|
||||||
|
|> assign(:title_status, title_status)
|
||||||
|
|> assign(:desc_status, desc_status)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="seo-char-counts">
|
||||||
|
<div class="seo-char-count" data-status={@title_status}>
|
||||||
|
<span class="seo-char-label">Title</span>
|
||||||
|
<span class="seo-char-value">{@title_len}/60</span>
|
||||||
|
<span class="seo-char-indicator"></span>
|
||||||
|
</div>
|
||||||
|
<div class="seo-char-count" data-status={@desc_status}>
|
||||||
|
<span class="seo-char-label">Description</span>
|
||||||
|
<span class="seo-char-value">{@desc_len}/160</span>
|
||||||
|
<span class="seo-char-indicator"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Title: green ≤60, yellow 61-70, red >70
|
||||||
|
defp title_status(len) when len <= 60, do: "good"
|
||||||
|
defp title_status(len) when len <= 70, do: "warning"
|
||||||
|
defp title_status(_), do: "error"
|
||||||
|
|
||||||
|
# Description: green 120-155, yellow 100-119 or 156-160, red <100 or >160
|
||||||
|
defp desc_status(len) when len >= 120 and len <= 155, do: "good"
|
||||||
|
defp desc_status(len) when len >= 100 and len <= 160, do: "warning"
|
||||||
|
defp desc_status(_), do: "error"
|
||||||
|
|
||||||
|
defp truncate(nil, _max), do: ""
|
||||||
|
defp truncate("", _max), do: ""
|
||||||
|
|
||||||
|
defp truncate(text, max) when byte_size(text) > max do
|
||||||
|
String.slice(text, 0, max - 3) <> "..."
|
||||||
|
end
|
||||||
|
|
||||||
|
defp truncate(text, _max), do: text
|
||||||
|
|
||||||
|
defp build_breadcrumb(url, site_name) do
|
||||||
|
case URI.parse(url) do
|
||||||
|
%URI{path: path} when is_binary(path) ->
|
||||||
|
parts =
|
||||||
|
path
|
||||||
|
|> String.split("/", trim: true)
|
||||||
|
|> Enum.take(2)
|
||||||
|
|
||||||
|
if parts == [] do
|
||||||
|
site_name
|
||||||
|
else
|
||||||
|
site_name <> " › " <> Enum.join(parts, " › ")
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
site_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_domain(url) do
|
||||||
|
case URI.parse(url) do
|
||||||
|
%URI{host: host} when is_binary(host) -> host
|
||||||
|
_ -> "example.com"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -64,6 +64,7 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
|
|||||||
|> assign(:header_nav, state.header_nav)
|
|> assign(:header_nav, state.header_nav)
|
||||||
|> assign(:footer_nav, state.footer_nav)
|
|> assign(:footer_nav, state.footer_nav)
|
||||||
|> assign(:social_links, state.social_links)
|
|> assign(:social_links, state.social_links)
|
||||||
|
|> assign(:business_info, state.business_info)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div class="editor-site-content">
|
<div class="editor-site-content">
|
||||||
@@ -109,6 +110,10 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
|
|||||||
<.site_section title="Social links" icon="hero-link">
|
<.site_section title="Social links" icon="hero-link">
|
||||||
<.social_links_editor links={@social_links} event_prefix={@event_prefix} />
|
<.social_links_editor links={@social_links} event_prefix={@event_prefix} />
|
||||||
</.site_section>
|
</.site_section>
|
||||||
|
|
||||||
|
<.site_section title="Business info" icon="hero-building-storefront">
|
||||||
|
<.business_info_editor info={@business_info} event_prefix={@event_prefix} />
|
||||||
|
</.site_section>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -856,6 +861,144 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Business Info Editor ────────────────────────────────────────────
|
||||||
|
|
||||||
|
attr :info, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: "site_"
|
||||||
|
|
||||||
|
defp business_info_editor(assigns) do
|
||||||
|
~H"""
|
||||||
|
<form class="site-editor-form" phx-change={@event_prefix <> "update_business_info"}>
|
||||||
|
<p class="admin-text-secondary admin-text-sm" style="margin-bottom: 1rem;">
|
||||||
|
Used for rich snippets in search results and business schema data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label">Business type</label>
|
||||||
|
<div class="site-editor-radio-group">
|
||||||
|
<label class="admin-radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="business_info[business_type]"
|
||||||
|
value="Organization"
|
||||||
|
checked={@info["business_type"] != "LocalBusiness"}
|
||||||
|
/>
|
||||||
|
<span>Organisation</span>
|
||||||
|
</label>
|
||||||
|
<label class="admin-radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="business_info[business_type]"
|
||||||
|
value="LocalBusiness"
|
||||||
|
checked={@info["business_type"] == "LocalBusiness"}
|
||||||
|
/>
|
||||||
|
<span>Local business</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label" for="business-phone">Phone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="business-phone"
|
||||||
|
name="business_info[business_phone]"
|
||||||
|
value={@info["business_phone"]}
|
||||||
|
class="admin-input"
|
||||||
|
placeholder="+44 7123 456789"
|
||||||
|
phx-debounce="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label" for="business-email">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="business-email"
|
||||||
|
name="business_info[business_email]"
|
||||||
|
value={@info["business_email"]}
|
||||||
|
class="admin-input"
|
||||||
|
placeholder="hello@example.com"
|
||||||
|
phx-debounce="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Address fields shown for LocalBusiness --%>
|
||||||
|
<div
|
||||||
|
class="business-address-fields"
|
||||||
|
style={if @info["business_type"] != "LocalBusiness", do: "display: none;"}
|
||||||
|
>
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label" for="business-street">Street address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="business-street"
|
||||||
|
name="business_info[address_street]"
|
||||||
|
value={@info["address_street"]}
|
||||||
|
class="admin-input"
|
||||||
|
placeholder="123 High Street"
|
||||||
|
phx-debounce="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label" for="business-city">City</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="business-city"
|
||||||
|
name="business_info[address_city]"
|
||||||
|
value={@info["address_city"]}
|
||||||
|
class="admin-input"
|
||||||
|
placeholder="London"
|
||||||
|
phx-debounce="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-row admin-row-sm">
|
||||||
|
<div class="theme-section admin-fill">
|
||||||
|
<label class="theme-section-label" for="business-region">County / region</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="business-region"
|
||||||
|
name="business_info[address_region]"
|
||||||
|
value={@info["address_region"]}
|
||||||
|
class="admin-input"
|
||||||
|
placeholder="Greater London"
|
||||||
|
phx-debounce="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-section" style="flex: 0 0 8rem;">
|
||||||
|
<label class="theme-section-label" for="business-postcode">Postcode</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="business-postcode"
|
||||||
|
name="business_info[address_postal_code]"
|
||||||
|
value={@info["address_postal_code"]}
|
||||||
|
class="admin-input"
|
||||||
|
placeholder="SW1A 1AA"
|
||||||
|
phx-debounce="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label" for="business-country">Country</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="business-country"
|
||||||
|
name="business_info[address_country]"
|
||||||
|
value={@info["address_country"]}
|
||||||
|
class="admin-input"
|
||||||
|
placeholder="United Kingdom"
|
||||||
|
phx-debounce="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
# ── Helpers ─────────────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Social
|
# Social
|
||||||
|
|||||||
67
lib/berrypod_web/controllers/gsc_auth_controller.ex
Normal file
67
lib/berrypod_web/controllers/gsc_auth_controller.ex
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
defmodule BerrypodWeb.GSCAuthController do
|
||||||
|
@moduledoc """
|
||||||
|
Handles Google Search Console OAuth flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use BerrypodWeb, :controller
|
||||||
|
|
||||||
|
alias Berrypod.GSC.{Cache, OAuth}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Initiates the OAuth flow by redirecting to Google's consent screen.
|
||||||
|
"""
|
||||||
|
def connect(conn, _params) do
|
||||||
|
case OAuth.authorize_url() do
|
||||||
|
{:ok, url} ->
|
||||||
|
redirect(conn, external: url)
|
||||||
|
|
||||||
|
{:error, :missing_client_id} ->
|
||||||
|
conn
|
||||||
|
|> put_flash(
|
||||||
|
:error,
|
||||||
|
"Google Search Console is not configured. Set GSC_CLIENT_ID and GSC_CLIENT_SECRET environment variables."
|
||||||
|
)
|
||||||
|
|> redirect(to: ~p"/admin/gsc")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Handles the OAuth callback from Google.
|
||||||
|
|
||||||
|
Exchanges the authorization code for tokens and redirects to the dashboard.
|
||||||
|
"""
|
||||||
|
def callback(conn, %{"code" => code}) do
|
||||||
|
case OAuth.exchange_code(code) do
|
||||||
|
{:ok, _access_token} ->
|
||||||
|
# Invalidate any stale cache when reconnecting
|
||||||
|
Cache.invalidate()
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Connected to Google Search Console")
|
||||||
|
|> redirect(to: ~p"/admin/gsc")
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Failed to connect: #{inspect(reason)}")
|
||||||
|
|> redirect(to: ~p"/admin/gsc")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def callback(conn, %{"error" => error}) do
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Authorization denied: #{error}")
|
||||||
|
|> redirect(to: ~p"/admin/gsc")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Disconnects from Google Search Console by clearing stored tokens.
|
||||||
|
"""
|
||||||
|
def disconnect(conn, _params) do
|
||||||
|
OAuth.disconnect()
|
||||||
|
Cache.invalidate()
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Disconnected from Google Search Console")
|
||||||
|
|> redirect(to: ~p"/admin/gsc")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -26,48 +26,66 @@ defmodule BerrypodWeb.SeoController do
|
|||||||
|
|
||||||
def sitemap(conn, _params) do
|
def sitemap(conn, _params) do
|
||||||
base = BerrypodWeb.Endpoint.url()
|
base = BerrypodWeb.Endpoint.url()
|
||||||
products = Products.list_visible_products()
|
products = Products.list_products_for_sitemap()
|
||||||
categories = Products.list_categories()
|
categories = Products.list_categories()
|
||||||
|
|
||||||
static_pages = [
|
static_pages = [
|
||||||
{R.home(), "daily", "1.0"},
|
{R.home(), "daily", "1.0", []},
|
||||||
{R.collection("all"), "daily", "0.9"},
|
{R.collection("all"), "daily", "0.9", []},
|
||||||
{R.about(), "monthly", "0.5"},
|
{R.about(), "monthly", "0.5", []},
|
||||||
{R.contact(), "monthly", "0.5"},
|
{R.contact(), "monthly", "0.5", []},
|
||||||
{R.delivery(), "monthly", "0.5"},
|
{R.delivery(), "monthly", "0.5", []},
|
||||||
{R.privacy(), "monthly", "0.3"},
|
{R.privacy(), "monthly", "0.3", []},
|
||||||
{R.terms(), "monthly", "0.3"}
|
{R.terms(), "monthly", "0.3", []}
|
||||||
]
|
]
|
||||||
|
|
||||||
category_pages =
|
category_pages =
|
||||||
Enum.map(categories, fn cat ->
|
Enum.map(categories, fn cat ->
|
||||||
{R.collection(cat.slug), "daily", "0.8"}
|
{R.collection(cat.slug), "daily", "0.8", []}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
product_pages =
|
product_pages =
|
||||||
Enum.map(products, fn product ->
|
Enum.map(products, fn product ->
|
||||||
{R.product(product.slug), "weekly", "0.9"}
|
images = product_image_entries(product, base)
|
||||||
|
{R.product(product.slug), "weekly", "0.9", images}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
custom_pages =
|
custom_pages =
|
||||||
Pages.list_custom_pages()
|
Pages.list_custom_pages()
|
||||||
|> Enum.filter(& &1.published)
|
|> Enum.filter(& &1.published)
|
||||||
|> Enum.map(fn page -> {"/#{page.slug}", "weekly", "0.6"} end)
|
|> Enum.map(fn page -> {"/#{page.slug}", "weekly", "0.6", []} end)
|
||||||
|
|
||||||
all_pages = static_pages ++ category_pages ++ product_pages ++ custom_pages
|
all_pages = static_pages ++ category_pages ++ product_pages ++ custom_pages
|
||||||
|
|
||||||
entries =
|
entries =
|
||||||
Enum.map_join(all_pages, "\n", fn {path, changefreq, priority} ->
|
Enum.map_join(all_pages, "\n", fn {path, changefreq, priority, images} ->
|
||||||
" <url>\n" <>
|
image_tags =
|
||||||
" <loc>#{base}#{path}</loc>\n" <>
|
Enum.map_join(images, "\n", fn img ->
|
||||||
|
"""
|
||||||
|
<image:image>
|
||||||
|
<image:loc>#{xml_escape(img.url)}</image:loc>
|
||||||
|
<image:title>#{xml_escape(img.title)}</image:title>
|
||||||
|
</image:image>
|
||||||
|
"""
|
||||||
|
|> String.trim_trailing()
|
||||||
|
end)
|
||||||
|
|
||||||
|
url_content =
|
||||||
|
" <loc>#{xml_escape(base <> path)}</loc>\n" <>
|
||||||
" <changefreq>#{changefreq}</changefreq>\n" <>
|
" <changefreq>#{changefreq}</changefreq>\n" <>
|
||||||
" <priority>#{priority}</priority>\n" <>
|
" <priority>#{priority}</priority>"
|
||||||
" </url>"
|
|
||||||
|
if image_tags == "" do
|
||||||
|
" <url>\n#{url_content}\n </url>"
|
||||||
|
else
|
||||||
|
" <url>\n#{url_content}\n#{image_tags}\n </url>"
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
xml = """
|
xml = """
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
|
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
||||||
#{entries}
|
#{entries}
|
||||||
</urlset>
|
</urlset>
|
||||||
"""
|
"""
|
||||||
@@ -76,4 +94,27 @@ defmodule BerrypodWeb.SeoController do
|
|||||||
|> put_resp_content_type("application/xml")
|
|> put_resp_content_type("application/xml")
|
||||||
|> send_resp(200, xml)
|
|> send_resp(200, xml)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp product_image_entries(product, base_url) do
|
||||||
|
product.images
|
||||||
|
|> Enum.take(5)
|
||||||
|
|> Enum.map(fn product_image ->
|
||||||
|
image = product_image.image
|
||||||
|
alt_text = product_image.alt_text || product.title
|
||||||
|
url = "#{base_url}/image_cache/#{image.id}.webp"
|
||||||
|
|
||||||
|
%{url: url, title: alt_text}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp xml_escape(nil), do: ""
|
||||||
|
|
||||||
|
defp xml_escape(text) when is_binary(text) do
|
||||||
|
text
|
||||||
|
|> String.replace("&", "&")
|
||||||
|
|> String.replace("<", "<")
|
||||||
|
|> String.replace(">", ">")
|
||||||
|
|> String.replace("\"", """)
|
||||||
|
|> String.replace("'", "'")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
107
lib/berrypod_web/helpers/seo_helpers.ex
Normal file
107
lib/berrypod_web/helpers/seo_helpers.ex
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
defmodule BerrypodWeb.Helpers.SeoHelpers do
|
||||||
|
@moduledoc """
|
||||||
|
SEO-related helpers for generating structured data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
alias Berrypod.Media
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generates FAQPage JSON-LD schema from page blocks.
|
||||||
|
|
||||||
|
Extracts FAQ items from any FAQ blocks on the page and builds a valid
|
||||||
|
FAQPage schema. Returns nil if no FAQ blocks with valid content are found.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> faq_json_ld([%{"type" => "faq", "settings" => %{"items" => [...]}}])
|
||||||
|
~s({"@context":"https://schema.org","@type":"FAQPage",...})
|
||||||
|
|
||||||
|
"""
|
||||||
|
def faq_json_ld(blocks) when is_list(blocks) do
|
||||||
|
questions =
|
||||||
|
blocks
|
||||||
|
|> Enum.filter(&(&1["type"] == "faq"))
|
||||||
|
|> Enum.flat_map(fn block ->
|
||||||
|
(block["settings"] || %{})["items"] || []
|
||||||
|
end)
|
||||||
|
|> Enum.filter(fn item ->
|
||||||
|
question = String.trim(item["question"] || "")
|
||||||
|
answer = String.trim(item["answer"] || "")
|
||||||
|
question != "" and answer != ""
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn item ->
|
||||||
|
%{
|
||||||
|
"@type" => "Question",
|
||||||
|
"name" => item["question"],
|
||||||
|
"acceptedAnswer" => %{
|
||||||
|
"@type" => "Answer",
|
||||||
|
"text" => item["answer"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
if questions != [] do
|
||||||
|
%{
|
||||||
|
"@context" => "https://schema.org",
|
||||||
|
"@type" => "FAQPage",
|
||||||
|
"mainEntity" => questions
|
||||||
|
}
|
||||||
|
|> Jason.encode!(escape: :html_safe)
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def faq_json_ld(_), do: nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Assigns an og_image URL to the socket based on page-specific or default image.
|
||||||
|
|
||||||
|
Priority: page-specific image > site-wide default > none
|
||||||
|
"""
|
||||||
|
def assign_og_image(socket, page, base) do
|
||||||
|
og_image_id = get_og_image_id(page)
|
||||||
|
|
||||||
|
og_image =
|
||||||
|
cond do
|
||||||
|
og_image_id ->
|
||||||
|
Media.get_image(og_image_id)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
Media.get_default_og_image()
|
||||||
|
end
|
||||||
|
|
||||||
|
if og_image do
|
||||||
|
url = og_image_url(og_image, base)
|
||||||
|
assign(socket, :og_image, url)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_og_image_id(nil), do: nil
|
||||||
|
defp get_og_image_id(%{og_image_id: id}), do: id
|
||||||
|
defp get_og_image_id(page) when is_map(page), do: page[:og_image_id]
|
||||||
|
defp get_og_image_id(_), do: nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generates a full URL for an OG image, preferring 1200px width for social sharing.
|
||||||
|
|
||||||
|
Falls back to the largest available width if the image is smaller than 1200px.
|
||||||
|
"""
|
||||||
|
def og_image_url(image, base) do
|
||||||
|
path =
|
||||||
|
if image.is_svg do
|
||||||
|
"/image_cache/#{image.id}.webp"
|
||||||
|
else
|
||||||
|
widths = Berrypod.Images.Optimizer.applicable_widths(image.source_width)
|
||||||
|
# Prefer 1200px or larger, otherwise use the largest available
|
||||||
|
width = Enum.find(widths, List.last(widths), &(&1 >= 1200))
|
||||||
|
|
||||||
|
"/image_cache/#{image.id}-#{width}.webp"
|
||||||
|
end
|
||||||
|
|
||||||
|
base <> path
|
||||||
|
end
|
||||||
|
end
|
||||||
425
lib/berrypod_web/live/admin/gsc.ex
Normal file
425
lib/berrypod_web/live/admin/gsc.ex
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
defmodule BerrypodWeb.Admin.GSC do
|
||||||
|
@moduledoc """
|
||||||
|
Google Search Console dashboard.
|
||||||
|
|
||||||
|
Shows connection status, top queries, and top pages from GSC data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
|
alias Berrypod.GSC.{Cache, Client, OAuth}
|
||||||
|
alias Berrypod.Settings
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
# Demo mode: set GSC_DEMO=1 to see the dashboard with sample data
|
||||||
|
demo_mode = System.get_env("GSC_DEMO") == "1"
|
||||||
|
connected = demo_mode or OAuth.connected?()
|
||||||
|
site_url = if demo_mode, do: "https://example.com", else: Settings.get_setting("gsc_site_url")
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Search Console")
|
||||||
|
|> assign(:connected, connected)
|
||||||
|
|> assign(:site_url, site_url)
|
||||||
|
|> assign(:sites, [])
|
||||||
|
|> assign(:loading, false)
|
||||||
|
|> assign(:error, nil)
|
||||||
|
|> assign(:data, nil)
|
||||||
|
|> assign(:demo_mode, demo_mode)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
cond do
|
||||||
|
demo_mode -> assign(socket, :data, demo_data())
|
||||||
|
connected && site_url -> load_data(socket)
|
||||||
|
true -> socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp demo_data do
|
||||||
|
%{
|
||||||
|
top_queries: [
|
||||||
|
%{
|
||||||
|
keys: %{"query" => "wildflower tote bag"},
|
||||||
|
clicks: 145,
|
||||||
|
impressions: 2340,
|
||||||
|
ctr: 6.2,
|
||||||
|
position: 3.2
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
keys: %{"query" => "custom art prints"},
|
||||||
|
clicks: 98,
|
||||||
|
impressions: 1890,
|
||||||
|
ctr: 5.2,
|
||||||
|
position: 4.1
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
keys: %{"query" => "botanical poster"},
|
||||||
|
clicks: 76,
|
||||||
|
impressions: 1456,
|
||||||
|
ctr: 5.2,
|
||||||
|
position: 5.8
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
keys: %{"query" => "nature wall art"},
|
||||||
|
clicks: 54,
|
||||||
|
impressions: 980,
|
||||||
|
ctr: 5.5,
|
||||||
|
position: 7.2
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
keys: %{"query" => "meadow illustration"},
|
||||||
|
clicks: 32,
|
||||||
|
impressions: 654,
|
||||||
|
ctr: 4.9,
|
||||||
|
position: 8.4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
top_pages: [
|
||||||
|
%{
|
||||||
|
keys: %{"page" => "https://example.com/products/wildflower-tote"},
|
||||||
|
clicks: 234,
|
||||||
|
impressions: 4500,
|
||||||
|
ctr: 5.2,
|
||||||
|
position: 4.1
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
keys: %{"page" => "https://example.com/collections/art-prints"},
|
||||||
|
clicks: 187,
|
||||||
|
impressions: 3200,
|
||||||
|
ctr: 5.8,
|
||||||
|
position: 3.8
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
keys: %{"page" => "https://example.com/"},
|
||||||
|
clicks: 156,
|
||||||
|
impressions: 2800,
|
||||||
|
ctr: 5.6,
|
||||||
|
position: 2.1
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
keys: %{"page" => "https://example.com/products/botanical-poster"},
|
||||||
|
clicks: 98,
|
||||||
|
impressions: 1900,
|
||||||
|
ctr: 5.2,
|
||||||
|
position: 5.4
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
keys: %{"page" => "https://example.com/about"},
|
||||||
|
clicks: 45,
|
||||||
|
impressions: 890,
|
||||||
|
ctr: 5.1,
|
||||||
|
position: 6.2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
updated_at: DateTime.utc_now()
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("select_site", %{"site_url" => site_url}, socket) do
|
||||||
|
Settings.put_setting("gsc_site_url", site_url, "string")
|
||||||
|
Cache.invalidate()
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:site_url, site_url)
|
||||||
|
|> load_data()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("refresh_data", _params, socket) do
|
||||||
|
Cache.invalidate()
|
||||||
|
{:noreply, load_data(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("load_sites", _params, socket) do
|
||||||
|
case Client.list_sites() do
|
||||||
|
{:ok, sites} ->
|
||||||
|
{:noreply, assign(socket, :sites, sites)}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, assign(socket, :error, "Failed to load sites: #{inspect(reason)}")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_data(socket) do
|
||||||
|
site_url = socket.assigns.site_url
|
||||||
|
|
||||||
|
case Cache.get_all() do
|
||||||
|
{:ok, data} ->
|
||||||
|
assign(socket, :data, data)
|
||||||
|
|
||||||
|
:miss ->
|
||||||
|
fetch_fresh_data(socket, site_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_fresh_data(socket, site_url) do
|
||||||
|
with {:ok, queries} <- Client.top_queries(site_url, row_limit: 25),
|
||||||
|
{:ok, pages} <- Client.top_pages(site_url, row_limit: 25) do
|
||||||
|
Cache.put_top_queries(queries)
|
||||||
|
Cache.put_top_pages(pages)
|
||||||
|
|
||||||
|
data = %{
|
||||||
|
top_queries: queries,
|
||||||
|
top_pages: pages,
|
||||||
|
updated_at: DateTime.utc_now()
|
||||||
|
}
|
||||||
|
|
||||||
|
assign(socket, :data, data)
|
||||||
|
else
|
||||||
|
{:error, :not_connected} ->
|
||||||
|
assign(socket, :connected, false)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
assign(socket, :error, "Failed to fetch data: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<.header>Search Console</.header>
|
||||||
|
<p class="admin-page-description">
|
||||||
|
See how your site performs in Google search results
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="gsc-dashboard">
|
||||||
|
<%= if not @connected do %>
|
||||||
|
<.connection_card configured={gsc_configured?()} />
|
||||||
|
<% else %>
|
||||||
|
<.site_selector
|
||||||
|
sites={@sites}
|
||||||
|
site_url={@site_url}
|
||||||
|
loading={@loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<%= if @site_url do %>
|
||||||
|
<%= if @data do %>
|
||||||
|
<.data_header updated_at={@data.updated_at} />
|
||||||
|
<.metrics_grid data={@data} />
|
||||||
|
<% else %>
|
||||||
|
<.loading_state error={@error} />
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<.no_site_selected />
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Connection card for when not connected
|
||||||
|
defp connection_card(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="gsc-card gsc-card-connect">
|
||||||
|
<div class="gsc-card-icon">
|
||||||
|
<.icon name="hero-magnifying-glass" class="size-8" />
|
||||||
|
</div>
|
||||||
|
<h2>Connect Google Search Console</h2>
|
||||||
|
<p>
|
||||||
|
See your search performance data, top queries, and page rankings
|
||||||
|
directly in your admin dashboard.
|
||||||
|
</p>
|
||||||
|
<%= if @configured do %>
|
||||||
|
<a href={~p"/admin/gsc/connect"} class="admin-btn admin-btn-primary">
|
||||||
|
Connect with Google
|
||||||
|
</a>
|
||||||
|
<% else %>
|
||||||
|
<p class="gsc-not-configured">
|
||||||
|
Set <code>GSC_CLIENT_ID</code>
|
||||||
|
and <code>GSC_CLIENT_SECRET</code>
|
||||||
|
environment variables to enable this feature.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Site selector dropdown
|
||||||
|
defp site_selector(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="gsc-site-selector">
|
||||||
|
<div class="gsc-site-row">
|
||||||
|
<%= if @sites == [] do %>
|
||||||
|
<button phx-click="load_sites" class="admin-btn admin-btn-outline">
|
||||||
|
Load available sites
|
||||||
|
</button>
|
||||||
|
<% else %>
|
||||||
|
<form phx-change="select_site" class="gsc-site-form">
|
||||||
|
<label for="site_url">Site</label>
|
||||||
|
<select name="site_url" id="site_url" class="admin-select">
|
||||||
|
<option value="">Select a site...</option>
|
||||||
|
<%= for site <- @sites do %>
|
||||||
|
<option value={site["siteUrl"]} selected={@site_url == site["siteUrl"]}>
|
||||||
|
{site["siteUrl"]}
|
||||||
|
</option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={~p"/admin/gsc/disconnect"}
|
||||||
|
data-method="delete"
|
||||||
|
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Data header with refresh button and last updated
|
||||||
|
defp data_header(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="gsc-data-header">
|
||||||
|
<span class="gsc-updated">
|
||||||
|
Last updated: {format_datetime(@updated_at)}
|
||||||
|
</span>
|
||||||
|
<button phx-click="refresh_data" class="admin-btn admin-btn-ghost admin-btn-sm">
|
||||||
|
<.icon name="hero-arrow-path" class="size-4" /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Metrics grid with queries and pages tables
|
||||||
|
defp metrics_grid(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="gsc-grid">
|
||||||
|
<div class="gsc-card">
|
||||||
|
<h3>Top queries</h3>
|
||||||
|
<p class="gsc-card-description">What people search for to find your site</p>
|
||||||
|
<.queries_table queries={@data.top_queries} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gsc-card">
|
||||||
|
<h3>Top pages</h3>
|
||||||
|
<p class="gsc-card-description">Your best performing pages in search</p>
|
||||||
|
<.pages_table pages={@data.top_pages} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Top queries table
|
||||||
|
defp queries_table(assigns) do
|
||||||
|
~H"""
|
||||||
|
<table class="gsc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Query</th>
|
||||||
|
<th class="gsc-th-num">Clicks</th>
|
||||||
|
<th class="gsc-th-num">Impr.</th>
|
||||||
|
<th class="gsc-th-num">CTR</th>
|
||||||
|
<th class="gsc-th-num">Pos.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<%= if @queries == [] do %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="gsc-empty">No data yet</td>
|
||||||
|
</tr>
|
||||||
|
<% else %>
|
||||||
|
<%= for row <- @queries do %>
|
||||||
|
<tr>
|
||||||
|
<td class="gsc-query">{row.keys["query"]}</td>
|
||||||
|
<td class="gsc-num">{row.clicks}</td>
|
||||||
|
<td class="gsc-num">{format_number(row.impressions)}</td>
|
||||||
|
<td class="gsc-num">{row.ctr}%</td>
|
||||||
|
<td class="gsc-num">{row.position}</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Top pages table
|
||||||
|
defp pages_table(assigns) do
|
||||||
|
~H"""
|
||||||
|
<table class="gsc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Page</th>
|
||||||
|
<th class="gsc-th-num">Clicks</th>
|
||||||
|
<th class="gsc-th-num">Impr.</th>
|
||||||
|
<th class="gsc-th-num">CTR</th>
|
||||||
|
<th class="gsc-th-num">Pos.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<%= if @pages == [] do %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="gsc-empty">No data yet</td>
|
||||||
|
</tr>
|
||||||
|
<% else %>
|
||||||
|
<%= for row <- @pages do %>
|
||||||
|
<tr>
|
||||||
|
<td class="gsc-page">{format_page_url(row.keys["page"])}</td>
|
||||||
|
<td class="gsc-num">{row.clicks}</td>
|
||||||
|
<td class="gsc-num">{format_number(row.impressions)}</td>
|
||||||
|
<td class="gsc-num">{row.ctr}%</td>
|
||||||
|
<td class="gsc-num">{row.position}</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp loading_state(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="gsc-loading">
|
||||||
|
<%= if @error do %>
|
||||||
|
<p class="gsc-error">{@error}</p>
|
||||||
|
<% else %>
|
||||||
|
<p>Loading data...</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp no_site_selected(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="gsc-no-site">
|
||||||
|
<p>Select a site to view its search performance data.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
|
||||||
|
defp gsc_configured? do
|
||||||
|
System.get_env("GSC_CLIENT_ID") != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_datetime(nil), do: "Never"
|
||||||
|
|
||||||
|
defp format_datetime(dt) do
|
||||||
|
Calendar.strftime(dt, "%-d %b %Y, %H:%M")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_number(n) when n >= 1000 do
|
||||||
|
"#{Float.round(n / 1000, 1)}k"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_number(n), do: to_string(n)
|
||||||
|
|
||||||
|
defp format_page_url(url) when is_binary(url) do
|
||||||
|
case URI.parse(url) do
|
||||||
|
%{path: path} when is_binary(path) and path != "" -> path
|
||||||
|
_ -> url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_page_url(url), do: url
|
||||||
|
end
|
||||||
@@ -7,6 +7,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
alias Berrypod.Theme.{Fonts, PreviewData}
|
alias Berrypod.Theme.{Fonts, PreviewData}
|
||||||
|
|
||||||
import BerrypodWeb.BlockEditorComponents
|
import BerrypodWeb.BlockEditorComponents
|
||||||
|
import BerrypodWeb.Components.SeoChecklist
|
||||||
|
import BerrypodWeb.Components.SeoPreview
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"slug" => slug}, _session, socket) do
|
def mount(%{"slug" => slug}, _session, socket) do
|
||||||
@@ -503,19 +505,77 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("show_og_picker", _params, socket) do
|
||||||
|
images = Media.list_images()
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:settings_og_picker_open, true)
|
||||||
|
|> assign(:settings_og_picker_images, images)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("hide_og_picker", _params, socket) do
|
||||||
|
{:noreply, assign(socket, :settings_og_picker_open, false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("pick_og_image", %{"image-id" => image_id}, socket) do
|
||||||
|
image = Media.get_image(image_id)
|
||||||
|
|
||||||
|
# Update the form with the new og_image_id
|
||||||
|
current_params = socket.assigns.settings_form.params || %{}
|
||||||
|
params = Map.put(current_params, "og_image_id", image_id)
|
||||||
|
|
||||||
|
form =
|
||||||
|
socket.assigns.page_struct
|
||||||
|
|> Page.custom_changeset(params)
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|> to_form()
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:settings_form, form)
|
||||||
|
|> assign(:settings_og_image, image)
|
||||||
|
|> assign(:settings_og_picker_open, false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("clear_og_image", _params, socket) do
|
||||||
|
current_params = socket.assigns.settings_form.params || %{}
|
||||||
|
params = Map.put(current_params, "og_image_id", nil)
|
||||||
|
|
||||||
|
form =
|
||||||
|
socket.assigns.page_struct
|
||||||
|
|> Page.custom_changeset(params)
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|> to_form()
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:settings_form, form)
|
||||||
|
|> assign(:settings_og_image, nil)}
|
||||||
|
end
|
||||||
|
|
||||||
defp assign_settings_form(socket, slug) do
|
defp assign_settings_form(socket, slug) do
|
||||||
if Page.system_slug?(slug) do
|
if Page.system_slug?(slug) do
|
||||||
socket
|
socket
|
||||||
|> assign(:show_settings, false)
|
|> assign(:show_settings, false)
|
||||||
|> assign(:page_struct, nil)
|
|> assign(:page_struct, nil)
|
||||||
|> assign(:settings_form, nil)
|
|> assign(:settings_form, nil)
|
||||||
|
|> assign(:settings_og_image, nil)
|
||||||
|
|> assign(:settings_og_picker_open, false)
|
||||||
|
|> assign(:settings_og_picker_images, [])
|
||||||
else
|
else
|
||||||
page_struct = Pages.get_page_struct(slug)
|
page_struct = Pages.get_page_struct(slug)
|
||||||
|
|
||||||
|
og_image =
|
||||||
|
if page_struct.og_image_id, do: Media.get_image(page_struct.og_image_id), else: nil
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:show_settings, false)
|
|> assign(:show_settings, false)
|
||||||
|> assign(:page_struct, page_struct)
|
|> assign(:page_struct, page_struct)
|
||||||
|> assign(:settings_form, to_form(Page.custom_changeset(page_struct, %{})))
|
|> assign(:settings_form, to_form(Page.custom_changeset(page_struct, %{})))
|
||||||
|
|> assign(:settings_og_image, og_image)
|
||||||
|
|> assign(:settings_og_picker_open, false)
|
||||||
|
|> assign(:settings_og_picker_images, [])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -631,6 +691,88 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
label="Meta description"
|
label="Meta description"
|
||||||
phx-no-feedback
|
phx-no-feedback
|
||||||
/>
|
/>
|
||||||
|
<.input
|
||||||
|
field={@settings_form[:meta_robots]}
|
||||||
|
type="select"
|
||||||
|
label="Search engine indexing"
|
||||||
|
options={meta_robots_options()}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@settings_form[:focus_keyword]}
|
||||||
|
label="Focus keyword"
|
||||||
|
placeholder="e.g. handmade prints"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<%!-- Social sharing image --%>
|
||||||
|
<div class="page-settings-og-image">
|
||||||
|
<label class="admin-label">Social sharing image</label>
|
||||||
|
<p class="admin-hint">Shown when this page is shared on social media</p>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="page[og_image_id]"
|
||||||
|
value={@settings_og_image && @settings_og_image.id}
|
||||||
|
/>
|
||||||
|
<%= if @settings_og_image do %>
|
||||||
|
<div class="page-settings-og-preview">
|
||||||
|
<img
|
||||||
|
src={media_image_url(@settings_og_image, 400)}
|
||||||
|
alt="Social preview"
|
||||||
|
class="page-settings-og-thumb"
|
||||||
|
/>
|
||||||
|
<div class="page-settings-og-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="show_og_picker"
|
||||||
|
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="clear_og_image"
|
||||||
|
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="show_og_picker"
|
||||||
|
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||||
|
>
|
||||||
|
Select image
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- SEO analysis section --%>
|
||||||
|
<details class="page-settings-seo-preview">
|
||||||
|
<summary class="page-settings-seo-summary">
|
||||||
|
SEO analysis
|
||||||
|
</summary>
|
||||||
|
<div class="page-settings-seo-content">
|
||||||
|
<.seo_checklist page={seo_analysis_page(@settings_form, @blocks)} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<%!-- SEO preview section --%>
|
||||||
|
<details class="page-settings-seo-preview">
|
||||||
|
<summary class="page-settings-seo-summary">
|
||||||
|
SEO preview
|
||||||
|
</summary>
|
||||||
|
<div class="page-settings-seo-content">
|
||||||
|
<.seo_preview
|
||||||
|
title={seo_preview_title(@settings_form, @site_name)}
|
||||||
|
description={seo_preview_description(@settings_form)}
|
||||||
|
url={seo_preview_url(@settings_form)}
|
||||||
|
og_image={seo_preview_og_image(@settings_og_image)}
|
||||||
|
site_name={@site_name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="page-settings-row">
|
<div class="page-settings-row">
|
||||||
<.input field={@settings_form[:published]} type="checkbox" label="Published" />
|
<.input field={@settings_form[:published]} type="checkbox" label="Published" />
|
||||||
<.input
|
<.input
|
||||||
@@ -717,6 +859,52 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
search={@image_picker_search}
|
search={@image_picker_search}
|
||||||
upload={@uploads.image_picker_upload}
|
upload={@uploads.image_picker_upload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<%!-- OG image picker modal --%>
|
||||||
|
<.og_image_picker :if={@settings_og_picker_open} images={@settings_og_picker_images} />
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp og_image_picker(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="admin-modal-backdrop" phx-click="hide_og_picker">
|
||||||
|
<div class="admin-modal admin-modal-lg" phx-click-away="hide_og_picker">
|
||||||
|
<div class="admin-modal-header">
|
||||||
|
<h2>Select social sharing image</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="hide_og_picker"
|
||||||
|
class="admin-modal-close"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="admin-modal-hint">
|
||||||
|
Recommended: 1200×630px for best results on social platforms
|
||||||
|
</p>
|
||||||
|
<div class="admin-modal-body">
|
||||||
|
<div class="image-picker-grid">
|
||||||
|
<button
|
||||||
|
:for={image <- @images}
|
||||||
|
type="button"
|
||||||
|
phx-click="pick_og_image"
|
||||||
|
phx-value-image-id={image.id}
|
||||||
|
class="image-picker-item"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={media_image_url(image, 200)}
|
||||||
|
alt={image.alt || ""}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<%= if @images == [] do %>
|
||||||
|
<p class="image-picker-empty">No images in media library yet</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -864,4 +1052,62 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
|
|
||||||
defp default_nav_items("header"), do: Site.default_header_nav()
|
defp default_nav_items("header"), do: Site.default_header_nav()
|
||||||
defp default_nav_items("footer"), do: Site.default_footer_nav()
|
defp default_nav_items("footer"), do: Site.default_footer_nav()
|
||||||
|
|
||||||
|
defp meta_robots_options do
|
||||||
|
[
|
||||||
|
{"Index this page (default)", "index, follow"},
|
||||||
|
{"Don't index this page", "noindex, follow"},
|
||||||
|
{"Index but don't follow links", "index, nofollow"},
|
||||||
|
{"Don't index or follow links", "noindex, nofollow"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# SEO preview helpers — extract values from the live form
|
||||||
|
defp seo_preview_title(form, site_name) do
|
||||||
|
title = Phoenix.HTML.Form.input_value(form, :title) || ""
|
||||||
|
if title == "", do: site_name, else: "#{title} · #{site_name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seo_preview_description(form) do
|
||||||
|
Phoenix.HTML.Form.input_value(form, :meta_description) || ""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seo_preview_url(form) do
|
||||||
|
slug = Phoenix.HTML.Form.input_value(form, :slug) || ""
|
||||||
|
base = BerrypodWeb.Endpoint.url()
|
||||||
|
if slug == "", do: base, else: "#{base}/#{slug}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Build page map for SEO analysis
|
||||||
|
defp seo_analysis_page(form, blocks) do
|
||||||
|
%{
|
||||||
|
focus_keyword: Phoenix.HTML.Form.input_value(form, :focus_keyword),
|
||||||
|
title: Phoenix.HTML.Form.input_value(form, :title),
|
||||||
|
meta_description: Phoenix.HTML.Form.input_value(form, :meta_description),
|
||||||
|
slug: Phoenix.HTML.Form.input_value(form, :slug),
|
||||||
|
blocks: blocks
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seo_preview_og_image(nil), do: nil
|
||||||
|
|
||||||
|
defp seo_preview_og_image(image) do
|
||||||
|
media_image_url(image, 400)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate a URL for a media library image at the given width
|
||||||
|
defp media_image_url(nil, _width), do: nil
|
||||||
|
|
||||||
|
defp media_image_url(image, width) do
|
||||||
|
if image.is_svg do
|
||||||
|
"/image_cache/#{image.id}.webp"
|
||||||
|
else
|
||||||
|
applicable_width =
|
||||||
|
image.source_width
|
||||||
|
|> Berrypod.Images.Optimizer.applicable_widths()
|
||||||
|
|> Enum.find(&(&1 >= width))
|
||||||
|
|
||||||
|
"/image_cache/#{image.id}-#{applicable_width || width}.webp"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
defmodule BerrypodWeb.Admin.Settings do
|
defmodule BerrypodWeb.Admin.Settings do
|
||||||
use BerrypodWeb, :live_view
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
|
alias Berrypod.Media
|
||||||
alias Berrypod.Products
|
alias Berrypod.Products
|
||||||
alias Berrypod.Settings
|
alias Berrypod.Settings
|
||||||
alias Berrypod.Stripe.Setup, as: StripeSetup
|
alias Berrypod.Stripe.Setup, as: StripeSetup
|
||||||
@@ -19,7 +20,17 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
|> assign(:signing_secret_status, :idle)
|
|> assign(:signing_secret_status, :idle)
|
||||||
|> assign_stripe_state()
|
|> assign_stripe_state()
|
||||||
|> assign_products_state()
|
|> assign_products_state()
|
||||||
|> assign_url_prefixes()}
|
|> assign_url_prefixes()
|
||||||
|
|> assign_og_image_state()}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assign_og_image_state(socket) do
|
||||||
|
og_image = Media.get_default_og_image()
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:og_image, og_image)
|
||||||
|
|> assign(:og_picker_open, false)
|
||||||
|
|> assign(:og_picker_images, [])
|
||||||
end
|
end
|
||||||
|
|
||||||
defp assign_url_prefixes(socket) do
|
defp assign_url_prefixes(socket) do
|
||||||
@@ -170,6 +181,44 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# -- Events: OG image --
|
||||||
|
|
||||||
|
def handle_event("show_og_picker", _params, socket) do
|
||||||
|
images = Media.list_images() |> Enum.take(50)
|
||||||
|
{:noreply, assign(socket, og_picker_open: true, og_picker_images: images)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("hide_og_picker", _params, socket) do
|
||||||
|
{:noreply, assign(socket, og_picker_open: false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("pick_og_image", %{"id" => id}, socket) do
|
||||||
|
image = Media.get_image(id)
|
||||||
|
|
||||||
|
if image do
|
||||||
|
Media.update_image_type(image, "default_og")
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:og_image, image)
|
||||||
|
|> assign(:og_picker_open, false)
|
||||||
|
|> put_flash(:info, "Default social image set")}
|
||||||
|
else
|
||||||
|
{:noreply, put_flash(socket, :error, "Image not found")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("clear_og_image", _params, socket) do
|
||||||
|
if socket.assigns.og_image do
|
||||||
|
Media.update_image_type(socket.assigns.og_image, "media")
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:og_image, nil)
|
||||||
|
|> put_flash(:info, "Default social image removed")}
|
||||||
|
end
|
||||||
|
|
||||||
# -- Events: Stripe --
|
# -- Events: Stripe --
|
||||||
|
|
||||||
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
||||||
@@ -502,10 +551,113 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<%!-- Default social image --%>
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2 class="admin-section-title">Default social image</h2>
|
||||||
|
<p class="admin-section-desc">
|
||||||
|
The image shown when pages are shared on social media.
|
||||||
|
Individual pages can override this in their settings.
|
||||||
|
</p>
|
||||||
|
<div class="admin-section-body">
|
||||||
|
<%= if @og_image do %>
|
||||||
|
<div class="page-settings-og-preview">
|
||||||
|
<img
|
||||||
|
src={og_image_url(@og_image)}
|
||||||
|
alt="Current social image"
|
||||||
|
class="page-settings-og-thumb"
|
||||||
|
/>
|
||||||
|
<div class="page-settings-og-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="show_og_picker"
|
||||||
|
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="clear_og_image"
|
||||||
|
class="admin-link-danger"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="show_og_picker"
|
||||||
|
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||||
|
>
|
||||||
|
<.icon name="hero-photo" class="size-4" /> Choose image
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<.og_picker_modal
|
||||||
|
:if={@og_picker_open}
|
||||||
|
images={@og_picker_images}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp og_picker_modal(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="admin-modal-backdrop" phx-click="hide_og_picker">
|
||||||
|
<div class="admin-modal admin-modal-lg" phx-click-away="hide_og_picker">
|
||||||
|
<div class="admin-modal-header">
|
||||||
|
<h3>Choose social image</h3>
|
||||||
|
<button type="button" phx-click="hide_og_picker" class="admin-modal-close">
|
||||||
|
<.icon name="hero-x-mark" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-modal-body">
|
||||||
|
<p class="admin-help-text" style="margin-bottom: 1rem;">
|
||||||
|
Choose an image from your media library.
|
||||||
|
Recommended size: 1200×630 pixels.
|
||||||
|
</p>
|
||||||
|
<%= if @images == [] do %>
|
||||||
|
<p class="admin-text-secondary">
|
||||||
|
No images in your media library.
|
||||||
|
<.link navigate={~p"/admin/media"} class="admin-link">Upload images</.link>
|
||||||
|
first.
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="og-picker-grid">
|
||||||
|
<%= for image <- @images do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="pick_og_image"
|
||||||
|
phx-value-id={image.id}
|
||||||
|
class="og-picker-item"
|
||||||
|
>
|
||||||
|
<img src={og_image_url(image)} alt={image.filename} />
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp og_image_url(image) do
|
||||||
|
if image.is_svg do
|
||||||
|
"/image_cache/#{image.id}.webp"
|
||||||
|
else
|
||||||
|
applicable_width =
|
||||||
|
image.source_width
|
||||||
|
|> Berrypod.Images.Optimizer.applicable_widths()
|
||||||
|
|> Enum.find(&(&1 >= 400))
|
||||||
|
|
||||||
|
"/image_cache/#{image.id}-#{applicable_width || 400}.webp"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# -- Function components --
|
# -- Function components --
|
||||||
|
|
||||||
attr :color, :string, required: true
|
attr :color, :string, required: true
|
||||||
|
|||||||
@@ -14,10 +14,21 @@ defmodule BerrypodWeb.Shop.Pages.Cart do
|
|||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Cart")
|
|> assign(:page_title, "Cart")
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|
|> maybe_assign_meta_robots(page)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_assign_meta_robots(socket, page) do
|
||||||
|
meta_robots = page && page[:meta_robots]
|
||||||
|
|
||||||
|
if meta_robots && meta_robots != "index, follow" do
|
||||||
|
assign(socket, :meta_robots, meta_robots)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_params(_params, _uri, socket) do
|
def handle_params(_params, _uri, socket) do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
|
|||||||
|> assign(:page_title, "Order confirmed")
|
|> assign(:page_title, "Order confirmed")
|
||||||
|> assign(:order, order)
|
|> assign(:order, order)
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|
|> maybe_assign_meta_robots(page)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
@@ -58,6 +59,16 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
|
|||||||
{:redirect, redirect(socket, to: R.home())}
|
{:redirect, redirect(socket, to: R.home())}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_assign_meta_robots(socket, page) do
|
||||||
|
meta_robots = page && page[:meta_robots]
|
||||||
|
|
||||||
|
if meta_robots && meta_robots != "index, follow" do
|
||||||
|
assign(socket, :meta_robots, meta_robots)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_params(_params, _uri, socket) do
|
def handle_params(_params, _uri, socket) do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
|
|||||||
import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, put_flash: 3]
|
import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, put_flash: 3]
|
||||||
|
|
||||||
alias Berrypod.{Pages, Pagination, Products}
|
alias Berrypod.{Pages, Pagination, Products}
|
||||||
|
alias BerrypodWeb.Helpers.SeoHelpers
|
||||||
alias BerrypodWeb.R
|
alias BerrypodWeb.R
|
||||||
|
|
||||||
@sort_options [
|
@sort_options [
|
||||||
@@ -20,16 +21,29 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
|
|||||||
|
|
||||||
def init(socket, _params, _uri) do
|
def init(socket, _params, _uri) do
|
||||||
page = Pages.get_page("collection")
|
page = Pages.get_page("collection")
|
||||||
|
base = BerrypodWeb.Endpoint.url()
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|> assign(:sort_options, @sort_options)
|
|> assign(:sort_options, @sort_options)
|
||||||
|> assign(:current_sort, "featured")
|
|> assign(:current_sort, "featured")
|
||||||
|
|> maybe_assign_meta_robots(page)
|
||||||
|
|> SeoHelpers.assign_og_image(page, base)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_assign_meta_robots(socket, page) do
|
||||||
|
meta_robots = page && page[:meta_robots]
|
||||||
|
|
||||||
|
if meta_robots && meta_robots != "index, follow" do
|
||||||
|
assign(socket, :meta_robots, meta_robots)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# When accessed via custom URL (e.g. /shop) without a collection slug, show all products
|
# When accessed via custom URL (e.g. /shop) without a collection slug, show all products
|
||||||
def handle_params(params, uri, socket) when not is_map_key(params, "slug") do
|
def handle_params(params, uri, socket) when not is_map_key(params, "slug") do
|
||||||
handle_params(Map.put(params, "slug", "all"), uri, socket)
|
handle_params(Map.put(params, "slug", "all"), uri, socket)
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ defmodule BerrypodWeb.Shop.Pages.Contact do
|
|||||||
alias BerrypodWeb.R
|
alias BerrypodWeb.R
|
||||||
alias Berrypod.Orders.OrderNotifier
|
alias Berrypod.Orders.OrderNotifier
|
||||||
alias Berrypod.Pages
|
alias Berrypod.Pages
|
||||||
|
alias BerrypodWeb.Helpers.SeoHelpers
|
||||||
alias BerrypodWeb.OrderLookupController
|
alias BerrypodWeb.OrderLookupController
|
||||||
|
|
||||||
def init(socket, _params, _uri) do
|
def init(socket, _params, _uri) do
|
||||||
page = Pages.get_page("contact")
|
page = Pages.get_page("contact")
|
||||||
|
base = BerrypodWeb.Endpoint.url()
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -25,10 +27,22 @@ defmodule BerrypodWeb.Shop.Pages.Contact do
|
|||||||
|> assign(:og_url, R.url(R.contact()))
|
|> assign(:og_url, R.url(R.contact()))
|
||||||
|> assign(:tracking_state, :idle)
|
|> assign(:tracking_state, :idle)
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|
|> maybe_assign_meta_robots(page)
|
||||||
|
|> SeoHelpers.assign_og_image(page, base)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_assign_meta_robots(socket, page) do
|
||||||
|
meta_robots = page && page[:meta_robots]
|
||||||
|
|
||||||
|
if meta_robots && meta_robots != "index, follow" do
|
||||||
|
assign(socket, :meta_robots, meta_robots)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_params(_params, _uri, socket) do
|
def handle_params(_params, _uri, socket) do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
|
|||||||
alias Berrypod.LegalPages
|
alias Berrypod.LegalPages
|
||||||
alias Berrypod.Pages
|
alias Berrypod.Pages
|
||||||
alias Berrypod.Theme.PreviewData
|
alias Berrypod.Theme.PreviewData
|
||||||
|
alias BerrypodWeb.Helpers.SeoHelpers
|
||||||
alias BerrypodWeb.R
|
alias BerrypodWeb.R
|
||||||
|
|
||||||
def init(socket, _params, _uri) do
|
def init(socket, _params, _uri) do
|
||||||
@@ -27,10 +28,22 @@ defmodule BerrypodWeb.Shop.Pages.Content do
|
|||||||
|> assign(seo)
|
|> assign(seo)
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|> assign(:content_blocks, content_blocks)
|
|> assign(:content_blocks, content_blocks)
|
||||||
|
|> assign(:json_ld, SeoHelpers.faq_json_ld(page && page.blocks))
|
||||||
|
|> maybe_assign_meta_robots(page)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_assign_meta_robots(socket, page) do
|
||||||
|
meta_robots = page && page[:meta_robots]
|
||||||
|
|
||||||
|
if meta_robots && meta_robots != "index, follow" do
|
||||||
|
assign(socket, :meta_robots, meta_robots)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event(_event, _params, _socket), do: :cont
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
|
|
||||||
# Returns {seo_assigns, content_blocks} for each content page
|
# Returns {seo_assigns, content_blocks} for each content page
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
|
|||||||
import Phoenix.Component, only: [assign: 2, assign: 3]
|
import Phoenix.Component, only: [assign: 2, assign: 3]
|
||||||
|
|
||||||
alias Berrypod.Pages
|
alias Berrypod.Pages
|
||||||
|
alias BerrypodWeb.Helpers.SeoHelpers
|
||||||
|
|
||||||
def init(socket, _params, _uri) do
|
def init(socket, _params, _uri) do
|
||||||
# Custom pages load in handle_params based on slug
|
# Custom pages load in handle_params based on slug
|
||||||
@@ -55,10 +56,13 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
|
|||||||
type: page.type,
|
type: page.type,
|
||||||
published: page.published,
|
published: page.published,
|
||||||
meta_description: page.meta_description,
|
meta_description: page.meta_description,
|
||||||
|
meta_robots: page.meta_robots,
|
||||||
|
focus_keyword: page.focus_keyword,
|
||||||
url_slug: page.url_slug,
|
url_slug: page.url_slug,
|
||||||
show_in_nav: page.show_in_nav,
|
show_in_nav: page.show_in_nav,
|
||||||
nav_label: page.nav_label,
|
nav_label: page.nav_label,
|
||||||
nav_position: page.nav_position
|
nav_position: page.nav_position,
|
||||||
|
og_image_id: page.og_image_id
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -91,6 +95,7 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
|
|||||||
defp maybe_assign_meta(socket, page, base) do
|
defp maybe_assign_meta(socket, page, base) do
|
||||||
socket
|
socket
|
||||||
|> assign(:og_url, base <> "/#{page.slug}")
|
|> assign(:og_url, base <> "/#{page.slug}")
|
||||||
|
|> assign(:json_ld, SeoHelpers.faq_json_ld(page.blocks))
|
||||||
|> then(fn s ->
|
|> then(fn s ->
|
||||||
if page.meta_description do
|
if page.meta_description do
|
||||||
assign(s, :page_description, page.meta_description)
|
assign(s, :page_description, page.meta_description)
|
||||||
@@ -98,5 +103,15 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
|
|||||||
s
|
s
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|> then(fn s ->
|
||||||
|
meta_robots = page[:meta_robots]
|
||||||
|
|
||||||
|
if meta_robots && meta_robots != "index, follow" do
|
||||||
|
assign(s, :meta_robots, meta_robots)
|
||||||
|
else
|
||||||
|
s
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> SeoHelpers.assign_og_image(page, base)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ defmodule BerrypodWeb.Shop.Pages.Home do
|
|||||||
|
|
||||||
import Phoenix.Component, only: [assign: 2, assign: 3]
|
import Phoenix.Component, only: [assign: 2, assign: 3]
|
||||||
|
|
||||||
alias Berrypod.Pages
|
alias Berrypod.{Pages, Settings}
|
||||||
|
alias BerrypodWeb.Helpers.SeoHelpers
|
||||||
|
|
||||||
def init(socket, _params, _uri) do
|
def init(socket, _params, _uri) do
|
||||||
page = Pages.get_page("home")
|
page = Pages.get_page("home")
|
||||||
@@ -14,28 +15,145 @@ defmodule BerrypodWeb.Shop.Pages.Home do
|
|||||||
base = BerrypodWeb.Endpoint.url()
|
base = BerrypodWeb.Endpoint.url()
|
||||||
site_name = socket.assigns.site_name
|
site_name = socket.assigns.site_name
|
||||||
|
|
||||||
org_ld =
|
org_ld = build_organization_json_ld(socket.assigns, base, site_name)
|
||||||
Jason.encode!(
|
json_ld = combine_json_ld([org_ld, SeoHelpers.faq_json_ld(page.blocks)])
|
||||||
%{
|
|
||||||
"@context" => "https://schema.org",
|
|
||||||
"@type" => "Organization",
|
|
||||||
"name" => site_name,
|
|
||||||
"url" => base <> "/"
|
|
||||||
},
|
|
||||||
escape: :html_safe
|
|
||||||
)
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Home")
|
|> assign(:page_title, "Home")
|
||||||
|> assign(:og_url, base <> "/")
|
|> assign(:og_url, base <> "/")
|
||||||
|> assign(:json_ld, org_ld)
|
|> assign(:json_ld, json_ld)
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|
|> maybe_assign_meta_robots(page)
|
||||||
|
|> SeoHelpers.assign_og_image(page, base)
|
||||||
|> assign(extra)
|
|> assign(extra)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Combine multiple JSON-LD scripts into a single output (newline-separated)
|
||||||
|
defp combine_json_ld(ld_list) do
|
||||||
|
ld_list
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> case do
|
||||||
|
[] -> nil
|
||||||
|
[single] -> single
|
||||||
|
many -> Enum.join(many, "\n</script>\n<script type=\"application/ld+json\">\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_organization_json_ld(assigns, base_url, site_name) do
|
||||||
|
business_info = Settings.get_business_info()
|
||||||
|
|
||||||
|
org_type =
|
||||||
|
if business_info["business_type"] == "LocalBusiness",
|
||||||
|
do: "LocalBusiness",
|
||||||
|
else: "Organization"
|
||||||
|
|
||||||
|
org = %{
|
||||||
|
"@context" => "https://schema.org",
|
||||||
|
"@type" => org_type,
|
||||||
|
"name" => site_name,
|
||||||
|
"url" => base_url <> "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
org
|
||||||
|
|> maybe_add_logo(assigns[:logo_image], base_url)
|
||||||
|
|> maybe_add_contact_point(business_info)
|
||||||
|
|> maybe_add_address(business_info)
|
||||||
|
|> maybe_add_same_as(assigns[:social_links])
|
||||||
|
|> Jason.encode!(escape: :html_safe)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_logo(org, nil, _base_url), do: org
|
||||||
|
|
||||||
|
defp maybe_add_logo(org, logo_image, base_url) do
|
||||||
|
logo_url = base_url <> "/image_cache/#{logo_image.id}.webp"
|
||||||
|
Map.put(org, "logo", logo_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_contact_point(org, business_info) do
|
||||||
|
phone = business_info["business_phone"]
|
||||||
|
email = business_info["business_email"]
|
||||||
|
|
||||||
|
cond do
|
||||||
|
present?(phone) and present?(email) ->
|
||||||
|
Map.put(org, "contactPoint", [
|
||||||
|
%{"@type" => "ContactPoint", "telephone" => phone, "contactType" => "customer service"},
|
||||||
|
%{"@type" => "ContactPoint", "email" => email, "contactType" => "customer service"}
|
||||||
|
])
|
||||||
|
|
||||||
|
present?(phone) ->
|
||||||
|
Map.put(org, "contactPoint", %{
|
||||||
|
"@type" => "ContactPoint",
|
||||||
|
"telephone" => phone,
|
||||||
|
"contactType" => "customer service"
|
||||||
|
})
|
||||||
|
|
||||||
|
present?(email) ->
|
||||||
|
Map.put(org, "contactPoint", %{
|
||||||
|
"@type" => "ContactPoint",
|
||||||
|
"email" => email,
|
||||||
|
"contactType" => "customer service"
|
||||||
|
})
|
||||||
|
|
||||||
|
true ->
|
||||||
|
org
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_address(org, business_info) do
|
||||||
|
street = business_info["address_street"]
|
||||||
|
city = business_info["address_city"]
|
||||||
|
country = business_info["address_country"]
|
||||||
|
|
||||||
|
if present?(street) or present?(city) or present?(country) do
|
||||||
|
address = %{"@type" => "PostalAddress"}
|
||||||
|
|
||||||
|
address =
|
||||||
|
address
|
||||||
|
|> maybe_put("streetAddress", street)
|
||||||
|
|> maybe_put("addressLocality", city)
|
||||||
|
|> maybe_put("addressRegion", business_info["address_region"])
|
||||||
|
|> maybe_put("postalCode", business_info["address_postal_code"])
|
||||||
|
|> maybe_put("addressCountry", country)
|
||||||
|
|
||||||
|
Map.put(org, "address", address)
|
||||||
|
else
|
||||||
|
org
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_same_as(org, nil), do: org
|
||||||
|
defp maybe_add_same_as(org, []), do: org
|
||||||
|
|
||||||
|
defp maybe_add_same_as(org, social_links) do
|
||||||
|
urls =
|
||||||
|
social_links
|
||||||
|
|> Enum.map(& &1.url)
|
||||||
|
|> Enum.filter(&present?/1)
|
||||||
|
|
||||||
|
if urls != [], do: Map.put(org, "sameAs", urls), else: org
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_put(map, _key, nil), do: map
|
||||||
|
defp maybe_put(map, _key, ""), do: map
|
||||||
|
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
||||||
|
|
||||||
|
defp present?(nil), do: false
|
||||||
|
defp present?(""), do: false
|
||||||
|
defp present?(_), do: true
|
||||||
|
|
||||||
|
defp maybe_assign_meta_robots(socket, page) do
|
||||||
|
meta_robots = page && page[:meta_robots]
|
||||||
|
|
||||||
|
if meta_robots && meta_robots != "index, follow" do
|
||||||
|
assign(socket, :meta_robots, meta_robots)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_params(_params, _uri, socket) do
|
def handle_params(_params, _uri, socket) do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,10 +18,21 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
|
|||||||
socket
|
socket
|
||||||
|> assign(:lookup_email, session["email_session"])
|
|> assign(:lookup_email, session["email_session"])
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|
|> maybe_assign_meta_robots(page)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_assign_meta_robots(socket, page) do
|
||||||
|
meta_robots = page && page[:meta_robots]
|
||||||
|
|
||||||
|
if meta_robots && meta_robots != "index, follow" do
|
||||||
|
assign(socket, :meta_robots, meta_robots)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_params(%{"order_number" => order_number}, _uri, socket) do
|
def handle_params(%{"order_number" => order_number}, _uri, socket) do
|
||||||
email = socket.assigns.lookup_email
|
email = socket.assigns.lookup_email
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ defmodule BerrypodWeb.Shop.Pages.Orders do
|
|||||||
|> assign(:page_title, "Your orders")
|
|> assign(:page_title, "Your orders")
|
||||||
|> assign(:lookup_email, email)
|
|> assign(:lookup_email, email)
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|
|> maybe_assign_meta_robots(page)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
if email do
|
if email do
|
||||||
@@ -30,6 +31,16 @@ defmodule BerrypodWeb.Shop.Pages.Orders do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_assign_meta_robots(socket, page) do
|
||||||
|
meta_robots = page && page[:meta_robots]
|
||||||
|
|
||||||
|
if meta_robots && meta_robots != "index, follow" do
|
||||||
|
assign(socket, :meta_robots, meta_robots)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_params(_params, _uri, socket) do
|
def handle_params(_params, _uri, socket) do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2]
|
import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2]
|
||||||
|
|
||||||
alias Berrypod.{Analytics, Cart, Pages, Reviews}
|
alias Berrypod.{Analytics, Cart, Pages, Reviews}
|
||||||
|
alias BerrypodWeb.Helpers.SeoHelpers
|
||||||
alias BerrypodWeb.R
|
alias BerrypodWeb.R
|
||||||
alias Berrypod.Images.Optimizer
|
alias Berrypod.Images.Optimizer
|
||||||
alias Berrypod.Products
|
alias Berrypod.Products
|
||||||
@@ -57,6 +58,12 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
page = Pages.get_page("pdp")
|
page = Pages.get_page("pdp")
|
||||||
is_discontinued = product.status == "discontinued"
|
is_discontinued = product.status == "discontinued"
|
||||||
|
|
||||||
|
product_ld =
|
||||||
|
product_json_ld(product, og_url, og_image, avg_rating, review_count, seo_reviews)
|
||||||
|
|
||||||
|
faq_ld = SeoHelpers.faq_json_ld(page.blocks)
|
||||||
|
combined_ld = combine_json_ld([product_ld, faq_ld])
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, product.title)
|
|> assign(:page_title, product.title)
|
||||||
@@ -64,10 +71,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
|> assign(:og_type, "product")
|
|> assign(:og_type, "product")
|
||||||
|> assign(:og_url, og_url)
|
|> assign(:og_url, og_url)
|
||||||
|> assign(:og_image, og_image)
|
|> assign(:og_image, og_image)
|
||||||
|> assign(
|
|> assign(:json_ld, combined_ld)
|
||||||
:json_ld,
|
|
||||||
product_json_ld(product, og_url, og_image, avg_rating, review_count, seo_reviews)
|
|
||||||
)
|
|
||||||
|> assign(:product, product)
|
|> assign(:product, product)
|
||||||
|> assign(:all_images, all_images)
|
|> assign(:all_images, all_images)
|
||||||
|> assign(:quantity, 1)
|
|> assign(:quantity, 1)
|
||||||
@@ -78,6 +82,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
|> assign(:review_form, nil)
|
|> assign(:review_form, nil)
|
||||||
|> assign(:review_status, nil)
|
|> assign(:review_status, nil)
|
||||||
|> assign(:existing_review, nil)
|
|> assign(:existing_review, nil)
|
||||||
|
|> maybe_assign_meta_robots(page)
|
||||||
|
|
||||||
# Check if user has an existing review for this product
|
# Check if user has an existing review for this product
|
||||||
socket = load_existing_review(socket)
|
socket = load_existing_review(socket)
|
||||||
@@ -488,6 +493,27 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_assign_meta_robots(socket, page) do
|
||||||
|
meta_robots = page && page[:meta_robots]
|
||||||
|
|
||||||
|
if meta_robots && meta_robots != "index, follow" do
|
||||||
|
assign(socket, :meta_robots, meta_robots)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Combine multiple JSON-LD scripts into a single output (newline-separated)
|
||||||
|
defp combine_json_ld(ld_list) do
|
||||||
|
ld_list
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> case do
|
||||||
|
[] -> nil
|
||||||
|
[single] -> single
|
||||||
|
many -> Enum.join(many, "\n</script>\n<script type=\"application/ld+json\">\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp format_review_for_display(review) do
|
defp format_review_for_display(review) do
|
||||||
%{
|
%{
|
||||||
id: review.id,
|
id: review.id,
|
||||||
|
|||||||
@@ -16,10 +16,21 @@ defmodule BerrypodWeb.Shop.Pages.Search do
|
|||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Search")
|
|> assign(:page_title, "Search")
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|
|> maybe_assign_meta_robots(page)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_assign_meta_robots(socket, page) do
|
||||||
|
meta_robots = page && page[:meta_robots]
|
||||||
|
|
||||||
|
if meta_robots && meta_robots != "index, follow" do
|
||||||
|
assign(socket, :meta_robots, meta_robots)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_params(params, _uri, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
query = params["q"] || ""
|
query = params["q"] || ""
|
||||||
results = if query != "", do: Search.search(query), else: []
|
results = if query != "", do: Search.search(query), else: []
|
||||||
|
|||||||
@@ -1285,6 +1285,11 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
handle_theme_action("remove_header", %{}, socket)
|
handle_theme_action("remove_header", %{}, socket)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp handle_site_action("update_business_info", %{"business_info" => info_params}, socket) do
|
||||||
|
socket = handle_business_info_update(socket, info_params)
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
|
||||||
# Catch-all for unknown site actions
|
# Catch-all for unknown site actions
|
||||||
defp handle_site_action(_action, _params, socket), do: {:halt, socket}
|
defp handle_site_action(_action, _params, socket), do: {:halt, socket}
|
||||||
|
|
||||||
@@ -1334,6 +1339,20 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
|> assign(:site_dirty, SiteEditorState.dirty?(state))
|
|> assign(:site_dirty, SiteEditorState.dirty?(state))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp handle_business_info_update(socket, params) do
|
||||||
|
state = socket.assigns.site_state
|
||||||
|
|
||||||
|
# Update each business info field
|
||||||
|
state =
|
||||||
|
Enum.reduce(params, state, fn {key, value}, acc ->
|
||||||
|
SiteEditorState.put_business_info(acc, key, value)
|
||||||
|
end)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:site_state, state)
|
||||||
|
|> assign(:site_dirty, SiteEditorState.dirty?(state))
|
||||||
|
end
|
||||||
|
|
||||||
# Helper to update site state and compute dirty flag
|
# Helper to update site state and compute dirty flag
|
||||||
defp update_site_state(socket, update_fn) do
|
defp update_site_state(socket, update_fn) do
|
||||||
state = socket.assigns.site_state
|
state = socket.assigns.site_state
|
||||||
|
|||||||
@@ -801,6 +801,39 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp render_block(%{block: %{"type" => "faq"}} = assigns) do
|
||||||
|
settings = assigns.block["settings"] || %{}
|
||||||
|
title = settings["title"] || ""
|
||||||
|
|
||||||
|
items =
|
||||||
|
(settings["items"] || [])
|
||||||
|
|> Enum.filter(fn item ->
|
||||||
|
q = item["question"] || ""
|
||||||
|
String.trim(q) != ""
|
||||||
|
end)
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:faq_title, title)
|
||||||
|
|> assign(:items, items)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<section class="faq-section page-container">
|
||||||
|
<h2 :if={@faq_title != ""} class="faq-title">{@faq_title}</h2>
|
||||||
|
<dl class="faq-list">
|
||||||
|
<details :for={item <- @items} class="faq-item">
|
||||||
|
<summary class="faq-question">{item["question"]}</summary>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p :for={para <- String.split(item["answer"] || "", ~r/\n{2,}/, trim: true)}>
|
||||||
|
{para}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
defp render_block(%{block: %{"type" => "trust_badges"}} = assigns) do
|
defp render_block(%{block: %{"type" => "trust_badges"}} = assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
|
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ defmodule BerrypodWeb.Router do
|
|||||||
post "/providers", ProvidersController, :create
|
post "/providers", ProvidersController, :create
|
||||||
post "/providers/:id", ProvidersController, :update
|
post "/providers/:id", ProvidersController, :update
|
||||||
|
|
||||||
|
# GSC OAuth routes
|
||||||
|
get "/gsc/connect", GSCAuthController, :connect
|
||||||
|
get "/gsc/callback", GSCAuthController, :callback
|
||||||
|
delete "/gsc/disconnect", GSCAuthController, :disconnect
|
||||||
|
|
||||||
live_session :admin,
|
live_session :admin,
|
||||||
layout: {BerrypodWeb.Layouts, :admin},
|
layout: {BerrypodWeb.Layouts, :admin},
|
||||||
on_mount: [
|
on_mount: [
|
||||||
@@ -182,6 +187,7 @@ defmodule BerrypodWeb.Router do
|
|||||||
live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new
|
live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new
|
||||||
live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit
|
live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit
|
||||||
live "/redirects", Admin.Redirects, :index
|
live "/redirects", Admin.Redirects, :index
|
||||||
|
live "/gsc", Admin.GSC, :index
|
||||||
end
|
end
|
||||||
|
|
||||||
# Theme editor redirects to on-site editing
|
# Theme editor redirects to on-site editing
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
|||||||
state = SiteEditorState.revert(state)
|
state = SiteEditorState.revert(state)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Berrypod.Site
|
alias Berrypod.{Settings, Site}
|
||||||
alias Berrypod.Site.{NavItem, SocialLink}
|
alias Berrypod.Site.{NavItem, SocialLink}
|
||||||
|
|
||||||
defstruct [
|
defstruct [
|
||||||
@@ -37,6 +37,8 @@ defmodule BerrypodWeb.SiteEditorState do
|
|||||||
:footer_about,
|
:footer_about,
|
||||||
:footer_copyright,
|
:footer_copyright,
|
||||||
:show_newsletter,
|
:show_newsletter,
|
||||||
|
# Business info (stored in Settings as JSON)
|
||||||
|
:business_info,
|
||||||
# List fields (separate tables)
|
# List fields (separate tables)
|
||||||
:header_nav,
|
:header_nav,
|
||||||
:footer_nav,
|
:footer_nav,
|
||||||
@@ -52,6 +54,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
|||||||
footer_about: String.t(),
|
footer_about: String.t(),
|
||||||
footer_copyright: String.t(),
|
footer_copyright: String.t(),
|
||||||
show_newsletter: boolean(),
|
show_newsletter: boolean(),
|
||||||
|
business_info: map(),
|
||||||
header_nav: [NavItem.t()],
|
header_nav: [NavItem.t()],
|
||||||
footer_nav: [NavItem.t()],
|
footer_nav: [NavItem.t()],
|
||||||
social_links: [SocialLink.t()],
|
social_links: [SocialLink.t()],
|
||||||
@@ -67,6 +70,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
|||||||
header_nav = Site.list_nav_items(:header)
|
header_nav = Site.list_nav_items(:header)
|
||||||
footer_nav = Site.list_nav_items(:footer)
|
footer_nav = Site.list_nav_items(:footer)
|
||||||
social_links = Site.list_social_links()
|
social_links = Site.list_social_links()
|
||||||
|
business_info = Settings.get_business_info()
|
||||||
|
|
||||||
state = %__MODULE__{
|
state = %__MODULE__{
|
||||||
announcement_text: settings.announcement_text,
|
announcement_text: settings.announcement_text,
|
||||||
@@ -75,6 +79,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
|||||||
footer_about: settings.footer_about,
|
footer_about: settings.footer_about,
|
||||||
footer_copyright: settings.footer_copyright,
|
footer_copyright: settings.footer_copyright,
|
||||||
show_newsletter: settings.show_newsletter,
|
show_newsletter: settings.show_newsletter,
|
||||||
|
business_info: business_info,
|
||||||
header_nav: header_nav,
|
header_nav: header_nav,
|
||||||
footer_nav: footer_nav,
|
footer_nav: footer_nav,
|
||||||
social_links: social_links
|
social_links: social_links
|
||||||
@@ -102,6 +107,14 @@ defmodule BerrypodWeb.SiteEditorState do
|
|||||||
Map.put(state, field, value)
|
Map.put(state, field, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Update a business info field. Returns updated state.
|
||||||
|
"""
|
||||||
|
@spec put_business_info(t(), String.t(), any()) :: t()
|
||||||
|
def put_business_info(%__MODULE__{business_info: info} = state, key, value) do
|
||||||
|
%{state | business_info: Map.put(info, key, value)}
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Add a new social link. Returns updated state.
|
Add a new social link. Returns updated state.
|
||||||
"""
|
"""
|
||||||
@@ -255,6 +268,11 @@ defmodule BerrypodWeb.SiteEditorState do
|
|||||||
state.show_newsletter
|
state.show_newsletter
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Save business info if changed
|
||||||
|
if state.business_info != state._original.business_info do
|
||||||
|
Settings.put_business_info(state.business_info)
|
||||||
|
end
|
||||||
|
|
||||||
# Sync nav items and social links
|
# Sync nav items and social links
|
||||||
header_nav = sync_nav_items(state.header_nav, state._original.header_nav)
|
header_nav = sync_nav_items(state.header_nav, state._original.header_nav)
|
||||||
footer_nav = sync_nav_items(state.footer_nav, state._original.footer_nav)
|
footer_nav = sync_nav_items(state.footer_nav, state._original.footer_nav)
|
||||||
@@ -278,6 +296,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
|||||||
footer_about: original.footer_about,
|
footer_about: original.footer_about,
|
||||||
footer_copyright: original.footer_copyright,
|
footer_copyright: original.footer_copyright,
|
||||||
show_newsletter: original.show_newsletter,
|
show_newsletter: original.show_newsletter,
|
||||||
|
business_info: original.business_info,
|
||||||
header_nav: original.header_nav,
|
header_nav: original.header_nav,
|
||||||
footer_nav: original.footer_nav,
|
footer_nav: original.footer_nav,
|
||||||
social_links: original.social_links
|
social_links: original.social_links
|
||||||
@@ -301,6 +320,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
|||||||
footer_about: state.footer_about,
|
footer_about: state.footer_about,
|
||||||
footer_copyright: state.footer_copyright,
|
footer_copyright: state.footer_copyright,
|
||||||
show_newsletter: state.show_newsletter,
|
show_newsletter: state.show_newsletter,
|
||||||
|
business_info: state.business_info,
|
||||||
header_nav: Enum.map(state.header_nav, &nav_item_snapshot/1),
|
header_nav: Enum.map(state.header_nav, &nav_item_snapshot/1),
|
||||||
footer_nav: Enum.map(state.footer_nav, &nav_item_snapshot/1),
|
footer_nav: Enum.map(state.footer_nav, &nav_item_snapshot/1),
|
||||||
social_links: Enum.map(state.social_links, &social_link_snapshot/1)
|
social_links: Enum.map(state.social_links, &social_link_snapshot/1)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
defmodule Berrypod.Repo.Migrations.AddSeoFieldsToPages do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:pages) do
|
||||||
|
add :meta_robots, :string, default: "index, follow"
|
||||||
|
add :focus_keyword, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule Berrypod.Repo.Migrations.AddOgImageToPages do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:pages) do
|
||||||
|
add :og_image_id, :binary_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user