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""" +
+ See how your site performs in Google search results +
+ ++ 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.
+
What people search for to find your site
+ <.queries_table queries={@data.top_queries} /> +Your best performing pages in search
+ <.pages_table pages={@data.top_pages} /> +| Query | +Clicks | +Impr. | +CTR | +Pos. | +
|---|---|---|---|---|
| No data yet | +||||
| {row.keys["query"]} | +{row.clicks} | +{format_number(row.impressions)} | +{row.ctr}% | +{row.position} | +
| Page | +Clicks | +Impr. | +CTR | +Pos. | +
|---|---|---|---|---|
| No data yet | +||||
| {format_page_url(row.keys["page"])} | +{row.clicks} | +{format_number(row.impressions)} | +{row.ctr}% | +{row.position} | +
{@error}
+ <% else %> +Loading data...
+ <% end %> +Select a site to view its search performance data.
+Shown when this page is shared on social media
+ + <%= if @settings_og_image do %> ++ Recommended: 1200×630px for best results on social platforms +
+No images in media library yet
+ <% end %> ++ The image shown when pages are shared on social media. + Individual pages can override this in their settings. +
++ 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 %> +