All checks were successful
deploy / deploy (push) Successful in 4m59s
- 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>
925 lines
29 KiB
Elixir
925 lines
29 KiB
Elixir
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 → 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
|