diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index e02245f..d842581 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -757,6 +757,52 @@ 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 ── */ .admin-badge { @@ -6622,4 +6668,525 @@ 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 */ diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css index 4b1fc2b..33e5fc8 100644 --- a/assets/css/shop/components.css +++ b/assets/css/shop/components.css @@ -3658,3 +3658,77 @@ 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; + } + } + } +} diff --git a/docs/PROJECT-OVERVIEW.md b/docs/PROJECT-OVERVIEW.md new file mode 100644 index 0000000..044b1d3 --- /dev/null +++ b/docs/PROJECT-OVERVIEW.md @@ -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 diff --git a/lib/berrypod/application.ex b/lib/berrypod/application.ex index 72cace1..98d2387 100644 --- a/lib/berrypod/application.ex +++ b/lib/berrypod/application.ex @@ -46,7 +46,9 @@ defmodule Berrypod.Application do # Page definition cache - loads page block lists into ETS Berrypod.Pages.PageCache, # 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 diff --git a/lib/berrypod/gsc/cache.ex b/lib/berrypod/gsc/cache.ex new file mode 100644 index 0000000..350483b --- /dev/null +++ b/lib/berrypod/gsc/cache.ex @@ -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 diff --git a/lib/berrypod/gsc/client.ex b/lib/berrypod/gsc/client.ex new file mode 100644 index 0000000..2da8ef5 --- /dev/null +++ b/lib/berrypod/gsc/client.ex @@ -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 diff --git a/lib/berrypod/gsc/oauth.ex b/lib/berrypod/gsc/oauth.ex new file mode 100644 index 0000000..c645117 --- /dev/null +++ b/lib/berrypod/gsc/oauth.ex @@ -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 diff --git a/lib/berrypod/media.ex b/lib/berrypod/media.ex index e610fce..d8f5ff7 100644 --- a/lib/berrypod/media.ex +++ b/lib/berrypod/media.ex @@ -167,6 +167,8 @@ defmodule Berrypod.Media do nil """ + def get_image(nil), do: nil + def get_image(id) do Repo.get(ImageSchema, id) end @@ -252,6 +254,39 @@ defmodule Berrypod.Media do 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 """ Deletes an image. @@ -361,6 +396,26 @@ defmodule Berrypod.Media do |> Repo.update() 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 """ Returns a list of places an image is referenced. @@ -533,5 +588,8 @@ defmodule Berrypod.Media do defp invalidate_media_cache("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 end diff --git a/lib/berrypod/media/image.ex b/lib/berrypod/media/image.ex index 5d3a2a8..421dcb1 100644 --- a/lib/berrypod/media/image.ex +++ b/lib/berrypod/media/image.ex @@ -46,7 +46,7 @@ defmodule Berrypod.Media.Image do :dominant_colors ]) |> 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) |> detect_svg() end diff --git a/lib/berrypod/pages.ex b/lib/berrypod/pages.ex index e27237f..9114f6d 100644 --- a/lib/berrypod/pages.ex +++ b/lib/berrypod/pages.ex @@ -292,6 +292,9 @@ defmodule Berrypod.Pages do type: page.type || "system", published: page.published, 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, nav_label: page.nav_label, nav_position: page.nav_position, diff --git a/lib/berrypod/pages/block_types.ex b/lib/berrypod/pages/block_types.ex index e3194ea..335af83 100644 --- a/lib/berrypod/pages/block_types.ex +++ b/lib/berrypod/pages/block_types.ex @@ -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 ────────────────────────────────────────────────── diff --git a/lib/berrypod/pages/page.ex b/lib/berrypod/pages/page.ex index 23c1533..471f3a5 100644 --- a/lib/berrypod/pages/page.ex +++ b/lib/berrypod/pages/page.ex @@ -18,6 +18,13 @@ defmodule Berrypod.Pages.Page do sitemap.xml robots.txt setup dev ) + @meta_robots_options [ + "index, follow", + "noindex, follow", + "index, nofollow", + "noindex, nofollow" + ] + schema "pages" do field :slug, :string field :title, :string @@ -25,10 +32,13 @@ defmodule Berrypod.Pages.Page do field :type, :string, default: "system" field :published, :boolean, default: true field :meta_description, :string + field :meta_robots, :string, default: "index, follow" + field :focus_keyword, :string field :show_in_nav, :boolean, default: false field :nav_label, :string field :nav_position, :integer field :url_slug, :string + field :og_image_id, :binary_id timestamps(type: :utc_datetime) end @@ -52,9 +62,11 @@ defmodule Berrypod.Pages.Page do def system_changeset(page, attrs) do 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_inclusion(:slug, @system_slugs) + |> validate_inclusion(:meta_robots, @meta_robots_options) + |> validate_length(:meta_description, max: 300) |> validate_url_slug() |> unique_constraint(:slug) |> unique_constraint(:url_slug) @@ -69,10 +81,13 @@ defmodule Berrypod.Pages.Page do :type, :published, :meta_description, + :meta_robots, + :focus_keyword, :show_in_nav, :nav_label, :nav_position, - :url_slug + :url_slug, + :og_image_id ]) |> validate_required([:slug, :title]) |> 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(:title, max: 200) |> validate_length(:meta_description, max: 300) + |> validate_inclusion(:meta_robots, @meta_robots_options) + |> validate_length(:focus_keyword, max: 100) |> validate_url_slug() |> put_defaults() |> unique_constraint(:slug) @@ -131,6 +148,7 @@ defmodule Berrypod.Pages.Page do def system_slugs, do: @system_slugs def reserved_paths, do: @reserved_paths + def meta_robots_options, do: @meta_robots_options def system_slug?(slug), do: slug in @system_slugs def reserved_path?(slug), do: slug in @reserved_paths end diff --git a/lib/berrypod/products.ex b/lib/berrypod/products.ex index dbbe65d..423ece6 100644 --- a/lib/berrypod/products.ex +++ b/lib/berrypod/products.ex @@ -258,6 +258,21 @@ defmodule Berrypod.Products do |> Repo.preload(listing_preloads()) 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 """ Like `list_visible_products/1` but returns a `%Pagination{}` struct. diff --git a/lib/berrypod/seo/analyser.ex b/lib/berrypod/seo/analyser.ex new file mode 100644 index 0000000..c774769 --- /dev/null +++ b/lib/berrypod/seo/analyser.ex @@ -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 diff --git a/lib/berrypod/settings.ex b/lib/berrypod/settings.ex index c3b344f..571fd5a 100644 --- a/lib/berrypod/settings.ex +++ b/lib/berrypod/settings.ex @@ -443,6 +443,36 @@ defmodule Berrypod.Settings do :ok 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 defp fetch_setting(key) do diff --git a/lib/berrypod_web/components/layouts/shop_root.html.heex b/lib/berrypod_web/components/layouts/shop_root.html.heex index 7ee8925..52f25b4 100644 --- a/lib/berrypod_web/components/layouts/shop_root.html.heex +++ b/lib/berrypod_web/components/layouts/shop_root.html.heex @@ -11,6 +11,9 @@ "Welcome to #{@site_name}" } /> + <%= if assigns[:meta_robots] && assigns[:meta_robots] != "index, follow" do %> + + <% end %> diff --git a/lib/berrypod_web/components/seo_checklist.ex b/lib/berrypod_web/components/seo_checklist.ex new file mode 100644 index 0000000..684ee12 --- /dev/null +++ b/lib/berrypod_web/components/seo_checklist.ex @@ -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""" +
+
+ {@score}% + SEO score +
+ +
+ """ + 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 diff --git a/lib/berrypod_web/components/seo_preview.ex b/lib/berrypod_web/components/seo_preview.ex new file mode 100644 index 0000000..e1770dc --- /dev/null +++ b/lib/berrypod_web/components/seo_preview.ex @@ -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""" +
+ <.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} /> +
+ """ + 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""" +
+
Google search preview
+
+
{@breadcrumb}
+
{@truncated_title}
+
{@truncated_desc}
+
+
+ """ + 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""" +
+
Social card preview
+
+
+ <%= if @image do %> + + <% else %> +
+ No image set +
+ <% end %> +
+
+
{@domain}
+
{@truncated_title}
+
{@truncated_desc}
+
+
+
+ """ + 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""" +
+
+ Title + {@title_len}/60 + +
+
+ Description + {@desc_len}/160 + +
+
+ """ + 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 diff --git a/lib/berrypod_web/components/shop_components/site_editor.ex b/lib/berrypod_web/components/shop_components/site_editor.ex index 407beac..2de80e8 100644 --- a/lib/berrypod_web/components/shop_components/site_editor.ex +++ b/lib/berrypod_web/components/shop_components/site_editor.ex @@ -64,6 +64,7 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do |> assign(:header_nav, state.header_nav) |> assign(:footer_nav, state.footer_nav) |> assign(:social_links, state.social_links) + |> assign(:business_info, state.business_info) ~H"""
@@ -109,6 +110,10 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do <.site_section title="Social links" icon="hero-link"> <.social_links_editor links={@social_links} event_prefix={@event_prefix} /> + + <.site_section title="Business info" icon="hero-building-storefront"> + <.business_info_editor info={@business_info} event_prefix={@event_prefix} /> +
""" end @@ -856,6 +861,144 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do """ end + # ── Business Info Editor ──────────────────────────────────────────── + + attr :info, :map, required: true + attr :event_prefix, :string, default: "site_" + + defp business_info_editor(assigns) do + ~H""" +
"update_business_info"}> +

