All modules, configs, paths, and references updated. 836 tests pass, zero warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
692 lines
22 KiB
Elixir
692 lines
22 KiB
Elixir
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
|