Files
berrypod/lib/berrypod_web/live/admin/settings.ex
jamey 4aa7dece0c
All checks were successful
deploy / deploy (push) Successful in 4m59s
add SEO enhancements: OG images, meta robots, FAQ block, image sitemap
- Per-page SEO controls: meta robots directives, focus keyword, OG image
- Site-wide default OG image in admin settings
- FAQ block type with FAQPage JSON-LD schema
- Enhanced Organization JSON-LD with business info, contact, address
- Image sitemap with product images
- SEO preview panel with Google/social card mockups
- SEO checklist with real-time scoring
- Business info section in site editor
- GSC integration scaffolding (OAuth, client, cache)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-17 16:47:43 +01:00

925 lines
29 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
defmodule BerrypodWeb.Admin.Settings do
use BerrypodWeb, :live_view
alias Berrypod.Media
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(:cart_recovery_enabled, Settings.abandoned_cart_recovery_enabled?())
|> assign(:from_address, Settings.get_setting("email_from_address") || user.email)
|> assign(:from_address_status, :idle)
|> assign(:signing_secret_status, :idle)
|> assign_stripe_state()
|> assign_products_state()
|> assign_url_prefixes()
|> assign_og_image_state()}
end
defp assign_og_image_state(socket) do
og_image = Media.get_default_og_image()
socket
|> assign(:og_image, og_image)
|> assign(:og_picker_open, false)
|> assign(:og_picker_images, [])
end
defp assign_url_prefixes(socket) do
socket
|> assign(:products_prefix, Settings.get_url_prefix(:products))
|> assign(:collections_prefix, Settings.get_url_prefix(:collections))
|> assign(:prefix_status, :idle)
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
# -- 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: cart recovery --
def handle_event("toggle_cart_recovery", _params, socket) do
new_value = !socket.assigns.cart_recovery_enabled
{:ok, _} = Settings.set_abandoned_cart_recovery(new_value)
message =
if new_value,
do: "Cart recovery emails enabled",
else: "Cart recovery emails disabled"
{:noreply,
socket
|> assign(:cart_recovery_enabled, new_value)
|> put_flash(:info, message)}
end
# -- Events: from address --
def handle_event("change_from_address", _params, socket) do
{:noreply, assign(socket, :from_address_status, :idle)}
end
def handle_event("save_from_address", %{"from_address" => address}, socket) do
address = String.trim(address)
if address != "" do
Settings.put_setting("email_from_address", address)
{:noreply,
socket
|> assign(:from_address, address)
|> assign(:from_address_status, :saved)}
else
{:noreply, put_flash(socket, :error, "From address can't be blank")}
end
end
# -- Events: URL prefixes --
def handle_event("change_prefix", _params, socket) do
{:noreply, assign(socket, :prefix_status, :idle)}
end
def handle_event("save_prefixes", %{"prefixes" => params}, socket) do
products_prefix = params["products"] || ""
collections_prefix = params["collections"] || ""
errors = []
# Update products prefix if changed
errors =
if products_prefix != socket.assigns.products_prefix do
case Settings.update_url_prefix(:products, products_prefix) do
{:ok, _} -> errors
{:error, reason} -> [{:products, reason} | errors]
end
else
errors
end
# Update collections prefix if changed
errors =
if collections_prefix != socket.assigns.collections_prefix do
case Settings.update_url_prefix(:collections, collections_prefix) do
{:ok, _} -> errors
{:error, reason} -> [{:collections, reason} | errors]
end
else
errors
end
if errors == [] do
{:noreply,
socket
|> assign_url_prefixes()
|> assign(:prefix_status, :saved)}
else
error_message = format_prefix_errors(errors)
{:noreply, put_flash(socket, :error, error_message)}
end
end
# -- Events: OG image --
def handle_event("show_og_picker", _params, socket) do
images = Media.list_images() |> Enum.take(50)
{:noreply, assign(socket, og_picker_open: true, og_picker_images: images)}
end
def handle_event("hide_og_picker", _params, socket) do
{:noreply, assign(socket, og_picker_open: false)}
end
def handle_event("pick_og_image", %{"id" => id}, socket) do
image = Media.get_image(id)
if image do
Media.update_image_type(image, "default_og")
{:noreply,
socket
|> assign(:og_image, image)
|> assign(:og_picker_open, false)
|> put_flash(:info, "Default social image set")}
else
{:noreply, put_flash(socket, :error, "Image not found")}
end
end
def handle_event("clear_og_image", _params, socket) do
if socket.assigns.og_image do
Media.update_image_type(socket.assigns.og_image, "media")
end
{:noreply,
socket
|> assign(:og_image, nil)
|> put_flash(:info, "Default social image removed")}
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("change_signing_secret", _params, socket) do
{:noreply, assign(socket, :signing_secret_status, :idle)}
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()
|> assign(:signing_secret_status, :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
# -- Render --
@impl true
def render(assigns) do
~H"""
<div class="admin-settings">
<.header>
Settings
</.header>
<%!-- Shop status --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">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="admin-section-desc">
<%= 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="admin-section-body">
<button
phx-click="toggle_site_live"
class={[
"admin-btn admin-btn-sm",
if(@site_live, do: "admin-btn-outline", else: "admin-btn-primary")
]}
>
<%= 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="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">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}
signing_secret_status={@signing_secret_status}
/>
<% end %>
</section>
<%!-- Products --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">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="admin-section-body">
<p class="admin-section-desc admin-section-desc-flush">
Connect a print-on-demand provider to import products into your shop.
</p>
<div class="admin-section-body">
<.link navigate={~p"/admin/providers"} class="admin-btn admin-btn-primary admin-btn-sm">
<.icon name="hero-plus-mini" class="size-4" /> Connect a provider
</.link>
</div>
</div>
<% end %>
</section>
<%!-- Cart recovery --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">Cart recovery</h2>
<%= if @cart_recovery_enabled do %>
<.status_pill color="green">
<.icon name="hero-check-circle-mini" class="size-3" /> On
</.status_pill>
<% else %>
<.status_pill color="zinc">Off</.status_pill>
<% end %>
</div>
<p class="admin-section-desc">
When on, customers who entered their email at Stripe checkout but didn't complete
payment receive a single plain-text recovery email one hour later.
No tracking pixels. One email, never more.
</p>
<%= if @cart_recovery_enabled do %>
<p class="admin-section-desc admin-text-warning">
Make sure your privacy policy mentions that a single recovery email may be sent,
and that customers can unsubscribe at any time.
</p>
<% end %>
<div class="admin-section-body">
<button
phx-click="toggle_cart_recovery"
class={[
"admin-btn admin-btn-sm",
if(@cart_recovery_enabled, do: "admin-btn-outline", else: "admin-btn-primary")
]}
>
{if @cart_recovery_enabled, do: "Turn off", else: "Turn on"}
</button>
</div>
</section>
<%!-- From address --%>
<section class="admin-section">
<h2 class="admin-section-title">From address</h2>
<p class="admin-section-desc">
The sender address on all emails from your shop.
</p>
<div class="admin-section-body">
<form
action={~p"/admin/settings/from-address"}
method="post"
phx-change="change_from_address"
phx-submit="save_from_address"
class="admin-row admin-row-lg"
>
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<.input
name="from_address"
value={@from_address}
type="email"
placeholder="noreply@yourshop.com"
/>
<.button phx-disable-with="Saving...">Save</.button>
<.inline_feedback status={@from_address_status} />
</form>
</div>
</section>
<%!-- URL prefixes --%>
<section class="admin-section">
<h2 class="admin-section-title">URL prefixes</h2>
<p class="admin-section-desc">
Customise the URL structure for products and collections.
Old URLs will automatically redirect to the new ones.
</p>
<div class="admin-section-body">
<form
phx-change="change_prefix"
phx-submit="save_prefixes"
class="admin-stack"
>
<div class="admin-row admin-row-lg">
<label class="admin-label admin-label-inline" for="prefix-products">
Products
</label>
<div class="admin-row admin-row-sm">
<span class="admin-text-secondary">/</span>
<.input
name="prefixes[products]"
id="prefix-products"
value={@products_prefix}
placeholder="products"
pattern="[a-z0-9-]+"
/>
<span class="admin-text-secondary">/</span>
<span class="admin-text-tertiary">product-slug</span>
</div>
</div>
<div class="admin-row admin-row-lg">
<label class="admin-label admin-label-inline" for="prefix-collections">
Collections
</label>
<div class="admin-row admin-row-sm">
<span class="admin-text-secondary">/</span>
<.input
name="prefixes[collections]"
id="prefix-collections"
value={@collections_prefix}
placeholder="collections"
pattern="[a-z0-9-]+"
/>
<span class="admin-text-secondary">/</span>
<span class="admin-text-tertiary">collection-slug</span>
</div>
</div>
<p class="admin-help-text">
Use only lowercase letters, numbers, and hyphens.
</p>
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Save</.button>
<.inline_feedback status={@prefix_status} />
</div>
</form>
</div>
</section>
<%!-- Default social image --%>
<section class="admin-section">
<h2 class="admin-section-title">Default social image</h2>
<p class="admin-section-desc">
The image shown when pages are shared on social media.
Individual pages can override this in their settings.
</p>
<div class="admin-section-body">
<%= if @og_image do %>
<div class="page-settings-og-preview">
<img
src={og_image_url(@og_image)}
alt="Current social image"
class="page-settings-og-thumb"
/>
<div class="page-settings-og-actions">
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-outline admin-btn-sm"
>
Change
</button>
<button
type="button"
phx-click="clear_og_image"
class="admin-link-danger"
>
Remove
</button>
</div>
</div>
<% else %>
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-outline admin-btn-sm"
>
<.icon name="hero-photo" class="size-4" /> Choose image
</button>
<% end %>
</div>
</section>
<.og_picker_modal
:if={@og_picker_open}
images={@og_picker_images}
/>
</div>
"""
end
defp og_picker_modal(assigns) do
~H"""
<div class="admin-modal-backdrop" phx-click="hide_og_picker">
<div class="admin-modal admin-modal-lg" phx-click-away="hide_og_picker">
<div class="admin-modal-header">
<h3>Choose social image</h3>
<button type="button" phx-click="hide_og_picker" class="admin-modal-close">
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
<div class="admin-modal-body">
<p class="admin-help-text" style="margin-bottom: 1rem;">
Choose an image from your media library.
Recommended size: 1200×630 pixels.
</p>
<%= if @images == [] do %>
<p class="admin-text-secondary">
No images in your media library.
<.link navigate={~p"/admin/media"} class="admin-link">Upload images</.link>
first.
</p>
<% else %>
<div class="og-picker-grid">
<%= for image <- @images do %>
<button
type="button"
phx-click="pick_og_image"
phx-value-id={image.id}
class="og-picker-item"
>
<img src={og_image_url(image)} alt={image.filename} />
</button>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
"""
end
defp og_image_url(image) do
if image.is_svg do
"/image_cache/#{image.id}.webp"
else
applicable_width =
image.source_width
|> Berrypod.Images.Optimizer.applicable_widths()
|> Enum.find(&(&1 >= 400))
"/image_cache/#{image.id}-#{applicable_width || 400}.webp"
end
end
# -- Function components --
attr :color, :string, required: true
slot :inner_block, required: true
defp status_pill(assigns) do
modifier =
case assigns.color do
"green" -> "admin-status-pill-green"
"amber" -> "admin-status-pill-amber"
_ -> "admin-status-pill-zinc"
end
assigns = assign(assigns, :modifier, modifier)
~H"""
<span class={["admin-status-pill", @modifier]}>
{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="admin-section-body">
<dl class="admin-dl">
<div class="admin-dl-row">
<dt class="admin-dl-term">Provider</dt>
<dd class="admin-dl-value">{@provider_label}</dd>
</div>
<div class="admin-dl-row">
<dt class="admin-dl-term">Shop</dt>
<dd class="admin-dl-value">{@connection.name}</dd>
</div>
<div class="admin-dl-row">
<dt class="admin-dl-term">Products</dt>
<dd class="admin-dl-value">{@product_count}</dd>
</div>
<div class="admin-dl-row">
<dt class="admin-dl-term">Last synced</dt>
<dd class="admin-dl-value">
<%= if @connection.last_synced_at do %>
{format_relative_time(@connection.last_synced_at)}
<% else %>
<span class="admin-text-warning">Never</span>
<% end %>
</dd>
</div>
</dl>
<div class="admin-cluster admin-section-body">
<button
phx-click="sync"
phx-value-id={@connection.id}
disabled={@syncing}
class="admin-btn admin-btn-outline admin-btn-sm"
>
<.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="admin-btn admin-btn-outline admin-btn-sm"
>
<.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="admin-link-danger"
>
Disconnect
</button>
</div>
</div>
"""
end
defp stripe_setup_form(assigns) do
~H"""
<div class="admin-section-body">
<p class="admin-section-desc admin-section-desc-flush">
To accept payments, connect your Stripe account by entering your secret key.
You can find it in your
<.external_link href="https://dashboard.stripe.com/apikeys" class="admin-link">
Stripe dashboard
</.external_link>
under Developers &rarr; API keys.
</p>
<.form for={@connect_form} phx-submit="connect_stripe" class="admin-card-spaced">
<.input
field={@connect_form[:api_key]}
type="password"
label="Secret key"
autocomplete="off"
placeholder="sk_test_... or sk_live_..."
/>
<p class="admin-help-text">
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="admin-section-body">
<.button phx-disable-with="Connecting...">
Connect Stripe
</.button>
</div>
</.form>
</div>
"""
end
defp stripe_connected_view(assigns) do
~H"""
<div class="admin-stack admin-section-body">
<dl class="admin-dl">
<div class="admin-dl-row">
<dt class="admin-dl-term">API key</dt>
<dd class="admin-dl-value"><code>{@stripe_api_key_hint}</code></dd>
</div>
<div class="admin-dl-row">
<dt class="admin-dl-term">Webhook URL</dt>
<dd class="admin-dl-value">
<code class="admin-code-break">{@stripe_webhook_url}</code>
</dd>
</div>
<div class="admin-dl-row">
<dt class="admin-dl-term">Webhook secret</dt>
<dd class="admin-dl-value">
<%= if @stripe_has_signing_secret do %>
<code>{@stripe_signing_secret_hint}</code>
<% else %>
<span class="admin-text-warning">Not set</span>
<% end %>
</dd>
</div>
</dl>
<%= if @stripe_status == :connected_localhost do %>
<div class="admin-info-box admin-info-box-amber">
<p>
Stripe can't reach localhost for webhooks. For local testing, run the Stripe CLI:
</p>
<pre>stripe listen --forward-to localhost:4000/webhooks/stripe</pre>
<p class="admin-help-text admin-text-warning">
The CLI will output a signing secret starting with <code>whsec_</code>. Enter it below.
</p>
</div>
<.form
for={@secret_form}
action={~p"/admin/settings/stripe/signing-secret"}
method="post"
phx-change="change_signing_secret"
phx-submit="save_signing_secret"
>
<.input
field={@secret_form[:signing_secret]}
type="password"
label="Webhook signing secret"
autocomplete="off"
placeholder="whsec_..."
/>
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Save signing secret</.button>
<.inline_feedback status={@signing_secret_status} />
</div>
</.form>
<% else %>
<div class="admin-separator">
<button
phx-click="toggle_stripe_advanced"
class="admin-link-subtle admin-row admin-row-sm"
>
<.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="admin-form-actions-sm">
<p class="admin-text-tertiary">
Override the webhook signing secret if you need to use a custom endpoint or the Stripe CLI.
</p>
<.form
for={@secret_form}
action={~p"/admin/settings/stripe/signing-secret"}
method="post"
phx-change="change_signing_secret"
phx-submit="save_signing_secret"
>
<.input
field={@secret_form[:signing_secret]}
type="password"
label="Webhook signing secret"
autocomplete="off"
placeholder="whsec_..."
/>
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Save signing secret</.button>
<.inline_feedback status={@signing_secret_status} />
</div>
</.form>
</div>
<% end %>
</div>
<% end %>
<div class="admin-separator-lg">
<button
phx-click="disconnect_stripe"
data-confirm="This will remove your Stripe API key and delete the webhook endpoint. Are you sure?"
class="admin-link-danger"
>
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
defp format_prefix_errors(errors) do
Enum.map_join(errors, "; ", fn {field, reason} ->
field_name = if field == :products, do: "Products", else: "Collections"
message =
case reason do
:empty_prefix -> "can't be blank"
:invalid_format -> "must contain only letters, numbers, and hyphens"
:reserved_prefix -> "is reserved"
_ -> "is invalid"
end
"#{field_name} prefix #{message}"
end)
end
end