+ Used for rich snippets in search results and business schema data. +

+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + <%!-- Address fields shown for LocalBusiness --%> +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ """ + end + # ── Helpers ───────────────────────────────────────────────────────── # Social diff --git a/lib/berrypod_web/controllers/gsc_auth_controller.ex b/lib/berrypod_web/controllers/gsc_auth_controller.ex new file mode 100644 index 0000000..c4b63c1 --- /dev/null +++ b/lib/berrypod_web/controllers/gsc_auth_controller.ex @@ -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 diff --git a/lib/berrypod_web/controllers/seo_controller.ex b/lib/berrypod_web/controllers/seo_controller.ex index f275d38..4d732ed 100644 --- a/lib/berrypod_web/controllers/seo_controller.ex +++ b/lib/berrypod_web/controllers/seo_controller.ex @@ -26,48 +26,66 @@ defmodule BerrypodWeb.SeoController do def sitemap(conn, _params) do base = BerrypodWeb.Endpoint.url() - products = Products.list_visible_products() + products = Products.list_products_for_sitemap() categories = Products.list_categories() static_pages = [ - {R.home(), "daily", "1.0"}, - {R.collection("all"), "daily", "0.9"}, - {R.about(), "monthly", "0.5"}, - {R.contact(), "monthly", "0.5"}, - {R.delivery(), "monthly", "0.5"}, - {R.privacy(), "monthly", "0.3"}, - {R.terms(), "monthly", "0.3"} + {R.home(), "daily", "1.0", []}, + {R.collection("all"), "daily", "0.9", []}, + {R.about(), "monthly", "0.5", []}, + {R.contact(), "monthly", "0.5", []}, + {R.delivery(), "monthly", "0.5", []}, + {R.privacy(), "monthly", "0.3", []}, + {R.terms(), "monthly", "0.3", []} ] category_pages = Enum.map(categories, fn cat -> - {R.collection(cat.slug), "daily", "0.8"} + {R.collection(cat.slug), "daily", "0.8", []} end) product_pages = 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) custom_pages = Pages.list_custom_pages() |> 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 entries = - Enum.map_join(all_pages, "\n", fn {path, changefreq, priority} -> - " \n" <> - " #{base}#{path}\n" <> - " #{changefreq}\n" <> - " #{priority}\n" <> - " " + Enum.map_join(all_pages, "\n", fn {path, changefreq, priority, images} -> + image_tags = + Enum.map_join(images, "\n", fn img -> + """ + + #{xml_escape(img.url)} + #{xml_escape(img.title)} + + """ + |> String.trim_trailing() + end) + + url_content = + " #{xml_escape(base <> path)}\n" <> + " #{changefreq}\n" <> + " #{priority}" + + if image_tags == "" do + " \n#{url_content}\n " + else + " \n#{url_content}\n#{image_tags}\n " + end end) xml = """ - + #{entries} """ @@ -76,4 +94,27 @@ defmodule BerrypodWeb.SeoController do |> put_resp_content_type("application/xml") |> send_resp(200, xml) 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 diff --git a/lib/berrypod_web/helpers/seo_helpers.ex b/lib/berrypod_web/helpers/seo_helpers.ex new file mode 100644 index 0000000..2b30c44 --- /dev/null +++ b/lib/berrypod_web/helpers/seo_helpers.ex @@ -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 diff --git a/lib/berrypod_web/live/admin/gsc.ex b/lib/berrypod_web/live/admin/gsc.ex new file mode 100644 index 0000000..aff7034 --- /dev/null +++ b/lib/berrypod_web/live/admin/gsc.ex @@ -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 +

