All 14 pages now render through PageRenderer. Theme editor preview
unified from 10 preview_page clauses to one function + page-context
helpers. PageTemplates module and 10 .heex template files deleted.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stage 4 of the page builder: all shop pages now render via
PageRenderer instead of inline templates or PageTemplates.
- Collection: full filter bar moved to renderer (category pills,
sort dropdown, CollectionFilters hook, empty state)
- PDP: related_products and reviews loaded via block data loaders
instead of manual queries
- Cart: page definition loaded in mount, subtotal computed in render
- Search: page definition loaded in mount, handle_params unchanged
- Added Phoenix.VerifiedRoutes to PageRenderer for ~p sigil
- Net -55 lines (128 added, 183 removed)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Home, Content (about/delivery/privacy/terms), Contact, and ErrorHTML
now render through the generic PageRenderer instead of hardcoded
templates. Block wrapper divs enable CSS grid targeting. Featured
products block supports layout/card_variant/columns settings for
different page contexts. Contact page uses CSS grid on data-block-type
attributes for two-column layout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stage 1 of the page builder: Pages schema with 14 valid slugs,
BlockTypes registry (26 block types with settings schemas and data
loaders), Defaults module matching existing templates, ETS-backed
PageCache GenServer, and Pages context (get_page/save_page/reset_page
with cache -> DB -> defaults lookup). 34 tests, zero visual change.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
BrokenUrlTracker now queries real analytics pageview counts instead of
hardcoding 0, so broken URLs with prior traffic are distinguished from
bot noise. For /products/ 404s with a single FTS5 search match, auto-
creates a redirect and marks the broken URL resolved. 1232 tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Redirects context with redirect/broken_url schemas, chain flattening,
ETS cache for fast lookups in the request pipeline. BrokenUrlTracker
plug logs 404s. Auto-redirect on product slug change via upsert_product
hook. Admin redirects page with active/broken tabs, manual create form.
RedirectPrunerWorker cleans up old broken URLs. 1227 tests passing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Variant options (colour, size) are now URL params handled via
handle_params instead of phx-click events. Swatches and size buttons
render as patch links in shop mode, so changing variants works as
plain navigation without JS. Quantity is now a number input that
submits with the form. Unavailable variants render as disabled spans.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The delivery country form now has action="/cart/country" with a
noscript submit button. Without JS, changing the country and clicking
Update POSTs to a new CartController.update_country action that saves
the country to session and redirects back to /cart.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Both order tracking forms now have action="/contact/lookup" so they
POST to a new OrderLookupController.lookup action when JS is off.
The controller mirrors the LiveView handler: checks for paid orders,
sends the verification email, and redirects with a flash message.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The button type condition checked @mode == :live but ThemeHook sets
mode to :shop, so the button rendered as type="button" (doing nothing).
Changed to @mode == :preview to match the existing phx-click pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Every key shop flow now works via plain HTML forms when JS is
unavailable. LiveView progressively enhances when JS connects.
- PDP: form wraps variant/qty/add-to-cart with action="/cart/add"
- Cart page: qty +/- and remove use form POST fallbacks
- Cart/search header icons are now links with phx-click enhancement
- Collection sort form has GET action + noscript submit button
- New /search page with form-based search for no-JS users
- CartController gains add/remove/update_item POST actions
- CartHook gains update_quantity_form and remove_item_form handlers
- Fix flaky analytics tests caused by event table pollution
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Upload a source image (PNG, JPEG, or SVG) and get a complete favicon
setup: PNG variants at 32, 180, 192, 512px served from DB via
FaviconController with ETag caching, SVG favicon for vector sources,
dynamic site.webmanifest, and theme-color meta tag. Theme editor gains
a site icon section with "use logo as icon" toggle, dedicated icon
upload, short name, and background colour picker.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The stretched-link::after overlay (z-index: 0) was blocked by
product-card-image-wrap (z-index: 1), so only the title text was
actually clickable. Wrapping the image area in a <.link> component
directly fixes this — taps/clicks bubble up to the link naturally,
and touch-scroll on the image carousel still works on mobile.
Also corrects the mode check: ThemeHook sets mode: :shop on shop pages,
not :live, so the condition is now mode != :preview (consistent with
how the title link already worked).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Capitalise lead sentence regardless of shop_name value
- Add stripe.com/privacy URL when mentioning Stripe in payment section
- Remove mention of logout from session cookie description
- Make third-party sharing text provider-agnostic (no longer names Printify etc.)
- Add :updated_at block to privacy, delivery, and terms pages showing when
content last changed — auto-tracked via content hash, so the date advances
automatically whenever relevant settings change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded PreviewData placeholders with generated content
derived from real shop state: connected providers (production lead
times), shipping countries (grouped by region), shop country
(jurisdiction language and governing law), and feature flags
(abandoned cart recovery section, newsletter, VAT clause).
Returns policy correctly cites Consumer Contracts Regulations Reg
28(1)(b) for POD exemption and Consumer Rights Act for defective goods.
Cart recovery section uses jurisdiction-specific wording: PECR Reg 22
for UK, GDPR Art 6(1)(f) for EU, generic otherwise.
About page unchanged — shop owner's story to tell.
26 new tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All three notifier functions (order confirmation, shipping, cart
recovery) now read from the same two settings rather than using
hardcoded values or duplicating the lookup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- add product_id to order_items (migration + schema + create_order)
- cart recovery email now includes a direct product link per item
- extend session cookie max_age to 7 days so carts survive browser restarts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a Stripe checkout session expires without payment, if the customer
entered their email, we record an AbandonedCart and schedule a single
plain-text recovery email (1h delay via Oban).
Privacy design:
- feature is off by default; shop owner opts in via admin settings
- only contacts customers who entered their email at Stripe checkout
- single email, never more (emailed_at timestamp gate)
- suppression list blocks repeat contact; one-click unsubscribe via
signed token (/unsubscribe/:token)
- records pruned after 30 days (nightly Oban cron)
- no tracking pixels, no redirected links, no HTML
Legal notes:
- custom_text added to Stripe session footer when recovery is on
- UK PECR soft opt-in; EU legitimate interests both satisfied by this design
Files:
- migration: abandoned_carts + email_suppressions tables
- schemas: AbandonedCart, EmailSuppression
- context: Orders.create_abandoned_cart, check_suppression, add_suppression,
has_recent_paid_order?, get_abandoned_cart_by_session, mark_abandoned_cart_emailed
- workers: AbandonedCartEmailWorker (checkout queue), AbandonedCartPruneWorker (cron)
- notifier: OrderNotifier.deliver_cart_recovery/3
- webhook: extended checkout.session.expired handler
- controller: UnsubscribeController, admin settings toggle
- tests: 28 new tests across context, workers, and controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Downloads a ZIP with one CSV per report (overview, trend, pages, entry/exit
pages, sources, referrers, countries, devices, funnel). Export button lives
next to the period selector and picks up the current period and any active
filters using a JS hook + JS.set_attribute, so the downloaded data always
matches what's on screen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Magic link flow on contact page: customer enters email, gets a
time-limited signed link, clicks through to /orders showing all their
paid orders and full detail pages with thumbnails and product links.
- OrderLookupController generates/verifies Phoenix.Token signed links
- Contact LiveView handles lookup_orders + reset_tracking events
- Orders and OrderDetail LiveViews gated by session email
- Order detail shows thumbnails, links to products still available
- .themed-button gets base padding/font-weight so all usages are consistent
- order-summary-card sticky scoped to .cart-grid (was leaking to orders list)
- 27 new tests (1095 total)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Product pages: Product schema (name, description, image, price/currency,
availability) + BreadcrumbList (Home > Category > Product). Home page:
Organization schema (name, url). Uses Jason with html_safe escaping so
the JSON is safe to embed in <script> tags.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Product pages get og:type=product, og:url, og:image (hero image), and
twitter:card=summary_large_image. All other shop pages get og:type=website
and twitter:card=summary. og:title and og:description mirror the existing
page title and meta description.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Product pages: first 155 chars of description, stripped of HTML, truncated on word boundary.
Collections: contextual description based on collection type.
Content pages (about, delivery, privacy, terms): from hero_description text.
Contact: static description.
Home falls through to site_description from settings (already in layout).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All shop pages now render as "Page · Store Name" using live_title suffix.
Home stays as "Home · Store Name" for consistency with live navigation.
Updated cart test to use regex for whitespace-tolerant title matching.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ROW_NUMBER() window function picks first/last pageview per session.
Both tables live in the pages tab and support the pathname filter.
6 new tests, 1061 total.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Plug records a pageview with a known ID (plug_ref) into the ETS
buffer. When JS connects, the LiveView hook supersedes that event by
ID and records its own with full data (screen_size from connect params).
If JS never connects, the Plug's event flushes normally after 10s.
Also fixes: admin browsing no longer leaks product_view events — the
Plug now sets no analytics session data for admins, so all downstream
visitor_hash guards naturally filter them out.
Replaces the previous time-based skip logic which was brittle and
race-prone. The supersede approach is deterministic and handles both
the ETS buffer and already-flushed DB cases.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Click any row in pages, sources, countries, or devices tables to
filter the entire dashboard by that dimension. Active filters show
as dismissible chips. Filters thread through all queries including
previous-period deltas. 1050 tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each stat card now shows the percentage change vs the equivalent
previous period (e.g. 30d compares last 30 days vs 30 days before).
Handles zero-baseline with "new" label and caps extreme deltas at
>999%. Seed data extended to 2 years for meaningful 12m comparisons.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- add visitors_by_hour query for hourly breakdown on "today" period
- replace SVG-only chart with HTML/CSS grid layout (bars + labels)
- Y-axis scale with nice rounded max, midpoint, and zero
- X-axis date labels (formatted as "Feb 18") spaced evenly
- adaptive bar gaps (1px for sparse data, 0 for 365-day dense view)
- labels use real HTML text so they're readable on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Event call sites (product_view, add_to_cart, checkout_start, purchase)
were only passing visitor_hash and pathname, leaving browser, OS, screen
size and country nil. Add AnalyticsHook.attrs/1 helper to extract common
analytics fields from socket assigns, and use it in all LiveView event
call sites. Checkout controller reads the same fields from the session.
Also fix plug analytics test to clear stale events before assertions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Onboarding: re-fetch setup_status() after provider/Stripe connect instead
of manually patching the local assigns, which could miss admin_created and
leave users stuck on the setup page with no way forward.
Dev config: respect PHX_HOST for endpoint URL so Stripe checkout redirects
to the correct host instead of always using localhost.
Stripe setup: detect private/LAN IPs (10.x, 172.16-31.x, 192.168.x) as
unreachable, not just localhost — prevents creating webhook endpoints that
Stripe can never reach.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three-layer pipeline: Plug for all HTTP requests (no JS needed), LiveView
hook for SPA navigations, JS hook for screen width. ETS-backed buffer
batches writes to SQLite every 10s. Daily-rotating salt for visitor hashing.
Includes admin dashboard with date ranges, visitor trends, top pages,
sources, devices, and e-commerce conversion funnel. Oban cron for 12-month
data retention.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Same pattern as the Printful work: wire up base_options/0 so tests can
inject a Req.Test plug, fix unreachable 204 clause in delete, add
HTTP-level client tests and provider integration tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wire up Req.Test plug for the Printful HTTP client so tests can stub
responses. Adds HTTP-level tests for the client, provider integration
tests, and mockup enricher tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VariantCache now enqueues missing variants via OptimizeWorker instead
of processing directly with Task.async_stream. Simpler and uses the
existing job queue. Adds vips-heif to Docker runtime for HEIF support.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
BenchRepo for isolated concurrency testing against temp DB files.
Correctness tests prove WAL concurrent reads, IMMEDIATE transaction
mode vs DEFERRED upgrade failures, and PRAGMA application. Benchmark
tests (tagged :benchmark, excluded by default) measure throughput.
mix bench.sqlite runs HTTP load scenarios against the full Phoenix
stack with --prod, --scale, --pool-size, and --busy-timeout options.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stripe.Checkout.Session is a struct that doesn't implement Access,
so get_in with atom keys fails. Use direct struct field access instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
login_user_by_magic_link raises for unconfirmed users with a password,
which is exactly what recovery creates. Use get_user_by_magic_link_token
directly and log in without the magic link guard.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The login page now only shows the magic link form when a test email has
been sent successfully, not just when an adapter is configured. Saving
email settings or disconnecting clears the flag so the admin must
re-verify after config changes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When email isn't configured, the login page now hides the magic link
form and shows a recovery link. The /recover page logs the setup secret
to server logs and lets the admin reset their password with it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Settings keys like api_key were shared across providers, so switching
from e.g. Postmark to SendGrid showed the old API key. Now each
adapter gets its own namespaced key (email_postmark_api_key, etc.)
so credentials persist independently and switching back pre-fills
previously saved values.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Card radio component for picking email providers (SMTP, SendGrid, Mailjet, etc.)
with instant client-side switching via JS hook. Adapter configs are pre-rendered
and toggled without a server round-trip. Secrets are preserved when re-saving
with blank password fields. Includes from address field, test email sending,
and disconnect flow.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Setup wizard no longer requires email delivery. Admin account is
auto-confirmed and auto-logged-in via token redirect. Adds setup
secret gate for prod (logged on boot), SMTP env var config in
runtime.exs, email_configured? helper, and admin warning banner
when email isn't set up. Includes plan files for this task and
the follow-up email settings UI.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Avoids stale .gz files from mix assets.deploy shadowing freshly-built
dev assets. This was causing the admin to render with old DaisyUI dark
theme CSS even after the unified theme migration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>