diff --git a/PROGRESS.md b/PROGRESS.md index f06dfb6..20b2deb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -208,6 +208,15 @@ Codebase analysis identified ~380 lines of duplication across LiveViews, templat See: [docs/plans/dry-refactor.md](docs/plans/dry-refactor.md) for full analysis and plan +### Shop Page Integration Tests +**Status:** Follow-up + +Home, product detail, and cart pages have no LiveView integration tests. Collection and content pages are well-covered (16 and 10 tests respectively). Priority order by logic complexity: + +1. **Product detail page** — variant selection, add-to-cart, gallery, breadcrumb +2. **Cart page** — cart items, quantity changes, order summary, checkout link +3. **Home page** — hero section, featured products, category nav (mostly presentational) + ### Page Editor **Status:** Future (Tier 4) diff --git a/lib/simpleshop_theme_web.ex b/lib/simpleshop_theme_web.ex index eef6873..1bee8fa 100644 --- a/lib/simpleshop_theme_web.ex +++ b/lib/simpleshop_theme_web.ex @@ -88,7 +88,7 @@ defmodule SimpleshopThemeWeb do # Core UI components import SimpleshopThemeWeb.CoreComponents # Shop UI components - import SimpleshopThemeWeb.ShopComponents + use SimpleshopThemeWeb.ShopComponents # Common modules used in templates alias Phoenix.LiveView.JS diff --git a/lib/simpleshop_theme_web/components/layouts/shop.html.heex b/lib/simpleshop_theme_web/components/layouts/shop.html.heex index 30f9320..86b3e32 100644 --- a/lib/simpleshop_theme_web/components/layouts/shop.html.heex +++ b/lib/simpleshop_theme_web/components/layouts/shop.html.heex @@ -1,2 +1,2 @@ - +<.shop_flash_group flash={@flash} /> {@inner_content} diff --git a/lib/simpleshop_theme_web/components/page_templates.ex b/lib/simpleshop_theme_web/components/page_templates.ex index d6f5cf2..0127dc7 100644 --- a/lib/simpleshop_theme_web/components/page_templates.ex +++ b/lib/simpleshop_theme_web/components/page_templates.ex @@ -15,7 +15,7 @@ defmodule SimpleshopThemeWeb.PageTemplates do - `cart_count` - Number of items in cart """ use Phoenix.Component - import SimpleshopThemeWeb.ShopComponents + use SimpleshopThemeWeb.ShopComponents embed_templates "page_templates/*" end diff --git a/lib/simpleshop_theme_web/components/shop_components.ex b/lib/simpleshop_theme_web/components/shop_components.ex index 16735a1..2694992 100644 --- a/lib/simpleshop_theme_web/components/shop_components.ex +++ b/lib/simpleshop_theme_web/components/shop_components.ex @@ -1,4487 +1,23 @@ defmodule SimpleshopThemeWeb.ShopComponents do @moduledoc """ - Provides shop/storefront UI components. + Facade module for shop/storefront UI components. - These components are shared between the theme preview system and - the public storefront pages. They render using CSS custom properties - defined by the theme settings. - """ - use Phoenix.Component + `use SimpleshopThemeWeb.ShopComponents` imports all sub-modules: - @doc """ - Renders the announcement bar. - - The bar displays promotional messaging at the top of the page. - It uses CSS custom properties for theming. - - ## Attributes - - * `theme_settings` - Required. The theme settings map. - * `message` - Optional. The announcement message to display. - Defaults to "Free delivery on orders over £40". - - ## Examples - - <.announcement_bar theme_settings={@theme_settings} /> - <.announcement_bar theme_settings={@theme_settings} message="20% off this weekend!" /> - """ - attr :theme_settings, :map, required: true - attr :message, :string, default: "Sample announcement – e.g. free delivery, sales, or new drops" - - def announcement_bar(assigns) do - ~H""" -
-

{@message}

-
- """ - end - - @doc """ - Renders the skip link for keyboard navigation accessibility. - - This is a standard accessibility pattern that allows keyboard users - to skip directly to the main content. - """ - def skip_link(assigns) do - ~H""" - - """ - end - - @doc """ - Wraps page content in the standard shop shell: container, header, footer, - cart drawer, search modal, and mobile bottom nav. - - Templates pass their unique `
` content as the inner block. - The `error_page` flag disables the CartPersist hook and mobile bottom nav. - """ - attr :theme_settings, :map, required: true - attr :logo_image, :any, required: true - attr :header_image, :any, required: true - attr :mode, :atom, required: true - attr :cart_items, :list, required: true - attr :cart_count, :integer, required: true - attr :cart_subtotal, :string, required: true - attr :cart_drawer_open, :boolean, default: false - attr :cart_status, :string, default: nil - attr :active_page, :string, required: true - attr :error_page, :boolean, default: false - - slot :inner_block, required: true - - def shop_layout(assigns) do - ~H""" -
- <.skip_link /> - - <%= if @theme_settings.announcement_bar do %> - <.announcement_bar theme_settings={@theme_settings} /> - <% end %> - - <.shop_header - theme_settings={@theme_settings} - logo_image={@logo_image} - header_image={@header_image} - active_page={@active_page} - mode={@mode} - cart_count={@cart_count} - /> - - {render_slot(@inner_block)} - - <.shop_footer theme_settings={@theme_settings} mode={@mode} /> - - <.cart_drawer - cart_items={@cart_items} - subtotal={@cart_subtotal} - cart_count={@cart_count} - mode={@mode} - open={@cart_drawer_open} - cart_status={@cart_status} - /> - - <.search_modal hint_text={~s(Try a search – e.g. "mountain" or "notebook")} /> - - <.mobile_bottom_nav :if={!@error_page} active_page={@active_page} mode={@mode} /> -
- """ - end - - # ============================================================================= - # Themed Form Components - # ============================================================================= - # These components wrap common form elements with the themed CSS classes. - # They provide a consistent, theme-aware appearance across shop pages. - - @doc """ - Renders a themed text input. - - This component applies the `.themed-input` CSS class which inherits - colors, borders, and radii from the current theme's CSS variables. - - ## Attributes - - * `type` - Optional. Input type. Defaults to "text". - * `class` - Optional. Additional CSS classes. - * All other attributes are passed through to the input element. - - ## Examples - - <.shop_input type="email" placeholder="your@email.com" /> - <.shop_input type="text" name="name" class="flex-1" /> - """ - attr :type, :string, default: "text" - attr :class, :string, default: nil - attr :rest, :global, include: ~w(name value placeholder required disabled autocomplete readonly) - - def shop_input(assigns) do - ~H""" - - """ - end - - @doc """ - Renders a themed textarea. - - ## Attributes - - * `class` - Optional. Additional CSS classes. - * All other attributes are passed through to the textarea element. - - ## Examples - - <.shop_textarea placeholder="Your message..." rows="5" /> - """ - attr :class, :string, default: nil - attr :rest, :global, include: ~w(name rows placeholder required disabled readonly) - - def shop_textarea(assigns) do - ~H""" - - """ - end - - @doc """ - Renders a themed select dropdown. - - ## Attributes - - * `class` - Optional. Additional CSS classes. - * `options` - Required. List of options (strings or {value, label} tuples). - * `selected` - Optional. Currently selected value. - * All other attributes are passed through to the select element. - - ## Examples - - <.shop_select options={["Option 1", "Option 2"]} /> - <.shop_select options={[{"value", "Label"}]} selected="value" /> - """ - attr :class, :string, default: nil - attr :options, :list, required: true - attr :selected, :any, default: nil - attr :rest, :global, include: ~w(name required disabled aria-label) - - def shop_select(assigns) do - ~H""" - - """ - end - - @doc """ - Renders a themed primary button (accent color background). - - ## Attributes - - * `class` - Optional. Additional CSS classes. - * `type` - Optional. Button type. Defaults to "button". - * All other attributes are passed through to the button element. - - ## Slots - - * `inner_block` - Required. Button content. - - ## Examples - - <.shop_button>Send Message - <.shop_button type="submit" class="w-full">Subscribe - """ - attr :class, :string, default: nil - attr :type, :string, default: "button" - attr :rest, :global, include: ~w(disabled name value phx-click phx-value-page) - - slot :inner_block, required: true - - def shop_button(assigns) do - ~H""" - - """ - end - - @doc """ - Renders a themed outline/secondary button. - - ## Attributes - - * `class` - Optional. Additional CSS classes. - * `type` - Optional. Button type. Defaults to "button". - * All other attributes are passed through to the button element. - - ## Slots - - * `inner_block` - Required. Button content. - - ## Examples - - <.shop_button_outline>Continue Shopping - """ - attr :class, :string, default: nil - attr :type, :string, default: "button" - attr :rest, :global, include: ~w(disabled name value phx-click phx-value-page) - - slot :inner_block, required: true - - def shop_button_outline(assigns) do - ~H""" - - """ - end - - @doc """ - Renders a themed link styled as a primary button. - - ## Attributes - - * `href` - Required. Link destination. - * `class` - Optional. Additional CSS classes. - - ## Slots - - * `inner_block` - Required. Link content. - - ## Examples - - <.shop_link_button href="/checkout">Checkout - """ - attr :href, :string, required: true - attr :class, :string, default: nil - - slot :inner_block, required: true - - def shop_link_button(assigns) do - ~H""" - - {render_slot(@inner_block)} - - """ - end - - @doc """ - Renders a themed link styled as an outline button. - - ## Attributes - - * `href` - Required. Link destination. - * `class` - Optional. Additional CSS classes. - - ## Slots - - * `inner_block` - Required. Link content. - - ## Examples - - <.shop_link_outline href="/collections/all">Continue Shopping - """ - attr :href, :string, required: true - attr :class, :string, default: nil - - slot :inner_block, required: true - - def shop_link_outline(assigns) do - ~H""" - - {render_slot(@inner_block)} - - """ - end - - @doc """ - Renders a themed card container. - - ## Attributes - - * `class` - Optional. Additional CSS classes. - - ## Slots - - * `inner_block` - Required. Card content. - - ## Examples - - <.shop_card class="p-6"> -

Card Title

-

Card content...