+ See how your site performs in Google search results +

+ +
+ <%= 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 %> +
+ """ + end + + # Connection card for when not connected + defp connection_card(assigns) do + ~H""" +
+
+ <.icon name="hero-magnifying-glass" class="size-8" /> +
+

Connect Google Search Console

+

+ See your search performance data, top queries, and page rankings + directly in your admin dashboard. +

+ <%= if @configured do %> + + Connect with Google + + <% else %> +

+ Set GSC_CLIENT_ID + and GSC_CLIENT_SECRET + environment variables to enable this feature. +

+ <% end %> +
+ """ + end + + # Site selector dropdown + defp site_selector(assigns) do + ~H""" +
+
+ <%= if @sites == [] do %> + + <% else %> +
+ + +
+ <% end %> + + + Disconnect + +
+
+ """ + end + + # Data header with refresh button and last updated + defp data_header(assigns) do + ~H""" +
+ + Last updated: {format_datetime(@updated_at)} + + +
+ """ + end + + # Metrics grid with queries and pages tables + defp metrics_grid(assigns) do + ~H""" +
+
+

Top queries

+

What people search for to find your site

+ <.queries_table queries={@data.top_queries} /> +
+ +
+

Top pages

+

Your best performing pages in search

+ <.pages_table pages={@data.top_pages} /> +
+
+ """ + end + + # Top queries table + defp queries_table(assigns) do + ~H""" + + + + + + + + + + + + <%= if @queries == [] do %> + + + + <% else %> + <%= for row <- @queries do %> + + + + + + + + <% end %> + <% end %> + +
QueryClicksImpr.CTRPos.
No data yet
{row.keys["query"]}{row.clicks}{format_number(row.impressions)}{row.ctr}%{row.position}
+ """ + end + + # Top pages table + defp pages_table(assigns) do + ~H""" + + + + + + + + + + + + <%= if @pages == [] do %> + + + + <% else %> + <%= for row <- @pages do %> + + + + + + + + <% end %> + <% end %> + +
PageClicksImpr.CTRPos.
No data yet
{format_page_url(row.keys["page"])}{row.clicks}{format_number(row.impressions)}{row.ctr}%{row.position}
+ """ + end + + defp loading_state(assigns) do + ~H""" +
+ <%= if @error do %> +

