From 1bc08bfb23a957453ebeebda6553b6a76cf1c198 Mon Sep 17 00:00:00 2001 From: jamey Date: Thu, 5 Feb 2026 22:11:16 +0000 Subject: [PATCH] feat: add cart page, cart drawer, and shared cart infrastructure - Cart context with pure functions for add/remove/update/hydrate - Price formatting via ex_money (replaces all float division) - CartHook on_mount with attach_hook for shared event handlers (open/close drawer, remove item, PubSub sync) - Accessible cart drawer with focus trap, scroll lock, aria-live - Cart page with increment/decrement quantity controls - Preview mode cart drawer support in theme editor - Cart persistence to session via JS hook + API endpoint - 19 tests covering all Cart pure functions Co-Authored-By: Claude Opus 4.6 --- PROGRESS.md | 36 +-- assets/css/theme-semantic.css | 9 +- assets/js/app.js | 150 ++++++++- lib/simpleshop_theme/cart.ex | 233 +++++++++++++ lib/simpleshop_theme/products.ex | 15 + lib/simpleshop_theme/theme/preview_data.ex | 10 +- lib/simpleshop_theme_web/cart_hook.ex | 115 +++++++ .../components/page_templates/about.html.heex | 11 +- .../components/page_templates/cart.html.heex | 39 ++- .../page_templates/collection.html.heex | 10 +- .../page_templates/contact.html.heex | 11 +- .../components/page_templates/error.html.heex | 9 +- .../components/page_templates/home.html.heex | 11 +- .../components/page_templates/pdp.html.heex | 13 +- .../components/shop_components.ex | 305 +++++++++++++----- .../controllers/cart_controller.ex | 31 ++ .../live/shop_live/about.ex | 5 +- .../live/shop_live/cart.ex | 56 ++-- .../live/shop_live/collection.ex | 8 +- .../live/shop_live/contact.ex | 5 +- .../live/shop_live/home.ex | 5 +- .../live/shop_live/product_show.ex | 25 +- .../live/theme_live/index.ex | 19 ++ .../live/theme_live/index.html.heex | 1 + lib/simpleshop_theme_web/router.ex | 11 +- mise.toml | 2 + test/simpleshop_theme/cart_test.exs | 173 ++++++++++ 27 files changed, 1163 insertions(+), 155 deletions(-) create mode 100644 lib/simpleshop_theme/cart.ex create mode 100644 lib/simpleshop_theme_web/cart_hook.ex create mode 100644 lib/simpleshop_theme_web/controllers/cart_controller.ex create mode 100644 mise.toml create mode 100644 test/simpleshop_theme/cart_test.exs diff --git a/PROGRESS.md b/PROGRESS.md index a7fbdb6..e03f64b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,15 +10,16 @@ - Shop pages (home, collections, products, cart, about, contact) - Mobile-first design with bottom navigation - 100% PageSpeed score +- Variant selector with color swatches and size buttons **In Progress:** -- Products context with provider integration (wired to shop views, variant selector next) +- Session-based cart ## Next Up -1. **Variant Selector Component** - Size/colour picker on product pages -2. **Session-based Cart** - Real cart with actual variants -3. **Stripe Checkout Integration** - Payment processing +1. **Session-based Cart** - Real cart with actual variants +2. **Stripe Checkout Integration** - Payment processing +3. **Orders & Fulfillment** - Submit orders to Printify --- @@ -52,9 +53,8 @@ See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for implementation details ### Products & Provider Integration -**Status:** In Progress +**Status:** Complete -#### Completed - [x] Products context with schemas (c5c06d9) - [x] Provider abstraction layer - [x] Printify client integration @@ -64,21 +64,16 @@ See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for im - [x] Slug-based fallback matching for changed provider IDs - [x] Printify webhook endpoint with HMAC verification (a9c15ea) - Note: Printify only supports `product:deleted` and `product:publish:*` events (no `product:updated`) - -#### Remaining Tasks -- [ ] Add variant selector component (~2hr) - -#### Recently Completed -- [x] Product image download pipeline +- [x] Product image download pipeline (1b49b47) - Downloads Printify CDN images via ImageDownloadWorker - Processes through Media pipeline (WebP conversion, AVIF/WebP variants) - - PreviewData uses local images for responsive `` elements - - sync_product_images preserves image_id when URL unchanged - Startup recovery and `mix simpleshop.download_images` backfill -- [x] Wire shop LiveViews to Products context - - PreviewData now uses real products when available - - Fixed Printify image sync (position was string, not integer) - - Improved category extraction from Printify tags +- [x] Variant selector component (880e7a2) + - Color swatches with hex colors, size buttons + - Fixed Printify options parsing (Color/Size swap bug) + - Filters to only published variants (not full catalog) + - Price updates on variant change + - Startup recovery for stale sync status #### Future Enhancements (post-MVP) - [ ] Pre-checkout variant validation (verify availability before order) @@ -124,9 +119,12 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design | Feature | Commit | Notes | |---------|--------|-------| +| Variant selector | 880e7a2 | Color swatches, size buttons, price updates | +| Product image download | 1b49b47 | PageSpeed 100% with local images | +| Wire shop to real data | c818d03 | PreviewData uses Products context | +| Printify webhooks | a9c15ea | Deletion + publish events | | Products context Phase 1 | c5c06d9 | Schemas, provider abstraction | | Admin provider setup UI | 5b736b9 | Connect, test, sync with pagination | -| Printify webhooks | a9c15ea | Deletion + publish events (no update event available) | | Oban Lifeline plugin | c1e1988 | Rescue orphaned jobs | | Image optimization | Multiple | Full pipeline complete | | Self-hosted fonts | - | 10 typefaces, 728KB | diff --git a/assets/css/theme-semantic.css b/assets/css/theme-semantic.css index 2082165..4ac6adb 100644 --- a/assets/css/theme-semantic.css +++ b/assets/css/theme-semantic.css @@ -220,10 +220,13 @@ font-weight: 600; text-decoration: none; transition: top 0.2s ease; + outline: none; +} - &:focus { - top: 1rem; - } +.skip-link:focus { + top: 1rem; + outline: 3px solid var(--t-text-primary); + outline-offset: 2px; } /* Nav link styling with active state indicator */ diff --git a/assets/js/app.js b/assets/js/app.js index 2208907..a705ed5 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -43,6 +43,154 @@ const ColorSync = { } } +// Hook to persist cart to session via API +const CartPersist = { + mounted() { + this.handleEvent("persist_cart", ({items}) => { + const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") + fetch("/api/cart", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": csrfToken + }, + body: JSON.stringify({items}) + }) + }) + } +} + +// Hook for accessible cart drawer (WAI-ARIA dialog pattern) +const CartDrawer = { + mounted() { + this.triggerElement = null + this.boundKeydown = this.handleKeydown.bind(this) + this.boundTouchmove = this.handleTouchmove.bind(this) + this.scrollY = 0 + this.isOpen = this.el.classList.contains('open') + + if (this.isOpen) { + this.lockScroll() + document.addEventListener('keydown', this.boundKeydown) + } + }, + + updated() { + const nowOpen = this.el.classList.contains('open') + if (nowOpen && !this.isOpen) { + this.onOpen() + } else if (!nowOpen && this.isOpen) { + this.onClose() + } + this.isOpen = nowOpen + }, + + lockScroll() { + // Store current scroll position + this.scrollY = window.scrollY + + // Lock body scroll (works on mobile too) + document.body.style.position = 'fixed' + document.body.style.top = `-${this.scrollY}px` + document.body.style.left = '0' + document.body.style.right = '0' + document.body.style.overflow = 'hidden' + + // Prevent touchmove on document (extra safety for iOS) + document.addEventListener('touchmove', this.boundTouchmove, { passive: false }) + }, + + unlockScroll() { + // Restore body scroll + document.body.style.position = '' + document.body.style.top = '' + document.body.style.left = '' + document.body.style.right = '' + document.body.style.overflow = '' + + // Restore scroll position + window.scrollTo(0, this.scrollY) + + // Remove touchmove listener + document.removeEventListener('touchmove', this.boundTouchmove) + }, + + handleTouchmove(e) { + // Allow scrolling inside the drawer itself + if (this.el.contains(e.target)) { + return + } + e.preventDefault() + }, + + onOpen() { + // Store trigger for focus return + this.triggerElement = document.activeElement + + // Lock scroll + this.lockScroll() + + // Focus first focusable element (close button) + const firstFocusable = this.el.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + if (firstFocusable) { + setTimeout(() => firstFocusable.focus(), 50) + } + + // Enable focus trap and Escape handling + document.addEventListener('keydown', this.boundKeydown) + }, + + onClose() { + // Unlock scroll + this.unlockScroll() + + // Remove keyboard listener + document.removeEventListener('keydown', this.boundKeydown) + + // Return focus to trigger element + if (this.triggerElement) { + this.triggerElement.focus() + } + }, + + handleKeydown(e) { + // Close on Escape - let server handle the state change + if (e.key === 'Escape') { + this.pushEvent("close_cart_drawer") + return + } + + // Focus trap on Tab + if (e.key === 'Tab') { + const focusable = this.el.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + const first = focusable[0] + const last = focusable[focusable.length - 1] + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + }, + + destroyed() { + document.removeEventListener('keydown', this.boundKeydown) + document.removeEventListener('touchmove', this.boundTouchmove) + document.body.style.position = '' + document.body.style.top = '' + document.body.style.left = '' + document.body.style.right = '' + document.body.style.overflow = '' + } +} + // Hook for PDP image lightbox const Lightbox = { mounted() { @@ -140,7 +288,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken}, - hooks: {...colocatedHooks, ColorSync, Lightbox}, + hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer}, }) // Show progress bar on live navigation and form submits diff --git a/lib/simpleshop_theme/cart.ex b/lib/simpleshop_theme/cart.ex new file mode 100644 index 0000000..b6feb8e --- /dev/null +++ b/lib/simpleshop_theme/cart.ex @@ -0,0 +1,233 @@ +defmodule SimpleshopTheme.Cart do + @moduledoc """ + The Cart context. + + Manages shopping cart operations stored in session. Cart items are stored + as a list of {variant_id, quantity} tuples for minimal session storage. + Items are hydrated with full product/variant data when needed for display. + """ + + alias SimpleshopTheme.Products + + @session_key "cart" + + # ============================================================================= + # Session Operations + # ============================================================================= + + @doc """ + Gets cart items from session. + + Returns a list of {variant_id, quantity} tuples. + """ + def get_from_session(session) do + case Map.get(session, @session_key) do + nil -> [] + items when is_list(items) -> items + _ -> [] + end + end + + @doc """ + Puts cart items in session via Plug.Conn. + + Used by the CartController to persist cart to session cookie. + """ + def put_in_session(conn, cart_items) do + Plug.Conn.put_session(conn, @session_key, cart_items) + end + + # ============================================================================= + # Cart Operations + # ============================================================================= + + @doc """ + Adds an item to the cart. + + If the variant is already in the cart, increments the quantity. + Returns the updated cart items list. + """ + def add_item(cart_items, variant_id, quantity \\ 1) + when is_integer(quantity) and quantity > 0 do + case List.keyfind(cart_items, variant_id, 0) do + nil -> + cart_items ++ [{variant_id, quantity}] + + {^variant_id, existing_qty} -> + List.keyreplace(cart_items, variant_id, 0, {variant_id, existing_qty + quantity}) + end + end + + @doc """ + Updates the quantity of an item in the cart. + + If quantity is 0 or less, removes the item. + Returns the updated cart items list. + """ + def update_quantity(cart_items, variant_id, quantity) when is_integer(quantity) do + if quantity <= 0 do + remove_item(cart_items, variant_id) + else + case List.keyfind(cart_items, variant_id, 0) do + nil -> + cart_items + + {^variant_id, _} -> + List.keyreplace(cart_items, variant_id, 0, {variant_id, quantity}) + end + end + end + + @doc """ + Removes an item from the cart. + + Returns the updated cart items list. + """ + def remove_item(cart_items, variant_id) do + List.keydelete(cart_items, variant_id, 0) + end + + @doc """ + Gets the quantity of a specific variant in the cart. + + Returns 0 if not found. + """ + def get_quantity(cart_items, variant_id) do + case List.keyfind(cart_items, variant_id, 0) do + nil -> 0 + {_, qty} -> qty + end + end + + # ============================================================================= + # Hydration + # ============================================================================= + + @doc """ + Hydrates cart items with full variant and product data. + + Takes a list of {variant_id, quantity} tuples and returns a list of maps + with full display data including product name, variant options, price, and image. + """ + def hydrate(cart_items) when is_list(cart_items) do + variant_ids = Enum.map(cart_items, fn {id, _qty} -> id end) + + if variant_ids == [] do + [] + else + variants_map = Products.get_variants_with_products(variant_ids) + + cart_items + |> Enum.map(fn {variant_id, quantity} -> + case Map.get(variants_map, variant_id) do + nil -> + nil + + variant -> + %{ + variant_id: variant.id, + name: variant.product.title, + variant: format_variant_options(variant.options), + price: variant.price, + quantity: quantity, + image: variant_image_url(variant.product) + } + end + end) + |> Enum.reject(&is_nil/1) + end + end + + defp format_variant_options(options) when is_map(options) and map_size(options) > 0 do + options + |> Map.values() + |> Enum.join(" / ") + end + + defp format_variant_options(_), do: nil + + defp variant_image_url(product) do + # Get first image from preloaded images + case product.images do + [first | _] -> + if first.image_id do + "/images/#{first.image_id}/variant/400.webp" + else + first.src + end + + _ -> + nil + end + end + + # ============================================================================= + # Helpers + # ============================================================================= + + @doc """ + Returns the total item count in the cart. + """ + def item_count(cart_items) do + Enum.reduce(cart_items, 0, fn {_, qty}, acc -> acc + qty end) + end + + @doc """ + Calculates the subtotal from hydrated cart items. + + Returns the total in pence. + """ + def calculate_subtotal(hydrated_items) do + Enum.reduce(hydrated_items, 0, fn item, acc -> + acc + item.price * item.quantity + end) + end + + @doc """ + Formats a price in pence as a currency string using ex_money. + """ + def format_price(price_pence) when is_integer(price_pence) do + price_pence + |> Decimal.new() + |> Decimal.div(100) + |> then(&Money.new(:GBP, &1)) + |> Money.to_string!() + end + + def format_price(_), do: format_price(0) + + @doc """ + Formats the subtotal from hydrated items as a GBP string. + """ + def format_subtotal(hydrated_items) do + hydrated_items + |> calculate_subtotal() + |> format_price() + end + + @doc """ + Serializes cart items for JSON transport. + + Converts {variant_id, quantity} tuples to [variant_id, quantity] lists + for JSON compatibility. + """ + def serialize(cart_items) do + Enum.map(cart_items, fn {id, qty} -> [id, qty] end) + end + + @doc """ + Deserializes cart items from JSON transport. + + Converts [variant_id, quantity] lists back to {variant_id, quantity} tuples. + """ + def deserialize(items) when is_list(items) do + items + |> Enum.map(fn + [id, qty] when is_binary(id) and is_integer(qty) -> {id, qty} + _ -> nil + end) + |> Enum.reject(&is_nil/1) + end + + def deserialize(_), do: [] +end diff --git a/lib/simpleshop_theme/products.ex b/lib/simpleshop_theme/products.ex index 7355095..dd5c11e 100644 --- a/lib/simpleshop_theme/products.ex +++ b/lib/simpleshop_theme/products.ex @@ -411,6 +411,21 @@ defmodule SimpleshopTheme.Products do # Product Variants # ============================================================================= + @doc """ + Gets multiple variants by their IDs with associated products and images. + + Returns a map of variant_id => variant struct for efficient lookup. + Used by Cart.hydrate/1 to fetch variant data for display. + """ + def get_variants_with_products(variant_ids) when is_list(variant_ids) do + from(v in ProductVariant, + where: v.id in ^variant_ids, + preload: [product: [images: :image]] + ) + |> Repo.all() + |> Map.new(&{&1.id, &1}) + end + @doc """ Creates a product variant. """ diff --git a/lib/simpleshop_theme/theme/preview_data.ex b/lib/simpleshop_theme/theme/preview_data.ex index 3cea92a..fdcf455 100644 --- a/lib/simpleshop_theme/theme/preview_data.ex +++ b/lib/simpleshop_theme/theme/preview_data.ex @@ -32,19 +32,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do @doc """ Returns cart drawer items formatted for the cart drawer component. + + Format matches Cart.hydrate/1 output for consistency between preview and live modes. """ def cart_drawer_items do [ %{ + variant_id: "preview-1", name: "Mountain Sunrise Art Print", variant: "12″ x 18″ / Matte", - price: "£24.00", + price: 2400, + quantity: 1, image: "/mockups/mountain-sunrise-print-1.jpg" }, %{ + variant_id: "preview-2", name: "Fern Leaf Mug", variant: "11oz / White", - price: "£14.99", + price: 1499, + quantity: 2, image: "/mockups/fern-leaf-mug-1.jpg" } ] diff --git a/lib/simpleshop_theme_web/cart_hook.ex b/lib/simpleshop_theme_web/cart_hook.ex new file mode 100644 index 0000000..a17ac10 --- /dev/null +++ b/lib/simpleshop_theme_web/cart_hook.ex @@ -0,0 +1,115 @@ +defmodule SimpleshopThemeWeb.CartHook do + @moduledoc """ + LiveView on_mount hook for cart state and shared event handling. + + Mounted in the public_shop live_session to give all shop LiveViews + cart state, PubSub sync, and shared event handlers via attach_hook. + + Handles these events so individual LiveViews don't have to: + - `open_cart_drawer` / `close_cart_drawer` - toggle drawer visibility + - `remove_item` - remove item from cart + - `{:cart_updated, cart}` info - cross-tab cart sync via PubSub + + LiveViews with custom cart logic (add_to_cart, increment, decrement) + can call `update_cart_assigns/2` and `broadcast_and_update/2` directly. + """ + + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1, push_event: 3] + + alias SimpleshopTheme.Cart + + def on_mount(:mount_cart, _params, session, socket) do + cart_items = Cart.get_from_session(session) + hydrated = Cart.hydrate(cart_items) + + socket = + socket + |> assign(:raw_cart, cart_items) + |> assign(:cart_items, hydrated) + |> assign(:cart_count, Cart.item_count(cart_items)) + |> assign(:cart_subtotal, Cart.format_subtotal(hydrated)) + |> assign(:cart_drawer_open, false) + |> assign(:cart_status, nil) + |> attach_hook(:cart_events, :handle_event, &handle_cart_event/3) + |> attach_hook(:cart_info, :handle_info, &handle_cart_info/2) + + socket = + if connected?(socket) do + csrf_token = Map.get(session, "_csrf_token", "default") + topic = "cart:#{csrf_token}" + Phoenix.PubSub.subscribe(SimpleshopTheme.PubSub, topic) + assign(socket, :cart_topic, topic) + else + assign(socket, :cart_topic, nil) + end + + {:cont, socket} + end + + # Shared event handlers + + defp handle_cart_event("open_cart_drawer", _params, socket) do + {:halt, assign(socket, :cart_drawer_open, true)} + end + + defp handle_cart_event("close_cart_drawer", _params, socket) do + {:halt, assign(socket, :cart_drawer_open, false)} + end + + defp handle_cart_event("remove_item", %{"id" => variant_id}, socket) do + cart = Cart.remove_item(socket.assigns.raw_cart, variant_id) + + socket = + socket + |> broadcast_and_update(cart) + |> assign(:cart_status, "Item removed from cart") + + {:halt, socket} + end + + defp handle_cart_event(_event, _params, socket), do: {:cont, socket} + + # Shared info handlers + + defp handle_cart_info({:cart_updated, cart}, socket) do + {:halt, update_cart_assigns(socket, cart)} + end + + defp handle_cart_info(_msg, socket), do: {:cont, socket} + + # Public helpers for LiveViews with custom cart logic + + @doc """ + Updates all cart-related assigns from raw cart data. + """ + def update_cart_assigns(socket, cart) do + hydrated = Cart.hydrate(cart) + + socket + |> assign(:raw_cart, cart) + |> assign(:cart_items, hydrated) + |> assign(:cart_count, Cart.item_count(cart)) + |> assign(:cart_subtotal, Cart.format_subtotal(hydrated)) + end + + @doc """ + Broadcasts cart update to other tabs and updates local assigns. + + Uses broadcast_from to avoid notifying self (prevents double-update). + """ + def broadcast_and_update(socket, cart) do + if socket.assigns.cart_topic do + Phoenix.PubSub.broadcast_from( + SimpleshopTheme.PubSub, + self(), + socket.assigns.cart_topic, + {:cart_updated, cart} + ) + end + + socket + |> update_cart_assigns(cart) + |> push_event("persist_cart", %{items: Cart.serialize(cart)}) + end +end diff --git a/lib/simpleshop_theme_web/components/page_templates/about.html.heex b/lib/simpleshop_theme_web/components/page_templates/about.html.heex index d47cbc8..7f525fb 100644 --- a/lib/simpleshop_theme_web/components/page_templates/about.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/about.html.heex @@ -1,4 +1,6 @@
@@ -31,7 +33,14 @@ <.shop_footer theme_settings={@theme_settings} mode={@mode} /> - <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> + <.cart_drawer + cart_items={@cart_items} + subtotal={@cart_subtotal} + cart_count={@cart_count} + mode={@mode} + open={assigns[:cart_drawer_open] || false} + cart_status={assigns[:cart_status]} + /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> diff --git a/lib/simpleshop_theme_web/components/page_templates/cart.html.heex b/lib/simpleshop_theme_web/components/page_templates/cart.html.heex index 7ffb0b1..b3d1268 100644 --- a/lib/simpleshop_theme_web/components/page_templates/cart.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/cart.html.heex @@ -1,4 +1,6 @@
@@ -19,12 +21,45 @@
<.page_title text="Your basket" /> - <.cart_layout items={@cart_page_items} subtotal={@cart_page_subtotal} mode={@mode} /> + + <%= if @cart_items == [] do %> + <.cart_empty_state mode={@mode} /> + <% else %> +
+
+
    + <%= for item <- @cart_items do %> +
  • + <.shop_card class="p-4"> + <.cart_item_row item={item} size={:default} show_quantity_controls mode={@mode} /> + +
  • + <% end %> +
+
+ +
+ <.order_summary subtotal={@cart_page_subtotal} mode={@mode} /> +
+
+ <% end %>
<.shop_footer theme_settings={@theme_settings} mode={@mode} /> - <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> + <.cart_drawer + cart_items={@cart_items} + subtotal={@cart_subtotal} + cart_count={@cart_count} + mode={@mode} + open={assigns[:cart_drawer_open] || false} + cart_status={assigns[:cart_status]} + /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <.mobile_bottom_nav active_page="cart" mode={@mode} /> diff --git a/lib/simpleshop_theme_web/components/page_templates/collection.html.heex b/lib/simpleshop_theme_web/components/page_templates/collection.html.heex index 2e3a3d2..3b1d5ad 100644 --- a/lib/simpleshop_theme_web/components/page_templates/collection.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/collection.html.heex @@ -1,4 +1,6 @@
@@ -39,7 +41,13 @@ <.shop_footer theme_settings={@theme_settings} mode={@mode} /> - <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> + <.cart_drawer + cart_items={@cart_items} + subtotal={@cart_subtotal} + cart_count={@cart_count} + mode={@mode} + open={assigns[:cart_drawer_open] || false} + /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> diff --git a/lib/simpleshop_theme_web/components/page_templates/contact.html.heex b/lib/simpleshop_theme_web/components/page_templates/contact.html.heex index 5bfcb02..ca57530 100644 --- a/lib/simpleshop_theme_web/components/page_templates/contact.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/contact.html.heex @@ -1,4 +1,6 @@
@@ -54,7 +56,14 @@ <.shop_footer theme_settings={@theme_settings} mode={@mode} /> - <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> + <.cart_drawer + cart_items={@cart_items} + subtotal={@cart_subtotal} + cart_count={@cart_count} + mode={@mode} + open={assigns[:cart_drawer_open] || false} + cart_status={assigns[:cart_status]} + /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> diff --git a/lib/simpleshop_theme_web/components/page_templates/error.html.heex b/lib/simpleshop_theme_web/components/page_templates/error.html.heex index b48d0d4..24aeaf4 100644 --- a/lib/simpleshop_theme_web/components/page_templates/error.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/error.html.heex @@ -50,7 +50,14 @@ <.shop_footer theme_settings={@theme_settings} mode={@mode} /> - <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> + <.cart_drawer + cart_items={@cart_items} + subtotal={@cart_subtotal} + cart_count={@cart_count} + mode={@mode} + open={assigns[:cart_drawer_open] || false} + cart_status={assigns[:cart_status]} + /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
diff --git a/lib/simpleshop_theme_web/components/page_templates/home.html.heex b/lib/simpleshop_theme_web/components/page_templates/home.html.heex index af96f16..b521702 100644 --- a/lib/simpleshop_theme_web/components/page_templates/home.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/home.html.heex @@ -1,4 +1,6 @@
@@ -47,7 +49,14 @@ <.shop_footer theme_settings={@theme_settings} mode={@mode} /> - <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> + <.cart_drawer + cart_items={@cart_items} + subtotal={@cart_subtotal} + cart_count={@cart_count} + mode={@mode} + open={assigns[:cart_drawer_open] || false} + cart_status={assigns[:cart_status]} + /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> diff --git a/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex b/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex index edb5c5c..e58a816 100644 --- a/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex @@ -1,4 +1,6 @@
@@ -66,7 +68,7 @@
<.quantity_selector quantity={@quantity} in_stock={@product.in_stock} /> - <.add_to_cart_button /> + <.add_to_cart_button mode={@mode} /> <.trust_badges :if={@theme_settings.pdp_trust_badges} /> <.product_details product={@product} />
@@ -89,7 +91,14 @@ <.shop_footer theme_settings={@theme_settings} mode={@mode} /> - <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> + <.cart_drawer + cart_items={@cart_items} + subtotal={@cart_subtotal} + cart_count={@cart_count} + mode={@mode} + open={assigns[:cart_drawer_open] || false} + cart_status={assigns[:cart_status]} + /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <.mobile_bottom_nav active_page="pdp" mode={@mode} /> diff --git a/lib/simpleshop_theme_web/components/shop_components.ex b/lib/simpleshop_theme_web/components/shop_components.ex index 532bfcc..0d20f51 100644 --- a/lib/simpleshop_theme_web/components/shop_components.ex +++ b/lib/simpleshop_theme_web/components/shop_components.ex @@ -898,10 +898,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do type="button" class="header-icon-btn w-9 h-9 flex items-center justify-center transition-all relative" style="color: var(--t-text-secondary); background: none; border: none; cursor: pointer; border-radius: var(--t-radius-button);" - phx-click={ - Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer") - |> Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer-overlay") - } + phx-click={open_cart_drawer_js()} aria-label="Cart" > @@ -1064,17 +1061,26 @@ defmodule SimpleshopThemeWeb.ShopComponents do """ end + defp open_cart_drawer_js do + Phoenix.LiveView.JS.push("open_cart_drawer") + end + + defp close_cart_drawer_js do + Phoenix.LiveView.JS.push("close_cart_drawer") + end + @doc """ Renders the cart drawer (floating sidebar). The drawer slides in from the right when opened. It displays cart items - and checkout options. + and checkout options. Follows WAI-ARIA dialog pattern for accessibility. ## Attributes * `cart_items` - List of cart items to display. Each item should have - `image`, `name`, `variant`, and `price` keys. Default: [] + `image`, `name`, `variant`, `price`, and `variant_id` keys. Default: [] * `subtotal` - The subtotal to display. Default: nil (shows "£0.00") + * `cart_count` - Number of items for screen reader description. Default: 0 * `mode` - Either `:live` (default) for real stores or `:preview` for theme editor. In preview mode, "View basket" navigates via LiveView JS commands. @@ -1083,9 +1089,13 @@ defmodule SimpleshopThemeWeb.ShopComponents do <.cart_drawer cart_items={@cart.items} subtotal={@cart.subtotal} /> <.cart_drawer cart_items={demo_items} subtotal="£72.00" mode={:preview} /> """ + attr :cart_items, :list, default: [] attr :subtotal, :string, default: nil + attr :cart_count, :integer, default: 0 + attr :cart_status, :string, default: nil attr :mode, :atom, default: :live + attr :open, :boolean, default: false def cart_drawer(assigns) do assigns = @@ -1094,27 +1104,51 @@ defmodule SimpleshopThemeWeb.ShopComponents do end) ~H""" + <%!-- Screen reader announcements for cart changes --%> +
+ {@cart_status} +
+ + + +
+ """ + end - + @doc """ + Shared cart item row component used by both drawer and cart page. + + ## Attributes + + * `item` - Required. Cart item with `name`, `variant`, `price`, `quantity`, `image`, `variant_id`. + * `size` - Either `:compact` (drawer) or `:default` (cart page). Default: :default + * `show_quantity_controls` - Show +/- buttons. Default: false + * `mode` - Either `:live` or `:preview`. Default: :live + """ + attr :item, :map, required: true + attr :size, :atom, default: :default + attr :show_quantity_controls, :boolean, default: false + attr :mode, :atom, default: :live + + def cart_item_row(assigns) do + ~H""" """ end + @doc """ + Cart empty state component. + """ + attr :mode, :atom, default: :live + + def cart_empty_state(assigns) do + ~H""" + + """ + end + + @doc """ + Remove button for cart items. + """ + attr :variant_id, :string, required: true + attr :item_name, :string, default: "item" + + def cart_remove_button(assigns) do + ~H""" + + """ + end + @doc """ Renders a product card with configurable variants. @@ -1470,14 +1612,14 @@ defmodule SimpleshopThemeWeb.ShopComponents do class="text-lg font-bold" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));" > - £{@product.price / 100} + {SimpleshopTheme.Cart.format_price(@product.price)} - £{@product.compare_at_price / 100} + {SimpleshopTheme.Cart.format_price(@product.compare_at_price)} <% else %> - £{@product.price / 100} + {SimpleshopTheme.Cart.format_price(@product.price)} <% end %>
@@ -1485,18 +1627,18 @@ defmodule SimpleshopThemeWeb.ShopComponents do

<%= if @product.on_sale do %> - £{@product.compare_at_price / 100} + {SimpleshopTheme.Cart.format_price(@product.compare_at_price)} <% end %> - £{@product.price / 100} + {SimpleshopTheme.Cart.format_price(@product.price)}

<% :compact -> %>

- £{@product.price / 100} + {SimpleshopTheme.Cart.format_price(@product.price)}

<% :minimal -> %>

- £{@product.price / 100} + {SimpleshopTheme.Cart.format_price(@product.price)}

<% end %> """ @@ -2834,7 +2976,6 @@ defmodule SimpleshopThemeWeb.ShopComponents do <.cart_item item={item} /> """ attr :item, :map, required: true - attr :currency, :string, default: "£" def cart_item(assigns) do ~H""" @@ -2887,7 +3028,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do

- {@currency}{@item.product.price / 100 * @item.quantity} + {SimpleshopTheme.Cart.format_price(@item.product.price * @item.quantity)}

@@ -2912,7 +3053,6 @@ defmodule SimpleshopThemeWeb.ShopComponents do attr :subtotal, :integer, required: true attr :delivery, :integer, default: 800 attr :vat, :integer, default: 720 - attr :currency, :string, default: "£" attr :mode, :atom, default: :live def order_summary(assigns) do @@ -2933,24 +3073,24 @@ defmodule SimpleshopThemeWeb.ShopComponents do
Subtotal - {@currency}{Float.round(@subtotal / 100, 2)} + {SimpleshopTheme.Cart.format_price(@subtotal)}
Delivery - {@currency}{Float.round(@delivery / 100, 2)} + {SimpleshopTheme.Cart.format_price(@delivery)}
VAT (20%) - {@currency}{Float.round(@vat / 100, 2)} + {SimpleshopTheme.Cart.format_price(@vat)}
Total - {@currency}{Float.round(@total / 100, 2)} + {SimpleshopTheme.Cart.format_price(@total)}
@@ -3495,7 +3635,6 @@ defmodule SimpleshopThemeWeb.ShopComponents do <.product_info product={@product} /> """ attr :product, :map, required: true - attr :currency, :string, default: "£" def product_info(assigns) do ~H""" @@ -3513,10 +3652,10 @@ defmodule SimpleshopThemeWeb.ShopComponents do class="text-3xl font-bold" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));" > - {@currency}{@product.price / 100} + {SimpleshopTheme.Cart.format_price(@product.price)} - {@currency}{@product.compare_at_price / 100} + {SimpleshopTheme.Cart.format_price(@product.compare_at_price)} <% else %> - {@currency}{@product.price / 100} + {SimpleshopTheme.Cart.format_price(@product.price)} <% end %>
@@ -3710,6 +3849,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do * `text` - Optional. Button text. Defaults to "Add to basket". * `disabled` - Optional. Whether button is disabled. Defaults to false. * `sticky` - Optional. Whether to use sticky positioning on mobile. Defaults to true. + * `mode` - Either `:live` (sends add_to_cart event) or `:preview` (opens drawer only). ## Examples @@ -3719,6 +3859,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do attr :text, :string, default: "Add to basket" attr :disabled, :boolean, default: false attr :sticky, :boolean, default: true + attr :mode, :atom, default: :live def add_to_cart_button(assigns) do ~H""" @@ -3732,10 +3873,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do >