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 1bc08bfb23
27 changed files with 1163 additions and 155 deletions

View File

@@ -1,4 +1,6 @@
<div
id="shop-container"
phx-hook="CartPersist"
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);"
>
@@ -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")} />

View File

@@ -1,4 +1,6 @@
<div
id="shop-container"
phx-hook="CartPersist"
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);"
>
@@ -19,12 +21,45 @@
<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" />
<.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>
<.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} />

View File

@@ -1,4 +1,6 @@
<div
id="shop-container"
phx-hook="CartPersist"
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);"
>
@@ -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")} />

View File

@@ -1,4 +1,6 @@
<div
id="shop-container"
phx-hook="CartPersist"
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);"
>
@@ -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")} />

View File

@@ -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")} />
</div>

View File

@@ -1,4 +1,6 @@
<div
id="shop-container"
phx-hook="CartPersist"
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);"
>
@@ -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")} />

View File

@@ -1,4 +1,6 @@
<div
id="shop-container"
phx-hook="CartPersist"
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);"
>
@@ -66,7 +68,7 @@
</div>
<.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} />
</div>
@@ -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} />