add setup foundations: site gate, registration lockdown, coming soon page

- Settings.site_live?/0 and set_site_live/1 for shop visibility control
- Accounts.has_admin?/0 to detect single-tenant admin existence
- Registration lockdown: /users/register redirects when admin exists
- Setup.setup_status/0 aggregates provider, product, and stripe checks
- Coming soon page at /coming-soon with themed styling
- ThemeHook :require_site_live gate on all public shop routes
  - Site live → everyone through
  - Authenticated → admin preview through
  - No admin → fresh install demo through
  - Otherwise → redirect to coming soon
- Go live / take offline toggle on /admin/settings
- 648 tests, 0 failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-11 22:58:58 +00:00
parent 093bdcc7a6
commit e64bf40a71
16 changed files with 471 additions and 74 deletions

View File

@@ -60,6 +60,15 @@ defmodule SimpleshopTheme.Accounts do
"""
def get_user!(id), do: Repo.get!(User, id)
@doc """
Returns whether an admin user exists.
SimpleShop is single-tenant — any user in the database is the admin.
"""
def has_admin? do
Repo.exists?(User)
end
## User registration
@doc """

View File

@@ -116,6 +116,22 @@ defmodule SimpleshopTheme.Settings do
end
end
@doc """
Returns whether the shop is live (visible to the public).
Defaults to `false` for fresh installs.
"""
def site_live? do
get_setting("site_live", false) == true
end
@doc """
Sets whether the shop is live (visible to the public).
"""
def set_site_live(live?) when is_boolean(live?) do
put_setting("site_live", live?, "boolean")
end
@doc """
Deletes a setting by key.
"""

View File

@@ -0,0 +1,33 @@
defmodule SimpleshopTheme.Setup do
@moduledoc """
Aggregates setup status checks for the admin setup flow.
"""
alias SimpleshopTheme.{Accounts, Products, Settings}
@doc """
Returns a map describing the current setup status.
Used by the admin setup checklist and ThemeHook gate to determine
what's been completed and whether the shop can go live.
"""
def setup_status do
conn = Products.get_provider_connection_by_type("printify")
product_count = Products.count_products_for_connection(conn && conn.id)
printify_connected = conn != nil and conn.api_key_encrypted != nil
products_synced = product_count > 0
stripe_connected = Settings.has_secret?("stripe_api_key")
site_live = Settings.site_live?()
%{
admin_created: Accounts.has_admin?(),
printify_connected: printify_connected,
products_synced: products_synced,
product_count: product_count,
stripe_connected: stripe_connected,
site_live: site_live,
can_go_live: printify_connected and products_synced and stripe_connected
}
end
end

View File

@@ -6,7 +6,11 @@ defmodule SimpleshopThemeWeb.AdminLive.Settings do
@impl true
def mount(_params, _session, socket) do
{:ok, socket |> assign(:page_title, "Credentials") |> assign_stripe_state()}
{:ok,
socket
|> assign(:page_title, "Settings")
|> assign(:site_live, Settings.site_live?())
|> assign_stripe_state()}
end
defp assign_stripe_state(socket) do
@@ -96,6 +100,18 @@ defmodule SimpleshopThemeWeb.AdminLive.Settings do
end
end
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
def handle_event("toggle_advanced", _params, socket) do
{:noreply, assign(socket, :advanced_open, !socket.assigns.advanced_open)}
end
@@ -106,10 +122,50 @@ defmodule SimpleshopThemeWeb.AdminLive.Settings do
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="max-w-2xl">
<.header>
Credentials
<:subtitle>Connect payment providers and manage API keys</:subtitle>
Settings
<:subtitle>Shop status, payment providers, and API keys</:subtitle>
</.header>
<section class="mt-10">
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold">Shop status</h2>
<%= if @site_live do %>
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset">
<.icon name="hero-check-circle-mini" class="size-3" /> Live
</span>
<% else %>
<span class="inline-flex items-center gap-1 rounded-full bg-zinc-50 px-2 py-1 text-xs font-medium text-zinc-600 ring-1 ring-zinc-500/10 ring-inset">
Offline
</span>
<% end %>
</div>
<p class="mt-2 text-sm text-zinc-600">
<%= 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-zinc-100 text-zinc-700 hover:bg-zinc-200 ring-1 ring-zinc-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>
<section class="mt-10">
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold">Stripe</h2>

View File

@@ -0,0 +1,22 @@
defmodule SimpleshopThemeWeb.ShopLive.ComingSoon do
use SimpleshopThemeWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :page_title, "Coming soon")}
end
@impl true
def render(assigns) do
~H"""
<main class="flex min-h-screen items-center justify-center px-6 text-center" role="main">
<div>
<h1 class="text-3xl font-bold tracking-tight sm:text-4xl">{@theme_settings.site_name}</h1>
<p class="mt-4 text-lg text-[var(--t-text-muted)]">
We're getting things ready. Check back soon.
</p>
</div>
</main>
"""
end
end

View File

@@ -48,9 +48,15 @@ defmodule SimpleshopThemeWeb.UserLive.Registration do
end
def mount(_params, _session, socket) do
changeset = Accounts.change_user_email(%User{}, %{}, validate_unique: false)
{:ok, assign_form(socket, changeset), temporary_assigns: [form: nil]}
if Accounts.has_admin?() do
{:ok,
socket
|> put_flash(:error, "Registration is closed")
|> redirect(to: ~p"/users/log-in")}
else
changeset = Accounts.change_user_email(%User{}, %{}, validate_unique: false)
{:ok, assign_form(socket, changeset), temporary_assigns: [form: nil]}
end
end
@impl true

View File

@@ -32,10 +32,19 @@ defmodule SimpleshopThemeWeb.Router do
scope "/", SimpleshopThemeWeb do
pipe_through [:browser, :shop]
live_session :coming_soon,
layout: {SimpleshopThemeWeb.Layouts, :shop},
on_mount: [
{SimpleshopThemeWeb.ThemeHook, :mount_theme}
] do
live "/coming-soon", ShopLive.ComingSoon, :index
end
live_session :public_shop,
layout: {SimpleshopThemeWeb.Layouts, :shop},
on_mount: [
{SimpleshopThemeWeb.ThemeHook, :mount_theme},
{SimpleshopThemeWeb.ThemeHook, :require_site_live},
{SimpleshopThemeWeb.CartHook, :mount_cart}
] do
live "/", ShopLive.Home, :index

View File

@@ -4,6 +4,12 @@ defmodule SimpleshopThemeWeb.ThemeHook do
Mounted in the public_shop live_session alongside CartHook.
Eliminates the identical theme-loading boilerplate from every shop LiveView.
## Actions
- `:mount_theme` — loads theme settings, CSS, and media assigns
- `:require_site_live` — redirects unauthenticated visitors to /coming-soon
when the shop is not live
"""
import Phoenix.Component, only: [assign: 3]
@@ -35,4 +41,21 @@ defmodule SimpleshopThemeWeb.ThemeHook do
{:cont, socket}
end
def on_mount(:require_site_live, _params, session, socket) do
cond do
Settings.site_live?() ->
{:cont, socket}
session["user_token"] ->
{:cont, socket}
not SimpleshopTheme.Accounts.has_admin?() ->
# Fresh install — no admin yet, show the demo shop
{:cont, socket}
true ->
{:halt, Phoenix.LiveView.redirect(socket, to: "/coming-soon")}
end
end
end