extract site_name and site_description from theme settings into standalone settings

site_name and site_description are shop identity, not theme concerns.
They now live in the Settings table as first-class settings with their
own assigns (@site_name, @site_description) piped through hooks and
plugs. The setup wizard writes site_name on account creation, and the
theme editor reads/writes via Settings.put_setting. Removed the
"configure your shop" checklist item since currency/country aren't
built yet. Also adds shop name field to setup wizard step 1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-03 14:52:31 +00:00
parent 8ea77e5992
commit 5b41f3fedf
25 changed files with 464 additions and 255 deletions

View File

@@ -3,14 +3,6 @@ defmodule BerrypodWeb.Admin.Dashboard do
alias Berrypod.{Cart, Orders, Products, Settings}
@checklist_items [
%{key: :products_synced, label: "Sync your products", href: "/admin/providers"},
%{key: :stripe_connected, label: "Connect Stripe", href: "/admin/settings"},
%{key: :theme_customised, label: "Customise your theme", href: "/admin/theme"},
%{key: :has_orders, label: "Place a test order", href: "/"},
%{key: :site_live, label: "Go live", href: nil}
]
@impl true
def mount(_params, _session, socket) do
setup = Berrypod.Setup.setup_status()
@@ -23,6 +15,7 @@ defmodule BerrypodWeb.Admin.Dashboard do
|> assign(:page_title, "Dashboard")
|> assign(:setup, setup)
|> assign(:show_checklist, show_checklist?(setup))
|> assign(:checklist_collapsed, false)
|> assign(:just_went_live, false)
|> assign(:paid_count, paid_count)
|> assign(:revenue, Orders.total_revenue())
@@ -43,14 +36,8 @@ defmodule BerrypodWeb.Admin.Dashboard do
|> assign(:just_went_live, true)}
end
def handle_event("dismiss_checklist", _params, socket) do
{:ok, _} = Settings.put_setting("checklist_dismissed", true, "boolean")
setup = %{socket.assigns.setup | checklist_dismissed: true}
{:noreply,
socket
|> assign(:setup, setup)
|> assign(:show_checklist, false)}
def handle_event("toggle_checklist", _params, socket) do
{:noreply, assign(socket, :checklist_collapsed, !socket.assigns.checklist_collapsed)}
end
# ── Render ──
@@ -78,7 +65,11 @@ defmodule BerrypodWeb.Admin.Dashboard do
</div>
<%!-- Launch checklist --%>
<.launch_checklist :if={@show_checklist and !@just_went_live} setup={@setup} />
<.launch_checklist
:if={@show_checklist and !@just_went_live}
setup={@setup}
collapsed={@checklist_collapsed}
/>
<%!-- Stats --%>
<div class="admin-stats-grid">
@@ -158,86 +149,147 @@ defmodule BerrypodWeb.Admin.Dashboard do
# ==========================================================================
attr :setup, :map, required: true
attr :collapsed, :boolean, required: true
defp launch_checklist(assigns) do
items =
Enum.map(@checklist_items, fn item ->
Map.put(item, :done, Map.get(assigns.setup, item.key, false))
end)
items = checklist_items(assigns.setup)
done_count = Enum.count(items, & &1.done)
total = length(items)
# Email is optional — exclude from progress count
required_items = Enum.reject(items, &(&1.key == :email_configured))
done_count = Enum.count(required_items, & &1.done)
total = length(required_items)
progress_pct = round(done_count / total * 100)
can_go_live =
assigns.setup.provider_connected and assigns.setup.products_synced and
assigns.setup.stripe_connected
assigns =
assigns
|> assign(:items, items)
|> assign(:done_count, done_count)
|> assign(:total, total)
|> assign(:progress_pct, progress_pct)
|> assign(:can_go_live, can_go_live)
|> assign(:can_go_live, assigns.setup.can_go_live)
|> assign(:has_shipping, assigns.setup.has_shipping)
~H"""
<div class="admin-checklist admin-card-spaced">
<div class="admin-checklist-header">
<button type="button" phx-click="toggle_checklist" class="admin-checklist-header">
<h2 class="admin-checklist-title">Launch checklist</h2>
<div class="admin-checklist-progress">
<span>{@done_count} of {@total}</span>
<div class="admin-checklist-bar">
<div class="admin-checklist-bar-fill" style={"width: #{@progress_pct}%"} />
</div>
<.icon
name={if @collapsed, do: "hero-chevron-down-mini", else: "hero-chevron-up-mini"}
class="size-4"
/>
</div>
</div>
</button>
<ul class="admin-checklist-items">
<ul :if={!@collapsed} class="admin-checklist-items">
<li :for={item <- @items} class="admin-checklist-item">
<span class={["admin-checklist-check", item.done && "admin-checklist-check-done"]}>
<.icon :if={item.done} name="hero-check-mini" class="size-3" />
</span>
<span class={["admin-checklist-label", item.done && "admin-checklist-label-done"]}>
{item.label}
</span>
<div class="admin-checklist-content">
<div class="admin-checklist-row">
<span class={[
"admin-checklist-label",
item.done && "admin-checklist-label-done"
]}>
{item.label}
<span :if={item[:optional]} class="admin-checklist-optional">optional</span>
</span>
<span class="admin-checklist-action">
<%= if item.key == :site_live do %>
<button
phx-click="go_live"
disabled={!@can_go_live}
class="admin-btn admin-btn-primary admin-btn-sm"
>
<.icon name="hero-rocket-launch-mini" class="size-4" /> Go live
</button>
<% else %>
<.link
:if={!item.done}
navigate={item.href}
class="admin-btn admin-btn-secondary admin-btn-sm"
>
{if item.done, do: "View", else: "Start"} &rarr;
</.link>
<% end %>
</span>
<span class="admin-checklist-action">
<%= if item.key == :site_live do %>
<button
phx-click="go_live"
disabled={!@can_go_live}
class="admin-btn admin-btn-primary admin-btn-sm"
>
<.icon name="hero-rocket-launch-mini" class="size-4" /> Go live
</button>
<% else %>
<.link
:if={!item.done}
navigate={item.href}
class="admin-btn admin-btn-secondary admin-btn-sm"
>
Start &rarr;
</.link>
<% end %>
</span>
</div>
<p :if={item[:hint] && !item.done} class="admin-checklist-hint">
{item.hint}
</p>
<p
:if={item.key == :site_live && !@can_go_live && !@has_shipping}
class="admin-checklist-hint"
>
Shipping rates haven't synced yet. Try re-syncing your products from the <.link
navigate="/admin/providers"
class="admin-link"
>providers page</.link>.
</p>
</div>
</li>
</ul>
<div class="admin-checklist-footer">
<button
type="button"
phx-click="dismiss_checklist"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Dismiss
</button>
</div>
</div>
"""
end
defp checklist_items(setup) do
[
# Setup wizard items (done first during onboarding)
%{
key: :provider_connected,
label: "Connect a print provider",
href: "/admin/providers"
},
%{
key: :stripe_connected,
label: "Connect Stripe",
href: "/admin/settings"
},
# Post-setup items
%{
key: :products_synced,
label: "Sync your products",
href: if(setup.provider_connected, do: "/admin/products", else: "/admin/providers"),
hint: "Import products from your print provider."
},
%{
key: :theme_customised,
label: "Customise your theme",
href: "/admin/theme",
hint: "Upload your logo, pick your colours, and choose a font that matches your brand."
},
%{
key: :email_configured,
label: "Set up email",
href: "/admin/settings",
optional: true,
hint: "Needed for order confirmations, abandoned cart emails, and the contact form."
},
%{
key: :has_orders,
label: "Place a test order",
href: "/",
hint:
"Use card 4242 4242 4242 4242 with any future expiry and CVC. " <>
"You'll see the order in Orders when it works."
},
%{key: :site_live, label: "Go live"}
]
|> Enum.map(fn item ->
Map.put(item, :done, Map.get(setup, item.key, false))
end)
end
# ==========================================================================
# Components
# ==========================================================================
@@ -287,7 +339,7 @@ defmodule BerrypodWeb.Admin.Dashboard do
# ==========================================================================
defp show_checklist?(setup) do
not setup.site_live and not setup.checklist_dismissed
not setup.site_live
end
defp format_revenue(amount_pence) when is_integer(amount_pence) do

