add canonical URLs, robots.txt, and sitemap.xml

Canonical: all shop pages now assign og_url (reusing the existing og:url
assign), which the layout renders as <link rel="canonical">. Collection
pages strip the sort param so ?sort=price_asc doesn't create a duplicate
canonical.

robots.txt: dynamic controller disallows /admin/, /api/, /users/,
/webhooks/, /checkout/. Removed robots.txt from static_paths so it
goes through the router instead of Plug.Static.

sitemap.xml: auto-generated from all visible products + categories +
static pages, served as application/xml. 8 tests.

Also updates PROGRESS.md: marks tasks 55, 58, 59, 61, 62 as done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-23 21:47:35 +00:00
parent b11f7d47d0
commit 0f1135256d
16 changed files with 2144 additions and 13 deletions

View File

@@ -90,9 +90,56 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| | **Analytics v2** ([plan](docs/plans/analytics-v2.md)) | | | |
| ~~52~~ | ~~Comparison mode: period deltas on stat cards~~ | — | 1h | done |
| 53 | Dashboard filtering (click to filter by dimension) | 52 | 3h | planned |
| ~~53~~ | ~~Dashboard filtering (click to filter by dimension)~~ | 52 | 3h | done |
| 54 | CSV export | 52 | 1.5h | planned |
| 55 | Entry/exit pages panel | — | 1h | planned |
| ~~55~~ | ~~Entry/exit pages panel~~ | — | 1h | done |
| | **Favicon & site icons** ([plan](docs/plans/favicon.md)) | | | |
| 86 | Favicon source upload — `image_type: "icon"`, "use logo as icon" toggle, upload in theme editor, `FaviconGeneratorWorker`, `favicon_variants` table | — | 2.5h | planned |
| 87 | `FaviconController` serving all favicon routes + dynamic `site.webmanifest`; `<link>` tags + `theme-color` meta in `shop_root.html.heex`; default fallback icons | 86 | 1.5h | planned |
| 88 | SVG dark mode injection for SVG-source favicons; icon background colour + short name customisation | 86 | 1h | planned |
| | **No-JS support** | | | |
| 56 | Audit all key flows for no-JS (browse, cart, checkout, analytics) | — | 2h | planned |
| 57 | Fix any broken flows for no-JS clients | 56 | TBD | planned |
| | **SEO** | | | |
| ~~58~~ | ~~Page titles with separators across all pages~~ | — | 1h | done |
| ~~59~~ | ~~Open Graph + Twitter Card meta tags (products, collections, home)~~ | 58 | 2h | done |
| 60 | Structured data / JSON-LD (Product, BreadcrumbList, Organization) | 59 | 2h | planned |
| ~~61~~ | ~~Canonical URLs, robots.txt, sitemap.xml~~ | 59 | 1.5h | done |
| ~~62~~ | ~~Meta descriptions (per-page, auto-generated fallbacks)~~ | 58 | 1h | done |
| | **Profit-aware pricing & sales** ([plan](docs/plans/profit-aware-pricing.md)) | | | |
| 63 | Fix Printful cost sync (cross-reference catalog API for variant costs) | — | 45m | planned |
| 64 | Cost snapshot on orders (`unit_cost` on order_items, `total_cost`/`gross_profit` on orders) | 63 | 1.5h | planned |
| 65 | Exact Stripe fees (fetch from Balance Transaction API post-payment, not estimated) | 64 | 45m | planned |
| 66 | Tax toggle + Stripe Tax (shop country, registered Y/N, enable automatic_tax on checkout, "inc. VAT" on shop) | 65 | 1.5h | planned |
| 67 | Admin profit dashboard (per-product margins, per-order profit, overall P&L, VAT-aware) | 64, 65, 66 | 3h | planned |
| 68 | Profit-aware price editor (show margin/profit when setting prices, warn on low/negative margin) | 67 | 2h | planned |
| 69 | Sales & promotions (% or fixed discount, scoped to catalogue/category/products, scheduled start/end) | 68 | 3h | planned |
| 70 | Margin guard on sales (prevent discounts that breach minimum profit threshold) | 69 | 1h | planned |
| 71 | Announcement bar (dismissable shop banner for active sales, admin-configurable) | 69 | 1.5h | planned |
| | **URL redirects** ([plan](docs/plans/url-redirects.md)) | | | |
| 78 | `redirects` + `broken_urls` schemas, `Redirects` context, ETS-cached Plug in pipeline | — | 2h | planned |
| 79 | Auto-redirect on slug change — hook into `upsert_product/2` to detect old/new slug diff | 78 | 45m | planned |
| 80 | Analytics-powered 404 monitoring — query analytics on 404, FTS5 auto-resolve, broken URLs queue | 78 | 2h | planned |
| 81 | Admin redirects UI — active redirects, broken URLs (sorted by prior traffic), manual create | 78 | 2h | planned |
| 82 | Dead link monitoring — validate stored links (internal via Phoenix.Router, external via async Oban HEAD), event-driven on product changes, admin dead links tab | 78 | 2.5h | planned |
| | **Activity log & order timeline** ([plan](docs/plans/activity-log.md)) | | | |
| 89 | `activity_log` schema + migration + `ActivityLog` context (`log_event/3`, `list_for_order/1`, `list_recent/1`, `count_needing_attention/0`, `resolve/1`) | — | 1.5h | planned |
| 90 | Instrument existing event points — stripe webhook, OrderNotifier, OrderSubmissionWorker, fulfilment status, ProductSyncWorker | 89 | 1.5h | planned |
| 91 | Order timeline component on `/admin/orders/:id` — chronological feed replacing scattered field cards | 89 | 1.5h | planned |
| 92 | Global `/admin/activity` LiveView — all activity + "needs attention" tab, resolve action, count badge on admin nav | 89 | 2h | planned |
| | **Other features** | | | |
| 72 | Order status lookup — wire up existing stub on contact page (UI already exists, backend unbuilt) | — | 1.5h | planned |
| | **Abandoned cart recovery** ([plan](docs/plans/abandoned-cart.md)) | | | |
| 75 | Handle `checkout.session.expired` webhook, store abandoned cart record | — | 1h | planned |
| 76 | Send single recovery email (plain text, no tracking, clear opt-out) | 75 | 1h | planned |
| 77 | Suppression list (unsubscribe), 30-day data pruning Oban job, Stripe footer notice | 76 | 1h | planned |
| | **Legal page generator** ([plan](docs/plans/legal-page-generator.md)) | | | |
| 83 | `LegalPages` module — generate accurate privacy, delivery, and terms content from settings + provider + shipping data | — | 2.5h | planned |
| 84 | Wire `LegalPages` into `Content` LiveView — replace `PreviewData` calls, add tests | 83 | 45m | planned |
| 85 | Page editor integration — "Regenerate" button, auto-regenerate on settings change, customised vs auto label | 83, 19 | 1.5h | planned |
| | **Platform site** | | | |
| 73 | Platform/marketing site — brochure, pricing, sign-up | — | TBD | planned |
| 74 | Separation of concerns: platform site vs AGPL open source core | 73 | TBD | planned |
See [css-migration.md](docs/plans/css-migration.md) for full plan with architecture, visual regression testing strategy, and acceptance criteria per phase.
@@ -119,21 +166,35 @@ Issues from hands-on testing of the deployed prod site (Feb 2025). 16 of 18 comp
### Tier 3 — Compliance & quality
10. ~~**Privacy-respecting analytics**~~ — ✅ In progress. Custom cookie-free analytics with pageviews, e-commerce funnel, device/browser/OS/country tracking. Period comparison deltas on stat cards. Next: dashboard filtering, CSV export, entry/exit pages.
10. ~~**Privacy-respecting analytics**~~ — ✅ In progress. Custom cookie-free analytics with pageviews, e-commerce funnel, device/browser/OS/country tracking. Period comparison deltas on stat cards, dashboard filtering. Next: CSV export, entry/exit pages.
11. **AGPL licensing & code hosting** — Currently AGPL-3.0. Decide on GitHub vs Codeberg vs self-hosted Forgejo. Set up proper LICENSE file, contribution guidelines, and release process.
12. **Security (Paraxial.io)** — Runtime application security monitoring for Elixir. Bot detection, rate limiting, vulnerability scanning. Evaluate whether it fits the self-hosted model.
13. **No-JS support** — Audit and fix all key user flows for no-JS clients. Browse, search, cart, checkout, and analytics should all work without JavaScript. The analytics pipeline already supports no-JS (Plug records pageview, only superseded if JS connects). Cart and checkout use LiveView but should degrade gracefully.
14. **SEO** — Best-in-breed SEO to match or exceed Shopify/Squarespace/WordPress plugins. Page titles with site name separator across all pages. Open Graph and Twitter Card meta tags for products (with images, prices), collections, and home. JSON-LD structured data (Product with offers/availability, BreadcrumbList, Organization). Canonical URLs, robots.txt, sitemap.xml (auto-generated from products/collections). Per-page meta descriptions with auto-generated fallbacks from product/collection data.
15. **Favicon & site icons** — Upload one source image, get a complete best-practice icon setup. "Use logo as icon" toggle (on by default) with fallback to a separately uploaded icon image if the logo is wide/unsuitable at small sizes. Auto-generates PNG variants at 32×32 (browser fallback), 180×180 (iOS home screen), 192×192 and 512×512 (Android/PWA) via an Oban job using the existing `image` library pipeline. If the source is an SVG, the SVG itself is served as `favicon.svg` with a `prefers-color-scheme: dark` media query injected so the icon adapts to dark mode automatically. A `FaviconController` serves all variants from the DB at the expected root paths. Dynamic `site.webmanifest` served as JSON, pulling shop name, theme colour, and background colour from settings. Customisation: short name (shown under home screen icon), icon background colour (Android circle/squircle fill), theme colour (defaults to active theme primary). `.ico` fallback is a pre-baked static file — libvips can't output ICO, and it's only needed for very old browsers anyway. See [plan](docs/plans/favicon.md).
16. **URL redirects & dead link monitoring** — Preserve link equity and UX when product URLs change. Three layers: (1) automatic redirect creation when a product slug changes during sync — detected in `upsert_product/2`, no admin involvement needed; (2) ETS-cached Plug in the request pipeline that checks redirects before routing, 301s and halts; (3) analytics-powered 404 monitoring — when a 404 fires on a path with prior analytics history, it's a real broken URL that matters (not a bot scanner). FTS5 search attempts auto-resolution; low-confidence cases surface in an admin broken URLs queue sorted by prior traffic (highest impact first). Manual redirect creation for anything else. Redirect chains flattened on creation (A→B, B→C becomes A→C). **Dead link monitoring** catches broken outgoing links in your own content (social URLs, custom pages, product descriptions): internal links validated instantly via `Phoenix.Router.route_info` — no HTTP needed; external links checked asynchronously via Oban HEAD requests. Event-driven — product deletion/rename triggers a scan of stored links. Admin "Dead links" tab with one-click "Update link" for moved URLs. Weekly re-check cron for external links. See [plan](docs/plans/url-redirects.md).
### Tier 3.5 — Business tools
17. **Profit-aware pricing (inc. tax)** — Complete cost visibility for shop owners. Fix Printful cost sync (catalog API cross-reference for variant costs). Snapshot `unit_cost` on order items at time of sale. **Stripe does the heavy lifting:** use Stripe Tax for automatic tax calculation at checkout (50+ countries, correct rates, one line of config — far better than maintaining our own rate tables); fetch exact Stripe fees from the Balance Transaction API after payment rather than estimating. **Tax as a toggle:** most small POD sellers aren't VAT registered (UK threshold: £90k), so by default there's no tax — prices are prices and all revenue is profit. When they flip the toggle, Stripe Tax activates at checkout and the shop shows "inc. VAT" (UK/EU/AU) or "+ tax" (US/CA) based on shop country. Profit calculations use actual post-payment data: exact Stripe fee + tax amount from Stripe session. Admin profit dashboard showing per-product margins, per-order P&L, and overall business health. Price editor shows live margin as you set prices. The goal: shop owners always know exactly what they're making, with no hidden costs. See [plan](docs/plans/profit-aware-pricing.md).
18. **Sales & promotions** — Transparent, honest alternative to discount codes (no empty "enter code" box at checkout — that's a dark pattern). Create time-limited sales scoped to the entire catalogue, specific categories, or individual products. Percentage or fixed-amount discounts. Scheduled start/end dates with automatic activation via Oban or date-bounded queries. Original price shown struck through with sale price. **Margin guard**: sales cannot breach a configurable minimum profit threshold — the system prevents shop owners from accidentally selling at a loss. Announcement bar (dismissable banner across the shop) to promote active sales. Later ties into newsletter for sale email blasts.
19. **Activity log & order timeline** — A single `activity_log` table records every meaningful event: order created, confirmation email sent, submitted to provider, in production, shipped (with tracking), delivered, errors and retries. Two views on the same data: (1) **order timeline** on the order detail page — a chronological feed showing the complete lifecycle of that specific order, replacing the current scattered key/value cards. Errors are never overwritten — if submission failed and retried, both entries are visible. Email sends are recorded, so you can see if the confirmation actually reached the customer. (2) **global activity feed** at `/admin/activity` — reverse-chronological stream of all system events: orders, syncs, emails, abandoned carts. Two tabs: all activity and "needs attention" (unresolved errors/warnings). Count badge on the admin nav when attention is needed. 90-day pruning via Oban cron. See [plan](docs/plans/activity-log.md).
20. **Order status lookup** — The UI stub already exists: a "Track your order" card on the contact page sidebar with an email input and Send button, but it's purely static HTML with no backend. Two possible approaches: (a) email-only lookup — customer enters their email, receives a signed magic-link showing all their orders (nicer UX, requires sending an email); (b) email + order number — inline lookup, no email sending needed, slightly more friction but simpler to build. Either way, no customer accounts required.
### Tier 4 — Growth & content
13. **Page editor** — Database-driven pages with drag-and-drop sections. Extend the theme system to custom pages beyond the defaults. Replaces the static content pages from Tier 1 with editable versions. **Removes PreviewData usage from `content.ex`** (about, delivery, privacy, terms content blocks are currently hardcoded in PreviewData).
14. **Newsletter & email marketing** — Email list collection (signup forms). Campaign sending for product launches, sales. Can be simple initially (collect emails, send via Swoosh) or integrate with a service.
15. **Product page improvements** — Pre-checkout variant validation (verify Printify availability). Cost change monitoring/alerts. Better image gallery (zoom, multiple angles). **Product reviews system** to replace the hardcoded `PreviewData.reviews()` on the PDP template.
21. **Page editor** — Database-driven pages with drag-and-drop sections. Extend the theme system to custom pages beyond the defaults. Replaces the static content pages from Tier 1 with editable versions. **Removes PreviewData usage from `content.ex`** (about, delivery, privacy, terms content blocks are currently hardcoded in PreviewData).
22. **Legal page generator** — Replace the hardcoded `PreviewData` placeholder content on `/privacy`, `/delivery`, and `/terms` with generated content that's factually accurate for each shop. Berrypod already knows which providers are connected (each with different lead times), which countries it ships to (from the shipping rates table), whether VAT is enabled, whether abandoned cart recovery is on, the shop country (drives jurisdiction language). Privacy policy cites correct statutes (UK GDPR, PECR) and includes conditional sections only for features that are actually enabled. Delivery policy quotes real shipping destinations from DB and correctly applies the Consumer Contracts Regulations Regulation 28(1)(b) exemption for POD (made-to-order goods are exempt from the 14-day right to cancel — most generic templates get this wrong). Terms cites governing law from shop country. Phase 2 (after page editor): "Regenerate from settings" button and auto-regeneration when settings change. See [plan](docs/plans/legal-page-generator.md).
23. **Newsletter & email marketing** — Email list collection (signup forms). Campaign sending for product launches, sales. Can be simple initially (collect emails, send via Swoosh) or integrate with a service. Ties into sales & promotions for sale announcement emails.
24. **Abandoned cart recovery** — Privacy-respecting, GDPR-compliant recovery for customers who started Stripe Checkout but didn't complete payment. Triggered by `checkout.session.expired` webhook (Stripe fires this after 24h). Only possible for customers who entered their email on the Stripe Checkout page — anonymous cart sessions with no email are never contacted. Single plain-text email, no tracking pixels, one-click unsubscribe (suppression list honoured for all future emails). Abandoned cart records deleted after 30 days. Stripe Checkout footer text notifies customers at collection time. Lawful basis: UK PECR soft opt-in (email obtained during negotiation of a sale, single follow-up for similar products). EU: legitimate interests with documented LIA. See [plan](docs/plans/abandoned-cart.md).
25. **Product page improvements** — Pre-checkout variant validation (verify Printify availability). Cost change monitoring/alerts. Better image gallery (zoom, multiple angles). **Product reviews system** to replace the hardcoded `PreviewData.reviews()` on the PDP template.
### Tier 5 — Platform vision
16. **Hosted platform** — Marketing/brochure site for Berrypod as a service. Subscribe/sign-up flow. Multi-tenancy with per-tenant databases. **OAuth connect flows** for providers and payments: register as a Printify/Printful OAuth app so hosted users get a one-click "Connect with Printify" button on the setup page; use Stripe Connect so merchants authorise via OAuth redirect instead of pasting API keys. The setup UI already supports this — check for OAuth credentials and show the connect button when available, fall back to the API key form on self-hosted installs where no OAuth app is configured. Provider metadata (`connect_mode: :oauth | :api_key`) drives which form renders.
17. **Migration & export** — Let shop owners export their data (products, orders, customers, theme settings). Import from other platforms (Shopify, WooCommerce). Portable data as a selling point for the self-hosted story.
18. **Internationalisation (i18n)** — Multi-language support via Gettext (already in Phoenix). Currency formatting. RTL layout support. Per-shop locale configuration. **Note:** `ex_money`/`ex_cldr` are currently used *only* for `Cart.format_price/1` (a single GBP formatting call) but add ~13 MB to the release (ex_cldr 9.5 MB, digital_token 3.7 MB, ex_cldr_numbers, ex_cldr_currencies). Consider replacing with a simple `format_price/2` function that handles GBP/EUR/USD directly — all three use 2 decimal places and are trivial to format. Re-add `ex_money` later if proper locale-aware number formatting is needed (e.g., German `12.345,67 €`).
26. **Platform/marketing site** — Berrypod.com as the public face: brochure pages (features, pricing, comparison), sign-up/subscribe flow, demo store showcase. Needs clear separation between the platform site (commercial, hosted by us) and the open source AGPL core (self-hostable, community-driven). The platform site should position Berrypod as fast, capable, and valuable — competing on speed, privacy, and simplicity against Shopify/Squarespace. Consider: separate Phoenix app or separate layout/router scope within the same app? The AGPL core ships without any platform branding — just the store engine.
27. **Hosted platform infrastructure** — Multi-tenancy with per-tenant databases. **OAuth connect flows** for providers and payments: register as a Printify/Printful OAuth app so hosted users get a one-click "Connect with Printify" button on the setup page; use Stripe Connect so merchants authorise via OAuth redirect instead of pasting API keys. The setup UI already supports this — check for OAuth credentials and show the connect button when available, fall back to the API key form on self-hosted installs where no OAuth app is configured. Provider metadata (`connect_mode: :oauth | :api_key`) drives which form renders.
28. **Migration & export** — Let shop owners export their data (products, orders, customers, theme settings). Import from other platforms (Shopify, WooCommerce). Portable data as a selling point for the self-hosted story.
29. **Internationalisation (i18n)** — Multi-language support via Gettext (already in Phoenix). Currency formatting. RTL layout support. Per-shop locale configuration. **Note:** `ex_money`/`ex_cldr` are currently used *only* for `Cart.format_price/1` (a single GBP formatting call) but add ~13 MB to the release (ex_cldr 9.5 MB, digital_token 3.7 MB, ex_cldr_numbers, ex_cldr_currencies). Consider replacing with a simple `format_price/2` function that handles GBP/EUR/USD directly — all three use 2 decimal places and are trivial to format. Re-add `ex_money` later if proper locale-aware number formatting is needed (e.g., German `12.345,67 €`).
---
@@ -390,9 +451,9 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details
- [x] HTML/CSS bar chart with hourly today view and readable labels (08fcd60)
- [x] Period comparison deltas on stat cards (6eda1de)
- [x] 2-year demo seed data with growth curve (6eda1de)
- [ ] Dashboard filtering (click referrer/country/device to filter all panels)
- [x] Dashboard filtering (click referrer/country/device to filter all panels) (7ceee9c)
- [ ] CSV export
- [ ] Entry/exit pages panel
- [x] Entry/exit pages panel
See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan