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 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-05 22:11:16 +00:00
parent 880e7a2888
commit f244a42546
27 changed files with 1162 additions and 154 deletions

View File

@ -10,15 +10,16 @@
- Shop pages (home, collections, products, cart, about, contact) - Shop pages (home, collections, products, cart, about, contact)
- Mobile-first design with bottom navigation - Mobile-first design with bottom navigation
- 100% PageSpeed score - 100% PageSpeed score
- Variant selector with color swatches and size buttons
**In Progress:** **In Progress:**
- Products context with provider integration (wired to shop views, variant selector next) - Session-based cart
## Next Up ## Next Up
1. **Variant Selector Component** - Size/colour picker on product pages 1. **Session-based Cart** - Real cart with actual variants
2. **Session-based Cart** - Real cart with actual variants 2. **Stripe Checkout Integration** - Payment processing
3. **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 See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for implementation details
### Products & Provider Integration ### Products & Provider Integration
**Status:** In Progress **Status:** Complete
#### Completed
- [x] Products context with schemas (c5c06d9) - [x] Products context with schemas (c5c06d9)
- [x] Provider abstraction layer - [x] Provider abstraction layer
- [x] Printify client integration - [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] Slug-based fallback matching for changed provider IDs
- [x] Printify webhook endpoint with HMAC verification (a9c15ea) - [x] Printify webhook endpoint with HMAC verification (a9c15ea)
- Note: Printify only supports `product:deleted` and `product:publish:*` events (no `product:updated`) - Note: Printify only supports `product:deleted` and `product:publish:*` events (no `product:updated`)
- [x] Product image download pipeline (1b49b47)
#### Remaining Tasks
- [ ] Add variant selector component (~2hr)
#### Recently Completed
- [x] Product image download pipeline
- Downloads Printify CDN images via ImageDownloadWorker - Downloads Printify CDN images via ImageDownloadWorker
- Processes through Media pipeline (WebP conversion, AVIF/WebP variants) - Processes through Media pipeline (WebP conversion, AVIF/WebP variants)
- PreviewData uses local images for responsive `<picture>` elements
- sync_product_images preserves image_id when URL unchanged
- Startup recovery and `mix simpleshop.download_images` backfill - Startup recovery and `mix simpleshop.download_images` backfill
- [x] Wire shop LiveViews to Products context - [x] Variant selector component (880e7a2)
- PreviewData now uses real products when available - Color swatches with hex colors, size buttons
- Fixed Printify image sync (position was string, not integer) - Fixed Printify options parsing (Color/Size swap bug)
- Improved category extraction from Printify tags - Filters to only published variants (not full catalog)
- Price updates on variant change
- Startup recovery for stale sync status
#### Future Enhancements (post-MVP) #### Future Enhancements (post-MVP)
- [ ] Pre-checkout variant validation (verify availability before order) - [ ] 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 | | 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 | | Products context Phase 1 | c5c06d9 | Schemas, provider abstraction |
| Admin provider setup UI | 5b736b9 | Connect, test, sync with pagination | | 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 | | Oban Lifeline plugin | c1e1988 | Rescue orphaned jobs |
| Image optimization | Multiple | Full pipeline complete | | Image optimization | Multiple | Full pipeline complete |
| Self-hosted fonts | - | 10 typefaces, 728KB | | Self-hosted fonts | - | 10 typefaces, 728KB |

View File

@ -220,10 +220,13 @@
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
transition: top 0.2s ease; 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 */ /* Nav link styling with active state indicator */

View File

@ -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 // Hook for PDP image lightbox
const Lightbox = { const Lightbox = {
mounted() { mounted() {
@ -140,7 +288,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}, params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ColorSync, Lightbox}, hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer},
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits

View File

@ -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

View File

@ -411,6 +411,21 @@ defmodule SimpleshopTheme.Products do
# Product Variants # 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 """ @doc """
Creates a product variant. Creates a product variant.
""" """

View File

@ -32,19 +32,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
@doc """ @doc """
Returns cart drawer items formatted for the cart drawer component. 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 def cart_drawer_items do
[ [
%{ %{
variant_id: "preview-1",
name: "Mountain Sunrise Art Print", name: "Mountain Sunrise Art Print",
variant: "12″ x 18″ / Matte", variant: "12″ x 18″ / Matte",
price: "£24.00", price: 2400,
quantity: 1,
image: "/mockups/mountain-sunrise-print-1.jpg" image: "/mockups/mountain-sunrise-print-1.jpg"
}, },
%{ %{
variant_id: "preview-2",
name: "Fern Leaf Mug", name: "Fern Leaf Mug",
variant: "11oz / White", variant: "11oz / White",
price: "£14.99", price: 1499,
quantity: 2,
image: "/mockups/fern-leaf-mug-1.jpg" image: "/mockups/fern-leaf-mug-1.jpg"
} }
] ]

View File

@ -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

View File

@ -1,4 +1,6 @@
<div <div
id="shop-container"
phx-hook="CartPersist"
class="shop-container min-h-screen pb-20 md:pb-0" class="shop-container min-h-screen pb-20 md:pb-0"
style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"
> >
@ -31,7 +33,14 @@
<.shop_footer theme_settings={@theme_settings} mode={@mode} /> <.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")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />

View File

@ -1,4 +1,6 @@
<div <div
id="shop-container"
phx-hook="CartPersist"
class="shop-container min-h-screen pb-20 md:pb-0" class="shop-container min-h-screen pb-20 md:pb-0"
style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"
> >
@ -19,12 +21,45 @@
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<.page_title text="Your basket" /> <.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 %>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<ul
role="list"
aria-label="Cart items"
class="flex flex-col gap-4"
style="list-style: none; margin: 0; padding: 0;"
>
<%= for item <- @cart_items do %>
<li>
<.shop_card class="p-4">
<.cart_item_row item={item} size={:default} show_quantity_controls mode={@mode} />
</.shop_card>
</li>
<% end %>
</ul>
</div>
<div>
<.order_summary subtotal={@cart_page_subtotal} mode={@mode} />
</div>
</div>
<% end %>
</main> </main>
<.shop_footer theme_settings={@theme_settings} mode={@mode} /> <.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")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
<.mobile_bottom_nav active_page="cart" mode={@mode} /> <.mobile_bottom_nav active_page="cart" mode={@mode} />

View File

@ -1,4 +1,6 @@
<div <div
id="shop-container"
phx-hook="CartPersist"
class="shop-container min-h-screen pb-20 md:pb-0" class="shop-container min-h-screen pb-20 md:pb-0"
style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"
> >
@ -39,7 +41,13 @@
<.shop_footer theme_settings={@theme_settings} mode={@mode} /> <.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")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />

View File

@ -1,4 +1,6 @@
<div <div
id="shop-container"
phx-hook="CartPersist"
class="shop-container min-h-screen pb-20 md:pb-0" class="shop-container min-h-screen pb-20 md:pb-0"
style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"
> >
@ -54,7 +56,14 @@
<.shop_footer theme_settings={@theme_settings} mode={@mode} /> <.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")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />

View File

@ -50,7 +50,14 @@
<.shop_footer theme_settings={@theme_settings} mode={@mode} /> <.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")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
</div> </div>

View File

@ -1,4 +1,6 @@
<div <div
id="shop-container"
phx-hook="CartPersist"
class="shop-container min-h-screen pb-20 md:pb-0" class="shop-container min-h-screen pb-20 md:pb-0"
style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"
> >
@ -47,7 +49,14 @@
<.shop_footer theme_settings={@theme_settings} mode={@mode} /> <.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")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />

View File

@ -1,4 +1,6 @@
<div <div
id="shop-container"
phx-hook="CartPersist"
class="shop-container min-h-screen pb-20 md:pb-0" class="shop-container min-h-screen pb-20 md:pb-0"
style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"
> >
@ -66,7 +68,7 @@
</div> </div>
<.quantity_selector quantity={@quantity} in_stock={@product.in_stock} /> <.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} /> <.trust_badges :if={@theme_settings.pdp_trust_badges} />
<.product_details product={@product} /> <.product_details product={@product} />
</div> </div>
@ -89,7 +91,14 @@
<.shop_footer theme_settings={@theme_settings} mode={@mode} /> <.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")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
<.mobile_bottom_nav active_page="pdp" mode={@mode} /> <.mobile_bottom_nav active_page="pdp" mode={@mode} />

