refactor: extract remaining page sections to ShopComponents
Add reusable components for all page sections: - category_nav: Category circles for homepage navigation - featured_products_section: Product grid with title and view-all CTA - image_text_section: Two-column image + text layout - collection_header: Page header with title and product count - filter_bar: Category pills and sort dropdown - content_body: Long-form content wrapper for about page - contact_form: Contact form card - order_tracking_card: Order tracking input card - info_card: Bullet-point info card - contact_info_card: Email contact card with icon - social_links: Social media icon links - cart_item: Cart item row with quantity controls - order_summary: Order totals and checkout card - breadcrumb: Navigation breadcrumb trail - related_products_section: Related products grid All preview pages now compose entirely of reusable components, making them easier to maintain and enabling future section-based theme customization. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1589ebaeca
commit
0c15929c19
@ -1043,4 +1043,846 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
|||||||
defp hero_cta_style(:secondary) do
|
defp hero_cta_style(:secondary) do
|
||||||
"border: 2px solid var(--t-border-default); color: var(--t-text-primary); border-radius: var(--t-radius-button); background: transparent; cursor: pointer;"
|
"border: 2px solid var(--t-border-default); color: var(--t-text-primary); border-radius: var(--t-radius-button); background: transparent; cursor: pointer;"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<section style="padding: var(--space-xl) var(--space-lg); background-color: var(--t-surface-base);">
|
||||||
|
<nav class="grid grid-cols-3 gap-4 max-w-3xl mx-auto" aria-label="Product categories">
|
||||||
|
<%= for category <- Enum.take(@categories, @limit) do %>
|
||||||
|
<%= if @mode == :preview do %>
|
||||||
|
<a href="#" phx-click="change_preview_page" phx-value-page="collection" class="flex flex-col items-center gap-3 p-4 rounded-lg transition-colors hover:bg-black/5" style="text-decoration: none; cursor: pointer;">
|
||||||
|
<div
|
||||||
|
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
|
||||||
|
style={"background-image: url('#{category.image_url}');"}
|
||||||
|
></div>
|
||||||
|
<span class="text-sm font-medium" style="font-family: var(--t-font-body); color: var(--t-text-primary);">
|
||||||
|
<%= category.name %>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<% else %>
|
||||||
|
<a href={"/products?category=#{category[:slug] || category.name}"} class="flex flex-col items-center gap-3 p-4 rounded-lg transition-colors hover:bg-black/5" style="text-decoration: none;">
|
||||||
|
<div
|
||||||
|
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
|
||||||
|
style={"background-image: url('#{category.image_url}');"}
|
||||||
|
></div>
|
||||||
|
<span class="text-sm font-medium" style="font-family: var(--t-font-body); color: var(--t-text-primary);">
|
||||||
|
<%= category.name %>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
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 "/products".
|
||||||
|
|
||||||
|
## 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: "/products"
|
||||||
|
|
||||||
|
def featured_products_section(assigns) do
|
||||||
|
~H"""
|
||||||
|
<section style="padding: var(--space-xl) var(--space-lg); background-color: var(--t-surface-sunken);">
|
||||||
|
<h2 class="text-2xl mb-6" style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking); color: var(--t-text-primary);">
|
||||||
|
<%= @title %>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<.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 %>
|
||||||
|
</.product_grid>
|
||||||
|
|
||||||
|
<div class="text-center mt-8">
|
||||||
|
<%= if @mode == :preview do %>
|
||||||
|
<button
|
||||||
|
phx-click="change_preview_page"
|
||||||
|
phx-value-page={@cta_page}
|
||||||
|
class="px-6 py-3 font-medium transition-all"
|
||||||
|
style="background-color: transparent; color: var(--t-text-primary); border: 1px solid var(--t-text-primary); border-radius: var(--t-radius-button); cursor: pointer;"
|
||||||
|
>
|
||||||
|
<%= @cta_text %>
|
||||||
|
</button>
|
||||||
|
<% else %>
|
||||||
|
<a
|
||||||
|
href={@cta_href}
|
||||||
|
class="inline-block px-6 py-3 font-medium transition-all"
|
||||||
|
style="background-color: transparent; color: var(--t-text-primary); border: 1px solid var(--t-text-primary); border-radius: var(--t-radius-button); text-decoration: none;"
|
||||||
|
>
|
||||||
|
<%= @cta_text %>
|
||||||
|
</a>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<section class="grid grid-cols-1 md:grid-cols-2 gap-12 items-center" style="padding: var(--space-2xl) var(--space-lg); background-color: var(--t-surface-base);">
|
||||||
|
<%= 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 %>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :image_url, :string, required: true
|
||||||
|
|
||||||
|
defp image_text_image(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
class="h-72 rounded-lg bg-cover bg-center"
|
||||||
|
style={"background-image: url('#{@image_url}'); border-radius: var(--t-radius-image);"}
|
||||||
|
></div>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl mb-4" style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking); color: var(--t-text-primary);">
|
||||||
|
<%= @title %>
|
||||||
|
</h2>
|
||||||
|
<p class="text-base mb-4" style="color: var(--t-text-secondary); line-height: 1.7;">
|
||||||
|
<%= @description %>
|
||||||
|
</p>
|
||||||
|
<%= if @link_text do %>
|
||||||
|
<%= if @mode == :preview do %>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
phx-click="change_preview_page"
|
||||||
|
phx-value-page={@link_page}
|
||||||
|
class="text-sm font-medium transition-colors"
|
||||||
|
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); text-decoration: none; cursor: pointer;"
|
||||||
|
>
|
||||||
|
<%= @link_text %>
|
||||||
|
</a>
|
||||||
|
<% else %>
|
||||||
|
<a
|
||||||
|
href={@link_href || "/"}
|
||||||
|
class="text-sm font-medium transition-colors"
|
||||||
|
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); text-decoration: none;"
|
||||||
|
>
|
||||||
|
<%= @link_text %>
|
||||||
|
</a>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<div class="border-b" style="background-color: var(--t-surface-raised); border-color: var(--t-border-default);">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<h1 class="text-3xl md:text-4xl font-bold mb-2" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);">
|
||||||
|
<%= @title %>
|
||||||
|
</h1>
|
||||||
|
<%= if @subtitle do %>
|
||||||
|
<p style="color: var(--t-text-secondary);"><%= @subtitle %></p>
|
||||||
|
<% end %>
|
||||||
|
<%= if @product_count do %>
|
||||||
|
<p style="color: var(--t-text-secondary);"><%= @product_count %> products</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
|
<!-- Category Pills -->
|
||||||
|
<div class="filter-pills-container flex gap-2 overflow-x-auto">
|
||||||
|
<button class={"filter-pill#{if @active_category == "All", do: " filter-pill-active", else: ""}"}>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<%= for category <- @categories do %>
|
||||||
|
<button class={"filter-pill#{if @active_category == category.name, do: " filter-pill-active", else: ""}"}>
|
||||||
|
<%= category.name %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort Dropdown -->
|
||||||
|
<select
|
||||||
|
class="px-4 py-2"
|
||||||
|
style="background-color: var(--t-surface-raised); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
|
||||||
|
>
|
||||||
|
<%= for option <- @sort_options do %>
|
||||||
|
<option><%= option %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a content body container for long-form content pages (about, etc.).
|
||||||
|
|
||||||
|
## Attributes
|
||||||
|
|
||||||
|
* `image_url` - Optional. Header image URL.
|
||||||
|
|
||||||
|
## Slots
|
||||||
|
|
||||||
|
* `inner_block` - Required. The content to render.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.content_body image_url="/images/about.jpg">
|
||||||
|
<p>Content here...</p>
|
||||||
|
</.content_body>
|
||||||
|
"""
|
||||||
|
attr :image_url, :string, default: nil
|
||||||
|
|
||||||
|
slot :inner_block, required: true
|
||||||
|
|
||||||
|
def content_body(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="content-body" style="padding: var(--space-xl) var(--space-lg); max-width: 800px; margin: 0 auto;">
|
||||||
|
<%= if @image_url do %>
|
||||||
|
<div
|
||||||
|
class="content-image about-image"
|
||||||
|
style={"width: 100%; height: 300px; border-radius: var(--t-radius-image); margin-bottom: var(--space-lg); background-size: cover; background-position: center; background-image: url('#{@image_url}');"}
|
||||||
|
></div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="content-text" style="line-height: 1.7;">
|
||||||
|
<%= render_slot(@inner_block) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a contact form card.
|
||||||
|
|
||||||
|
## Attributes
|
||||||
|
|
||||||
|
* `title` - Optional. Form heading. Defaults to "Send us a message".
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.contact_form />
|
||||||
|
<.contact_form title="Get in touch" />
|
||||||
|
"""
|
||||||
|
attr :title, :string, default: "Send us a message"
|
||||||
|
|
||||||
|
def contact_form(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
class="p-8"
|
||||||
|
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-bold mb-6" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
|
||||||
|
<%= @title %>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
class="w-full px-4 py-2"
|
||||||
|
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
class="w-full px-4 py-2"
|
||||||
|
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
|
||||||
|
Subject
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="How can we help?"
|
||||||
|
class="w-full px-4 py-2"
|
||||||
|
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
|
||||||
|
Message
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows="5"
|
||||||
|
placeholder="Your message..."
|
||||||
|
class="w-full px-4 py-2"
|
||||||
|
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full px-6 py-3 font-semibold transition-all"
|
||||||
|
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button);"
|
||||||
|
>
|
||||||
|
Send Message
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the order tracking card.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.order_tracking_card />
|
||||||
|
"""
|
||||||
|
def order_tracking_card(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
class="p-6"
|
||||||
|
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
||||||
|
>
|
||||||
|
<h3 class="font-bold mb-3" style="color: var(--t-text-primary);">Track your order</h3>
|
||||||
|
<p class="text-sm mb-3" style="color: var(--t-text-secondary);">
|
||||||
|
Enter your email and we'll send you a link to check your order status.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
class="flex-1 min-w-0 px-3 py-2 text-sm"
|
||||||
|
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input); min-width: 150px;"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium whitespace-nowrap"
|
||||||
|
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button);"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<div
|
||||||
|
class="p-6"
|
||||||
|
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
||||||
|
>
|
||||||
|
<h3 class="font-bold mb-3" style="color: var(--t-text-primary);"><%= @title %></h3>
|
||||||
|
<ul class="space-y-2 text-sm" style="color: var(--t-text-secondary);">
|
||||||
|
<%= for item <- @items do %>
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<span style="color: var(--t-text-tertiary);">•</span>
|
||||||
|
<span><strong style="color: var(--t-text-primary);"><%= item.label %>:</strong> <%= item.value %></span>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<div
|
||||||
|
class="p-6"
|
||||||
|
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
||||||
|
>
|
||||||
|
<h3 class="font-bold mb-3" style="color: var(--t-text-primary);"><%= @title %></h3>
|
||||||
|
<a href={"mailto:#{@email}"} class="flex items-center gap-2 mb-2" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); text-decoration: none;">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<%= @email %>
|
||||||
|
</a>
|
||||||
|
<p class="text-sm" style="color: var(--t-text-secondary);">
|
||||||
|
<%= @response_text %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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: "#", label: "Instagram"},
|
||||||
|
%{platform: :pinterest, url: "#", label: "Pinterest"}
|
||||||
|
]
|
||||||
|
|
||||||
|
def social_links(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex gap-4 justify-center">
|
||||||
|
<%= for link <- @links do %>
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
class="social-link w-9 h-9 flex items-center justify-center transition-all"
|
||||||
|
style="color: var(--t-text-secondary); border-radius: var(--t-radius-button);"
|
||||||
|
aria-label={link.label}
|
||||||
|
>
|
||||||
|
<.social_icon platform={link.platform} />
|
||||||
|
</a>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :platform, :atom, required: true
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :instagram} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect>
|
||||||
|
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path>
|
||||||
|
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line>
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :pinterest} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<path d="M8 12c0-2.2 1.8-4 4-4s4 1.8 4 4-1.8 4-4 4"></path>
|
||||||
|
<line x1="12" y1="16" x2="9" y2="21"></line>
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
attr :currency, :string, default: "£"
|
||||||
|
|
||||||
|
def cart_item(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
class="flex gap-4 p-4"
|
||||||
|
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
||||||
|
>
|
||||||
|
<div class="w-24 h-24 flex-shrink-0 bg-gray-200 overflow-hidden" style="border-radius: var(--t-radius-image);">
|
||||||
|
<img
|
||||||
|
src={@item.product.image_url}
|
||||||
|
alt={@item.product.name}
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-semibold mb-1" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
|
||||||
|
<%= @item.product.name %>
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm mb-2" style="color: var(--t-text-secondary);">
|
||||||
|
<%= @item.variant %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center" style="border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);">
|
||||||
|
<button class="px-3 py-1" style="color: var(--t-text-primary);">−</button>
|
||||||
|
<span class="px-3 py-1 border-x" style="border-color: var(--t-border-default); color: var(--t-text-primary);">
|
||||||
|
<%= @item.quantity %>
|
||||||
|
</span>
|
||||||
|
<button class="px-3 py-1" style="color: var(--t-text-primary);">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="text-sm" style="color: var(--t-text-tertiary);">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-bold text-lg" style="color: var(--t-text-primary);">
|
||||||
|
<%= @currency %><%= @item.product.price / 100 * @item.quantity %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the order summary card.
|
||||||
|
|
||||||
|
## Attributes
|
||||||
|
|
||||||
|
* `subtotal` - Required. Subtotal amount (in pence/cents).
|
||||||
|
* `delivery` - Optional. Delivery cost. Defaults to 800 (£8.00).
|
||||||
|
* `vat` - Optional. VAT amount. Defaults to 720 (£7.20).
|
||||||
|
* `currency` - Optional. Currency symbol. Defaults to "£".
|
||||||
|
* `mode` - Either `:live` (default) or `:preview`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.order_summary subtotal={3600} />
|
||||||
|
"""
|
||||||
|
attr :subtotal, :integer, required: true
|
||||||
|
attr :delivery, :integer, default: 800
|
||||||
|
attr :vat, :integer, default: 720
|
||||||
|
attr :currency, :string, default: "£"
|
||||||
|
attr :mode, :atom, default: :live
|
||||||
|
|
||||||
|
def order_summary(assigns) do
|
||||||
|
total = assigns.subtotal + assigns.delivery + assigns.vat
|
||||||
|
|
||||||
|
assigns = assign(assigns, :total, total)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
class="p-6 sticky top-4"
|
||||||
|
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-bold mb-6" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
|
||||||
|
Order Summary
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="space-y-3 mb-6">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span style="color: var(--t-text-secondary);">Subtotal</span>
|
||||||
|
<span style="color: var(--t-text-primary);">
|
||||||
|
<%= @currency %><%= Float.round(@subtotal / 100, 2) %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span style="color: var(--t-text-secondary);">Delivery</span>
|
||||||
|
<span style="color: var(--t-text-primary);"><%= @currency %><%= Float.round(@delivery / 100, 2) %></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span style="color: var(--t-text-secondary);">VAT (20%)</span>
|
||||||
|
<span style="color: var(--t-text-primary);"><%= @currency %><%= Float.round(@vat / 100, 2) %></span>
|
||||||
|
</div>
|
||||||
|
<div class="border-t pt-3" style="border-color: var(--t-border-default);">
|
||||||
|
<div class="flex justify-between text-lg">
|
||||||
|
<span class="font-semibold" style="color: var(--t-text-primary);">Total</span>
|
||||||
|
<span class="font-bold" style="color: var(--t-text-primary);">
|
||||||
|
<%= @currency %><%= Float.round(@total / 100, 2) %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full px-6 py-3 font-semibold transition-all mb-3"
|
||||||
|
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button);"
|
||||||
|
>
|
||||||
|
Checkout
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<%= if @mode == :preview do %>
|
||||||
|
<button
|
||||||
|
phx-click="change_preview_page"
|
||||||
|
phx-value-page="collection"
|
||||||
|
class="w-full px-6 py-3 font-semibold transition-all"
|
||||||
|
style="border: 2px solid var(--t-border-default); color: var(--t-text-primary); border-radius: var(--t-radius-button); background: transparent; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Continue Shopping
|
||||||
|
</button>
|
||||||
|
<% else %>
|
||||||
|
<a
|
||||||
|
href="/products"
|
||||||
|
class="block w-full px-6 py-3 font-semibold transition-all text-center"
|
||||||
|
style="border: 2px solid var(--t-border-default); color: var(--t-text-primary); border-radius: var(--t-radius-button); text-decoration: none;"
|
||||||
|
>
|
||||||
|
Continue Shopping
|
||||||
|
</a>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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: "/products?category=art-prints"},
|
||||||
|
%{label: "Mountain Sunrise", current: true}
|
||||||
|
]} mode={:preview} />
|
||||||
|
"""
|
||||||
|
attr :items, :list, required: true
|
||||||
|
attr :mode, :atom, default: :live
|
||||||
|
|
||||||
|
def breadcrumb(assigns) do
|
||||||
|
~H"""
|
||||||
|
<nav class="mb-8 flex items-center gap-2 text-sm" style="color: var(--t-text-secondary);">
|
||||||
|
<%= for {item, index} <- Enum.with_index(@items) do %>
|
||||||
|
<%= if index > 0 do %>
|
||||||
|
<span>/</span>
|
||||||
|
<% end %>
|
||||||
|
<%= if item[:current] do %>
|
||||||
|
<span style="color: var(--t-text-primary);"><%= item.label %></span>
|
||||||
|
<% else %>
|
||||||
|
<%= if @mode == :preview do %>
|
||||||
|
<a href="#" phx-click="change_preview_page" phx-value-page={item.page} class="hover:underline"><%= item.label %></a>
|
||||||
|
<% else %>
|
||||||
|
<a href={item.href || "/"} class="hover:underline"><%= item.label %></a>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</nav>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<div class="py-12" style="border-top: 1px solid var(--t-border-default);">
|
||||||
|
<h2 class="text-2xl font-bold mb-6" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight);">
|
||||||
|
<%= @title %>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<.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 %>
|
||||||
|
</.product_grid>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -20,15 +20,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Content Body -->
|
<!-- Content Body -->
|
||||||
<div class="content-body" style="padding: var(--space-xl) var(--space-lg); max-width: 800px; margin: 0 auto;">
|
<.content_body image_url="/mockups/night-sky-blanket-3.jpg">
|
||||||
<!-- About Image -->
|
|
||||||
<div
|
|
||||||
class="content-image about-image"
|
|
||||||
style="width: 100%; height: 300px; border-radius: var(--t-radius-image); margin-bottom: var(--space-lg); background-size: cover; background-position: center; background-image: url('/mockups/night-sky-blanket-3.jpg');"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Content Text -->
|
|
||||||
<div class="content-text" style="line-height: 1.7;">
|
|
||||||
<p class="lead-text text-lg mb-4" style="color: var(--t-text-primary);">
|
<p class="lead-text text-lg mb-4" style="color: var(--t-text-primary);">
|
||||||
I'm Emma, a nature photographer based in the UK. What started as weekend walks with my camera has grown into something I never expected – a little shop where I can share my favourite captures with others.
|
I'm Emma, a nature photographer based in the UK. What started as weekend walks with my camera has grown into something I never expected – a little shop where I can share my favourite captures with others.
|
||||||
</p>
|
</p>
|
||||||
@ -58,8 +50,7 @@
|
|||||||
<p class="mt-8" style="color: var(--t-text-secondary);">
|
<p class="mt-8" style="color: var(--t-text-secondary);">
|
||||||
Thank you for visiting. It means a lot that you're here.
|
Thank you for visiting. It means a lot that you're here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</.content_body>
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
<%
|
||||||
|
subtotal = Enum.reduce(@preview_data.cart_items, 0, fn item, acc -> acc + item.product.price * item.quantity end)
|
||||||
|
%>
|
||||||
<div class="shop-container min-h-screen" style="position: relative; background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
|
<div class="shop-container min-h-screen" style="position: relative; background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
|
||||||
<!-- Skip Link for Accessibility -->
|
<!-- Skip Link for Accessibility -->
|
||||||
<.skip_link />
|
<.skip_link />
|
||||||
@ -20,100 +23,14 @@
|
|||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<%= for item <- @preview_data.cart_items do %>
|
<%= for item <- @preview_data.cart_items do %>
|
||||||
<div
|
<.cart_item item={item} currency="$" />
|
||||||
class="flex gap-4 p-4"
|
|
||||||
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
|
||||||
>
|
|
||||||
<div class="w-24 h-24 flex-shrink-0 bg-gray-200 overflow-hidden" style="border-radius: var(--t-radius-image);">
|
|
||||||
<img
|
|
||||||
src={item.product.image_url}
|
|
||||||
alt={item.product.name}
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="font-semibold mb-1" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
|
|
||||||
<%= item.product.name %>
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm mb-2" style="color: var(--t-text-secondary);">
|
|
||||||
<%= item.variant %>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="flex items-center" style="border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);">
|
|
||||||
<button class="px-3 py-1" style="color: var(--t-text-primary);">−</button>
|
|
||||||
<span class="px-3 py-1 border-x" style="border-color: var(--t-border-default); color: var(--t-text-primary);">
|
|
||||||
<%= item.quantity %>
|
|
||||||
</span>
|
|
||||||
<button class="px-3 py-1" style="color: var(--t-text-primary);">+</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="text-sm" style="color: var(--t-text-tertiary);">
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="font-bold text-lg" style="color: var(--t-text-primary);">
|
|
||||||
$<%= item.product.price / 100 * item.quantity %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Summary -->
|
<!-- Order Summary -->
|
||||||
<div>
|
<div>
|
||||||
<div
|
<.order_summary subtotal={subtotal} mode={:preview} />
|
||||||
class="p-6 sticky top-4"
|
|
||||||
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
|
||||||
>
|
|
||||||
<h2 class="text-xl font-bold mb-6" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
|
|
||||||
Order Summary
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="space-y-3 mb-6">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span style="color: var(--t-text-secondary);">Subtotal</span>
|
|
||||||
<span style="color: var(--t-text-primary);">
|
|
||||||
$<%= Enum.reduce(@preview_data.cart_items, 0, fn item, acc -> acc + item.product.price * item.quantity end) / 100 %>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span style="color: var(--t-text-secondary);">Delivery</span>
|
|
||||||
<span style="color: var(--t-text-primary);">£8.00</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span style="color: var(--t-text-secondary);">VAT (20%)</span>
|
|
||||||
<span style="color: var(--t-text-primary);">£7.20</span>
|
|
||||||
</div>
|
|
||||||
<div class="border-t pt-3" style="border-color: var(--t-border-default);">
|
|
||||||
<div class="flex justify-between text-lg">
|
|
||||||
<span class="font-semibold" style="color: var(--t-text-primary);">Total</span>
|
|
||||||
<span class="font-bold" style="color: var(--t-text-primary);">
|
|
||||||
£<%= (Enum.reduce(@preview_data.cart_items, 0, fn item, acc -> acc + item.product.price * item.quantity end) / 100 + 8.00 + 7.20) |> Float.round(2) %>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="w-full px-6 py-3 font-semibold transition-all mb-3"
|
|
||||||
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button);"
|
|
||||||
>
|
|
||||||
Checkout
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="w-full px-6 py-3 font-semibold transition-all"
|
|
||||||
style="border: 2px solid var(--t-border-default); color: var(--t-text-primary); border-radius: var(--t-radius-button);"
|
|
||||||
>
|
|
||||||
Continue Shopping
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -12,44 +12,11 @@
|
|||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
<div class="border-b" style="background-color: var(--t-surface-raised); border-color: var(--t-border-default);">
|
<.collection_header title="All Products" product_count={length(@preview_data.products)} />
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<h1 class="text-3xl md:text-4xl font-bold mb-2" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);">
|
|
||||||
All Products
|
|
||||||
</h1>
|
|
||||||
<p style="color: var(--t-text-secondary);">
|
|
||||||
<%= length(@preview_data.products) %> products
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Filter Bar: Category Pills + Sort -->
|
<!-- Filter Bar: Category Pills + Sort -->
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
<.filter_bar categories={@preview_data.categories} />
|
||||||
<!-- Category Pills -->
|
|
||||||
<div class="filter-pills-container flex gap-2 overflow-x-auto">
|
|
||||||
<button class="filter-pill filter-pill-active">
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
<%= for category <- @preview_data.categories do %>
|
|
||||||
<button class="filter-pill">
|
|
||||||
<%= category.name %>
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sort Dropdown -->
|
|
||||||
<select
|
|
||||||
class="px-4 py-2"
|
|
||||||
style="background-color: var(--t-surface-raised); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
|
|
||||||
>
|
|
||||||
<option>Sort by: Featured</option>
|
|
||||||
<option>Price: Low to High</option>
|
|
||||||
<option>Price: High to Low</option>
|
|
||||||
<option>Newest</option>
|
|
||||||
<option>Best Selling</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Product Grid -->
|
<!-- Product Grid -->
|
||||||
<.product_grid theme_settings={@theme_settings}>
|
<.product_grid theme_settings={@theme_settings}>
|
||||||
|
|||||||
@ -19,166 +19,25 @@
|
|||||||
|
|
||||||
<div class="grid gap-8 md:grid-cols-2 mb-12">
|
<div class="grid gap-8 md:grid-cols-2 mb-12">
|
||||||
<!-- Contact Form -->
|
<!-- Contact Form -->
|
||||||
<div
|
<.contact_form />
|
||||||
class="p-8"
|
|
||||||
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
|
||||||
>
|
|
||||||
<h2 class="text-xl font-bold mb-6" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
|
|
||||||
Send us a message
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Your name"
|
|
||||||
class="w-full px-4 py-2"
|
|
||||||
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
class="w-full px-4 py-2"
|
|
||||||
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
|
|
||||||
Subject
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="How can we help?"
|
|
||||||
class="w-full px-4 py-2"
|
|
||||||
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block font-medium mb-2" style="color: var(--t-text-primary);">
|
|
||||||
Message
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
rows="5"
|
|
||||||
placeholder="Your message..."
|
|
||||||
class="w-full px-4 py-2"
|
|
||||||
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input);"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full px-6 py-3 font-semibold transition-all"
|
|
||||||
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button);"
|
|
||||||
>
|
|
||||||
Send Message
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contact Info -->
|
<!-- Contact Info -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Order Tracking -->
|
<!-- Order Tracking -->
|
||||||
<div
|
<.order_tracking_card />
|
||||||
class="p-6"
|
|
||||||
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
|
||||||
>
|
|
||||||
<h3 class="font-bold mb-3" style="color: var(--t-text-primary);">Track your order</h3>
|
|
||||||
<p class="text-sm mb-3" style="color: var(--t-text-secondary);">
|
|
||||||
Enter your email and we'll send you a link to check your order status.
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
class="flex-1 min-w-0 px-3 py-2 text-sm"
|
|
||||||
style="background-color: var(--t-surface-base); color: var(--t-text-primary); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-input); min-width: 150px;"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 text-sm font-medium whitespace-nowrap"
|
|
||||||
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button);"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Helpful info -->
|
<!-- Helpful info -->
|
||||||
<div
|
<.info_card title="Handy to know" items={[
|
||||||
class="p-6"
|
%{label: "Printing", value: "2-5 business days"},
|
||||||
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
%{label: "Delivery", value: "3-7 business days after printing"},
|
||||||
>
|
%{label: "Returns", value: "Happy to help with faulty or damaged items"}
|
||||||
<h3 class="font-bold mb-3" style="color: var(--t-text-primary);">Handy to know</h3>
|
]} />
|
||||||
<ul class="space-y-2 text-sm" style="color: var(--t-text-secondary);">
|
|
||||||
<li class="flex items-start gap-2">
|
|
||||||
<span style="color: var(--t-text-tertiary);">•</span>
|
|
||||||
<span><strong style="color: var(--t-text-primary);">Printing:</strong> 2-5 business days</span>
|
|
||||||
</li>
|
|
||||||
<li class="flex items-start gap-2">
|
|
||||||
<span style="color: var(--t-text-tertiary);">•</span>
|
|
||||||
<span><strong style="color: var(--t-text-primary);">Delivery:</strong> 3-7 business days after printing</span>
|
|
||||||
</li>
|
|
||||||
<li class="flex items-start gap-2">
|
|
||||||
<span style="color: var(--t-text-tertiary);">•</span>
|
|
||||||
<span><strong style="color: var(--t-text-primary);">Returns:</strong> Happy to help with faulty or damaged items</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Get in touch -->
|
<!-- Get in touch -->
|
||||||
<div
|
<.contact_info_card email="hello@example.com" />
|
||||||
class="p-6"
|
|
||||||
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
|
|
||||||
>
|
|
||||||
<h3 class="font-bold mb-3" style="color: var(--t-text-primary);">Get in touch</h3>
|
|
||||||
<a href="mailto:hello@example.com" class="flex items-center gap-2 mb-2" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); text-decoration: none;">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
hello@example.com
|
|
||||||
</a>
|
|
||||||
<p class="text-sm" style="color: var(--t-text-secondary);">
|
|
||||||
We typically respond within 24 hours
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Social links -->
|
<!-- Social links -->
|
||||||
<div class="flex gap-4 justify-center">
|
<.social_links />
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="social-link w-9 h-9 flex items-center justify-center transition-all"
|
|
||||||
style="color: var(--t-text-secondary); border-radius: var(--t-radius-button);"
|
|
||||||
aria-label="Instagram"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect>
|
|
||||||
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path>
|
|
||||||
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="social-link w-9 h-9 flex items-center justify-center transition-all"
|
|
||||||
style="color: var(--t-text-secondary); border-radius: var(--t-radius-button);"
|
|
||||||
aria-label="Pinterest"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
|
||||||
<path d="M8 12c0-2.2 1.8-4 4-4s4 1.8 4 4-1.8 4-4 4"></path>
|
|
||||||
<line x1="12" y1="16" x2="9" y2="21"></line>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -21,75 +21,25 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Categories -->
|
<!-- Categories -->
|
||||||
<section style="padding: var(--space-xl) var(--space-lg); background-color: var(--t-surface-base);">
|
<.category_nav categories={@preview_data.categories} mode={:preview} />
|
||||||
<nav class="grid grid-cols-3 gap-4 max-w-3xl mx-auto" aria-label="Product categories">
|
|
||||||
<%= for category <- Enum.take(@preview_data.categories, 3) do %>
|
|
||||||
<a href="#" phx-click="change_preview_page" phx-value-page="collection" class="flex flex-col items-center gap-3 p-4 rounded-lg transition-colors hover:bg-black/5" style="text-decoration: none; cursor: pointer;">
|
|
||||||
<div
|
|
||||||
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
|
|
||||||
style={"background-image: url('#{category.image_url}');"}
|
|
||||||
></div>
|
|
||||||
<span class="text-sm font-medium" style="font-family: var(--t-font-body); color: var(--t-text-primary);">
|
|
||||||
<%= category.name %>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<% end %>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Featured Products -->
|
<!-- Featured Products -->
|
||||||
<section style="padding: var(--space-xl) var(--space-lg); background-color: var(--t-surface-sunken);">
|
<.featured_products_section
|
||||||
<h2 class="text-2xl mb-6" style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking); color: var(--t-text-primary);">
|
title="Featured products"
|
||||||
Featured products
|
products={@preview_data.products}
|
||||||
</h2>
|
|
||||||
|
|
||||||
<.product_grid theme_settings={@theme_settings}>
|
|
||||||
<%= for product <- Enum.take(@preview_data.products, 4) do %>
|
|
||||||
<.product_card
|
|
||||||
product={product}
|
|
||||||
theme_settings={@theme_settings}
|
theme_settings={@theme_settings}
|
||||||
mode={:preview}
|
mode={:preview}
|
||||||
variant={:featured}
|
|
||||||
/>
|
/>
|
||||||
<% end %>
|
|
||||||
</.product_grid>
|
|
||||||
|
|
||||||
<div class="text-center mt-8">
|
|
||||||
<button
|
|
||||||
phx-click="change_preview_page"
|
|
||||||
phx-value-page="collection"
|
|
||||||
class="px-6 py-3 font-medium transition-all"
|
|
||||||
style="background-color: transparent; color: var(--t-text-primary); border: 1px solid var(--t-text-primary); border-radius: var(--t-radius-button); cursor: pointer;"
|
|
||||||
>
|
|
||||||
View all products
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- About Section -->
|
<!-- About Section -->
|
||||||
<section class="grid grid-cols-1 md:grid-cols-2 gap-12 items-center" style="padding: var(--space-2xl) var(--space-lg); background-color: var(--t-surface-base);">
|
<.image_text_section
|
||||||
<div
|
title="Made with passion, printed with care"
|
||||||
class="h-72 rounded-lg bg-cover bg-center"
|
description="Every design starts with an idea. We work with quality print partners to bring those ideas to life on premium products – from gallery-quality art prints to everyday essentials."
|
||||||
style="background-image: url('/mockups/mountain-sunrise-print-3.jpg'); border-radius: var(--t-radius-image);"
|
image_url="/mockups/mountain-sunrise-print-3.jpg"
|
||||||
></div>
|
link_text="Learn more about the studio →"
|
||||||
<div>
|
link_page="about"
|
||||||
<h2 class="text-2xl mb-4" style="font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking); color: var(--t-text-primary);">
|
mode={:preview}
|
||||||
Made with passion, printed with care
|
/>
|
||||||
</h2>
|
|
||||||
<p class="text-base mb-4" style="color: var(--t-text-secondary); line-height: 1.7;">
|
|
||||||
Every design starts with an idea. We work with quality print partners to bring those ideas to life on premium products – from gallery-quality art prints to everyday essentials.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
phx-click="change_preview_page"
|
|
||||||
phx-value-page="about"
|
|
||||||
class="text-sm font-medium transition-colors"
|
|
||||||
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); text-decoration: none; cursor: pointer;"
|
|
||||||
>
|
|
||||||
Learn more about the studio →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|||||||
@ -15,13 +15,11 @@
|
|||||||
|
|
||||||
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<nav class="mb-8 flex items-center gap-2 text-sm" style="color: var(--t-text-secondary);">
|
<.breadcrumb items={[
|
||||||
<a href="#" phx-click="change_preview_page" phx-value-page="home" class="hover:underline">Home</a>
|
%{label: "Home", page: "home", href: "/"},
|
||||||
<span>/</span>
|
%{label: product.category, page: "collection", href: "/products"},
|
||||||
<a href="#" phx-click="change_preview_page" phx-value-page="collection" class="hover:underline"><%= product.category %></a>
|
%{label: product.name, current: true}
|
||||||
<span>/</span>
|
]} mode={:preview} />
|
||||||
<span style="color: var(--t-text-primary);"><%= product.name %></span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 mb-16">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 mb-16">
|
||||||
<!-- Product Images -->
|
<!-- Product Images -->
|
||||||
@ -397,23 +395,12 @@
|
|||||||
|
|
||||||
<!-- Related Products -->
|
<!-- Related Products -->
|
||||||
<%= if @theme_settings.pdp_related_products do %>
|
<%= if @theme_settings.pdp_related_products do %>
|
||||||
<div class="py-12" style="border-top: 1px solid var(--t-border-default);">
|
<.related_products_section
|
||||||
<h2 class="text-2xl font-bold mb-6" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight);">
|
products={Enum.slice(@preview_data.products, 1, 4)}
|
||||||
You might also like
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<.product_grid columns={:fixed_4} gap="gap-6">
|
|
||||||
<%= for related_product <- Enum.slice(@preview_data.products, 1, 4) do %>
|
|
||||||
<.product_card
|
|
||||||
product={related_product}
|
|
||||||
theme_settings={@theme_settings}
|
theme_settings={@theme_settings}
|
||||||
mode={:preview}
|
mode={:preview}
|
||||||
variant={:compact}
|
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
</.product_grid>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user