rename project from SimpleshopTheme to Berrypod
All modules, configs, paths, and references updated. 836 tests pass, zero warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
19
lib/berrypod_web/admin_layout_hook.ex
Normal file
19
lib/berrypod_web/admin_layout_hook.ex
Normal file
@@ -0,0 +1,19 @@
|
||||
defmodule BerrypodWeb.AdminLayoutHook do
|
||||
@moduledoc """
|
||||
LiveView on_mount hook that assigns the current path for admin sidebar navigation.
|
||||
"""
|
||||
import Phoenix.Component
|
||||
|
||||
def on_mount(:assign_current_path, _params, _session, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:current_path, "")
|
||||
|> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,
|
||||
uri,
|
||||
socket ->
|
||||
{:cont, assign(socket, :current_path, URI.parse(uri).path)}
|
||||
end)
|
||||
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
166
lib/berrypod_web/cart_hook.ex
Normal file
166
lib/berrypod_web/cart_hook.ex
Normal file
@@ -0,0 +1,166 @@
|
||||
defmodule BerrypodWeb.CartHook do
|
||||
@moduledoc """
|
||||
LiveView on_mount hook for cart state and shared event handling.
|
||||
|
||||
Mounted in the public_shop live_session to give all shop LiveViews
|
||||
cart state, PubSub sync, and shared event handlers via attach_hook.
|
||||
|
||||
Handles these events so individual LiveViews don't have to:
|
||||
- `open_cart_drawer` / `close_cart_drawer` - toggle drawer visibility
|
||||
- `remove_item` - remove item from cart
|
||||
- `increment` / `decrement` - change item quantity
|
||||
- `change_country` - update shipping country
|
||||
- `{:cart_updated, cart}` info - cross-tab cart sync via PubSub
|
||||
|
||||
LiveViews with custom cart logic (e.g. add_to_cart) can call
|
||||
`update_cart_assigns/2` and `broadcast_and_update/2` directly.
|
||||
"""
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1, push_event: 3]
|
||||
|
||||
alias Berrypod.Cart
|
||||
alias Berrypod.Shipping
|
||||
|
||||
def on_mount(:mount_cart, _params, session, socket) do
|
||||
cart_items = Cart.get_from_session(session)
|
||||
country_code = session["country_code"] || "GB"
|
||||
available_countries = Shipping.list_available_countries_with_names()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:country_code, country_code)
|
||||
|> assign(:available_countries, available_countries)
|
||||
|> update_cart_assigns(cart_items)
|
||||
|> assign(:cart_drawer_open, false)
|
||||
|> assign(:cart_status, nil)
|
||||
|> attach_hook(:cart_events, :handle_event, &handle_cart_event/3)
|
||||
|> attach_hook(:cart_info, :handle_info, &handle_cart_info/2)
|
||||
|
||||
socket =
|
||||
if connected?(socket) do
|
||||
csrf_token = Map.get(session, "_csrf_token", "default")
|
||||
topic = "cart:#{csrf_token}"
|
||||
Phoenix.PubSub.subscribe(Berrypod.PubSub, topic)
|
||||
assign(socket, :cart_topic, topic)
|
||||
else
|
||||
assign(socket, :cart_topic, nil)
|
||||
end
|
||||
|
||||
{:cont, socket}
|
||||
end
|
||||
|
||||
# Shared event handlers
|
||||
|
||||
defp handle_cart_event("open_cart_drawer", _params, socket) do
|
||||
{:halt, assign(socket, :cart_drawer_open, true)}
|
||||
end
|
||||
|
||||
defp handle_cart_event("close_cart_drawer", _params, socket) do
|
||||
{:halt, assign(socket, :cart_drawer_open, false)}
|
||||
end
|
||||
|
||||
defp handle_cart_event("change_country", %{"country" => code}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:country_code, code)
|
||||
|> update_cart_assigns(socket.assigns.raw_cart)
|
||||
|> push_event("persist_country", %{code: code})
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
defp handle_cart_event("remove_item", %{"id" => variant_id}, socket) do
|
||||
cart = Cart.remove_item(socket.assigns.raw_cart, variant_id)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> broadcast_and_update(cart)
|
||||
|> assign(:cart_status, "Item removed from cart")
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
defp handle_cart_event("increment", %{"id" => variant_id}, socket) do
|
||||
cart = Cart.add_item(socket.assigns.raw_cart, variant_id, 1)
|
||||
new_qty = Cart.get_quantity(cart, variant_id)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> broadcast_and_update(cart)
|
||||
|> assign(:cart_status, "Quantity updated to #{new_qty}")
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
defp handle_cart_event("decrement", %{"id" => variant_id}, socket) do
|
||||
current = Cart.get_quantity(socket.assigns.raw_cart, variant_id)
|
||||
cart = Cart.update_quantity(socket.assigns.raw_cart, variant_id, current - 1)
|
||||
new_qty = Cart.get_quantity(cart, variant_id)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> broadcast_and_update(cart)
|
||||
|> assign(:cart_status, "Quantity updated to #{new_qty}")
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
defp handle_cart_event(_event, _params, socket), do: {:cont, socket}
|
||||
|
||||
# Shared info handlers
|
||||
|
||||
defp handle_cart_info({:cart_updated, cart}, socket) do
|
||||
{:halt, update_cart_assigns(socket, cart)}
|
||||
end
|
||||
|
||||
defp handle_cart_info(_msg, socket), do: {:cont, socket}
|
||||
|
||||
# Public helpers for LiveViews with custom cart logic
|
||||
|
||||
@doc """
|
||||
Updates all cart-related assigns from raw cart data.
|
||||
"""
|
||||
def update_cart_assigns(socket, cart) do
|
||||
%{items: items, count: count, subtotal: subtotal} = Cart.build_state(cart)
|
||||
country_code = socket.assigns[:country_code] || "GB"
|
||||
subtotal_pence = Cart.calculate_subtotal(items)
|
||||
|
||||
shipping_estimate =
|
||||
case Shipping.calculate_for_cart(items, country_code) do
|
||||
{:ok, cost} when cost > 0 -> cost
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
cart_total =
|
||||
Cart.format_price(subtotal_pence + (shipping_estimate || 0))
|
||||
|
||||
socket
|
||||
|> assign(:raw_cart, cart)
|
||||
|> assign(:cart_items, items)
|
||||
|> assign(:cart_count, count)
|
||||
|> assign(:cart_subtotal, subtotal)
|
||||
|> assign(:cart_total, cart_total)
|
||||
|> assign(:shipping_estimate, shipping_estimate)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts cart update to other tabs and updates local assigns.
|
||||
|
||||
Uses broadcast_from to avoid notifying self (prevents double-update).
|
||||
"""
|
||||
def broadcast_and_update(socket, cart) do
|
||||
if socket.assigns.cart_topic do
|
||||
Phoenix.PubSub.broadcast_from(
|
||||
Berrypod.PubSub,
|
||||
self(),
|
||||
socket.assigns.cart_topic,
|
||||
{:cart_updated, cart}
|
||||
)
|
||||
end
|
||||
|
||||
socket
|
||||
|> update_cart_assigns(cart)
|
||||
|> push_event("persist_cart", %{items: Cart.serialize(cart)})
|
||||
end
|
||||
end
|
||||
524
lib/berrypod_web/components/core_components.ex
Normal file
524
lib/berrypod_web/components/core_components.ex
Normal file
@@ -0,0 +1,524 @@
|
||||
defmodule BerrypodWeb.CoreComponents do
|
||||
@moduledoc """
|
||||
Provides core UI components.
|
||||
|
||||
At first glance, this module may seem daunting, but its goal is to provide
|
||||
core building blocks for your application, such as tables, forms, and
|
||||
inputs. The components consist mostly of markup and are well-documented
|
||||
with doc strings and declarative assigns. You may customize and style
|
||||
them in any way you want, based on your application growth and needs.
|
||||
|
||||
Styled with custom admin CSS (`assets/css/admin/components.css`).
|
||||
|
||||
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
|
||||
|
||||
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
|
||||
the component system used by Phoenix. Some components, such as `<.link>`
|
||||
and `<.form>`, are defined there.
|
||||
|
||||
"""
|
||||
use Phoenix.Component
|
||||
use Gettext, backend: BerrypodWeb.Gettext
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
|
||||
"""
|
||||
attr :id, :string, doc: "the optional id of flash container"
|
||||
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||
attr :title, :string, default: nil
|
||||
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
||||
def flash(assigns) do
|
||||
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||
id={@id}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class="admin-toast"
|
||||
{@rest}
|
||||
>
|
||||
<div class={[
|
||||
"admin-alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||
@kind == :info && "admin-alert-info",
|
||||
@kind == :error && "admin-alert-error"
|
||||
]}>
|
||||
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<p :if={@title} class="font-semibold">{@title}</p>
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a button with navigation support.
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" variant="primary">Send!</.button>
|
||||
<.button navigate={~p"/"}>Home</.button>
|
||||
"""
|
||||
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
|
||||
attr :class, :string
|
||||
attr :variant, :string, values: ~w(primary)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(%{rest: rest} = assigns) do
|
||||
variants = %{"primary" => "admin-btn-primary", nil => "admin-btn-primary admin-btn-soft"}
|
||||
|
||||
assigns =
|
||||
assign_new(assigns, :class, fn ->
|
||||
["admin-btn", Map.fetch!(variants, assigns[:variant])]
|
||||
end)
|
||||
|
||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||
~H"""
|
||||
<.link class={@class} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
else
|
||||
~H"""
|
||||
<button class={@class} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
A `Phoenix.HTML.FormField` may be passed as argument,
|
||||
which is used to retrieve the input name, id, and values.
|
||||
Otherwise all attributes may be passed explicitly.
|
||||
|
||||
## Types
|
||||
|
||||
This function accepts all HTML input types, considering that:
|
||||
|
||||
* You may also set `type="select"` to render a `<select>` tag
|
||||
|
||||
* `type="checkbox"` is used exclusively to render boolean values
|
||||
|
||||
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
||||
for more information. Unsupported types, such as hidden and radio,
|
||||
are best written directly in your templates.
|
||||
|
||||
## Examples
|
||||
|
||||
<.input field={@form[:email]} type="email" />
|
||||
<.input name="my-input" errors={["oh no!"]} />
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any
|
||||
attr :label, :string, default: nil
|
||||
attr :value, :any
|
||||
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file month number password
|
||||
search select tel text textarea time url week)
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :errors, :list, default: []
|
||||
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||
attr :class, :string, default: nil, doc: "the input class to use over defaults"
|
||||
attr :error_class, :string, default: nil, doc: "the input error class to use over defaults"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
|
||||
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> input()
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div class="admin-fieldset">
|
||||
<label>
|
||||
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||
<span class="admin-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class={@class || "admin-checkbox admin-checkbox-sm"}
|
||||
{@rest}
|
||||
/>{@label}
|
||||
</span>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div class="admin-fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="admin-label">{@label}</span>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[@class || "admin-select", @errors != [] && (@error_class || "admin-input-error")]}
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value="">{@prompt}</option>
|
||||
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||
</select>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div class="admin-fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="admin-label">{@label}</span>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
@class || "admin-textarea",
|
||||
@errors != [] && (@error_class || "admin-input-error")
|
||||
]}
|
||||
{@rest}
|
||||
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div class="admin-fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="admin-label">{@label}</span>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
@class || "admin-input",
|
||||
@errors != [] && (@error_class || "admin-input-error")
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper used by inputs to generate form errors
|
||||
defp error(assigns) do
|
||||
~H"""
|
||||
<p class="admin-error mt-1.5 flex gap-2 items-center text-sm">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
"""
|
||||
slot :inner_block, required: true
|
||||
slot :subtitle
|
||||
slot :actions
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8">
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="text-sm text-base-content/70">
|
||||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none">{render_slot(@actions)}</div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a table with generic styling.
|
||||
|
||||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id">{user.id}</:col>
|
||||
<:col :let={user} label="username">{user.username}</:col>
|
||||
</.table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :rows, :list, required: true
|
||||
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||
|
||||
attr :row_item, :any,
|
||||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
end
|
||||
|
||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||
|
||||
def table(assigns) do
|
||||
assigns =
|
||||
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||
end
|
||||
|
||||
~H"""
|
||||
<table class="admin-table admin-table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
<th :if={@action != []}>
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={@row_click && "hover:cursor-pointer"}
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<div class="flex gap-4">
|
||||
<%= for action <- @action do %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
## Examples
|
||||
|
||||
<.list>
|
||||
<:item title="Title">{@post.title}</:item>
|
||||
<:item title="Views">{@post.views}</:item>
|
||||
</.list>
|
||||
"""
|
||||
slot :item, required: true do
|
||||
attr :title, :string, required: true
|
||||
end
|
||||
|
||||
def list(assigns) do
|
||||
~H"""
|
||||
<ul class="admin-list">
|
||||
<li :for={item <- @item} class="admin-list-row">
|
||||
<div class="admin-list-grow">
|
||||
<div class="font-bold">{item.title}</div>
|
||||
<div>{render_slot(item)}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
Heroicons come in three styles – outline, solid, and mini.
|
||||
By default, the outline style is used, but solid and mini may
|
||||
be applied by using the `-solid` and `-mini` suffix.
|
||||
|
||||
You can customize the size and colors of the icons by setting
|
||||
width, height, and background color classes.
|
||||
|
||||
Icons are extracted from the `deps/heroicons` directory and bundled into
|
||||
`admin/icons.css` by `mix generate_admin_icons`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.icon name="hero-x-mark" />
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :class, :string, default: "size-4"
|
||||
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
<span class={[@name, @class]} />
|
||||
"""
|
||||
end
|
||||
|
||||
## JS Commands
|
||||
|
||||
def show(js \\ %JS{}, selector) do
|
||||
JS.show(js,
|
||||
to: selector,
|
||||
time: 300,
|
||||
transition:
|
||||
{"transition-all ease-out duration-300",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
|
||||
"opacity-100 translate-y-0 sm:scale-100"}
|
||||
)
|
||||
end
|
||||
|
||||
def hide(js \\ %JS{}, selector) do
|
||||
JS.hide(js,
|
||||
to: selector,
|
||||
time: 200,
|
||||
transition:
|
||||
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates an error message using gettext.
|
||||
"""
|
||||
def translate_error({msg, opts}) do
|
||||
# When using gettext, we typically pass the strings we want
|
||||
# to translate as a static argument:
|
||||
#
|
||||
# # Translate the number of files with plural rules
|
||||
# dngettext("errors", "1 file", "%{count} files", count)
|
||||
#
|
||||
# However the error messages in our forms and APIs are generated
|
||||
# dynamically, so we need to translate them by calling Gettext
|
||||
# with our gettext backend as first argument. Translations are
|
||||
# available in the errors.po file (as we use the "errors" domain).
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(BerrypodWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(BerrypodWeb.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates the errors for a field from a keyword list of errors.
|
||||
"""
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a modal dialog.
|
||||
|
||||
Uses daisyUI's modal component with proper accessibility.
|
||||
|
||||
## Examples
|
||||
|
||||
<.modal id="confirm-modal">
|
||||
Are you sure?
|
||||
<:actions>
|
||||
<button class="btn">Cancel</button>
|
||||
<button class="btn btn-primary">Confirm</button>
|
||||
</:actions>
|
||||
</.modal>
|
||||
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :show, :boolean, default: false
|
||||
attr :on_cancel, JS, default: %JS{}
|
||||
|
||||
slot :inner_block, required: true
|
||||
slot :actions
|
||||
|
||||
def modal(assigns) do
|
||||
~H"""
|
||||
<dialog
|
||||
id={@id}
|
||||
class="admin-modal"
|
||||
phx-mounted={@show && show_modal(@id)}
|
||||
phx-remove={hide_modal(@id)}
|
||||
>
|
||||
<div class="admin-modal-box">
|
||||
<form method="dialog" class="admin-modal-close">
|
||||
<button
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon-round admin-btn-sm"
|
||||
phx-click={@on_cancel}
|
||||
aria-label={gettext("close")}
|
||||
>
|
||||
<.icon name="hero-x-mark" class="size-5" />
|
||||
</button>
|
||||
</form>
|
||||
{render_slot(@inner_block)}
|
||||
<div :if={@actions != []} class="admin-modal-actions">
|
||||
{render_slot(@actions)}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
"""
|
||||
end
|
||||
|
||||
def show_modal(js \\ %JS{}, id) when is_binary(id) do
|
||||
js
|
||||
|> JS.exec("showModal()", to: "##{id}")
|
||||
end
|
||||
|
||||
def hide_modal(js \\ %JS{}, id) when is_binary(id) do
|
||||
js
|
||||
|> JS.exec("close()", to: "##{id}")
|
||||
|> JS.pop_focus()
|
||||
end
|
||||
end
|
||||
136
lib/berrypod_web/components/layouts.ex
Normal file
136
lib/berrypod_web/components/layouts.ex
Normal file
@@ -0,0 +1,136 @@
|
||||
defmodule BerrypodWeb.Layouts do
|
||||
@moduledoc """
|
||||
This module holds layouts and related functionality
|
||||
used by your application.
|
||||
"""
|
||||
use BerrypodWeb, :html
|
||||
|
||||
# Embed all files in layouts/* within this module.
|
||||
# The default root.html.heex file contains the HTML
|
||||
# skeleton of your application, namely HTML headers
|
||||
# and other static content.
|
||||
embed_templates "layouts/*"
|
||||
|
||||
@doc """
|
||||
Renders your app layout.
|
||||
|
||||
This function is typically invoked from every template,
|
||||
and it often contains your application menu, sidebar,
|
||||
or similar.
|
||||
|
||||
## Examples
|
||||
|
||||
<Layouts.app flash={@flash}>
|
||||
<h1>Content</h1>
|
||||
</Layouts.app>
|
||||
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
|
||||
attr :current_scope, :map,
|
||||
default: nil,
|
||||
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def app(assigns) do
|
||||
~H"""
|
||||
<main class="px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-lg flex flex-col gap-4">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
end
|
||||
|
||||
@doc false
|
||||
def admin_nav_active?(current_path, "/admin") do
|
||||
if current_path == "/admin", do: "active", else: nil
|
||||
end
|
||||
|
||||
def admin_nav_active?(current_path, link_path) do
|
||||
if String.starts_with?(current_path, link_path), do: "active", else: nil
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows the flash group with standard titles and content.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
||||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id} aria-live="polite">
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:error} flash={@flash} />
|
||||
|
||||
<.flash
|
||||
id="client-error"
|
||||
kind={:error}
|
||||
title={gettext("We can't find the internet")}
|
||||
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
|
||||
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
id="server-error"
|
||||
kind={:error}
|
||||
title={gettext("Something went wrong!")}
|
||||
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
|
||||
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Provides dark vs light theme toggle based on themes defined in admin.css.
|
||||
|
||||
See <head> in root.html.heex which applies the theme before page load.
|
||||
"""
|
||||
def theme_toggle(assigns) do
|
||||
~H"""
|
||||
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
|
||||
<div class="theme-toggle-indicator absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0" />
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="system"
|
||||
>
|
||||
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="light"
|
||||
>
|
||||
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="dark"
|
||||
>
|
||||
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
108
lib/berrypod_web/components/layouts/admin.html.heex
Normal file
108
lib/berrypod_web/components/layouts/admin.html.heex
Normal file
@@ -0,0 +1,108 @@
|
||||
<div class="admin-layout h-full">
|
||||
<input id="admin-drawer" type="checkbox" class="admin-layout-toggle" />
|
||||
|
||||
<%!-- main content area --%>
|
||||
<div class="admin-layout-content">
|
||||
<%!-- mobile header --%>
|
||||
<header class="admin-topbar">
|
||||
<label
|
||||
for="admin-drawer"
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="size-5" />
|
||||
</label>
|
||||
<span class="admin-topbar-title">Berrypod</span>
|
||||
<.link href={~p"/"} class="admin-btn admin-btn-ghost admin-btn-sm">
|
||||
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> Shop
|
||||
</.link>
|
||||
</header>
|
||||
|
||||
<%!-- page content --%>
|
||||
<main class="flex-1 p-4 sm:p-6 lg:p-8">
|
||||
<div class="mx-auto max-w-5xl">
|
||||
{@inner_content}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<%!-- sidebar --%>
|
||||
<div class="admin-sidebar-wrapper">
|
||||
<label for="admin-drawer" class="admin-sidebar-overlay" aria-label="Close navigation"></label>
|
||||
<aside class="admin-sidebar">
|
||||
<%!-- sidebar header --%>
|
||||
<div class="p-4 border-b border-base-300">
|
||||
<.link navigate={~p"/admin"} class="text-lg font-bold tracking-tight">
|
||||
Berrypod
|
||||
</.link>
|
||||
<p class="text-xs text-base-content/60 mt-0.5 truncate">
|
||||
{@current_scope.user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%!-- nav links --%>
|
||||
<nav class="flex-1 p-2" aria-label="Admin navigation">
|
||||
<ul class="admin-nav">
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin"}
|
||||
class={admin_nav_active?(@current_path, "/admin")}
|
||||
>
|
||||
<.icon name="hero-home" class="size-5" /> Dashboard
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/orders"}
|
||||
class={admin_nav_active?(@current_path, "/admin/orders")}
|
||||
>
|
||||
<.icon name="hero-shopping-bag" class="size-5" /> Orders
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/products"}
|
||||
class={admin_nav_active?(@current_path, "/admin/products")}
|
||||
>
|
||||
<.icon name="hero-cube" class="size-5" /> Products
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/admin/theme"}
|
||||
class={admin_nav_active?(@current_path, "/admin/theme")}
|
||||
>
|
||||
<.icon name="hero-paint-brush" class="size-5" /> Theme
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/settings"}
|
||||
class={admin_nav_active?(@current_path, "/admin/settings")}
|
||||
>
|
||||
<.icon name="hero-cog-6-tooth" class="size-5" /> Settings
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<%!-- sidebar footer --%>
|
||||
<div class="p-2 border-t border-base-300">
|
||||
<ul class="admin-nav">
|
||||
<li>
|
||||
<.link href={~p"/"}>
|
||||
<.icon name="hero-arrow-top-right-on-square" class="size-5" /> View shop
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/log-out"} method="delete">
|
||||
<.icon name="hero-arrow-right-start-on-rectangle" class="size-5" /> Log out
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
40
lib/berrypod_web/components/layouts/admin_root.html.heex
Normal file
40
lib/berrypod_web/components/layouts/admin_root.html.heex
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title default="Admin" suffix=" · Berrypod">
|
||||
{assigns[:page_title]}
|
||||
</.live_title>
|
||||
<!-- Pre-declare layer order so shop reset < Tailwind base regardless of load order -->
|
||||
<style>
|
||||
@layer properties, reset, primitives, tokens, theme, base, components, layout, utilities, overrides;
|
||||
</style>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/admin.css"} />
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/shop.css"} />
|
||||
<script defer phx-track-static src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const setTheme = (theme) => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("phx:theme");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
};
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||
}
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-full">
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
36
lib/berrypod_web/components/layouts/root.html.heex
Normal file
36
lib/berrypod_web/components/layouts/root.html.heex
Normal file
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title default="Berrypod" suffix=" · Phoenix Framework">
|
||||
{assigns[:page_title]}
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/admin.css"} />
|
||||
<script defer phx-track-static src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const setTheme = (theme) => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("phx:theme");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
};
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||
}
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
|
||||
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
2
lib/berrypod_web/components/layouts/shop.html.heex
Normal file
2
lib/berrypod_web/components/layouts/shop.html.heex
Normal file
@@ -0,0 +1,2 @@
|
||||
<.shop_flash_group flash={@flash} />
|
||||
{@inner_content}
|
||||
51
lib/berrypod_web/components/layouts/shop_root.html.heex
Normal file
51
lib/berrypod_web/components/layouts/shop_root.html.heex
Normal file
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<meta
|
||||
name="description"
|
||||
content={
|
||||
assigns[:page_description] || @theme_settings.site_description ||
|
||||
"Welcome to #{@theme_settings.site_name}"
|
||||
}
|
||||
/>
|
||||
<.live_title>{assigns[:page_title] || @theme_settings.site_name}</.live_title>
|
||||
<!-- Preload critical fonts for the current typography preset -->
|
||||
<%= for preload <- Berrypod.Theme.Fonts.preload_links(
|
||||
@theme_settings.typography,
|
||||
&BerrypodWeb.Endpoint.static_path/1
|
||||
) do %>
|
||||
<link rel="preload" href={preload.href} as="font" type="font/woff2" crossorigin />
|
||||
<% end %>
|
||||
<!-- Pre-declare layer order so reset < components regardless of load order -->
|
||||
<style>
|
||||
@layer properties, reset, primitives, tokens, theme, base, components, layout, utilities, overrides;
|
||||
</style>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/shop.css"} />
|
||||
<script defer phx-track-static src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
<!-- Generated theme CSS with @font-face declarations -->
|
||||
<style id="theme-css">
|
||||
<%= Phoenix.HTML.raw(@generated_css) %>
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<div
|
||||
class="themed shop-root h-full"
|
||||
data-mood={@theme_settings.mood}
|
||||
data-typography={@theme_settings.typography}
|
||||
data-shape={@theme_settings.shape}
|
||||
data-density={@theme_settings.density}
|
||||
data-grid={@theme_settings.grid_columns}
|
||||
data-header={@theme_settings.header_layout}
|
||||
data-sticky={to_string(@theme_settings.sticky_header)}
|
||||
data-layout={@theme_settings.layout_width}
|
||||
data-shadow={@theme_settings.card_shadow}
|
||||
data-button-style={@theme_settings.button_style}
|
||||
>
|
||||
{@inner_content}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
21
lib/berrypod_web/components/page_templates.ex
Normal file
21
lib/berrypod_web/components/page_templates.ex
Normal file
@@ -0,0 +1,21 @@
|
||||
defmodule BerrypodWeb.PageTemplates do
|
||||
@moduledoc """
|
||||
Shared page templates used by both the public shop and theme preview.
|
||||
|
||||
These templates accept a `mode` parameter to control navigation behavior:
|
||||
- `:shop` - Links navigate normally (real shop pages)
|
||||
- `:preview` - Links send events to parent LiveView (theme editor)
|
||||
|
||||
All templates expect these common assigns:
|
||||
- `theme_settings` - Current theme configuration
|
||||
- `logo_image` - Logo image struct or nil
|
||||
- `header_image` - Header image struct or nil
|
||||
- `mode` - `:shop` or `:preview`
|
||||
- `cart_items` - List of cart items (can be empty)
|
||||
- `cart_count` - Number of items in cart
|
||||
"""
|
||||
use Phoenix.Component
|
||||
use BerrypodWeb.ShopComponents
|
||||
|
||||
embed_templates "page_templates/*"
|
||||
end
|
||||
37
lib/berrypod_web/components/page_templates/cart.html.heex
Normal file
37
lib/berrypod_web/components/page_templates/cart.html.heex
Normal file
@@ -0,0 +1,37 @@
|
||||
<.shop_layout {layout_assigns(assigns)} active_page="cart">
|
||||
<main id="main-content" class="page-container">
|
||||
<.page_title text="Your basket" />
|
||||
|
||||
<%= if @cart_items == [] do %>
|
||||
<.cart_empty_state mode={@mode} />
|
||||
<% else %>
|
||||
<div class="cart-grid">
|
||||
<div>
|
||||
<ul
|
||||
role="list"
|
||||
aria-label="Cart items"
|
||||
class="cart-page-list"
|
||||
>
|
||||
<%= for item <- @cart_items do %>
|
||||
<li>
|
||||
<.shop_card class="cart-page-card">
|
||||
<.cart_item_row item={item} size={:default} show_quantity_controls mode={@mode} />
|
||||
</.shop_card>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<.order_summary
|
||||
subtotal={@cart_page_subtotal}
|
||||
shipping_estimate={assigns[:shipping_estimate]}
|
||||
country_code={assigns[:country_code] || "GB"}
|
||||
available_countries={assigns[:available_countries] || []}
|
||||
mode={@mode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</main>
|
||||
</.shop_layout>
|
||||
@@ -0,0 +1,134 @@
|
||||
<.shop_layout {layout_assigns(assigns)} active_page="checkout">
|
||||
<main id="main-content" class="page-container checkout-main">
|
||||
<%= if @order && @order.payment_status == "paid" do %>
|
||||
<div class="checkout-header">
|
||||
<div class="checkout-icon">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="checkout-heading">
|
||||
Thank you for your order
|
||||
</h1>
|
||||
|
||||
<p class="checkout-meta">
|
||||
Order <strong>{@order.order_number}</strong>
|
||||
</p>
|
||||
|
||||
<%= if @order.customer_email do %>
|
||||
<p class="checkout-meta">
|
||||
A confirmation will be sent to <strong>{@order.customer_email}</strong>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.shop_card class="checkout-card">
|
||||
<h2 class="checkout-heading">
|
||||
Order details
|
||||
</h2>
|
||||
|
||||
<ul class="checkout-items">
|
||||
<%= for item <- @order.items do %>
|
||||
<li class="checkout-item">
|
||||
<div>
|
||||
<p class="checkout-item-name">
|
||||
{item.product_name}
|
||||
</p>
|
||||
<%= if item.variant_title do %>
|
||||
<p class="checkout-item-detail">
|
||||
{item.variant_title}
|
||||
</p>
|
||||
<% end %>
|
||||
<p class="checkout-item-detail">
|
||||
Qty: {item.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<span class="checkout-item-price">
|
||||
{Berrypod.Cart.format_price(item.unit_price * item.quantity)}
|
||||
</span>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<div class="checkout-total-border">
|
||||
<div class="checkout-total">
|
||||
<span class="checkout-total-label">Total</span>
|
||||
<span class="checkout-total-amount">
|
||||
{Berrypod.Cart.format_price(@order.total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</.shop_card>
|
||||
|
||||
<%= if @order.shipping_address != %{} do %>
|
||||
<.shop_card class="checkout-card">
|
||||
<h2 class="checkout-heading">
|
||||
Shipping to
|
||||
</h2>
|
||||
<div class="checkout-shipping-address">
|
||||
<p>{@order.shipping_address["name"]}</p>
|
||||
<p>{@order.shipping_address["line1"]}</p>
|
||||
<%= if @order.shipping_address["line2"] do %>
|
||||
<p>{@order.shipping_address["line2"]}</p>
|
||||
<% end %>
|
||||
<p>
|
||||
{@order.shipping_address["city"]}, {@order.shipping_address["postal_code"]}
|
||||
</p>
|
||||
<p>{@order.shipping_address["country"]}</p>
|
||||
</div>
|
||||
</.shop_card>
|
||||
<% end %>
|
||||
|
||||
<div class="checkout-actions">
|
||||
<.shop_link_button href="/collections/all" class="checkout-cta">
|
||||
Continue shopping
|
||||
</.shop_link_button>
|
||||
</div>
|
||||
<% else %>
|
||||
<%!-- Payment pending or order not found --%>
|
||||
<div class="checkout-header">
|
||||
<div class="checkout-pending-icon">
|
||||
<span class="checkout-pending-spinner">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="checkout-heading">
|
||||
Processing your payment
|
||||
</h1>
|
||||
|
||||
<p class="checkout-pending-text">
|
||||
Please wait while we confirm your payment. This usually takes a few seconds.
|
||||
</p>
|
||||
|
||||
<p class="checkout-pending-hint">
|
||||
If this page doesn't update, please <.link
|
||||
navigate="/contact"
|
||||
class="checkout-contact-link"
|
||||
>contact us</.link>.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</main>
|
||||
</.shop_layout>
|
||||
@@ -0,0 +1,21 @@
|
||||
<.shop_layout {layout_assigns(assigns)} active_page="collection">
|
||||
<main id="main-content">
|
||||
<.collection_header title="All Products" product_count={length(assigns[:products] || [])} />
|
||||
|
||||
<div class="page-container">
|
||||
<.filter_bar categories={assigns[:categories] || []} />
|
||||
|
||||
<.product_grid theme_settings={@theme_settings}>
|
||||
<%= for product <- assigns[:products] || [] do %>
|
||||
<.product_card
|
||||
product={product}
|
||||
theme_settings={@theme_settings}
|
||||
mode={@mode}
|
||||
variant={:default}
|
||||
show_category={true}
|
||||
/>
|
||||
<% end %>
|
||||
</.product_grid>
|
||||
</div>
|
||||
</main>
|
||||
</.shop_layout>
|
||||
30
lib/berrypod_web/components/page_templates/contact.html.heex
Normal file
30
lib/berrypod_web/components/page_templates/contact.html.heex
Normal file
@@ -0,0 +1,30 @@
|
||||
<.shop_layout {layout_assigns(assigns)} active_page="contact">
|
||||
<main id="main-content" class="page-container contact-main">
|
||||
<.hero_section
|
||||
variant={:page}
|
||||
title="Get in touch"
|
||||
description="Sample contact page for the demo store. Add your own message here – something friendly about how customers can reach you."
|
||||
/>
|
||||
|
||||
<div class="contact-grid">
|
||||
<.contact_form email="hello@example.com" />
|
||||
|
||||
<div class="contact-sidebar">
|
||||
<.order_tracking_card />
|
||||
|
||||
<.info_card
|
||||
title="Handy to know"
|
||||
items={[
|
||||
%{label: "Printing", value: "Example: 2-5 business days"},
|
||||
%{label: "Delivery", value: "Example: 3-7 business days after printing"},
|
||||
%{label: "Issues", value: "Example: Reprints for any defects"}
|
||||
]}
|
||||
/>
|
||||
|
||||
<.newsletter_card />
|
||||
|
||||
<.social_links_card />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</.shop_layout>
|
||||
33
lib/berrypod_web/components/page_templates/content.html.heex
Normal file
33
lib/berrypod_web/components/page_templates/content.html.heex
Normal file
@@ -0,0 +1,33 @@
|
||||
<.shop_layout {layout_assigns(assigns)}>
|
||||
<main id="main-content" class="content-page">
|
||||
<%= if assigns[:hero_background] do %>
|
||||
<.hero_section
|
||||
title={@hero_title}
|
||||
description={@hero_description}
|
||||
background={@hero_background}
|
||||
/>
|
||||
<% else %>
|
||||
<.hero_section
|
||||
variant={:page}
|
||||
title={@hero_title}
|
||||
description={@hero_description}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
<div class="content-body">
|
||||
<%= if assigns[:image_src] do %>
|
||||
<div class="content-image">
|
||||
<.responsive_image
|
||||
src={@image_src}
|
||||
source_width={1200}
|
||||
alt={@image_alt}
|
||||
sizes="(max-width: 800px) 100vw, 800px"
|
||||
class="content-hero-image"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<.rich_text blocks={@content_blocks} />
|
||||
</div>
|
||||
</main>
|
||||
</.shop_layout>
|
||||
33
lib/berrypod_web/components/page_templates/error.html.heex
Normal file
33
lib/berrypod_web/components/page_templates/error.html.heex
Normal file
@@ -0,0 +1,33 @@
|
||||
<.shop_layout {layout_assigns(assigns)} active_page="error" error_page>
|
||||
<main
|
||||
id="main-content"
|
||||
class="error-main"
|
||||
>
|
||||
<div class="page-container error-container">
|
||||
<.hero_section
|
||||
variant={:error}
|
||||
pre_title={@error_code}
|
||||
title={@error_title}
|
||||
description={@error_description}
|
||||
cta_text="Go to Homepage"
|
||||
cta_page="home"
|
||||
cta_href="/"
|
||||
secondary_cta_text="Browse Products"
|
||||
secondary_cta_page="collection"
|
||||
secondary_cta_href="/collections/all"
|
||||
mode={@mode}
|
||||
/>
|
||||
|
||||
<.product_grid columns={:fixed_4}>
|
||||
<%= for product <- Enum.take(assigns[:products] || [], 4) do %>
|
||||
<.product_card
|
||||
product={product}
|
||||
theme_settings={@theme_settings}
|
||||
mode={@mode}
|
||||
variant={:minimal}
|
||||
/>
|
||||
<% end %>
|
||||
</.product_grid>
|
||||
</div>
|
||||
</main>
|
||||
</.shop_layout>
|
||||
31
lib/berrypod_web/components/page_templates/home.html.heex
Normal file
31
lib/berrypod_web/components/page_templates/home.html.heex
Normal file
@@ -0,0 +1,31 @@
|
||||
<.shop_layout {layout_assigns(assigns)} active_page="home">
|
||||
<main id="main-content">
|
||||
<.hero_section
|
||||
title="Original designs, printed on demand"
|
||||
description="Welcome to the Berrypod demo store. This is where your hero text goes – something short and punchy about what makes your shop worth a browse."
|
||||
cta_text="Shop the collection"
|
||||
cta_page="collection"
|
||||
cta_href="/collections/all"
|
||||
mode={@mode}
|
||||
/>
|
||||
|
||||
<.category_nav categories={assigns[:categories] || []} mode={@mode} />
|
||||
|
||||
<.featured_products_section
|
||||
title="Featured products"
|
||||
products={assigns[:products] || []}
|
||||
theme_settings={@theme_settings}
|
||||
mode={@mode}
|
||||
/>
|
||||
|
||||
<.image_text_section
|
||||
title="Made with passion, printed with care"
|
||||
description="This is an example content section. Use it to share your story, highlight what makes your products special, or link to your about page."
|
||||
image_url="/mockups/mountain-sunrise-print-3-800.webp"
|
||||
link_text="Learn more about the studio →"
|
||||
link_page="about"
|
||||
link_href="/about"
|
||||
mode={@mode}
|
||||
/>
|
||||
</main>
|
||||
</.shop_layout>
|
||||
67
lib/berrypod_web/components/page_templates/pdp.html.heex
Normal file
67
lib/berrypod_web/components/page_templates/pdp.html.heex
Normal file
@@ -0,0 +1,67 @@
|
||||
<.shop_layout {layout_assigns(assigns)} active_page="pdp">
|
||||
<main id="main-content" class="page-container">
|
||||
<.breadcrumb
|
||||
items={
|
||||
if @product.category do
|
||||
[
|
||||
%{
|
||||
label: @product.category,
|
||||
page: "collection",
|
||||
href:
|
||||
"/collections/#{@product.category |> String.downcase() |> String.replace(" ", "-")}"
|
||||
}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end ++
|
||||
[%{label: @product.title, current: true}]
|
||||
}
|
||||
mode={@mode}
|
||||
/>
|
||||
|
||||
<div class="pdp-grid">
|
||||
<.product_gallery images={@gallery_images} product_name={@product.title} />
|
||||
|
||||
<div>
|
||||
<.product_info product={@product} display_price={@display_price} />
|
||||
|
||||
<%!-- Dynamic variant selectors --%>
|
||||
<%= for option_type <- @option_types do %>
|
||||
<.variant_selector
|
||||
option_type={option_type}
|
||||
selected={@selected_options[option_type.name]}
|
||||
available={@available_options[option_type.name] || []}
|
||||
mode={@mode}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
<%!-- Fallback for products with no variant options --%>
|
||||
<div
|
||||
:if={@option_types == []}
|
||||
class="pdp-variant-fallback"
|
||||
>
|
||||
One size
|
||||
</div>
|
||||
|
||||
<.quantity_selector quantity={@quantity} in_stock={@product.in_stock} />
|
||||
<.add_to_cart_button mode={@mode} />
|
||||
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
|
||||
<.product_details product={@product} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.reviews_section
|
||||
:if={@theme_settings.pdp_reviews}
|
||||
reviews={Berrypod.Theme.PreviewData.reviews()}
|
||||
average_rating={5}
|
||||
total_count={24}
|
||||
/>
|
||||
|
||||
<.related_products_section
|
||||
:if={@theme_settings.pdp_related_products}
|
||||
products={@related_products}
|
||||
theme_settings={@theme_settings}
|
||||
mode={@mode}
|
||||
/>
|
||||
</main>
|
||||
</.shop_layout>
|
||||
23
lib/berrypod_web/components/shop_components.ex
Normal file
23
lib/berrypod_web/components/shop_components.ex
Normal file
@@ -0,0 +1,23 @@
|
||||
defmodule BerrypodWeb.ShopComponents do
|
||||
@moduledoc """
|
||||
Facade module for shop/storefront UI components.
|
||||
|
||||
`use BerrypodWeb.ShopComponents` imports all sub-modules:
|
||||
|
||||
- `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
|
||||
"""
|
||||
|
||||
defmacro __using__(_opts \\ []) do
|
||||
quote do
|
||||
import BerrypodWeb.ShopComponents.Base
|
||||
import BerrypodWeb.ShopComponents.Cart
|
||||
import BerrypodWeb.ShopComponents.Content
|
||||
import BerrypodWeb.ShopComponents.Layout
|
||||
import BerrypodWeb.ShopComponents.Product
|
||||
end
|
||||
end
|
||||
end
|
||||
243
lib/berrypod_web/components/shop_components/base.ex
Normal file
243
lib/berrypod_web/components/shop_components/base.ex
Normal file
@@ -0,0 +1,243 @@
|
||||
defmodule BerrypodWeb.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"""
|
||||
<input type={@type} class={["themed-input", @class]} {@rest} />
|
||||
"""
|
||||
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"""
|
||||
<textarea class={["themed-input", @class]} {@rest}></textarea>
|
||||
"""
|
||||
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"""
|
||||
<select class={["themed-select", @class]} {@rest}>
|
||||
<%= for option <- @options do %>
|
||||
<%= case option do %>
|
||||
<% {value, label} -> %>
|
||||
<option value={value} selected={@selected == value}>{label}</option>
|
||||
<% label -> %>
|
||||
<option selected={@selected == label}>{label}</option>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</select>
|
||||
"""
|
||||
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>
|
||||
<.shop_button type="submit" class="w-full">Subscribe</.shop_button>
|
||||
"""
|
||||
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"""
|
||||
<button type={@type} class={["themed-button", @class]} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
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</.shop_button_outline>
|
||||
"""
|
||||
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"""
|
||||
<button type={@type} class={["themed-button-outline", @class]} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
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</.shop_link_button>
|
||||
"""
|
||||
attr :href, :string, required: true
|
||||
attr :class, :string, default: nil
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def shop_link_button(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
navigate={@href}
|
||||
class={["themed-button", @class]}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
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</.shop_link_outline>
|
||||
"""
|
||||
attr :href, :string, required: true
|
||||
attr :class, :string, default: nil
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def shop_link_outline(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
navigate={@href}
|
||||
class={["themed-button-outline", @class]}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
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">
|
||||
<h3>Card Title</h3>
|
||||
<p>Card content...</p>
|
||||
</.shop_card>
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def shop_card(assigns) do
|
||||
~H"""
|
||||
<div class={["themed-card", @class]}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
539
lib/berrypod_web/components/shop_components/cart.ex
Normal file
539
lib/berrypod_web/components/shop_components/cart.ex
Normal file
@@ -0,0 +1,539 @@
|
||||
defmodule BerrypodWeb.ShopComponents.Cart do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
import BerrypodWeb.ShopComponents.Base
|
||||
|
||||
alias Berrypod.Products.{Product, ProductImage}
|
||||
|
||||
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 :total, :string, default: nil
|
||||
attr :cart_count, :integer, default: 0
|
||||
attr :cart_status, :string, default: nil
|
||||
attr :mode, :atom, default: :live
|
||||
attr :open, :boolean, default: false
|
||||
attr :shipping_estimate, :integer, default: nil
|
||||
attr :country_code, :string, default: "GB"
|
||||
attr :available_countries, :list, default: []
|
||||
|
||||
def cart_drawer(assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :display_total, fn ->
|
||||
assigns.total || assigns.subtotal || "£0.00"
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<%!-- Screen reader announcements for cart changes --%>
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||
{@cart_status}
|
||||
</div>
|
||||
|
||||
<!-- Cart Drawer Overlay -->
|
||||
<div
|
||||
id="cart-drawer-overlay"
|
||||
class={["cart-drawer-overlay", @open && "open"]}
|
||||
aria-hidden={to_string(!@open)}
|
||||
phx-click={close_cart_drawer_js()}
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Cart Drawer -->
|
||||
<div
|
||||
id="cart-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cart-drawer-title"
|
||||
aria-describedby="cart-drawer-description"
|
||||
aria-hidden={to_string(!@open)}
|
||||
phx-hook="CartDrawer"
|
||||
class={["cart-drawer", @open && "open"]}
|
||||
>
|
||||
<p id="cart-drawer-description" class="sr-only">
|
||||
Shopping basket with {@cart_count} {if @cart_count == 1, do: "item", else: "items"}. Press Escape to close.
|
||||
</p>
|
||||
<div class="cart-drawer-header">
|
||||
<h2
|
||||
id="cart-drawer-title"
|
||||
class="cart-drawer-title"
|
||||
>
|
||||
Your basket
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="cart-drawer-close"
|
||||
phx-click={close_cart_drawer_js()}
|
||||
aria-label="Close cart"
|
||||
>
|
||||
<svg
|
||||
class="cart-drawer-close-icon"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cart-drawer-items">
|
||||
<%= if @cart_items == [] do %>
|
||||
<.cart_empty_state mode={@mode} />
|
||||
<% else %>
|
||||
<ul role="list" aria-label="Cart items">
|
||||
<%= for item <- @cart_items do %>
|
||||
<li>
|
||||
<.cart_item_row item={item} size={:compact} show_quantity_controls mode={@mode} />
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="cart-drawer-footer">
|
||||
<.delivery_line
|
||||
shipping_estimate={@shipping_estimate}
|
||||
country_code={@country_code}
|
||||
available_countries={@available_countries}
|
||||
mode={@mode}
|
||||
/>
|
||||
<div class="cart-drawer-total">
|
||||
<span>{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}</span>
|
||||
<span>{@display_total}</span>
|
||||
</div>
|
||||
<%= if @mode == :preview do %>
|
||||
<button
|
||||
type="button"
|
||||
class="cart-drawer-checkout"
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
<% else %>
|
||||
<form action="/checkout" method="post">
|
||||
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||
<button
|
||||
type="submit"
|
||||
class="cart-drawer-checkout"
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<div
|
||||
class="cart-item-row"
|
||||
data-size={if @size == :compact, do: "compact"}
|
||||
>
|
||||
<%= if @mode != :preview do %>
|
||||
<.link
|
||||
navigate={"/products/#{@item.product_id}"}
|
||||
class={["cart-item-image", !@item.image && "cart-item-image--empty"]}
|
||||
data-size={if @size == :compact, do: "compact"}
|
||||
style={if @item.image, do: "background-image: url('#{@item.image}');"}
|
||||
aria-label={"View #{@item.name}"}
|
||||
>
|
||||
</.link>
|
||||
<% else %>
|
||||
<div
|
||||
class={["cart-item-image", !@item.image && "cart-item-image--empty"]}
|
||||
data-size={if @size == :compact, do: "compact"}
|
||||
style={if @item.image, do: "background-image: url('#{@item.image}');"}
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="cart-item-details">
|
||||
<h3 class="cart-item-name" data-size={if @size == :compact, do: "compact"}>
|
||||
<%= if @mode != :preview do %>
|
||||
<.link
|
||||
navigate={"/products/#{@item.product_id}"}
|
||||
class="cart-item-name-link"
|
||||
>
|
||||
{@item.name}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span>{@item.name}</span>
|
||||
<% end %>
|
||||
</h3>
|
||||
<%= if @item.variant do %>
|
||||
<p class="cart-item-variant-text">
|
||||
{@item.variant}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<div class="cart-item-actions">
|
||||
<%= if @show_quantity_controls do %>
|
||||
<div class="cart-qty-group">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="decrement"
|
||||
phx-value-id={@item.variant_id}
|
||||
class="cart-qty-btn"
|
||||
aria-label={"Decrease quantity of #{@item.name}"}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="cart-qty-display">
|
||||
{@item.quantity}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="increment"
|
||||
phx-value-id={@item.variant_id}
|
||||
class="cart-qty-btn"
|
||||
aria-label={"Increase quantity of #{@item.name}"}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="cart-qty-text">
|
||||
Qty: {@item.quantity}
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<.cart_remove_button variant_id={@item.variant_id} item_name={@item.name} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cart-item-price-col">
|
||||
<p class="cart-item-price" data-size={if @size == :compact, do: "compact"}>
|
||||
{Berrypod.Cart.format_price(@item.price * @item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cart empty state component.
|
||||
"""
|
||||
attr :mode, :atom, default: :live
|
||||
|
||||
def cart_empty_state(assigns) do
|
||||
~H"""
|
||||
<div class="cart-empty">
|
||||
<svg
|
||||
class="cart-empty-icon"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"></path>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<path d="M16 10a4 4 0 01-8 0"></path>
|
||||
</svg>
|
||||
<p>Your basket is empty</p>
|
||||
<%= if @mode == :preview do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="collection"
|
||||
class="cart-continue-link"
|
||||
>
|
||||
Continue shopping
|
||||
</button>
|
||||
<% else %>
|
||||
<.link
|
||||
navigate="/collections/all"
|
||||
class="cart-continue-link"
|
||||
>
|
||||
Continue shopping
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_item"
|
||||
phx-value-id={@variant_id}
|
||||
class="cart-remove-btn"
|
||||
aria-label={"Remove #{@item_name} from cart"}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
"""
|
||||
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="cart-page-item">
|
||||
<div class="cart-page-image">
|
||||
<img
|
||||
src={cart_item_image(@item.product)}
|
||||
alt={@item.product.title}
|
||||
width="96"
|
||||
height="96"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="cart-page-item-info">
|
||||
<h3 class="cart-page-item-name">
|
||||
{@item.product.title}
|
||||
</h3>
|
||||
<p class="cart-page-item-variant">
|
||||
{@item.variant}
|
||||
</p>
|
||||
|
||||
<div class="cart-page-item-actions">
|
||||
<div class="cart-qty-group">
|
||||
<button class="cart-qty-btn">−</button>
|
||||
<span class="cart-qty-display">
|
||||
{@item.quantity}
|
||||
</span>
|
||||
<button class="cart-qty-btn">+</button>
|
||||
</div>
|
||||
|
||||
<button class="cart-page-item-remove">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cart-page-item-price-col">
|
||||
<p class="cart-page-item-price">
|
||||
{Berrypod.Cart.format_price(@item.product.cheapest_price * @item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
</.shop_card>
|
||||
"""
|
||||
end
|
||||
|
||||
defp cart_item_image(product) do
|
||||
ProductImage.url(Product.primary_image(product), 400)
|
||||
end
|
||||
|
||||
# Shared delivery line used by both cart_drawer and order_summary.
|
||||
# Shows a country <select> when rates are available, falls back to plain text.
|
||||
attr :shipping_estimate, :integer, default: nil
|
||||
attr :country_code, :string, default: "GB"
|
||||
attr :available_countries, :list, default: []
|
||||
attr :mode, :atom, default: :live
|
||||
|
||||
defp delivery_line(assigns) do
|
||||
~H"""
|
||||
<div class="delivery-line">
|
||||
<span class="delivery-line-label">
|
||||
Delivery to
|
||||
<%= if @available_countries != [] and @mode != :preview do %>
|
||||
<form phx-change="change_country">
|
||||
<select
|
||||
name="country"
|
||||
class="delivery-select"
|
||||
aria-label="Delivery country"
|
||||
>
|
||||
<%= for {code, name} <- @available_countries do %>
|
||||
<option value={code} selected={code == @country_code}>{name}</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
<% else %>
|
||||
<span>{Berrypod.Shipping.country_name(@country_code)}</span>
|
||||
<% end %>
|
||||
</span>
|
||||
<%= if @shipping_estimate do %>
|
||||
<span>{Berrypod.Cart.format_price(@shipping_estimate)}</span>
|
||||
<% else %>
|
||||
<span>Calculated at checkout</span>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the order summary card.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `subtotal` - Required. Subtotal amount (in pence/cents).
|
||||
* `shipping_estimate` - Optional. Shipping estimate in pence.
|
||||
* `country_code` - Optional. Current country code. Default "GB".
|
||||
* `available_countries` - Optional. List of `{code, name}` tuples.
|
||||
* `mode` - Either `:live` (default) or `:preview`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.order_summary subtotal={3600} />
|
||||
"""
|
||||
attr :subtotal, :integer, required: true
|
||||
attr :shipping_estimate, :integer, default: nil
|
||||
attr :country_code, :string, default: "GB"
|
||||
attr :available_countries, :list, default: []
|
||||
attr :mode, :atom, default: :live
|
||||
|
||||
def order_summary(assigns) do
|
||||
assigns =
|
||||
assign(assigns, :estimated_total, assigns.subtotal + (assigns.shipping_estimate || 0))
|
||||
|
||||
~H"""
|
||||
<.shop_card class="order-summary-card">
|
||||
<h2 class="order-summary-heading">
|
||||
Order summary
|
||||
</h2>
|
||||
|
||||
<div class="order-summary-lines">
|
||||
<div class="order-summary-line">
|
||||
<span>Subtotal</span>
|
||||
<span>
|
||||
{Berrypod.Cart.format_price(@subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
<.delivery_line
|
||||
shipping_estimate={@shipping_estimate}
|
||||
country_code={@country_code}
|
||||
available_countries={@available_countries}
|
||||
mode={@mode}
|
||||
/>
|
||||
<div class="order-summary-divider">
|
||||
<div class="order-summary-total">
|
||||
<span>
|
||||
{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}
|
||||
</span>
|
||||
<span>
|
||||
{Berrypod.Cart.format_price(@estimated_total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @mode == :preview do %>
|
||||
<.shop_button class="order-summary-checkout">
|
||||
Checkout
|
||||
</.shop_button>
|
||||
<.shop_button_outline
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="collection"
|
||||
class="order-summary-continue"
|
||||
>
|
||||
Continue shopping
|
||||
</.shop_button_outline>
|
||||
<% else %>
|
||||
<form action="/checkout" method="post" class="order-summary-checkout-form">
|
||||
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||
<.shop_button type="submit" class="order-summary-checkout">
|
||||
Checkout
|
||||
</.shop_button>
|
||||
</form>
|
||||
<.shop_link_outline
|
||||
href="/collections/all"
|
||||
class="order-summary-continue"
|
||||
>
|
||||
Continue shopping
|
||||
</.shop_link_outline>
|
||||
<% end %>
|
||||
</.shop_card>
|
||||
"""
|
||||
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"""
|
||||
<div class="cart-layout">
|
||||
<div class="cart-items-stack">
|
||||
<%= for item <- @items do %>
|
||||
<.cart_item item={item} />
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<.order_summary subtotal={@subtotal} mode={@mode} />
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
1083
lib/berrypod_web/components/shop_components/content.ex
Normal file
1083
lib/berrypod_web/components/shop_components/content.ex
Normal file
File diff suppressed because it is too large
Load Diff
939
lib/berrypod_web/components/shop_components/layout.ex
Normal file
939
lib/berrypod_web/components/shop_components/layout.ex
Normal file
@@ -0,0 +1,939 @@
|
||||
defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
use Phoenix.Component
|
||||
|
||||
import BerrypodWeb.ShopComponents.Cart
|
||||
import BerrypodWeb.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"""
|
||||
<div class="announcement-bar">
|
||||
<p>{@message}</p>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<a href="#main-content" class="skip-link">
|
||||
Skip to main content
|
||||
</a>
|
||||
"""
|
||||
end
|
||||
|
||||
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates
|
||||
# can spread assigns without listing each one explicitly.
|
||||
@layout_keys ~w(theme_settings logo_image header_image mode cart_items cart_count
|
||||
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
|
||||
search_query search_results search_open categories shipping_estimate
|
||||
country_code available_countries)a
|
||||
|
||||
@doc """
|
||||
Extracts the assigns relevant to `shop_layout` from a full assigns map.
|
||||
|
||||
Page templates can use this instead of listing every attr explicitly:
|
||||
|
||||
<.shop_layout {layout_assigns(assigns)} active_page="home">
|
||||
...
|
||||
</.shop_layout>
|
||||
"""
|
||||
def layout_assigns(assigns) do
|
||||
Map.take(assigns, @layout_keys)
|
||||
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 `<main>` 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_total, :string, default: nil
|
||||
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
|
||||
attr :is_admin, :boolean, default: false
|
||||
attr :search_query, :string, default: ""
|
||||
attr :search_results, :list, default: []
|
||||
attr :search_open, :boolean, default: false
|
||||
attr :shipping_estimate, :integer, default: nil
|
||||
attr :country_code, :string, default: "GB"
|
||||
attr :available_countries, :list, default: []
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def shop_layout(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={unless @error_page, do: "shop-container"}
|
||||
phx-hook={unless @error_page, do: "CartPersist"}
|
||||
class="shop-container"
|
||||
data-bottom-nav={!@error_page || nil}
|
||||
>
|
||||
<.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}
|
||||
is_admin={@is_admin}
|
||||
/>
|
||||
|
||||
{render_slot(@inner_block)}
|
||||
|
||||
<.shop_footer
|
||||
theme_settings={@theme_settings}
|
||||
mode={@mode}
|
||||
categories={assigns[:categories] || []}
|
||||
/>
|
||||
|
||||
<.cart_drawer
|
||||
cart_items={@cart_items}
|
||||
subtotal={@cart_subtotal}
|
||||
total={@cart_total}
|
||||
cart_count={@cart_count}
|
||||
mode={@mode}
|
||||
open={@cart_drawer_open}
|
||||
cart_status={@cart_status}
|
||||
shipping_estimate={@shipping_estimate}
|
||||
country_code={@country_code}
|
||||
available_countries={@available_countries}
|
||||
/>
|
||||
|
||||
<.search_modal
|
||||
hint_text={~s(Try a search – e.g. "mountain" or "notebook")}
|
||||
search_query={@search_query}
|
||||
search_results={@search_results}
|
||||
search_open={@search_open}
|
||||
/>
|
||||
|
||||
<.mobile_bottom_nav :if={!@error_page} active_page={@active_page} mode={@mode} />
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<nav
|
||||
class="mobile-bottom-nav"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<ul>
|
||||
<.mobile_nav_item
|
||||
icon={:home}
|
||||
label="Home"
|
||||
page="home"
|
||||
href="/"
|
||||
active_page={@active_page}
|
||||
mode={@mode}
|
||||
/>
|
||||
<.mobile_nav_item
|
||||
icon={:shop}
|
||||
label="Shop"
|
||||
page="collection"
|
||||
href="/collections/all"
|
||||
active_page={@active_page}
|
||||
active_pages={["collection", "pdp"]}
|
||||
mode={@mode}
|
||||
/>
|
||||
<.mobile_nav_item
|
||||
icon={:about}
|
||||
label="About"
|
||||
page="about"
|
||||
href="/about"
|
||||
active_page={@active_page}
|
||||
mode={@mode}
|
||||
/>
|
||||
<.mobile_nav_item
|
||||
icon={:contact}
|
||||
label="Contact"
|
||||
page="contact"
|
||||
href="/contact"
|
||||
active_page={@active_page}
|
||||
mode={@mode}
|
||||
/>
|
||||
</ul>
|
||||
</nav>
|
||||
"""
|
||||
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"""
|
||||
<li>
|
||||
<%= if @mode == :preview do %>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page={@page}
|
||||
class="mobile-nav-link"
|
||||
aria-current={if @is_current, do: "page", else: nil}
|
||||
>
|
||||
<.nav_icon icon={@icon} />
|
||||
<span>{@label}</span>
|
||||
</a>
|
||||
<% else %>
|
||||
<.link
|
||||
navigate={@href}
|
||||
class="mobile-nav-link"
|
||||
aria-current={if @is_current, do: "page", else: nil}
|
||||
>
|
||||
<.nav_icon icon={@icon} />
|
||||
<span>{@label}</span>
|
||||
</.link>
|
||||
<% end %>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
defp nav_icon(%{icon: :home} = assigns) do
|
||||
~H"""
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"></path>
|
||||
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
defp nav_icon(%{icon: :shop} = assigns) do
|
||||
~H"""
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
defp nav_icon(%{icon: :about} = assigns) do
|
||||
~H"""
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
defp nav_icon(%{icon: :contact} = assigns) do
|
||||
~H"""
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
|
||||
<polyline points="22,6 12,13 2,6"></polyline>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the search modal overlay with live search results.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `hint_text` - Hint text shown when no query is entered.
|
||||
* `search_query` - Current search query string.
|
||||
* `search_results` - List of Product structs matching the query.
|
||||
"""
|
||||
attr :hint_text, :string, default: nil
|
||||
attr :search_query, :string, default: ""
|
||||
attr :search_results, :list, default: []
|
||||
attr :search_open, :boolean, default: false
|
||||
|
||||
def search_modal(assigns) do
|
||||
alias Berrypod.Cart
|
||||
alias Berrypod.Products.{Product, ProductImage}
|
||||
|
||||
assigns =
|
||||
assign(
|
||||
assigns,
|
||||
:results_with_images,
|
||||
assigns.search_results
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {product, idx} ->
|
||||
image = Product.primary_image(product)
|
||||
%{product: product, image_url: ProductImage.url(image, 400), idx: idx}
|
||||
end)
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id="search-modal"
|
||||
class="search-modal"
|
||||
style={"display: #{if @search_open, do: "flex", else: "none"};"}
|
||||
phx-hook="SearchModal"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
|
||||
>
|
||||
<div
|
||||
class="search-panel"
|
||||
onclick="event.stopPropagation()"
|
||||
>
|
||||
<div class="search-bar">
|
||||
<svg
|
||||
class="search-icon"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="M21 21l-4.35-4.35"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
name="query"
|
||||
class="search-input"
|
||||
placeholder="Search products..."
|
||||
value={@search_query}
|
||||
phx-keyup="search"
|
||||
phx-debounce="150"
|
||||
autocomplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={to_string(@search_results != [])}
|
||||
aria-controls="search-results-list"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<div
|
||||
class="search-kbd"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<kbd>⌘</kbd><kbd>K</kbd>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="search-close"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
|
||||
aria-label="Close search"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search-results">
|
||||
<%= cond do %>
|
||||
<% @search_results != [] -> %>
|
||||
<ul id="search-results-list" role="listbox" aria-label="Search results">
|
||||
<li
|
||||
:for={item <- @results_with_images}
|
||||
id={"search-result-#{item.idx}"}
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
>
|
||||
<.link
|
||||
navigate={"/products/#{item.product.slug || item.product.id}"}
|
||||
class="search-result"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
|
||||
>
|
||||
<div
|
||||
:if={item.image_url}
|
||||
class="search-result-thumb"
|
||||
>
|
||||
<img
|
||||
src={item.image_url}
|
||||
alt={item.product.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-result-details">
|
||||
<p class="search-result-title">
|
||||
{item.product.title}
|
||||
</p>
|
||||
<p class="search-result-meta">
|
||||
{item.product.category}
|
||||
<span>
|
||||
{Cart.format_price(item.product.cheapest_price)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
<% String.length(@search_query) >= 2 -> %>
|
||||
<div class="search-hint">
|
||||
<p>No products found for "{@search_query}"</p>
|
||||
</div>
|
||||
<% @hint_text != nil -> %>
|
||||
<div class="search-hint">
|
||||
<p>{@hint_text}</p>
|
||||
</div>
|
||||
<% true -> %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
attr :categories, :list, default: []
|
||||
|
||||
def shop_footer(assigns) do
|
||||
assigns = assign(assigns, :current_year, Date.utc_today().year)
|
||||
|
||||
~H"""
|
||||
<footer class="shop-footer">
|
||||
<div class="shop-footer-inner">
|
||||
<div class="footer-grid">
|
||||
<.newsletter_card variant={:inline} />
|
||||
|
||||
<div class="footer-links">
|
||||
<div>
|
||||
<h4 class="footer-heading">
|
||||
Shop
|
||||
</h4>
|
||||
<ul class="footer-nav">
|
||||
<%= if @mode == :preview do %>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="collection"
|
||||
class="footer-link"
|
||||
>
|
||||
All products
|
||||
</a>
|
||||
</li>
|
||||
<%= for category <- @categories do %>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="collection"
|
||||
class="footer-link"
|
||||
>
|
||||
{category.name}
|
||||
</a>
|
||||
</li>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<li>
|
||||
<.link
|
||||
navigate="/collections/all"
|
||||
class="footer-link"
|
||||
>
|
||||
All products
|
||||
</.link>
|
||||
</li>
|
||||
<%= for category <- @categories do %>
|
||||
<li>
|
||||
<.link
|
||||
navigate={"/collections/#{category.slug}"}
|
||||
class="footer-link"
|
||||
>
|
||||
{category.name}
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="footer-heading">
|
||||
Help
|
||||
</h4>
|
||||
<ul class="footer-nav">
|
||||
<%= if @mode == :preview do %>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="delivery"
|
||||
class="footer-link"
|
||||
>
|
||||
Delivery & returns
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="privacy"
|
||||
class="footer-link"
|
||||
>
|
||||
Privacy policy
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="terms"
|
||||
class="footer-link"
|
||||
>
|
||||
Terms of service
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="contact"
|
||||
class="footer-link"
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
</li>
|
||||
<% else %>
|
||||
<li>
|
||||
<.link
|
||||
navigate="/delivery"
|
||||
class="footer-link"
|
||||
>
|
||||
Delivery & returns
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate="/privacy"
|
||||
class="footer-link"
|
||||
>
|
||||
Privacy policy
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate="/terms"
|
||||
class="footer-link"
|
||||
>
|
||||
Terms of service
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate="/contact"
|
||||
class="footer-link"
|
||||
>
|
||||
Contact
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="footer-bottom">
|
||||
<p class="footer-copyright">
|
||||
© {@current_year} {@theme_settings.site_name}
|
||||
</p>
|
||||
<.social_links />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
"""
|
||||
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
|
||||
attr :is_admin, :boolean, default: false
|
||||
|
||||
def shop_header(assigns) do
|
||||
~H"""
|
||||
<header class="shop-header">
|
||||
<%= if @theme_settings.header_background_enabled && @header_image do %>
|
||||
<div style={header_background_style(@theme_settings, @header_image)} />
|
||||
<% end %>
|
||||
|
||||
<div class="shop-logo">
|
||||
<.logo_content
|
||||
theme_settings={@theme_settings}
|
||||
logo_image={@logo_image}
|
||||
active_page={@active_page}
|
||||
mode={@mode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav class="shop-nav">
|
||||
<%= if @mode == :preview do %>
|
||||
<.nav_item label="Home" page="home" active_page={@active_page} mode={:preview} />
|
||||
<.nav_item
|
||||
label="Shop"
|
||||
page="collection"
|
||||
active_page={@active_page}
|
||||
mode={:preview}
|
||||
active_pages={["collection", "pdp"]}
|
||||
/>
|
||||
<.nav_item label="About" page="about" active_page={@active_page} mode={:preview} />
|
||||
<.nav_item label="Contact" page="contact" active_page={@active_page} mode={:preview} />
|
||||
<% else %>
|
||||
<.nav_item label="Home" href="/" active_page={@active_page} page="home" />
|
||||
<.nav_item
|
||||
label="Shop"
|
||||
href="/collections/all"
|
||||
active_page={@active_page}
|
||||
page="collection"
|
||||
active_pages={["collection", "pdp"]}
|
||||
/>
|
||||
<.nav_item label="About" href="/about" active_page={@active_page} page="about" />
|
||||
<.nav_item label="Contact" href="/contact" active_page={@active_page} page="contact" />
|
||||
<% end %>
|
||||
</nav>
|
||||
|
||||
<div class="shop-actions">
|
||||
<.link
|
||||
:if={@is_admin}
|
||||
href="/admin"
|
||||
class="header-icon-btn"
|
||||
aria-label="Admin"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</.link>
|
||||
<button
|
||||
type="button"
|
||||
class="header-icon-btn"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("open-search", to: "#search-modal")}
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="M21 21l-4.35-4.35"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="header-icon-btn"
|
||||
phx-click={open_cart_drawer_js()}
|
||||
aria-label="Cart"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"></path>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<path d="M16 10a4 4 0 01-8 0"></path>
|
||||
</svg>
|
||||
<%= if @cart_count > 0 do %>
|
||||
<span class="cart-badge">
|
||||
{@cart_count}
|
||||
</span>
|
||||
<% end %>
|
||||
<span class="sr-only">Cart ({@cart_count})</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
"""
|
||||
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: "/image_cache/#{logo_image.id}.webp"
|
||||
|
||||
# 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 %>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="home"
|
||||
class="shop-logo-link"
|
||||
>
|
||||
<.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} />
|
||||
</a>
|
||||
<% else %>
|
||||
<.link navigate="/" class="shop-logo-link">
|
||||
<.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} />
|
||||
</.link>
|
||||
<% 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" -> %>
|
||||
<span class="shop-logo-text">
|
||||
{@theme_settings.site_name}
|
||||
</span>
|
||||
<% "logo-text" -> %>
|
||||
<%= if @logo_image do %>
|
||||
<img
|
||||
src={logo_url(@logo_image, @theme_settings)}
|
||||
alt={@theme_settings.site_name}
|
||||
class="shop-logo-img"
|
||||
style={"height: #{@theme_settings.logo_size}px;"}
|
||||
/>
|
||||
<% end %>
|
||||
<span class="shop-logo-text">
|
||||
{@theme_settings.site_name}
|
||||
</span>
|
||||
<% "logo-only" -> %>
|
||||
<%= if @logo_image do %>
|
||||
<img
|
||||
src={logo_url(@logo_image, @theme_settings)}
|
||||
alt={@theme_settings.site_name}
|
||||
class="shop-logo-img"
|
||||
style={"height: #{@theme_settings.logo_size}px;"}
|
||||
/>
|
||||
<% else %>
|
||||
<span class="shop-logo-text">
|
||||
{@theme_settings.site_name}
|
||||
</span>
|
||||
<% end %>
|
||||
<% _ -> %>
|
||||
<span class="shop-logo-text">
|
||||
{@theme_settings.site_name}
|
||||
</span>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp header_background_style(settings, header_image) do
|
||||
"position: absolute; top: 0; left: 0; right: 0; bottom: 0; " <>
|
||||
"background-image: url('/image_cache/#{header_image.id}.webp'); " <>
|
||||
"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 %>
|
||||
<span class="nav-link" aria-current="page">
|
||||
{@label}
|
||||
</span>
|
||||
<% else %>
|
||||
<%= if @mode == :preview do %>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page={@page}
|
||||
class="nav-link"
|
||||
>
|
||||
{@label}
|
||||
</a>
|
||||
<% else %>
|
||||
<.link navigate={@href} class="nav-link">
|
||||
{@label}
|
||||
</.link>
|
||||
<% end %>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp open_cart_drawer_js do
|
||||
Phoenix.LiveView.JS.push("open_cart_drawer")
|
||||
end
|
||||
end
|
||||
1652
lib/berrypod_web/components/shop_components/product.ex
Normal file
1652
lib/berrypod_web/components/shop_components/product.ex
Normal file
File diff suppressed because it is too large
Load Diff
7
lib/berrypod_web/controllers/admin_controller.ex
Normal file
7
lib/berrypod_web/controllers/admin_controller.ex
Normal file
@@ -0,0 +1,7 @@
|
||||
defmodule BerrypodWeb.AdminController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
def index(conn, _params) do
|
||||
redirect(conn, to: ~p"/admin/orders")
|
||||
end
|
||||
end
|
||||
31
lib/berrypod_web/controllers/cart_controller.ex
Normal file
31
lib/berrypod_web/controllers/cart_controller.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule BerrypodWeb.CartController do
|
||||
@moduledoc """
|
||||
API controller for cart session persistence.
|
||||
|
||||
LiveView cannot write to session directly, so cart updates are persisted
|
||||
via this API endpoint called from a JS hook after each cart modification.
|
||||
"""
|
||||
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Cart
|
||||
|
||||
@doc """
|
||||
Updates the cart in session.
|
||||
|
||||
Expects JSON body with `items` as a list of [variant_id, quantity] arrays.
|
||||
"""
|
||||
def update(conn, %{"items" => items}) when is_list(items) do
|
||||
cart_items = Cart.deserialize(items)
|
||||
|
||||
conn
|
||||
|> Cart.put_in_session(cart_items)
|
||||
|> json(%{ok: true})
|
||||
end
|
||||
|
||||
def update(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Invalid cart data"})
|
||||
end
|
||||
end
|
||||
129
lib/berrypod_web/controllers/checkout_controller.ex
Normal file
129
lib/berrypod_web/controllers/checkout_controller.ex
Normal file
@@ -0,0 +1,129 @@
|
||||
defmodule BerrypodWeb.CheckoutController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Cart
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Shipping
|
||||
|
||||
require Logger
|
||||
|
||||
def create(conn, _params) do
|
||||
cart_items = Cart.get_from_session(get_session(conn))
|
||||
hydrated = Cart.hydrate(cart_items)
|
||||
|
||||
if hydrated == [] do
|
||||
conn
|
||||
|> put_flash(:error, "Your basket is empty")
|
||||
|> redirect(to: ~p"/cart")
|
||||
else
|
||||
create_checkout(conn, hydrated)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_checkout(conn, hydrated_items) do
|
||||
# Create a pending order with price snapshots
|
||||
case Orders.create_order(%{items: hydrated_items}) do
|
||||
{:ok, order} ->
|
||||
create_stripe_session(conn, order, hydrated_items)
|
||||
|
||||
{:error, _changeset} ->
|
||||
Logger.error("Failed to create order")
|
||||
|
||||
conn
|
||||
|> put_flash(:error, "Something went wrong. Please try again.")
|
||||
|> redirect(to: ~p"/cart")
|
||||
end
|
||||
end
|
||||
|
||||
defp create_stripe_session(conn, order, hydrated_items) do
|
||||
line_items =
|
||||
Enum.map(hydrated_items, fn item ->
|
||||
product_name =
|
||||
if item.variant,
|
||||
do: "#{item.name} — #{item.variant}",
|
||||
else: item.name
|
||||
|
||||
%{
|
||||
price_data: %{
|
||||
currency: "gbp",
|
||||
unit_amount: item.price,
|
||||
product_data: %{name: product_name}
|
||||
},
|
||||
quantity: item.quantity
|
||||
}
|
||||
end)
|
||||
|
||||
base_url = BerrypodWeb.Endpoint.url()
|
||||
|
||||
params =
|
||||
%{
|
||||
mode: "payment",
|
||||
line_items: line_items,
|
||||
success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: "#{base_url}/cart",
|
||||
metadata: %{"order_id" => order.id},
|
||||
shipping_address_collection: %{
|
||||
allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"]
|
||||
}
|
||||
}
|
||||
|> maybe_add_shipping_options(hydrated_items)
|
||||
|
||||
case Stripe.Checkout.Session.create(params) do
|
||||
{:ok, session} ->
|
||||
{:ok, _order} = Orders.set_stripe_session(order, session.id)
|
||||
|
||||
conn
|
||||
|> redirect(external: session.url)
|
||||
|
||||
{:error, %Stripe.Error{message: message}} ->
|
||||
Logger.error("Stripe session creation failed: #{message}")
|
||||
Orders.mark_failed(order)
|
||||
|
||||
conn
|
||||
|> put_flash(:error, "Payment setup failed. Please try again.")
|
||||
|> redirect(to: ~p"/cart")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Stripe session creation failed: #{inspect(reason)}")
|
||||
Orders.mark_failed(order)
|
||||
|
||||
conn
|
||||
|> put_flash(:error, "Payment setup failed. Please try again.")
|
||||
|> redirect(to: ~p"/cart")
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_shipping_options(params, hydrated_items) do
|
||||
gb_result = Shipping.calculate_for_cart(hydrated_items, "GB")
|
||||
us_result = Shipping.calculate_for_cart(hydrated_items, "US")
|
||||
|
||||
options =
|
||||
[]
|
||||
|> maybe_add_option(gb_result, "UK delivery", 5, 10)
|
||||
|> maybe_add_option(us_result, "International delivery", 10, 20)
|
||||
|
||||
if options == [] do
|
||||
params
|
||||
else
|
||||
Map.put(params, :shipping_options, options)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_option(options, {:ok, cost}, name, min_days, max_days) when cost > 0 do
|
||||
option = %{
|
||||
shipping_rate_data: %{
|
||||
type: "fixed_amount",
|
||||
display_name: name,
|
||||
fixed_amount: %{amount: cost, currency: "gbp"},
|
||||
delivery_estimate: %{
|
||||
minimum: %{unit: "business_day", value: min_days},
|
||||
maximum: %{unit: "business_day", value: max_days}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
options ++ [option]
|
||||
end
|
||||
|
||||
defp maybe_add_option(options, _result, _name, _min, _max), do: options
|
||||
end
|
||||
135
lib/berrypod_web/controllers/error_html.ex
Normal file
135
lib/berrypod_web/controllers/error_html.ex
Normal file
@@ -0,0 +1,135 @@
|
||||
defmodule BerrypodWeb.ErrorHTML do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on HTML requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
use BerrypodWeb, :html
|
||||
|
||||
alias Berrypod.Settings
|
||||
alias Berrypod.Settings.ThemeSettings
|
||||
alias Berrypod.Media
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Theme.{CSSCache, CSSGenerator}
|
||||
|
||||
def render("404.html", assigns) do
|
||||
render_error_page(
|
||||
assigns,
|
||||
"404",
|
||||
"Page Not Found",
|
||||
"Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved."
|
||||
)
|
||||
end
|
||||
|
||||
def render("500.html", assigns) do
|
||||
render_error_page(
|
||||
assigns,
|
||||
"500",
|
||||
"Server Error",
|
||||
"Something went wrong on our end. Please try again later or contact support if the problem persists."
|
||||
)
|
||||
end
|
||||
|
||||
def render(template, _assigns) do
|
||||
Phoenix.Controller.status_message_from_template(template)
|
||||
end
|
||||
|
||||
defp render_error_page(assigns, error_code, error_title, error_description) do
|
||||
# Load theme settings with fallback for error conditions
|
||||
{theme_settings, generated_css} = load_theme_data()
|
||||
logo_image = safe_load(&Media.get_logo/0)
|
||||
header_image = safe_load(&Media.get_header/0)
|
||||
|
||||
products = safe_load(fn -> Products.list_visible_products(limit: 4) end) || []
|
||||
categories = safe_load(fn -> Products.list_categories() end) || []
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> Map.put(:theme_settings, theme_settings)
|
||||
|> Map.put(:generated_css, generated_css)
|
||||
|> Map.put(:logo_image, logo_image)
|
||||
|> Map.put(:header_image, header_image)
|
||||
|> Map.put(:products, products)
|
||||
|> Map.put(:categories, categories)
|
||||
|> Map.put(:error_code, error_code)
|
||||
|> Map.put(:error_title, error_title)
|
||||
|> Map.put(:error_description, error_description)
|
||||
|> Map.put(:mode, :shop)
|
||||
|> Map.put(:cart_items, [])
|
||||
|> Map.put(:cart_count, 0)
|
||||
|> Map.put(:cart_subtotal, "£0.00")
|
||||
|
||||
~H"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{@error_code} - {@error_title}</title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/admin.css"} />
|
||||
<style id="theme-css">
|
||||
<%= Phoenix.HTML.raw(@generated_css) %>
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<div
|
||||
class="shop-root themed h-full"
|
||||
data-mood={@theme_settings.mood}
|
||||
data-typography={@theme_settings.typography}
|
||||
data-shape={@theme_settings.shape}
|
||||
data-density={@theme_settings.density}
|
||||
data-grid={@theme_settings.grid_columns}
|
||||
data-header={@theme_settings.header_layout}
|
||||
data-sticky={to_string(@theme_settings.sticky_header)}
|
||||
data-layout={@theme_settings.layout_width}
|
||||
data-shadow={@theme_settings.card_shadow}
|
||||
>
|
||||
<BerrypodWeb.PageTemplates.error
|
||||
theme_settings={@theme_settings}
|
||||
logo_image={@logo_image}
|
||||
header_image={@header_image}
|
||||
products={@products}
|
||||
categories={@categories}
|
||||
error_code={@error_code}
|
||||
error_title={@error_title}
|
||||
error_description={@error_description}
|
||||
mode={@mode}
|
||||
cart_items={@cart_items}
|
||||
cart_count={@cart_count}
|
||||
cart_subtotal={@cart_subtotal}
|
||||
/>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
end
|
||||
|
||||
defp load_theme_data do
|
||||
try do
|
||||
theme_settings = Settings.get_theme_settings()
|
||||
|
||||
generated_css =
|
||||
case CSSCache.get() do
|
||||
{:ok, css} ->
|
||||
css
|
||||
|
||||
:miss ->
|
||||
css = CSSGenerator.generate(theme_settings)
|
||||
CSSCache.put(css)
|
||||
css
|
||||
end
|
||||
|
||||
{theme_settings, generated_css}
|
||||
rescue
|
||||
_ -> {%ThemeSettings{}, ""}
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_load(fun) do
|
||||
try do
|
||||
fun.()
|
||||
rescue
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
21
lib/berrypod_web/controllers/error_json.ex
Normal file
21
lib/berrypod_web/controllers/error_json.ex
Normal file
@@ -0,0 +1,21 @@
|
||||
defmodule BerrypodWeb.ErrorJSON do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
|
||||
# If you want to customize a particular status code,
|
||||
# you may add your own clauses, such as:
|
||||
#
|
||||
# def render("500.json", _assigns) do
|
||||
# %{errors: %{detail: "Internal Server Error"}}
|
||||
# end
|
||||
|
||||
# By default, Phoenix returns the status message from
|
||||
# the template name. For example, "404.json" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||
end
|
||||
end
|
||||
20
lib/berrypod_web/controllers/error_preview_controller.ex
Normal file
20
lib/berrypod_web/controllers/error_preview_controller.ex
Normal file
@@ -0,0 +1,20 @@
|
||||
defmodule BerrypodWeb.ErrorPreviewController do
|
||||
@moduledoc """
|
||||
Development-only controller for previewing error pages.
|
||||
"""
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
def not_found(conn, _params) do
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> put_view(BerrypodWeb.ErrorHTML)
|
||||
|> render("404.html")
|
||||
end
|
||||
|
||||
def server_error(conn, _params) do
|
||||
conn
|
||||
|> put_status(:internal_server_error)
|
||||
|> put_view(BerrypodWeb.ErrorHTML)
|
||||
|> render("500.html")
|
||||
end
|
||||
end
|
||||
7
lib/berrypod_web/controllers/health_controller.ex
Normal file
7
lib/berrypod_web/controllers/health_controller.ex
Normal file
@@ -0,0 +1,7 @@
|
||||
defmodule BerrypodWeb.HealthController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
def show(conn, _params) do
|
||||
json(conn, %{status: "ok"})
|
||||
end
|
||||
end
|
||||
49
lib/berrypod_web/controllers/image_controller.ex
Normal file
49
lib/berrypod_web/controllers/image_controller.ex
Normal file
@@ -0,0 +1,49 @@
|
||||
defmodule BerrypodWeb.ImageController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Media
|
||||
alias Berrypod.Media.SVGRecolorer
|
||||
|
||||
@doc """
|
||||
Serves an SVG image recolored with the specified color.
|
||||
|
||||
The color should be a hex color code (with or without the leading #).
|
||||
Only works with SVG images.
|
||||
"""
|
||||
def recolored_svg(conn, %{"id" => id, "color" => color}) do
|
||||
clean_color = normalize_color(color)
|
||||
|
||||
with true <- SVGRecolorer.valid_hex_color?(clean_color),
|
||||
%{is_svg: true, svg_content: svg} when not is_nil(svg) <- Media.get_image(id) do
|
||||
recolored = SVGRecolorer.recolor(svg, clean_color)
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("image/svg+xml")
|
||||
|> put_resp_header("cache-control", "public, max-age=3600")
|
||||
|> put_resp_header("etag", ~s("#{id}-#{clean_color}"))
|
||||
|> send_resp(200, recolored)
|
||||
else
|
||||
false ->
|
||||
send_resp(conn, 400, "Invalid color format")
|
||||
|
||||
nil ->
|
||||
send_resp(conn, 404, "Image not found")
|
||||
|
||||
%{is_svg: false} ->
|
||||
send_resp(conn, 400, "Image is not an SVG")
|
||||
|
||||
%{svg_content: nil} ->
|
||||
send_resp(conn, 400, "SVG content not available")
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_color(color) do
|
||||
color = String.trim(color)
|
||||
|
||||
if String.starts_with?(color, "#") do
|
||||
color
|
||||
else
|
||||
"#" <> color
|
||||
end
|
||||
end
|
||||
end
|
||||
7
lib/berrypod_web/controllers/page_controller.ex
Normal file
7
lib/berrypod_web/controllers/page_controller.ex
Normal file
@@ -0,0 +1,7 @@
|
||||
defmodule BerrypodWeb.PageController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
def home(conn, _params) do
|
||||
render(conn, :home)
|
||||
end
|
||||
end
|
||||
10
lib/berrypod_web/controllers/page_html.ex
Normal file
10
lib/berrypod_web/controllers/page_html.ex
Normal file
@@ -0,0 +1,10 @@
|
||||
defmodule BerrypodWeb.PageHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by PageController.
|
||||
|
||||
See the `page_html` directory for all templates available.
|
||||
"""
|
||||
use BerrypodWeb, :html
|
||||
|
||||
embed_templates "page_html/*"
|
||||
end
|
||||
202
lib/berrypod_web/controllers/page_html/home.html.heex
Normal file
202
lib/berrypod_web/controllers/page_html/home.html.heex
Normal file
@@ -0,0 +1,202 @@
|
||||
<Layouts.flash_group flash={@flash} />
|
||||
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
|
||||
<svg
|
||||
viewBox="0 0 1480 957"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
preserveAspectRatio="xMinYMid slice"
|
||||
>
|
||||
<path fill="#EE7868" d="M0 0h1480v957H0z" />
|
||||
<path
|
||||
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
|
||||
fill="#FF9F92"
|
||||
/>
|
||||
<path
|
||||
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
|
||||
fill="#FA8372"
|
||||
/>
|
||||
<path
|
||||
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
|
||||
fill="#E96856"
|
||||
fill-opacity=".6"
|
||||
/>
|
||||
<path
|
||||
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
|
||||
fill="#C42652"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
|
||||
fill="#A41C42"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
|
||||
fill="#A41C42"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
|
||||
<div class="mx-auto max-w-xl lg:mx-0">
|
||||
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
|
||||
<path
|
||||
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
|
||||
fill="#FD4F00"
|
||||
/>
|
||||
</svg>
|
||||
<div class="mt-10 flex justify-between items-center">
|
||||
<h1 class="flex items-center text-sm font-semibold leading-6">
|
||||
Phoenix Framework
|
||||
<small class="badge badge-warning badge-sm ml-3">
|
||||
v{Application.spec(:phoenix, :vsn)}
|
||||
</small>
|
||||
</h1>
|
||||
<Layouts.theme_toggle />
|
||||
</div>
|
||||
|
||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
|
||||
Peace of mind from prototype to production.
|
||||
</p>
|
||||
<p class="mt-4 leading-7 text-base-content/70">
|
||||
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
|
||||
</p>
|
||||
<div class="flex">
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://hexdocs.pm/phoenix/overview.html"
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
|
||||
<path
|
||||
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Guides & Docs
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/phoenixframework/phoenix"
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
|
||||
/>
|
||||
</svg>
|
||||
Source Code
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
d="M12 1v6M12 17v6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="4"
|
||||
fill="currentColor"
|
||||
fill-opacity=".15"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Changelog
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
|
||||
<div>
|
||||
<a
|
||||
href="https://elixirforum.com"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
|
||||
</svg>
|
||||
Discuss on the Elixir Forum
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://discord.gg/elixir"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
|
||||
</svg>
|
||||
Join our Discord server
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://elixir-slack.community/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
|
||||
</svg>
|
||||
Join us on Slack
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://fly.io/docs/elixir/getting-started/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
|
||||
</svg>
|
||||
Deploy your application
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
132
lib/berrypod_web/controllers/stripe_webhook_controller.ex
Normal file
132
lib/berrypod_web/controllers/stripe_webhook_controller.ex
Normal file
@@ -0,0 +1,132 @@
|
||||
defmodule BerrypodWeb.StripeWebhookController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Orders.{OrderNotifier, OrderSubmissionWorker}
|
||||
|
||||
require Logger
|
||||
|
||||
def handle(conn, _params) do
|
||||
raw_body = conn.assigns[:raw_body] || ""
|
||||
signature = List.first(get_req_header(conn, "stripe-signature")) || ""
|
||||
signing_secret = Application.get_env(:stripity_stripe, :signing_secret) || ""
|
||||
|
||||
case Stripe.Webhook.construct_event(raw_body, signature, signing_secret) do
|
||||
{:ok, %Stripe.Event{} = event} ->
|
||||
handle_event(event)
|
||||
json(conn, %{received: true})
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Stripe webhook verification failed: #{inspect(reason)}")
|
||||
|
||||
conn
|
||||
|> put_status(401)
|
||||
|> json(%{error: "Invalid signature"})
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_event(%Stripe.Event{type: "checkout.session.completed", data: %{object: session}}) do
|
||||
order_id = get_in(session, [:metadata, "order_id"]) || session.metadata["order_id"]
|
||||
|
||||
case Orders.get_order(order_id) do
|
||||
nil ->
|
||||
Logger.warning("Stripe webhook: order not found for id=#{order_id}")
|
||||
|
||||
order ->
|
||||
payment_intent_id = session.payment_intent
|
||||
{:ok, order} = Orders.mark_paid(order, payment_intent_id)
|
||||
|
||||
# Update shipping cost from Stripe (if shipping options were presented)
|
||||
order = update_shipping_cost(order, session)
|
||||
|
||||
# Update shipping address if collected by Stripe
|
||||
order =
|
||||
if session.shipping_details do
|
||||
{:ok, updated} = update_shipping(order, session.shipping_details)
|
||||
updated
|
||||
else
|
||||
order
|
||||
end
|
||||
|
||||
# Update customer email from Stripe session
|
||||
order =
|
||||
if session.customer_details && session.customer_details.email do
|
||||
{:ok, updated} =
|
||||
Orders.update_order(order, %{customer_email: session.customer_details.email})
|
||||
|
||||
updated
|
||||
else
|
||||
order
|
||||
end
|
||||
|
||||
# Reload items for the email (update_order doesn't preload)
|
||||
order = Orders.get_order(order.id)
|
||||
|
||||
# Broadcast to success page via PubSub
|
||||
Phoenix.PubSub.broadcast(
|
||||
Berrypod.PubSub,
|
||||
"order:#{order.id}:status",
|
||||
{:order_paid, order}
|
||||
)
|
||||
|
||||
OrderNotifier.deliver_order_confirmation(order)
|
||||
|
||||
# Submit to fulfilment provider
|
||||
if order.shipping_address && order.shipping_address != %{} do
|
||||
OrderSubmissionWorker.enqueue(order.id)
|
||||
else
|
||||
Logger.warning(
|
||||
"Order #{order.order_number} paid but no shipping address — manual submit needed"
|
||||
)
|
||||
end
|
||||
|
||||
Logger.info("Order #{order.order_number} marked as paid")
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_event(%Stripe.Event{type: "checkout.session.expired", data: %{object: session}}) do
|
||||
order_id = get_in(session, [:metadata, "order_id"]) || session.metadata["order_id"]
|
||||
|
||||
case Orders.get_order(order_id) do
|
||||
nil -> :ok
|
||||
order -> Orders.mark_failed(order)
|
||||
end
|
||||
|
||||
Logger.info("Stripe checkout session expired for order #{order_id}")
|
||||
end
|
||||
|
||||
defp handle_event(%Stripe.Event{type: type}) do
|
||||
Logger.debug("Unhandled Stripe event: #{type}")
|
||||
end
|
||||
|
||||
defp update_shipping(order, shipping_details) do
|
||||
address = shipping_details.address || %{}
|
||||
|
||||
shipping_address = %{
|
||||
"name" => shipping_details.name,
|
||||
"line1" => address.line1,
|
||||
"line2" => address.line2,
|
||||
"city" => address.city,
|
||||
"postal_code" => address.postal_code,
|
||||
"state" => address.state,
|
||||
"country" => address.country
|
||||
}
|
||||
|
||||
Orders.update_order(order, %{shipping_address: shipping_address})
|
||||
end
|
||||
|
||||
defp update_shipping_cost(order, session) do
|
||||
shipping_amount = get_in(session, [Access.key(:shipping_cost), Access.key(:amount_total)])
|
||||
|
||||
if is_integer(shipping_amount) and shipping_amount > 0 do
|
||||
new_total = order.subtotal + shipping_amount
|
||||
|
||||
case Orders.update_order(order, %{shipping_cost: shipping_amount, total: new_total}) do
|
||||
{:ok, updated} -> updated
|
||||
{:error, _} -> order
|
||||
end
|
||||
else
|
||||
order
|
||||
end
|
||||
end
|
||||
end
|
||||
67
lib/berrypod_web/controllers/user_session_controller.ex
Normal file
67
lib/berrypod_web/controllers/user_session_controller.ex
Normal file
@@ -0,0 +1,67 @@
|
||||
defmodule BerrypodWeb.UserSessionController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Accounts
|
||||
alias BerrypodWeb.UserAuth
|
||||
|
||||
def create(conn, %{"_action" => "confirmed"} = params) do
|
||||
create(conn, params, "User confirmed successfully.")
|
||||
end
|
||||
|
||||
def create(conn, params) do
|
||||
create(conn, params, "Welcome back!")
|
||||
end
|
||||
|
||||
# magic link login
|
||||
defp create(conn, %{"user" => %{"token" => token} = user_params}, info) do
|
||||
case Accounts.login_user_by_magic_link(token) do
|
||||
{:ok, {user, tokens_to_disconnect}} ->
|
||||
UserAuth.disconnect_sessions(tokens_to_disconnect)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_flash(:error, "The link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
# email + password login
|
||||
defp create(conn, %{"user" => user_params}, info) do
|
||||
%{"email" => email, "password" => password} = user_params
|
||||
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
else
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||
conn
|
||||
|> put_flash(:error, "Invalid email or password")
|
||||
|> put_flash(:email, String.slice(email, 0, 160))
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
def update_password(conn, %{"user" => user_params} = params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
true = Accounts.sudo_mode?(user)
|
||||
{:ok, {_user, expired_tokens}} = Accounts.update_user_password(user, user_params)
|
||||
|
||||
# disconnect all existing LiveViews with old sessions
|
||||
UserAuth.disconnect_sessions(expired_tokens)
|
||||
|
||||
conn
|
||||
|> put_session(:user_return_to, ~p"/admin/settings")
|
||||
|> create(params, "Password updated successfully!")
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> UserAuth.log_out_user()
|
||||
end
|
||||
end
|
||||
66
lib/berrypod_web/controllers/webhook_controller.ex
Normal file
66
lib/berrypod_web/controllers/webhook_controller.ex
Normal file
@@ -0,0 +1,66 @@
|
||||
defmodule BerrypodWeb.WebhookController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Webhooks
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Receives Printify webhook events.
|
||||
|
||||
Events:
|
||||
- product:publish:started - Product publish initiated
|
||||
- product:updated - Product was modified
|
||||
- product:deleted - Product was deleted
|
||||
- shop:disconnected - Shop was disconnected
|
||||
"""
|
||||
def printify(conn, params) do
|
||||
event_type = params["type"] || params["event"]
|
||||
resource = params["resource"] || params["data"] || %{}
|
||||
|
||||
Logger.info("Received Printify webhook: #{event_type}")
|
||||
|
||||
case Webhooks.handle_printify_event(event_type, resource) do
|
||||
:ok ->
|
||||
json(conn, %{status: "ok"})
|
||||
|
||||
{:ok, _} ->
|
||||
json(conn, %{status: "ok"})
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Webhook handling failed: #{inspect(reason)}")
|
||||
# Still return 200 to prevent Printify retrying
|
||||
json(conn, %{status: "ok"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Receives Printful webhook events.
|
||||
|
||||
Events:
|
||||
- package_shipped - Package has been shipped
|
||||
- order_failed - Order processing failed
|
||||
- order_canceled - Order was canceled
|
||||
- product_updated - Sync product was updated
|
||||
- product_deleted - Sync product was deleted
|
||||
"""
|
||||
def printful(conn, params) do
|
||||
event_type = params["type"]
|
||||
data = params["data"] || %{}
|
||||
|
||||
Logger.info("Received Printful webhook: #{event_type}")
|
||||
|
||||
case Webhooks.handle_printful_event(event_type, data) do
|
||||
:ok ->
|
||||
json(conn, %{status: "ok"})
|
||||
|
||||
{:ok, _} ->
|
||||
json(conn, %{status: "ok"})
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Printful webhook handling failed: #{inspect(reason)}")
|
||||
# Return 200 to prevent Printful retrying
|
||||
json(conn, %{status: "ok"})
|
||||
end
|
||||
end
|
||||
end
|
||||
64
lib/berrypod_web/endpoint.ex
Normal file
64
lib/berrypod_web/endpoint.ex
Normal file
@@ -0,0 +1,64 @@
|
||||
defmodule BerrypodWeb.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :berrypod
|
||||
|
||||
# The session will be stored in the cookie and signed,
|
||||
# this means its contents can be read but not tampered with.
|
||||
# Set :encryption_salt if you would also like to encrypt it.
|
||||
@session_options [
|
||||
store: :cookie,
|
||||
key: "_berrypod_key",
|
||||
signing_salt: "JNwRcD7y",
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket,
|
||||
websocket: [connect_info: [session: @session_options]],
|
||||
longpoll: [connect_info: [session: @session_options]]
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
# gzip is always enabled — Plug.Static serves .gz files when they exist
|
||||
# (created by `mix phx.digest`), falls back to uncompressed otherwise.
|
||||
# In prod, digested filenames change per deploy so immutable caching is safe.
|
||||
# In dev, we omit the option to get the Plug.Static default ("public").
|
||||
plug Plug.Static,
|
||||
at: "/",
|
||||
from: :berrypod,
|
||||
gzip: true,
|
||||
only: BerrypodWeb.static_paths(),
|
||||
cache_control_for_etags:
|
||||
if(Application.compile_env(:berrypod, :env) == :prod,
|
||||
do: "public, max-age=31536000, immutable",
|
||||
else: "public"
|
||||
)
|
||||
|
||||
if Code.ensure_loaded?(Tidewave) do
|
||||
plug Tidewave, allow_remote_access: true
|
||||
end
|
||||
|
||||
# Code reloading can be explicitly enabled under the
|
||||
# :code_reloader configuration of your endpoint.
|
||||
if code_reloading? do
|
||||
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||
plug Phoenix.LiveReloader
|
||||
plug Phoenix.CodeReloader
|
||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :berrypod
|
||||
end
|
||||
|
||||
plug Phoenix.LiveDashboard.RequestLogger,
|
||||
param_key: "request_logger",
|
||||
cookie_key: "request_logger"
|
||||
|
||||
plug Plug.RequestId
|
||||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||
|
||||
plug Plug.Parsers,
|
||||
parsers: [:urlencoded, :multipart, :json],
|
||||
pass: ["*/*"],
|
||||
body_reader: {BerrypodWeb.Plugs.CacheRawBody, :read_body, []},
|
||||
json_decoder: Phoenix.json_library()
|
||||
|
||||
plug Plug.MethodOverride
|
||||
plug Plug.Head
|
||||
plug Plug.Session, @session_options
|
||||
plug BerrypodWeb.Router
|
||||
end
|
||||
25
lib/berrypod_web/gettext.ex
Normal file
25
lib/berrypod_web/gettext.ex
Normal file
@@ -0,0 +1,25 @@
|
||||
defmodule BerrypodWeb.Gettext do
|
||||
@moduledoc """
|
||||
A module providing Internationalization with a gettext-based API.
|
||||
|
||||
By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
|
||||
that you can use in your application. To use this Gettext backend module,
|
||||
call `use Gettext` and pass it as an option:
|
||||
|
||||
use Gettext, backend: BerrypodWeb.Gettext
|
||||
|
||||
# Simple translation
|
||||
gettext("Here is the string to translate")
|
||||
|
||||
# Plural translation
|
||||
ngettext("Here is the string to translate",
|
||||
"Here are the strings to translate",
|
||||
3)
|
||||
|
||||
# Domain-based translation
|
||||
dgettext("errors", "Here is the error message to translate")
|
||||
|
||||
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||
"""
|
||||
use Gettext.Backend, otp_app: :berrypod
|
||||
end
|
||||
787
lib/berrypod_web/live/admin/dashboard.ex
Normal file
787
lib/berrypod_web/live/admin/dashboard.ex
Normal file
@@ -0,0 +1,787 @@
|
||||
defmodule BerrypodWeb.Admin.Dashboard do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.{Cart, Orders, Products, Settings, Setup}
|
||||
alias Berrypod.Products.ProviderConnection
|
||||
alias Berrypod.Providers
|
||||
alias Berrypod.Stripe.Setup, as: StripeSetup
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
status = Setup.setup_status()
|
||||
status_counts = Orders.count_orders_by_status()
|
||||
paid_count = Map.get(status_counts, "paid", 0)
|
||||
recent_orders = Orders.list_orders(status: "paid") |> Enum.take(5)
|
||||
|
||||
conn = Products.get_provider_connection_by_type("printify")
|
||||
|
||||
if conn && connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Berrypod.PubSub, "sync:#{conn.id}")
|
||||
end
|
||||
|
||||
active_step = determine_active_step(status)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Dashboard")
|
||||
|> assign(:setup, status)
|
||||
|> assign(:active_step, active_step)
|
||||
# Printify state
|
||||
|> assign(:printify_conn, conn)
|
||||
|> assign(:printify_form, to_form(%{"api_key" => ""}, as: :printify))
|
||||
|> assign(:printify_testing, false)
|
||||
|> assign(:printify_test_result, nil)
|
||||
|> assign(:printify_saving, false)
|
||||
|> assign(:pending_api_key, nil)
|
||||
|> assign(:sync_status, conn && conn.sync_status)
|
||||
# Stripe state
|
||||
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|
||||
|> assign(:stripe_connecting, false)
|
||||
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
|
||||
# Celebration
|
||||
|> assign(:just_went_live, false)
|
||||
# Stats
|
||||
|> assign(:paid_count, paid_count)
|
||||
|> assign(:revenue, Orders.total_revenue())
|
||||
|> assign(:recent_orders, recent_orders)}
|
||||
end
|
||||
|
||||
# -- Step determination --
|
||||
|
||||
defp determine_active_step(status) do
|
||||
cond do
|
||||
!status.printify_connected -> :printify
|
||||
!status.products_synced -> :printify
|
||||
!status.stripe_connected -> :stripe
|
||||
!status.site_live -> :go_live
|
||||
true -> :complete
|
||||
end
|
||||
end
|
||||
|
||||
# -- Events: Printify --
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_printify", %{"printify" => params}, socket) do
|
||||
{:noreply, assign(socket, pending_api_key: params["api_key"])}
|
||||
end
|
||||
|
||||
def handle_event("test_printify", _params, socket) do
|
||||
api_key = socket.assigns.pending_api_key
|
||||
|
||||
if api_key in [nil, ""] do
|
||||
{:noreply, assign(socket, printify_test_result: {:error, :no_api_key})}
|
||||
else
|
||||
socket = assign(socket, printify_testing: true, printify_test_result: nil)
|
||||
|
||||
temp_conn = %ProviderConnection{
|
||||
provider_type: "printify",
|
||||
api_key_encrypted: encrypt_api_key(api_key)
|
||||
}
|
||||
|
||||
result = Providers.test_connection(temp_conn)
|
||||
|
||||
{:noreply, assign(socket, printify_testing: false, printify_test_result: result)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("connect_printify", %{"printify" => %{"api_key" => api_key}}, socket) do
|
||||
if api_key == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please enter your Printify API token")}
|
||||
else
|
||||
socket = assign(socket, printify_saving: true)
|
||||
|
||||
params =
|
||||
%{"api_key" => api_key, "provider_type" => "printify"}
|
||||
|> maybe_add_shop_config(socket.assigns.printify_test_result)
|
||||
|> maybe_add_name(socket.assigns.printify_test_result)
|
||||
|
||||
case Products.create_provider_connection(params) do
|
||||
{:ok, connection} ->
|
||||
Products.enqueue_sync(connection)
|
||||
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Berrypod.PubSub, "sync:#{connection.id}")
|
||||
end
|
||||
|
||||
status = %{socket.assigns.setup | printify_connected: true}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:printify_saving, false)
|
||||
|> assign(:printify_conn, connection)
|
||||
|> assign(:sync_status, "syncing")
|
||||
|> assign(:setup, status)
|
||||
|> put_flash(:info, "Connected to Printify! Syncing products...")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:printify_saving, false)
|
||||
|> put_flash(:error, "Failed to save connection")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("retry_sync", _params, socket) do
|
||||
conn = socket.assigns.printify_conn
|
||||
|
||||
if conn do
|
||||
Products.enqueue_sync(conn)
|
||||
{:noreply, assign(socket, sync_status: "syncing")}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# -- Events: Stripe --
|
||||
|
||||
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
||||
if api_key == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")}
|
||||
else
|
||||
socket = assign(socket, stripe_connecting: true)
|
||||
|
||||
case StripeSetup.connect(api_key) do
|
||||
{:ok, _result} ->
|
||||
status = %{socket.assigns.setup | stripe_connected: true, can_go_live: true}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:stripe_connecting, false)
|
||||
|> assign(:setup, status)
|
||||
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
|
||||
|> assign(:active_step, :go_live)
|
||||
|> put_flash(:info, "Stripe connected")}
|
||||
|
||||
{:error, message} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:stripe_connecting, false)
|
||||
|> put_flash(:error, "Stripe connection failed: #{message}")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# -- Events: Go live --
|
||||
|
||||
def handle_event("go_live", _params, socket) do
|
||||
{:ok, _} = Settings.set_site_live(true)
|
||||
status = %{socket.assigns.setup | site_live: true}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:setup, status)
|
||||
|> assign(:just_went_live, true)}
|
||||
end
|
||||
|
||||
# -- Events: Step navigation --
|
||||
|
||||
def handle_event("toggle_step", %{"step" => step}, socket) do
|
||||
step = String.to_existing_atom(step)
|
||||
|
||||
new_active =
|
||||
if socket.assigns.active_step == step do
|
||||
determine_active_step(socket.assigns.setup)
|
||||
else
|
||||
step
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, active_step: new_active)}
|
||||
end
|
||||
|
||||
# -- PubSub: Sync progress --
|
||||
|
||||
@impl true
|
||||
def handle_info({:sync_status, "completed", product_count}, socket) do
|
||||
status = %{
|
||||
socket.assigns.setup
|
||||
| products_synced: true,
|
||||
product_count: product_count
|
||||
}
|
||||
|
||||
active_step = if status.stripe_connected, do: :go_live, else: :stripe
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:setup, status)
|
||||
|> assign(:sync_status, "completed")
|
||||
|> assign(:active_step, active_step)
|
||||
|> put_flash(:info, "#{product_count} products synced")}
|
||||
end
|
||||
|
||||
def handle_info({:sync_status, "failed"}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:sync_status, "failed")
|
||||
|> put_flash(:error, "Product sync failed — try again")}
|
||||
end
|
||||
|
||||
def handle_info({:sync_status, status}, socket) do
|
||||
{:noreply, assign(socket, sync_status: status)}
|
||||
end
|
||||
|
||||
# -- Render --
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Dashboard
|
||||
</.header>
|
||||
|
||||
<%!-- Celebration state --%>
|
||||
<.celebration :if={@just_went_live} />
|
||||
|
||||
<%!-- Setup stepper (when not live and not celebrating) --%>
|
||||
<.setup_stepper
|
||||
:if={!@setup.site_live and !@just_went_live}
|
||||
setup={@setup}
|
||||
active_step={@active_step}
|
||||
printify_conn={@printify_conn}
|
||||
printify_form={@printify_form}
|
||||
printify_testing={@printify_testing}
|
||||
printify_test_result={@printify_test_result}
|
||||
printify_saving={@printify_saving}
|
||||
sync_status={@sync_status}
|
||||
stripe_form={@stripe_form}
|
||||
stripe_connecting={@stripe_connecting}
|
||||
stripe_api_key_hint={@stripe_api_key_hint}
|
||||
/>
|
||||
|
||||
<%!-- Stats --%>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
|
||||
<.stat_card
|
||||
label="Orders"
|
||||
value={@paid_count}
|
||||
icon="hero-shopping-bag"
|
||||
href={~p"/admin/orders"}
|
||||
/>
|
||||
<.stat_card
|
||||
label="Revenue"
|
||||
value={format_revenue(@revenue)}
|
||||
icon="hero-banknotes"
|
||||
href={~p"/admin/orders"}
|
||||
/>
|
||||
<.stat_card
|
||||
label="Products"
|
||||
value={@setup.product_count}
|
||||
icon="hero-cube"
|
||||
href={~p"/admin/products"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Recent orders --%>
|
||||
<section class="mt-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">Recent orders</h2>
|
||||
<.link
|
||||
navigate={~p"/admin/orders"}
|
||||
class="text-sm text-base-content/60 hover:text-base-content"
|
||||
>
|
||||
View all →
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%= if @recent_orders == [] do %>
|
||||
<div class="rounded-lg border border-base-200 p-8 text-center text-base-content/60">
|
||||
<.icon name="hero-inbox" class="size-10 mx-auto mb-3 text-base-content/30" />
|
||||
<p class="font-medium">No orders yet</p>
|
||||
<p class="text-sm mt-1">Orders will appear here once customers check out.</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-base-200 text-left text-base-content/60">
|
||||
<th class="pb-2 font-medium">Order</th>
|
||||
<th class="pb-2 font-medium">Date</th>
|
||||
<th class="pb-2 font-medium">Customer</th>
|
||||
<th class="pb-2 font-medium text-right">Total</th>
|
||||
<th class="pb-2 font-medium">Fulfilment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
:for={order <- @recent_orders}
|
||||
class="border-b border-base-200 hover:bg-base-200/50 cursor-pointer"
|
||||
phx-click={JS.navigate(~p"/admin/orders/#{order}")}
|
||||
>
|
||||
<td class="py-2.5 font-medium">{order.order_number}</td>
|
||||
<td class="py-2.5 text-base-content/60">{format_date(order.inserted_at)}</td>
|
||||
<td class="py-2.5 text-base-content/60">{order.customer_email || "—"}</td>
|
||||
<td class="py-2.5 text-right">{Cart.format_price(order.total)}</td>
|
||||
<td class="py-2.5"><.fulfilment_pill status={order.fulfilment_status} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# Setup stepper components
|
||||
# ==========================================================================
|
||||
|
||||
attr :setup, :map, required: true
|
||||
attr :active_step, :atom, required: true
|
||||
attr :printify_conn, :any, required: true
|
||||
attr :printify_form, :any, required: true
|
||||
attr :printify_testing, :boolean, required: true
|
||||
attr :printify_test_result, :any, required: true
|
||||
attr :printify_saving, :boolean, required: true
|
||||
attr :sync_status, :string, required: true
|
||||
attr :stripe_form, :any, required: true
|
||||
attr :stripe_connecting, :boolean, required: true
|
||||
attr :stripe_api_key_hint, :string, required: true
|
||||
|
||||
defp setup_stepper(assigns) do
|
||||
~H"""
|
||||
<div class="mt-6">
|
||||
<ol class="relative" aria-label="Setup steps">
|
||||
<%!-- Step 1: Printify --%>
|
||||
<.setup_step
|
||||
step={:printify}
|
||||
number={1}
|
||||
title="Connect to Printify"
|
||||
active_step={@active_step}
|
||||
done={@setup.printify_connected and @setup.products_synced}
|
||||
last={false}
|
||||
next_done={@setup.stripe_connected}
|
||||
>
|
||||
<:summary :if={@setup.printify_connected and @setup.products_synced}>
|
||||
Connected · {@setup.product_count} products synced
|
||||
</:summary>
|
||||
<:content>
|
||||
<.printify_step_content
|
||||
setup={@setup}
|
||||
printify_conn={@printify_conn}
|
||||
printify_form={@printify_form}
|
||||
printify_testing={@printify_testing}
|
||||
printify_test_result={@printify_test_result}
|
||||
printify_saving={@printify_saving}
|
||||
sync_status={@sync_status}
|
||||
/>
|
||||
</:content>
|
||||
</.setup_step>
|
||||
|
||||
<%!-- Step 2: Stripe --%>
|
||||
<.setup_step
|
||||
step={:stripe}
|
||||
number={2}
|
||||
title="Connect Stripe"
|
||||
active_step={@active_step}
|
||||
done={@setup.stripe_connected}
|
||||
last={false}
|
||||
next_done={@setup.site_live}
|
||||
>
|
||||
<:summary :if={@setup.stripe_connected}>
|
||||
Connected · {@stripe_api_key_hint}
|
||||
</:summary>
|
||||
<:content>
|
||||
<.stripe_step_content
|
||||
stripe_form={@stripe_form}
|
||||
stripe_connecting={@stripe_connecting}
|
||||
/>
|
||||
</:content>
|
||||
</.setup_step>
|
||||
|
||||
<%!-- Step 3: Go live --%>
|
||||
<.setup_step
|
||||
step={:go_live}
|
||||
number={3}
|
||||
title="Go live"
|
||||
active_step={@active_step}
|
||||
done={@setup.site_live}
|
||||
last={true}
|
||||
next_done={false}
|
||||
>
|
||||
<:content>
|
||||
<.go_live_step_content setup={@setup} />
|
||||
</:content>
|
||||
</.setup_step>
|
||||
</ol>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :step, :atom, required: true
|
||||
attr :number, :integer, required: true
|
||||
attr :title, :string, required: true
|
||||
attr :active_step, :atom, required: true
|
||||
attr :done, :boolean, required: true
|
||||
attr :last, :boolean, required: true
|
||||
attr :next_done, :boolean, required: true
|
||||
|
||||
slot :summary
|
||||
slot :content, required: true
|
||||
|
||||
defp setup_step(assigns) do
|
||||
is_active = assigns.active_step == assigns.step
|
||||
is_clickable = assigns.done
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:is_active, is_active)
|
||||
|> assign(:is_clickable, is_clickable)
|
||||
|
||||
~H"""
|
||||
<li class="relative pl-10 pb-8 last:pb-0" aria-current={@is_active && "step"}>
|
||||
<%!-- Connector line --%>
|
||||
<div
|
||||
:if={!@last}
|
||||
class={[
|
||||
"absolute left-[0.9375rem] top-8 -bottom-0 w-0.5",
|
||||
if(@done, do: "bg-green-500", else: "bg-base-300")
|
||||
]}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<%!-- Step circle --%>
|
||||
<div class={[
|
||||
"absolute left-0 top-0 flex size-8 items-center justify-center rounded-full text-sm font-semibold ring-4 ring-base-100",
|
||||
cond do
|
||||
@done -> "bg-green-500 text-white"
|
||||
@is_active -> "bg-base-content text-white"
|
||||
true -> "bg-base-200 text-base-content/40"
|
||||
end
|
||||
]}>
|
||||
<%= if @done do %>
|
||||
<.icon name="hero-check-mini" class="size-5" />
|
||||
<% else %>
|
||||
{@number}
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Step header --%>
|
||||
<%= if @is_clickable do %>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 text-left"
|
||||
phx-click="toggle_step"
|
||||
phx-value-step={@step}
|
||||
aria-expanded={to_string(@is_active)}
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-base-content">{@title}</h3>
|
||||
<.icon
|
||||
name={if @is_active, do: "hero-chevron-up-mini", else: "hero-chevron-down-mini"}
|
||||
class="size-4 text-base-content/40"
|
||||
/>
|
||||
</button>
|
||||
<% else %>
|
||||
<h3 class={[
|
||||
"text-sm font-semibold",
|
||||
if(@is_active, do: "text-base-content", else: "text-base-content/40")
|
||||
]}>
|
||||
{@title}
|
||||
</h3>
|
||||
<% end %>
|
||||
|
||||
<%!-- Collapsed summary for completed steps --%>
|
||||
<p :if={@done and !@is_active and @summary != []} class="text-sm text-base-content/60 mt-0.5">
|
||||
{render_slot(@summary)}
|
||||
</p>
|
||||
|
||||
<%!-- Expanded content --%>
|
||||
<div :if={@is_active} class="mt-3">
|
||||
{render_slot(@content)}
|
||||
</div>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Printify step content --
|
||||
|
||||
attr :setup, :map, required: true
|
||||
attr :printify_conn, :any, required: true
|
||||
attr :printify_form, :any, required: true
|
||||
attr :printify_testing, :boolean, required: true
|
||||
attr :printify_test_result, :any, required: true
|
||||
attr :printify_saving, :boolean, required: true
|
||||
attr :sync_status, :string, required: true
|
||||
|
||||
defp printify_step_content(assigns) do
|
||||
~H"""
|
||||
<%!-- Not yet connected: show form --%>
|
||||
<div :if={!@setup.printify_connected}>
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
Connect your Printify account to import products.
|
||||
Get an API token from <a
|
||||
href="https://printify.com/app/account/connections"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-base-content underline"
|
||||
>
|
||||
Printify → Account → Connections
|
||||
</a>.
|
||||
</p>
|
||||
|
||||
<.form
|
||||
for={@printify_form}
|
||||
phx-change="validate_printify"
|
||||
phx-submit="connect_printify"
|
||||
>
|
||||
<.input
|
||||
field={@printify_form[:api_key]}
|
||||
type="password"
|
||||
label="Printify API token"
|
||||
placeholder="Paste your token here"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="test_printify"
|
||||
disabled={@printify_testing}
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset disabled:opacity-50"
|
||||
>
|
||||
<.icon
|
||||
name={if @printify_testing, do: "hero-arrow-path", else: "hero-signal"}
|
||||
class={if @printify_testing, do: "size-4 animate-spin", else: "size-4"}
|
||||
/>
|
||||
{if @printify_testing, do: "Checking...", else: "Check connection"}
|
||||
</button>
|
||||
<.button type="submit" disabled={@printify_saving or @printify_testing}>
|
||||
{if @printify_saving, do: "Connecting...", else: "Connect to Printify"}
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<.printify_test_feedback :if={@printify_test_result} result={@printify_test_result} />
|
||||
</.form>
|
||||
</div>
|
||||
|
||||
<%!-- Connected, syncing --%>
|
||||
<div
|
||||
:if={@setup.printify_connected and @sync_status == "syncing"}
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="size-4 animate-spin text-base-content/40" />
|
||||
<span class="text-base-content/60">Syncing products from Printify...</span>
|
||||
</div>
|
||||
|
||||
<%!-- Connected, sync failed --%>
|
||||
<div :if={@setup.printify_connected and @sync_status == "failed"}>
|
||||
<p class="text-sm text-red-600 mb-2">Product sync failed.</p>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="retry_sync"
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="size-4" /> Try again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Connected, synced (shown when user expands a completed step) --%>
|
||||
<div :if={@setup.printify_connected and @setup.products_synced}>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{@setup.product_count} products synced from Printify.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :result, :any, required: true
|
||||
|
||||
defp printify_test_feedback(assigns) do
|
||||
~H"""
|
||||
<div class="mt-2 text-sm">
|
||||
<%= case @result do %>
|
||||
<% {:ok, info} -> %>
|
||||
<span class="text-green-600 flex items-center gap-1">
|
||||
<.icon name="hero-check-circle" class="size-4" /> Connected to {info.shop_name}
|
||||
</span>
|
||||
<% {:error, reason} -> %>
|
||||
<span class="text-red-600 flex items-center gap-1">
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{format_printify_error(reason)}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Stripe step content --
|
||||
|
||||
attr :stripe_form, :any, required: true
|
||||
attr :stripe_connecting, :boolean, required: true
|
||||
|
||||
defp stripe_step_content(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
Enter your Stripe secret key to accept payments.
|
||||
Find it in your
|
||||
<a
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-base-content underline"
|
||||
>
|
||||
Stripe dashboard
|
||||
</a>
|
||||
under Developers → API keys.
|
||||
</p>
|
||||
|
||||
<.form for={@stripe_form} phx-submit="connect_stripe">
|
||||
<.input
|
||||
field={@stripe_form[:api_key]}
|
||||
type="password"
|
||||
label="Secret key"
|
||||
autocomplete="off"
|
||||
placeholder="sk_test_... or sk_live_..."
|
||||
/>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
Starts with <code>sk_test_</code> or <code>sk_live_</code>. Encrypted at rest.
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Connecting...">
|
||||
{if @stripe_connecting, do: "Connecting...", else: "Connect Stripe"}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Go live step content --
|
||||
|
||||
attr :setup, :map, required: true
|
||||
|
||||
defp go_live_step_content(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
Your shop is ready. Visitors currently see a "coming soon" page —
|
||||
hit the button to make it live.
|
||||
</p>
|
||||
<button
|
||||
phx-click="go_live"
|
||||
disabled={!@setup.can_go_live}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-green-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<.icon name="hero-rocket-launch" class="size-5" /> Go live
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Celebration --
|
||||
|
||||
defp celebration(assigns) do
|
||||
~H"""
|
||||
<div class="mt-6 rounded-lg border border-green-200 bg-green-50 p-6 text-center">
|
||||
<.icon name="hero-check-badge" class="size-12 mx-auto text-green-600 mb-3" />
|
||||
<h2 class="text-lg font-semibold text-green-900">Your shop is live!</h2>
|
||||
<p class="text-sm text-green-700 mt-1 mb-4">
|
||||
Customers can now browse and buy from your shop.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-2 justify-center">
|
||||
<.link
|
||||
navigate={~p"/"}
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white hover:bg-green-500"
|
||||
>
|
||||
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/admin/theme"}
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-100 px-3 py-2 text-sm font-medium text-base-content ring-1 ring-base-300 ring-inset hover:bg-base-200/50"
|
||||
>
|
||||
<.icon name="hero-paint-brush-mini" class="size-4" /> Customise theme
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# Stats components
|
||||
# ==========================================================================
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :value, :any, required: true
|
||||
attr :icon, :string, required: true
|
||||
attr :href, :string, required: true
|
||||
|
||||
defp stat_card(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
navigate={@href}
|
||||
class="rounded-lg border border-base-200 p-4 hover:border-base-300 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-base-200 p-2">
|
||||
<.icon name={@icon} class="size-5 text-base-content/60" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{@value}</p>
|
||||
<p class="text-sm text-base-content/60">{@label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
defp fulfilment_pill(assigns) do
|
||||
{color, label} =
|
||||
case assigns.status do
|
||||
"unfulfilled" -> {"bg-base-200 text-base-content/60", "unfulfilled"}
|
||||
"submitted" -> {"bg-blue-50 text-blue-700", "submitted"}
|
||||
"processing" -> {"bg-amber-50 text-amber-700", "processing"}
|
||||
"shipped" -> {"bg-purple-50 text-purple-700", "shipped"}
|
||||
"delivered" -> {"bg-green-50 text-green-700", "delivered"}
|
||||
"failed" -> {"bg-red-50 text-red-700", "failed"}
|
||||
_ -> {"bg-base-200 text-base-content/60", assigns.status || "—"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, color: color, label: label)
|
||||
|
||||
~H"""
|
||||
<span class={["inline-flex rounded-full px-2 py-0.5 text-xs font-medium", @color]}>
|
||||
{@label}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# Helpers
|
||||
# ==========================================================================
|
||||
|
||||
defp format_revenue(amount_pence) when is_integer(amount_pence) do
|
||||
Cart.format_price(amount_pence)
|
||||
end
|
||||
|
||||
defp format_revenue(_), do: "£0.00"
|
||||
|
||||
defp format_date(datetime) do
|
||||
Calendar.strftime(datetime, "%d %b %Y")
|
||||
end
|
||||
|
||||
defp encrypt_api_key(api_key) do
|
||||
case Berrypod.Vault.encrypt(api_key) do
|
||||
{:ok, encrypted} -> encrypted
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_shop_config(params, {:ok, %{shop_id: shop_id}}) do
|
||||
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
|
||||
Map.put(params, "config", config)
|
||||
end
|
||||
|
||||
defp maybe_add_shop_config(params, _), do: params
|
||||
|
||||
defp maybe_add_name(params, {:ok, %{shop_name: shop_name}}) when is_binary(shop_name) do
|
||||
Map.put_new(params, "name", shop_name)
|
||||
end
|
||||
|
||||
defp maybe_add_name(params, _), do: Map.put_new(params, "name", "Printify")
|
||||
|
||||
defp format_printify_error(:no_api_key), do: "Please enter your API token"
|
||||
defp format_printify_error(:unauthorized), do: "That token doesn't seem to be valid"
|
||||
defp format_printify_error(:timeout), do: "Couldn't reach Printify — try again"
|
||||
defp format_printify_error({:http_error, _code}), do: "Something went wrong — try again"
|
||||
defp format_printify_error(error) when is_binary(error), do: error
|
||||
defp format_printify_error(_), do: "Connection failed — check your token and try again"
|
||||
end
|
||||
325
lib/berrypod_web/live/admin/order_show.ex
Normal file
325
lib/berrypod_web/live/admin/order_show.ex
Normal file
@@ -0,0 +1,325 @@
|
||||
defmodule BerrypodWeb.Admin.OrderShow do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Cart
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
case Orders.get_order(id) do
|
||||
nil ->
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:error, "Order not found")
|
||||
|> push_navigate(to: ~p"/admin/orders")
|
||||
|
||||
{:ok, socket}
|
||||
|
||||
order ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, order.order_number)
|
||||
|> assign(:order, order)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
<.link
|
||||
navigate={~p"/admin/orders"}
|
||||
class="text-sm font-normal text-base-content/60 hover:underline"
|
||||
>
|
||||
← Orders
|
||||
</.link>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<span class="text-2xl font-bold">{@order.order_number}</span>
|
||||
<.status_badge status={@order.payment_status} />
|
||||
</div>
|
||||
</.header>
|
||||
|
||||
<div class="grid gap-6 mt-6 lg:grid-cols-2">
|
||||
<%!-- order info --%>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Order details</h3>
|
||||
<.list>
|
||||
<:item title="Date">{format_date(@order.inserted_at)}</:item>
|
||||
<:item title="Customer">{@order.customer_email || "—"}</:item>
|
||||
<:item title="Payment status">
|
||||
<.status_badge status={@order.payment_status} />
|
||||
</:item>
|
||||
<:item :if={@order.stripe_payment_intent_id} title="Stripe payment">
|
||||
<code class="text-xs">{@order.stripe_payment_intent_id}</code>
|
||||
</:item>
|
||||
<:item title="Currency">{String.upcase(@order.currency)}</:item>
|
||||
</.list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- shipping address --%>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Shipping address</h3>
|
||||
<%= if @order.shipping_address != %{} do %>
|
||||
<.list>
|
||||
<:item :if={@order.shipping_address["name"]} title="Name">
|
||||
{@order.shipping_address["name"]}
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["line1"]} title="Address">
|
||||
{@order.shipping_address["line1"]}
|
||||
<span :if={@order.shipping_address["line2"]}>
|
||||
<br />{@order.shipping_address["line2"]}
|
||||
</span>
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["city"]} title="City">
|
||||
{@order.shipping_address["city"]}
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["state"] not in [nil, ""]} title="State">
|
||||
{@order.shipping_address["state"]}
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["postal_code"]} title="Postcode">
|
||||
{@order.shipping_address["postal_code"]}
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["country"]} title="Country">
|
||||
{@order.shipping_address["country"]}
|
||||
</:item>
|
||||
</.list>
|
||||
<% else %>
|
||||
<p class="text-base-content/60 text-sm">No shipping address provided</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- fulfilment --%>
|
||||
<div class="admin-card mt-6">
|
||||
<div class="admin-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="admin-card-title">Fulfilment</h3>
|
||||
<.fulfilment_badge status={@order.fulfilment_status} />
|
||||
</div>
|
||||
<.list>
|
||||
<:item :if={@order.provider_order_id} title="Provider order ID">
|
||||
<code class="text-xs">{@order.provider_order_id}</code>
|
||||
</:item>
|
||||
<:item :if={@order.provider_status} title="Provider status">
|
||||
{@order.provider_status}
|
||||
</:item>
|
||||
<:item :if={@order.submitted_at} title="Submitted">
|
||||
{format_date(@order.submitted_at)}
|
||||
</:item>
|
||||
<:item :if={@order.tracking_number} title="Tracking">
|
||||
<%= if @order.tracking_url do %>
|
||||
<a href={@order.tracking_url} target="_blank" class="admin-link">
|
||||
{@order.tracking_number}
|
||||
</a>
|
||||
<% else %>
|
||||
{@order.tracking_number}
|
||||
<% end %>
|
||||
</:item>
|
||||
<:item :if={@order.shipped_at} title="Shipped">
|
||||
{format_date(@order.shipped_at)}
|
||||
</:item>
|
||||
<:item :if={@order.delivered_at} title="Delivered">
|
||||
{format_date(@order.delivered_at)}
|
||||
</:item>
|
||||
<:item :if={@order.fulfilment_error} title="Error">
|
||||
<span class="text-error text-sm">{@order.fulfilment_error}</span>
|
||||
</:item>
|
||||
</.list>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
:if={can_submit?(@order)}
|
||||
phx-click="submit_to_provider"
|
||||
class="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
<.icon name="hero-paper-airplane-mini" class="size-4" />
|
||||
{if @order.fulfilment_status == "failed",
|
||||
do: "Retry submission",
|
||||
else: "Submit to provider"}
|
||||
</button>
|
||||
<button
|
||||
:if={can_refresh?(@order)}
|
||||
phx-click="refresh_status"
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
>
|
||||
<.icon name="hero-arrow-path-mini" class="size-4" /> Refresh status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- line items --%>
|
||||
<div class="admin-card mt-6">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Items</h3>
|
||||
<table class="admin-table admin-table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Variant</th>
|
||||
<th class="text-right">Qty</th>
|
||||
<th class="text-right">Unit price</th>
|
||||
<th class="text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={item <- @order.items}>
|
||||
<td>{item.product_name}</td>
|
||||
<td>{item.variant_title}</td>
|
||||
<td class="text-right">{item.quantity}</td>
|
||||
<td class="text-right">{Cart.format_price(item.unit_price)}</td>
|
||||
<td class="text-right">{Cart.format_price(item.unit_price * item.quantity)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" class="text-right font-medium">Subtotal</td>
|
||||
<td class="text-right font-medium">{Cart.format_price(@order.subtotal)}</td>
|
||||
</tr>
|
||||
<tr class="text-lg">
|
||||
<td colspan="4" class="text-right font-bold">Total</td>
|
||||
<td class="text-right font-bold">{Cart.format_price(@order.total)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit_to_provider", _params, socket) do
|
||||
order = socket.assigns.order
|
||||
|
||||
case Orders.submit_to_provider(order) do
|
||||
{:ok, updated} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:order, updated)
|
||||
|> put_flash(:info, "Order submitted to provider")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _reason} ->
|
||||
order = Orders.get_order(order.id)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:order, order)
|
||||
|> put_flash(:error, order.fulfilment_error || "Submission failed")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("refresh_status", _params, socket) do
|
||||
order = socket.assigns.order
|
||||
|
||||
case Orders.refresh_fulfilment_status(order) do
|
||||
{:ok, updated} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:order, updated)
|
||||
|> put_flash(:info, "Status refreshed")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to refresh: #{inspect(reason)}")}
|
||||
end
|
||||
end
|
||||
|
||||
defp can_submit?(order) do
|
||||
order.payment_status == "paid" and order.fulfilment_status in ["unfulfilled", "failed"]
|
||||
end
|
||||
|
||||
defp can_refresh?(order) do
|
||||
not is_nil(order.provider_order_id) and
|
||||
order.fulfilment_status in ["submitted", "processing", "shipped"]
|
||||
end
|
||||
|
||||
defp fulfilment_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
case assigns.status do
|
||||
"submitted" ->
|
||||
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
|
||||
|
||||
"processing" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
|
||||
|
||||
"shipped" ->
|
||||
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
|
||||
|
||||
"delivered" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"cancelled" ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-no-symbol-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-minus-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
case assigns.status do
|
||||
"paid" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"pending" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-clock-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"refunded" ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-arrow-uturn-left-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-question-mark-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_date(datetime) do
|
||||
Calendar.strftime(datetime, "%d %b %Y %H:%M")
|
||||
end
|
||||
end
|
||||
206
lib/berrypod_web/live/admin/orders.ex
Normal file
206
lib/berrypod_web/live/admin/orders.ex
Normal file
@@ -0,0 +1,206 @@
|
||||
defmodule BerrypodWeb.Admin.Orders do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Cart
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
counts = Orders.count_orders_by_status()
|
||||
orders = Orders.list_orders()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Orders")
|
||||
|> assign(:status_filter, "all")
|
||||
|> assign(:status_counts, counts)
|
||||
|> assign(:order_count, length(orders))
|
||||
|> stream(:orders, orders)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter", %{"status" => status}, socket) do
|
||||
orders = Orders.list_orders(status: status)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:status_filter, status)
|
||||
|> assign(:order_count, length(orders))
|
||||
|> stream(:orders, orders, reset: true)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Orders
|
||||
</.header>
|
||||
|
||||
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
|
||||
<.filter_tab
|
||||
status="all"
|
||||
label="All"
|
||||
count={total_count(@status_counts)}
|
||||
active={@status_filter}
|
||||
/>
|
||||
<.filter_tab
|
||||
status="paid"
|
||||
label="Paid"
|
||||
count={@status_counts["paid"]}
|
||||
active={@status_filter}
|
||||
/>
|
||||
<.filter_tab
|
||||
status="pending"
|
||||
label="Pending"
|
||||
count={@status_counts["pending"]}
|
||||
active={@status_filter}
|
||||
/>
|
||||
<.filter_tab
|
||||
status="failed"
|
||||
label="Failed"
|
||||
count={@status_counts["failed"]}
|
||||
active={@status_filter}
|
||||
/>
|
||||
<.filter_tab
|
||||
status="refunded"
|
||||
label="Refunded"
|
||||
count={@status_counts["refunded"]}
|
||||
active={@status_filter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.table
|
||||
:if={@order_count > 0}
|
||||
id="orders"
|
||||
rows={@streams.orders}
|
||||
row_item={fn {_id, order} -> order end}
|
||||
row_click={fn {_id, order} -> JS.navigate(~p"/admin/orders/#{order}") end}
|
||||
>
|
||||
<:col :let={order} label="Order">{order.order_number}</:col>
|
||||
<:col :let={order} label="Date">{format_date(order.inserted_at)}</:col>
|
||||
<:col :let={order} label="Customer">{order.customer_email || "—"}</:col>
|
||||
<:col :let={order} label="Total">{Cart.format_price(order.total)}</:col>
|
||||
<:col :let={order} label="Status"><.status_badge status={order.payment_status} /></:col>
|
||||
<:col :let={order} label="Fulfilment">
|
||||
<.fulfilment_badge status={order.fulfilment_status} />
|
||||
</:col>
|
||||
</.table>
|
||||
|
||||
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60">
|
||||
<.icon name="hero-inbox" class="size-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No orders yet</p>
|
||||
<p class="text-sm mt-1">Orders will appear here once customers check out.</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp filter_tab(assigns) do
|
||||
count = assigns[:count] || 0
|
||||
active = assigns.active == assigns.status
|
||||
|
||||
assigns = assign(assigns, count: count, active: active)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
phx-click="filter"
|
||||
phx-value-status={@status}
|
||||
class={[
|
||||
"admin-btn admin-btn-sm",
|
||||
@active && "admin-btn-primary",
|
||||
!@active && "admin-btn-ghost"
|
||||
]}
|
||||
>
|
||||
{@label}
|
||||
<span :if={@count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
case assigns.status do
|
||||
"paid" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"pending" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-clock-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"refunded" ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-arrow-uturn-left-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-question-mark-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_date(datetime) do
|
||||
Calendar.strftime(datetime, "%d %b %Y %H:%M")
|
||||
end
|
||||
|
||||
defp fulfilment_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
case assigns.status do
|
||||
"submitted" ->
|
||||
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
|
||||
|
||||
"processing" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
|
||||
|
||||
"shipped" ->
|
||||
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
|
||||
|
||||
"delivered" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"cancelled" ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-no-symbol-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
||||
"hero-minus-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp total_count(counts) do
|
||||
counts |> Map.values() |> Enum.sum()
|
||||
end
|
||||
end
|
||||
345
lib/berrypod_web/live/admin/product_show.ex
Normal file
345
lib/berrypod_web/live/admin/product_show.ex
Normal file
@@ -0,0 +1,345 @@
|
||||
defmodule BerrypodWeb.Admin.ProductShow do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.{Product, ProductImage, ProductVariant}
|
||||
alias Berrypod.Cart
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
case Products.get_product(id, preload: [:provider_connection, images: :image, variants: []]) do
|
||||
nil ->
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:error, "Product not found")
|
||||
|> push_navigate(to: ~p"/admin/products")
|
||||
|
||||
{:ok, socket}
|
||||
|
||||
product ->
|
||||
form =
|
||||
product
|
||||
|> Product.storefront_changeset(%{})
|
||||
|> to_form(as: "product")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, product.title)
|
||||
|> assign(:product, product)
|
||||
|> assign(:form, form)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_storefront", %{"product" => params}, socket) do
|
||||
form =
|
||||
socket.assigns.product
|
||||
|> Product.storefront_changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: "product")
|
||||
|
||||
{:noreply, assign(socket, :form, form)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_storefront", %{"product" => params}, socket) do
|
||||
case Products.update_storefront(socket.assigns.product, params) do
|
||||
{:ok, updated} ->
|
||||
product = %{
|
||||
updated
|
||||
| provider_connection: socket.assigns.product.provider_connection,
|
||||
images: socket.assigns.product.images,
|
||||
variants: socket.assigns.product.variants
|
||||
}
|
||||
|
||||
form =
|
||||
product
|
||||
|> Product.storefront_changeset(%{})
|
||||
|> to_form(as: "product")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:product, product)
|
||||
|> assign(:form, form)
|
||||
|> put_flash(:info, "Product updated")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset, as: "product"))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("resync", _params, socket) do
|
||||
product = socket.assigns.product
|
||||
|
||||
if product.provider_connection do
|
||||
Products.enqueue_sync(product.provider_connection)
|
||||
|
||||
{:noreply, put_flash(socket, :info, "Sync queued for #{product.provider_connection.name}")}
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, "No provider connection")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
<.link
|
||||
navigate={~p"/admin/products"}
|
||||
class="text-sm font-normal text-base-content/60 hover:underline"
|
||||
>
|
||||
← Products
|
||||
</.link>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<span class="text-2xl font-bold">{@product.title}</span>
|
||||
<.visibility_badge visible={@product.visible} />
|
||||
<.status_badge status={@product.status} />
|
||||
</div>
|
||||
<:actions>
|
||||
<.link
|
||||
:if={provider_edit_url(@product)}
|
||||
href={provider_edit_url(@product)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
>
|
||||
Edit on {provider_label(@product)}
|
||||
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" />
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/products/#{@product.slug}"}
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
>
|
||||
View on shop <.icon name="hero-arrow-top-right-on-square-mini" class="size-4" />
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<%!-- images + details --%>
|
||||
<div class="grid gap-6 mt-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
<div
|
||||
:for={image <- sorted_images(@product)}
|
||||
class="aspect-square rounded bg-base-200 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={ProductImage.url(image, 400)}
|
||||
alt={image.alt || @product.title}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p :if={@product.images == []} class="text-base-content/40 text-sm">No images</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Details</h3>
|
||||
<.list>
|
||||
<:item :if={@product.provider_connection} title="Provider">
|
||||
{provider_label(@product)} via {@product.provider_connection.name}
|
||||
</:item>
|
||||
<:item title="Category">{@product.category || "—"}</:item>
|
||||
<:item title="Price">{Cart.format_price(@product.cheapest_price)}</:item>
|
||||
<:item title="Variants">{length(@product.variants)}</:item>
|
||||
<:item title="Images">{length(@product.images)}</:item>
|
||||
<:item title="Created">{format_date(@product.inserted_at)}</:item>
|
||||
<:item
|
||||
:if={@product.provider_connection && @product.provider_connection.last_synced_at}
|
||||
title="Last synced"
|
||||
>
|
||||
{format_date(@product.provider_connection.last_synced_at)}
|
||||
</:item>
|
||||
</.list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- storefront controls --%>
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Storefront controls</h3>
|
||||
<.form
|
||||
for={@form}
|
||||
phx-submit="save_storefront"
|
||||
phx-change="validate_storefront"
|
||||
class="flex flex-wrap gap-4 items-end"
|
||||
>
|
||||
<label class="w-auto">
|
||||
<span class="text-xs mb-0.5">Visibility</span>
|
||||
<select
|
||||
name="product[visible]"
|
||||
class="admin-select admin-select-sm"
|
||||
aria-label="Visibility"
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={@form[:visible].value == true || @form[:visible].value == "true"}
|
||||
>
|
||||
Visible
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={@form[:visible].value == false || @form[:visible].value == "false"}
|
||||
>
|
||||
Hidden
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="w-auto flex-1 min-w-48">
|
||||
<span class="text-xs mb-0.5">Category</span>
|
||||
<input
|
||||
type="text"
|
||||
name="product[category]"
|
||||
value={@form[:category].value}
|
||||
class="admin-input admin-input-sm"
|
||||
placeholder="e.g. Apparel"
|
||||
/>
|
||||
</label>
|
||||
<.button type="submit" class="admin-btn-sm admin-btn-primary">Save</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- variants --%>
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Variants ({length(@product.variants)})</h3>
|
||||
<.table id="variants" rows={@product.variants}>
|
||||
<:col :let={variant} label="Options">{ProductVariant.options_title(variant)}</:col>
|
||||
<:col :let={variant} label="SKU">{variant.sku || "—"}</:col>
|
||||
<:col :let={variant} label="Price">{Cart.format_price(variant.price)}</:col>
|
||||
<:col :let={variant} label="Cost">
|
||||
{if variant.cost, do: Cart.format_price(variant.cost), else: "—"}
|
||||
</:col>
|
||||
<:col :let={variant} label="Profit">
|
||||
{if ProductVariant.profit(variant),
|
||||
do: Cart.format_price(ProductVariant.profit(variant)),
|
||||
else: "—"}
|
||||
</:col>
|
||||
<:col :let={variant} label="Available">
|
||||
<.icon
|
||||
:if={variant.is_enabled && variant.is_available}
|
||||
name="hero-check-circle-mini"
|
||||
class="size-5 text-green-600"
|
||||
/>
|
||||
<.icon
|
||||
:if={!variant.is_enabled || !variant.is_available}
|
||||
name="hero-x-circle-mini"
|
||||
class="size-5 text-base-content/30"
|
||||
/>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- provider data --%>
|
||||
<div
|
||||
:if={@product.provider_connection}
|
||||
class="card bg-base-100 shadow-sm border border-base-200 mt-6"
|
||||
>
|
||||
<div class="admin-card-body">
|
||||
<h3 class="admin-card-title">Provider data</h3>
|
||||
<.list>
|
||||
<:item title="Provider">
|
||||
{provider_label(@product)} via {@product.provider_connection.name}
|
||||
</:item>
|
||||
<:item title="Provider product ID">{@product.provider_product_id}</:item>
|
||||
<:item title="Status">{@product.status}</:item>
|
||||
<:item title="Sync status">{@product.provider_connection.sync_status}</:item>
|
||||
</.list>
|
||||
<div class="mt-4">
|
||||
<button phx-click="resync" class="admin-btn admin-btn-outline admin-btn-sm">
|
||||
<.icon name="hero-arrow-path" class="size-4" /> Re-sync
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp sorted_images(product) do
|
||||
(product.images || []) |> Enum.sort_by(& &1.position)
|
||||
end
|
||||
|
||||
defp visibility_badge(assigns) do
|
||||
{bg, text, ring, label} =
|
||||
if assigns.visible do
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "visible"}
|
||||
else
|
||||
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10", "hidden"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, label: label)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
{@label}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_badge(assigns) do
|
||||
{bg, text, ring} =
|
||||
case assigns.status do
|
||||
"active" -> {"bg-green-50", "text-green-700", "ring-green-600/20"}
|
||||
"draft" -> {"bg-amber-50", "text-amber-700", "ring-amber-600/20"}
|
||||
"archived" -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"}
|
||||
_ -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
{@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp provider_label(%{provider_connection: %{provider_type: "printify"}}), do: "Printify"
|
||||
defp provider_label(%{provider_connection: %{provider_type: "printful"}}), do: "Printful"
|
||||
defp provider_label(%{provider_connection: %{provider_type: type}}), do: String.capitalize(type)
|
||||
defp provider_label(_), do: nil
|
||||
|
||||
defp provider_edit_url(%{
|
||||
provider_connection: %{provider_type: "printify", config: config},
|
||||
provider_product_id: pid
|
||||
}) do
|
||||
shop_id = config["shop_id"]
|
||||
if shop_id && pid, do: "https://printify.com/app/editor/#{shop_id}/#{pid}"
|
||||
end
|
||||
|
||||
defp provider_edit_url(%{
|
||||
provider_connection: %{provider_type: "printful"},
|
||||
provider_product_id: pid
|
||||
}) do
|
||||
if pid, do: "https://www.printful.com/dashboard/sync/update?id=#{pid}"
|
||||
end
|
||||
|
||||
defp provider_edit_url(_), do: nil
|
||||
|
||||
defp format_date(nil), do: "—"
|
||||
defp format_date(datetime), do: Calendar.strftime(datetime, "%d %b %Y %H:%M")
|
||||
end
|
||||
285
lib/berrypod_web/live/admin/products.ex
Normal file
285
lib/berrypod_web/live/admin/products.ex
Normal file
@@ -0,0 +1,285 @@
|
||||
defmodule BerrypodWeb.Admin.Products do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.{Product, ProductImage}
|
||||
alias Berrypod.Cart
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
connections = Products.list_provider_connections()
|
||||
categories = Products.list_all_categories()
|
||||
products = Products.list_products_admin()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Products")
|
||||
|> assign(:connections, connections)
|
||||
|> assign(:categories, categories)
|
||||
|> assign(:product_count, length(products))
|
||||
|> assign(:provider_filter, "all")
|
||||
|> assign(:category_filter, "all")
|
||||
|> assign(:visibility_filter, "all")
|
||||
|> assign(:stock_filter, "all")
|
||||
|> assign(:sort, "newest")
|
||||
|> stream(:products, products)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter", params, socket) do
|
||||
provider_filter = params["provider"] || socket.assigns.provider_filter
|
||||
category_filter = params["category"] || socket.assigns.category_filter
|
||||
visibility_filter = params["visibility"] || socket.assigns.visibility_filter
|
||||
stock_filter = params["stock"] || socket.assigns.stock_filter
|
||||
sort = params["sort"] || socket.assigns.sort
|
||||
|
||||
opts =
|
||||
[]
|
||||
|> maybe_add_filter(:provider_connection_id, provider_filter)
|
||||
|> maybe_add_filter(:category, category_filter)
|
||||
|> maybe_add_visibility(visibility_filter)
|
||||
|> maybe_add_stock(stock_filter)
|
||||
|> Keyword.put(:sort, sort)
|
||||
|
||||
products = Products.list_products_admin(opts)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:provider_filter, provider_filter)
|
||||
|> assign(:category_filter, category_filter)
|
||||
|> assign(:visibility_filter, visibility_filter)
|
||||
|> assign(:stock_filter, stock_filter)
|
||||
|> assign(:sort, sort)
|
||||
|> assign(:product_count, length(products))
|
||||
|> stream(:products, products, reset: true)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_visibility", %{"id" => id}, socket) do
|
||||
product =
|
||||
Products.get_product(id, preload: [:provider_connection, images: :image, variants: []])
|
||||
|
||||
case Products.toggle_visibility(product) do
|
||||
{:ok, updated} ->
|
||||
updated = %{
|
||||
updated
|
||||
| provider_connection: product.provider_connection,
|
||||
images: product.images,
|
||||
variants: product.variants
|
||||
}
|
||||
|
||||
{:noreply, stream_insert(socket, :products, updated)}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Could not update visibility")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Products
|
||||
<:subtitle>{@product_count} products</:subtitle>
|
||||
</.header>
|
||||
|
||||
<form phx-change="filter" class="flex gap-2 mt-6 mb-4 flex-wrap items-end">
|
||||
<.filter_select
|
||||
:if={length(@connections) > 1}
|
||||
name="provider"
|
||||
label="Provider"
|
||||
value={@provider_filter}
|
||||
options={[{"All providers", "all"}] ++ Enum.map(@connections, &{&1.name, &1.id})}
|
||||
/>
|
||||
<.filter_select
|
||||
:if={@categories != []}
|
||||
name="category"
|
||||
label="Category"
|
||||
value={@category_filter}
|
||||
options={[{"All categories", "all"}] ++ Enum.map(@categories, &{&1, &1})}
|
||||
/>
|
||||
<.filter_select
|
||||
name="visibility"
|
||||
label="Visibility"
|
||||
value={@visibility_filter}
|
||||
options={[{"All", "all"}, {"Visible", "visible"}, {"Hidden", "hidden"}]}
|
||||
/>
|
||||
<.filter_select
|
||||
name="stock"
|
||||
label="Stock"
|
||||
value={@stock_filter}
|
||||
options={[{"All", "all"}, {"In stock", "in_stock"}, {"Out of stock", "out_of_stock"}]}
|
||||
/>
|
||||
<.filter_select
|
||||
name="sort"
|
||||
label="Sort"
|
||||
value={@sort}
|
||||
options={[
|
||||
{"Newest", "newest"},
|
||||
{"Name A\u2013Z", "name_asc"},
|
||||
{"Name Z\u2013A", "name_desc"},
|
||||
{"Price: low\u2013high", "price_asc"},
|
||||
{"Price: high\u2013low", "price_desc"}
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<.table
|
||||
:if={@product_count > 0}
|
||||
id="products"
|
||||
rows={@streams.products}
|
||||
row_item={fn {_id, product} -> product end}
|
||||
row_click={fn {_id, product} -> JS.navigate(~p"/admin/products/#{product}") end}
|
||||
>
|
||||
<:col :let={product} label="">
|
||||
<.product_thumbnail product={product} />
|
||||
</:col>
|
||||
<:col :let={product} label="Product">
|
||||
<div class="font-medium">
|
||||
<.link navigate={~p"/admin/products/#{product}"} class="hover:underline">
|
||||
{product.title}
|
||||
</.link>
|
||||
</div>
|
||||
<.provider_badge :if={product.provider_connection} connection={product.provider_connection} />
|
||||
</:col>
|
||||
<:col :let={product} label="Category">
|
||||
{product.category || "—"}
|
||||
</:col>
|
||||
<:col :let={product} label="Price">
|
||||
<span :if={product.on_sale} class="text-red-600 text-xs font-medium mr-1">Sale</span>
|
||||
{Cart.format_price(product.cheapest_price)}
|
||||
</:col>
|
||||
<:col :let={product} label="Stock">
|
||||
<.stock_badge in_stock={product.in_stock} />
|
||||
</:col>
|
||||
<:col :let={product} label="Variants">
|
||||
{length(product.variants)}
|
||||
</:col>
|
||||
<:col :let={product} label="Visible">
|
||||
<button
|
||||
phx-click="toggle_visibility"
|
||||
phx-value-id={product.id}
|
||||
aria-pressed={to_string(product.visible)}
|
||||
aria-label={"Toggle visibility for #{product.title}"}
|
||||
class={[
|
||||
"admin-btn admin-btn-ghost admin-btn-sm",
|
||||
product.visible && "text-green-600",
|
||||
!product.visible && "text-base-content/30"
|
||||
]}
|
||||
>
|
||||
<.icon :if={product.visible} name="hero-eye" class="size-5" />
|
||||
<.icon :if={!product.visible} name="hero-eye-slash" class="size-5" />
|
||||
</button>
|
||||
</:col>
|
||||
</.table>
|
||||
|
||||
<div :if={@product_count == 0} class="text-center py-12 text-base-content/60">
|
||||
<.icon name="hero-cube" class="size-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No products yet</p>
|
||||
<p class="text-sm mt-1">
|
||||
<.link navigate={~p"/admin/providers"} class="admin-link">
|
||||
Connect a provider
|
||||
</.link>
|
||||
to sync your products.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper components
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp filter_select(assigns) do
|
||||
~H"""
|
||||
<label class="w-auto">
|
||||
<span class="text-xs mb-0.5">{@label}</span>
|
||||
<select name={@name} class="admin-select admin-select-sm" aria-label={@label}>
|
||||
<option :for={{label, value} <- @options} value={value} selected={value == @value}>
|
||||
{label}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
||||
defp product_thumbnail(assigns) do
|
||||
image = Product.primary_image(assigns.product)
|
||||
|
||||
url =
|
||||
if image do
|
||||
ProductImage.thumbnail_url(image)
|
||||
end
|
||||
|
||||
alt = (image && image.alt) || assigns.product.title
|
||||
|
||||
assigns = assign(assigns, url: url, alt: alt)
|
||||
|
||||
~H"""
|
||||
<div class="w-10 h-10 rounded bg-base-200 overflow-hidden flex-shrink-0">
|
||||
<img :if={@url} src={@url} alt={@alt} class="w-full h-full object-cover" loading="lazy" />
|
||||
<div :if={!@url} class="w-full h-full flex items-center justify-center">
|
||||
<.icon name="hero-photo" class="size-5 text-base-content/30" />
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp provider_badge(assigns) do
|
||||
label =
|
||||
case assigns.connection.provider_type do
|
||||
"printify" -> "Printify"
|
||||
"printful" -> "Printful"
|
||||
other -> String.capitalize(other)
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :label, label)
|
||||
|
||||
~H"""
|
||||
<span class="inline-flex items-center rounded-full bg-base-200 px-1.5 py-0.5 text-xs text-base-content/60 mt-0.5">
|
||||
{@label}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp stock_badge(assigns) do
|
||||
{bg, text, ring, label} =
|
||||
if assigns.in_stock do
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "In stock"}
|
||||
else
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "Out of stock"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, label: label)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
{@label}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp maybe_add_filter(opts, _key, "all"), do: opts
|
||||
defp maybe_add_filter(opts, key, value), do: Keyword.put(opts, key, value)
|
||||
|
||||
defp maybe_add_visibility(opts, "visible"), do: Keyword.put(opts, :visible, true)
|
||||
defp maybe_add_visibility(opts, "hidden"), do: Keyword.put(opts, :visible, false)
|
||||
defp maybe_add_visibility(opts, _), do: opts
|
||||
|
||||
defp maybe_add_stock(opts, "in_stock"), do: Keyword.put(opts, :in_stock, true)
|
||||
defp maybe_add_stock(opts, "out_of_stock"), do: Keyword.put(opts, :in_stock, false)
|
||||
defp maybe_add_stock(opts, _), do: opts
|
||||
end
|
||||
163
lib/berrypod_web/live/admin/providers/form.ex
Normal file
163
lib/berrypod_web/live/admin/providers/form.ex
Normal file
@@ -0,0 +1,163 @@
|
||||
defmodule BerrypodWeb.Admin.Providers.Form do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.ProviderConnection
|
||||
alias Berrypod.Providers
|
||||
|
||||
@supported_types ~w(printify printful)
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
{:ok, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, params) do
|
||||
provider_type = validated_type(params["type"])
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Connect to #{provider_label(provider_type)}")
|
||||
|> assign(:provider_type, provider_type)
|
||||
|> assign(:connection, %ProviderConnection{provider_type: provider_type})
|
||||
|> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{})))
|
||||
|> assign(:testing, false)
|
||||
|> assign(:test_result, nil)
|
||||
|> assign(:pending_api_key, nil)
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
connection = Products.get_provider_connection!(id)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "#{provider_label(connection.provider_type)} settings")
|
||||
|> assign(:provider_type, connection.provider_type)
|
||||
|> assign(:connection, connection)
|
||||
|> assign(:form, to_form(ProviderConnection.changeset(connection, %{})))
|
||||
|> assign(:testing, false)
|
||||
|> assign(:test_result, nil)
|
||||
|> assign(:pending_api_key, nil)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"provider_connection" => params}, socket) do
|
||||
form =
|
||||
socket.assigns.connection
|
||||
|> ProviderConnection.changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
# Store api_key separately since changeset encrypts it immediately
|
||||
{:noreply, assign(socket, form: form, pending_api_key: params["api_key"])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("test_connection", _params, socket) do
|
||||
socket = assign(socket, testing: true, test_result: nil)
|
||||
|
||||
api_key =
|
||||
socket.assigns[:pending_api_key] ||
|
||||
ProviderConnection.get_api_key(socket.assigns.connection)
|
||||
|
||||
if api_key && api_key != "" do
|
||||
temp_conn = %ProviderConnection{
|
||||
provider_type: socket.assigns.provider_type,
|
||||
api_key_encrypted: encrypt_api_key(api_key)
|
||||
}
|
||||
|
||||
result = Providers.test_connection(temp_conn)
|
||||
{:noreply, assign(socket, testing: false, test_result: result)}
|
||||
else
|
||||
{:noreply, assign(socket, testing: false, test_result: {:error, :no_api_key})}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"provider_connection" => params}, socket) do
|
||||
save_connection(socket, socket.assigns.live_action, params)
|
||||
end
|
||||
|
||||
defp save_connection(socket, :new, params) do
|
||||
provider_type = socket.assigns.provider_type
|
||||
|
||||
params =
|
||||
params
|
||||
|> Map.put("provider_type", provider_type)
|
||||
|> maybe_add_config(provider_type, socket.assigns.test_result)
|
||||
|> maybe_add_name(provider_type, socket.assigns.test_result)
|
||||
|
||||
case Products.create_provider_connection(params) do
|
||||
{:ok, _connection} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Connected to #{provider_label(provider_type)}!")
|
||||
|> push_navigate(to: ~p"/admin/settings")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_connection(socket, :edit, params) do
|
||||
case Products.update_provider_connection(socket.assigns.connection, params) do
|
||||
{:ok, _connection} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Settings saved")
|
||||
|> push_navigate(to: ~p"/admin/settings")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
# Printify returns shop_id, Printful returns store_id
|
||||
defp maybe_add_config(params, "printify", {:ok, %{shop_id: shop_id}}) do
|
||||
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
|
||||
Map.put(params, "config", config)
|
||||
end
|
||||
|
||||
defp maybe_add_config(params, "printful", {:ok, %{store_id: store_id}}) do
|
||||
config = Map.get(params, "config", %{}) |> Map.put("store_id", to_string(store_id))
|
||||
Map.put(params, "config", config)
|
||||
end
|
||||
|
||||
defp maybe_add_config(params, _type, _result), do: params
|
||||
|
||||
defp maybe_add_name(params, "printify", {:ok, %{shop_name: name}}) when is_binary(name) do
|
||||
Map.put_new(params, "name", name)
|
||||
end
|
||||
|
||||
defp maybe_add_name(params, "printful", {:ok, %{store_name: name}}) when is_binary(name) do
|
||||
Map.put_new(params, "name", name)
|
||||
end
|
||||
|
||||
defp maybe_add_name(params, type, _result) do
|
||||
Map.put_new(params, "name", provider_label(type))
|
||||
end
|
||||
|
||||
defp encrypt_api_key(api_key) do
|
||||
case Berrypod.Vault.encrypt(api_key) do
|
||||
{:ok, encrypted} -> encrypted
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp validated_type(type) when type in @supported_types, do: type
|
||||
defp validated_type(_), do: "printify"
|
||||
|
||||
# Shared helpers used by the template
|
||||
|
||||
defp provider_label("printful"), do: "Printful"
|
||||
defp provider_label(_), do: "Printify"
|
||||
|
||||
defp connection_name({:ok, %{shop_name: name}}), do: name
|
||||
defp connection_name({:ok, %{store_name: name}}), do: name
|
||||
defp connection_name(_), do: nil
|
||||
|
||||
defp format_error(:no_api_key), do: "Please enter your API key"
|
||||
defp format_error(:unauthorized), do: "That key doesn't seem to be valid"
|
||||
defp format_error(:timeout), do: "Couldn't reach the provider - try again"
|
||||
defp format_error({:http_error, _code}), do: "Something went wrong - try again"
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_), do: "Connection failed - check your key and try again"
|
||||
end
|
||||
135
lib/berrypod_web/live/admin/providers/form.html.heex
Normal file
135
lib/berrypod_web/live/admin/providers/form.html.heex
Normal file
@@ -0,0 +1,135 @@
|
||||
<.header>
|
||||
{if @live_action == :new,
|
||||
do: "Connect to #{provider_label(@provider_type)}",
|
||||
else: "#{provider_label(@provider_type)} settings"}
|
||||
</.header>
|
||||
|
||||
<div class="max-w-xl mt-6">
|
||||
<%= if @live_action == :new do %>
|
||||
<div class="prose prose-sm mb-6">
|
||||
<p>
|
||||
{provider_label(@provider_type)} is a print-on-demand service that prints and ships products for you.
|
||||
Connect your account to automatically import your products into your shop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= if @provider_type == "printify" do %>
|
||||
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
|
||||
<p class="font-medium mb-2">Get your API key from Printify:</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-base-content/80">
|
||||
<li>
|
||||
<a
|
||||
href="https://printify.com/app/auth/login"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-link"
|
||||
>
|
||||
Log in to Printify
|
||||
</a>
|
||||
(or <a
|
||||
href="https://printify.com/app/auth/register"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-link"
|
||||
>create a free account</a>)
|
||||
</li>
|
||||
<li>Click <strong>Account</strong> (top right)</li>
|
||||
<li>Select <strong>Connections</strong> from the dropdown</li>
|
||||
<li>Find <strong>API tokens</strong> and click <strong>Generate</strong></li>
|
||||
<li>
|
||||
Enter a name (e.g. "My Shop"), keep <strong>all scopes</strong>
|
||||
selected, and click <strong>Generate token</strong>
|
||||
</li>
|
||||
<li>Click <strong>Copy to clipboard</strong> and paste it below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
|
||||
<p class="font-medium mb-2">Get your API key from Printful:</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-base-content/80">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.printful.com/auth/login"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-link"
|
||||
>
|
||||
Log in to Printful
|
||||
</a>
|
||||
(or <a
|
||||
href="https://www.printful.com/auth/signup"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-link"
|
||||
>create a free account</a>)
|
||||
</li>
|
||||
<li>Go to <strong>Settings</strong> → <strong>API access</strong></li>
|
||||
<li>Click <strong>Create API key</strong></li>
|
||||
<li>Give it a name and select <strong>all scopes</strong></li>
|
||||
<li>Copy the token and paste it below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<.form for={@form} id="provider-form" phx-change="validate" phx-submit="save">
|
||||
<input type="hidden" name="provider_connection[provider_type]" value={@provider_type} />
|
||||
|
||||
<.input
|
||||
field={@form[:api_key]}
|
||||
type="password"
|
||||
label={"#{provider_label(@provider_type)} API key"}
|
||||
placeholder={
|
||||
if @live_action == :edit,
|
||||
do: "Leave blank to keep current key",
|
||||
else: "Paste your key here"
|
||||
}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||
phx-click="test_connection"
|
||||
disabled={@testing}
|
||||
>
|
||||
<.icon
|
||||
name={if @testing, do: "hero-arrow-path", else: "hero-signal"}
|
||||
class={if @testing, do: "size-4 animate-spin", else: "size-4"}
|
||||
/>
|
||||
{if @testing, do: "Checking...", else: "Check connection"}
|
||||
</button>
|
||||
|
||||
<div :if={@test_result} class="text-sm">
|
||||
<%= case @test_result do %>
|
||||
<% {:ok, _info} -> %>
|
||||
<span class="text-success flex items-center gap-1">
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
Connected to {connection_name(@test_result) || provider_label(@provider_type)}
|
||||
</span>
|
||||
<% {:error, reason} -> %>
|
||||
<span class="text-error flex items-center gap-1">
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{format_error(reason)}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @live_action == :edit do %>
|
||||
<.input field={@form[:enabled]} type="checkbox" label="Connection enabled" />
|
||||
<% end %>
|
||||
|
||||
<div class="flex gap-2 mt-6">
|
||||
<.button type="submit" disabled={@testing}>
|
||||
{if @live_action == :new,
|
||||
do: "Connect to #{provider_label(@provider_type)}",
|
||||
else: "Save changes"}
|
||||
</.button>
|
||||
<.link navigate={~p"/admin/providers"} class="admin-btn admin-btn-ghost">
|
||||
Cancel
|
||||
</.link>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
98
lib/berrypod_web/live/admin/providers/index.ex
Normal file
98
lib/berrypod_web/live/admin/providers/index.ex
Normal file
@@ -0,0 +1,98 @@
|
||||
defmodule BerrypodWeb.Admin.Providers.Index do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.ProviderConnection
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
connections = Products.list_provider_connections()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Provider connections")
|
||||
|> stream(:connections, connections)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
connection = Products.get_provider_connection!(id)
|
||||
{:ok, _} = Products.delete_provider_connection(connection)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream_delete(:connections, connection)
|
||||
|> put_flash(:info, "Provider connection deleted")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sync", %{"id" => id}, socket) do
|
||||
connection = Products.get_provider_connection!(id)
|
||||
|
||||
case Products.enqueue_sync(connection) do
|
||||
{:ok, _job} ->
|
||||
# Update the connection status in the stream
|
||||
updated = %{connection | sync_status: "syncing"}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream_insert(:connections, updated)
|
||||
|> put_flash(:info, "Sync started for #{connection.name}")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to start sync")}
|
||||
end
|
||||
end
|
||||
|
||||
# Function components for the template
|
||||
|
||||
attr :status, :string, required: true
|
||||
attr :enabled, :boolean, required: true
|
||||
|
||||
defp status_indicator(assigns) do
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex size-3 rounded-full",
|
||||
cond do
|
||||
not @enabled -> "bg-base-content/30"
|
||||
@status == "syncing" -> "bg-warning animate-pulse"
|
||||
@status == "completed" -> "bg-success"
|
||||
@status == "failed" -> "bg-error"
|
||||
true -> "bg-base-content/30"
|
||||
end
|
||||
]} />
|
||||
"""
|
||||
end
|
||||
|
||||
attr :connection, ProviderConnection, required: true
|
||||
|
||||
defp connection_info(assigns) do
|
||||
product_count = Products.count_products_for_connection(assigns.connection.id)
|
||||
assigns = assign(assigns, :product_count, product_count)
|
||||
|
||||
~H"""
|
||||
<span>
|
||||
<.icon name="hero-cube" class="size-4 inline" />
|
||||
{@product_count} {if @product_count == 1, do: "product", else: "products"}
|
||||
</span>
|
||||
<span :if={@connection.last_synced_at}>
|
||||
<.icon name="hero-clock" class="size-4 inline" />
|
||||
Last synced {format_relative_time(@connection.last_synced_at)}
|
||||
</span>
|
||||
<span :if={!@connection.last_synced_at} class="text-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="size-4 inline" /> Never synced
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_relative_time(datetime) do
|
||||
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
||||
|
||||
cond do
|
||||
diff < 60 -> "just now"
|
||||
diff < 3600 -> "#{div(diff, 60)} min ago"
|
||||
diff < 86400 -> "#{div(diff, 3600)} hours ago"
|
||||
true -> "#{div(diff, 86400)} days ago"
|
||||
end
|
||||
end
|
||||
end
|
||||
94
lib/berrypod_web/live/admin/providers/index.html.heex
Normal file
94
lib/berrypod_web/live/admin/providers/index.html.heex
Normal file
@@ -0,0 +1,94 @@
|
||||
<.header>
|
||||
Providers
|
||||
<:actions>
|
||||
<div class="admin-dropdown">
|
||||
<div tabindex="0" role="button" class="admin-btn admin-btn-primary">
|
||||
<.icon name="hero-plus" class="size-4 mr-1" /> Connect provider
|
||||
</div>
|
||||
<ul tabindex="0" class="admin-dropdown-content">
|
||||
<li>
|
||||
<.link navigate={~p"/admin/providers/new?type=printify"}>Printify</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate={~p"/admin/providers/new?type=printful"}>Printful</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div id="connections" phx-update="stream" class="mt-6 space-y-4">
|
||||
<div id="connections-empty" class="hidden only:block text-center py-12">
|
||||
<.icon name="hero-cube" class="size-16 mx-auto mb-4 text-base-content/30" />
|
||||
<h2 class="text-xl font-medium">Connect a print-on-demand provider</h2>
|
||||
<p class="mt-2 text-base-content/60 max-w-md mx-auto">
|
||||
Connect your Printify or Printful account to import products
|
||||
and start selling.
|
||||
</p>
|
||||
<div class="flex justify-center gap-3 mt-6">
|
||||
<.button navigate={~p"/admin/providers/new?type=printify"}>
|
||||
Connect Printify
|
||||
</.button>
|
||||
<.button navigate={~p"/admin/providers/new?type=printful"} class="admin-btn-outline">
|
||||
Connect Printful
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:for={{dom_id, connection} <- @streams.connections}
|
||||
id={dom_id}
|
||||
class="admin-card"
|
||||
>
|
||||
<div class="admin-card-body">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<.status_indicator status={connection.sync_status} enabled={connection.enabled} />
|
||||
<h3 class="font-semibold text-lg">
|
||||
{String.capitalize(connection.provider_type)}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-base-content/70 mt-1">{connection.name}</p>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-sm text-base-content/60">
|
||||
<.connection_info connection={connection} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<.link
|
||||
navigate={~p"/admin/providers/#{connection.id}/edit"}
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
>
|
||||
Settings
|
||||
</.link>
|
||||
<button
|
||||
phx-click="delete"
|
||||
phx-value-id={connection.id}
|
||||
data-confirm={"Disconnect from #{String.capitalize(connection.provider_type)}? Your synced products will remain in your shop."}
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm text-error"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card-actions">
|
||||
<button
|
||||
phx-click="sync"
|
||||
phx-value-id={connection.id}
|
||||
disabled={connection.sync_status == "syncing"}
|
||||
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||
>
|
||||
<.icon
|
||||
name="hero-arrow-path"
|
||||
class={
|
||||
if connection.sync_status == "syncing", do: "size-4 animate-spin", else: "size-4"
|
||||
}
|
||||
/>
|
||||
{if connection.sync_status == "syncing", do: "Syncing...", else: "Sync products"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
691
lib/berrypod_web/live/admin/settings.ex
Normal file
691
lib/berrypod_web/live/admin/settings.ex
Normal file
@@ -0,0 +1,691 @@
|
||||
defmodule BerrypodWeb.Admin.Settings do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Accounts
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Settings
|
||||
alias Berrypod.Stripe.Setup, as: StripeSetup
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Settings")
|
||||
|> assign(:site_live, Settings.site_live?())
|
||||
|> assign_stripe_state()
|
||||
|> assign_products_state()
|
||||
|> assign_account_state(user)}
|
||||
end
|
||||
|
||||
# -- Stripe assigns --
|
||||
|
||||
defp assign_stripe_state(socket) do
|
||||
has_key = Settings.has_secret?("stripe_api_key")
|
||||
has_signing = Settings.has_secret?("stripe_signing_secret")
|
||||
|
||||
status =
|
||||
cond do
|
||||
!has_key -> :not_configured
|
||||
has_key && StripeSetup.localhost?() -> :connected_localhost
|
||||
true -> :connected
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:stripe_status, status)
|
||||
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
|
||||
|> assign(:stripe_signing_secret_hint, Settings.secret_hint("stripe_signing_secret"))
|
||||
|> assign(:stripe_webhook_url, StripeSetup.webhook_url())
|
||||
|> assign(:stripe_has_signing_secret, has_signing)
|
||||
|> assign(:connect_form, to_form(%{"api_key" => ""}, as: :stripe))
|
||||
|> assign(:secret_form, to_form(%{"signing_secret" => ""}, as: :webhook))
|
||||
|> assign(:stripe_advanced_open, false)
|
||||
|> assign(:connecting, false)
|
||||
end
|
||||
|
||||
# -- Products assigns --
|
||||
|
||||
defp assign_products_state(socket) do
|
||||
connections = Products.list_provider_connections()
|
||||
|
||||
connection_info =
|
||||
case connections do
|
||||
[conn | _] ->
|
||||
product_count = Products.count_products_for_connection(conn.id)
|
||||
%{connection: conn, product_count: product_count}
|
||||
|
||||
[] ->
|
||||
nil
|
||||
end
|
||||
|
||||
assign(socket, :provider, connection_info)
|
||||
end
|
||||
|
||||
# -- Account assigns --
|
||||
|
||||
defp assign_account_state(socket, user) do
|
||||
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
|
||||
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
|
||||
|
||||
socket
|
||||
|> assign(:current_email, user.email)
|
||||
|> assign(:email_form, to_form(email_changeset))
|
||||
|> assign(:password_form, to_form(password_changeset))
|
||||
|> assign(:trigger_submit, false)
|
||||
end
|
||||
|
||||
# -- Events: shop status --
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_site_live", _params, socket) do
|
||||
new_value = !socket.assigns.site_live
|
||||
{:ok, _} = Settings.set_site_live(new_value)
|
||||
|
||||
message = if new_value, do: "Shop is now live", else: "Shop taken offline"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:site_live, new_value)
|
||||
|> put_flash(:info, message)}
|
||||
end
|
||||
|
||||
# -- Events: Stripe --
|
||||
|
||||
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
||||
if api_key == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")}
|
||||
else
|
||||
socket = assign(socket, :connecting, true)
|
||||
|
||||
case StripeSetup.connect(api_key) do
|
||||
{:ok, :webhook_created} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(:info, "Stripe connected and webhook endpoint created")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:ok, :localhost} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(
|
||||
:info,
|
||||
"API key saved. Enter a webhook signing secret below for local testing."
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, message} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:connecting, false)
|
||||
|> put_flash(:error, "Stripe connection failed: #{message}")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("disconnect_stripe", _params, socket) do
|
||||
StripeSetup.disconnect()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(:info, "Stripe disconnected")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("save_signing_secret", %{"webhook" => %{"signing_secret" => secret}}, socket) do
|
||||
if secret == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please enter a signing secret")}
|
||||
else
|
||||
StripeSetup.save_signing_secret(secret)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(:info, "Webhook signing secret saved")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_stripe_advanced", _params, socket) do
|
||||
{:noreply, assign(socket, :stripe_advanced_open, !socket.assigns.stripe_advanced_open)}
|
||||
end
|
||||
|
||||
# -- Events: products --
|
||||
|
||||
def handle_event("sync", %{"id" => id}, socket) do
|
||||
connection = Products.get_provider_connection!(id)
|
||||
|
||||
case Products.enqueue_sync(connection) do
|
||||
{:ok, _job} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_products_state()
|
||||
|> put_flash(:info, "Sync started for #{connection.name}")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to start sync")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete_connection", %{"id" => id}, socket) do
|
||||
connection = Products.get_provider_connection!(id)
|
||||
{:ok, _} = Products.delete_provider_connection(connection)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_products_state()
|
||||
|> put_flash(:info, "Provider connection deleted")}
|
||||
end
|
||||
|
||||
# -- Events: account --
|
||||
|
||||
def handle_event("validate_email", %{"user" => user_params}, socket) do
|
||||
email_form =
|
||||
socket.assigns.current_scope.user
|
||||
|> Accounts.change_user_email(user_params, validate_unique: false)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, email_form: email_form)}
|
||||
end
|
||||
|
||||
def handle_event("update_email", %{"user" => user_params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
unless Accounts.sudo_mode?(user) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Please log in again to change account settings.")
|
||||
|> redirect(to: ~p"/users/log-in")}
|
||||
else
|
||||
case Accounts.change_user_email(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
Accounts.deliver_user_update_email_instructions(
|
||||
Ecto.Changeset.apply_action!(changeset, :insert),
|
||||
user.email,
|
||||
&url(~p"/users/settings/confirm-email/#{&1}")
|
||||
)
|
||||
|
||||
info = "A link to confirm your email change has been sent to the new address."
|
||||
{:noreply, put_flash(socket, :info, info)}
|
||||
|
||||
changeset ->
|
||||
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_password", %{"user" => user_params}, socket) do
|
||||
password_form =
|
||||
socket.assigns.current_scope.user
|
||||
|> Accounts.change_user_password(user_params, hash_password: false)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, password_form: password_form)}
|
||||
end
|
||||
|
||||
def handle_event("update_password", %{"user" => user_params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
unless Accounts.sudo_mode?(user) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Please log in again to change account settings.")
|
||||
|> redirect(to: ~p"/users/log-in")}
|
||||
else
|
||||
case Accounts.change_user_password(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
|
||||
|
||||
changeset ->
|
||||
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# -- Render --
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl">
|
||||
<.header>
|
||||
Settings
|
||||
</.header>
|
||||
|
||||
<%!-- Shop status --%>
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Shop status</h2>
|
||||
<%= if @site_live do %>
|
||||
<.status_pill color="green">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Live
|
||||
</.status_pill>
|
||||
<% else %>
|
||||
<.status_pill color="zinc">Offline</.status_pill>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
<%= if @site_live do %>
|
||||
Your shop is visible to the public.
|
||||
<% else %>
|
||||
Your shop is offline. Visitors see a "coming soon" page.
|
||||
<% end %>
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
phx-click="toggle_site_live"
|
||||
class={[
|
||||
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold shadow-xs",
|
||||
if(@site_live,
|
||||
do: "bg-base-200 text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset",
|
||||
else: "bg-green-600 text-white hover:bg-green-500"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= if @site_live do %>
|
||||
<.icon name="hero-eye-slash-mini" class="size-4" /> Take offline
|
||||
<% else %>
|
||||
<.icon name="hero-eye-mini" class="size-4" /> Go live
|
||||
<% end %>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Payments --%>
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Payments</h2>
|
||||
<%= case @stripe_status do %>
|
||||
<% :connected -> %>
|
||||
<.status_pill color="green">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Connected
|
||||
</.status_pill>
|
||||
<% :connected_localhost -> %>
|
||||
<.status_pill color="amber">
|
||||
<.icon name="hero-exclamation-triangle-mini" class="size-3" /> Dev mode
|
||||
</.status_pill>
|
||||
<% :not_configured -> %>
|
||||
<.status_pill color="zinc">Not connected</.status_pill>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @stripe_status == :not_configured do %>
|
||||
<.stripe_setup_form connect_form={@connect_form} connecting={@connecting} />
|
||||
<% else %>
|
||||
<.stripe_connected_view
|
||||
stripe_status={@stripe_status}
|
||||
stripe_api_key_hint={@stripe_api_key_hint}
|
||||
stripe_webhook_url={@stripe_webhook_url}
|
||||
stripe_signing_secret_hint={@stripe_signing_secret_hint}
|
||||
stripe_has_signing_secret={@stripe_has_signing_secret}
|
||||
secret_form={@secret_form}
|
||||
advanced_open={@stripe_advanced_open}
|
||||
/>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<%!-- Products --%>
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Products</h2>
|
||||
<%= if @provider do %>
|
||||
<.status_pill color="green">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Connected
|
||||
</.status_pill>
|
||||
<% else %>
|
||||
<.status_pill color="zinc">Not connected</.status_pill>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @provider do %>
|
||||
<.provider_connected provider={@provider} />
|
||||
<% else %>
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-base-content/60">
|
||||
Connect a print-on-demand provider to import products into your shop.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<.link
|
||||
navigate={~p"/admin/providers"}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-base-content px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-base-content/80"
|
||||
>
|
||||
<.icon name="hero-plus-mini" class="size-4" /> Connect a provider
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<%!-- Account --%>
|
||||
<section class="mt-10">
|
||||
<h2 class="text-lg font-semibold">Account</h2>
|
||||
|
||||
<div class="mt-4 space-y-6">
|
||||
<.form
|
||||
for={@email_form}
|
||||
id="email_form"
|
||||
phx-submit="update_email"
|
||||
phx-change="validate_email"
|
||||
>
|
||||
<.input
|
||||
field={@email_form[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Saving...">Change email</.button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<div class="border-t border-base-200 pt-6">
|
||||
<.form
|
||||
for={@password_form}
|
||||
id="password_form"
|
||||
action={~p"/users/update-password"}
|
||||
method="post"
|
||||
phx-change="validate_password"
|
||||
phx-submit="update_password"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<input
|
||||
name={@password_form[:email].name}
|
||||
type="hidden"
|
||||
id="hidden_user_email"
|
||||
autocomplete="username"
|
||||
value={@current_email}
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password]}
|
||||
type="password"
|
||||
label="New password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password_confirmation]}
|
||||
type="password"
|
||||
label="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Saving...">Change password</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Advanced --%>
|
||||
<section class="mt-10 pb-10">
|
||||
<h2 class="text-lg font-semibold">Advanced</h2>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<.link
|
||||
href={~p"/admin/dashboard"}
|
||||
class="text-sm text-base-content/60 hover:text-base-content"
|
||||
>
|
||||
<.icon name="hero-chart-bar" class="size-4 inline" /> System dashboard
|
||||
</.link>
|
||||
<.link href={~p"/admin/errors"} class="text-sm text-base-content/60 hover:text-base-content">
|
||||
<.icon name="hero-bug-ant" class="size-4 inline" /> Error tracker
|
||||
</.link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Function components --
|
||||
|
||||
attr :color, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp status_pill(assigns) do
|
||||
classes =
|
||||
case assigns.color do
|
||||
"green" -> "bg-green-50 text-green-700 ring-green-600/20"
|
||||
"amber" -> "bg-amber-50 text-amber-700 ring-amber-600/20"
|
||||
"zinc" -> "bg-base-200/50 text-base-content/60 ring-base-content/10"
|
||||
_ -> "bg-base-200/50 text-base-content/60 ring-base-content/10"
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :classes, classes)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@classes
|
||||
]}>
|
||||
{render_slot(@inner_block)}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :provider, :map, required: true
|
||||
|
||||
defp provider_connected(assigns) do
|
||||
conn = assigns.provider.connection
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:connection, conn)
|
||||
|> assign(:product_count, assigns.provider.product_count)
|
||||
|> assign(:syncing, conn.sync_status == "syncing")
|
||||
|> assign(:provider_label, String.capitalize(conn.provider_type))
|
||||
|
||||
~H"""
|
||||
<div class="mt-4">
|
||||
<dl class="text-sm">
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Provider</dt>
|
||||
<dd class="text-base-content">{@provider_label}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Shop</dt>
|
||||
<dd class="text-base-content">{@connection.name}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Products</dt>
|
||||
<dd class="text-base-content">{@product_count}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Last synced</dt>
|
||||
<dd class="text-base-content">
|
||||
<%= if @connection.last_synced_at do %>
|
||||
{format_relative_time(@connection.last_synced_at)}
|
||||
<% else %>
|
||||
<span class="text-amber-600">Never</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
phx-click="sync"
|
||||
phx-value-id={@connection.id}
|
||||
disabled={@syncing}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-base-200 px-3 py-1.5 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
|
||||
>
|
||||
<.icon
|
||||
name="hero-arrow-path"
|
||||
class={if @syncing, do: "size-4 animate-spin", else: "size-4"}
|
||||
/>
|
||||
{if @syncing, do: "Syncing...", else: "Sync products"}
|
||||
</button>
|
||||
<.link
|
||||
navigate={~p"/admin/providers/#{@connection.id}/edit"}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-base-200 px-3 py-1.5 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
|
||||
>
|
||||
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
|
||||
</.link>
|
||||
<button
|
||||
phx-click="delete_connection"
|
||||
phx-value-id={@connection.id}
|
||||
data-confirm={"Disconnect from #{@provider_label}? Your synced products will remain in your shop."}
|
||||
class="text-sm text-red-600 hover:text-red-800 px-2 py-1.5"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp stripe_setup_form(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-base-content/60">
|
||||
To accept payments, connect your Stripe account by entering your secret key.
|
||||
You can find it in your
|
||||
<a
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-base-content underline"
|
||||
>
|
||||
Stripe dashboard
|
||||
</a>
|
||||
under Developers → API keys.
|
||||
</p>
|
||||
|
||||
<.form for={@connect_form} phx-submit="connect_stripe" class="mt-6">
|
||||
<.input
|
||||
field={@connect_form[:api_key]}
|
||||
type="password"
|
||||
label="Secret key"
|
||||
autocomplete="off"
|
||||
placeholder="sk_test_... or sk_live_..."
|
||||
/>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
Starts with <code>sk_test_</code> (test mode) or <code>sk_live_</code> (live mode).
|
||||
This key is encrypted at rest in the database.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<.button phx-disable-with="Connecting...">
|
||||
Connect Stripe
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp stripe_connected_view(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4 space-y-4">
|
||||
<dl class="text-sm">
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">API key</dt>
|
||||
<dd><code class="text-base-content">{@stripe_api_key_hint}</code></dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Webhook URL</dt>
|
||||
<dd><code class="text-base-content text-xs break-all">{@stripe_webhook_url}</code></dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-base-content/60 w-28 shrink-0">Webhook secret</dt>
|
||||
<dd>
|
||||
<%= if @stripe_has_signing_secret do %>
|
||||
<code class="text-base-content">{@stripe_signing_secret_hint}</code>
|
||||
<% else %>
|
||||
<span class="text-amber-600">Not set</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<%= if @stripe_status == :connected_localhost do %>
|
||||
<div class="rounded-md bg-amber-50 p-4 ring-1 ring-amber-600/10 ring-inset">
|
||||
<p class="text-sm text-amber-800">
|
||||
Stripe can't reach localhost for webhooks. For local testing, run the Stripe CLI:
|
||||
</p>
|
||||
<pre class="mt-2 rounded bg-amber-100 p-2 text-xs text-amber-900 overflow-x-auto">stripe listen --forward-to localhost:4000/webhooks/stripe</pre>
|
||||
<p class="mt-2 text-xs text-amber-700">
|
||||
The CLI will output a signing secret starting with <code>whsec_</code>. Enter it below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.form for={@secret_form} phx-submit="save_signing_secret" class="mt-2">
|
||||
<.input
|
||||
field={@secret_form[:signing_secret]}
|
||||
type="password"
|
||||
label="Webhook signing secret"
|
||||
autocomplete="off"
|
||||
placeholder="whsec_..."
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Saving...">Save signing secret</.button>
|
||||
</div>
|
||||
</.form>
|
||||
<% else %>
|
||||
<div class="border-t border-base-200 pt-3">
|
||||
<button
|
||||
phx-click="toggle_stripe_advanced"
|
||||
class="flex items-center gap-1 text-sm text-base-content/60 hover:text-base-content"
|
||||
>
|
||||
<.icon
|
||||
name={if @advanced_open, do: "hero-chevron-down-mini", else: "hero-chevron-right-mini"}
|
||||
class="size-4"
|
||||
/> Advanced
|
||||
</button>
|
||||
|
||||
<%= if @advanced_open do %>
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-base-content/60 mb-3">
|
||||
Override the webhook signing secret if you need to use a custom endpoint or the Stripe CLI.
|
||||
</p>
|
||||
<.form for={@secret_form} phx-submit="save_signing_secret">
|
||||
<.input
|
||||
field={@secret_form[:signing_secret]}
|
||||
type="password"
|
||||
label="Webhook signing secret"
|
||||
autocomplete="off"
|
||||
placeholder="whsec_..."
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Saving...">Save signing secret</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="border-t border-base-200 pt-4">
|
||||
<button
|
||||
phx-click="disconnect_stripe"
|
||||
data-confirm="This will remove your Stripe API key and delete the webhook endpoint. Are you sure?"
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Disconnect Stripe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_relative_time(datetime) do
|
||||
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
||||
|
||||
cond do
|
||||
diff < 60 -> "just now"
|
||||
diff < 3600 -> "#{div(diff, 60)} min ago"
|
||||
diff < 86400 -> "#{div(diff, 3600)} hours ago"
|
||||
true -> "#{div(diff, 86400)} days ago"
|
||||
end
|
||||
end
|
||||
end
|
||||
482
lib/berrypod_web/live/admin/theme/index.ex
Normal file
482
lib/berrypod_web/live/admin/theme/index.ex
Normal file
@@ -0,0 +1,482 @@
|
||||
defmodule BerrypodWeb.Admin.Theme.Index do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Settings
|
||||
alias Berrypod.Media
|
||||
alias Berrypod.Theme.{CSSGenerator, Presets, PreviewData}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
theme_settings = Settings.get_theme_settings()
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
preview_data = %{
|
||||
products: PreviewData.products(),
|
||||
cart_items: PreviewData.cart_items(),
|
||||
testimonials: PreviewData.testimonials(),
|
||||
categories: PreviewData.categories()
|
||||
}
|
||||
|
||||
logo_image = Media.get_logo()
|
||||
header_image = Media.get_header()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:preview_page, :home)
|
||||
|> assign(:presets_with_descriptions, Presets.all_with_descriptions())
|
||||
|> assign(:active_preset, active_preset)
|
||||
|> assign(:preview_data, preview_data)
|
||||
|> assign(:logo_image, logo_image)
|
||||
|> assign(:header_image, header_image)
|
||||
|> assign(:customise_open, false)
|
||||
|> assign(:sidebar_collapsed, false)
|
||||
|> assign(:cart_drawer_open, false)
|
||||
|> allow_upload(:logo_upload,
|
||||
accept: ~w(.png .jpg .jpeg .webp .svg),
|
||||
max_entries: 1,
|
||||
max_file_size: 2_000_000,
|
||||
auto_upload: true,
|
||||
progress: &handle_progress/3
|
||||
)
|
||||
|> allow_upload(:header_upload,
|
||||
accept: ~w(.png .jpg .jpeg .webp),
|
||||
max_entries: 1,
|
||||
max_file_size: 5_000_000,
|
||||
auto_upload: true,
|
||||
progress: &handle_progress/3
|
||||
)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp handle_progress(:logo_upload, entry, socket) do
|
||||
if entry.done? do
|
||||
consume_uploaded_entries(socket, :logo_upload, fn %{path: path}, entry ->
|
||||
case Media.upload_from_entry(path, entry, "logo") do
|
||||
{:ok, image} ->
|
||||
Settings.update_theme_settings(%{logo_image_id: image.id})
|
||||
{:ok, image}
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
[image | _] ->
|
||||
{:noreply, assign(socket, :logo_image, image)}
|
||||
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_progress(:header_upload, entry, socket) do
|
||||
if entry.done? do
|
||||
consume_uploaded_entries(socket, :header_upload, fn %{path: path}, entry ->
|
||||
case Media.upload_from_entry(path, entry, "header") do
|
||||
{:ok, image} ->
|
||||
Settings.update_theme_settings(%{header_image_id: image.id})
|
||||
{:ok, image}
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
[image | _] ->
|
||||
{:noreply, assign(socket, :header_image, image)}
|
||||
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("apply_preset", %{"preset" => preset_name}, socket) do
|
||||
preset_atom = String.to_existing_atom(preset_name)
|
||||
|
||||
case Settings.apply_preset(preset_atom) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:active_preset, preset_atom)
|
||||
|> put_flash(:info, "Applied #{preset_name} preset")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to apply preset")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("change_preview_page", %{"page" => page_name}, socket) do
|
||||
page_atom = String.to_existing_atom(page_name)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:preview_page, page_atom)
|
||||
|> push_event("scroll-preview-top", %{})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_setting", %{"field" => field, "setting_value" => value}, socket) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
attrs = %{field_atom => value}
|
||||
|
||||
case Settings.update_theme_settings(attrs) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:active_preset, active_preset)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_setting", %{"field" => field} = params, socket) do
|
||||
# For phx-change events from select/input elements, the value comes from the name attribute
|
||||
value = params[field] || params["#{field}_text"] || params["value"]
|
||||
|
||||
if value do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
attrs = %{field_atom => value}
|
||||
|
||||
case Settings.update_theme_settings(attrs) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:active_preset, active_preset)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_color", %{"field" => field, "value" => value}, socket) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
attrs = %{field_atom => value}
|
||||
|
||||
case Settings.update_theme_settings(attrs) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:active_preset, active_preset)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_setting", %{"field" => field}, socket) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
current_value = Map.get(socket.assigns.theme_settings, field_atom)
|
||||
attrs = %{field_atom => !current_value}
|
||||
|
||||
case Settings.update_theme_settings(attrs) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:active_preset, active_preset)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_theme", _params, socket) do
|
||||
socket = put_flash(socket, :info, "Theme saved successfully")
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("remove_logo", _params, socket) do
|
||||
if logo = socket.assigns.logo_image do
|
||||
Media.delete_image(logo)
|
||||
end
|
||||
|
||||
Settings.update_theme_settings(%{logo_image_id: nil})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:logo_image, nil)
|
||||
|> put_flash(:info, "Logo removed")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("remove_header", _params, socket) do
|
||||
if header = socket.assigns.header_image do
|
||||
Media.delete_image(header)
|
||||
end
|
||||
|
||||
Settings.update_theme_settings(%{header_image_id: nil})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:header_image, nil)
|
||||
|> put_flash(:info, "Header image removed")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_upload", %{"ref" => ref, "upload" => upload_name}, socket) do
|
||||
upload_atom = String.to_existing_atom(upload_name)
|
||||
{:noreply, cancel_upload(socket, upload_atom, ref)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_customise", _params, socket) do
|
||||
{:noreply, assign(socket, :customise_open, !socket.assigns.customise_open)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_sidebar", _params, socket) do
|
||||
{:noreply, assign(socket, :sidebar_collapsed, !socket.assigns.sidebar_collapsed)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_cart_drawer", _params, socket) do
|
||||
{:noreply, assign(socket, :cart_drawer_open, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close_cart_drawer", _params, socket) do
|
||||
{:noreply, assign(socket, :cart_drawer_open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("noop", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def error_to_string(:too_large), do: "File is too large"
|
||||
def error_to_string(:too_many_files), do: "Too many files"
|
||||
def error_to_string(:not_accepted), do: "File type not accepted"
|
||||
def error_to_string(err), do: inspect(err)
|
||||
|
||||
defp preview_assigns(assigns) do
|
||||
assign(assigns, %{
|
||||
mode: :preview,
|
||||
products: assigns.preview_data.products,
|
||||
categories: assigns.preview_data.categories,
|
||||
cart_items: PreviewData.cart_drawer_items(),
|
||||
cart_count: 2,
|
||||
cart_subtotal: "£72.00"
|
||||
})
|
||||
end
|
||||
|
||||
# Preview page component — delegates to shared PageTemplates with preview-specific assigns
|
||||
attr :page, :atom, required: true
|
||||
attr :preview_data, :map, required: true
|
||||
attr :theme_settings, :map, required: true
|
||||
attr :logo_image, :any, required: true
|
||||
attr :header_image, :any, required: true
|
||||
attr :cart_drawer_open, :boolean, default: false
|
||||
|
||||
defp preview_page(%{page: :home} = assigns) do
|
||||
assigns = preview_assigns(assigns)
|
||||
~H"<BerrypodWeb.PageTemplates.home {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :collection} = assigns) do
|
||||
assigns = preview_assigns(assigns)
|
||||
~H"<BerrypodWeb.PageTemplates.collection {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :pdp} = assigns) do
|
||||
product = List.first(assigns.preview_data.products)
|
||||
option_types = Map.get(product, :option_types) || []
|
||||
variants = Map.get(product, :variants) || []
|
||||
|
||||
{selected_options, selected_variant} =
|
||||
case variants do
|
||||
[first | _] -> {first.options, first}
|
||||
[] -> {%{}, nil}
|
||||
end
|
||||
|
||||
available_options =
|
||||
Enum.reduce(option_types, %{}, fn opt, acc ->
|
||||
values = Enum.map(opt.values, & &1.title)
|
||||
Map.put(acc, opt.name, values)
|
||||
end)
|
||||
|
||||
display_price =
|
||||
if selected_variant, do: selected_variant.price, else: product.cheapest_price
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(:product, product)
|
||||
|> assign(:gallery_images, build_gallery_images(product))
|
||||
|> assign(:related_products, Enum.slice(assigns.preview_data.products, 1, 4))
|
||||
|> assign(:option_types, option_types)
|
||||
|> assign(:selected_options, selected_options)
|
||||
|> assign(:available_options, available_options)
|
||||
|> assign(:display_price, display_price)
|
||||
|> assign(:quantity, 1)
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.pdp {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :cart} = assigns) do
|
||||
cart_items = assigns.preview_data.cart_items
|
||||
|
||||
subtotal =
|
||||
Enum.reduce(cart_items, 0, fn item, acc ->
|
||||
acc + item.product.cheapest_price * item.quantity
|
||||
end)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(:cart_page_items, cart_items)
|
||||
|> assign(:cart_page_subtotal, subtotal)
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.cart {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :about} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(%{
|
||||
active_page: "about",
|
||||
hero_title: "About the studio",
|
||||
hero_description: "Your story goes here – this is sample content for the demo shop",
|
||||
hero_background: :sunken,
|
||||
image_src: "/mockups/night-sky-blanket-3",
|
||||
image_alt: "Night sky blanket draped over a chair",
|
||||
content_blocks: PreviewData.about_content()
|
||||
})
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :delivery} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(%{
|
||||
active_page: "delivery",
|
||||
hero_title: "Delivery & returns",
|
||||
hero_description: "Everything you need to know about shipping and returns",
|
||||
content_blocks: PreviewData.delivery_content()
|
||||
})
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :privacy} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(%{
|
||||
active_page: "privacy",
|
||||
hero_title: "Privacy policy",
|
||||
hero_description: "How we handle your personal information",
|
||||
content_blocks: PreviewData.privacy_content()
|
||||
})
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :terms} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(%{
|
||||
active_page: "terms",
|
||||
hero_title: "Terms of service",
|
||||
hero_description: "The legal bits",
|
||||
content_blocks: PreviewData.terms_content()
|
||||
})
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :contact} = assigns) do
|
||||
assigns = preview_assigns(assigns)
|
||||
~H"<BerrypodWeb.PageTemplates.contact {assigns} />"
|
||||
end
|
||||
|
||||
defp preview_page(%{page: :error} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> preview_assigns()
|
||||
|> assign(%{
|
||||
error_code: "404",
|
||||
error_title: "Page Not Found",
|
||||
error_description:
|
||||
"Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved."
|
||||
})
|
||||
|
||||
~H"<BerrypodWeb.PageTemplates.error {assigns} />"
|
||||
end
|
||||
|
||||
defp build_gallery_images(product) do
|
||||
alias Berrypod.Products.ProductImage
|
||||
|
||||
(Map.get(product, :images) || [])
|
||||
|> Enum.sort_by(& &1.position)
|
||||
|> Enum.map(fn img -> ProductImage.url(img, 1200) end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> case do
|
||||
[] -> []
|
||||
urls -> urls
|
||||
end
|
||||
end
|
||||
end
|
||||
1183
lib/berrypod_web/live/admin/theme/index.html.heex
Normal file
1183
lib/berrypod_web/live/admin/theme/index.html.heex
Normal file
File diff suppressed because it is too large
Load Diff
94
lib/berrypod_web/live/auth/confirmation.ex
Normal file
94
lib/berrypod_web/live/auth/confirmation.ex
Normal file
@@ -0,0 +1,94 @@
|
||||
defmodule BerrypodWeb.Auth.Confirmation do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Accounts
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>Welcome {@user.email}</.header>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={!@user.confirmed_at}
|
||||
for={@form}
|
||||
id="confirmation_form"
|
||||
phx-mounted={JS.focus_first()}
|
||||
phx-submit="submit"
|
||||
action={~p"/users/log-in?_action=confirmed"}
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||
<.button
|
||||
name={@form[:remember_me].name}
|
||||
value="true"
|
||||
phx-disable-with="Confirming..."
|
||||
class="admin-btn-primary w-full"
|
||||
>
|
||||
Confirm and stay logged in
|
||||
</.button>
|
||||
<.button phx-disable-with="Confirming..." class="admin-btn-soft w-full mt-2">
|
||||
Confirm and log in only this time
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<.form
|
||||
:if={@user.confirmed_at}
|
||||
for={@form}
|
||||
id="login_form"
|
||||
phx-submit="submit"
|
||||
phx-mounted={JS.focus_first()}
|
||||
action={~p"/users/log-in"}
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||
<%= if @current_scope do %>
|
||||
<.button phx-disable-with="Logging in..." class="admin-btn-primary w-full">
|
||||
Log in
|
||||
</.button>
|
||||
<% else %>
|
||||
<.button
|
||||
name={@form[:remember_me].name}
|
||||
value="true"
|
||||
phx-disable-with="Logging in..."
|
||||
class="admin-btn-primary w-full"
|
||||
>
|
||||
Keep me logged in on this device
|
||||
</.button>
|
||||
<.button phx-disable-with="Logging in..." class="admin-btn-soft w-full mt-2">
|
||||
Log me in only this time
|
||||
</.button>
|
||||
<% end %>
|
||||
</.form>
|
||||
|
||||
<p :if={!@user.confirmed_at} class="admin-alert admin-alert-outline mt-8">
|
||||
Tip: If you prefer passwords, you can enable them in the user settings.
|
||||
</p>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(%{"token" => token}, _session, socket) do
|
||||
if user = Accounts.get_user_by_magic_link_token(token) do
|
||||
form = to_form(%{"token" => token}, as: "user")
|
||||
|
||||
{:ok, assign(socket, user: user, form: form, trigger_submit: false),
|
||||
temporary_assigns: [form: nil]}
|
||||
else
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Magic link is invalid or it has expired.")
|
||||
|> push_navigate(to: ~p"/users/log-in")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit", %{"user" => params}, socket) do
|
||||
{:noreply, assign(socket, form: to_form(params, as: "user"), trigger_submit: true)}
|
||||
end
|
||||
end
|
||||
132
lib/berrypod_web/live/auth/login.ex
Normal file
132
lib/berrypod_web/live/auth/login.ex
Normal file
@@ -0,0 +1,132 @@
|
||||
defmodule BerrypodWeb.Auth.Login do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Accounts
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm flex flex-col gap-4">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
<p>Log in</p>
|
||||
<:subtitle>
|
||||
<%= if @current_scope do %>
|
||||
You need to reauthenticate to perform sensitive actions on your account.
|
||||
<% else %>
|
||||
Don't have an account? <.link
|
||||
navigate={~p"/users/register"}
|
||||
class="font-semibold text-brand hover:underline"
|
||||
phx-no-format
|
||||
>Sign up</.link> for an account now.
|
||||
<% end %>
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<div :if={local_mail_adapter?()} class="admin-alert admin-alert-info">
|
||||
<.icon name="hero-information-circle" class="size-6 shrink-0" />
|
||||
<div>
|
||||
<p>You are running the local mail adapter.</p>
|
||||
<p>
|
||||
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:let={f}
|
||||
for={@form}
|
||||
id="login_form_magic"
|
||||
action={~p"/users/log-in"}
|
||||
phx-submit="submit_magic"
|
||||
>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<.button class="admin-btn-primary w-full">
|
||||
Log in with email <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<div class="admin-divider">or</div>
|
||||
|
||||
<.form
|
||||
:let={f}
|
||||
for={@form}
|
||||
id="login_form_password"
|
||||
action={~p"/users/log-in"}
|
||||
phx-submit="submit_password"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@form[:password]}
|
||||
type="password"
|
||||
label="Password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<.button class="admin-btn-primary w-full" name={@form[:remember_me].name} value="true">
|
||||
Log in and stay logged in <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
<.button class="admin-btn-soft w-full mt-2">
|
||||
Log in only this time
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
email =
|
||||
Phoenix.Flash.get(socket.assigns.flash, :email) ||
|
||||
get_in(socket.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
|
||||
|
||||
form = to_form(%{"email" => email}, as: "user")
|
||||
|
||||
{:ok, assign(socket, form: form, trigger_submit: false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit_password", _params, socket) do
|
||||
{:noreply, assign(socket, :trigger_submit, true)}
|
||||
end
|
||||
|
||||
def handle_event("submit_magic", %{"user" => %{"email" => email}}, socket) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&url(~p"/users/log-in/#{&1}")
|
||||
)
|
||||
end
|
||||
|
||||
info =
|
||||
"If your email is in our system, you will receive instructions for logging in shortly."
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, info)
|
||||
|> push_navigate(to: ~p"/users/log-in")}
|
||||
end
|
||||
|
||||
defp local_mail_adapter? do
|
||||
Application.get_env(:berrypod, Berrypod.Mailer)[:adapter] ==
|
||||
Swoosh.Adapters.Local
|
||||
end
|
||||
end
|
||||
94
lib/berrypod_web/live/auth/registration.ex
Normal file
94
lib/berrypod_web/live/auth/registration.ex
Normal file
@@ -0,0 +1,94 @@
|
||||
defmodule BerrypodWeb.Auth.Registration do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Accounts
|
||||
alias Berrypod.Accounts.User
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Register for an account
|
||||
<:subtitle>
|
||||
Already registered?
|
||||
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
|
||||
Log in
|
||||
</.link>
|
||||
to your account now.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<.form for={@form} id="registration_form" phx-submit="save" phx-change="validate">
|
||||
<.input
|
||||
field={@form[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="username"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
|
||||
<.button phx-disable-with="Creating account..." class="admin-btn-primary w-full">
|
||||
Create an account
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, %{assigns: %{current_scope: %{user: user}}} = socket)
|
||||
when not is_nil(user) do
|
||||
{:ok, redirect(socket, to: BerrypodWeb.UserAuth.signed_in_path(socket))}
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
if Accounts.has_admin?() do
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Registration is closed")
|
||||
|> redirect(to: ~p"/users/log-in")}
|
||||
else
|
||||
changeset = Accounts.change_user_email(%User{}, %{}, validate_unique: false)
|
||||
{:ok, assign_form(socket, changeset), temporary_assigns: [form: nil]}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
case Accounts.register_user(user_params) do
|
||||
{:ok, user} ->
|
||||
{:ok, _} =
|
||||
Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&url(~p"/users/log-in/#{&1}")
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
"An email was sent to #{user.email}, please access it to confirm your account."
|
||||
)
|
||||
|> push_navigate(to: ~p"/users/log-in")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
changeset = Accounts.change_user_email(%User{}, user_params, validate_unique: false)
|
||||
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
|
||||
end
|
||||
|
||||
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||
form = to_form(changeset, as: "user")
|
||||
assign(socket, form: form)
|
||||
end
|
||||
end
|
||||
30
lib/berrypod_web/live/auth/settings.ex
Normal file
30
lib/berrypod_web/live/auth/settings.ex
Normal file
@@ -0,0 +1,30 @@
|
||||
defmodule BerrypodWeb.Auth.Settings do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Accounts
|
||||
|
||||
# Confirm-email token: process it and redirect to admin settings
|
||||
@impl true
|
||||
def mount(%{"token" => token}, _session, socket) do
|
||||
socket =
|
||||
case Accounts.update_user_email(socket.assigns.current_scope.user, token) do
|
||||
{:ok, _user} ->
|
||||
put_flash(socket, :info, "Email changed successfully.")
|
||||
|
||||
{:error, _} ->
|
||||
put_flash(socket, :error, "Email change link is invalid or it has expired.")
|
||||
end
|
||||
|
||||
{:ok, redirect(socket, to: ~p"/admin/settings")}
|
||||
end
|
||||
|
||||
# Main mount: just redirect — account settings live in admin now
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, redirect(socket, to: ~p"/admin/settings")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H""
|
||||
end
|
||||
end
|
||||
19
lib/berrypod_web/live/shop/cart.ex
Normal file
19
lib/berrypod_web/live/shop/cart.ex
Normal file
@@ -0,0 +1,19 @@
|
||||
defmodule BerrypodWeb.Shop.Cart do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Cart
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, page_title: "Cart")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns = assign(assigns, :cart_page_subtotal, Cart.calculate_subtotal(assigns.cart_items))
|
||||
|
||||
~H"""
|
||||
<BerrypodWeb.PageTemplates.cart {assigns} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
46
lib/berrypod_web/live/shop/checkout_success.ex
Normal file
46
lib/berrypod_web/live/shop/checkout_success.ex
Normal file
@@ -0,0 +1,46 @@
|
||||
defmodule BerrypodWeb.Shop.CheckoutSuccess do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Orders
|
||||
|
||||
@impl true
|
||||
def mount(%{"session_id" => session_id}, _session, socket) do
|
||||
order = Orders.get_order_by_stripe_session(session_id)
|
||||
|
||||
# Subscribe to order status updates (webhook may arrive after redirect)
|
||||
if order && connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Berrypod.PubSub, "order:#{order.id}:status")
|
||||
end
|
||||
|
||||
# Clear the cart after successful checkout
|
||||
socket =
|
||||
if order && connected?(socket) do
|
||||
BerrypodWeb.CartHook.broadcast_and_update(socket, [])
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Order confirmed")
|
||||
|> assign(:order, order)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, redirect(socket, to: ~p"/")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:order_paid, order}, socket) do
|
||||
{:noreply, assign(socket, :order, order)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<BerrypodWeb.PageTemplates.checkout_success {assigns} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
184
lib/berrypod_web/live/shop/collection.ex
Normal file
184
lib/berrypod_web/live/shop/collection.ex
Normal file
@@ -0,0 +1,184 @@
|
||||
defmodule BerrypodWeb.Shop.Collection do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Products
|
||||
|
||||
@sort_options [
|
||||
{"featured", "Featured"},
|
||||
{"newest", "Newest"},
|
||||
{"price_asc", "Price: Low to High"},
|
||||
{"price_desc", "Price: High to Low"},
|
||||
{"name_asc", "Name: A-Z"},
|
||||
{"name_desc", "Name: Z-A"}
|
||||
]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:sort_options, @sort_options)
|
||||
|> assign(:current_sort, "featured")
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"slug" => slug} = params, _uri, socket) do
|
||||
sort = params["sort"] || "featured"
|
||||
|
||||
case load_collection(slug, sort) do
|
||||
{:ok, title, category, products} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, title)
|
||||
|> assign(:collection_title, title)
|
||||
|> assign(:current_category, category)
|
||||
|> assign(:current_sort, sort)
|
||||
|> assign(:products, products)}
|
||||
|
||||
:not_found ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Collection not found")
|
||||
|> push_navigate(to: ~p"/collections/all")}
|
||||
end
|
||||
end
|
||||
|
||||
defp load_collection("all", sort) do
|
||||
{:ok, "All Products", nil, Products.list_visible_products(sort: sort)}
|
||||
end
|
||||
|
||||
defp load_collection("sale", sort) do
|
||||
{:ok, "Sale", :sale, Products.list_visible_products(on_sale: true, sort: sort)}
|
||||
end
|
||||
|
||||
defp load_collection(slug, sort) do
|
||||
case Enum.find(Products.list_categories(), &(&1.slug == slug)) do
|
||||
nil ->
|
||||
:not_found
|
||||
|
||||
category ->
|
||||
products = Products.list_visible_products(category: category.name, sort: sort)
|
||||
{:ok, category.name, category, products}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sort_changed", %{"sort" => sort}, socket) do
|
||||
slug =
|
||||
case socket.assigns.current_category do
|
||||
nil -> "all"
|
||||
:sale -> "sale"
|
||||
category -> category.slug
|
||||
end
|
||||
|
||||
{:noreply, push_patch(socket, to: ~p"/collections/#{slug}?sort=#{sort}")}
|
||||
end
|
||||
|
||||
defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}"
|
||||
defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}"
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.shop_layout {layout_assigns(assigns)} active_page="collection">
|
||||
<main id="main-content">
|
||||
<.collection_header
|
||||
title={@collection_title}
|
||||
product_count={length(@products)}
|
||||
/>
|
||||
|
||||
<div class="page-container collection-body">
|
||||
<.collection_filter_bar
|
||||
categories={@categories}
|
||||
current_slug={
|
||||
case @current_category do
|
||||
:sale -> "sale"
|
||||
nil -> nil
|
||||
cat -> cat.slug
|
||||
end
|
||||
}
|
||||
sort_options={@sort_options}
|
||||
current_sort={@current_sort}
|
||||
/>
|
||||
|
||||
<.product_grid theme_settings={@theme_settings}>
|
||||
<%= for product <- @products do %>
|
||||
<.product_card
|
||||
product={product}
|
||||
theme_settings={@theme_settings}
|
||||
mode={@mode}
|
||||
variant={:default}
|
||||
show_category={@current_category in [nil, :sale]}
|
||||
/>
|
||||
<% end %>
|
||||
</.product_grid>
|
||||
|
||||
<%= if @products == [] do %>
|
||||
<div class="collection-empty">
|
||||
<p>No products found in this collection.</p>
|
||||
<.link navigate={~p"/collections/all"} class="collection-empty-link">
|
||||
View all products
|
||||
</.link>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</main>
|
||||
</.shop_layout>
|
||||
"""
|
||||
end
|
||||
|
||||
defp collection_filter_bar(assigns) do
|
||||
~H"""
|
||||
<div class="filter-bar">
|
||||
<nav
|
||||
aria-label="Collection filters"
|
||||
id="collection-filters"
|
||||
phx-hook="CollectionFilters"
|
||||
class="collection-filters"
|
||||
>
|
||||
<ul class="collection-filter-pills">
|
||||
<li>
|
||||
<.link
|
||||
navigate={collection_path("all", @current_sort)}
|
||||
aria-current={@current_slug == nil && "page"}
|
||||
class={["collection-filter-pill", @current_slug == nil && "active"]}
|
||||
>
|
||||
All
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={collection_path("sale", @current_sort)}
|
||||
aria-current={@current_slug == "sale" && "page"}
|
||||
class={["collection-filter-pill", @current_slug == "sale" && "active"]}
|
||||
>
|
||||
Sale
|
||||
</.link>
|
||||
</li>
|
||||
<%= for category <- @categories do %>
|
||||
<li>
|
||||
<.link
|
||||
navigate={collection_path(category.slug, @current_sort)}
|
||||
aria-current={@current_slug == category.slug && "page"}
|
||||
class={["collection-filter-pill", @current_slug == category.slug && "active"]}
|
||||
>
|
||||
{category.name}
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<form phx-change="sort_changed">
|
||||
<.shop_select
|
||||
name="sort"
|
||||
options={@sort_options}
|
||||
selected={@current_sort}
|
||||
aria-label="Sort products"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
22
lib/berrypod_web/live/shop/coming_soon.ex
Normal file
22
lib/berrypod_web/live/shop/coming_soon.ex
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule BerrypodWeb.Shop.ComingSoon do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :page_title, "Coming soon")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<main class="flex min-h-screen items-center justify-center px-6 text-center" role="main">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight sm:text-4xl">{@theme_settings.site_name}</h1>
|
||||
<p class="mt-4 text-lg text-[var(--t-text-muted)]">
|
||||
We're getting things ready. Check back soon.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
"""
|
||||
end
|
||||
end
|
||||
15
lib/berrypod_web/live/shop/contact.ex
Normal file
15
lib/berrypod_web/live/shop/contact.ex
Normal file
@@ -0,0 +1,15 @@
|
||||
defmodule BerrypodWeb.Shop.Contact do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :page_title, "Contact")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<BerrypodWeb.PageTemplates.contact {assigns} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
66
lib/berrypod_web/live/shop/content.ex
Normal file
66
lib/berrypod_web/live/shop/content.ex
Normal file
@@ -0,0 +1,66 @@
|
||||
defmodule BerrypodWeb.Shop.Content do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Theme.PreviewData
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _uri, socket) do
|
||||
config = page_config(socket.assigns.live_action)
|
||||
{:noreply, assign(socket, config)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<BerrypodWeb.PageTemplates.content {assigns} />
|
||||
"""
|
||||
end
|
||||
|
||||
defp page_config(:about) do
|
||||
%{
|
||||
page_title: "About",
|
||||
active_page: "about",
|
||||
hero_title: "About the studio",
|
||||
hero_description: "Your story goes here – this is sample content for the demo shop",
|
||||
hero_background: :sunken,
|
||||
image_src: "/mockups/night-sky-blanket-3",
|
||||
image_alt: "Night sky blanket draped over a chair",
|
||||
content_blocks: PreviewData.about_content()
|
||||
}
|
||||
end
|
||||
|
||||
defp page_config(:delivery) do
|
||||
%{
|
||||
page_title: "Delivery & returns",
|
||||
active_page: "delivery",
|
||||
hero_title: "Delivery & returns",
|
||||
hero_description: "Everything you need to know about shipping and returns",
|
||||
content_blocks: PreviewData.delivery_content()
|
||||
}
|
||||
end
|
||||
|
||||
defp page_config(:privacy) do
|
||||
%{
|
||||
page_title: "Privacy policy",
|
||||
active_page: "privacy",
|
||||
hero_title: "Privacy policy",
|
||||
hero_description: "How we handle your personal information",
|
||||
content_blocks: PreviewData.privacy_content()
|
||||
}
|
||||
end
|
||||
|
||||
defp page_config(:terms) do
|
||||
%{
|
||||
page_title: "Terms of service",
|
||||
active_page: "terms",
|
||||
hero_title: "Terms of service",
|
||||
hero_description: "The legal bits",
|
||||
content_blocks: PreviewData.terms_content()
|
||||
}
|
||||
end
|
||||
end
|
||||
24
lib/berrypod_web/live/shop/home.ex
Normal file
24
lib/berrypod_web/live/shop/home.ex
Normal file
@@ -0,0 +1,24 @@
|
||||
defmodule BerrypodWeb.Shop.Home do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Products
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
products = Products.list_visible_products(limit: 8)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Home")
|
||||
|> assign(:products, products)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<BerrypodWeb.PageTemplates.home {assigns} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
199
lib/berrypod_web/live/shop/product_show.ex
Normal file
199
lib/berrypod_web/live/shop/product_show.ex
Normal file
@@ -0,0 +1,199 @@
|
||||
defmodule BerrypodWeb.Shop.ProductShow do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Cart
|
||||
alias Berrypod.Images.Optimizer
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.{Product, ProductImage}
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => slug}, _session, socket) do
|
||||
case Products.get_visible_product(slug) do
|
||||
nil ->
|
||||
{:ok, push_navigate(socket, to: ~p"/collections/all")}
|
||||
|
||||
product ->
|
||||
related_products =
|
||||
Products.list_visible_products(
|
||||
category: product.category,
|
||||
limit: 4,
|
||||
exclude: product.id
|
||||
)
|
||||
|
||||
all_images =
|
||||
(product.images || [])
|
||||
|> Enum.sort_by(& &1.position)
|
||||
|> Enum.map(fn img ->
|
||||
width =
|
||||
case ProductImage.source_width(img) do
|
||||
nil -> 1200
|
||||
sw -> Enum.max(Optimizer.applicable_widths(sw))
|
||||
end
|
||||
|
||||
%{url: ProductImage.url(img, width), color: img.color}
|
||||
end)
|
||||
|> Enum.reject(fn img -> is_nil(img.url) end)
|
||||
|
||||
option_types = Product.option_types(product)
|
||||
variants = product.variants || []
|
||||
{selected_options, selected_variant} = initialize_variant_selection(variants)
|
||||
available_options = compute_available_options(option_types, variants, selected_options)
|
||||
display_price = variant_price(selected_variant, product)
|
||||
gallery_images = filter_gallery_images(all_images, selected_options["Color"])
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, product.title)
|
||||
|> assign(:product, product)
|
||||
|> assign(:all_images, all_images)
|
||||
|> assign(:gallery_images, gallery_images)
|
||||
|> assign(:related_products, related_products)
|
||||
|> assign(:quantity, 1)
|
||||
|> assign(:option_types, option_types)
|
||||
|> assign(:variants, variants)
|
||||
|> assign(:selected_options, selected_options)
|
||||
|> assign(:selected_variant, selected_variant)
|
||||
|> assign(:available_options, available_options)
|
||||
|> assign(:display_price, display_price)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp initialize_variant_selection([first | _] = _variants) do
|
||||
{first.options, first}
|
||||
end
|
||||
|
||||
defp initialize_variant_selection([]) do
|
||||
{%{}, nil}
|
||||
end
|
||||
|
||||
defp compute_available_options(option_types, variants, selected_options) do
|
||||
Enum.reduce(option_types, %{}, fn opt_type, acc ->
|
||||
other_options = Map.delete(selected_options, opt_type.name)
|
||||
|
||||
available_values =
|
||||
variants
|
||||
|> Enum.filter(fn v ->
|
||||
v.is_available &&
|
||||
Enum.all?(other_options, fn {k, selected_val} ->
|
||||
v.options[k] == selected_val
|
||||
end)
|
||||
end)
|
||||
|> Enum.map(fn v -> v.options[opt_type.name] end)
|
||||
|> Enum.uniq()
|
||||
|
||||
Map.put(acc, opt_type.name, available_values)
|
||||
end)
|
||||
end
|
||||
|
||||
defp variant_price(%{price: price}, _product) when is_integer(price), do: price
|
||||
defp variant_price(_, %{cheapest_price: price}), do: price
|
||||
defp variant_price(_, _), do: 0
|
||||
|
||||
# If the current combo doesn't match any variant, auto-adjust other options
|
||||
# to find a valid one. Keeps the just-changed option fixed, adjusts the rest.
|
||||
defp resolve_valid_combo(variants, option_types, selected_options, changed_option) do
|
||||
if Enum.any?(variants, fn v -> v.options == selected_options end) do
|
||||
selected_options
|
||||
else
|
||||
matching =
|
||||
Enum.filter(variants, fn v ->
|
||||
v.is_available && v.options[changed_option] == selected_options[changed_option]
|
||||
end)
|
||||
|
||||
case matching do
|
||||
[first | _] ->
|
||||
Enum.reduce(option_types, selected_options, fn opt_type, acc ->
|
||||
if opt_type.name == changed_option do
|
||||
acc
|
||||
else
|
||||
Map.put(acc, opt_type.name, first.options[opt_type.name])
|
||||
end
|
||||
end)
|
||||
|
||||
[] ->
|
||||
selected_options
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp find_variant(variants, selected_options) do
|
||||
Enum.find(variants, fn v -> v.options == selected_options end)
|
||||
end
|
||||
|
||||
defp filter_gallery_images(all_images, selected_color) do
|
||||
if selected_color do
|
||||
color_images = Enum.filter(all_images, &(&1.color == selected_color))
|
||||
if color_images == [], do: all_images, else: color_images
|
||||
else
|
||||
all_images
|
||||
end
|
||||
|> Enum.map(& &1.url)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("select_option", %{"option" => option_name, "selected" => value}, socket) do
|
||||
variants = socket.assigns.variants
|
||||
option_types = socket.assigns.option_types
|
||||
|
||||
selected_options = Map.put(socket.assigns.selected_options, option_name, value)
|
||||
selected_options = resolve_valid_combo(variants, option_types, selected_options, option_name)
|
||||
|
||||
selected_variant = find_variant(variants, selected_options)
|
||||
|
||||
available_options =
|
||||
compute_available_options(option_types, variants, selected_options)
|
||||
|
||||
gallery_images = filter_gallery_images(socket.assigns.all_images, selected_options["Color"])
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:selected_options, selected_options)
|
||||
|> assign(:selected_variant, selected_variant)
|
||||
|> assign(:available_options, available_options)
|
||||
|> assign(:display_price, variant_price(selected_variant, socket.assigns.product))
|
||||
|> assign(:gallery_images, gallery_images)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("increment_quantity", _params, socket) do
|
||||
quantity = min(socket.assigns.quantity + 1, 99)
|
||||
{:noreply, assign(socket, :quantity, quantity)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("decrement_quantity", _params, socket) do
|
||||
quantity = max(socket.assigns.quantity - 1, 1)
|
||||
{:noreply, assign(socket, :quantity, quantity)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("add_to_cart", _params, socket) do
|
||||
variant = socket.assigns.selected_variant
|
||||
|
||||
if variant do
|
||||
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> BerrypodWeb.CartHook.broadcast_and_update(cart)
|
||||
|> assign(:quantity, 1)
|
||||
|> assign(:cart_drawer_open, true)
|
||||
|> assign(:cart_status, "#{socket.assigns.product.title} added to cart")
|
||||
|
||||
{:noreply, socket}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<BerrypodWeb.PageTemplates.pdp {assigns} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
22
lib/berrypod_web/plugs/cache_raw_body.ex
Normal file
22
lib/berrypod_web/plugs/cache_raw_body.ex
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule BerrypodWeb.Plugs.CacheRawBody do
|
||||
@moduledoc """
|
||||
Custom body reader that caches the raw request body for webhook signature verification.
|
||||
Used with Plug.Parsers :body_reader option.
|
||||
"""
|
||||
|
||||
def read_body(conn, opts) do
|
||||
case Plug.Conn.read_body(conn, opts) do
|
||||
{:ok, body, conn} ->
|
||||
conn = Plug.Conn.assign(conn, :raw_body, body)
|
||||
{:ok, body, conn}
|
||||
|
||||
{:more, body, conn} ->
|
||||
existing = conn.assigns[:raw_body] || ""
|
||||
conn = Plug.Conn.assign(conn, :raw_body, existing <> body)
|
||||
{:more, body, conn}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
55
lib/berrypod_web/plugs/country_detect.ex
Normal file
55
lib/berrypod_web/plugs/country_detect.ex
Normal file
@@ -0,0 +1,55 @@
|
||||
defmodule BerrypodWeb.Plugs.CountryDetect do
|
||||
@moduledoc """
|
||||
Plug that detects the visitor's country from cookies or Accept-Language.
|
||||
|
||||
Priority:
|
||||
1. `shipping_country` cookie (set when user explicitly changes country)
|
||||
2. Accept-Language header (locale tags like `en-GB` → `GB`)
|
||||
3. Falls back to `"GB"`
|
||||
|
||||
The result is stored in the session as `country_code` so LiveViews can
|
||||
read it. Only runs once per session — skips if `country_code` is already
|
||||
set (unless the cookie has changed).
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
@default_country "GB"
|
||||
@cookie_name "shipping_country"
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
cookie_country = conn.cookies[@cookie_name]
|
||||
session_country = get_session(conn, "country_code")
|
||||
|
||||
cond do
|
||||
# Cookie takes priority — user explicitly chose this country
|
||||
cookie_country not in [nil, ""] and cookie_country != session_country ->
|
||||
put_session(conn, "country_code", cookie_country)
|
||||
|
||||
# Session already set and no cookie override
|
||||
session_country != nil ->
|
||||
conn
|
||||
|
||||
# First visit: detect from Accept-Language
|
||||
true ->
|
||||
country = detect_from_header(conn)
|
||||
put_session(conn, "country_code", country)
|
||||
end
|
||||
end
|
||||
|
||||
defp detect_from_header(conn) do
|
||||
conn
|
||||
|> get_req_header("accept-language")
|
||||
|> List.first("")
|
||||
|> parse_country()
|
||||
end
|
||||
|
||||
defp parse_country(header) do
|
||||
case Regex.run(~r/[a-z]{2}-([A-Z]{2})/, header) do
|
||||
[_, country] -> country
|
||||
nil -> @default_country
|
||||
end
|
||||
end
|
||||
end
|
||||
39
lib/berrypod_web/plugs/load_theme.ex
Normal file
39
lib/berrypod_web/plugs/load_theme.ex
Normal file
@@ -0,0 +1,39 @@
|
||||
defmodule BerrypodWeb.Plugs.LoadTheme do
|
||||
@moduledoc """
|
||||
Plug that loads theme settings and generated CSS for public shop pages.
|
||||
|
||||
This plug:
|
||||
1. Checks the ETS cache for pre-generated CSS
|
||||
2. Falls back to generating CSS from theme settings on cache miss
|
||||
3. Assigns both `theme_settings` and `generated_css` to the connection
|
||||
|
||||
The generated CSS contains only the active theme values (not all variants),
|
||||
making it much smaller than the full theme-layer2-attributes.css file used
|
||||
by the theme editor for live preview switching.
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
alias Berrypod.Settings
|
||||
alias Berrypod.Theme.{CSSGenerator, CSSCache}
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
{theme_settings, generated_css} =
|
||||
case CSSCache.get() do
|
||||
{:ok, css} ->
|
||||
{Settings.get_theme_settings(), css}
|
||||
|
||||
:miss ->
|
||||
settings = Settings.get_theme_settings()
|
||||
css = CSSGenerator.generate(settings)
|
||||
CSSCache.put(css)
|
||||
{settings, css}
|
||||
end
|
||||
|
||||
conn
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
end
|
||||
end
|
||||
67
lib/berrypod_web/plugs/verify_printful_webhook.ex
Normal file
67
lib/berrypod_web/plugs/verify_printful_webhook.ex
Normal file
@@ -0,0 +1,67 @@
|
||||
defmodule BerrypodWeb.Plugs.VerifyPrintfulWebhook do
|
||||
@moduledoc """
|
||||
Verifies Printful webhook requests using a shared secret token.
|
||||
|
||||
Checks the `webhook_secret` stored in the Printful provider connection
|
||||
config against the `X-PF-Webhook-Token` header (or `token` query param
|
||||
as fallback). Can be upgraded to HMAC signature verification once the
|
||||
exact Printful signing format is confirmed.
|
||||
|
||||
Expects raw body cached in conn.assigns[:raw_body] (via CacheRawBody).
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
require Logger
|
||||
|
||||
alias Berrypod.Products
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
with {:ok, token} <- get_token(conn),
|
||||
{:ok, secret} <- get_webhook_secret(),
|
||||
:ok <- verify_token(token, secret) do
|
||||
conn
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Printful webhook verification failed: #{reason}")
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(401, Jason.encode!(%{error: "Invalid token"}))
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp get_token(conn) do
|
||||
# Check header first, then query param
|
||||
case get_req_header(conn, "x-pf-webhook-token") do
|
||||
[token] when token != "" ->
|
||||
{:ok, token}
|
||||
|
||||
_ ->
|
||||
case conn.query_params["token"] || conn.params["token"] do
|
||||
token when is_binary(token) and token != "" -> {:ok, token}
|
||||
_ -> {:error, :missing_token}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_webhook_secret do
|
||||
case Products.get_provider_connection_by_type("printful") do
|
||||
%{config: %{"webhook_secret" => secret}} when is_binary(secret) and secret != "" ->
|
||||
{:ok, secret}
|
||||
|
||||
_ ->
|
||||
{:error, :no_webhook_secret}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_token(token, secret) do
|
||||
if Plug.Crypto.secure_compare(token, secret) do
|
||||
:ok
|
||||
else
|
||||
{:error, :token_mismatch}
|
||||
end
|
||||
end
|
||||
end
|
||||
63
lib/berrypod_web/plugs/verify_printify_webhook.ex
Normal file
63
lib/berrypod_web/plugs/verify_printify_webhook.ex
Normal file
@@ -0,0 +1,63 @@
|
||||
defmodule BerrypodWeb.Plugs.VerifyPrintifyWebhook do
|
||||
@moduledoc """
|
||||
Verifies Printify webhook signatures using HMAC-SHA256.
|
||||
|
||||
Expects:
|
||||
- Raw body cached in conn.assigns[:raw_body]
|
||||
- X-Pfy-Signature header in format "sha256={hex_digest}"
|
||||
- Webhook secret stored in provider connection config
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
require Logger
|
||||
|
||||
alias Berrypod.Products
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
with {:ok, signature} <- get_signature(conn),
|
||||
{:ok, secret} <- get_webhook_secret(),
|
||||
:ok <- verify_signature(conn.assigns[:raw_body], secret, signature) do
|
||||
conn
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Printify webhook verification failed: #{reason}")
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(401, Jason.encode!(%{error: "Invalid signature"}))
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp get_signature(conn) do
|
||||
case get_req_header(conn, "x-pfy-signature") do
|
||||
["sha256=" <> hex_digest] -> {:ok, hex_digest}
|
||||
[_other] -> {:error, :invalid_signature_format}
|
||||
[] -> {:error, :missing_signature}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_webhook_secret do
|
||||
case Products.get_provider_connection_by_type("printify") do
|
||||
%{config: %{"webhook_secret" => secret}} when is_binary(secret) and secret != "" ->
|
||||
{:ok, secret}
|
||||
|
||||
_ ->
|
||||
{:error, :no_webhook_secret}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_signature(body, secret, expected_hex) do
|
||||
computed =
|
||||
:crypto.mac(:hmac, :sha256, secret, body || "")
|
||||
|> Base.encode16(case: :lower)
|
||||
|
||||
if Plug.Crypto.secure_compare(computed, String.downcase(expected_hex)) do
|
||||
:ok
|
||||
else
|
||||
{:error, :signature_mismatch}
|
||||
end
|
||||
end
|
||||
end
|
||||
199
lib/berrypod_web/router.ex
Normal file
199
lib/berrypod_web/router.ex
Normal file
@@ -0,0 +1,199 @@
|
||||
defmodule BerrypodWeb.Router do
|
||||
use BerrypodWeb, :router
|
||||
|
||||
import BerrypodWeb.UserAuth
|
||||
import Phoenix.LiveDashboard.Router
|
||||
import ErrorTracker.Web.Router
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, html: {BerrypodWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :fetch_current_scope_for_user
|
||||
plug BerrypodWeb.Plugs.CountryDetect
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
|
||||
# Lightweight pipeline for SVG recoloring — no session, CSRF, auth, or layout
|
||||
pipeline :image do
|
||||
plug :put_secure_browser_headers
|
||||
end
|
||||
|
||||
pipeline :printify_webhook do
|
||||
plug BerrypodWeb.Plugs.VerifyPrintifyWebhook
|
||||
end
|
||||
|
||||
pipeline :printful_webhook do
|
||||
plug BerrypodWeb.Plugs.VerifyPrintfulWebhook
|
||||
end
|
||||
|
||||
pipeline :shop do
|
||||
plug :put_root_layout, html: {BerrypodWeb.Layouts, :shop_root}
|
||||
plug BerrypodWeb.Plugs.LoadTheme
|
||||
end
|
||||
|
||||
pipeline :admin do
|
||||
plug :put_root_layout, html: {BerrypodWeb.Layouts, :admin_root}
|
||||
end
|
||||
|
||||
# Public storefront (root level)
|
||||
scope "/", BerrypodWeb do
|
||||
pipe_through [:browser, :shop]
|
||||
|
||||
live_session :coming_soon,
|
||||
layout: {BerrypodWeb.Layouts, :shop},
|
||||
on_mount: [
|
||||
{BerrypodWeb.ThemeHook, :mount_theme}
|
||||
] do
|
||||
live "/coming-soon", Shop.ComingSoon, :index
|
||||
end
|
||||
|
||||
live_session :public_shop,
|
||||
layout: {BerrypodWeb.Layouts, :shop},
|
||||
on_mount: [
|
||||
{BerrypodWeb.UserAuth, :mount_current_scope},
|
||||
{BerrypodWeb.ThemeHook, :mount_theme},
|
||||
{BerrypodWeb.ThemeHook, :require_site_live},
|
||||
{BerrypodWeb.CartHook, :mount_cart},
|
||||
{BerrypodWeb.SearchHook, :mount_search}
|
||||
] do
|
||||
live "/", Shop.Home, :index
|
||||
live "/about", Shop.Content, :about
|
||||
live "/delivery", Shop.Content, :delivery
|
||||
live "/privacy", Shop.Content, :privacy
|
||||
live "/terms", Shop.Content, :terms
|
||||
live "/contact", Shop.Contact, :index
|
||||
live "/collections/:slug", Shop.Collection, :show
|
||||
live "/products/:id", Shop.ProductShow, :show
|
||||
live "/cart", Shop.Cart, :index
|
||||
live "/checkout/success", Shop.CheckoutSuccess, :show
|
||||
end
|
||||
|
||||
# Checkout (POST — creates Stripe session and redirects)
|
||||
post "/checkout", CheckoutController, :create
|
||||
end
|
||||
|
||||
# Health check (no auth, no theme loading — for load balancers and uptime monitors)
|
||||
scope "/", BerrypodWeb do
|
||||
pipe_through [:api]
|
||||
|
||||
get "/health", HealthController, :show
|
||||
end
|
||||
|
||||
# Cart API (session persistence for LiveView)
|
||||
scope "/api", BerrypodWeb do
|
||||
pipe_through [:browser]
|
||||
|
||||
post "/cart", CartController, :update
|
||||
end
|
||||
|
||||
# SVG recoloring (dynamic — can't be pre-generated to disk)
|
||||
scope "/images", BerrypodWeb do
|
||||
pipe_through :image
|
||||
|
||||
get "/:id/recolored/:color", ImageController, :recolored_svg
|
||||
end
|
||||
|
||||
# Webhook endpoints (no CSRF, signature verified)
|
||||
scope "/webhooks", BerrypodWeb do
|
||||
pipe_through [:api, :printify_webhook]
|
||||
|
||||
post "/printify", WebhookController, :printify
|
||||
end
|
||||
|
||||
scope "/webhooks", BerrypodWeb do
|
||||
pipe_through [:api, :printful_webhook]
|
||||
|
||||
post "/printful", WebhookController, :printful
|
||||
end
|
||||
|
||||
scope "/webhooks", BerrypodWeb do
|
||||
pipe_through [:api]
|
||||
|
||||
post "/stripe", StripeWebhookController, :handle
|
||||
end
|
||||
|
||||
# LiveDashboard and ErrorTracker behind admin auth (available in all environments)
|
||||
scope "/admin" do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live_dashboard "/dashboard", metrics: BerrypodWeb.Telemetry
|
||||
error_tracker_dashboard("/errors")
|
||||
end
|
||||
|
||||
# Dev-only routes (mailbox preview, error previews)
|
||||
if Application.compile_env(:berrypod, :dev_routes) do
|
||||
scope "/dev" do
|
||||
pipe_through :browser
|
||||
|
||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||
|
||||
# Preview error pages
|
||||
get "/errors/404", BerrypodWeb.ErrorPreviewController, :not_found
|
||||
get "/errors/500", BerrypodWeb.ErrorPreviewController, :server_error
|
||||
end
|
||||
end
|
||||
|
||||
## Authentication routes
|
||||
|
||||
# Admin pages with sidebar layout
|
||||
scope "/admin", BerrypodWeb do
|
||||
pipe_through [:browser, :require_authenticated_user, :admin]
|
||||
|
||||
live_session :admin,
|
||||
layout: {BerrypodWeb.Layouts, :admin},
|
||||
on_mount: [
|
||||
{BerrypodWeb.UserAuth, :require_authenticated},
|
||||
{BerrypodWeb.AdminLayoutHook, :assign_current_path}
|
||||
] do
|
||||
live "/", Admin.Dashboard, :index
|
||||
live "/orders", Admin.Orders, :index
|
||||
live "/orders/:id", Admin.OrderShow, :show
|
||||
live "/products", Admin.Products, :index
|
||||
live "/products/:id", Admin.ProductShow, :show
|
||||
live "/providers", Admin.Providers.Index, :index
|
||||
live "/providers/new", Admin.Providers.Form, :new
|
||||
live "/providers/:id/edit", Admin.Providers.Form, :edit
|
||||
live "/settings", Admin.Settings, :index
|
||||
end
|
||||
|
||||
# Theme editor: admin root layout but full-screen (no sidebar)
|
||||
live_session :admin_theme,
|
||||
on_mount: [{BerrypodWeb.UserAuth, :require_authenticated}] do
|
||||
live "/theme", Admin.Theme.Index, :index
|
||||
end
|
||||
end
|
||||
|
||||
# User account settings
|
||||
scope "/", BerrypodWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live_session :user_settings,
|
||||
on_mount: [{BerrypodWeb.UserAuth, :require_authenticated}] do
|
||||
live "/users/settings", Auth.Settings, :edit
|
||||
live "/users/settings/confirm-email/:token", Auth.Settings, :confirm_email
|
||||
end
|
||||
|
||||
post "/users/update-password", UserSessionController, :update_password
|
||||
end
|
||||
|
||||
scope "/", BerrypodWeb do
|
||||
pipe_through [:browser]
|
||||
|
||||
live_session :current_user,
|
||||
on_mount: [{BerrypodWeb.UserAuth, :mount_current_scope}] do
|
||||
live "/users/register", Auth.Registration, :new
|
||||
live "/users/log-in", Auth.Login, :new
|
||||
live "/users/log-in/:token", Auth.Confirmation, :new
|
||||
end
|
||||
|
||||
post "/users/log-in", UserSessionController, :create
|
||||
delete "/users/log-out", UserSessionController, :delete
|
||||
end
|
||||
end
|
||||
55
lib/berrypod_web/search_hook.ex
Normal file
55
lib/berrypod_web/search_hook.ex
Normal file
@@ -0,0 +1,55 @@
|
||||
defmodule BerrypodWeb.SearchHook do
|
||||
@moduledoc """
|
||||
LiveView on_mount hook for product search.
|
||||
|
||||
Mounted in the public_shop live_session to give all shop LiveViews
|
||||
search state and shared event handlers via attach_hook.
|
||||
|
||||
Handles these events:
|
||||
- `search` - run FTS5 search with debounced query
|
||||
- `clear_search` - reset search state
|
||||
"""
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
import Phoenix.LiveView, only: [attach_hook: 4]
|
||||
|
||||
alias Berrypod.Search
|
||||
|
||||
def on_mount(:mount_search, _params, _session, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:search_results, [])
|
||||
|> assign(:search_open, false)
|
||||
|> attach_hook(:search_events, :handle_event, &handle_search_event/3)
|
||||
|
||||
{:cont, socket}
|
||||
end
|
||||
|
||||
defp handle_search_event("open_search", _params, socket) do
|
||||
{:halt, assign(socket, :search_open, true)}
|
||||
end
|
||||
|
||||
defp handle_search_event("search", %{"value" => query}, socket) do
|
||||
results = Search.search(query)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:search_query, query)
|
||||
|> assign(:search_results, results)
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
defp handle_search_event("clear_search", _params, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:search_results, [])
|
||||
|> assign(:search_open, false)
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
defp handle_search_event(_event, _params, socket), do: {:cont, socket}
|
||||
end
|
||||
111
lib/berrypod_web/telemetry.ex
Normal file
111
lib/berrypod_web/telemetry.ex
Normal file
@@ -0,0 +1,111 @@
|
||||
defmodule BerrypodWeb.Telemetry do
|
||||
use Supervisor
|
||||
import Telemetry.Metrics
|
||||
|
||||
def start_link(arg) do
|
||||
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_arg) do
|
||||
children = [
|
||||
# Telemetry poller will execute the given period measurements
|
||||
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||
# Add reporters as children of your supervision tree.
|
||||
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def metrics do
|
||||
[
|
||||
# Phoenix Metrics
|
||||
summary("phoenix.endpoint.start.system_time",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.endpoint.stop.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.start.system_time",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.exception.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.stop.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.socket_connected.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
sum("phoenix.socket_drain.count"),
|
||||
summary("phoenix.channel_joined.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.channel_handled_in.duration",
|
||||
tags: [:event],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
|
||||
# Database Metrics
|
||||
summary("berrypod.repo.query.total_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The sum of the other measurements"
|
||||
),
|
||||
summary("berrypod.repo.query.decode_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent decoding the data received from the database"
|
||||
),
|
||||
summary("berrypod.repo.query.query_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent executing the query"
|
||||
),
|
||||
summary("berrypod.repo.query.queue_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent waiting for a database connection"
|
||||
),
|
||||
summary("berrypod.repo.query.idle_time",
|
||||
unit: {:native, :millisecond},
|
||||
description:
|
||||
"The time the connection spent waiting before being checked out for the query"
|
||||
),
|
||||
|
||||
# Oban job metrics
|
||||
summary("oban.job.stop.duration",
|
||||
tags: [:worker],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
counter("oban.job.stop.duration", tags: [:worker]),
|
||||
counter("oban.job.exception.duration", tags: [:worker]),
|
||||
|
||||
# LiveView metrics
|
||||
summary("phoenix.live_view.mount.stop.duration",
|
||||
tags: [:view],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.live_view.handle_event.stop.duration",
|
||||
tags: [:event],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
|
||||
# VM Metrics
|
||||
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||
summary("vm.total_run_queue_lengths.total"),
|
||||
summary("vm.total_run_queue_lengths.cpu"),
|
||||
summary("vm.total_run_queue_lengths.io")
|
||||
]
|
||||
end
|
||||
|
||||
defp periodic_measurements do
|
||||
[
|
||||
# A module, function and arguments to be invoked periodically.
|
||||
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||
# {BerrypodWeb, :count_users, []}
|
||||
]
|
||||
end
|
||||
end
|
||||
67
lib/berrypod_web/theme_hook.ex
Normal file
67
lib/berrypod_web/theme_hook.ex
Normal file
@@ -0,0 +1,67 @@
|
||||
defmodule BerrypodWeb.ThemeHook do
|
||||
@moduledoc """
|
||||
LiveView on_mount hook for theme settings, CSS, and media assigns.
|
||||
|
||||
Mounted in the public_shop live_session alongside CartHook.
|
||||
Eliminates the identical theme-loading boilerplate from every shop LiveView.
|
||||
|
||||
## Actions
|
||||
|
||||
- `:mount_theme` — loads theme settings, CSS, and media assigns
|
||||
- `:require_site_live` — redirects unauthenticated visitors to /coming-soon
|
||||
when the shop is not live
|
||||
"""
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
|
||||
alias Berrypod.{Products, Settings, Media}
|
||||
alias Berrypod.Theme.{CSSCache, CSSGenerator}
|
||||
|
||||
def on_mount(:mount_theme, _params, _session, socket) do
|
||||
theme_settings = Settings.get_theme_settings()
|
||||
|
||||
generated_css =
|
||||
case CSSCache.get() do
|
||||
{:ok, css} ->
|
||||
css
|
||||
|
||||
:miss ->
|
||||
css = CSSGenerator.generate(theme_settings)
|
||||
CSSCache.put(css)
|
||||
css
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:logo_image, Media.get_logo())
|
||||
|> assign(:header_image, Media.get_header())
|
||||
|> assign(:categories, Products.list_categories())
|
||||
|> assign(:mode, :shop)
|
||||
|> assign(
|
||||
:is_admin,
|
||||
!!(socket.assigns[:current_scope] && socket.assigns.current_scope.user)
|
||||
)
|
||||
|
||||
{:cont, socket}
|
||||
end
|
||||
|
||||
def on_mount(:require_site_live, _params, _session, socket) do
|
||||
cond do
|
||||
Settings.site_live?() ->
|
||||
{:cont, socket}
|
||||
|
||||
# mount_current_scope runs first, so current_scope is already validated
|
||||
socket.assigns[:current_scope] && socket.assigns.current_scope.user ->
|
||||
{:cont, socket}
|
||||
|
||||
not Berrypod.Accounts.has_admin?() ->
|
||||
# Fresh install — send to registration
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: "/users/register")}
|
||||
|
||||
true ->
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: "/coming-soon")}
|
||||
end
|
||||
end
|
||||
end
|
||||
283
lib/berrypod_web/user_auth.ex
Normal file
283
lib/berrypod_web/user_auth.ex
Normal file
@@ -0,0 +1,283 @@
|
||||
defmodule BerrypodWeb.UserAuth do
|
||||
use BerrypodWeb, :verified_routes
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
|
||||
alias Berrypod.Accounts
|
||||
alias Berrypod.Accounts.Scope
|
||||
|
||||
# Make the remember me cookie valid for 14 days. This should match
|
||||
# the session validity setting in UserToken.
|
||||
@max_cookie_age_in_days 14
|
||||
@remember_me_cookie "_berrypod_web_user_remember_me"
|
||||
@remember_me_options [
|
||||
sign: true,
|
||||
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
# How old the session token should be before a new one is issued. When a request is made
|
||||
# with a session token older than this value, then a new session token will be created
|
||||
# and the session and remember-me cookies (if set) will be updated with the new token.
|
||||
# Lowering this value will result in more tokens being created by active users. Increasing
|
||||
# it will result in less time before a session token expires for a user to get issued a new
|
||||
# token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
|
||||
# the reissuing of tokens completely.
|
||||
@session_reissue_age_in_days 7
|
||||
|
||||
@doc """
|
||||
Logs the user in.
|
||||
|
||||
Redirects to the session's `:user_return_to` path
|
||||
or falls back to the `signed_in_path/1`.
|
||||
"""
|
||||
def log_in_user(conn, user, params \\ %{}) do
|
||||
user_return_to = get_session(conn, :user_return_to)
|
||||
|
||||
conn
|
||||
|> create_or_extend_session(user, params)
|
||||
|> redirect(to: user_return_to || signed_in_path(conn))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user out.
|
||||
|
||||
It clears all session data for safety. See renew_session.
|
||||
"""
|
||||
def log_out_user(conn) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
user_token && Accounts.delete_user_session_token(user_token)
|
||||
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
BerrypodWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session(nil)
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates the user by looking into the session and remember me token.
|
||||
|
||||
Will reissue the session token if it is older than the configured age.
|
||||
"""
|
||||
def fetch_current_scope_for_user(conn, _opts) do
|
||||
with {token, conn} <- ensure_user_token(conn),
|
||||
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_user(user))
|
||||
|> maybe_reissue_user_session_token(user, token_inserted_at)
|
||||
else
|
||||
nil -> assign(conn, :current_scope, Scope.for_user(nil))
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_user_token(conn) do
|
||||
if token = get_session(conn, :user_token) do
|
||||
{token, conn}
|
||||
else
|
||||
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
||||
|
||||
if token = conn.cookies[@remember_me_cookie] do
|
||||
{token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Reissue the session token if it is older than the configured reissue age.
|
||||
defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
|
||||
token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
|
||||
|
||||
if token_age >= @session_reissue_age_in_days do
|
||||
create_or_extend_session(conn, user, %{})
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
# This function is the one responsible for creating session tokens
|
||||
# and storing them safely in the session and cookies. It may be called
|
||||
# either when logging in, during sudo mode, or to renew a session which
|
||||
# will soon expire.
|
||||
#
|
||||
# When the session is created, rather than extended, the renew_session
|
||||
# function will clear the session to avoid fixation attacks. See the
|
||||
# renew_session function to customize this behaviour.
|
||||
defp create_or_extend_session(conn, user, params) do
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
remember_me = get_session(conn, :user_remember_me)
|
||||
|
||||
conn
|
||||
|> renew_session(user)
|
||||
|> put_token_in_session(token)
|
||||
|> maybe_write_remember_me_cookie(token, params, remember_me)
|
||||
end
|
||||
|
||||
# Do not renew session if the user is already logged in
|
||||
# to prevent CSRF errors or data being lost in tabs that are still open
|
||||
defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do
|
||||
conn
|
||||
end
|
||||
|
||||
# This function renews the session ID and erases the whole
|
||||
# session to avoid fixation attacks. If there is any data
|
||||
# in the session you may want to preserve after log in/log out,
|
||||
# you must explicitly fetch the session data before clearing
|
||||
# and then immediately set it after clearing, for example:
|
||||
#
|
||||
# defp renew_session(conn, _user) do
|
||||
# delete_csrf_token()
|
||||
# preferred_locale = get_session(conn, :preferred_locale)
|
||||
#
|
||||
# conn
|
||||
# |> configure_session(renew: true)
|
||||
# |> clear_session()
|
||||
# |> put_session(:preferred_locale, preferred_locale)
|
||||
# end
|
||||
#
|
||||
defp renew_session(conn, _user) do
|
||||
delete_csrf_token()
|
||||
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
|
||||
do: write_remember_me_cookie(conn, token)
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, _params, true),
|
||||
do: write_remember_me_cookie(conn, token)
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
|
||||
|
||||
defp write_remember_me_cookie(conn, token) do
|
||||
conn
|
||||
|> put_session(:user_remember_me, true)
|
||||
|> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
|
||||
end
|
||||
|
||||
defp put_token_in_session(conn, token) do
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, user_session_topic(token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disconnects existing sockets for the given tokens.
|
||||
"""
|
||||
def disconnect_sessions(tokens) do
|
||||
Enum.each(tokens, fn %{token: token} ->
|
||||
BerrypodWeb.Endpoint.broadcast(user_session_topic(token), "disconnect", %{})
|
||||
end)
|
||||
end
|
||||
|
||||
defp user_session_topic(token), do: "users_sessions:#{Base.url_encode64(token)}"
|
||||
|
||||
@doc """
|
||||
Handles mounting and authenticating the current_scope in LiveViews.
|
||||
|
||||
## `on_mount` arguments
|
||||
|
||||
* `:mount_current_scope` - Assigns current_scope
|
||||
to socket assigns based on user_token, or nil if
|
||||
there's no user_token or no matching user.
|
||||
|
||||
* `:require_authenticated` - Authenticates the user from the session,
|
||||
and assigns the current_scope to socket assigns based
|
||||
on user_token.
|
||||
Redirects to login page if there's no logged user.
|
||||
|
||||
## Examples
|
||||
|
||||
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
|
||||
the `current_scope`:
|
||||
|
||||
defmodule BerrypodWeb.PageLive do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
on_mount {BerrypodWeb.UserAuth, :mount_current_scope}
|
||||
...
|
||||
end
|
||||
|
||||
Or use the `live_session` of your router to invoke the on_mount callback:
|
||||
|
||||
live_session :authenticated, on_mount: [{BerrypodWeb.UserAuth, :require_authenticated}] do
|
||||
live "/profile", ProfileLive, :index
|
||||
end
|
||||
"""
|
||||
def on_mount(:mount_current_scope, _params, session, socket) do
|
||||
{:cont, mount_current_scope(socket, session)}
|
||||
end
|
||||
|
||||
def on_mount(:require_authenticated, _params, session, socket) do
|
||||
socket = mount_current_scope(socket, session)
|
||||
|
||||
if socket.assigns.current_scope && socket.assigns.current_scope.user do
|
||||
{:cont, socket}
|
||||
else
|
||||
socket =
|
||||
socket
|
||||
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def on_mount(:require_sudo_mode, _params, session, socket) do
|
||||
socket = mount_current_scope(socket, session)
|
||||
|
||||
if Accounts.sudo_mode?(socket.assigns.current_scope.user, -10) do
|
||||
{:cont, socket}
|
||||
else
|
||||
socket =
|
||||
socket
|
||||
|> Phoenix.LiveView.put_flash(:error, "You must re-authenticate to access this page.")
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp mount_current_scope(socket, session) do
|
||||
Phoenix.Component.assign_new(socket, :current_scope, fn ->
|
||||
{user, _} =
|
||||
if user_token = session["user_token"] do
|
||||
Accounts.get_user_by_session_token(user_token)
|
||||
end || {nil, nil}
|
||||
|
||||
Scope.for_user(user)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc "Returns the path to redirect to after log in."
|
||||
# Single-tenant: every user is the admin, always go to dashboard
|
||||
def signed_in_path(_), do: ~p"/admin"
|
||||
|
||||
@doc """
|
||||
Plug for routes that require the user to be authenticated.
|
||||
"""
|
||||
def require_authenticated_user(conn, _opts) do
|
||||
if conn.assigns.current_scope && conn.assigns.current_scope.user do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must log in to access this page.")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
||||
put_session(conn, :user_return_to, current_path(conn))
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(conn), do: conn
|
||||
end
|
||||
Reference in New Issue
Block a user