View File

@@ -688,6 +688,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
page_data={@page_data}
preview_data={@preview_data}
theme_settings={@theme_settings}
site_name={@site_name}
generated_css={@generated_css}
logo_image={@logo_image}
header_image={@header_image}

View File

@@ -26,6 +26,8 @@ defmodule BerrypodWeb.Admin.Theme.Index do
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:site_name, Settings.site_name())
|> assign(:site_description, Settings.site_description())
|> assign(:generated_css, generated_css)
|> assign(:preview_page, :home)
|> assign(:presets_with_descriptions, Presets.all_with_descriptions())
@@ -174,6 +176,16 @@ defmodule BerrypodWeb.Admin.Theme.Index do
{:noreply, socket}
end
# Settings stored outside the theme JSON
@standalone_settings ~w(site_name site_description)
@impl true
def handle_event("update_setting", %{"field" => field, "setting_value" => value}, socket)
when field in @standalone_settings do
Settings.put_setting(field, value, "string")
{:noreply, assign(socket, String.to_existing_atom(field), value)}
end
@impl true
def handle_event("update_setting", %{"field" => field, "setting_value" => value}, socket) do
field_atom = String.to_existing_atom(field)
@@ -197,6 +209,19 @@ defmodule BerrypodWeb.Admin.Theme.Index do
end
end
@impl true
def handle_event("update_setting", %{"field" => field} = params, socket)
when field in @standalone_settings do
value = params[field]
if value do
Settings.put_setting(field, value, "string")
{:noreply, assign(socket, String.to_existing_atom(field), value)}
else
{: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
@@ -439,6 +464,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
attr :page, :atom, required: true
attr :preview_data, :map, required: true
attr :theme_settings, :map, required: true
attr :site_name, :string, required: true
attr :logo_image, :any, required: true
attr :header_image, :any, required: true
attr :cart_drawer_open, :boolean, default: false

View File

@@ -74,7 +74,7 @@
<input
type="text"
name="site_name"
value={@theme_settings.site_name}
value={@site_name}
placeholder="Your shop name"
class="admin-input admin-input-lg"
/>
@@ -374,7 +374,7 @@
type="text"
name="favicon_short_name"
value={@theme_settings.favicon_short_name}
placeholder={String.slice(@theme_settings.site_name, 0, 12)}
placeholder={String.slice(@site_name, 0, 12)}
maxlength="12"
class="admin-input admin-input-sm"
/>
@@ -1181,7 +1181,7 @@
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
<span class="theme-browser-url-text truncate">
{@theme_settings.site_name |> String.downcase() |> String.replace(" ", "")}.myshopify.com
{@site_name |> String.downcase() |> String.replace(" ", "")}.myshopify.com
</span>
</div>
</div>
@@ -1213,6 +1213,7 @@
page={@preview_page}
preview_data={@preview_data}
theme_settings={@theme_settings}
site_name={@site_name}
logo_image={@logo_image}
header_image={@header_image}
cart_drawer_open={@cart_drawer_open}

View File

@@ -2,7 +2,6 @@ defmodule BerrypodWeb.Setup.Onboarding do
use BerrypodWeb, :live_view
alias Berrypod.{Accounts, Products, Settings, Setup}
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers.Provider
alias Berrypod.Stripe.Setup, as: StripeSetup
@@ -38,25 +37,30 @@ defmodule BerrypodWeb.Setup.Onboarding do
logged_in? = get_user(socket) != nil
provider_conn = Products.get_first_provider_connection()
current_step =
cond do
not logged_in? -> 1
not setup.provider_connected -> 2
true -> 3
end
socket
|> assign(:page_title, "Set up your shop")
|> assign(:setup, setup)
|> assign(:logged_in?, logged_in?)
|> assign(:current_step, current_step)
# Secret gate
|> assign(:require_secret?, Setup.require_setup_secret?())
|> assign(:secret_verified, false)
|> assign(:secret_form, to_form(%{"secret" => ""}, as: :secret))
# Account (card 1)
|> assign(:account_form, to_form(%{"email" => ""}, as: :account))
|> assign(:account_form, to_form(%{"email" => "", "shop_name" => ""}, as: :account))
# Provider (card 2)
|> assign(:providers, Provider.all())
|> assign(:selected_provider, nil)
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
|> assign(:provider_testing, false)
|> assign(:provider_test_result, nil)
|> assign(:provider_connecting, false)
|> assign(:provider_conn, provider_conn)
|> assign(:pending_provider_key, nil)
# Stripe (card 3)
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|> assign(:stripe_connecting, false)
@@ -75,10 +79,17 @@ defmodule BerrypodWeb.Setup.Onboarding do
# ── Events: Account ──
def handle_event("create_account", %{"account" => %{"email" => email}}, socket) do
def handle_event("create_account", %{"account" => params}, socket) do
email = params["email"]
shop_name = String.trim(params["shop_name"] || "")
if email == "" do
{:noreply, put_flash(socket, :error, "Please enter your email address")}
else
if shop_name != "" do
Settings.put_setting("site_name", shop_name, "string")
end
case Accounts.register_and_confirm_admin(%{email: email}) do
{:ok, user} ->
token = Accounts.generate_login_token(user)
@@ -105,33 +116,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
{:noreply,
socket
|> assign(:selected_provider, type)
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
|> assign(:provider_test_result, nil)
|> assign(:pending_provider_key, nil)}
end
def handle_event("validate_provider", %{"provider" => params}, socket) do
{:noreply, assign(socket, pending_provider_key: params["api_key"])}
end
def handle_event("test_provider", _params, socket) do
type = socket.assigns.selected_provider
api_key = socket.assigns.pending_provider_key
if api_key in [nil, ""] do
{:noreply, assign(socket, provider_test_result: {:error, :no_api_key})}
else
socket = assign(socket, provider_testing: true, provider_test_result: nil)
temp_conn = %ProviderConnection{
provider_type: type,
api_key_encrypted: encrypt_api_key(api_key)
}
result = Berrypod.Providers.test_connection(temp_conn)
{:noreply, assign(socket, provider_testing: false, provider_test_result: result)}
end
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))}
end
def handle_event("connect_provider", %{"provider" => %{"api_key" => api_key}}, socket) do
@@ -142,22 +127,32 @@ defmodule BerrypodWeb.Setup.Onboarding do
else
socket = assign(socket, provider_connecting: true)
params =
%{"api_key" => api_key, "provider_type" => type}
|> maybe_add_shop_config(socket.assigns.provider_test_result)
|> maybe_add_name(socket.assigns.provider_test_result, type)
name =
case Provider.get(type) do
nil -> type
info -> info.name
end
params = %{"api_key" => api_key, "provider_type" => type, "name" => name}
case Products.create_provider_connection(params) do
{:ok, connection} ->
Products.enqueue_sync(connection)
setup = Setup.setup_status()
{:noreply,
socket
|> assign(:provider_connecting, false)
|> assign(:provider_conn, connection)
|> assign(:setup, setup)
|> put_flash(:info, "Connected! Product sync started in the background.")}
if setup.setup_complete do
{:noreply,
socket
|> put_flash(:info, "You're in! Here's your launch checklist.")
|> push_navigate(to: ~p"/admin")}
else
{:noreply,
socket
|> assign(:provider_connecting, false)
|> assign(:provider_conn, connection)
|> assign(:setup, setup)
|> put_flash(:info, "Connected! Product sync started in the background.")}
end
{:error, _changeset} ->
{:noreply,
@@ -180,11 +175,18 @@ defmodule BerrypodWeb.Setup.Onboarding do
{:ok, _result} ->
setup = Setup.setup_status()
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> assign(:setup, setup)
|> put_flash(:info, "Stripe connected")}
if setup.setup_complete do
{:noreply,
socket
|> put_flash(:info, "You're in! Here's your launch checklist.")
|> push_navigate(to: ~p"/admin")}
else
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> assign(:setup, setup)
|> put_flash(:info, "Stripe connected")}
end
{:error, message} ->
{:noreply,
@@ -243,20 +245,27 @@ defmodule BerrypodWeb.Setup.Onboarding do
<%!-- All three setup cards --%>
<div class="setup-sections">
<.section_card
title="Create admin account"
title="Set up your account"
number={1}
done={@logged_in?}
summary={account_summary(assigns)}
>
<p class="setup-hint">Enter your email to create the admin account.</p>
<p class="setup-hint">Name your shop and create the admin account.</p>
<.form for={@account_form} phx-submit="create_account">
<.input
field={@account_form[:shop_name]}
type="text"
label="Shop name"
placeholder="e.g. Acme Prints"
autocomplete="off"
phx-mounted={@current_step == 1 && JS.focus()}
/>
<.input
field={@account_form[:email]}
type="email"
label="Email address"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<div class="setup-actions">
<.button phx-disable-with="Creating account...">Create account</.button>
@@ -274,8 +283,6 @@ defmodule BerrypodWeb.Setup.Onboarding do
providers={@providers}
selected={@selected_provider}
form={@provider_form}
testing={@provider_testing}
test_result={@provider_test_result}
connecting={@provider_connecting}
/>
</.section_card>
@@ -289,6 +296,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
<.stripe_section
form={@stripe_form}
connecting={@stripe_connecting}
focus={@current_step == 3}
/>
</.section_card>
</div>
@@ -346,8 +354,6 @@ defmodule BerrypodWeb.Setup.Onboarding do
attr :providers, :list, required: true
attr :selected, :string, default: nil
attr :form, :any, required: true
attr :testing, :boolean, required: true
attr :test_result, :any, default: nil
attr :connecting, :boolean, required: true
defp provider_section(assigns) do
@@ -376,7 +382,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
</a>
</p>
<.form for={@form} phx-change="validate_provider" phx-submit="connect_provider">
<.form for={@form} phx-submit="connect_provider">
<.input
field={@form[:api_key]}
type="password"
@@ -386,58 +392,21 @@ defmodule BerrypodWeb.Setup.Onboarding do
/>
<div class="setup-actions">
<button
type="button"
phx-click="test_provider"
disabled={@testing}
class="admin-btn admin-btn-secondary"
>
<%= if @testing do %>
<.icon name="hero-arrow-path" class="size-4 animate-spin" /> Checking...
<% else %>
<.icon name="hero-signal" class="size-4" /> Check connection
<% end %>
</button>
<.button type="submit" disabled={@connecting or @testing}>
<.button type="submit" disabled={@connecting}>
{if @connecting, do: "Connecting...", else: "Connect"}
</.button>
</div>
<.provider_test_feedback :if={@test_result} result={@test_result} />
</.form>
</div>
</div>
"""
end
attr :result, :any, required: true
defp provider_test_feedback(assigns) do
~H"""
<div class="setup-test-result">
<%= case @result do %>
<% {:ok, info} -> %>
<span class="setup-test-ok">
<.icon name="hero-check-circle" class="size-4" />
Connected{if info[:shop_name], do: " to #{info.shop_name}", else: ""}
</span>
<% {:error, :no_api_key} -> %>
<span class="setup-test-error">
<.icon name="hero-x-circle" class="size-4" /> Please enter your API token
</span>
<% {:error, reason} -> %>
<span class="setup-test-error">
<.icon name="hero-x-circle" class="size-4" /> {format_error(reason)}
</span>
<% end %>
</div>
"""
end
# ── Stripe section ──
attr :form, :any, required: true
attr :connecting, :boolean, required: true
attr :focus, :boolean, default: false
defp stripe_section(assigns) do
~H"""
@@ -461,13 +430,14 @@ defmodule BerrypodWeb.Setup.Onboarding do
label="Secret key"
autocomplete="off"
placeholder="sk_test_... or sk_live_..."
phx-mounted={@focus && JS.focus()}
/>
<p class="setup-key-hint">
Starts with <code>sk_test_</code> or <code>sk_live_</code>. Encrypted at rest.
</p>
<div class="setup-actions">
<.button phx-disable-with="Connecting...">
{if @connecting, do: "Connecting...", else: "Connect Stripe"}
{if @connecting, do: "Connecting...", else: "Connect"}
</.button>
</div>
</.form>
@@ -501,31 +471,6 @@ defmodule BerrypodWeb.Setup.Onboarding do
defp stripe_summary(_), do: nil
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: name}}, _type) when is_binary(name) do
Map.put_new(params, "name", name)
end
defp maybe_add_name(params, _, type) do
case Provider.get(type) do
nil -> Map.put_new(params, "name", type)
info -> Map.put_new(params, "name", info.name)
end
end
defp provider_card_options(providers) do
Enum.map(providers, fn provider ->
option = %{
@@ -541,11 +486,4 @@ defmodule BerrypodWeb.Setup.Onboarding do
end
end)
end
defp format_error(:unauthorized), do: "That token doesn't seem to be valid"
defp format_error(:timeout), do: "Couldn't reach the provider — try again"
defp format_error(:provider_not_implemented), do: "This provider isn't supported yet"
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 token and try again"
end

View File

@@ -12,9 +12,9 @@ defmodule BerrypodWeb.Shop.ComingSoon do
<main class="coming-soon" role="main">
<div>
<div :if={@logo_image} class="coming-soon-logo">
<img src={logo_url(@logo_image)} alt={@theme_settings.site_name} />
<img src={logo_url(@logo_image)} alt={@site_name} />
</div>
<h1 class="coming-soon-title">{@theme_settings.site_name}</h1>
<h1 class="coming-soon-title">{@site_name}</h1>
<p class="coming-soon-message">
We're getting things ready. Check back soon.
</p>

View File

@@ -9,7 +9,7 @@ defmodule BerrypodWeb.Shop.Home do
extra = Pages.load_block_data(page.blocks, socket.assigns)
base = BerrypodWeb.Endpoint.url()
site_name = socket.assigns.theme_settings.site_name
site_name = socket.assigns.site_name
org_ld =
Jason.encode!(