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(:cart_recovery_enabled, Settings.abandoned_cart_recovery_enabled?()) |> 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: 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: 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"""
<.header> Settings <%!-- Shop status --%>

Shop status

<%= if @site_live do %> <.status_pill color="green"> <.icon name="hero-check-circle-mini" class="size-3" /> Live <% else %> <.status_pill color="zinc">Offline <% end %>

<%= if @site_live do %> Your shop is visible to the public. <% else %> Your shop is offline. Visitors see a "coming soon" page. <% end %>

<%!-- Payments --%>

Payments

<%= case @stripe_status do %> <% :connected -> %> <.status_pill color="green"> <.icon name="hero-check-circle-mini" class="size-3" /> Connected <% :connected_localhost -> %> <.status_pill color="amber"> <.icon name="hero-exclamation-triangle-mini" class="size-3" /> Dev mode <% :not_configured -> %> <.status_pill color="zinc">Not connected <% end %>
<%= 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 %>
<%!-- Products --%>

Products

<%= if @provider do %> <.status_pill color="green"> <.icon name="hero-check-circle-mini" class="size-3" /> Connected <% else %> <.status_pill color="zinc">Not connected <% end %>
<%= if @provider do %> <.provider_connected provider={@provider} /> <% else %>

Connect a print-on-demand provider to import products into your shop.

<.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
<% end %>
<%!-- Cart recovery --%>

Cart recovery

<%= if @cart_recovery_enabled do %> <.status_pill color="green"> <.icon name="hero-check-circle-mini" class="size-3" /> On <% else %> <.status_pill color="zinc">Off <% end %>

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.

<%= if @cart_recovery_enabled do %>

Make sure your privacy policy mentions that a single recovery email may be sent, and that customers can unsubscribe at any time.

<% end %>
<%!-- Account --%>

Account

<.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 />
<.button phx-disable-with="Saving...">Change email
<.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 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" />
<.button phx-disable-with="Saving...">Change password
<%!-- Advanced --%>

Advanced

<.link href={~p"/admin/dashboard"} class="admin-link-subtle"> <.icon name="hero-chart-bar" class="size-4 inline" /> System dashboard <.link href={~p"/admin/errors"} class="admin-link-subtle"> <.icon name="hero-bug-ant" class="size-4 inline" /> Error tracker
""" 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""" {render_slot(@inner_block)} """ 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"""
Provider
{@provider_label}
Shop
{@connection.name}
Products
{@product_count}
Last synced
<%= if @connection.last_synced_at do %> {format_relative_time(@connection.last_synced_at)} <% else %> Never <% end %>
<.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
""" end defp stripe_setup_form(assigns) do ~H"""

To accept payments, connect your Stripe account by entering your secret key. You can find it in your Stripe dashboard under Developers → API keys.

<.form for={@connect_form} phx-submit="connect_stripe" style="margin-top: 1.5rem;"> <.input field={@connect_form[:api_key]} type="password" label="Secret key" autocomplete="off" placeholder="sk_test_... or sk_live_..." />

Starts with sk_test_ (test mode) or sk_live_ (live mode). This key is encrypted at rest in the database.

<.button phx-disable-with="Connecting..."> Connect Stripe
""" end defp stripe_connected_view(assigns) do ~H"""
API key
{@stripe_api_key_hint}
Webhook URL
{@stripe_webhook_url}
Webhook secret
<%= if @stripe_has_signing_secret do %> {@stripe_signing_secret_hint} <% else %> Not set <% end %>
<%= if @stripe_status == :connected_localhost do %>

Stripe can't reach localhost for webhooks. For local testing, run the Stripe CLI:

stripe listen --forward-to localhost:4000/webhooks/stripe

The CLI will output a signing secret starting with whsec_. Enter it below.

<.form for={@secret_form} phx-submit="save_signing_secret" style="margin-top: 0.5rem;"> <.input field={@secret_form[:signing_secret]} type="password" label="Webhook signing secret" autocomplete="off" placeholder="whsec_..." />
<.button phx-disable-with="Saving...">Save signing secret
<% else %>
<%= if @advanced_open do %>

Override the webhook signing secret if you need to use a custom endpoint or the Stripe CLI.

<.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_..." />
<.button phx-disable-with="Saving...">Save signing secret
<% end %>
<% end %>
""" 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