- - """ - attr :class, :string, default: nil - - slot :inner_block, required: true - - def shop_card(assigns) do - ~H""" -
- {render_slot(@inner_block)} -
- """ - end - - @doc """ - Renders a mobile bottom navigation bar. - - This component provides thumb-friendly navigation for mobile devices, - following modern UX best practices. It's hidden on larger screens where - the standard header navigation is used. - - ## Attributes - - * `active_page` - Required. The current page identifier (e.g., "home", "collection", "about", "contact"). - * `mode` - Optional. Either `:live` (default) for real navigation or - `:preview` for theme preview mode with phx-click handlers. - * `cart_count` - Optional. Number of items in cart for badge display. Default: 0. - - ## Examples - - <.mobile_bottom_nav active_page="home" /> - <.mobile_bottom_nav active_page="collection" mode={:preview} /> - """ - attr :active_page, :string, required: true - attr :mode, :atom, default: :live - attr :cart_count, :integer, default: 0 - - def mobile_bottom_nav(assigns) do - ~H""" - - """ - end - - attr :icon, :atom, required: true - attr :label, :string, required: true - attr :page, :string, required: true - attr :href, :string, required: true - attr :active_page, :string, required: true - attr :active_pages, :list, default: nil - attr :mode, :atom, default: :live - - defp mobile_nav_item(assigns) do - active_pages = assigns.active_pages || [assigns.page] - is_current = assigns.active_page in active_pages - assigns = assign(assigns, :is_current, is_current) - - ~H""" -
  • - <%= if @mode == :preview do %> - - <.nav_icon icon={@icon} size={if @is_current, do: "w-6 h-6", else: "w-5 h-5"} /> - {@label} - - <% else %> - - <.nav_icon icon={@icon} size={if @is_current, do: "w-6 h-6", else: "w-5 h-5"} /> - {@label} - - <% end %> -
  • - """ - end - - defp nav_icon(%{icon: :home} = assigns) do - assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end) - - ~H""" - - - - - """ - end - - defp nav_icon(%{icon: :shop} = assigns) do - assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end) - - ~H""" - - - - - - - """ - end - - defp nav_icon(%{icon: :about} = assigns) do - assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end) - - ~H""" - - - - - - """ - end - - defp nav_icon(%{icon: :contact} = assigns) do - assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end) - - ~H""" - - - - - """ - end - - @doc """ - Renders the search modal overlay. - - This is a modal dialog for searching products. Currently provides - the UI shell; search functionality will be added later. - - ## Attributes - - * `hint_text` - Optional. Hint text shown below the search input. - Defaults to nil (no hint shown). - - ## Examples - - <.search_modal /> - <.search_modal hint_text="Try searching for \"mountain\" or \"forest\"" /> - """ - attr :hint_text, :string, default: nil - - def search_modal(assigns) do - ~H""" - - """ - end - - @doc """ - Renders the shop footer with newsletter signup and links. - - ## Attributes - - * `theme_settings` - Required. The theme settings map containing site_name. - * `mode` - Optional. Either `:live` (default) for real navigation or - `:preview` for theme preview mode with phx-click handlers. - - ## Examples - - <.shop_footer theme_settings={@theme_settings} /> - <.shop_footer theme_settings={@theme_settings} mode={:preview} /> - """ - attr :theme_settings, :map, required: true - attr :mode, :atom, default: :live - - def shop_footer(assigns) do - assigns = assign(assigns, :current_year, Date.utc_today().year) - - ~H""" - - """ - end - - @doc """ - Renders the shop header with logo, navigation, and actions. - - ## Attributes - - * `theme_settings` - Required. The theme settings map. - * `logo_image` - Optional. The logo image struct (with id, is_svg fields). - * `header_image` - Optional. The header background image struct. - * `active_page` - Optional. Current page for nav highlighting. - * `mode` - Optional. Either `:live` (default) or `:preview`. - * `cart_count` - Optional. Number of items in cart. Defaults to 0. - - ## Examples - - <.shop_header theme_settings={@theme_settings} /> - <.shop_header theme_settings={@theme_settings} mode={:preview} cart_count={2} /> - """ - attr :theme_settings, :map, required: true - attr :logo_image, :map, default: nil - attr :header_image, :map, default: nil - attr :active_page, :string, default: nil - attr :mode, :atom, default: :live - attr :cart_count, :integer, default: 0 - - def shop_header(assigns) do - ~H""" -
    - <%= if @theme_settings.header_background_enabled && @header_image do %> -
    - <% end %> - - - - - -
    - - -
    -
    - """ - end - - defp logo_url(logo_image, %{logo_recolor: true, logo_color: color}) when logo_image.is_svg do - clean_color = String.trim_leading(color, "#") - "/images/#{logo_image.id}/recolored/#{clean_color}" - end - - defp logo_url(logo_image, _), do: "/images/#{logo_image.id}" - - # Logo content that links to home, except when already on home page. - # This follows accessibility best practices - current page should not be a link. - attr :theme_settings, :map, required: true - attr :logo_image, :map, default: nil - attr :active_page, :string, default: nil - attr :mode, :atom, default: :live - - defp logo_content(assigns) do - is_home = assigns.active_page == "home" - assigns = assign(assigns, :is_home, is_home) - - ~H""" - <%= if @is_home do %> - <.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} /> - <% else %> - <%= if @mode == :preview do %> - - <.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} /> - - <% else %> - - <.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} /> - - <% end %> - <% end %> - """ - end - - attr :theme_settings, :map, required: true - attr :logo_image, :map, default: nil - - defp logo_inner(assigns) do - ~H""" - <%= case @theme_settings.logo_mode do %> - <% "text-only" -> %> - - {@theme_settings.site_name} - - <% "logo-text" -> %> - <%= if @logo_image do %> - {@theme_settings.site_name} - <% end %> - - {@theme_settings.site_name} - - <% "logo-only" -> %> - <%= if @logo_image do %> - {@theme_settings.site_name} - <% else %> - - {@theme_settings.site_name} - - <% end %> - <% _ -> %> - - {@theme_settings.site_name} - - <% end %> - """ - end - - defp header_background_style(settings, header_image) do - "position: absolute; top: 0; left: 0; right: 0; bottom: 0; " <> - "background-image: url('/images/#{header_image.id}'); " <> - "background-size: #{settings.header_zoom}%; " <> - "background-position: #{settings.header_position_x}% #{settings.header_position_y}%; " <> - "background-repeat: no-repeat; z-index: 0;" - end - - # Navigation item that renders as a span (not a link) when on the current page. - # This follows accessibility best practices - current page should not be a link. - attr :label, :string, required: true - attr :page, :string, required: true - attr :active_page, :string, required: true - attr :href, :string, default: nil - attr :mode, :atom, default: :live - attr :active_pages, :list, default: nil - - defp nav_item(assigns) do - # Allow matching multiple pages (e.g., "Shop" is active for both collection and pdp) - active_pages = assigns.active_pages || [assigns.page] - is_current = assigns.active_page in active_pages - assigns = assign(assigns, :is_current, is_current) - - ~H""" - <%= if @is_current do %> - - {@label} - - <% else %> - <%= if @mode == :preview do %> - - {@label} - - <% else %> - - {@label} - - <% end %> - <% 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 """ - Renders the cart drawer (floating sidebar). - - The drawer slides in from the right when opened. It displays cart items - and checkout options. Follows WAI-ARIA dialog pattern for accessibility. - - ## Attributes - - * `cart_items` - List of cart items to display. Each item should have - `image`, `name`, `variant`, `price`, and `variant_id` keys. Default: [] - * `subtotal` - The subtotal to display. Default: nil (shows "£0.00") - * `cart_count` - Number of items for screen reader description. Default: 0 - * `mode` - Either `:live` (default) for real stores or `:preview` for theme editor. - In preview mode, "View basket" navigates via LiveView JS commands. - - ## Examples - - <.cart_drawer cart_items={@cart.items} subtotal={@cart.subtotal} /> - <.cart_drawer cart_items={demo_items} subtotal="£72.00" mode={:preview} /> + - `Base` — themed inputs, buttons, cards + - `Layout` — header, footer, mobile nav, shop_layout wrapper + - `Cart` — cart drawer, cart items, order summary + - `Product` — product cards, gallery, variant selector, hero sections + - `Content` — rich text, responsive images, contact form, reviews """ - attr :cart_items, :list, default: [] - attr :subtotal, :string, default: nil - attr :cart_count, :integer, default: 0 - attr :cart_status, :string, default: nil - attr :mode, :atom, default: :live - attr :open, :boolean, default: false - - def cart_drawer(assigns) do - assigns = - assign_new(assigns, :display_subtotal, fn -> - assigns.subtotal || "£0.00" - end) - - ~H""" - <%!-- Screen reader announcements for cart changes --%> -
    - {@cart_status} -
    - - - - - - - """ - end - - @doc """ - Shared cart item row component used by both drawer and cart page. - - ## Attributes - - * `item` - Required. Cart item with `name`, `variant`, `price`, `quantity`, `image`, `variant_id`, `product_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""" -
    - <%= if @mode != :preview do %> - - - <% else %> -
    -
    - <% end %> -
    -

    - <%= if @mode != :preview do %> - - {@item.name} - - <% else %> - {@item.name} - <% end %> -

    - <%= if @item.variant do %> -

    - {@item.variant} -

    - <% end %> - -
    - <%= if @show_quantity_controls do %> -
    - - - {@item.quantity} - - -
    - <% else %> - - Qty: {@item.quantity} - - <% end %> - - <.cart_remove_button variant_id={@item.variant_id} item_name={@item.name} /> -
    -
    - -
    -

    - {SimpleshopTheme.Cart.format_price(@item.price * @item.quantity)} -

    -
    -
    - """ - end - - @doc """ - Cart empty state component. - """ - attr :mode, :atom, default: :live - - def cart_empty_state(assigns) do - ~H""" -
    - - - - - -

    Your basket is empty

    - <%= if @mode == :preview do %> - - <% else %> - - Continue shopping - - <% end %> -
    - """ - end - - @doc """ - Remove button for cart items. - """ - attr :variant_id, :string, required: true - attr :item_name, :string, default: "item" - - def cart_remove_button(assigns) do - ~H""" - - """ - end - - @doc """ - Renders a product card with configurable variants. - - ## Attributes - - * `product` - Required. The product map with `name`, `image_url`, `price`, etc. - * `theme_settings` - Required. The theme settings map. - * `mode` - Either `:live` (default) or `:preview`. - * `variant` - The visual variant: - - `:default` - Collection page style with border, category, full details - - `:featured` - Home page style with hover lift, no border - - `:compact` - PDP related products with aspect-square, minimal info - - `:minimal` - Error 404 style, smallest, not clickable - * `show_category` - Show category label. Defaults based on variant. - * `show_badges` - Show product badges. Defaults based on variant. - * `show_delivery_text` - Show "Free delivery" text. Defaults based on variant. - * `clickable` - Whether the card navigates. Defaults based on variant. - - ## Examples - - <.product_card product={product} theme_settings={@theme_settings} /> - <.product_card product={product} theme_settings={@theme_settings} variant={:featured} mode={:preview} /> - """ - attr :product, :map, required: true - attr :theme_settings, :map, required: true - attr :mode, :atom, default: :live - attr :variant, :atom, default: :default - attr :show_category, :boolean, default: nil - attr :show_badges, :boolean, default: nil - attr :show_delivery_text, :boolean, default: nil - attr :clickable, :boolean, default: nil - - def product_card(assigns) do - # Apply variant defaults for nil values - defaults = variant_defaults(assigns.variant) - - assigns = - assigns - |> assign_new(:show_category_resolved, fn -> - if assigns.show_category == nil, do: defaults.show_category, else: assigns.show_category - end) - |> assign_new(:show_badges_resolved, fn -> - if assigns.show_badges == nil, do: defaults.show_badges, else: assigns.show_badges - end) - |> assign_new(:show_delivery_text_resolved, fn -> - if assigns.show_delivery_text == nil, - do: defaults.show_delivery_text, - else: assigns.show_delivery_text - end) - |> assign_new(:clickable_resolved, fn -> - if assigns.clickable == nil, do: defaults.clickable, else: assigns.clickable - end) - - ~H""" - <%= if @clickable_resolved do %> - <%= if @mode == :preview do %> - - <.product_card_inner - product={@product} - theme_settings={@theme_settings} - variant={@variant} - show_category={@show_category_resolved} - show_badges={@show_badges_resolved} - show_delivery_text={@show_delivery_text_resolved} - /> - - <% else %> - - <.product_card_inner - product={@product} - theme_settings={@theme_settings} - variant={@variant} - show_category={@show_category_resolved} - show_badges={@show_badges_resolved} - show_delivery_text={@show_delivery_text_resolved} - /> - - <% end %> - <% else %> -
    - <.product_card_inner - product={@product} - theme_settings={@theme_settings} - variant={@variant} - show_category={@show_category_resolved} - show_badges={@show_badges_resolved} - show_delivery_text={@show_delivery_text_resolved} - /> -
    - <% end %> - """ - end - - attr :product, :map, required: true - attr :theme_settings, :map, required: true - attr :variant, :atom, required: true - attr :show_category, :boolean, required: true - attr :show_badges, :boolean, required: true - attr :show_delivery_text, :boolean, required: true - - defp product_card_inner(assigns) do - ~H""" -
    - <%= if @show_badges do %> - <.product_badge product={@product} /> - <% end %> - <.product_card_image - product={@product} - variant={@variant} - class="product-image-primary w-full h-full object-cover transition-opacity duration-300" - /> - <%= if @theme_settings.hover_image && @product[:hover_image_url] do %> - <.product_card_image - product={@product} - variant={@variant} - image_key={:hover} - class="product-image-hover w-full h-full object-cover" - /> - <% end %> -
    -
    - <%= if @show_category && @product[:category] do %> -

    - {@product.category} -

    - <% end %> -

    - {@product.name} -

    - <%= if @theme_settings.show_prices do %> - <.product_price product={@product} variant={@variant} /> - <% end %> - <%= if @show_delivery_text do %> -

    - Made to order -

    - <% end %> -
    - """ - end - - # Helper to render product images with responsive variants. - # Works for both mockups (static files) and database images. - # Requires source_width to be set for responsive image support. - attr :product, :map, required: true - attr :variant, :atom, required: true - attr :class, :string, default: "" - attr :image_key, :atom, default: :primary - - defp product_card_image(assigns) do - # Determine which image fields to use based on primary vs hover - {image_id_field, image_url_field, source_width_field} = - case assigns.image_key do - :hover -> {:hover_image_id, :hover_image_url, :hover_source_width} - _ -> {:image_id, :image_url, :source_width} - end - - # Build the base image path: - # - Database images: /images/{id}/variant - # - Mockup images: {image_url} (e.g., /mockups/product-1) - image_id = assigns.product[image_id_field] - image_url = assigns.product[image_url_field] - - src = - if image_id do - # Trailing slash so build_srcset produces /images/{id}/variant/800.webp - "/images/#{image_id}/variant/" - else - image_url - end - - assigns = - assigns - |> assign(:src, src) - |> assign(:source_width, assigns.product[source_width_field]) - - ~H""" - <%= if @source_width do %> - <.responsive_image - src={@src} - alt={@product.name} - source_width={@source_width} - sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px" - class={@class} - width={600} - height={600} - priority={@variant == :minimal} - /> - <% else %> - {@product.name} - <% end %> - """ - end - - attr :product, :map, required: true - - defp product_badge(assigns) do - ~H""" - <%= cond do %> - <% Map.get(@product, :in_stock, true) == false -> %> - Sold out - <% @product[:is_new] -> %> - New - <% @product[:on_sale] -> %> - Sale - <% true -> %> - <% end %> - """ - end - - attr :product, :map, required: true - attr :variant, :atom, required: true - - defp product_price(assigns) do - ~H""" - <%= case @variant do %> - <% :default -> %> -
    - <%= if @product.on_sale do %> - - {SimpleshopTheme.Cart.format_price(@product.price)} - - - {SimpleshopTheme.Cart.format_price(@product.compare_at_price)} - - <% else %> - - {SimpleshopTheme.Cart.format_price(@product.price)} - - <% end %> -
    - <% :featured -> %> -

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

    - <% :compact -> %> -

    - {SimpleshopTheme.Cart.format_price(@product.price)} -

    - <% :minimal -> %> -

    - {SimpleshopTheme.Cart.format_price(@product.price)} -

    - <% end %> - """ - end - - defp variant_defaults(:default), - do: %{show_category: true, show_badges: true, show_delivery_text: true, clickable: true} - - defp variant_defaults(:featured), - do: %{show_category: false, show_badges: true, show_delivery_text: true, clickable: true} - - defp variant_defaults(:compact), - do: %{show_category: false, show_badges: false, show_delivery_text: false, clickable: true} - - defp variant_defaults(:minimal), - do: %{show_category: false, show_badges: false, show_delivery_text: false, clickable: false} - - defp card_classes(:default), do: "product-card group overflow-hidden transition-all" - - defp card_classes(:featured), - do: "product-card group overflow-hidden transition-all hover:-translate-y-1" - - defp card_classes(:compact), do: "product-card group overflow-hidden cursor-pointer" - defp card_classes(:minimal), do: "product-card group overflow-hidden" - - defp card_style(:default), - do: - "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card); cursor: pointer;" - - defp card_style(:featured), - do: - "background-color: var(--t-surface-raised); border-radius: var(--t-radius-card); cursor: pointer;" - - defp card_style(:compact), - do: - "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);" - - defp card_style(:minimal), - do: - "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-subtle); border-radius: var(--t-radius-card);" - - defp image_container_classes(:compact), - do: "product-image-container aspect-square bg-gray-200 overflow-hidden relative" - - defp image_container_classes(:minimal), - do: "product-image-container aspect-square bg-gray-200 overflow-hidden relative" - - defp image_container_classes(_), - do: "product-image-container bg-gray-200 overflow-hidden relative" - - defp content_padding_class(:compact), do: "p-3" - defp content_padding_class(:minimal), do: "p-2" - defp content_padding_class(_), do: "" - - defp title_classes(:default), do: "font-semibold mb-2" - defp title_classes(:featured), do: "text-sm font-medium mb-1" - defp title_classes(:compact), do: "font-semibold text-sm mb-1" - defp title_classes(:minimal), do: "text-xs font-semibold truncate" - - defp title_style(:default), - do: "font-family: var(--t-font-heading); color: var(--t-text-primary);" - - defp title_style(:featured), do: "color: var(--t-text-primary);" - - defp title_style(:compact), - do: "font-family: var(--t-font-heading); color: var(--t-text-primary);" - - defp title_style(:minimal), do: "color: var(--t-text-primary);" - - @doc """ - Renders a responsive product grid container. - - This component wraps product cards in a responsive grid layout. It supports - theme-based column settings or fixed column layouts for specific use cases. - - ## Attributes - - * `theme_settings` - Optional. When provided, uses `grid_columns` setting for lg breakpoint. - * `columns` - Optional. Fixed column count for lg breakpoint (overrides theme_settings). - Use `:fixed_4` for a fixed 2/4 column layout (error page, related products). - * `gap` - Optional. Gap size. Defaults to standard gap. - * `class` - Optional. Additional CSS classes to add. - - ## Slots - - * `inner_block` - Required. The product cards to render inside the grid. - - ## Examples - - <.product_grid theme_settings={@theme_settings}> - <%= for product <- @products do %> - <.product_card product={product} theme_settings={@theme_settings} /> - <% end %> - - - <.product_grid columns={:fixed_4} gap="gap-6"> - ... - - """ - attr :theme_settings, :map, default: nil - attr :columns, :atom, default: nil - attr :gap, :string, default: nil - attr :class, :string, default: nil - - slot :inner_block, required: true - - def product_grid(assigns) do - ~H""" -
    - {render_slot(@inner_block)} -
    - """ - end - - defp grid_classes(theme_settings, columns, gap, extra_class) do - base = "product-grid grid" - - cols = - cond do - columns == :fixed_4 -> - "grid-cols-2 md:grid-cols-4" - - theme_settings != nil -> - responsive_cols = "grid-cols-1 sm:grid-cols-2" - - lg_cols = - case theme_settings.grid_columns do - "2" -> "lg:grid-cols-2" - "3" -> "lg:grid-cols-3" - "4" -> "lg:grid-cols-4" - _ -> "lg:grid-cols-3" - end - - "#{responsive_cols} #{lg_cols}" - - true -> - "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3" - end - - gap_class = gap || "" - - [base, cols, gap_class, extra_class] - |> Enum.reject(&is_nil/1) - |> Enum.reject(&(&1 == "")) - |> Enum.join(" ") - end - - @doc """ - Renders a centered hero section with title, description, and optional CTAs. - - ## Attributes - - * `title` - Required. The main heading text. - * `description` - Required. The description paragraph text. - * `variant` - The visual variant: - - `:default` - Standard hero with section wrapper and padding - - `:page` - Page header style, larger title, more spacing, no section wrapper - - `:error` - Error page with pre-title (404), two buttons - * `background` - Background style. Either `:base` (default) or `:sunken`. - * `pre_title` - Optional. Text shown above title (e.g., "404" for error pages). - * `cta_text` - Optional. Text for the primary CTA button. - * `cta_page` - Optional. Page to navigate to on click (for preview mode). - * `cta_href` - Optional. URL for live mode navigation. - * `secondary_cta_text` - Optional. Text for secondary button (error variant). - * `secondary_cta_page` - Optional. Page for secondary button (preview mode). - * `secondary_cta_href` - Optional. URL for secondary button (live mode). - * `mode` - Either `:live` (default) or `:preview`. - - ## Examples - - <.hero_section - title="Original designs, printed on demand" - description="From art prints to apparel..." - cta_text="Shop the collection" - cta_page="collection" - mode={:preview} - /> - - <.hero_section - variant={:page} - title="Get in touch" - description="Questions about your order?" - /> - - <.hero_section - variant={:error} - pre_title="404" - title="Page Not Found" - description="Sorry, that page doesn't exist..." - cta_text="Go to Homepage" - secondary_cta_text="Browse Products" - mode={:preview} - /> - """ - attr :title, :string, required: true - attr :description, :string, required: true - attr :variant, :atom, default: :default - attr :background, :atom, default: :base - attr :pre_title, :string, default: nil - attr :cta_text, :string, default: nil - attr :cta_page, :string, default: nil - attr :cta_href, :string, default: nil - attr :secondary_cta_text, :string, default: nil - attr :secondary_cta_page, :string, default: nil - attr :secondary_cta_href, :string, default: nil - attr :mode, :atom, default: :live - - def hero_section(assigns) do - ~H""" - <%= case @variant do %> - <% :default -> %> -
    -

    - {@title} -

    -

    - {@description} -

    - <.hero_cta - :if={@cta_text} - text={@cta_text} - page={@cta_page} - href={@cta_href} - mode={@mode} - variant={:primary} - /> -
    - <% :page -> %> -
    -

    - {@title} -

    -

    - {@description} -

    -
    - <% :error -> %> -
    - <%= if @pre_title do %> -

    - {@pre_title} -

    - <% end %> -

    - {@title} -

    -

    - {@description} -

    - <%= if @cta_text || @secondary_cta_text do %> -
    - <.hero_cta - :if={@cta_text} - text={@cta_text} - page={@cta_page} - href={@cta_href} - mode={@mode} - variant={:primary} - /> - <.hero_cta - :if={@secondary_cta_text} - text={@secondary_cta_text} - page={@secondary_cta_page} - href={@secondary_cta_href} - mode={@mode} - variant={:secondary} - /> -
    - <% end %> -
    - <% end %> - """ - end - - attr :text, :string, required: true - attr :page, :string, default: nil - attr :href, :string, default: nil - attr :mode, :atom, required: true - attr :variant, :atom, required: true - - defp hero_cta(assigns) do - ~H""" - <%= if @mode == :preview do %> - - <% else %> - " text-decoration: none;"} - > - {@text} - - <% end %> - """ - end - - defp hero_cta_classes(:primary), do: "themed-button px-8 py-3 font-semibold transition-all" - - defp hero_cta_classes(:secondary), - do: "themed-button-outline px-8 py-3 font-semibold transition-all" - - defp hero_cta_style(:primary), do: "" - defp hero_cta_style(:secondary), do: "" - - @doc """ - Renders a row of category circles for navigation. - - ## Attributes - - * `categories` - Required. List of category maps with `name` and `image_url`. - * `limit` - Optional. Maximum number of categories to show. Defaults to 3. - * `mode` - Either `:live` (default) or `:preview`. - - ## Examples - - <.category_nav categories={@categories} mode={:preview} /> - <.category_nav categories={@categories} limit={4} /> - """ - attr :categories, :list, required: true - attr :limit, :integer, default: 3 - attr :mode, :atom, default: :live - - def category_nav(assigns) do - ~H""" -
    -

    Shop by Category

    - -
    - """ - end - - @doc """ - Renders a featured products section with title, product grid, and view-all button. - - ## Attributes - - * `title` - Required. Section heading text. - * `products` - Required. List of products to display. - * `theme_settings` - Required. The theme settings map. - * `limit` - Optional. Maximum products to show. Defaults to 4. - * `mode` - Either `:live` (default) or `:preview`. - * `cta_text` - Optional. Text for the "view all" button. Defaults to "View all products". - * `cta_page` - Optional. Page to navigate to (preview mode). Defaults to "collection". - * `cta_href` - Optional. URL for live mode. Defaults to "/collections/all". - - ## Examples - - <.featured_products_section - title="Featured products" - products={@products} - theme_settings={@theme_settings} - mode={:preview} - /> - """ - attr :title, :string, required: true - attr :products, :list, required: true - attr :theme_settings, :map, required: true - attr :limit, :integer, default: 4 - attr :mode, :atom, default: :live - attr :cta_text, :string, default: "View all products" - attr :cta_page, :string, default: "collection" - attr :cta_href, :string, default: "/collections/all" - - def featured_products_section(assigns) do - ~H""" -
    -

    - {@title} -

    - - <.product_grid theme_settings={@theme_settings}> - <%= for product <- Enum.take(@products, @limit) do %> - <.product_card - product={product} - theme_settings={@theme_settings} - mode={@mode} - variant={:featured} - /> - <% end %> - - -
    - <%= if @mode == :preview do %> - - <% else %> - - {@cta_text} - - <% end %> -
    -
    - """ - end - - @doc """ - Renders a two-column image + text section. - - ## Attributes - - * `title` - Required. Section heading text. - * `description` - Required. Body text content. - * `image_url` - Required. URL for the image. - * `link_text` - Optional. Text for the link. If nil, no link is shown. - * `link_page` - Optional. Page to navigate to (preview mode). - * `link_href` - Optional. URL for live mode. - * `image_position` - Either `:left` (default) or `:right`. - * `mode` - Either `:live` (default) or `:preview`. - - ## Examples - - <.image_text_section - title="Made with passion, printed with care" - description="Every design starts with an idea..." - image_url="/mockups/example.jpg" - link_text="Learn more about the studio →" - link_page="about" - mode={:preview} - /> - """ - attr :title, :string, required: true - attr :description, :string, required: true - attr :image_url, :string, required: true - attr :link_text, :string, default: nil - attr :link_page, :string, default: nil - attr :link_href, :string, default: nil - attr :image_position, :atom, default: :left - attr :mode, :atom, default: :live - - def image_text_section(assigns) do - ~H""" -
    - <%= if @image_position == :left do %> - <.image_text_image image_url={@image_url} /> - <.image_text_content - title={@title} - description={@description} - link_text={@link_text} - link_page={@link_page} - link_href={@link_href} - mode={@mode} - /> - <% else %> - <.image_text_content - title={@title} - description={@description} - link_text={@link_text} - link_page={@link_page} - link_href={@link_href} - mode={@mode} - /> - <.image_text_image image_url={@image_url} /> - <% end %> -
    - """ - end - - attr :image_url, :string, required: true - - defp image_text_image(assigns) do - ~H""" -
    -
    - """ - end - - attr :title, :string, required: true - attr :description, :string, required: true - attr :link_text, :string, default: nil - attr :link_page, :string, default: nil - attr :link_href, :string, default: nil - attr :mode, :atom, required: true - - defp image_text_content(assigns) do - ~H""" -
    -

    - {@title} -

    -

    - {@description} -

    - <%= if @link_text do %> - <%= if @mode == :preview do %> - - {@link_text} - - <% else %> - - {@link_text} - - <% end %> - <% end %> -
    - """ - end - - @doc """ - Renders a page header with title and optional product count. - - ## Attributes - - * `title` - Required. The page title. - * `subtitle` - Optional. Text below the title. - * `product_count` - Optional. Number to display as "X products". - - ## Examples - - <.collection_header title="All Products" product_count={24} /> - """ - attr :title, :string, required: true - attr :subtitle, :string, default: nil - attr :product_count, :integer, default: nil - - def collection_header(assigns) do - ~H""" -
    -
    -

    - {@title} -

    - <%= if @subtitle do %> -

    {@subtitle}

    - <% end %> - <%= if @product_count do %> -

    {@product_count} products

    - <% end %> -
    -
    - """ - end - - @doc """ - Renders a filter bar with category pills and sort dropdown. - - ## Attributes - - * `categories` - Required. List of category maps with `name` field. - * `active_category` - Optional. Currently selected category name. Defaults to "All". - * `sort_options` - Optional. List of sort option strings. - - ## Examples - - <.filter_bar categories={@categories} /> - <.filter_bar categories={@categories} active_category="Art Prints" /> - """ - attr :categories, :list, required: true - attr :active_category, :string, default: "All" - - attr :sort_options, :list, - default: [ - "Sort by: Featured", - "Price: Low to High", - "Price: High to Low", - "Newest", - "Best Selling" - ] - - def filter_bar(assigns) do - ~H""" -
    - -
    - - <%= for category <- @categories do %> - - <% end %> -
    - - - <.shop_select options={@sort_options} class="px-4 py-2" /> -
    - """ - end - - @doc """ - Renders a content body container for long-form content pages (about, etc.). - - ## Attributes - - * `image_src` - Optional. Base path to image (without size/extension), used with responsive_image. - * `image_alt` - Optional. Alt text for the image. Defaults to "Page image". - - ## Slots - - * `inner_block` - Required. The content to render. - - ## Examples - - <.content_body image_src="/mockups/night-sky-blanket-3" image_alt="A cosy blanket"> -

    Content here...

    - - """ - attr :image_src, :string, default: nil - attr :image_alt, :string, default: "Page image" - - slot :inner_block, required: true - - def content_body(assigns) do - ~H""" -
    - <%= if @image_src do %> -
    - <.responsive_image - src={@image_src} - source_width={1200} - alt={@image_alt} - sizes="(max-width: 800px) 100vw, 800px" - class="w-full h-[300px] object-cover" - /> -
    - <% end %> - -
    - {render_slot(@inner_block)} -
    -
    - """ - end - - @doc """ - Renders a contact form card. - - ## Attributes - - * `title` - Optional. Form heading. Defaults to "Send a message". - * `email` - Optional. If provided, displays "Email me: [email]" below the title. - - ## Examples - - <.contact_form /> - <.contact_form title="Get in touch" /> - <.contact_form email="hello@example.com" /> - """ - attr :title, :string, default: "Send a message" - attr :email, :string, default: nil - attr :response_time, :string, default: nil - - def contact_form(assigns) do - ~H""" - <.shop_card class="p-8"> -

    - {@title} -

    - <%= if @email || @response_time do %> -
    - <%= if @email do %> -

    - Email me: - - {@email} - -

    - <% end %> - <%= if @response_time do %> -

    {@response_time}

    - <% end %> -
    - <% else %> -
    - <% end %> - -
    -
    - - <.shop_input type="text" placeholder="Your name" class="w-full px-4 py-2" /> -
    - -
    - - <.shop_input type="email" placeholder="your@email.com" class="w-full px-4 py-2" /> -
    - -
    - - <.shop_input type="text" placeholder="How can I help?" class="w-full px-4 py-2" /> -
    - -
    - - <.shop_textarea rows="5" placeholder="Your message..." class="w-full px-4 py-2" /> -
    - - <.shop_button type="submit" class="w-full px-6 py-3 font-semibold transition-all"> - Send Message - -
    - - """ - end - - @doc """ - Renders the order tracking card. - - ## Examples - - <.order_tracking_card /> - """ - def order_tracking_card(assigns) do - ~H""" - <.shop_card class="p-6"> -

    Track your order

    -

    - Enter your email and I'll send you a link to check your order status. -

    -
    - <.shop_input - type="email" - placeholder="your@email.com" - class="flex-1 min-w-0 px-3 py-2 text-sm" - style="min-width: 150px;" - /> - <.shop_button class="px-4 py-2 text-sm font-medium whitespace-nowrap"> - Send - -
    - - """ - end - - @doc """ - Renders the info card with bullet points (e.g., "Handy to know" section). - - ## Attributes - - * `title` - Required. Card heading. - * `items` - Required. List of maps with `label` and `value` keys. - - ## Examples - - <.info_card title="Handy to know" items={[ - %{label: "Printing", value: "2-5 business days"}, - %{label: "Delivery", value: "3-7 business days after printing"} - ]} /> - """ - attr :title, :string, required: true - attr :items, :list, required: true - - def info_card(assigns) do - ~H""" - <.shop_card class="p-6"> -

    {@title}

    - - - """ - end - - @doc """ - Renders the contact info card with email link. - - ## Attributes - - * `title` - Optional. Card heading. Defaults to "Get in touch". - * `email` - Required. Email address. - * `response_text` - Optional. Response time text. Defaults to "We typically respond within 24 hours". - - ## Examples - - <.contact_info_card email="hello@example.com" /> - """ - attr :title, :string, default: "Get in touch" - attr :email, :string, required: true - attr :response_text, :string, default: "We typically respond within 24 hours" - - def contact_info_card(assigns) do - ~H""" - <.shop_card class="p-6"> -

    {@title}

    - - - - - {@email} - -

    - {@response_text} -

    - - """ - end - - @doc """ - Renders a newsletter signup card. - - ## Attributes - - * `title` - Optional. Card heading. Defaults to "Stay in touch". - * `description` - Optional. Card description. - * `button_text` - Optional. Button text. Defaults to "Subscribe". - * `variant` - Optional. Either `:card` (default, with border/background) or `:inline` (no card styling, for embedding in footer). - - ## Examples - - <.newsletter_card /> - <.newsletter_card title="Studio news" description="Get updates on new products." /> - <.newsletter_card variant={:inline} /> - """ - attr :title, :string, default: "Newsletter" - - attr :description, :string, - default: "Sample newsletter signup. Replace with your own message to encourage subscribers." - - attr :button_text, :string, default: "Subscribe" - attr :variant, :atom, default: :card - - def newsletter_card(%{variant: :inline} = assigns) do - ~H""" -
    -

    - {@title} -

    -

    - {@description} -

    -
    - <.shop_input - type="email" - placeholder="your@email.com" - class="flex-1 min-w-0 px-4 py-2 text-sm" - style="min-width: 150px;" - /> - <.shop_button type="submit" class="px-6 py-2 text-sm font-medium whitespace-nowrap"> - {@button_text} - -
    -
    - """ - end - - def newsletter_card(assigns) do - ~H""" - <.shop_card class="p-6"> -

    {@title}

    -

    - {@description} -

    -
    - <.shop_input - type="email" - placeholder="your@email.com" - class="flex-1 min-w-0 px-3 py-2 text-sm" - style="min-width: 150px;" - /> - <.shop_button type="submit" class="px-4 py-2 text-sm font-medium whitespace-nowrap"> - {@button_text} - -
    - - """ - end - - @doc """ - Renders social media links in a single card with a compact grid layout. - - ## Attributes - - * `title` - Optional. Card heading. Defaults to "Follow us". - * `links` - Optional. List of maps with `platform`, `url`, and `label` keys. - Supported platforms: :instagram, :pinterest, :facebook, :twitter, :tiktok, :patreon, :youtube - - ## Examples - - <.social_links_card /> - <.social_links_card title="Elsewhere" links={[%{platform: :instagram, url: "https://instagram.com/example", label: "Instagram"}]} /> - """ - attr :title, :string, default: "Find me on" - - attr :links, :list, - default: [ - %{platform: :instagram, url: "#", label: "Instagram"}, - %{platform: :pinterest, url: "#", label: "Pinterest"} - ] - - def social_links_card(assigns) do - ~H""" - <.shop_card class="p-6"> -

    {@title}

    -
    - <%= for link <- @links do %> - - - <.social_icon platform={link.platform} /> - - {link.label} - - <% end %> -
    - - """ - end - - @doc """ - Renders social media icon links. - - ## Attributes - - * `links` - Optional. List of maps with `platform`, `url`, and `label` keys. - Supported platforms: :instagram, :pinterest - - ## Examples - - <.social_links /> - <.social_links links={[%{platform: :instagram, url: "https://instagram.com/example", label: "Instagram"}]} /> - """ - attr :links, :list, - default: [ - %{platform: :instagram, url: "https://instagram.com", label: "Instagram"}, - %{platform: :pinterest, url: "https://pinterest.com", label: "Pinterest"} - ] - - def social_links(assigns) do - ~H""" -
    - <%= for link <- @links do %> - - <% end %> -
    - """ - end - - # Renders a social media icon for the given platform. - # - # All icons are from Simple Icons (simpleicons.org), MIT licensed. - # - # ## Supported platforms - # - # **Commercial/Creative:** - # :instagram, :pinterest, :tiktok, :facebook, :twitter, :youtube, :patreon, :kofi, :etsy, :gumroad, :bandcamp - # - # **Open Web/Federated:** - # :mastodon, :pixelfed, :bluesky, :peertube, :lemmy, :matrix - # - # **Developer/Hacker:** - # :github, :gitlab, :codeberg, :sourcehut - # - # **Communication:** - # :discord, :telegram, :signal - # - # **Other:** - # :substack, :rss, :website - attr :platform, :atom, required: true - - # Commercial/Creative platforms - defp social_icon(%{platform: :instagram} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :pinterest} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :tiktok} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :facebook} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :twitter} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :youtube} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :patreon} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :kofi} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :etsy} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :gumroad} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :bandcamp} = assigns) do - ~H""" - - - - """ - end - - # Open Web/Federated platforms - defp social_icon(%{platform: :mastodon} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :pixelfed} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :bluesky} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :peertube} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :lemmy} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :matrix} = assigns) do - ~H""" - - - - """ - end - - # Developer/Hacker platforms - defp social_icon(%{platform: :github} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :gitlab} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :codeberg} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :sourcehut} = assigns) do - ~H""" - - - - """ - end - - # Communication platforms - defp social_icon(%{platform: :discord} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :telegram} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :signal} = assigns) do - ~H""" - - - - """ - end - - # Other platforms - defp social_icon(%{platform: :substack} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :rss} = assigns) do - ~H""" - - - - """ - end - - defp social_icon(%{platform: :website} = assigns) do - ~H""" - - - - """ - end - - # Fallback for unknown platforms - defp social_icon(assigns) do - ~H""" - - - - """ - end - - @doc """ - Renders a cart item row. - - ## Attributes - - * `item` - Required. Map with `product` (containing `image_url`, `name`, `price`), `variant`, and `quantity`. - * `currency` - Optional. Currency symbol. Defaults to "£". - - ## Examples - - <.cart_item item={item} /> - """ - attr :item, :map, required: true - - def cart_item(assigns) do - ~H""" - <.shop_card class="flex gap-4 p-4"> -
    - {@item.product.name} -
    - -
    -

    - {@item.product.name} -

    -

    - {@item.variant} -

    - -
    -
    - - - {@item.quantity} - - -
    - - -
    -
    - -
    -

    - {SimpleshopTheme.Cart.format_price(@item.product.price * @item.quantity)} -

    -
    - - """ - 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 "£". - * `mode` - Either `:live` (default) or `:preview`. - - ## Examples - - <.order_summary subtotal={3600} /> - """ - attr :subtotal, :integer, required: true - attr :mode, :atom, default: :live - - def order_summary(assigns) do - ~H""" - <.shop_card class="p-6 sticky top-4"> -

    - Order summary -

    - -
    -
    - Subtotal - - {SimpleshopTheme.Cart.format_price(@subtotal)} - -
    -
    - Delivery - - Calculated at checkout - -
    -
    -
    - Subtotal - - {SimpleshopTheme.Cart.format_price(@subtotal)} - -
    -
    -
    - - <%= if @mode == :preview do %> - <.shop_button class="w-full px-6 py-3 font-semibold transition-all mb-3"> - Checkout - - <.shop_button_outline - phx-click="change_preview_page" - phx-value-page="collection" - class="w-full px-6 py-3 font-semibold transition-all" - > - Continue shopping - - <% else %> -
    - - <.shop_button type="submit" class="w-full px-6 py-3 font-semibold transition-all"> - Checkout - -
    - <.shop_link_outline - href="/collections/all" - class="block w-full px-6 py-3 font-semibold transition-all text-center" - > - Continue shopping - - <% end %> - - """ - end - - @doc """ - Renders a breadcrumb navigation. - - ## Attributes - - * `items` - Required. List of breadcrumb items. Each item is a map with: - - `label` - Required. Display text - - `page` - Optional. Page name for preview mode navigation - - `href` - Optional. URL for live mode navigation - - `current` - Optional. Boolean, if true this is the current page (not a link) - * `mode` - Either `:live` (default) or `:preview`. - - ## Examples - - <.breadcrumb items={[ - %{label: "Home", page: "home", href: "/"}, - %{label: "Art Prints", page: "collection", href: "/collections/art-prints"}, - %{label: "Mountain Sunrise", current: true} - ]} mode={:preview} /> - """ - attr :items, :list, required: true - attr :mode, :atom, default: :live - - def breadcrumb(assigns) do - ~H""" - - """ - end - - @doc """ - Renders a related products section with title and product grid. - - ## Attributes - - * `title` - Optional. Section heading. Defaults to "You might also like". - * `products` - Required. List of products to display. - * `theme_settings` - Required. The theme settings map. - * `limit` - Optional. Maximum products to show. Defaults to 4. - * `mode` - Either `:live` (default) or `:preview`. - - ## Examples - - <.related_products_section - products={@related_products} - theme_settings={@theme_settings} - mode={:preview} - /> - """ - attr :title, :string, default: "You might also like" - attr :products, :list, required: true - attr :theme_settings, :map, required: true - attr :limit, :integer, default: 4 - attr :mode, :atom, default: :live - - def related_products_section(assigns) do - ~H""" -
    -

    - {@title} -

    - - <.product_grid columns={:fixed_4} gap="gap-6"> - <%= for product <- Enum.take(@products, @limit) do %> - <.product_card - product={product} - theme_settings={@theme_settings} - mode={@mode} - variant={:compact} - /> - <% end %> - -
    - """ - end - - @doc """ - Renders a star rating display. - - ## Attributes - - * `rating` - Required. Number of filled stars (1-5). - * `max` - Optional. Maximum stars to display. Defaults to 5. - * `size` - Optional. Size variant: `:sm` (w-4), `:md` (w-5). Defaults to `:sm`. - * `color` - Optional. Star color. Defaults to "#f59e0b" (amber). - - ## Examples - - <.star_rating rating={5} /> - <.star_rating rating={4} size={:md} /> - """ - attr :rating, :integer, required: true - attr :max, :integer, default: 5 - attr :size, :atom, default: :sm - attr :color, :string, default: "#f59e0b" - - def star_rating(assigns) do - size_class = if assigns.size == :md, do: "w-5 h-5", else: "w-4 h-4" - assigns = assign(assigns, :size_class, size_class) - - ~H""" -
    - <%= for i <- 1..@max do %> - - - - <% end %> -
    - """ - end - - @doc """ - Renders trust badges (e.g., Free Delivery, Easy Returns). - - ## Attributes - - * `items` - Optional. List of badge items. Each item is a map with: - - `icon` - Icon type: `:check` or `:shield` - - `title` - Badge title - - `description` - Badge description - Defaults to Free Delivery and Easy Returns badges. - - ## Examples - - <.trust_badges /> - <.trust_badges items={[%{icon: :check, title: "Custom", description: "Badge text"}]} /> - """ - attr :items, :list, - default: [ - %{icon: :check, title: "Made to Order", description: "Printed just for you"}, - %{icon: :shield, title: "Quality Materials", description: "Premium inks and substrates"} - ] - - def trust_badges(assigns) do - ~H""" -
    - <%= for item <- @items do %> -
    - <.trust_badge_icon icon={item.icon} /> -
    -

    {item.title}

    -

    {item.description}

    -
    -
    - <% end %> -
    - """ - end - - attr :icon, :atom, required: true - - defp trust_badge_icon(%{icon: :check} = assigns) do - ~H""" - - - - """ - end - - defp trust_badge_icon(%{icon: :shield} = assigns) do - ~H""" - - - - """ - end - - defp trust_badge_icon(assigns) do - ~H""" - - - - """ - end - - @doc """ - Renders a customer reviews section with collapsible header and review cards. - - ## Attributes - - * `reviews` - Required. List of review maps with: - - `rating` - Star rating (1-5) - - `title` - Review title - - `body` - Review text - - `author` - Reviewer name - - `date` - Relative date string (e.g., "2 weeks ago") - - `verified` - Boolean, if true shows "Verified purchase" badge - * `average_rating` - Optional. Average rating to show in header. Defaults to 5. - * `total_count` - Optional. Total number of reviews. Defaults to length of reviews list. - * `open` - Optional. Whether section is expanded by default. Defaults to true. - - ## Examples - - <.reviews_section reviews={@product.reviews} average_rating={4.8} total_count={24} /> - """ - attr :reviews, :list, required: true - attr :average_rating, :integer, default: 5 - attr :total_count, :integer, default: nil - attr :open, :boolean, default: true - - def reviews_section(assigns) do - assigns = - assign_new(assigns, :display_count, fn -> - assigns.total_count || length(assigns.reviews) - end) - - ~H""" -
    - -
    -

    - Customer reviews -

    -
    - <.star_rating rating={@average_rating} /> - ({@display_count}) -
    -
    - - - -
    - -
    -
    - <%= for review <- @reviews do %> - <.review_card review={review} /> - <% end %> -
    - - <.shop_button_outline class="mt-6 px-6 py-2 text-sm font-medium transition-all mx-auto block"> - Load more reviews - -
    -
    - """ - end - - @doc """ - Renders a single review card. - - ## Attributes - - * `review` - Required. Map with `rating`, `title`, `body`, `author`, `date`, `verified`. - - ## Examples - - <.review_card review={%{rating: 5, title: "Great!", body: "...", author: "Jane", date: "1 week ago", verified: true}} /> - """ - attr :review, :map, required: true - - def review_card(assigns) do - ~H""" -
    -
    - <.star_rating rating={@review.rating} /> - {@review.date} -
    -

    {@review.title}

    -

    - {@review.body} -

    -
    - - {@review.author} - - <%= if @review.verified do %> - - Verified purchase - - <% end %> -
    -
    - """ - end - - @doc """ - Renders a product image gallery with thumbnails and lightbox. - - ## Attributes - - * `images` - Required. List of image URLs. - * `product_name` - Required. Product name for alt text. - * `id_prefix` - Optional. Prefix for element IDs. Defaults to "pdp". - - ## Examples - - <.product_gallery images={@product_images} product_name={@product.name} /> - """ - attr :images, :list, required: true - attr :product_name, :string, required: true - attr :id_prefix, :string, default: "pdp" - - def product_gallery(assigns) do - ~H""" -
    -
    - {@product_name} -
    -
    - - - -
    -
    -
    -
    - <%= for {img_url, idx} <- Enum.with_index(@images) do %> - - <% end %> -
    - - <.product_lightbox images={@images} product_name={@product_name} id_prefix={@id_prefix} /> -
    - """ - end - - # Private: Renders a product image lightbox dialog used by `product_gallery`. - attr :images, :list, required: true - attr :product_name, :string, required: true - attr :id_prefix, :string, required: true - - defp product_lightbox(assigns) do - ~H""" - - - - """ - end - - @doc """ - Renders product title and price information. - - ## Attributes - - * `product` - Required. Product map with `name`, `price`, `on_sale`, `compare_at_price`. - * `currency` - Optional. Currency symbol. Defaults to "£". - - ## Examples - - <.product_info product={@product} /> - """ - attr :product, :map, required: true - - def product_info(assigns) do - ~H""" -
    -

    - {@product.name} -

    - -
    - <%= if @product.on_sale do %> - - {SimpleshopTheme.Cart.format_price(@product.price)} - - - {SimpleshopTheme.Cart.format_price(@product.compare_at_price)} - - - SAVE {round( - (@product.compare_at_price - @product.price) / @product.compare_at_price * 100 - )}% - - <% else %> - - {SimpleshopTheme.Cart.format_price(@product.price)} - - <% end %> -
    -
    - """ - end - - @doc """ - Renders a variant selector for a single option type. - - Shows color swatches for color-type options, text buttons for others. - Disables unavailable options and fires `select_option` event on click. - - ## Attributes - - * `option_type` - Required. Map with :name, :type, :values keys - * `selected` - Required. Currently selected value (string) - * `available` - Required. List of available values for this option - * `mode` - Optional. :shop or :preview (default: :shop) - - ## Examples - - <.variant_selector - option_type={%{name: "Size", type: :size, values: [%{title: "S"}, ...]}} - selected="M" - available={["S", "M", "L"]} - /> - """ - attr :option_type, :map, required: true - attr :selected, :string, required: true - attr :available, :list, required: true - attr :mode, :atom, default: :shop - - def variant_selector(assigns) do - ~H""" -
    - -
    - <%= if @option_type.type == :color do %> - <.color_swatch - :for={value <- @option_type.values} - title={value.title} - hex={value[:hex] || "#888888"} - selected={value.title == @selected} - disabled={value.title not in @available} - option_name={@option_type.name} - mode={@mode} - /> - <% else %> - <.size_button - :for={value <- @option_type.values} - title={value.title} - selected={value.title == @selected} - disabled={value.title not in @available} - option_name={@option_type.name} - mode={@mode} - /> - <% end %> -
    -
    - """ - end - - attr :title, :string, required: true - attr :hex, :string, required: true - attr :selected, :boolean, required: true - attr :disabled, :boolean, required: true - attr :option_name, :string, required: true - attr :mode, :atom, default: :shop - - defp color_swatch(assigns) do - ~H""" - - """ - end - - attr :title, :string, required: true - attr :selected, :boolean, required: true - attr :disabled, :boolean, required: true - attr :option_name, :string, required: true - attr :mode, :atom, default: :shop - - defp size_button(assigns) do - ~H""" - - """ - end - - @doc """ - Renders a quantity selector with increment/decrement buttons. - - ## Attributes - - * `quantity` - Optional. Current quantity value. Defaults to 1. - * `in_stock` - Optional. Whether the product is in stock. Defaults to true. - * `min` - Optional. Minimum quantity. Defaults to 1. - * `max` - Optional. Maximum quantity. Defaults to 99. - - ## Examples - - <.quantity_selector /> - <.quantity_selector quantity={2} in_stock={false} /> - """ - attr :quantity, :integer, default: 1 - attr :in_stock, :boolean, default: true - attr :min, :integer, default: 1 - attr :max, :integer, default: 99 - - def quantity_selector(assigns) do - ~H""" -
    - -
    -
    - - - {@quantity} - - -
    - <%= if @in_stock do %> - In stock - <% else %> - Out of stock - <% end %> -
    -
    - """ - end - - @doc """ - Renders the add to cart button. - - ## Attributes - - * `text` - Optional. Button text. Defaults to "Add to basket". - * `disabled` - Optional. Whether button is disabled. Defaults to false. - * `sticky` - Optional. Whether to use sticky positioning on mobile. Defaults to true. - * `mode` - Either `:live` (sends add_to_cart event) or `:preview` (opens drawer only). - - ## Examples - - <.add_to_cart_button /> - <.add_to_cart_button text="Add to bag" disabled={true} /> - """ - attr :text, :string, default: "Add to basket" - attr :disabled, :boolean, default: false - attr :sticky, :boolean, default: true - attr :mode, :atom, default: :live - - def add_to_cart_button(assigns) do - ~H""" -
    - -
    - """ - end - - @doc """ - Renders a collapsible details/accordion item. - - ## Attributes - - * `title` - Required. Section heading text. - * `open` - Optional. Whether section is expanded by default. Defaults to false. - - ## Slots - - * `inner_block` - Required. Content to show when expanded. - - ## Examples - - <.accordion_item title="Description" open={true}> -

    Product description here...

    - - """ - attr :title, :string, required: true - attr :open, :boolean, default: false - - slot :inner_block, required: true - - def accordion_item(assigns) do - ~H""" -
    - - {@title} - - - - -
    - {render_slot(@inner_block)} -
    -
    - """ - end - - @doc """ - Renders a product details accordion with Description, Size Guide, and Shipping sections. - - ## Attributes - - * `product` - Required. Product map with `description`. - * `show_size_guide` - Optional. Whether to show size guide. Defaults to true. - * `size_guide` - Optional. Custom size guide data. Uses default if not provided. - - ## Examples - - <.product_details product={@product} /> - <.product_details product={@product} show_size_guide={false} /> - """ - attr :product, :map, required: true - attr :show_size_guide, :boolean, default: true - attr :size_guide, :list, default: nil - - def product_details(assigns) do - assigns = - assign_new(assigns, :sizes, fn -> - assigns.size_guide || - [ - %{size: "S", chest: "86-91", length: "71"}, - %{size: "M", chest: "91-96", length: "73"}, - %{size: "L", chest: "96-101", length: "75"}, - %{size: "XL", chest: "101-106", length: "77"} - ] - end) - - ~H""" -
    - <.accordion_item title="Description" open={true}> -

    - {@product.description}. Crafted with attention to detail and quality materials, this product is designed to last. Perfect for everyday use or special occasions. -

    - - - <%= if @show_size_guide do %> - <.accordion_item title="Size Guide"> - - - - - - - - - - <%= for {size_row, idx} <- Enum.with_index(@sizes) do %> - - - - - - <% end %> - -
    - Size - - Chest (cm) - - Length (cm) -
    {size_row.size}{size_row.chest}{size_row.length}
    - - <% end %> - - <.accordion_item title="Shipping & Returns"> -
    -
    -

    Delivery

    -

    - Free UK delivery on orders over £40. Standard delivery 3-5 working days. Express delivery available at checkout. -

    -
    -
    -

    Returns

    -

    - We offer a 30-day return policy. Items must be unused and in original packaging. Please contact us to arrange a return. -

    -
    -
    - -
    - """ - end - - @doc """ - Renders a page title heading. - - ## Attributes - - * `text` - Required. The title text. - * `class` - Optional. Additional CSS classes. - - ## Examples - - <.page_title text="Your basket" /> - <.page_title text="Order History" class="mb-4" /> - """ - attr :text, :string, required: true - attr :class, :string, default: "mb-8" - - def page_title(assigns) do - ~H""" -

    - {@text} -

    - """ - end - - @doc """ - Renders a cart items list with order summary layout. - - ## Attributes - - * `items` - Required. List of cart items. - * `subtotal` - Required. Subtotal in pence/cents. - * `currency` - Optional. Currency symbol. Defaults to "£". - * `mode` - Either `:live` (default) or `:preview`. - - ## Examples - - <.cart_layout items={@cart_items} subtotal={3600} mode={:preview} /> - """ - attr :items, :list, required: true - attr :subtotal, :integer, required: true - attr :mode, :atom, default: :live - - def cart_layout(assigns) do - ~H""" -
    -
    -
    - <%= for item <- @items do %> - <.cart_item item={item} /> - <% end %> -
    -
    - -
    - <.order_summary subtotal={@subtotal} mode={@mode} /> -
    -
    - """ - end - - @doc """ - Renders rich text content with themed typography. - - This component renders structured content blocks (paragraphs, headings) - with appropriate theme styling. - - ## Attributes - - * `blocks` - Required. List of content blocks. Each block is a map with: - - `type` - Either `:paragraph`, `:heading`, or `:lead` - - `text` - The text content - - `level` - For headings, the level (2, 3, etc.). Defaults to 2. - - ## Examples - - <.rich_text blocks={[ - %{type: :lead, text: "Introduction paragraph..."}, - %{type: :paragraph, text: "Regular paragraph..."}, - %{type: :heading, text: "Section Title"}, - %{type: :paragraph, text: "More content..."} - ]} /> - """ - attr :blocks, :list, required: true - - def rich_text(assigns) do - ~H""" -
    - <%= for block <- @blocks do %> - <.rich_text_block block={block} /> - <% end %> -
    - """ - end - - attr :block, :map, required: true - - defp rich_text_block(%{block: %{type: :lead}} = assigns) do - ~H""" -

    - {@block.text} -

    - """ - end - - defp rich_text_block(%{block: %{type: :paragraph}} = assigns) do - ~H""" -

    - {@block.text} -

    - """ - end - - defp rich_text_block(%{block: %{type: :heading}} = assigns) do - ~H""" -

    - {@block.text} -

    - """ - end - - defp rich_text_block(%{block: %{type: :closing}} = assigns) do - ~H""" -

    - {@block.text} -

    - """ - end - - defp rich_text_block(%{block: %{type: :list}} = assigns) do - ~H""" - - """ - end - - defp rich_text_block(assigns) do - ~H""" -

    - {@block.text} -

    - """ - end - - @doc """ - Renders a responsive `` element with AVIF, WebP, and JPEG sources. - - Computes available widths from `source_width` to avoid upscaling - only generates - srcset entries for sizes smaller than or equal to the original image dimensions. - - The component renders: - - `` for AVIF (best compression, modern browsers) - - `` for WebP (good compression, broad support) - - `` with JPEG srcset (fallback for legacy browsers) - - ## Attributes - - * `src` - Required. Base path to the image variants (without size/extension). - * `alt` - Required. Alt text for accessibility. - * `source_width` - Required. Original image width in pixels. - * `sizes` - Optional. Responsive sizes attribute. Defaults to "100vw". - * `class` - Optional. CSS classes to apply to the `` element. - * `width` - Optional. Explicit width attribute. - * `height` - Optional. Explicit height attribute. - * `priority` - Optional. If true, sets eager loading and high fetchpriority. - Defaults to false (lazy loading). - - ## Examples - - <.responsive_image - src="/image_cache/abc123" - source_width={1200} - alt="Product image" - /> - - <.responsive_image - src="/image_cache/abc123" - source_width={1200} - alt="Hero banner" - priority={true} - sizes="(max-width: 768px) 100vw, 50vw" - /> - """ - attr :src, :string, required: true, doc: "Base path without size/extension" - attr :alt, :string, required: true - attr :source_width, :integer, required: true, doc: "Original image width" - attr :sizes, :string, default: "100vw" - attr :class, :string, default: "" - attr :width, :integer, default: nil - attr :height, :integer, default: nil - attr :priority, :boolean, default: false - - def responsive_image(assigns) do - alias SimpleshopTheme.Images.Optimizer - - # Compute available widths from source dimensions (no upscaling) - available = Optimizer.applicable_widths(assigns.source_width) - default_width = Enum.max(available) - - # Database images end with / (e.g., /images/{id}/variant/) - # Mockups use - separator (e.g., /mockups/product-1) - separator = if String.ends_with?(assigns.src, "/"), do: "", else: "-" - - assigns = - assigns - |> assign(:available_widths, available) - |> assign(:default_width, default_width) - |> assign(:separator, separator) - - ~H""" - - - - {@alt} - - """ - end - - @doc """ - Renders flash messages styled for the shop theme. - """ - attr :flash, :map, required: true - - def shop_flash_group(assigns) do - ~H""" -
    - <%= if msg = Phoenix.Flash.get(@flash, :info) do %> - - <% end %> - <%= if msg = Phoenix.Flash.get(@flash, :error) do %> - - <% end %> -
    - """ - end - - defp build_srcset(base, widths, format) do - # Database images end with / (e.g., /images/{id}/variant/) - # Mockups use - separator (e.g., /mockups/product-1) - separator = if String.ends_with?(base, "/"), do: "", else: "-" - - widths - |> Enum.sort() - |> Enum.map(&"#{base}#{separator}#{&1}.#{format} #{&1}w") - |> Enum.join(", ") + defmacro __using__(_opts \\ []) do + quote do + import SimpleshopThemeWeb.ShopComponents.Base + import SimpleshopThemeWeb.ShopComponents.Cart + import SimpleshopThemeWeb.ShopComponents.Content + import SimpleshopThemeWeb.ShopComponents.Layout + import SimpleshopThemeWeb.ShopComponents.Product + end end end diff --git a/lib/simpleshop_theme_web/components/shop_components/base.ex b/lib/simpleshop_theme_web/components/shop_components/base.ex new file mode 100644 index 0000000..575a0f5 --- /dev/null +++ b/lib/simpleshop_theme_web/components/shop_components/base.ex @@ -0,0 +1,245 @@ +defmodule SimpleshopThemeWeb.ShopComponents.Base do + use Phoenix.Component + + @doc """ + Renders a themed text input. + + This component applies the `.themed-input` CSS class which inherits + colors, borders, and radii from the current theme's CSS variables. + + ## Attributes + + * `type` - Optional. Input type. Defaults to "text". + * `class` - Optional. Additional CSS classes. + * All other attributes are passed through to the input element. + + ## Examples + + <.shop_input type="email" placeholder="your@email.com" /> + <.shop_input type="text" name="name" class="flex-1" /> + """ + attr :type, :string, default: "text" + attr :class, :string, default: nil + attr :rest, :global, include: ~w(name value placeholder required disabled autocomplete readonly) + + def shop_input(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a themed textarea. + + ## Attributes + + * `class` - Optional. Additional CSS classes. + * All other attributes are passed through to the textarea element. + + ## Examples + + <.shop_textarea placeholder="Your message..." rows="5" /> + """ + attr :class, :string, default: nil + attr :rest, :global, include: ~w(name rows placeholder required disabled readonly) + + def shop_textarea(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a themed select dropdown. + + ## Attributes + + * `class` - Optional. Additional CSS classes. + * `options` - Required. List of options (strings or {value, label} tuples). + * `selected` - Optional. Currently selected value. + * All other attributes are passed through to the select element. + + ## Examples + + <.shop_select options={["Option 1", "Option 2"]} /> + <.shop_select options={[{"value", "Label"}]} selected="value" /> + """ + attr :class, :string, default: nil + attr :options, :list, required: true + attr :selected, :any, default: nil + attr :rest, :global, include: ~w(name required disabled aria-label) + + def shop_select(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a themed primary button (accent color background). + + ## Attributes + + * `class` - Optional. Additional CSS classes. + * `type` - Optional. Button type. Defaults to "button". + * All other attributes are passed through to the button element. + + ## Slots + + * `inner_block` - Required. Button content. + + ## Examples + + <.shop_button>Send Message + <.shop_button type="submit" class="w-full">Subscribe + """ + attr :class, :string, default: nil + attr :type, :string, default: "button" + attr :rest, :global, include: ~w(disabled name value phx-click phx-value-page) + + slot :inner_block, required: true + + def shop_button(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a themed outline/secondary button. + + ## Attributes + + * `class` - Optional. Additional CSS classes. + * `type` - Optional. Button type. Defaults to "button". + * All other attributes are passed through to the button element. + + ## Slots + + * `inner_block` - Required. Button content. + + ## Examples + + <.shop_button_outline>Continue Shopping + """ + attr :class, :string, default: nil + attr :type, :string, default: "button" + attr :rest, :global, include: ~w(disabled name value phx-click phx-value-page) + + slot :inner_block, required: true + + def shop_button_outline(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a themed link styled as a primary button. + + ## Attributes + + * `href` - Required. Link destination. + * `class` - Optional. Additional CSS classes. + + ## Slots + + * `inner_block` - Required. Link content. + + ## Examples + + <.shop_link_button href="/checkout">Checkout + """ + attr :href, :string, required: true + attr :class, :string, default: nil + + slot :inner_block, required: true + + def shop_link_button(assigns) do + ~H""" + + {render_slot(@inner_block)} + + """ + end + + @doc """ + Renders a themed link styled as an outline button. + + ## Attributes + + * `href` - Required. Link destination. + * `class` - Optional. Additional CSS classes. + + ## Slots + + * `inner_block` - Required. Link content. + + ## Examples + + <.shop_link_outline href="/collections/all">Continue Shopping + """ + attr :href, :string, required: true + attr :class, :string, default: nil + + slot :inner_block, required: true + + def shop_link_outline(assigns) do + ~H""" + + {render_slot(@inner_block)} + + """ + end + + @doc """ + Renders a themed card container. + + ## Attributes + + * `class` - Optional. Additional CSS classes. + + ## Slots + + * `inner_block` - Required. Card content. + + ## Examples + + <.shop_card class="p-6"> +

    Card Title

    +

    Card content...

    + + """ + attr :class, :string, default: nil + + slot :inner_block, required: true + + def shop_card(assigns) do + ~H""" +
    + {render_slot(@inner_block)} +
    + """ + end +end diff --git a/lib/simpleshop_theme_web/components/shop_components/cart.ex b/lib/simpleshop_theme_web/components/shop_components/cart.ex new file mode 100644 index 0000000..738e0da --- /dev/null +++ b/lib/simpleshop_theme_web/components/shop_components/cart.ex @@ -0,0 +1,532 @@ +defmodule SimpleshopThemeWeb.ShopComponents.Cart do + @moduledoc false + + use Phoenix.Component + + import SimpleshopThemeWeb.ShopComponents.Base + + defp close_cart_drawer_js do + Phoenix.LiveView.JS.push("close_cart_drawer") + end + + @doc """ + Renders the cart drawer (floating sidebar). + + The drawer slides in from the right when opened. It displays cart items + and checkout options. Follows WAI-ARIA dialog pattern for accessibility. + + ## Attributes + + * `cart_items` - List of cart items to display. Each item should have + `image`, `name`, `variant`, `price`, and `variant_id` keys. Default: [] + * `subtotal` - The subtotal to display. Default: nil (shows "£0.00") + * `cart_count` - Number of items for screen reader description. Default: 0 + * `mode` - Either `:live` (default) for real stores or `:preview` for theme editor. + In preview mode, "View basket" navigates via LiveView JS commands. + + ## Examples + + <.cart_drawer cart_items={@cart.items} subtotal={@cart.subtotal} /> + <.cart_drawer cart_items={demo_items} subtotal="£72.00" mode={:preview} /> + """ + + attr :cart_items, :list, default: [] + attr :subtotal, :string, default: nil + attr :cart_count, :integer, default: 0 + attr :cart_status, :string, default: nil + attr :mode, :atom, default: :live + attr :open, :boolean, default: false + + def cart_drawer(assigns) do + assigns = + assign_new(assigns, :display_subtotal, fn -> + assigns.subtotal || "£0.00" + end) + + ~H""" + <%!-- Screen reader announcements for cart changes --%> +
    + {@cart_status} +
    + + + + + + + """ + end + + @doc """ + Shared cart item row component used by both drawer and cart page. + + ## Attributes + + * `item` - Required. Cart item with `name`, `variant`, `price`, `quantity`, `image`, `variant_id`, `product_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""" +
    + <%= if @mode != :preview do %> + + + <% else %> +
    +
    + <% end %> +
    +

    + <%= if @mode != :preview do %> + + {@item.name} + + <% else %> + {@item.name} + <% end %> +

    + <%= if @item.variant do %> +

    + {@item.variant} +

    + <% end %> + +
    + <%= if @show_quantity_controls do %> +
    + + + {@item.quantity} + + +
    + <% else %> + + Qty: {@item.quantity} + + <% end %> + + <.cart_remove_button variant_id={@item.variant_id} item_name={@item.name} /> +
    +
    + +
    +

    + {SimpleshopTheme.Cart.format_price(@item.price * @item.quantity)} +

    +
    +
    + """ + end + + @doc """ + Cart empty state component. + """ + attr :mode, :atom, default: :live + + def cart_empty_state(assigns) do + ~H""" +
    + + + + + +

    Your basket is empty

    + <%= if @mode == :preview do %> + + <% else %> + + Continue shopping + + <% end %> +
    + """ + end + + @doc """ + Remove button for cart items. + """ + attr :variant_id, :string, required: true + attr :item_name, :string, default: "item" + + def cart_remove_button(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a cart item row. + + ## Attributes + + * `item` - Required. Map with `product` (containing `image_url`, `name`, `price`), `variant`, and `quantity`. + * `currency` - Optional. Currency symbol. Defaults to "£". + + ## Examples + + <.cart_item item={item} /> + """ + attr :item, :map, required: true + + def cart_item(assigns) do + ~H""" + <.shop_card class="flex gap-4 p-4"> +
    + {@item.product.name} +
    + +
    +

    + {@item.product.name} +

    +

    + {@item.variant} +

    + +
    +
    + + + {@item.quantity} + + +
    + + +
    +
    + +
    +

    + {SimpleshopTheme.Cart.format_price(@item.product.price * @item.quantity)} +

    +
    + + """ + 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 "£". + * `mode` - Either `:live` (default) or `:preview`. + + ## Examples + + <.order_summary subtotal={3600} /> + """ + attr :subtotal, :integer, required: true + attr :mode, :atom, default: :live + + def order_summary(assigns) do + ~H""" + <.shop_card class="p-6 sticky top-4"> +

    + Order summary +

    + +
    +
    + Subtotal + + {SimpleshopTheme.Cart.format_price(@subtotal)} + +
    +
    + Delivery + + Calculated at checkout + +
    +
    +
    + Subtotal + + {SimpleshopTheme.Cart.format_price(@subtotal)} + +
    +
    +
    + + <%= if @mode == :preview do %> + <.shop_button class="w-full px-6 py-3 font-semibold transition-all mb-3"> + Checkout + + <.shop_button_outline + phx-click="change_preview_page" + phx-value-page="collection" + class="w-full px-6 py-3 font-semibold transition-all" + > + Continue shopping + + <% else %> +
    + + <.shop_button type="submit" class="w-full px-6 py-3 font-semibold transition-all"> + Checkout + +
    + <.shop_link_outline + href="/collections/all" + class="block w-full px-6 py-3 font-semibold transition-all text-center" + > + Continue shopping + + <% end %> + + """ + end + + @doc """ + Renders a cart items list with order summary layout. + + ## Attributes + + * `items` - Required. List of cart items. + * `subtotal` - Required. Subtotal in pence/cents. + * `currency` - Optional. Currency symbol. Defaults to "£". + * `mode` - Either `:live` (default) or `:preview`. + + ## Examples + + <.cart_layout items={@cart_items} subtotal={3600} mode={:preview} /> + """ + attr :items, :list, required: true + attr :subtotal, :integer, required: true + attr :mode, :atom, default: :live + + def cart_layout(assigns) do + ~H""" +
    +
    +
    + <%= for item <- @items do %> + <.cart_item item={item} /> + <% end %> +
    +
    + +
    + <.order_summary subtotal={@subtotal} mode={@mode} /> +
    +
    + """ + end +end diff --git a/lib/simpleshop_theme_web/components/shop_components/content.ex b/lib/simpleshop_theme_web/components/shop_components/content.ex new file mode 100644 index 0000000..7b94337 --- /dev/null +++ b/lib/simpleshop_theme_web/components/shop_components/content.ex @@ -0,0 +1,1205 @@ +defmodule SimpleshopThemeWeb.ShopComponents.Content do + @moduledoc false + + use Phoenix.Component + + import SimpleshopThemeWeb.ShopComponents.Base + + @doc """ + Renders a content body container for long-form content pages (about, etc.). + + ## Attributes + + * `image_src` - Optional. Base path to image (without size/extension), used with responsive_image. + * `image_alt` - Optional. Alt text for the image. Defaults to "Page image". + + ## Slots + + * `inner_block` - Required. The content to render. + + ## Examples + + <.content_body image_src="/mockups/night-sky-blanket-3" image_alt="A cosy blanket"> +

    Content here...

    + + """ + attr :image_src, :string, default: nil + attr :image_alt, :string, default: "Page image" + + slot :inner_block, required: true + + def content_body(assigns) do + ~H""" +
    + <%= if @image_src do %> +
    + <.responsive_image + src={@image_src} + source_width={1200} + alt={@image_alt} + sizes="(max-width: 800px) 100vw, 800px" + class="w-full h-[300px] object-cover" + /> +
    + <% end %> + +
    + {render_slot(@inner_block)} +
    +
    + """ + end + + @doc """ + Renders a contact form card. + + ## Attributes + + * `title` - Optional. Form heading. Defaults to "Send a message". + * `email` - Optional. If provided, displays "Email me: [email]" below the title. + + ## Examples + + <.contact_form /> + <.contact_form title="Get in touch" /> + <.contact_form email="hello@example.com" /> + """ + attr :title, :string, default: "Send a message" + attr :email, :string, default: nil + attr :response_time, :string, default: nil + + def contact_form(assigns) do + ~H""" + <.shop_card class="p-8"> +

    + {@title} +

    + <%= if @email || @response_time do %> +
    + <%= if @email do %> +

    + Email me: + + {@email} + +

    + <% end %> + <%= if @response_time do %> +

    {@response_time}

    + <% end %> +
    + <% else %> +
    + <% end %> + +
    +
    + + <.shop_input type="text" placeholder="Your name" class="w-full px-4 py-2" /> +
    + +
    + + <.shop_input type="email" placeholder="your@email.com" class="w-full px-4 py-2" /> +
    + +
    + + <.shop_input type="text" placeholder="How can I help?" class="w-full px-4 py-2" /> +
    + +
    + + <.shop_textarea rows="5" placeholder="Your message..." class="w-full px-4 py-2" /> +
    + + <.shop_button type="submit" class="w-full px-6 py-3 font-semibold transition-all"> + Send Message + +
    + + """ + end + + @doc """ + Renders the order tracking card. + + ## Examples + + <.order_tracking_card /> + """ + def order_tracking_card(assigns) do + ~H""" + <.shop_card class="p-6"> +

    Track your order

    +

    + Enter your email and I'll send you a link to check your order status. +

    +
    + <.shop_input + type="email" + placeholder="your@email.com" + class="flex-1 min-w-0 px-3 py-2 text-sm" + style="min-width: 150px;" + /> + <.shop_button class="px-4 py-2 text-sm font-medium whitespace-nowrap"> + Send + +
    + + """ + end + + @doc """ + Renders the info card with bullet points (e.g., "Handy to know" section). + + ## Attributes + + * `title` - Required. Card heading. + * `items` - Required. List of maps with `label` and `value` keys. + + ## Examples + + <.info_card title="Handy to know" items={[ + %{label: "Printing", value: "2-5 business days"}, + %{label: "Delivery", value: "3-7 business days after printing"} + ]} /> + """ + attr :title, :string, required: true + attr :items, :list, required: true + + def info_card(assigns) do + ~H""" + <.shop_card class="p-6"> +

    {@title}

    +
      + <%= for item <- @items do %> +
    • + + + {item.label}: {item.value} + +
    • + <% end %> +
    + + """ + end + + @doc """ + Renders the contact info card with email link. + + ## Attributes + + * `title` - Optional. Card heading. Defaults to "Get in touch". + * `email` - Required. Email address. + * `response_text` - Optional. Response time text. Defaults to "We typically respond within 24 hours". + + ## Examples + + <.contact_info_card email="hello@example.com" /> + """ + attr :title, :string, default: "Get in touch" + attr :email, :string, required: true + attr :response_text, :string, default: "We typically respond within 24 hours" + + def contact_info_card(assigns) do + ~H""" + <.shop_card class="p-6"> +

    {@title}

    + + + + + {@email} + +

    + {@response_text} +

    + + """ + end + + @doc """ + Renders a newsletter signup card. + + ## Attributes + + * `title` - Optional. Card heading. Defaults to "Stay in touch". + * `description` - Optional. Card description. + * `button_text` - Optional. Button text. Defaults to "Subscribe". + * `variant` - Optional. Either `:card` (default, with border/background) or `:inline` (no card styling, for embedding in footer). + + ## Examples + + <.newsletter_card /> + <.newsletter_card title="Studio news" description="Get updates on new products." /> + <.newsletter_card variant={:inline} /> + """ + attr :title, :string, default: "Newsletter" + + attr :description, :string, + default: "Sample newsletter signup. Replace with your own message to encourage subscribers." + + attr :button_text, :string, default: "Subscribe" + attr :variant, :atom, default: :card + + def newsletter_card(%{variant: :inline} = assigns) do + ~H""" +
    +

    + {@title} +

    +

    + {@description} +

    +
    + <.shop_input + type="email" + placeholder="your@email.com" + class="flex-1 min-w-0 px-4 py-2 text-sm" + style="min-width: 150px;" + /> + <.shop_button type="submit" class="px-6 py-2 text-sm font-medium whitespace-nowrap"> + {@button_text} + +
    +
    + """ + end + + def newsletter_card(assigns) do + ~H""" + <.shop_card class="p-6"> +

    {@title}

    +

    + {@description} +

    +
    + <.shop_input + type="email" + placeholder="your@email.com" + class="flex-1 min-w-0 px-3 py-2 text-sm" + style="min-width: 150px;" + /> + <.shop_button type="submit" class="px-4 py-2 text-sm font-medium whitespace-nowrap"> + {@button_text} + +
    + + """ + end + + @doc """ + Renders social media links in a single card with a compact grid layout. + + ## Attributes + + * `title` - Optional. Card heading. Defaults to "Follow us". + * `links` - Optional. List of maps with `platform`, `url`, and `label` keys. + Supported platforms: :instagram, :pinterest, :facebook, :twitter, :tiktok, :patreon, :youtube + + ## Examples + + <.social_links_card /> + <.social_links_card title="Elsewhere" links={[%{platform: :instagram, url: "https://instagram.com/example", label: "Instagram"}]} /> + """ + attr :title, :string, default: "Find me on" + + attr :links, :list, + default: [ + %{platform: :instagram, url: "#", label: "Instagram"}, + %{platform: :pinterest, url: "#", label: "Pinterest"} + ] + + def social_links_card(assigns) do + ~H""" + <.shop_card class="p-6"> +

    {@title}

    +
    + <%= for link <- @links do %> + + + <.social_icon platform={link.platform} /> + + {link.label} + + <% end %> +
    + + """ + end + + @doc """ + Renders social media icon links. + + ## Attributes + + * `links` - Optional. List of maps with `platform`, `url`, and `label` keys. + Supported platforms: :instagram, :pinterest + + ## Examples + + <.social_links /> + <.social_links links={[%{platform: :instagram, url: "https://instagram.com/example", label: "Instagram"}]} /> + """ + attr :links, :list, + default: [ + %{platform: :instagram, url: "https://instagram.com", label: "Instagram"}, + %{platform: :pinterest, url: "https://pinterest.com", label: "Pinterest"} + ] + + def social_links(assigns) do + ~H""" +
    + <%= for link <- @links do %> + + <% end %> +
    + """ + end + + # Renders a social media icon for the given platform. + # + # All icons are from Simple Icons (simpleicons.org), MIT licensed. + # + # ## Supported platforms + # + # **Commercial/Creative:** + # :instagram, :pinterest, :tiktok, :facebook, :twitter, :youtube, :patreon, :kofi, :etsy, :gumroad, :bandcamp + # + # **Open Web/Federated:** + # :mastodon, :pixelfed, :bluesky, :peertube, :lemmy, :matrix + # + # **Developer/Hacker:** + # :github, :gitlab, :codeberg, :sourcehut + # + # **Communication:** + # :discord, :telegram, :signal + # + # **Other:** + # :substack, :rss, :website + attr :platform, :atom, required: true + + # Commercial/Creative platforms + defp social_icon(%{platform: :instagram} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :pinterest} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :tiktok} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :facebook} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :twitter} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :youtube} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :patreon} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :kofi} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :etsy} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :gumroad} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :bandcamp} = assigns) do + ~H""" + + + + """ + end + + # Open Web/Federated platforms + defp social_icon(%{platform: :mastodon} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :pixelfed} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :bluesky} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :peertube} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :lemmy} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :matrix} = assigns) do + ~H""" + + + + """ + end + + # Developer/Hacker platforms + defp social_icon(%{platform: :github} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :gitlab} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :codeberg} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :sourcehut} = assigns) do + ~H""" + + + + """ + end + + # Communication platforms + defp social_icon(%{platform: :discord} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :telegram} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :signal} = assigns) do + ~H""" + + + + """ + end + + # Other platforms + defp social_icon(%{platform: :substack} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :rss} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :website} = assigns) do + ~H""" + + + + """ + end + + # Fallback for unknown platforms + defp social_icon(assigns) do + ~H""" + + + + """ + end + + @doc """ + Renders a star rating display. + + ## Attributes + + * `rating` - Required. Number of filled stars (1-5). + * `max` - Optional. Maximum stars to display. Defaults to 5. + * `size` - Optional. Size variant: `:sm` (w-4), `:md` (w-5). Defaults to `:sm`. + * `color` - Optional. Star color. Defaults to "#f59e0b" (amber). + + ## Examples + + <.star_rating rating={5} /> + <.star_rating rating={4} size={:md} /> + """ + attr :rating, :integer, required: true + attr :max, :integer, default: 5 + attr :size, :atom, default: :sm + attr :color, :string, default: "#f59e0b" + + def star_rating(assigns) do + size_class = if assigns.size == :md, do: "w-5 h-5", else: "w-4 h-4" + assigns = assign(assigns, :size_class, size_class) + + ~H""" +
    + <%= for i <- 1..@max do %> + + + + <% end %> +
    + """ + end + + @doc """ + Renders trust badges (e.g., Free Delivery, Easy Returns). + + ## Attributes + + * `items` - Optional. List of badge items. Each item is a map with: + - `icon` - Icon type: `:check` or `:shield` + - `title` - Badge title + - `description` - Badge description + Defaults to Free Delivery and Easy Returns badges. + + ## Examples + + <.trust_badges /> + <.trust_badges items={[%{icon: :check, title: "Custom", description: "Badge text"}]} /> + """ + attr :items, :list, + default: [ + %{icon: :check, title: "Made to Order", description: "Printed just for you"}, + %{icon: :shield, title: "Quality Materials", description: "Premium inks and substrates"} + ] + + def trust_badges(assigns) do + ~H""" +
    + <%= for item <- @items do %> +
    + <.trust_badge_icon icon={item.icon} /> +
    +

    {item.title}

    +

    {item.description}

    +
    +
    + <% end %> +
    + """ + end + + attr :icon, :atom, required: true + + defp trust_badge_icon(%{icon: :check} = assigns) do + ~H""" + + + + """ + end + + defp trust_badge_icon(%{icon: :shield} = assigns) do + ~H""" + + + + """ + end + + defp trust_badge_icon(assigns) do + ~H""" + + + + """ + end + + @doc """ + Renders a customer reviews section with collapsible header and review cards. + + ## Attributes + + * `reviews` - Required. List of review maps with: + - `rating` - Star rating (1-5) + - `title` - Review title + - `body` - Review text + - `author` - Reviewer name + - `date` - Relative date string (e.g., "2 weeks ago") + - `verified` - Boolean, if true shows "Verified purchase" badge + * `average_rating` - Optional. Average rating to show in header. Defaults to 5. + * `total_count` - Optional. Total number of reviews. Defaults to length of reviews list. + * `open` - Optional. Whether section is expanded by default. Defaults to true. + + ## Examples + + <.reviews_section reviews={@product.reviews} average_rating={4.8} total_count={24} /> + """ + attr :reviews, :list, required: true + attr :average_rating, :integer, default: 5 + attr :total_count, :integer, default: nil + attr :open, :boolean, default: true + + def reviews_section(assigns) do + assigns = + assign_new(assigns, :display_count, fn -> + assigns.total_count || length(assigns.reviews) + end) + + ~H""" +
    + +
    +

    + Customer reviews +

    +
    + <.star_rating rating={@average_rating} /> + ({@display_count}) +
    +
    + + + +
    + +
    +
    + <%= for review <- @reviews do %> + <.review_card review={review} /> + <% end %> +
    + + <.shop_button_outline class="mt-6 px-6 py-2 text-sm font-medium transition-all mx-auto block"> + Load more reviews + +
    +
    + """ + end + + @doc """ + Renders a single review card. + + ## Attributes + + * `review` - Required. Map with `rating`, `title`, `body`, `author`, `date`, `verified`. + + ## Examples + + <.review_card review={%{rating: 5, title: "Great!", body: "...", author: "Jane", date: "1 week ago", verified: true}} /> + """ + attr :review, :map, required: true + + def review_card(assigns) do + ~H""" +
    +
    + <.star_rating rating={@review.rating} /> + {@review.date} +
    +

    {@review.title}

    +

    + {@review.body} +

    +
    + + {@review.author} + + <%= if @review.verified do %> + + Verified purchase + + <% end %> +
    +
    + """ + end + + @doc """ + Renders a page title heading. + + ## Attributes + + * `text` - Required. The title text. + * `class` - Optional. Additional CSS classes. + + ## Examples + + <.page_title text="Your basket" /> + <.page_title text="Order History" class="mb-4" /> + """ + attr :text, :string, required: true + attr :class, :string, default: "mb-8" + + def page_title(assigns) do + ~H""" +

    + {@text} +

    + """ + end + + @doc """ + Renders rich text content with themed typography. + + This component renders structured content blocks (paragraphs, headings) + with appropriate theme styling. + + ## Attributes + + * `blocks` - Required. List of content blocks. Each block is a map with: + - `type` - Either `:paragraph`, `:heading`, or `:lead` + - `text` - The text content + - `level` - For headings, the level (2, 3, etc.). Defaults to 2. + + ## Examples + + <.rich_text blocks={[ + %{type: :lead, text: "Introduction paragraph..."}, + %{type: :paragraph, text: "Regular paragraph..."}, + %{type: :heading, text: "Section Title"}, + %{type: :paragraph, text: "More content..."} + ]} /> + """ + attr :blocks, :list, required: true + + def rich_text(assigns) do + ~H""" +
    + <%= for block <- @blocks do %> + <.rich_text_block block={block} /> + <% end %> +
    + """ + end + + attr :block, :map, required: true + + defp rich_text_block(%{block: %{type: :lead}} = assigns) do + ~H""" +

    + {@block.text} +

    + """ + end + + defp rich_text_block(%{block: %{type: :paragraph}} = assigns) do + ~H""" +

    + {@block.text} +

    + """ + end + + defp rich_text_block(%{block: %{type: :heading}} = assigns) do + ~H""" +

    + {@block.text} +

    + """ + end + + defp rich_text_block(%{block: %{type: :closing}} = assigns) do + ~H""" +

    + {@block.text} +

    + """ + end + + defp rich_text_block(%{block: %{type: :list}} = assigns) do + ~H""" +
      + <%= for item <- @block.items do %> +
    • {item}
    • + <% end %> +
    + """ + end + + defp rich_text_block(assigns) do + ~H""" +

    + {@block.text} +

    + """ + end + + @doc """ + Renders a responsive `` element with AVIF, WebP, and JPEG sources. + + Computes available widths from `source_width` to avoid upscaling - only generates + srcset entries for sizes smaller than or equal to the original image dimensions. + + The component renders: + - `` for AVIF (best compression, modern browsers) + - `` for WebP (good compression, broad support) + - `` with JPEG srcset (fallback for legacy browsers) + + ## Attributes + + * `src` - Required. Base path to the image variants (without size/extension). + * `alt` - Required. Alt text for accessibility. + * `source_width` - Required. Original image width in pixels. + * `sizes` - Optional. Responsive sizes attribute. Defaults to "100vw". + * `class` - Optional. CSS classes to apply to the `` element. + * `width` - Optional. Explicit width attribute. + * `height` - Optional. Explicit height attribute. + * `priority` - Optional. If true, sets eager loading and high fetchpriority. + Defaults to false (lazy loading). + + ## Examples + + <.responsive_image + src="/image_cache/abc123" + source_width={1200} + alt="Product image" + /> + + <.responsive_image + src="/image_cache/abc123" + source_width={1200} + alt="Hero banner" + priority={true} + sizes="(max-width: 768px) 100vw, 50vw" + /> + """ + attr :src, :string, required: true, doc: "Base path without size/extension" + attr :alt, :string, required: true + attr :source_width, :integer, required: true, doc: "Original image width" + attr :sizes, :string, default: "100vw" + attr :class, :string, default: "" + attr :width, :integer, default: nil + attr :height, :integer, default: nil + attr :priority, :boolean, default: false + + def responsive_image(assigns) do + alias SimpleshopTheme.Images.Optimizer + + # Compute available widths from source dimensions (no upscaling) + available = Optimizer.applicable_widths(assigns.source_width) + default_width = Enum.max(available) + + # Database images end with / (e.g., /images/{id}/variant/) + # Mockups use - separator (e.g., /mockups/product-1) + separator = if String.ends_with?(assigns.src, "/"), do: "", else: "-" + + assigns = + assigns + |> assign(:available_widths, available) + |> assign(:default_width, default_width) + |> assign(:separator, separator) + + ~H""" + + + + {@alt} + + """ + end + + @doc """ + Renders flash messages styled for the shop theme. + """ + attr :flash, :map, required: true + + def shop_flash_group(assigns) do + ~H""" +
    + <%= if msg = Phoenix.Flash.get(@flash, :info) do %> + + <% end %> + <%= if msg = Phoenix.Flash.get(@flash, :error) do %> + + <% end %> +
    + """ + end + + defp build_srcset(base, widths, format) do + # Database images end with / (e.g., /images/{id}/variant/) + # Mockups use - separator (e.g., /mockups/product-1) + separator = if String.ends_with?(base, "/"), do: "", else: "-" + + widths + |> Enum.sort() + |> Enum.map(&"#{base}#{separator}#{&1}.#{format} #{&1}w") + |> Enum.join(", ") + end +end diff --git a/lib/simpleshop_theme_web/components/shop_components/layout.ex b/lib/simpleshop_theme_web/components/shop_components/layout.ex new file mode 100644 index 0000000..baad948 --- /dev/null +++ b/lib/simpleshop_theme_web/components/shop_components/layout.ex @@ -0,0 +1,903 @@ +defmodule SimpleshopThemeWeb.ShopComponents.Layout do + use Phoenix.Component + + import SimpleshopThemeWeb.ShopComponents.Cart + import SimpleshopThemeWeb.ShopComponents.Content + + @doc """ + Renders the announcement bar. + + The bar displays promotional messaging at the top of the page. + It uses CSS custom properties for theming. + + ## Attributes + + * `theme_settings` - Required. The theme settings map. + * `message` - Optional. The announcement message to display. + Defaults to "Free delivery on orders over £40". + + ## Examples + + <.announcement_bar theme_settings={@theme_settings} /> + <.announcement_bar theme_settings={@theme_settings} message="20% off this weekend!" /> + """ + attr :theme_settings, :map, required: true + attr :message, :string, default: "Sample announcement – e.g. free delivery, sales, or new drops" + + def announcement_bar(assigns) do + ~H""" +
    +

    {@message}

    +
    + """ + end + + @doc """ + Renders the skip link for keyboard navigation accessibility. + + This is a standard accessibility pattern that allows keyboard users + to skip directly to the main content. + """ + def skip_link(assigns) do + ~H""" + + """ + end + + @doc """ + Wraps page content in the standard shop shell: container, header, footer, + cart drawer, search modal, and mobile bottom nav. + + Templates pass their unique `
    ` content as the inner block. + The `error_page` flag disables the CartPersist hook and mobile bottom nav. + """ + attr :theme_settings, :map, required: true + attr :logo_image, :any, required: true + attr :header_image, :any, required: true + attr :mode, :atom, required: true + attr :cart_items, :list, required: true + attr :cart_count, :integer, required: true + attr :cart_subtotal, :string, required: true + attr :cart_drawer_open, :boolean, default: false + attr :cart_status, :string, default: nil + attr :active_page, :string, required: true + attr :error_page, :boolean, default: false + + slot :inner_block, required: true + + def shop_layout(assigns) do + ~H""" +
    + <.skip_link /> + + <%= if @theme_settings.announcement_bar do %> + <.announcement_bar theme_settings={@theme_settings} /> + <% end %> + + <.shop_header + theme_settings={@theme_settings} + logo_image={@logo_image} + header_image={@header_image} + active_page={@active_page} + mode={@mode} + cart_count={@cart_count} + /> + + {render_slot(@inner_block)} + + <.shop_footer theme_settings={@theme_settings} mode={@mode} /> + + <.cart_drawer + cart_items={@cart_items} + subtotal={@cart_subtotal} + cart_count={@cart_count} + mode={@mode} + open={@cart_drawer_open} + cart_status={@cart_status} + /> + + <.search_modal hint_text={~s(Try a search – e.g. "mountain" or "notebook")} /> + + <.mobile_bottom_nav :if={!@error_page} active_page={@active_page} mode={@mode} /> +
    + """ + end + + @doc """ + Renders a mobile bottom navigation bar. + + This component provides thumb-friendly navigation for mobile devices, + following modern UX best practices. It's hidden on larger screens where + the standard header navigation is used. + + ## Attributes + + * `active_page` - Required. The current page identifier (e.g., "home", "collection", "about", "contact"). + * `mode` - Optional. Either `:live` (default) for real navigation or + `:preview` for theme preview mode with phx-click handlers. + * `cart_count` - Optional. Number of items in cart for badge display. Default: 0. + + ## Examples + + <.mobile_bottom_nav active_page="home" /> + <.mobile_bottom_nav active_page="collection" mode={:preview} /> + """ + attr :active_page, :string, required: true + attr :mode, :atom, default: :live + attr :cart_count, :integer, default: 0 + + def mobile_bottom_nav(assigns) do + ~H""" + + """ + end + + attr :icon, :atom, required: true + attr :label, :string, required: true + attr :page, :string, required: true + attr :href, :string, required: true + attr :active_page, :string, required: true + attr :active_pages, :list, default: nil + attr :mode, :atom, default: :live + + defp mobile_nav_item(assigns) do + active_pages = assigns.active_pages || [assigns.page] + is_current = assigns.active_page in active_pages + assigns = assign(assigns, :is_current, is_current) + + ~H""" +
  • + <%= if @mode == :preview do %> + + <.nav_icon icon={@icon} size={if @is_current, do: "w-6 h-6", else: "w-5 h-5"} /> + {@label} + + <% else %> + + <.nav_icon icon={@icon} size={if @is_current, do: "w-6 h-6", else: "w-5 h-5"} /> + {@label} + + <% end %> +
  • + """ + end + + defp nav_icon(%{icon: :home} = assigns) do + assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end) + + ~H""" + + + + + """ + end + + defp nav_icon(%{icon: :shop} = assigns) do + assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end) + + ~H""" + + + + + + + """ + end + + defp nav_icon(%{icon: :about} = assigns) do + assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end) + + ~H""" + + + + + + """ + end + + defp nav_icon(%{icon: :contact} = assigns) do + assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end) + + ~H""" + + + + + """ + end + + @doc """ + Renders the search modal overlay. + + This is a modal dialog for searching products. Currently provides + the UI shell; search functionality will be added later. + + ## Attributes + + * `hint_text` - Optional. Hint text shown below the search input. + Defaults to nil (no hint shown). + + ## Examples + + <.search_modal /> + <.search_modal hint_text="Try searching for \"mountain\" or \"forest\"" /> + """ + attr :hint_text, :string, default: nil + + def search_modal(assigns) do + ~H""" + + """ + end + + @doc """ + Renders the shop footer with newsletter signup and links. + + ## Attributes + + * `theme_settings` - Required. The theme settings map containing site_name. + * `mode` - Optional. Either `:live` (default) for real navigation or + `:preview` for theme preview mode with phx-click handlers. + + ## Examples + + <.shop_footer theme_settings={@theme_settings} /> + <.shop_footer theme_settings={@theme_settings} mode={:preview} /> + """ + attr :theme_settings, :map, required: true + attr :mode, :atom, default: :live + + def shop_footer(assigns) do + assigns = assign(assigns, :current_year, Date.utc_today().year) + + ~H""" + + """ + end + + @doc """ + Renders the shop header with logo, navigation, and actions. + + ## Attributes + + * `theme_settings` - Required. The theme settings map. + * `logo_image` - Optional. The logo image struct (with id, is_svg fields). + * `header_image` - Optional. The header background image struct. + * `active_page` - Optional. Current page for nav highlighting. + * `mode` - Optional. Either `:live` (default) or `:preview`. + * `cart_count` - Optional. Number of items in cart. Defaults to 0. + + ## Examples + + <.shop_header theme_settings={@theme_settings} /> + <.shop_header theme_settings={@theme_settings} mode={:preview} cart_count={2} /> + """ + attr :theme_settings, :map, required: true + attr :logo_image, :map, default: nil + attr :header_image, :map, default: nil + attr :active_page, :string, default: nil + attr :mode, :atom, default: :live + attr :cart_count, :integer, default: 0 + + def shop_header(assigns) do + ~H""" +
    + <%= if @theme_settings.header_background_enabled && @header_image do %> +
    + <% end %> + + + + + +
    + + +
    +
    + """ + end + + defp logo_url(logo_image, %{logo_recolor: true, logo_color: color}) when logo_image.is_svg do + clean_color = String.trim_leading(color, "#") + "/images/#{logo_image.id}/recolored/#{clean_color}" + end + + defp logo_url(logo_image, _), do: "/images/#{logo_image.id}" + + # Logo content that links to home, except when already on home page. + # This follows accessibility best practices - current page should not be a link. + attr :theme_settings, :map, required: true + attr :logo_image, :map, default: nil + attr :active_page, :string, default: nil + attr :mode, :atom, default: :live + + defp logo_content(assigns) do + is_home = assigns.active_page == "home" + assigns = assign(assigns, :is_home, is_home) + + ~H""" + <%= if @is_home do %> + <.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} /> + <% else %> + <%= if @mode == :preview do %> + + <.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} /> + + <% else %> + + <.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} /> + + <% end %> + <% end %> + """ + end + + attr :theme_settings, :map, required: true + attr :logo_image, :map, default: nil + + defp logo_inner(assigns) do + ~H""" + <%= case @theme_settings.logo_mode do %> + <% "text-only" -> %> + + {@theme_settings.site_name} + + <% "logo-text" -> %> + <%= if @logo_image do %> + {@theme_settings.site_name} + <% end %> + + {@theme_settings.site_name} + + <% "logo-only" -> %> + <%= if @logo_image do %> + {@theme_settings.site_name} + <% else %> + + {@theme_settings.site_name} + + <% end %> + <% _ -> %> + + {@theme_settings.site_name} + + <% end %> + """ + end + + defp header_background_style(settings, header_image) do + "position: absolute; top: 0; left: 0; right: 0; bottom: 0; " <> + "background-image: url('/images/#{header_image.id}'); " <> + "background-size: #{settings.header_zoom}%; " <> + "background-position: #{settings.header_position_x}% #{settings.header_position_y}%; " <> + "background-repeat: no-repeat; z-index: 0;" + end + + # Navigation item that renders as a span (not a link) when on the current page. + # This follows accessibility best practices - current page should not be a link. + attr :label, :string, required: true + attr :page, :string, required: true + attr :active_page, :string, required: true + attr :href, :string, default: nil + attr :mode, :atom, default: :live + attr :active_pages, :list, default: nil + + defp nav_item(assigns) do + # Allow matching multiple pages (e.g., "Shop" is active for both collection and pdp) + active_pages = assigns.active_pages || [assigns.page] + is_current = assigns.active_page in active_pages + assigns = assign(assigns, :is_current, is_current) + + ~H""" + <%= if @is_current do %> + + {@label} + + <% else %> + <%= if @mode == :preview do %> + + {@label} + + <% else %> + + {@label} + + <% end %> + <% end %> + """ + end + + defp open_cart_drawer_js do + Phoenix.LiveView.JS.push("open_cart_drawer") + end +end diff --git a/lib/simpleshop_theme_web/components/shop_components/product.ex b/lib/simpleshop_theme_web/components/shop_components/product.ex new file mode 100644 index 0000000..450925f --- /dev/null +++ b/lib/simpleshop_theme_web/components/shop_components/product.ex @@ -0,0 +1,1619 @@ +defmodule SimpleshopThemeWeb.ShopComponents.Product do + use Phoenix.Component + + import SimpleshopThemeWeb.ShopComponents.Base + import SimpleshopThemeWeb.ShopComponents.Content, only: [responsive_image: 1] + + @doc """ + Renders a product card with configurable variants. + + ## Attributes + + * `product` - Required. The product map with `name`, `image_url`, `price`, etc. + * `theme_settings` - Required. The theme settings map. + * `mode` - Either `:live` (default) or `:preview`. + * `variant` - The visual variant: + - `:default` - Collection page style with border, category, full details + - `:featured` - Home page style with hover lift, no border + - `:compact` - PDP related products with aspect-square, minimal info + - `:minimal` - Error 404 style, smallest, not clickable + * `show_category` - Show category label. Defaults based on variant. + * `show_badges` - Show product badges. Defaults based on variant. + * `show_delivery_text` - Show "Free delivery" text. Defaults based on variant. + * `clickable` - Whether the card navigates. Defaults based on variant. + + ## Examples + + <.product_card product={product} theme_settings={@theme_settings} /> + <.product_card product={product} theme_settings={@theme_settings} variant={:featured} mode={:preview} /> + """ + attr :product, :map, required: true + attr :theme_settings, :map, required: true + attr :mode, :atom, default: :live + attr :variant, :atom, default: :default + attr :show_category, :boolean, default: nil + attr :show_badges, :boolean, default: nil + attr :show_delivery_text, :boolean, default: nil + attr :clickable, :boolean, default: nil + + def product_card(assigns) do + # Apply variant defaults for nil values + defaults = variant_defaults(assigns.variant) + + assigns = + assigns + |> assign_new(:show_category_resolved, fn -> + if assigns.show_category == nil, do: defaults.show_category, else: assigns.show_category + end) + |> assign_new(:show_badges_resolved, fn -> + if assigns.show_badges == nil, do: defaults.show_badges, else: assigns.show_badges + end) + |> assign_new(:show_delivery_text_resolved, fn -> + if assigns.show_delivery_text == nil, + do: defaults.show_delivery_text, + else: assigns.show_delivery_text + end) + |> assign_new(:clickable_resolved, fn -> + if assigns.clickable == nil, do: defaults.clickable, else: assigns.clickable + end) + + ~H""" + <%= if @clickable_resolved do %> + <%= if @mode == :preview do %> + + <.product_card_inner + product={@product} + theme_settings={@theme_settings} + variant={@variant} + show_category={@show_category_resolved} + show_badges={@show_badges_resolved} + show_delivery_text={@show_delivery_text_resolved} + /> + + <% else %> + + <.product_card_inner + product={@product} + theme_settings={@theme_settings} + variant={@variant} + show_category={@show_category_resolved} + show_badges={@show_badges_resolved} + show_delivery_text={@show_delivery_text_resolved} + /> + + <% end %> + <% else %> +
    + <.product_card_inner + product={@product} + theme_settings={@theme_settings} + variant={@variant} + show_category={@show_category_resolved} + show_badges={@show_badges_resolved} + show_delivery_text={@show_delivery_text_resolved} + /> +
    + <% end %> + """ + end + + attr :product, :map, required: true + attr :theme_settings, :map, required: true + attr :variant, :atom, required: true + attr :show_category, :boolean, required: true + attr :show_badges, :boolean, required: true + attr :show_delivery_text, :boolean, required: true + + defp product_card_inner(assigns) do + ~H""" +
    + <%= if @show_badges do %> + <.product_badge product={@product} /> + <% end %> + <.product_card_image + product={@product} + variant={@variant} + class="product-image-primary w-full h-full object-cover transition-opacity duration-300" + /> + <%= if @theme_settings.hover_image && @product[:hover_image_url] do %> + <.product_card_image + product={@product} + variant={@variant} + image_key={:hover} + class="product-image-hover w-full h-full object-cover" + /> + <% end %> +
    +
    + <%= if @show_category && @product[:category] do %> +

    + {@product.category} +

    + <% end %> +

    + {@product.name} +

    + <%= if @theme_settings.show_prices do %> + <.product_price product={@product} variant={@variant} /> + <% end %> + <%= if @show_delivery_text do %> +

    + Made to order +

    + <% end %> +
    + """ + end + + # Helper to render product images with responsive variants. + # Works for both mockups (static files) and database images. + # Requires source_width to be set for responsive image support. + attr :product, :map, required: true + attr :variant, :atom, required: true + attr :class, :string, default: "" + attr :image_key, :atom, default: :primary + + defp product_card_image(assigns) do + # Determine which image fields to use based on primary vs hover + {image_id_field, image_url_field, source_width_field} = + case assigns.image_key do + :hover -> {:hover_image_id, :hover_image_url, :hover_source_width} + _ -> {:image_id, :image_url, :source_width} + end + + # Build the base image path: + # - Database images: /images/{id}/variant + # - Mockup images: {image_url} (e.g., /mockups/product-1) + image_id = assigns.product[image_id_field] + image_url = assigns.product[image_url_field] + + src = + if image_id do + # Trailing slash so build_srcset produces /images/{id}/variant/800.webp + "/images/#{image_id}/variant/" + else + image_url + end + + assigns = + assigns + |> assign(:src, src) + |> assign(:source_width, assigns.product[source_width_field]) + + ~H""" + <%= if @source_width do %> + <.responsive_image + src={@src} + alt={@product.name} + source_width={@source_width} + sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px" + class={@class} + width={600} + height={600} + priority={@variant == :minimal} + /> + <% else %> + {@product.name} + <% end %> + """ + end + + attr :product, :map, required: true + + defp product_badge(assigns) do + ~H""" + <%= cond do %> + <% Map.get(@product, :in_stock, true) == false -> %> + Sold out + <% @product[:is_new] -> %> + New + <% @product[:on_sale] -> %> + Sale + <% true -> %> + <% end %> + """ + end + + attr :product, :map, required: true + attr :variant, :atom, required: true + + defp product_price(assigns) do + ~H""" + <%= case @variant do %> + <% :default -> %> +
    + <%= if @product.on_sale do %> + + {SimpleshopTheme.Cart.format_price(@product.price)} + + + {SimpleshopTheme.Cart.format_price(@product.compare_at_price)} + + <% else %> + + {SimpleshopTheme.Cart.format_price(@product.price)} + + <% end %> +
    + <% :featured -> %> +

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

    + <% :compact -> %> +

    + {SimpleshopTheme.Cart.format_price(@product.price)} +

    + <% :minimal -> %> +

    + {SimpleshopTheme.Cart.format_price(@product.price)} +

    + <% end %> + """ + end + + defp variant_defaults(:default), + do: %{show_category: true, show_badges: true, show_delivery_text: true, clickable: true} + + defp variant_defaults(:featured), + do: %{show_category: false, show_badges: true, show_delivery_text: true, clickable: true} + + defp variant_defaults(:compact), + do: %{show_category: false, show_badges: false, show_delivery_text: false, clickable: true} + + defp variant_defaults(:minimal), + do: %{show_category: false, show_badges: false, show_delivery_text: false, clickable: false} + + defp card_classes(:default), do: "product-card group overflow-hidden transition-all" + + defp card_classes(:featured), + do: "product-card group overflow-hidden transition-all hover:-translate-y-1" + + defp card_classes(:compact), do: "product-card group overflow-hidden cursor-pointer" + defp card_classes(:minimal), do: "product-card group overflow-hidden" + + defp card_style(:default), + do: + "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card); cursor: pointer;" + + defp card_style(:featured), + do: + "background-color: var(--t-surface-raised); border-radius: var(--t-radius-card); cursor: pointer;" + + defp card_style(:compact), + do: + "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);" + + defp card_style(:minimal), + do: + "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-subtle); border-radius: var(--t-radius-card);" + + defp image_container_classes(:compact), + do: "product-image-container aspect-square bg-gray-200 overflow-hidden relative" + + defp image_container_classes(:minimal), + do: "product-image-container aspect-square bg-gray-200 overflow-hidden relative" + + defp image_container_classes(_), + do: "product-image-container bg-gray-200 overflow-hidden relative" + + defp content_padding_class(:compact), do: "p-3" + defp content_padding_class(:minimal), do: "p-2" + defp content_padding_class(_), do: "" + + defp title_classes(:default), do: "font-semibold mb-2" + defp title_classes(:featured), do: "text-sm font-medium mb-1" + defp title_classes(:compact), do: "font-semibold text-sm mb-1" + defp title_classes(:minimal), do: "text-xs font-semibold truncate" + + defp title_style(:default), + do: "font-family: var(--t-font-heading); color: var(--t-text-primary);" + + defp title_style(:featured), do: "color: var(--t-text-primary);" + + defp title_style(:compact), + do: "font-family: var(--t-font-heading); color: var(--t-text-primary);" + + defp title_style(:minimal), do: "color: var(--t-text-primary);" + + @doc """ + Renders a responsive product grid container. + + This component wraps product cards in a responsive grid layout. It supports + theme-based column settings or fixed column layouts for specific use cases. + + ## Attributes + + * `theme_settings` - Optional. When provided, uses `grid_columns` setting for lg breakpoint. + * `columns` - Optional. Fixed column count for lg breakpoint (overrides theme_settings). + Use `:fixed_4` for a fixed 2/4 column layout (error page, related products). + * `gap` - Optional. Gap size. Defaults to standard gap. + * `class` - Optional. Additional CSS classes to add. + + ## Slots + + * `inner_block` - Required. The product cards to render inside the grid. + + ## Examples + + <.product_grid theme_settings={@theme_settings}> + <%= for product <- @products do %> + <.product_card product={product} theme_settings={@theme_settings} /> + <% end %> + + + <.product_grid columns={:fixed_4} gap="gap-6"> + ... + + """ + attr :theme_settings, :map, default: nil + attr :columns, :atom, default: nil + attr :gap, :string, default: nil + attr :class, :string, default: nil + + slot :inner_block, required: true + + def product_grid(assigns) do + ~H""" +
    + {render_slot(@inner_block)} +
    + """ + end + + defp grid_classes(theme_settings, columns, gap, extra_class) do + base = "product-grid grid" + + cols = + cond do + columns == :fixed_4 -> + "grid-cols-2 md:grid-cols-4" + + theme_settings != nil -> + responsive_cols = "grid-cols-1 sm:grid-cols-2" + + lg_cols = + case theme_settings.grid_columns do + "2" -> "lg:grid-cols-2" + "3" -> "lg:grid-cols-3" + "4" -> "lg:grid-cols-4" + _ -> "lg:grid-cols-3" + end + + "#{responsive_cols} #{lg_cols}" + + true -> + "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3" + end + + gap_class = gap || "" + + [base, cols, gap_class, extra_class] + |> Enum.reject(&is_nil/1) + |> Enum.reject(&(&1 == "")) + |> Enum.join(" ") + end + + @doc """ + Renders a centered hero section with title, description, and optional CTAs. + + ## Attributes + + * `title` - Required. The main heading text. + * `description` - Required. The description paragraph text. + * `variant` - The visual variant: + - `:default` - Standard hero with section wrapper and padding + - `:page` - Page header style, larger title, more spacing, no section wrapper + - `:error` - Error page with pre-title (404), two buttons + * `background` - Background style. Either `:base` (default) or `:sunken`. + * `pre_title` - Optional. Text shown above title (e.g., "404" for error pages). + * `cta_text` - Optional. Text for the primary CTA button. + * `cta_page` - Optional. Page to navigate to on click (for preview mode). + * `cta_href` - Optional. URL for live mode navigation. + * `secondary_cta_text` - Optional. Text for secondary button (error variant). + * `secondary_cta_page` - Optional. Page for secondary button (preview mode). + * `secondary_cta_href` - Optional. URL for secondary button (live mode). + * `mode` - Either `:live` (default) or `:preview`. + + ## Examples + + <.hero_section + title="Original designs, printed on demand" + description="From art prints to apparel..." + cta_text="Shop the collection" + cta_page="collection" + mode={:preview} + /> + + <.hero_section + variant={:page} + title="Get in touch" + description="Questions about your order?" + /> + + <.hero_section + variant={:error} + pre_title="404" + title="Page Not Found" + description="Sorry, that page doesn't exist..." + cta_text="Go to Homepage" + secondary_cta_text="Browse Products" + mode={:preview} + /> + """ + attr :title, :string, required: true + attr :description, :string, required: true + attr :variant, :atom, default: :default + attr :background, :atom, default: :base + attr :pre_title, :string, default: nil + attr :cta_text, :string, default: nil + attr :cta_page, :string, default: nil + attr :cta_href, :string, default: nil + attr :secondary_cta_text, :string, default: nil + attr :secondary_cta_page, :string, default: nil + attr :secondary_cta_href, :string, default: nil + attr :mode, :atom, default: :live + + def hero_section(assigns) do + ~H""" + <%= case @variant do %> + <% :default -> %> +
    +

    + {@title} +

    +

    + {@description} +

    + <.hero_cta + :if={@cta_text} + text={@cta_text} + page={@cta_page} + href={@cta_href} + mode={@mode} + variant={:primary} + /> +
    + <% :page -> %> +
    +

    + {@title} +

    +

    + {@description} +

    +
    + <% :error -> %> +
    + <%= if @pre_title do %> +

    + {@pre_title} +

    + <% end %> +

    + {@title} +

    +

    + {@description} +

    + <%= if @cta_text || @secondary_cta_text do %> +
    + <.hero_cta + :if={@cta_text} + text={@cta_text} + page={@cta_page} + href={@cta_href} + mode={@mode} + variant={:primary} + /> + <.hero_cta + :if={@secondary_cta_text} + text={@secondary_cta_text} + page={@secondary_cta_page} + href={@secondary_cta_href} + mode={@mode} + variant={:secondary} + /> +
    + <% end %> +
    + <% end %> + """ + end + + attr :text, :string, required: true + attr :page, :string, default: nil + attr :href, :string, default: nil + attr :mode, :atom, required: true + attr :variant, :atom, required: true + + defp hero_cta(assigns) do + ~H""" + <%= if @mode == :preview do %> + + <% else %> + " text-decoration: none;"} + > + {@text} + + <% end %> + """ + end + + defp hero_cta_classes(:primary), do: "themed-button px-8 py-3 font-semibold transition-all" + + defp hero_cta_classes(:secondary), + do: "themed-button-outline px-8 py-3 font-semibold transition-all" + + defp hero_cta_style(:primary), do: "" + defp hero_cta_style(:secondary), do: "" + + @doc """ + Renders a row of category circles for navigation. + + ## Attributes + + * `categories` - Required. List of category maps with `name` and `image_url`. + * `limit` - Optional. Maximum number of categories to show. Defaults to 3. + * `mode` - Either `:live` (default) or `:preview`. + + ## Examples + + <.category_nav categories={@categories} mode={:preview} /> + <.category_nav categories={@categories} limit={4} /> + """ + attr :categories, :list, required: true + attr :limit, :integer, default: 3 + attr :mode, :atom, default: :live + + def category_nav(assigns) do + ~H""" +
    +

    Shop by Category

    + +
    + """ + end + + @doc """ + Renders a featured products section with title, product grid, and view-all button. + + ## Attributes + + * `title` - Required. Section heading text. + * `products` - Required. List of products to display. + * `theme_settings` - Required. The theme settings map. + * `limit` - Optional. Maximum products to show. Defaults to 4. + * `mode` - Either `:live` (default) or `:preview`. + * `cta_text` - Optional. Text for the "view all" button. Defaults to "View all products". + * `cta_page` - Optional. Page to navigate to (preview mode). Defaults to "collection". + * `cta_href` - Optional. URL for live mode. Defaults to "/collections/all". + + ## Examples + + <.featured_products_section + title="Featured products" + products={@products} + theme_settings={@theme_settings} + mode={:preview} + /> + """ + attr :title, :string, required: true + attr :products, :list, required: true + attr :theme_settings, :map, required: true + attr :limit, :integer, default: 4 + attr :mode, :atom, default: :live + attr :cta_text, :string, default: "View all products" + attr :cta_page, :string, default: "collection" + attr :cta_href, :string, default: "/collections/all" + + def featured_products_section(assigns) do + ~H""" +
    +

    + {@title} +

    + + <.product_grid theme_settings={@theme_settings}> + <%= for product <- Enum.take(@products, @limit) do %> + <.product_card + product={product} + theme_settings={@theme_settings} + mode={@mode} + variant={:featured} + /> + <% end %> + + +
    + <%= if @mode == :preview do %> + + <% else %> + + {@cta_text} + + <% end %> +
    +
    + """ + end + + @doc """ + Renders a two-column image + text section. + + ## Attributes + + * `title` - Required. Section heading text. + * `description` - Required. Body text content. + * `image_url` - Required. URL for the image. + * `link_text` - Optional. Text for the link. If nil, no link is shown. + * `link_page` - Optional. Page to navigate to (preview mode). + * `link_href` - Optional. URL for live mode. + * `image_position` - Either `:left` (default) or `:right`. + * `mode` - Either `:live` (default) or `:preview`. + + ## Examples + + <.image_text_section + title="Made with passion, printed with care" + description="Every design starts with an idea..." + image_url="/mockups/example.jpg" + link_text="Learn more about the studio →" + link_page="about" + mode={:preview} + /> + """ + attr :title, :string, required: true + attr :description, :string, required: true + attr :image_url, :string, required: true + attr :link_text, :string, default: nil + attr :link_page, :string, default: nil + attr :link_href, :string, default: nil + attr :image_position, :atom, default: :left + attr :mode, :atom, default: :live + + def image_text_section(assigns) do + ~H""" +
    + <%= if @image_position == :left do %> + <.image_text_image image_url={@image_url} /> + <.image_text_content + title={@title} + description={@description} + link_text={@link_text} + link_page={@link_page} + link_href={@link_href} + mode={@mode} + /> + <% else %> + <.image_text_content + title={@title} + description={@description} + link_text={@link_text} + link_page={@link_page} + link_href={@link_href} + mode={@mode} + /> + <.image_text_image image_url={@image_url} /> + <% end %> +
    + """ + end + + attr :image_url, :string, required: true + + defp image_text_image(assigns) do + ~H""" +
    +
    + """ + end + + attr :title, :string, required: true + attr :description, :string, required: true + attr :link_text, :string, default: nil + attr :link_page, :string, default: nil + attr :link_href, :string, default: nil + attr :mode, :atom, required: true + + defp image_text_content(assigns) do + ~H""" +
    +

    + {@title} +

    +

    + {@description} +

    + <%= if @link_text do %> + <%= if @mode == :preview do %> + + {@link_text} + + <% else %> + + {@link_text} + + <% end %> + <% end %> +
    + """ + end + + @doc """ + Renders a page header with title and optional product count. + + ## Attributes + + * `title` - Required. The page title. + * `subtitle` - Optional. Text below the title. + * `product_count` - Optional. Number to display as "X products". + + ## Examples + + <.collection_header title="All Products" product_count={24} /> + """ + attr :title, :string, required: true + attr :subtitle, :string, default: nil + attr :product_count, :integer, default: nil + + def collection_header(assigns) do + ~H""" +
    +
    +

    + {@title} +

    + <%= if @subtitle do %> +

    {@subtitle}

    + <% end %> + <%= if @product_count do %> +

    {@product_count} products

    + <% end %> +
    +
    + """ + end + + @doc """ + Renders a filter bar with category pills and sort dropdown. + + ## Attributes + + * `categories` - Required. List of category maps with `name` field. + * `active_category` - Optional. Currently selected category name. Defaults to "All". + * `sort_options` - Optional. List of sort option strings. + + ## Examples + + <.filter_bar categories={@categories} /> + <.filter_bar categories={@categories} active_category="Art Prints" /> + """ + attr :categories, :list, required: true + attr :active_category, :string, default: "All" + + attr :sort_options, :list, + default: [ + "Sort by: Featured", + "Price: Low to High", + "Price: High to Low", + "Newest", + "Best Selling" + ] + + def filter_bar(assigns) do + ~H""" +
    + +
    + + <%= for category <- @categories do %> + + <% end %> +
    + + + <.shop_select options={@sort_options} class="px-4 py-2" /> +
    + """ + end + + @doc """ + Renders a breadcrumb navigation. + + ## Attributes + + * `items` - Required. List of breadcrumb items. Each item is a map with: + - `label` - Required. Display text + - `page` - Optional. Page name for preview mode navigation + - `href` - Optional. URL for live mode navigation + - `current` - Optional. Boolean, if true this is the current page (not a link) + * `mode` - Either `:live` (default) or `:preview`. + + ## Examples + + <.breadcrumb items={[ + %{label: "Home", page: "home", href: "/"}, + %{label: "Art Prints", page: "collection", href: "/collections/art-prints"}, + %{label: "Mountain Sunrise", current: true} + ]} mode={:preview} /> + """ + attr :items, :list, required: true + attr :mode, :atom, default: :live + + def breadcrumb(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a related products section with title and product grid. + + ## Attributes + + * `title` - Optional. Section heading. Defaults to "You might also like". + * `products` - Required. List of products to display. + * `theme_settings` - Required. The theme settings map. + * `limit` - Optional. Maximum products to show. Defaults to 4. + * `mode` - Either `:live` (default) or `:preview`. + + ## Examples + + <.related_products_section + products={@related_products} + theme_settings={@theme_settings} + mode={:preview} + /> + """ + attr :title, :string, default: "You might also like" + attr :products, :list, required: true + attr :theme_settings, :map, required: true + attr :limit, :integer, default: 4 + attr :mode, :atom, default: :live + + def related_products_section(assigns) do + ~H""" +
    +

    + {@title} +

    + + <.product_grid columns={:fixed_4} gap="gap-6"> + <%= for product <- Enum.take(@products, @limit) do %> + <.product_card + product={product} + theme_settings={@theme_settings} + mode={@mode} + variant={:compact} + /> + <% end %> + +
    + """ + end + + @doc """ + Renders a product image gallery with thumbnails and lightbox. + + ## Attributes + + * `images` - Required. List of image URLs. + * `product_name` - Required. Product name for alt text. + * `id_prefix` - Optional. Prefix for element IDs. Defaults to "pdp". + + ## Examples + + <.product_gallery images={@product_images} product_name={@product.name} /> + """ + attr :images, :list, required: true + attr :product_name, :string, required: true + attr :id_prefix, :string, default: "pdp" + + def product_gallery(assigns) do + ~H""" +
    +
    + {@product_name} +
    +
    + + + +
    +
    +
    +
    + <%= for {img_url, idx} <- Enum.with_index(@images) do %> + + <% end %> +
    + + <.product_lightbox images={@images} product_name={@product_name} id_prefix={@id_prefix} /> +
    + """ + end + + # Private: Renders a product image lightbox dialog used by `product_gallery`. + attr :images, :list, required: true + attr :product_name, :string, required: true + attr :id_prefix, :string, required: true + + defp product_lightbox(assigns) do + ~H""" + + + + """ + end + + @doc """ + Renders product title and price information. + + ## Attributes + + * `product` - Required. Product map with `name`, `price`, `on_sale`, `compare_at_price`. + * `currency` - Optional. Currency symbol. Defaults to "£". + + ## Examples + + <.product_info product={@product} /> + """ + attr :product, :map, required: true + + def product_info(assigns) do + ~H""" +
    +

    + {@product.name} +

    + +
    + <%= if @product.on_sale do %> + + {SimpleshopTheme.Cart.format_price(@product.price)} + + + {SimpleshopTheme.Cart.format_price(@product.compare_at_price)} + + + SAVE {round( + (@product.compare_at_price - @product.price) / @product.compare_at_price * 100 + )}% + + <% else %> + + {SimpleshopTheme.Cart.format_price(@product.price)} + + <% end %> +
    +
    + """ + end + + @doc """ + Renders a variant selector for a single option type. + + Shows color swatches for color-type options, text buttons for others. + Disables unavailable options and fires `select_option` event on click. + + ## Attributes + + * `option_type` - Required. Map with :name, :type, :values keys + * `selected` - Required. Currently selected value (string) + * `available` - Required. List of available values for this option + * `mode` - Optional. :shop or :preview (default: :shop) + + ## Examples + + <.variant_selector + option_type={%{name: "Size", type: :size, values: [%{title: "S"}, ...]}} + selected="M" + available={["S", "M", "L"]} + /> + """ + attr :option_type, :map, required: true + attr :selected, :string, required: true + attr :available, :list, required: true + attr :mode, :atom, default: :shop + + def variant_selector(assigns) do + ~H""" +
    + +
    + <%= if @option_type.type == :color do %> + <.color_swatch + :for={value <- @option_type.values} + title={value.title} + hex={value[:hex] || "#888888"} + selected={value.title == @selected} + disabled={value.title not in @available} + option_name={@option_type.name} + mode={@mode} + /> + <% else %> + <.size_button + :for={value <- @option_type.values} + title={value.title} + selected={value.title == @selected} + disabled={value.title not in @available} + option_name={@option_type.name} + mode={@mode} + /> + <% end %> +
    +
    + """ + end + + attr :title, :string, required: true + attr :hex, :string, required: true + attr :selected, :boolean, required: true + attr :disabled, :boolean, required: true + attr :option_name, :string, required: true + attr :mode, :atom, default: :shop + + defp color_swatch(assigns) do + ~H""" + + """ + end + + attr :title, :string, required: true + attr :selected, :boolean, required: true + attr :disabled, :boolean, required: true + attr :option_name, :string, required: true + attr :mode, :atom, default: :shop + + defp size_button(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a quantity selector with increment/decrement buttons. + + ## Attributes + + * `quantity` - Optional. Current quantity value. Defaults to 1. + * `in_stock` - Optional. Whether the product is in stock. Defaults to true. + * `min` - Optional. Minimum quantity. Defaults to 1. + * `max` - Optional. Maximum quantity. Defaults to 99. + + ## Examples + + <.quantity_selector /> + <.quantity_selector quantity={2} in_stock={false} /> + """ + attr :quantity, :integer, default: 1 + attr :in_stock, :boolean, default: true + attr :min, :integer, default: 1 + attr :max, :integer, default: 99 + + def quantity_selector(assigns) do + ~H""" +
    + +
    +
    + + + {@quantity} + + +
    + <%= if @in_stock do %> + In stock + <% else %> + Out of stock + <% end %> +
    +
    + """ + end + + @doc """ + Renders the add to cart button. + + ## Attributes + + * `text` - Optional. Button text. Defaults to "Add to basket". + * `disabled` - Optional. Whether button is disabled. Defaults to false. + * `sticky` - Optional. Whether to use sticky positioning on mobile. Defaults to true. + * `mode` - Either `:live` (sends add_to_cart event) or `:preview` (opens drawer only). + + ## Examples + + <.add_to_cart_button /> + <.add_to_cart_button text="Add to bag" disabled={true} /> + """ + attr :text, :string, default: "Add to basket" + attr :disabled, :boolean, default: false + attr :sticky, :boolean, default: true + attr :mode, :atom, default: :live + + def add_to_cart_button(assigns) do + ~H""" +
    + +
    + """ + end + + defp open_cart_drawer_js do + Phoenix.LiveView.JS.push("open_cart_drawer") + end + + @doc """ + Renders a collapsible details/accordion item. + + ## Attributes + + * `title` - Required. Section heading text. + * `open` - Optional. Whether section is expanded by default. Defaults to false. + + ## Slots + + * `inner_block` - Required. Content to show when expanded. + + ## Examples + + <.accordion_item title="Description" open={true}> +

    Product description here...

    + + """ + attr :title, :string, required: true + attr :open, :boolean, default: false + + slot :inner_block, required: true + + def accordion_item(assigns) do + ~H""" +
    + + {@title} + + + + +
    + {render_slot(@inner_block)} +
    +
    + """ + end + + @doc """ + Renders a product details accordion with Description, Size Guide, and Shipping sections. + + ## Attributes + + * `product` - Required. Product map with `description`. + * `show_size_guide` - Optional. Whether to show size guide. Defaults to true. + * `size_guide` - Optional. Custom size guide data. Uses default if not provided. + + ## Examples + + <.product_details product={@product} /> + <.product_details product={@product} show_size_guide={false} /> + """ + attr :product, :map, required: true + attr :show_size_guide, :boolean, default: true + attr :size_guide, :list, default: nil + + def product_details(assigns) do + assigns = + assign_new(assigns, :sizes, fn -> + assigns.size_guide || + [ + %{size: "S", chest: "86-91", length: "71"}, + %{size: "M", chest: "91-96", length: "73"}, + %{size: "L", chest: "96-101", length: "75"}, + %{size: "XL", chest: "101-106", length: "77"} + ] + end) + + ~H""" +
    + <.accordion_item title="Description" open={true}> +

    + {@product.description}. Crafted with attention to detail and quality materials, this product is designed to last. Perfect for everyday use or special occasions. +

    + + + <%= if @show_size_guide do %> + <.accordion_item title="Size Guide"> + + + + + + + + + + <%= for {size_row, idx} <- Enum.with_index(@sizes) do %> + + + + + + <% end %> + +
    + Size + + Chest (cm) + + Length (cm) +
    {size_row.size}{size_row.chest}{size_row.length}
    + + <% end %> + + <.accordion_item title="Shipping & Returns"> +
    +
    +

    Delivery

    +

    + Free UK delivery on orders over £40. Standard delivery 3-5 working days. Express delivery available at checkout. +

    +
    +
    +

    Returns

    +

    + We offer a 30-day return policy. Items must be unused and in original packaging. Please contact us to arrange a return. +

    +
    +
    + +
    + """ + end +end diff --git a/lib/simpleshop_theme_web/live/shop_live/collection.ex b/lib/simpleshop_theme_web/live/shop_live/collection.ex index 9ff5af9..ba15590 100644 --- a/lib/simpleshop_theme_web/live/shop_live/collection.ex +++ b/lib/simpleshop_theme_web/live/shop_live/collection.ex @@ -78,7 +78,7 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do @impl true def render(assigns) do ~H""" -
    - @@ -104,9 +104,9 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do current_sort={@current_sort} /> - + <.product_grid theme_settings={@theme_settings}> <%= for product <- @products do %> - <% end %> - + <%= if @products == [] do %>
    @@ -130,7 +130,7 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do <% end %>
    -
    + """ end