View File

@ -898,10 +898,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
type="button" type="button"
class="header-icon-btn w-9 h-9 flex items-center justify-center transition-all relative" 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);" style="color: var(--t-text-secondary); background: none; border: none; cursor: pointer; border-radius: var(--t-radius-button);"
phx-click={ phx-click={open_cart_drawer_js()}
Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer")
|> Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer-overlay")
}
aria-label="Cart" aria-label="Cart"
> >
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -1064,17 +1061,26 @@ defmodule SimpleshopThemeWeb.ShopComponents do
""" """
end 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 """ @doc """
Renders the cart drawer (floating sidebar). Renders the cart drawer (floating sidebar).
The drawer slides in from the right when opened. It displays cart items 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 ## Attributes
* `cart_items` - List of cart items to display. Each item should have * `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") * `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. * `mode` - Either `:live` (default) for real stores or `:preview` for theme editor.
In preview mode, "View basket" navigates via LiveView JS commands. 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={@cart.items} subtotal={@cart.subtotal} />
<.cart_drawer cart_items={demo_items} subtotal="£72.00" mode={:preview} /> <.cart_drawer cart_items={demo_items} subtotal="£72.00" mode={:preview} />
""" """
attr :cart_items, :list, default: [] attr :cart_items, :list, default: []
attr :subtotal, :string, default: nil attr :subtotal, :string, default: nil
attr :cart_count, :integer, default: 0
attr :cart_status, :string, default: nil
attr :mode, :atom, default: :live attr :mode, :atom, default: :live
attr :open, :boolean, default: false
def cart_drawer(assigns) do def cart_drawer(assigns) do
assigns = assigns =
@ -1094,27 +1104,51 @@ defmodule SimpleshopThemeWeb.ShopComponents do
end) end)
~H""" ~H"""
<%!-- Screen reader announcements for cart changes --%>
<div aria-live="polite" aria-atomic="true" class="sr-only">
{@cart_status}
</div>
<!-- Cart Drawer Overlay -->
<div
id="cart-drawer-overlay"
class={["cart-drawer-overlay", @open && "open"]}
aria-hidden={to_string(!@open)}
style="position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 999; opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease;"
phx-click={close_cart_drawer_js()}
>
</div>
<!-- Cart Drawer --> <!-- Cart Drawer -->
<div <div
id="cart-drawer" id="cart-drawer"
class="cart-drawer" role="dialog"
aria-modal="true"
aria-labelledby="cart-drawer-title"
aria-describedby="cart-drawer-description"
aria-hidden={to_string(!@open)}
phx-hook="CartDrawer"
class={["cart-drawer", @open && "open"]}
style="position: fixed; top: 0; right: -400px; width: 400px; max-width: 90vw; height: 100vh; background: var(--t-surface-raised); z-index: 1001; display: flex; flex-direction: column; transition: right 0.3s ease; box-shadow: -4px 0 20px rgba(0,0,0,0.15);" style="position: fixed; top: 0; right: -400px; width: 400px; max-width: 90vw; height: 100vh; background: var(--t-surface-raised); z-index: 1001; display: flex; flex-direction: column; transition: right 0.3s ease; box-shadow: -4px 0 20px rgba(0,0,0,0.15);"
> >
<p id="cart-drawer-description" class="sr-only">
Shopping basket with {@cart_count} {if @cart_count == 1, do: "item", else: "items"}. Press Escape to close.
</p>
<div <div
class="cart-drawer-header" class="cart-drawer-header"
style="display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; border-bottom: 1px solid var(--t-border-default);" style="display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; border-bottom: 1px solid var(--t-border-default);"
> >
<h2 style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); font-size: var(--t-text-large); color: var(--t-text-primary); margin: 0;"> <h2
id="cart-drawer-title"
style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); font-size: var(--t-text-large); color: var(--t-text-primary); margin: 0;"
>
Your basket Your basket
</h2> </h2>
<button <button
type="button" type="button"
class="cart-drawer-close" class="cart-drawer-close"
style="background: none; border: none; padding: 0.5rem; cursor: pointer; color: var(--t-text-secondary);" style="background: none; border: none; padding: 0.5rem; cursor: pointer; color: var(--t-text-secondary);"
phx-click={ phx-click={close_cart_drawer_js()}
Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer")
|> Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer-overlay")
}
aria-label="Close cart" aria-label="Close cart"
> >
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -1125,39 +1159,16 @@ defmodule SimpleshopThemeWeb.ShopComponents do
</div> </div>
<div class="cart-drawer-items" style="flex: 1; overflow-y: auto; padding: 1rem;"> <div class="cart-drawer-items" style="flex: 1; overflow-y: auto; padding: 1rem;">
<%= if @cart_items == [] do %>
<.cart_empty_state mode={@mode} />
<% else %>
<ul role="list" aria-label="Cart items" style="list-style: none; margin: 0; padding: 0;">
<%= for item <- @cart_items do %> <%= for item <- @cart_items do %>
<div <li style="border-bottom: 1px solid var(--t-border-default);">
class="cart-drawer-item" <.cart_item_row item={item} size={:compact} mode={@mode} />
style="display: flex; gap: 0.75rem; padding: 0.75rem 0; border-bottom: 1px solid var(--t-border-default);" </li>
> <% end %>
<div </ul>
class="cart-drawer-item-image"
style={"width: 60px; height: 60px; border-radius: var(--t-radius-card); background-size: cover; background-position: center; background-image: url('#{item.image}'); flex-shrink: 0;"}
>
</div>
<div class="cart-drawer-item-details" style="flex: 1;">
<h3 style="font-family: var(--t-font-body); font-size: var(--t-text-small); font-weight: 500; color: var(--t-text-primary); margin: 0 0 2px;">
{item.name}
</h3>
<p style="font-family: var(--t-font-body); font-size: var(--t-text-caption); color: var(--t-text-tertiary); margin: 0;">
{item.variant}
</p>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 4px;">
<p
class="cart-drawer-item-price"
style="color: var(--t-text-primary); font-weight: 500; font-size: var(--t-text-small); margin: 0;"
>
{item.price}
</p>
<button
type="button"
style="background: none; border: none; padding: 0; cursor: pointer; font-family: var(--t-font-body); font-size: var(--t-text-caption); color: var(--t-text-tertiary); text-decoration: underline;"
>
Remove
</button>
</div>
</div>
</div>
<% end %> <% end %>
</div> </div>
@ -1187,8 +1198,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<a <a
href="#" href="#"
phx-click={ phx-click={
Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer") close_cart_drawer_js()
|> Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer-overlay")
|> Phoenix.LiveView.JS.push("change_preview_page", value: %{page: "cart"}) |> Phoenix.LiveView.JS.push("change_preview_page", value: %{page: "cart"})
} }
class="cart-drawer-link" class="cart-drawer-link"
@ -1199,10 +1209,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<% else %> <% else %>
<a <a
href="/cart" href="/cart"
phx-click={ phx-click={close_cart_drawer_js()}
Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer")
|> Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer-overlay")
}
class="cart-drawer-link" class="cart-drawer-link"
style="display: block; text-align: center; font-family: var(--t-font-body); font-size: var(--t-text-small); color: var(--t-text-secondary); text-decoration: underline; cursor: pointer;" style="display: block; text-align: center; font-family: var(--t-font-body); font-size: var(--t-text-small); color: var(--t-text-secondary); text-decoration: underline; cursor: pointer;"
> >
@ -1211,18 +1218,153 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<% end %> <% end %>
</div> </div>
</div> </div>
"""
end
<!-- Cart Drawer Overlay --> @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"""
<div <div
id="cart-drawer-overlay" class="cart-item-row"
class="cart-drawer-overlay" style={"display: flex; gap: #{if @size == :compact, do: "0.75rem", else: "1rem"}; padding: 0.75rem 0;"}
style="position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 999; opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease;" >
phx-click={ <div
Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer") class="cart-item-image"
|> Phoenix.LiveView.JS.remove_class("open", to: "#cart-drawer-overlay") style={"width: #{if @size == :compact, do: "60px", else: "80px"}; height: #{if @size == :compact, do: "60px", else: "80px"}; border-radius: var(--t-radius-card); background-size: cover; background-position: center; flex-shrink: 0; #{if @item.image, do: "background-image: url('#{@item.image}');", else: "background-color: var(--t-surface-sunken);"}"}
}
> >
</div> </div>
<div class="cart-item-details" style="flex: 1; min-width: 0;">
<h3 style={"font-family: var(--t-font-body); font-size: #{if @size == :compact, do: "var(--t-text-small)", else: "var(--t-text-base)"}; font-weight: 500; color: var(--t-text-primary); margin: 0 0 2px;"}>
{@item.name}
</h3>
<%= if @item.variant do %>
<p style="font-family: var(--t-font-body); font-size: var(--t-text-caption); color: var(--t-text-tertiary); margin: 0;">
{@item.variant}
</p>
<% end %>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 4px; flex-wrap: wrap; gap: 0.5rem;">
<%= if @show_quantity_controls do %>
<div
class="flex items-center"
style="border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
>
<button
type="button"
phx-click="decrement"
phx-value-id={@item.variant_id}
class="px-3 py-1"
style="background: none; border: none; cursor: pointer; color: var(--t-text-primary);"
aria-label={"Decrease quantity of #{@item.name}"}
>
</button>
<span
class="px-3 py-1 border-x"
style="border-color: var(--t-border-default); color: var(--t-text-primary); min-width: 2rem; text-align: center;"
>
{@item.quantity}
</span>
<button
type="button"
phx-click="increment"
phx-value-id={@item.variant_id}
class="px-3 py-1"
style="background: none; border: none; cursor: pointer; color: var(--t-text-primary);"
aria-label={"Increase quantity of #{@item.name}"}
>
+
</button>
</div>
<% else %>
<span style="font-size: var(--t-text-caption); color: var(--t-text-tertiary);">
Qty: {@item.quantity}
</span>
<% end %>
<.cart_remove_button variant_id={@item.variant_id} item_name={@item.name} />
</div>
</div>
<div class="text-right" style="flex-shrink: 0;">
<p style={"font-weight: 500; font-size: #{if @size == :compact, do: "var(--t-text-small)", else: "var(--t-text-base)"}; color: var(--t-text-primary); margin: 0;"}>
{SimpleshopTheme.Cart.format_price(@item.price * @item.quantity)}
</p>
</div>
</div>
"""
end
@doc """
Cart empty state component.
"""
attr :mode, :atom, default: :live
def cart_empty_state(assigns) do
~H"""
<div class="text-center py-8" style="color: var(--t-text-secondary);">
<svg
class="w-16 h-16 mx-auto mb-4"
style="color: var(--t-text-tertiary);"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"></path>
<line x1="3" y1="6" x2="21" y2="6"></line>
<path d="M16 10a4 4 0 01-8 0"></path>
</svg>
<p class="mb-4">Your basket is empty</p>
<%= if @mode == :preview do %>
<button
type="button"
phx-click="change_preview_page"
phx-value-page="collection"
style="color: var(--t-text-accent); text-decoration: underline; background: none; border: none; cursor: pointer;"
>
Continue shopping
</button>
<% else %>
<a href="/collections/all" style="color: var(--t-text-accent); text-decoration: underline;">
Continue shopping
</a>
<% end %>
</div>
"""
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"""
<button
type="button"
phx-click="remove_item"
phx-value-id={@variant_id}
style="background: none; border: none; padding: 0; cursor: pointer; font-family: var(--t-font-body); font-size: var(--t-text-caption); color: var(--t-text-tertiary); text-decoration: underline;"
aria-label={"Remove #{@item_name} from cart"}
>
Remove
</button>
""" """
end end
@ -1470,14 +1612,14 @@ defmodule SimpleshopThemeWeb.ShopComponents do
class="text-lg font-bold" class="text-lg font-bold"
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));"
> >
£{@product.price / 100} {SimpleshopTheme.Cart.format_price(@product.price)}
</span> </span>
<span class="text-sm line-through ml-2" style="color: var(--t-text-tertiary);"> <span class="text-sm line-through ml-2" style="color: var(--t-text-tertiary);">
£{@product.compare_at_price / 100} {SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
</span> </span>
<% else %> <% else %>
<span class="text-lg font-bold" style="color: var(--t-text-primary);"> <span class="text-lg font-bold" style="color: var(--t-text-primary);">
£{@product.price / 100} {SimpleshopTheme.Cart.format_price(@product.price)}
</span> </span>
<% end %> <% end %>
</div> </div>
@ -1485,18 +1627,18 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<p class="text-sm" style="color: var(--t-text-secondary);"> <p class="text-sm" style="color: var(--t-text-secondary);">
<%= if @product.on_sale do %> <%= if @product.on_sale do %>
<span class="line-through mr-1" style="color: var(--t-text-tertiary);"> <span class="line-through mr-1" style="color: var(--t-text-tertiary);">
£{@product.compare_at_price / 100} {SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
</span> </span>
<% end %> <% end %>
£{@product.price / 100} {SimpleshopTheme.Cart.format_price(@product.price)}
</p> </p>
<% :compact -> %> <% :compact -> %>
<p class="font-bold" style="color: var(--t-text-primary);"> <p class="font-bold" style="color: var(--t-text-primary);">
£{@product.price / 100} {SimpleshopTheme.Cart.format_price(@product.price)}
</p> </p>
<% :minimal -> %> <% :minimal -> %>
<p class="text-xs" style="color: var(--t-text-secondary);"> <p class="text-xs" style="color: var(--t-text-secondary);">
£{@product.price / 100} {SimpleshopTheme.Cart.format_price(@product.price)}
</p> </p>
<% end %> <% end %>
""" """
@ -2834,7 +2976,6 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<.cart_item item={item} /> <.cart_item item={item} />
""" """
attr :item, :map, required: true attr :item, :map, required: true
attr :currency, :string, default: "£"
def cart_item(assigns) do def cart_item(assigns) do
~H""" ~H"""
@ -2887,7 +3028,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<div class="text-right"> <div class="text-right">
<p class="font-bold text-lg" style="color: var(--t-text-primary);"> <p class="font-bold text-lg" style="color: var(--t-text-primary);">
{@currency}{@item.product.price / 100 * @item.quantity} {SimpleshopTheme.Cart.format_price(@item.product.price * @item.quantity)}
</p> </p>
</div> </div>
</.shop_card> </.shop_card>
@ -2912,7 +3053,6 @@ defmodule SimpleshopThemeWeb.ShopComponents do
attr :subtotal, :integer, required: true attr :subtotal, :integer, required: true
attr :delivery, :integer, default: 800 attr :delivery, :integer, default: 800
attr :vat, :integer, default: 720 attr :vat, :integer, default: 720
attr :currency, :string, default: "£"
attr :mode, :atom, default: :live attr :mode, :atom, default: :live
def order_summary(assigns) do def order_summary(assigns) do
@ -2933,24 +3073,24 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<div class="flex justify-between"> <div class="flex justify-between">
<span style="color: var(--t-text-secondary);">Subtotal</span> <span style="color: var(--t-text-secondary);">Subtotal</span>
<span style="color: var(--t-text-primary);"> <span style="color: var(--t-text-primary);">
{@currency}{Float.round(@subtotal / 100, 2)} {SimpleshopTheme.Cart.format_price(@subtotal)}
</span> </span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span style="color: var(--t-text-secondary);">Delivery</span> <span style="color: var(--t-text-secondary);">Delivery</span>
<span style="color: var(--t-text-primary);"> <span style="color: var(--t-text-primary);">
{@currency}{Float.round(@delivery / 100, 2)} {SimpleshopTheme.Cart.format_price(@delivery)}
</span> </span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span style="color: var(--t-text-secondary);">VAT (20%)</span> <span style="color: var(--t-text-secondary);">VAT (20%)</span>
<span style="color: var(--t-text-primary);">{@currency}{Float.round(@vat / 100, 2)}</span> <span style="color: var(--t-text-primary);">{SimpleshopTheme.Cart.format_price(@vat)}</span>
</div> </div>
<div class="border-t pt-3" style="border-color: var(--t-border-default);"> <div class="border-t pt-3" style="border-color: var(--t-border-default);">
<div class="flex justify-between text-lg"> <div class="flex justify-between text-lg">
<span class="font-semibold" style="color: var(--t-text-primary);">Total</span> <span class="font-semibold" style="color: var(--t-text-primary);">Total</span>
<span class="font-bold" style="color: var(--t-text-primary);"> <span class="font-bold" style="color: var(--t-text-primary);">
{@currency}{Float.round(@total / 100, 2)} {SimpleshopTheme.Cart.format_price(@total)}
</span> </span>
</div> </div>
</div> </div>
@ -3495,7 +3635,6 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<.product_info product={@product} /> <.product_info product={@product} />
""" """
attr :product, :map, required: true attr :product, :map, required: true
attr :currency, :string, default: "£"
def product_info(assigns) do def product_info(assigns) do
~H""" ~H"""
@ -3513,10 +3652,10 @@ defmodule SimpleshopThemeWeb.ShopComponents do
class="text-3xl font-bold" class="text-3xl font-bold"
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));" 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)}
</span> </span>
<span class="text-xl line-through" style="color: var(--t-text-tertiary);"> <span class="text-xl line-through" style="color: var(--t-text-tertiary);">
{@currency}{@product.compare_at_price / 100} {SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
</span> </span>
<span <span
class="px-2 py-1 text-sm font-bold text-white rounded" class="px-2 py-1 text-sm font-bold text-white rounded"
@ -3528,7 +3667,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
</span> </span>
<% else %> <% else %>
<span class="text-3xl font-bold" style="color: var(--t-text-primary);"> <span class="text-3xl font-bold" style="color: var(--t-text-primary);">
{@currency}{@product.price / 100} {SimpleshopTheme.Cart.format_price(@product.price)}
</span> </span>
<% end %> <% end %>
</div> </div>
@ -3710,6 +3849,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
* `text` - Optional. Button text. Defaults to "Add to basket". * `text` - Optional. Button text. Defaults to "Add to basket".
* `disabled` - Optional. Whether button is disabled. Defaults to false. * `disabled` - Optional. Whether button is disabled. Defaults to false.
* `sticky` - Optional. Whether to use sticky positioning on mobile. Defaults to true. * `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 ## Examples
@ -3719,6 +3859,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
attr :text, :string, default: "Add to basket" attr :text, :string, default: "Add to basket"
attr :disabled, :boolean, default: false attr :disabled, :boolean, default: false
attr :sticky, :boolean, default: true attr :sticky, :boolean, default: true
attr :mode, :atom, default: :live
def add_to_cart_button(assigns) do def add_to_cart_button(assigns) do
~H""" ~H"""
@ -3732,10 +3873,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
> >
<button <button
type="button" type="button"
phx-click={ phx-click={if @mode == :preview, do: open_cart_drawer_js(), else: "add_to_cart"}
Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer")
|> Phoenix.LiveView.JS.add_class("open", to: "#cart-drawer-overlay")
}
disabled={@disabled} disabled={@disabled}
class="w-full px-6 py-4 text-lg font-semibold transition-all" class="w-full px-6 py-4 text-lg font-semibold transition-all"
style={"background-color: #{if @disabled, do: "var(--t-border-default)", else: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))"}; color: var(--t-text-inverse); border-radius: var(--t-radius-button); cursor: #{if @disabled, do: "not-allowed", else: "pointer"}; border: none;"} style={"background-color: #{if @disabled, do: "var(--t-border-default)", else: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))"}; color: var(--t-text-inverse); border-radius: var(--t-radius-button); cursor: #{if @disabled, do: "not-allowed", else: "pointer"}; border: none;"}
@ -3930,7 +4068,6 @@ defmodule SimpleshopThemeWeb.ShopComponents do
""" """
attr :items, :list, required: true attr :items, :list, required: true
attr :subtotal, :integer, required: true attr :subtotal, :integer, required: true
attr :currency, :string, default: "£"
attr :mode, :atom, default: :live attr :mode, :atom, default: :live
def cart_layout(assigns) do def cart_layout(assigns) do
@ -3939,7 +4076,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<%= for item <- @items do %> <%= for item <- @items do %>
<.cart_item item={item} currency={@currency} /> <.cart_item item={item} />
<% end %> <% end %>
</div> </div>
</div> </div>

View File

@ -0,0 +1,31 @@
defmodule SimpleshopThemeWeb.CartController do
@moduledoc """
API controller for cart session persistence.
LiveView cannot write to session directly, so cart updates are persisted
via this API endpoint called from a JS hook after each cart modification.
"""
use SimpleshopThemeWeb, :controller
alias SimpleshopTheme.Cart
@doc """
Updates the cart in session.
Expects JSON body with `items` as a list of [variant_id, quantity] arrays.
"""
def update(conn, %{"items" => items}) when is_list(items) do
cart_items = Cart.deserialize(items)
conn
|> Cart.put_in_session(cart_items)
|> json(%{ok: true})
end
def update(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Invalid cart data"})
end
end

View File

@ -31,9 +31,6 @@ defmodule SimpleshopThemeWeb.ShopLive.About do
|> assign(:logo_image, logo_image) |> assign(:logo_image, logo_image)
|> assign(:header_image, header_image) |> assign(:header_image, header_image)
|> assign(:mode, :shop) |> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
{:ok, socket} {:ok, socket}
end end
@ -49,6 +46,8 @@ defmodule SimpleshopThemeWeb.ShopLive.About do
cart_items={@cart_items} cart_items={@cart_items}
cart_count={@cart_count} cart_count={@cart_count}
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={@cart_drawer_open}
cart_status={@cart_status}
/> />
""" """
end end

View File

@ -1,9 +1,10 @@
defmodule SimpleshopThemeWeb.ShopLive.Cart do defmodule SimpleshopThemeWeb.ShopLive.Cart do
use SimpleshopThemeWeb, :live_view use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Cart
alias SimpleshopTheme.Settings alias SimpleshopTheme.Settings
alias SimpleshopTheme.Media alias SimpleshopTheme.Media
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator, PreviewData} alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator}
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -23,15 +24,6 @@ defmodule SimpleshopThemeWeb.ShopLive.Cart do
logo_image = Media.get_logo() logo_image = Media.get_logo()
header_image = Media.get_header() header_image = Media.get_header()
# For now, use preview data for cart items
# In a real implementation, this would come from session/database
cart_page_items = PreviewData.cart_items()
cart_page_subtotal =
Enum.reduce(cart_page_items, 0, fn item, acc ->
acc + item.product.price * item.quantity
end)
socket = socket =
socket socket
|> assign(:page_title, "Cart") |> assign(:page_title, "Cart")
@ -39,34 +31,56 @@ defmodule SimpleshopThemeWeb.ShopLive.Cart do
|> assign(:generated_css, generated_css) |> assign(:generated_css, generated_css)
|> assign(:logo_image, logo_image) |> assign(:logo_image, logo_image)
|> assign(:header_image, header_image) |> assign(:header_image, header_image)
|> assign(:cart_page_items, cart_page_items)
|> assign(:cart_page_subtotal, cart_page_subtotal)
|> assign(:mode, :shop) |> assign(:mode, :shop)
|> assign(:cart_items, PreviewData.cart_drawer_items())
|> assign(:cart_count, length(cart_page_items))
|> assign(:cart_subtotal, format_subtotal(cart_page_subtotal))
{:ok, socket} {:ok, socket}
end end
@impl true
def handle_event("increment", %{"id" => variant_id}, socket) do
cart = Cart.add_item(socket.assigns.raw_cart, variant_id, 1)
new_qty = Cart.get_quantity(cart, variant_id)
socket =
socket
|> SimpleshopThemeWeb.CartHook.broadcast_and_update(cart)
|> assign(:cart_status, "Quantity updated to #{new_qty}")
{:noreply, socket}
end
@impl true
def handle_event("decrement", %{"id" => variant_id}, socket) do
current = Cart.get_quantity(socket.assigns.raw_cart, variant_id)
cart = Cart.update_quantity(socket.assigns.raw_cart, variant_id, current - 1)
new_qty = Cart.get_quantity(cart, variant_id)
socket =
socket
|> SimpleshopThemeWeb.CartHook.broadcast_and_update(cart)
|> assign(:cart_status, "Quantity updated to #{new_qty}")
{:noreply, socket}
end
@impl true @impl true
def render(assigns) do def render(assigns) do
cart_page_subtotal = Cart.calculate_subtotal(assigns.cart_items)
assigns = assign(assigns, :cart_page_subtotal, cart_page_subtotal)
~H""" ~H"""
<SimpleshopThemeWeb.PageTemplates.cart <SimpleshopThemeWeb.PageTemplates.cart
theme_settings={@theme_settings} theme_settings={@theme_settings}
logo_image={@logo_image} logo_image={@logo_image}
header_image={@header_image} header_image={@header_image}
cart_page_items={@cart_page_items} cart_items={@cart_items}
cart_page_subtotal={@cart_page_subtotal} cart_page_subtotal={@cart_page_subtotal}
mode={@mode} mode={@mode}
cart_items={@cart_items}
cart_count={@cart_count} cart_count={@cart_count}
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={@cart_drawer_open}
cart_status={@cart_status}
/> />
""" """
end end
defp format_subtotal(subtotal_pence) do
"£#{Float.round(subtotal_pence / 100, 2)}"
end
end end

View File

@ -39,9 +39,6 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do
|> assign(:logo_image, logo_image) |> assign(:logo_image, logo_image)
|> assign(:header_image, header_image) |> assign(:header_image, header_image)
|> assign(:mode, :shop) |> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
|> assign(:categories, PreviewData.categories()) |> assign(:categories, PreviewData.categories())
|> assign(:sort_options, @sort_options) |> assign(:sort_options, @sort_options)
|> assign(:current_sort, "featured") |> assign(:current_sort, "featured")
@ -105,6 +102,8 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div <div
id="shop-container"
phx-hook="CartPersist"
class="shop-container min-h-screen pb-20 md:pb-0" class="shop-container min-h-screen pb-20 md:pb-0"
style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"
> >
@ -169,7 +168,10 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do
<SimpleshopThemeWeb.ShopComponents.cart_drawer <SimpleshopThemeWeb.ShopComponents.cart_drawer
cart_items={@cart_items} cart_items={@cart_items}
subtotal={@cart_subtotal} subtotal={@cart_subtotal}
cart_count={@cart_count}
mode={@mode} mode={@mode}
open={@cart_drawer_open}
cart_status={assigns[:cart_status]}
/> />
<SimpleshopThemeWeb.ShopComponents.search_modal hint_text={ <SimpleshopThemeWeb.ShopComponents.search_modal hint_text={

View File

@ -31,9 +31,6 @@ defmodule SimpleshopThemeWeb.ShopLive.Contact do
|> assign(:logo_image, logo_image) |> assign(:logo_image, logo_image)
|> assign(:header_image, header_image) |> assign(:header_image, header_image)
|> assign(:mode, :shop) |> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
{:ok, socket} {:ok, socket}
end end
@ -49,6 +46,8 @@ defmodule SimpleshopThemeWeb.ShopLive.Contact do
cart_items={@cart_items} cart_items={@cart_items}
cart_count={@cart_count} cart_count={@cart_count}
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={@cart_drawer_open}
cart_status={@cart_status}
/> />
""" """
end end

View File

@ -37,9 +37,6 @@ defmodule SimpleshopThemeWeb.ShopLive.Home do
|> assign(:header_image, header_image) |> assign(:header_image, header_image)
|> assign(:preview_data, preview_data) |> assign(:preview_data, preview_data)
|> assign(:mode, :shop) |> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
{:ok, socket} {:ok, socket}
end end
@ -56,6 +53,8 @@ defmodule SimpleshopThemeWeb.ShopLive.Home do
cart_items={@cart_items} cart_items={@cart_items}
cart_count={@cart_count} cart_count={@cart_count}
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={@cart_drawer_open}
cart_status={@cart_status}
/> />
""" """
end end

View File

@ -1,6 +1,7 @@
defmodule SimpleshopThemeWeb.ShopLive.ProductShow do defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
use SimpleshopThemeWeb, :live_view use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Cart
alias SimpleshopTheme.Settings alias SimpleshopTheme.Settings
alias SimpleshopTheme.Media alias SimpleshopTheme.Media
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator, PreviewData} alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator, PreviewData}
@ -61,9 +62,6 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
|> assign(:related_products, related_products) |> assign(:related_products, related_products)
|> assign(:quantity, 1) |> assign(:quantity, 1)
|> assign(:mode, :shop) |> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
|> assign(:option_types, option_types) |> assign(:option_types, option_types)
|> assign(:variants, variants) |> assign(:variants, variants)
|> assign(:selected_options, selected_options) |> assign(:selected_options, selected_options)
@ -153,6 +151,25 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("add_to_cart", _params, socket) do
variant = socket.assigns.selected_variant
if variant do
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
socket =
socket
|> SimpleshopThemeWeb.CartHook.broadcast_and_update(cart)
|> assign(:cart_drawer_open, true)
|> assign(:cart_status, "#{socket.assigns.product.name} added to cart")
{:noreply, socket}
else
{:noreply, socket}
end
end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -168,6 +185,8 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
cart_items={@cart_items} cart_items={@cart_items}
cart_count={@cart_count} cart_count={@cart_count}
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={@cart_drawer_open}
cart_status={@cart_status}
option_types={@option_types} option_types={@option_types}
selected_options={@selected_options} selected_options={@selected_options}
available_options={@available_options} available_options={@available_options}

View File

@ -33,6 +33,7 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
|> assign(:header_image, header_image) |> assign(:header_image, header_image)
|> assign(:customise_open, false) |> assign(:customise_open, false)
|> assign(:sidebar_collapsed, false) |> assign(:sidebar_collapsed, false)
|> assign(:cart_drawer_open, false)
|> allow_upload(:logo_upload, |> allow_upload(:logo_upload,
accept: ~w(.png .jpg .jpeg .webp .svg), accept: ~w(.png .jpg .jpeg .webp .svg),
max_entries: 1, max_entries: 1,
@ -287,6 +288,16 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
{:noreply, assign(socket, :sidebar_collapsed, !socket.assigns.sidebar_collapsed)} {:noreply, assign(socket, :sidebar_collapsed, !socket.assigns.sidebar_collapsed)}
end end
@impl true
def handle_event("open_cart_drawer", _params, socket) do
{:noreply, assign(socket, :cart_drawer_open, true)}
end
@impl true
def handle_event("close_cart_drawer", _params, socket) do
{:noreply, assign(socket, :cart_drawer_open, false)}
end
@impl true @impl true
def handle_event("noop", _params, socket) do def handle_event("noop", _params, socket) do
{:noreply, socket} {:noreply, socket}
@ -303,6 +314,7 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
attr :theme_settings, :map, required: true attr :theme_settings, :map, required: true
attr :logo_image, :any, required: true attr :logo_image, :any, required: true
attr :header_image, :any, required: true attr :header_image, :any, required: true
attr :cart_drawer_open, :boolean, default: false
defp preview_page(%{page: :home} = assigns) do defp preview_page(%{page: :home} = assigns) do
~H""" ~H"""
@ -315,6 +327,7 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
cart_items={PreviewData.cart_drawer_items()} cart_items={PreviewData.cart_drawer_items()}
cart_count={2} cart_count={2}
cart_subtotal="£72.00" cart_subtotal="£72.00"
cart_drawer_open={@cart_drawer_open}
/> />
""" """
end end
@ -330,6 +343,7 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
cart_items={PreviewData.cart_drawer_items()} cart_items={PreviewData.cart_drawer_items()}
cart_count={2} cart_count={2}
cart_subtotal="£72.00" cart_subtotal="£72.00"
cart_drawer_open={@cart_drawer_open}
/> />
""" """
end end
@ -379,6 +393,7 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
cart_items={PreviewData.cart_drawer_items()} cart_items={PreviewData.cart_drawer_items()}
cart_count={2} cart_count={2}
cart_subtotal="£72.00" cart_subtotal="£72.00"
cart_drawer_open={@cart_drawer_open}
option_types={@option_types} option_types={@option_types}
selected_options={@selected_options} selected_options={@selected_options}
available_options={@available_options} available_options={@available_options}
@ -409,6 +424,7 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
cart_items={PreviewData.cart_drawer_items()} cart_items={PreviewData.cart_drawer_items()}
cart_count={2} cart_count={2}
cart_subtotal="£72.00" cart_subtotal="£72.00"
cart_drawer_open={@cart_drawer_open}
/> />
""" """
end end
@ -423,6 +439,7 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
cart_items={PreviewData.cart_drawer_items()} cart_items={PreviewData.cart_drawer_items()}
cart_count={2} cart_count={2}
cart_subtotal="£72.00" cart_subtotal="£72.00"
cart_drawer_open={@cart_drawer_open}
/> />
""" """
end end
@ -437,6 +454,7 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
cart_items={PreviewData.cart_drawer_items()} cart_items={PreviewData.cart_drawer_items()}
cart_count={2} cart_count={2}
cart_subtotal="£72.00" cart_subtotal="£72.00"
cart_drawer_open={@cart_drawer_open}
/> />
""" """
end end
@ -455,6 +473,7 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
cart_items={PreviewData.cart_drawer_items()} cart_items={PreviewData.cart_drawer_items()}
cart_count={2} cart_count={2}
cart_subtotal="£72.00" cart_subtotal="£72.00"
cart_drawer_open={@cart_drawer_open}
/> />
""" """
end end

View File

@ -1164,6 +1164,7 @@
theme_settings={@theme_settings} theme_settings={@theme_settings}
logo_image={@logo_image} logo_image={@logo_image}
header_image={@header_image} header_image={@header_image}
cart_drawer_open={@cart_drawer_open}
/> />
</div> </div>
</div> </div>

View File

@ -30,7 +30,9 @@ defmodule SimpleshopThemeWeb.Router do
scope "/", SimpleshopThemeWeb do scope "/", SimpleshopThemeWeb do
pipe_through [:browser, :shop] pipe_through [:browser, :shop]
live_session :public_shop, layout: {SimpleshopThemeWeb.Layouts, :shop} do live_session :public_shop,
layout: {SimpleshopThemeWeb.Layouts, :shop},
on_mount: [{SimpleshopThemeWeb.CartHook, :mount_cart}] do
live "/", ShopLive.Home, :index live "/", ShopLive.Home, :index
live "/about", ShopLive.About, :index live "/about", ShopLive.About, :index
live "/contact", ShopLive.Contact, :index live "/contact", ShopLive.Contact, :index
@ -40,6 +42,13 @@ defmodule SimpleshopThemeWeb.Router do
end end
end end
# Cart API (session persistence for LiveView)
scope "/api", SimpleshopThemeWeb do
pipe_through [:browser]
post "/cart", CartController, :update
end
# Image serving routes (public, no auth required) # Image serving routes (public, no auth required)
scope "/images", SimpleshopThemeWeb do scope "/images", SimpleshopThemeWeb do
pipe_through :browser pipe_through :browser

2
mise.toml Normal file
View File

@ -0,0 +1,2 @@
[tools]
node = "latest"

View File

@ -0,0 +1,173 @@
defmodule SimpleshopTheme.CartTest do
use ExUnit.Case, async: true
alias SimpleshopTheme.Cart
describe "add_item/3" do
test "adds a new item to an empty cart" do
cart = Cart.add_item([], "variant-1")
assert cart == [{"variant-1", 1}]
end
test "adds a new item with custom quantity" do
cart = Cart.add_item([], "variant-1", 3)
assert cart == [{"variant-1", 3}]
end
test "increments quantity for existing item" do
cart = [{"variant-1", 2}]
cart = Cart.add_item(cart, "variant-1", 1)
assert cart == [{"variant-1", 3}]
end
test "adds different items separately" do
cart =
[]
|> Cart.add_item("variant-1", 1)
|> Cart.add_item("variant-2", 2)
assert cart == [{"variant-1", 1}, {"variant-2", 2}]
end
end
describe "update_quantity/3" do
test "updates the quantity of an existing item" do
cart = [{"variant-1", 2}]
cart = Cart.update_quantity(cart, "variant-1", 5)
assert cart == [{"variant-1", 5}]
end
test "removes item when quantity is zero" do
cart = [{"variant-1", 2}]
cart = Cart.update_quantity(cart, "variant-1", 0)
assert cart == []
end
test "removes item when quantity is negative" do
cart = [{"variant-1", 2}]
cart = Cart.update_quantity(cart, "variant-1", -1)
assert cart == []
end
test "does nothing for non-existent item" do
cart = [{"variant-1", 2}]
cart = Cart.update_quantity(cart, "variant-999", 5)
assert cart == [{"variant-1", 2}]
end
end
describe "remove_item/2" do
test "removes an item from the cart" do
cart = [{"variant-1", 2}, {"variant-2", 1}]
cart = Cart.remove_item(cart, "variant-1")
assert cart == [{"variant-2", 1}]
end
test "does nothing for non-existent item" do
cart = [{"variant-1", 2}]
cart = Cart.remove_item(cart, "variant-999")
assert cart == [{"variant-1", 2}]
end
test "returns empty list when removing last item" do
cart = Cart.remove_item([{"variant-1", 1}], "variant-1")
assert cart == []
end
end
describe "get_quantity/2" do
test "returns quantity for existing item" do
cart = [{"variant-1", 3}]
assert Cart.get_quantity(cart, "variant-1") == 3
end
test "returns 0 for non-existent item" do
assert Cart.get_quantity([], "variant-1") == 0
end
end
describe "item_count/1" do
test "sums all quantities" do
cart = [{"variant-1", 2}, {"variant-2", 3}]
assert Cart.item_count(cart) == 5
end
test "returns 0 for empty cart" do
assert Cart.item_count([]) == 0
end
end
describe "format_price/1" do
test "formats pence as GBP string" do
assert Cart.format_price(2400) == "£24.00"
end
test "formats with pence correctly" do
assert Cart.format_price(1499) == "£14.99"
end
test "formats zero" do
assert Cart.format_price(0) == "£0.00"
end
test "formats single digit pence with padding" do
assert Cart.format_price(105) == "£1.05"
end
test "returns fallback for non-integer" do
assert Cart.format_price(nil) == "£0.00"
assert Cart.format_price("foo") == "£0.00"
end
end
describe "calculate_subtotal/1" do
test "sums price * quantity for all items" do
items = [
%{price: 2400, quantity: 1},
%{price: 1499, quantity: 2}
]
assert Cart.calculate_subtotal(items) == 5398
end
test "returns 0 for empty list" do
assert Cart.calculate_subtotal([]) == 0
end
end
describe "serialize/1 and deserialize/1" do
test "round-trips cart data" do
cart = [{"variant-1", 2}, {"variant-2", 1}]
assert cart == cart |> Cart.serialize() |> Cart.deserialize()
end
test "serialize converts tuples to lists" do
assert Cart.serialize([{"v1", 3}]) == [["v1", 3]]
end
test "deserialize drops malformed entries" do
assert Cart.deserialize([[1, 2]]) == []
assert Cart.deserialize([["valid", 1], [nil, 2]]) == [{"valid", 1}]
end
test "deserialize handles non-list input" do
assert Cart.deserialize(nil) == []
assert Cart.deserialize("garbage") == []
end
end
describe "get_from_session/1" do
test "returns cart items from session" do
session = %{"cart" => [{"v1", 2}]}
assert Cart.get_from_session(session) == [{"v1", 2}]
end
test "returns empty list when no cart in session" do
assert Cart.get_from_session(%{}) == []
end
test "returns empty list for invalid cart data" do
assert Cart.get_from_session(%{"cart" => "garbage"}) == []
end
end
end