add shipping costs with live exchange rates and country detection
Shipping rates fetched from Printify during product sync, converted to GBP at sync time using frankfurter.app ECB exchange rates with 5% buffer. Cached in shipping_rates table per blueprint/provider/country. Cart page shows shipping estimate with country selector (detected from Accept-Language header, persisted in cookie). Stripe Checkout includes shipping_options for UK domestic and international delivery. Order shipping_cost extracted from Stripe on payment. ScheduledSyncWorker runs every 6 hours via Oban cron to keep rates and exchange rates fresh. REST_OF_THE_WORLD fallback covers unlisted countries. 780 tests, 0 failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ defmodule SimpleshopThemeWeb.CartHook do
|
||||
- `open_cart_drawer` / `close_cart_drawer` - toggle drawer visibility
|
||||
- `remove_item` - remove item from cart
|
||||
- `increment` / `decrement` - change item quantity
|
||||
- `change_country` - update shipping country
|
||||
- `{:cart_updated, cart}` info - cross-tab cart sync via PubSub
|
||||
|
||||
LiveViews with custom cart logic (e.g. add_to_cart) can call
|
||||
@@ -19,12 +20,17 @@ defmodule SimpleshopThemeWeb.CartHook do
|
||||
import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1, push_event: 3]
|
||||
|
||||
alias SimpleshopTheme.Cart
|
||||
alias SimpleshopTheme.Shipping
|
||||
|
||||
def on_mount(:mount_cart, _params, session, socket) do
|
||||
cart_items = Cart.get_from_session(session)
|
||||
country_code = session["country_code"] || "GB"
|
||||
available_countries = Shipping.list_available_countries_with_names()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:country_code, country_code)
|
||||
|> assign(:available_countries, available_countries)
|
||||
|> update_cart_assigns(cart_items)
|
||||
|> assign(:cart_drawer_open, false)
|
||||
|> assign(:cart_status, nil)
|
||||
@@ -54,6 +60,16 @@ defmodule SimpleshopThemeWeb.CartHook do
|
||||
{:halt, assign(socket, :cart_drawer_open, false)}
|
||||
end
|
||||
|
||||
defp handle_cart_event("change_country", %{"country" => code}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:country_code, code)
|
||||
|> update_cart_assigns(socket.assigns.raw_cart)
|
||||
|> push_event("persist_country", %{code: code})
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
defp handle_cart_event("remove_item", %{"id" => variant_id}, socket) do
|
||||
cart = Cart.remove_item(socket.assigns.raw_cart, variant_id)
|
||||
|
||||
@@ -107,12 +123,25 @@ defmodule SimpleshopThemeWeb.CartHook do
|
||||
"""
|
||||
def update_cart_assigns(socket, cart) do
|
||||
%{items: items, count: count, subtotal: subtotal} = Cart.build_state(cart)
|
||||
country_code = socket.assigns[:country_code] || "GB"
|
||||
subtotal_pence = Cart.calculate_subtotal(items)
|
||||
|
||||
shipping_estimate =
|
||||
case Shipping.calculate_for_cart(items, country_code) do
|
||||
{:ok, cost} when cost > 0 -> cost
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
cart_total =
|
||||
Cart.format_price(subtotal_pence + (shipping_estimate || 0))
|
||||
|
||||
socket
|
||||
|> assign(:raw_cart, cart)
|
||||
|> assign(:cart_items, items)
|
||||
|> assign(:cart_count, count)
|
||||
|> assign(:cart_subtotal, subtotal)
|
||||
|> assign(:cart_total, cart_total)
|
||||
|> assign(:shipping_estimate, shipping_estimate)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
||||
@@ -24,7 +24,13 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<.order_summary subtotal={@cart_page_subtotal} mode={@mode} />
|
||||
<.order_summary
|
||||
subtotal={@cart_page_subtotal}
|
||||
shipping_estimate={assigns[:shipping_estimate]}
|
||||
country_code={assigns[:country_code] || "GB"}
|
||||
available_countries={assigns[:available_countries] || []}
|
||||
mode={@mode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -34,15 +34,19 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
|
||||
|
||||
attr :cart_items, :list, default: []
|
||||
attr :subtotal, :string, default: nil
|
||||
attr :total, :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
|
||||
attr :shipping_estimate, :integer, default: nil
|
||||
attr :country_code, :string, default: "GB"
|
||||
attr :available_countries, :list, default: []
|
||||
|
||||
def cart_drawer(assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :display_subtotal, fn ->
|
||||
assigns.subtotal || "£0.00"
|
||||
assign_new(assigns, :display_total, fn ->
|
||||
assigns.total || assigns.subtotal || "£0.00"
|
||||
end)
|
||||
|
||||
~H"""
|
||||
@@ -126,16 +130,18 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
|
||||
class="cart-drawer-footer"
|
||||
style="padding: 1rem 1.5rem; border-top: 1px solid var(--t-border-default); background: var(--t-surface-sunken);"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; font-family: var(--t-font-body); font-size: var(--t-text-small); color: var(--t-text-secondary); margin-bottom: 0.5rem;">
|
||||
<span>Delivery</span>
|
||||
<span>Calculated at checkout</span>
|
||||
</div>
|
||||
<.delivery_line
|
||||
shipping_estimate={@shipping_estimate}
|
||||
country_code={@country_code}
|
||||
available_countries={@available_countries}
|
||||
mode={@mode}
|
||||
/>
|
||||
<div
|
||||
class="cart-drawer-total"
|
||||
style="display: flex; justify-content: space-between; font-family: var(--t-font-body); font-size: var(--t-text-base); font-weight: 600; color: var(--t-text-primary); margin-bottom: 1rem;"
|
||||
>
|
||||
<span>Subtotal</span>
|
||||
<span>{@display_subtotal}</span>
|
||||
<span>{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}</span>
|
||||
<span>{@display_total}</span>
|
||||
</div>
|
||||
<%= if @mode == :preview do %>
|
||||
<button
|
||||
@@ -411,15 +417,55 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
|
||||
ProductImage.direct_url(Product.primary_image(product), 400)
|
||||
end
|
||||
|
||||
# Shared delivery line used by both cart_drawer and order_summary.
|
||||
# Shows a country <select> when rates are available, falls back to plain text.
|
||||
attr :shipping_estimate, :integer, default: nil
|
||||
attr :country_code, :string, default: "GB"
|
||||
attr :available_countries, :list, default: []
|
||||
attr :mode, :atom, default: :live
|
||||
|
||||
defp delivery_line(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="flex justify-between items-center"
|
||||
style="font-family: var(--t-font-body); font-size: var(--t-text-small); color: var(--t-text-secondary);"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
Delivery to
|
||||
<%= if @available_countries != [] and @mode != :preview do %>
|
||||
<form phx-change="change_country" style="display: inline;">
|
||||
<select
|
||||
name="country"
|
||||
aria-label="Delivery country"
|
||||
style="appearance: auto; background: transparent; border: none; color: inherit; font: inherit; padding: 0; cursor: pointer; text-decoration: underline; text-underline-offset: 2px;"
|
||||
>
|
||||
<%= for {code, name} <- @available_countries do %>
|
||||
<option value={code} selected={code == @country_code}>{name}</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
<% else %>
|
||||
<span>{SimpleshopTheme.Shipping.country_name(@country_code)}</span>
|
||||
<% end %>
|
||||
</span>
|
||||
<%= if @shipping_estimate do %>
|
||||
<span>{SimpleshopTheme.Cart.format_price(@shipping_estimate)}</span>
|
||||
<% else %>
|
||||
<span>Calculated at checkout</span>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the order summary card.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `subtotal` - Required. Subtotal amount (in pence/cents).
|
||||
* `delivery` - Optional. Delivery cost. Defaults to 800 (£8.00).
|
||||
* `vat` - Optional. VAT amount. Defaults to 720 (£7.20).
|
||||
* `currency` - Optional. Currency symbol. Defaults to "£".
|
||||
* `shipping_estimate` - Optional. Shipping estimate in pence.
|
||||
* `country_code` - Optional. Current country code. Default "GB".
|
||||
* `available_countries` - Optional. List of `{code, name}` tuples.
|
||||
* `mode` - Either `:live` (default) or `:preview`.
|
||||
|
||||
## Examples
|
||||
@@ -427,9 +473,15 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
|
||||
<.order_summary subtotal={3600} />
|
||||
"""
|
||||
attr :subtotal, :integer, required: true
|
||||
attr :shipping_estimate, :integer, default: nil
|
||||
attr :country_code, :string, default: "GB"
|
||||
attr :available_countries, :list, default: []
|
||||
attr :mode, :atom, default: :live
|
||||
|
||||
def order_summary(assigns) do
|
||||
assigns =
|
||||
assign(assigns, :estimated_total, assigns.subtotal + (assigns.shipping_estimate || 0))
|
||||
|
||||
~H"""
|
||||
<.shop_card class="p-6 sticky top-4">
|
||||
<h2
|
||||
@@ -446,17 +498,19 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
|
||||
{SimpleshopTheme.Cart.format_price(@subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span style="color: var(--t-text-secondary);">Delivery</span>
|
||||
<span class="text-sm" style="color: var(--t-text-secondary);">
|
||||
Calculated at checkout
|
||||
</span>
|
||||
</div>
|
||||
<.delivery_line
|
||||
shipping_estimate={@shipping_estimate}
|
||||
country_code={@country_code}
|
||||
available_countries={@available_countries}
|
||||
mode={@mode}
|
||||
/>
|
||||
<div class="border-t pt-3" style="border-color: var(--t-border-default);">
|
||||
<div class="flex justify-between text-lg">
|
||||
<span class="font-semibold" style="color: var(--t-text-primary);">Subtotal</span>
|
||||
<span class="font-semibold" style="color: var(--t-text-primary);">
|
||||
{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}
|
||||
</span>
|
||||
<span class="font-bold" style="color: var(--t-text-primary);">
|
||||
{SimpleshopTheme.Cart.format_price(@subtotal)}
|
||||
{SimpleshopTheme.Cart.format_price(@estimated_total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,8 +52,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates
|
||||
# can spread assigns without listing each one explicitly.
|
||||
@layout_keys ~w(theme_settings logo_image header_image mode cart_items cart_count
|
||||
cart_subtotal cart_drawer_open cart_status active_page error_page is_admin
|
||||
search_query search_results search_open categories)a
|
||||
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
|
||||
search_query search_results search_open categories shipping_estimate
|
||||
country_code available_countries)a
|
||||
|
||||
@doc """
|
||||
Extracts the assigns relevant to `shop_layout` from a full assigns map.
|
||||
@@ -82,6 +83,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
attr :cart_items, :list, required: true
|
||||
attr :cart_count, :integer, required: true
|
||||
attr :cart_subtotal, :string, required: true
|
||||
attr :cart_total, :string, default: nil
|
||||
attr :cart_drawer_open, :boolean, default: false
|
||||
attr :cart_status, :string, default: nil
|
||||
attr :active_page, :string, required: true
|
||||
@@ -90,6 +92,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
attr :search_query, :string, default: ""
|
||||
attr :search_results, :list, default: []
|
||||
attr :search_open, :boolean, default: false
|
||||
attr :shipping_estimate, :integer, default: nil
|
||||
attr :country_code, :string, default: "GB"
|
||||
attr :available_countries, :list, default: []
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
@@ -128,10 +133,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
<.cart_drawer
|
||||
cart_items={@cart_items}
|
||||
subtotal={@cart_subtotal}
|
||||
total={@cart_total}
|
||||
cart_count={@cart_count}
|
||||
mode={@mode}
|
||||
open={@cart_drawer_open}
|
||||
cart_status={@cart_status}
|
||||
shipping_estimate={@shipping_estimate}
|
||||
country_code={@country_code}
|
||||
available_countries={@available_countries}
|
||||
/>
|
||||
|
||||
<.search_modal
|
||||
|
||||
@@ -3,6 +3,7 @@ defmodule SimpleshopThemeWeb.CheckoutController do
|
||||
|
||||
alias SimpleshopTheme.Cart
|
||||
alias SimpleshopTheme.Orders
|
||||
alias SimpleshopTheme.Shipping
|
||||
|
||||
require Logger
|
||||
|
||||
@@ -54,16 +55,18 @@ defmodule SimpleshopThemeWeb.CheckoutController do
|
||||
|
||||
base_url = SimpleshopThemeWeb.Endpoint.url()
|
||||
|
||||
params = %{
|
||||
mode: "payment",
|
||||
line_items: line_items,
|
||||
success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: "#{base_url}/cart",
|
||||
metadata: %{"order_id" => order.id},
|
||||
shipping_address_collection: %{
|
||||
allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"]
|
||||
params =
|
||||
%{
|
||||
mode: "payment",
|
||||
line_items: line_items,
|
||||
success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: "#{base_url}/cart",
|
||||
metadata: %{"order_id" => order.id},
|
||||
shipping_address_collection: %{
|
||||
allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|> maybe_add_shipping_options(hydrated_items)
|
||||
|
||||
case Stripe.Checkout.Session.create(params) do
|
||||
{:ok, session} ->
|
||||
@@ -89,4 +92,38 @@ defmodule SimpleshopThemeWeb.CheckoutController do
|
||||
|> redirect(to: ~p"/cart")
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_shipping_options(params, hydrated_items) do
|
||||
gb_result = Shipping.calculate_for_cart(hydrated_items, "GB")
|
||||
us_result = Shipping.calculate_for_cart(hydrated_items, "US")
|
||||
|
||||
options =
|
||||
[]
|
||||
|> maybe_add_option(gb_result, "UK delivery", 5, 10)
|
||||
|> maybe_add_option(us_result, "International delivery", 10, 20)
|
||||
|
||||
if options == [] do
|
||||
params
|
||||
else
|
||||
Map.put(params, :shipping_options, options)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_option(options, {:ok, cost}, name, min_days, max_days) when cost > 0 do
|
||||
option = %{
|
||||
shipping_rate_data: %{
|
||||
type: "fixed_amount",
|
||||
display_name: name,
|
||||
fixed_amount: %{amount: cost, currency: "gbp"},
|
||||
delivery_estimate: %{
|
||||
minimum: %{unit: "business_day", value: min_days},
|
||||
maximum: %{unit: "business_day", value: max_days}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
options ++ [option]
|
||||
end
|
||||
|
||||
defp maybe_add_option(options, _result, _name, _min, _max), do: options
|
||||
end
|
||||
|
||||
@@ -36,6 +36,9 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
|
||||
payment_intent_id = session.payment_intent
|
||||
{:ok, order} = Orders.mark_paid(order, payment_intent_id)
|
||||
|
||||
# Update shipping cost from Stripe (if shipping options were presented)
|
||||
order = update_shipping_cost(order, session)
|
||||
|
||||
# Update shipping address if collected by Stripe
|
||||
order =
|
||||
if session.shipping_details do
|
||||
@@ -111,4 +114,19 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
|
||||
|
||||
Orders.update_order(order, %{shipping_address: shipping_address})
|
||||
end
|
||||
|
||||
defp update_shipping_cost(order, session) do
|
||||
shipping_amount = get_in(session, [Access.key(:shipping_cost), Access.key(:amount_total)])
|
||||
|
||||
if is_integer(shipping_amount) and shipping_amount > 0 do
|
||||
new_total = order.subtotal + shipping_amount
|
||||
|
||||
case Orders.update_order(order, %{shipping_cost: shipping_amount, total: new_total}) do
|
||||
{:ok, updated} -> updated
|
||||
{:error, _} -> order
|
||||
end
|
||||
else
|
||||
order
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@ defmodule SimpleshopThemeWeb.Shop.Cart do
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :page_title, "Cart")}
|
||||
{:ok, assign(socket, page_title: "Cart")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
55
lib/simpleshop_theme_web/plugs/country_detect.ex
Normal file
55
lib/simpleshop_theme_web/plugs/country_detect.ex
Normal file
@@ -0,0 +1,55 @@
|
||||
defmodule SimpleshopThemeWeb.Plugs.CountryDetect do
|
||||
@moduledoc """
|
||||
Plug that detects the visitor's country from cookies or Accept-Language.
|
||||
|
||||
Priority:
|
||||
1. `shipping_country` cookie (set when user explicitly changes country)
|
||||
2. Accept-Language header (locale tags like `en-GB` → `GB`)
|
||||
3. Falls back to `"GB"`
|
||||
|
||||
The result is stored in the session as `country_code` so LiveViews can
|
||||
read it. Only runs once per session — skips if `country_code` is already
|
||||
set (unless the cookie has changed).
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
@default_country "GB"
|
||||
@cookie_name "shipping_country"
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
cookie_country = conn.cookies[@cookie_name]
|
||||
session_country = get_session(conn, "country_code")
|
||||
|
||||
cond do
|
||||
# Cookie takes priority — user explicitly chose this country
|
||||
cookie_country not in [nil, ""] and cookie_country != session_country ->
|
||||
put_session(conn, "country_code", cookie_country)
|
||||
|
||||
# Session already set and no cookie override
|
||||
session_country != nil ->
|
||||
conn
|
||||
|
||||
# First visit: detect from Accept-Language
|
||||
true ->
|
||||
country = detect_from_header(conn)
|
||||
put_session(conn, "country_code", country)
|
||||
end
|
||||
end
|
||||
|
||||
defp detect_from_header(conn) do
|
||||
conn
|
||||
|> get_req_header("accept-language")
|
||||
|> List.first("")
|
||||
|> parse_country()
|
||||
end
|
||||
|
||||
defp parse_country(header) do
|
||||
case Regex.run(~r/[a-z]{2}-([A-Z]{2})/, header) do
|
||||
[_, country] -> country
|
||||
nil -> @default_country
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,6 +13,7 @@ defmodule SimpleshopThemeWeb.Router do
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :fetch_current_scope_for_user
|
||||
plug SimpleshopThemeWeb.Plugs.CountryDetect
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
|
||||
Reference in New Issue
Block a user