{@error}

+ <% else %> +

Loading data...

+ <% end %> +
+ """ + end + + defp no_site_selected(assigns) do + ~H""" +
+

Select a site to view its search performance data.

+
+ """ + 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 diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex index 83a463e..ea46b51 100644 --- a/lib/berrypod_web/live/admin/pages/editor.ex +++ b/lib/berrypod_web/live/admin/pages/editor.ex @@ -7,6 +7,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do alias Berrypod.Theme.{Fonts, PreviewData} import BerrypodWeb.BlockEditorComponents + import BerrypodWeb.Components.SeoChecklist + import BerrypodWeb.Components.SeoPreview @impl true def mount(%{"slug" => slug}, _session, socket) do @@ -503,19 +505,77 @@ defmodule BerrypodWeb.Admin.Pages.Editor do 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 if Page.system_slug?(slug) do socket |> assign(:show_settings, false) |> assign(:page_struct, nil) |> assign(:settings_form, nil) + |> assign(:settings_og_image, nil) + |> assign(:settings_og_picker_open, false) + |> assign(:settings_og_picker_images, []) else 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 |> assign(:show_settings, false) |> assign(:page_struct, 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 @@ -631,6 +691,88 @@ defmodule BerrypodWeb.Admin.Pages.Editor do label="Meta description" 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 --%> +
+ +

Shown when this page is shared on social media

+ + <%= if @settings_og_image do %> +
+ Social preview +
+ + +
+
+ <% else %> + + <% end %> +
+ + <%!-- SEO analysis section --%> +
+ + SEO analysis + +
+ <.seo_checklist page={seo_analysis_page(@settings_form, @blocks)} /> +
+
+ + <%!-- SEO preview section --%> +
+ + SEO preview + +
+ <.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} + /> +
+
+
<.input field={@settings_form[:published]} type="checkbox" label="Published" /> <.input @@ -717,6 +859,52 @@ defmodule BerrypodWeb.Admin.Pages.Editor do search={@image_picker_search} upload={@uploads.image_picker_upload} /> + + <%!-- OG image picker modal --%> + <.og_image_picker :if={@settings_og_picker_open} images={@settings_og_picker_images} /> +
+ """ + end + + defp og_image_picker(assigns) do + ~H""" +
+
+
+

Select social sharing image

+ +
+

+ Recommended: 1200×630px for best results on social platforms +

+
+
+ +
+ <%= if @images == [] do %> +

No images in media library yet

+ <% 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("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 diff --git a/lib/berrypod_web/live/admin/settings.ex b/lib/berrypod_web/live/admin/settings.ex index 152ae67..1b86076 100644 --- a/lib/berrypod_web/live/admin/settings.ex +++ b/lib/berrypod_web/live/admin/settings.ex @@ -1,6 +1,7 @@ defmodule BerrypodWeb.Admin.Settings do use BerrypodWeb, :live_view + alias Berrypod.Media alias Berrypod.Products alias Berrypod.Settings alias Berrypod.Stripe.Setup, as: StripeSetup @@ -19,7 +20,17 @@ defmodule BerrypodWeb.Admin.Settings do |> assign(:signing_secret_status, :idle) |> assign_stripe_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 defp assign_url_prefixes(socket) do @@ -170,6 +181,44 @@ defmodule BerrypodWeb.Admin.Settings do 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 -- def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do @@ -502,10 +551,113 @@ defmodule BerrypodWeb.Admin.Settings do + + <%!-- Default social image --%> +
+

Default social image

+

+ The image shown when pages are shared on social media. + Individual pages can override this in their settings. +

+
+ <%= if @og_image do %> +
+ Current social image +
+ + +
+
+ <% else %> + + <% end %> +
+
+ + <.og_picker_modal + :if={@og_picker_open} + images={@og_picker_images} + /> """ end + defp og_picker_modal(assigns) do + ~H""" +
+
+
+

Choose social image

+ +
+
+

+ Choose an image from your media library. + Recommended size: 1200×630 pixels. +

+ <%= if @images == [] do %> +

+ No images in your media library. + <.link navigate={~p"/admin/media"} class="admin-link">Upload images + first. +

+ <% else %> +
+ <%= for image <- @images do %> + + <% end %> +
+ <% end %> +
+
+
+ """ + 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 -- attr :color, :string, required: true diff --git a/lib/berrypod_web/live/shop/pages/cart.ex b/lib/berrypod_web/live/shop/pages/cart.ex index b006c9d..0198dfe 100644 --- a/lib/berrypod_web/live/shop/pages/cart.ex +++ b/lib/berrypod_web/live/shop/pages/cart.ex @@ -14,10 +14,21 @@ defmodule BerrypodWeb.Shop.Pages.Cart do socket |> assign(:page_title, "Cart") |> assign(:page, page) + |> maybe_assign_meta_robots(page) {:noreply, socket} 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 {:noreply, socket} end diff --git a/lib/berrypod_web/live/shop/pages/checkout_success.ex b/lib/berrypod_web/live/shop/pages/checkout_success.ex index f866668..263c3d5 100644 --- a/lib/berrypod_web/live/shop/pages/checkout_success.ex +++ b/lib/berrypod_web/live/shop/pages/checkout_success.ex @@ -50,6 +50,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do |> assign(:page_title, "Order confirmed") |> assign(:order, order) |> assign(:page, page) + |> maybe_assign_meta_robots(page) {:noreply, socket} end @@ -58,6 +59,16 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do {:redirect, redirect(socket, to: R.home())} 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 {:noreply, socket} end diff --git a/lib/berrypod_web/live/shop/pages/collection.ex b/lib/berrypod_web/live/shop/pages/collection.ex index 2ce9a30..5c81720 100644 --- a/lib/berrypod_web/live/shop/pages/collection.ex +++ b/lib/berrypod_web/live/shop/pages/collection.ex @@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, put_flash: 3] alias Berrypod.{Pages, Pagination, Products} + alias BerrypodWeb.Helpers.SeoHelpers alias BerrypodWeb.R @sort_options [ @@ -20,16 +21,29 @@ defmodule BerrypodWeb.Shop.Pages.Collection do def init(socket, _params, _uri) do page = Pages.get_page("collection") + base = BerrypodWeb.Endpoint.url() socket = socket |> assign(:page, page) |> assign(:sort_options, @sort_options) |> assign(:current_sort, "featured") + |> maybe_assign_meta_robots(page) + |> SeoHelpers.assign_og_image(page, base) {:noreply, socket} 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 def handle_params(params, uri, socket) when not is_map_key(params, "slug") do handle_params(Map.put(params, "slug", "all"), uri, socket) diff --git a/lib/berrypod_web/live/shop/pages/contact.ex b/lib/berrypod_web/live/shop/pages/contact.ex index a95cefe..6bc98ca 100644 --- a/lib/berrypod_web/live/shop/pages/contact.ex +++ b/lib/berrypod_web/live/shop/pages/contact.ex @@ -10,10 +10,12 @@ defmodule BerrypodWeb.Shop.Pages.Contact do alias BerrypodWeb.R alias Berrypod.Orders.OrderNotifier alias Berrypod.Pages + alias BerrypodWeb.Helpers.SeoHelpers alias BerrypodWeb.OrderLookupController def init(socket, _params, _uri) do page = Pages.get_page("contact") + base = BerrypodWeb.Endpoint.url() socket = socket @@ -25,10 +27,22 @@ defmodule BerrypodWeb.Shop.Pages.Contact do |> assign(:og_url, R.url(R.contact())) |> assign(:tracking_state, :idle) |> assign(:page, page) + |> maybe_assign_meta_robots(page) + |> SeoHelpers.assign_og_image(page, base) {:noreply, socket} 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 {:noreply, socket} end diff --git a/lib/berrypod_web/live/shop/pages/content.ex b/lib/berrypod_web/live/shop/pages/content.ex index dd97922..abf95e2 100644 --- a/lib/berrypod_web/live/shop/pages/content.ex +++ b/lib/berrypod_web/live/shop/pages/content.ex @@ -9,6 +9,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do alias Berrypod.LegalPages alias Berrypod.Pages alias Berrypod.Theme.PreviewData + alias BerrypodWeb.Helpers.SeoHelpers alias BerrypodWeb.R def init(socket, _params, _uri) do @@ -27,10 +28,22 @@ defmodule BerrypodWeb.Shop.Pages.Content do |> assign(seo) |> assign(:page, page) |> assign(:content_blocks, content_blocks) + |> assign(:json_ld, SeoHelpers.faq_json_ld(page && page.blocks)) + |> maybe_assign_meta_robots(page) {:noreply, socket} 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 # Returns {seo_assigns, content_blocks} for each content page diff --git a/lib/berrypod_web/live/shop/pages/custom_page.ex b/lib/berrypod_web/live/shop/pages/custom_page.ex index e43ca64..b68b367 100644 --- a/lib/berrypod_web/live/shop/pages/custom_page.ex +++ b/lib/berrypod_web/live/shop/pages/custom_page.ex @@ -6,6 +6,7 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do import Phoenix.Component, only: [assign: 2, assign: 3] alias Berrypod.Pages + alias BerrypodWeb.Helpers.SeoHelpers def init(socket, _params, _uri) do # Custom pages load in handle_params based on slug @@ -55,10 +56,13 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do type: page.type, published: page.published, meta_description: page.meta_description, + meta_robots: page.meta_robots, + focus_keyword: page.focus_keyword, url_slug: page.url_slug, show_in_nav: page.show_in_nav, nav_label: page.nav_label, - nav_position: page.nav_position + nav_position: page.nav_position, + og_image_id: page.og_image_id } end @@ -91,6 +95,7 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do defp maybe_assign_meta(socket, page, base) do socket |> assign(:og_url, base <> "/#{page.slug}") + |> assign(:json_ld, SeoHelpers.faq_json_ld(page.blocks)) |> then(fn s -> if page.meta_description do assign(s, :page_description, page.meta_description) @@ -98,5 +103,15 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do s 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 diff --git a/lib/berrypod_web/live/shop/pages/home.ex b/lib/berrypod_web/live/shop/pages/home.ex index dcbf4cf..113163f 100644 --- a/lib/berrypod_web/live/shop/pages/home.ex +++ b/lib/berrypod_web/live/shop/pages/home.ex @@ -5,7 +5,8 @@ defmodule BerrypodWeb.Shop.Pages.Home do 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 page = Pages.get_page("home") @@ -14,28 +15,145 @@ defmodule BerrypodWeb.Shop.Pages.Home do base = BerrypodWeb.Endpoint.url() site_name = socket.assigns.site_name - org_ld = - Jason.encode!( - %{ - "@context" => "https://schema.org", - "@type" => "Organization", - "name" => site_name, - "url" => base <> "/" - }, - escape: :html_safe - ) + org_ld = build_organization_json_ld(socket.assigns, base, site_name) + json_ld = combine_json_ld([org_ld, SeoHelpers.faq_json_ld(page.blocks)]) socket = socket |> assign(:page_title, "Home") |> assign(:og_url, base <> "/") - |> assign(:json_ld, org_ld) + |> assign(:json_ld, json_ld) |> assign(:page, page) + |> maybe_assign_meta_robots(page) + |> SeoHelpers.assign_og_image(page, base) |> assign(extra) {:noreply, socket} 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\n\n