make PDP variant selection work without JS
All checks were successful
deploy / deploy (push) Successful in 1m3s
All checks were successful
deploy / deploy (push) Successful in 1m3s
Variant options (colour, size) are now URL params handled via handle_params instead of phx-click events. Swatches and size buttons render as patch links in shop mode, so changing variants works as plain navigation without JS. Quantity is now a number input that submits with the form. Unavailable variants render as disabled spans. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a61adf4939
commit
23e95a3de6
@ -578,11 +578,18 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
display: inline-block;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
&[aria-pressed="true"] {
|
&[aria-pressed="true"] {
|
||||||
border-color: var(--t-accent);
|
border-color: var(--t-accent);
|
||||||
--tw-ring-color: var(--t-accent);
|
--tw-ring-color: var(--t-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[aria-disabled="true"] {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.size-btn {
|
.size-btn {
|
||||||
@ -594,11 +601,18 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
&[aria-pressed="true"] {
|
&[aria-pressed="true"] {
|
||||||
border-color: var(--t-accent);
|
border-color: var(--t-accent);
|
||||||
background: color-mix(in oklch, var(--t-accent) 10%, transparent);
|
background: color-mix(in oklch, var(--t-accent) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[aria-disabled="true"] {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Quantity selector ── */
|
/* ── Quantity selector ── */
|
||||||
@ -643,9 +657,20 @@
|
|||||||
color: var(--t-text-primary);
|
color: var(--t-text-primary);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border-inline: 2px solid var(--t-border-default);
|
border-inline: 2px solid var(--t-border-default);
|
||||||
min-width: 3rem;
|
width: 3.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: none;
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: none;
|
||||||
|
font: inherit;
|
||||||
|
appearance: textfield;
|
||||||
|
|
||||||
|
&::-webkit-inner-spin-button,
|
||||||
|
&::-webkit-outer-spin-button {
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-in {
|
.stock-in {
|
||||||
|
|||||||
@ -32,7 +32,7 @@
|
|||||||
name="variant_id"
|
name="variant_id"
|
||||||
value={@selected_variant && @selected_variant.id}
|
value={@selected_variant && @selected_variant.id}
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="quantity" value={@quantity} />
|
<%!-- quantity is provided by the quantity_selector input below --%>
|
||||||
|
|
||||||
<%!-- Dynamic variant selectors --%>
|
<%!-- Dynamic variant selectors --%>
|
||||||
<%= for option_type <- @option_types do %>
|
<%= for option_type <- @option_types do %>
|
||||||
@ -41,6 +41,7 @@
|
|||||||
selected={@selected_options[option_type.name]}
|
selected={@selected_options[option_type.name]}
|
||||||
available={@available_options[option_type.name] || []}
|
available={@available_options[option_type.name] || []}
|
||||||
mode={@mode}
|
mode={@mode}
|
||||||
|
option_urls={(@option_urls || %{})[option_type.name] || %{}}
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|||||||
@ -1339,13 +1339,15 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
Renders a variant selector for a single option type.
|
Renders a variant selector for a single option type.
|
||||||
|
|
||||||
Shows color swatches for color-type options, text buttons for others.
|
Shows color swatches for color-type options, text buttons for others.
|
||||||
Disables unavailable options and fires `select_option` event on click.
|
In shop mode, each option is a patch link for progressive enhancement.
|
||||||
|
In preview mode, options are inert buttons.
|
||||||
|
|
||||||
## Attributes
|
## Attributes
|
||||||
|
|
||||||
* `option_type` - Required. Map with :name, :type, :values keys
|
* `option_type` - Required. Map with :name, :type, :values keys
|
||||||
* `selected` - Required. Currently selected value (string)
|
* `selected` - Required. Currently selected value (string)
|
||||||
* `available` - Required. List of available values for this option
|
* `available` - Required. List of available values for this option
|
||||||
|
* `option_urls` - Optional. Map of value title => patch URL
|
||||||
* `mode` - Optional. :shop or :preview (default: :shop)
|
* `mode` - Optional. :shop or :preview (default: :shop)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@ -1354,11 +1356,13 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
option_type={%{name: "Size", type: :size, values: [%{title: "S"}, ...]}}
|
option_type={%{name: "Size", type: :size, values: [%{title: "S"}, ...]}}
|
||||||
selected="M"
|
selected="M"
|
||||||
available={["S", "M", "L"]}
|
available={["S", "M", "L"]}
|
||||||
|
option_urls={%{"S" => "/products/foo?Size=S", ...}}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
attr :option_type, :map, required: true
|
attr :option_type, :map, required: true
|
||||||
attr :selected, :string, required: true
|
attr :selected, :string, required: true
|
||||||
attr :available, :list, required: true
|
attr :available, :list, required: true
|
||||||
|
attr :option_urls, :map, default: %{}
|
||||||
attr :mode, :atom, default: :shop
|
attr :mode, :atom, default: :shop
|
||||||
|
|
||||||
def variant_selector(assigns) do
|
def variant_selector(assigns) do
|
||||||
@ -1378,8 +1382,8 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
hex={value[:hex] || "#888888"}
|
hex={value[:hex] || "#888888"}
|
||||||
selected={value.title == @selected}
|
selected={value.title == @selected}
|
||||||
disabled={value.title not in @available}
|
disabled={value.title not in @available}
|
||||||
option_name={@option_type.name}
|
|
||||||
mode={@mode}
|
mode={@mode}
|
||||||
|
url={@option_urls[value.title]}
|
||||||
/>
|
/>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.size_button
|
<.size_button
|
||||||
@ -1387,8 +1391,8 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
title={value.title}
|
title={value.title}
|
||||||
selected={value.title == @selected}
|
selected={value.title == @selected}
|
||||||
disabled={value.title not in @available}
|
disabled={value.title not in @available}
|
||||||
option_name={@option_type.name}
|
|
||||||
mode={@mode}
|
mode={@mode}
|
||||||
|
url={@option_urls[value.title]}
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@ -1400,44 +1404,76 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
attr :hex, :string, required: true
|
attr :hex, :string, required: true
|
||||||
attr :selected, :boolean, required: true
|
attr :selected, :boolean, required: true
|
||||||
attr :disabled, :boolean, required: true
|
attr :disabled, :boolean, required: true
|
||||||
attr :option_name, :string, required: true
|
|
||||||
attr :mode, :atom, default: :shop
|
attr :mode, :atom, default: :shop
|
||||||
|
attr :url, :string, default: nil
|
||||||
|
|
||||||
defp color_swatch(assigns) do
|
defp color_swatch(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<button
|
<%= cond do %>
|
||||||
type="button"
|
<% @mode == :shop and not @disabled -> %>
|
||||||
phx-click={if @mode == :shop, do: "select_option"}
|
<.link
|
||||||
phx-value-option={@option_name}
|
patch={@url}
|
||||||
phx-value-selected={@title}
|
class="color-swatch"
|
||||||
class="color-swatch"
|
style={"background-color: #{@hex};"}
|
||||||
style={"background-color: #{@hex};"}
|
title={@title}
|
||||||
title={@title}
|
aria-label={"Select #{@title}"}
|
||||||
aria-label={"Select #{@title}"}
|
aria-pressed={to_string(@selected)}
|
||||||
aria-pressed={to_string(@selected)}
|
>
|
||||||
>
|
</.link>
|
||||||
</button>
|
<% @mode == :shop -> %>
|
||||||
|
<span
|
||||||
|
class="color-swatch"
|
||||||
|
style={"background-color: #{@hex};"}
|
||||||
|
title={"#{@title} (unavailable)"}
|
||||||
|
aria-label={"#{@title} (unavailable)"}
|
||||||
|
aria-disabled="true"
|
||||||
|
/>
|
||||||
|
<% true -> %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="color-swatch"
|
||||||
|
style={"background-color: #{@hex};"}
|
||||||
|
title={@title}
|
||||||
|
aria-label={"Select #{@title}"}
|
||||||
|
aria-pressed={to_string(@selected)}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
attr :title, :string, required: true
|
attr :title, :string, required: true
|
||||||
attr :selected, :boolean, required: true
|
attr :selected, :boolean, required: true
|
||||||
attr :disabled, :boolean, required: true
|
attr :disabled, :boolean, required: true
|
||||||
attr :option_name, :string, required: true
|
|
||||||
attr :mode, :atom, default: :shop
|
attr :mode, :atom, default: :shop
|
||||||
|
attr :url, :string, default: nil
|
||||||
|
|
||||||
defp size_button(assigns) do
|
defp size_button(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<button
|
<%= cond do %>
|
||||||
type="button"
|
<% @mode == :shop and not @disabled -> %>
|
||||||
phx-click={if @mode == :shop, do: "select_option"}
|
<.link
|
||||||
phx-value-option={@option_name}
|
patch={@url}
|
||||||
phx-value-selected={@title}
|
class="size-btn"
|
||||||
class="size-btn"
|
aria-pressed={to_string(@selected)}
|
||||||
aria-pressed={to_string(@selected)}
|
>
|
||||||
>
|
{@title}
|
||||||
{@title}
|
</.link>
|
||||||
</button>
|
<% @mode == :shop -> %>
|
||||||
|
<span
|
||||||
|
class="size-btn"
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
{@title}
|
||||||
|
</span>
|
||||||
|
<% true -> %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="size-btn"
|
||||||
|
aria-pressed={to_string(@selected)}
|
||||||
|
>
|
||||||
|
{@title}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -1478,9 +1514,15 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
>
|
>
|
||||||
−
|
−
|
||||||
</button>
|
</button>
|
||||||
<span class="qty-display">
|
<input
|
||||||
{@quantity}
|
type="number"
|
||||||
</span>
|
name="quantity"
|
||||||
|
value={@quantity}
|
||||||
|
min={@min}
|
||||||
|
max={@max}
|
||||||
|
class="qty-display"
|
||||||
|
aria-label="Quantity"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="increment_quantity"
|
phx-click="increment_quantity"
|
||||||
|
|||||||
@ -452,6 +452,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
|> assign(:available_options, available_options)
|
|> assign(:available_options, available_options)
|
||||||
|> assign(:display_price, display_price)
|
|> assign(:display_price, display_price)
|
||||||
|> assign(:quantity, 1)
|
|> assign(:quantity, 1)
|
||||||
|
|> assign(:option_urls, %{})
|
||||||
|
|
||||||
~H"<BerrypodWeb.PageTemplates.pdp {assigns} />"
|
~H"<BerrypodWeb.PageTemplates.pdp {assigns} />"
|
||||||
end
|
end
|
||||||
|
|||||||
@ -36,10 +36,6 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
|||||||
|
|
||||||
option_types = Product.option_types(product)
|
option_types = Product.option_types(product)
|
||||||
variants = product.variants || []
|
variants = product.variants || []
|
||||||
{selected_options, selected_variant} = initialize_variant_selection(variants)
|
|
||||||
available_options = compute_available_options(option_types, variants, selected_options)
|
|
||||||
display_price = variant_price(selected_variant, product)
|
|
||||||
gallery_images = filter_gallery_images(all_images, selected_options["Color"])
|
|
||||||
|
|
||||||
if connected?(socket) and socket.assigns[:analytics_visitor_hash] do
|
if connected?(socket) and socket.assigns[:analytics_visitor_hash] do
|
||||||
Analytics.track_event(
|
Analytics.track_event(
|
||||||
@ -62,26 +58,105 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
|||||||
|> assign(:json_ld, product_json_ld(product, og_url, og_image, base))
|
|> assign(:json_ld, product_json_ld(product, og_url, og_image, base))
|
||||||
|> assign(:product, product)
|
|> assign(:product, product)
|
||||||
|> assign(:all_images, all_images)
|
|> assign(:all_images, all_images)
|
||||||
|> assign(:gallery_images, gallery_images)
|
|
||||||
|> assign(:related_products, related_products)
|
|> assign(:related_products, related_products)
|
||||||
|> assign(:quantity, 1)
|
|> assign(:quantity, 1)
|
||||||
|> assign(:option_types, option_types)
|
|> assign(:option_types, option_types)
|
||||||
|> assign(:variants, variants)
|
|> assign(:variants, variants)
|
||||||
|> assign(:selected_options, selected_options)
|
|
||||||
|> assign(:selected_variant, selected_variant)
|
|
||||||
|> assign(:available_options, available_options)
|
|
||||||
|> assign(:display_price, display_price)
|
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp initialize_variant_selection([first | _] = _variants) do
|
@impl true
|
||||||
{first.options, first}
|
def handle_params(params, _uri, socket) do
|
||||||
|
if socket.assigns[:product] do
|
||||||
|
{:noreply, apply_variant_params(params, socket)}
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp initialize_variant_selection([]) do
|
defp apply_variant_params(params, socket) do
|
||||||
{%{}, nil}
|
%{option_types: option_types, variants: variants, product: product, all_images: all_images} =
|
||||||
|
socket.assigns
|
||||||
|
|
||||||
|
option_names = Enum.map(option_types, & &1.name)
|
||||||
|
|
||||||
|
default_options =
|
||||||
|
case variants do
|
||||||
|
[first | _] -> first.options
|
||||||
|
[] -> %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract option params from the URL, ignoring unknown keys
|
||||||
|
param_options =
|
||||||
|
Enum.reduce(option_names, %{}, fn name, acc ->
|
||||||
|
case params[name] do
|
||||||
|
nil -> acc
|
||||||
|
value -> Map.put(acc, name, value)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
{selected_options, changed_option} =
|
||||||
|
if map_size(param_options) == 0 do
|
||||||
|
{default_options, nil}
|
||||||
|
else
|
||||||
|
merged = Map.merge(default_options, param_options)
|
||||||
|
|
||||||
|
# Identify which option was changed from the default
|
||||||
|
changed =
|
||||||
|
Enum.find(option_names, fn name ->
|
||||||
|
Map.has_key?(param_options, name) and param_options[name] != default_options[name]
|
||||||
|
end)
|
||||||
|
|
||||||
|
{merged, changed}
|
||||||
|
end
|
||||||
|
|
||||||
|
selected_options =
|
||||||
|
if changed_option do
|
||||||
|
resolve_valid_combo(variants, option_types, selected_options, changed_option)
|
||||||
|
else
|
||||||
|
selected_options
|
||||||
|
end
|
||||||
|
|
||||||
|
selected_variant = find_variant(variants, selected_options)
|
||||||
|
|
||||||
|
# If params produced an invalid combo with no matching variant, fall back to defaults
|
||||||
|
{selected_options, selected_variant} =
|
||||||
|
if is_nil(selected_variant) and variants != [] do
|
||||||
|
opts = List.first(variants).options
|
||||||
|
{opts, List.first(variants)}
|
||||||
|
else
|
||||||
|
{selected_options, selected_variant}
|
||||||
|
end
|
||||||
|
|
||||||
|
available_options = compute_available_options(option_types, variants, selected_options)
|
||||||
|
display_price = variant_price(selected_variant, product)
|
||||||
|
gallery_images = filter_gallery_images(all_images, selected_options["Color"])
|
||||||
|
|
||||||
|
option_urls = build_option_urls(option_types, selected_options, product.slug)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:selected_options, selected_options)
|
||||||
|
|> assign(:selected_variant, selected_variant)
|
||||||
|
|> assign(:available_options, available_options)
|
||||||
|
|> assign(:display_price, display_price)
|
||||||
|
|> assign(:gallery_images, gallery_images)
|
||||||
|
|> assign(:option_urls, option_urls)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_option_urls(option_types, selected_options, slug) do
|
||||||
|
Enum.reduce(option_types, %{}, fn opt_type, acc ->
|
||||||
|
urls =
|
||||||
|
opt_type.values
|
||||||
|
|> Enum.map(fn value ->
|
||||||
|
params = Map.put(selected_options, opt_type.name, value.title)
|
||||||
|
{value.title, ~p"/products/#{slug}?#{params}"}
|
||||||
|
end)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
Map.put(acc, opt_type.name, urls)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp compute_available_options(option_types, variants, selected_options) do
|
defp compute_available_options(option_types, variants, selected_options) do
|
||||||
@ -148,32 +223,6 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
|||||||
|> Enum.map(& &1.url)
|
|> Enum.map(& &1.url)
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("select_option", %{"option" => option_name, "selected" => value}, socket) do
|
|
||||||
variants = socket.assigns.variants
|
|
||||||
option_types = socket.assigns.option_types
|
|
||||||
|
|
||||||
selected_options = Map.put(socket.assigns.selected_options, option_name, value)
|
|
||||||
selected_options = resolve_valid_combo(variants, option_types, selected_options, option_name)
|
|
||||||
|
|
||||||
selected_variant = find_variant(variants, selected_options)
|
|
||||||
|
|
||||||
available_options =
|
|
||||||
compute_available_options(option_types, variants, selected_options)
|
|
||||||
|
|
||||||
gallery_images = filter_gallery_images(socket.assigns.all_images, selected_options["Color"])
|
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> assign(:selected_options, selected_options)
|
|
||||||
|> assign(:selected_variant, selected_variant)
|
|
||||||
|> assign(:available_options, available_options)
|
|
||||||
|> assign(:display_price, variant_price(selected_variant, socket.assigns.product))
|
|
||||||
|> assign(:gallery_images, gallery_images)
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("increment_quantity", _params, socket) do
|
def handle_event("increment_quantity", _params, socket) do
|
||||||
quantity = min(socket.assigns.quantity + 1, 99)
|
quantity = min(socket.assigns.quantity + 1, 99)
|
||||||
|
|||||||
@ -211,30 +211,62 @@ defmodule BerrypodWeb.Shop.ProductShowTest do
|
|||||||
assert html =~ "Size"
|
assert html =~ "Size"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "selecting a size updates the price", %{conn: conn, print: print} do
|
test "variant buttons are links for no-JS fallback", %{conn: conn, print: print} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||||
|
|
||||||
|
assert has_element?(view, "a.size-btn", "18x24")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "selecting a size via link updates the price", %{conn: conn, print: print} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||||
|
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> element("button[phx-value-selected='18x24']")
|
|> element("a.size-btn", "18x24")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
assert html =~ "£32.00"
|
assert html =~ "£32.00"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "selecting a size via URL params updates the price", %{conn: conn, print: print} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}?Size=18x24")
|
||||||
|
|
||||||
|
assert html =~ "£32.00"
|
||||||
|
end
|
||||||
|
|
||||||
test "selecting a colour auto-adjusts size if needed", %{conn: conn, shirt: shirt} do
|
test "selecting a colour auto-adjusts size if needed", %{conn: conn, shirt: shirt} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/products/#{shirt.slug}")
|
{:ok, view, _html} = live(conn, ~p"/products/#{shirt.slug}")
|
||||||
|
|
||||||
# Select White — M and L are available, XL is not
|
# Select White — M and L are available, XL is not
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> element("button[aria-label='Select White']")
|
|> element("a[aria-label='Select White']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# White is selected, size M should still be selected (valid combo)
|
# White is selected, size M should still be selected (valid combo)
|
||||||
assert html =~ ~s(aria-pressed="true")
|
assert html =~ ~s(aria-pressed="true")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "invalid variant combo auto-corrects", %{conn: conn, shirt: shirt} do
|
||||||
|
# White/XL is unavailable — should auto-adjust to a valid combo
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/products/#{shirt.slug}?Color=White&Size=XL")
|
||||||
|
|
||||||
|
assert html =~ "£29.99"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unknown option values fall back to default", %{conn: conn, print: print} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}?Size=XXXL")
|
||||||
|
|
||||||
|
# Falls back to default variant (8x10 at £19.99)
|
||||||
|
assert html =~ "£19.99"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unavailable variant rendered as disabled span", %{conn: conn, shirt: shirt} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/products/#{shirt.slug}?Color=White")
|
||||||
|
|
||||||
|
assert has_element?(view, "span.size-btn[aria-disabled='true']", "XL")
|
||||||
|
end
|
||||||
|
|
||||||
test "shows variant for single-variant product", %{conn: conn, related: related} do
|
test "shows variant for single-variant product", %{conn: conn, related: related} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/products/#{related.slug}")
|
{:ok, _view, html} = live(conn, ~p"/products/#{related.slug}")
|
||||||
|
|
||||||
@ -244,6 +276,15 @@ defmodule BerrypodWeb.Shop.ProductShowTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "Quantity selector" do
|
describe "Quantity selector" do
|
||||||
|
test "quantity is a number input for no-JS fallback", %{conn: conn, print: print} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||||
|
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"form[phx-submit='add_to_cart'] input[type='number'][name='quantity']"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
test "renders quantity selector with initial value of 1", %{conn: conn, print: print} do
|
test "renders quantity selector with initial value of 1", %{conn: conn, print: print} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||||
|
|
||||||
@ -308,7 +349,11 @@ defmodule BerrypodWeb.Shop.ProductShowTest do
|
|||||||
test "add to basket button is a submit button inside the form", %{conn: conn, print: print} do
|
test "add to basket button is a submit button inside the form", %{conn: conn, print: print} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||||
|
|
||||||
assert has_element?(view, "form[phx-submit='add_to_cart'] button[type='submit']", "Add to basket")
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"form[phx-submit='add_to_cart'] button[type='submit']",
|
||||||
|
"Add to basket"
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "add to cart opens the cart drawer", %{conn: conn, print: print} do
|
test "add to cart opens the cart drawer", %{conn: conn, print: print} do
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user