fix content image double-suffix, clean up page defaults and editor UX
All checks were successful
deploy / deploy (push) Successful in 1m22s

- Fix resolve_content_image returning base path (not full URL) so
  responsive_image doesn't double-append width/extension
- Remove legacy image fields (image_src, image_alt, image_url) from
  block settings schemas
- Remove demo/mockup fallbacks from renderer and defaults — blank
  fields stay blank instead of showing preview content
- Replace demo text in defaults with instructional placeholders that
  guide new shop owners
- Remove redundant X button from editor sidebar, add unsaved-changes
  confirmation to Done button
- Fix block card name overflow on mobile (display: block, flex-wrap)
- Add onboarding UX improvement plan (10 tasks)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-03-03 00:56:01 +00:00
parent 61772c26ae
commit 8ea77e5992
11 changed files with 189 additions and 80 deletions

View File

@ -51,6 +51,21 @@ All 4 phases done. Tailwind utility clone deleted, all templates migrated to sem
| 70 | Margin guard on sales (prevent discounts that breach minimum profit threshold) | 69 | 1h | planned | | 70 | Margin guard on sales (prevent discounts that breach minimum profit threshold) | 69 | 1h | planned |
| 71 | Announcement bar (dismissable shop banner for active sales) | 69 | 1.5h | planned | | 71 | Announcement bar (dismissable shop banner for active sales) | 69 | 1.5h | planned |
### Onboarding UX improvements ([plan](docs/plans/onboarding-ux.md))
| # | Task | Priority | Est | Status |
|---|------|----------|-----|--------|
| 1 | Redirect to dashboard after wizard completion with welcome flash | High | 30m | planned |
| 2 | Add shipping setup to checklist, gate "Go live" on shipping existing | High | 1h | planned |
| 3 | Add shop settings to checklist (name, currency) | High | 45m | planned |
| 4 | Make checklist collapsible instead of dismissable | Medium | 15m | planned |
| 5 | Add test order guidance (test card number, what to expect) | Medium | 45m | planned |
| 6 | Skip completed wizard steps on revisit | Medium | 1h | planned |
| 7 | Better provider connection error messages with help links | Medium | 30m | planned |
| 8 | Add theme customisation tips to checklist | Low | 15m | planned |
| 9 | Smarter "Sync products" checklist link | Low | 15m | planned |
| 10 | Add email setup as optional checklist item | Low | 30m | planned |
### Platform site ### Platform site
| # | Task | Depends on | Est | Status | | # | Task | Depends on | Est | Status |
@ -93,4 +108,5 @@ All plans in [docs/plans/](docs/plans/). Completed plans are kept as architectur
| [favicon.md](docs/plans/favicon.md) | Complete | | [favicon.md](docs/plans/favicon.md) | Complete |
| [legal-page-generator.md](docs/plans/legal-page-generator.md) | Complete | | [legal-page-generator.md](docs/plans/legal-page-generator.md) | Complete |
| [url-redirects.md](docs/plans/url-redirects.md) | Complete | | [url-redirects.md](docs/plans/url-redirects.md) | Complete |
| [onboarding-ux.md](docs/plans/onboarding-ux.md) | Planned |
| [profit-aware-pricing.md](docs/plans/profit-aware-pricing.md) | Planned | | [profit-aware-pricing.md](docs/plans/profit-aware-pricing.md) | Planned |

View File

@ -1958,10 +1958,11 @@
.block-card-info { .block-card-info {
flex: 1; flex: 1;
min-width: 0; min-width: 6rem;
} }
.block-card-name { .block-card-name {
display: block;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
@ -1983,6 +1984,7 @@
align-items: center; align-items: center;
gap: 0.125rem; gap: 0.125rem;
flex-shrink: 0; flex-shrink: 0;
margin-left: auto;
} }
.block-remove-btn { .block-remove-btn {
@ -2119,7 +2121,8 @@
.block-card-header { .block-card-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.25rem 0.5rem;
flex-wrap: wrap;
width: 100%; width: 100%;
} }

109
docs/plans/onboarding-ux.md Normal file
View File

@ -0,0 +1,109 @@
# Onboarding UX improvements
Status: Planned
## Context
The setup wizard and dashboard launch checklist work, but there are gaps that leave new users guessing. Goal: make onboarding totally fool-proof and guided so a non-technical seller can go from zero to live store without confusion.
## Current flow
1. **Setup wizard** (`lib/berrypod_web/live/setup/onboarding.ex`): 3 cards — admin account → provider API key → Stripe connection. Gated by secret in prod, auto-login after account creation.
2. **Dashboard checklist** (`lib/berrypod_web/live/admin/dashboard.ex`): 5 items with progress bar — sync products, connect Stripe, customise theme, place test order, go live. Visible when `site_live` is false and not dismissed.
3. **Coming soon page**: blocks public access before launch.
4. **Setup status** (`lib/berrypod/setup.ex`): `setup_status/0` returns booleans for each milestone.
## Gaps identified
### High priority
#### 1. Dead end after setup wizard
The wizard ends at Stripe connection with no redirect or next-steps message. User has to figure out where to go.
**Fix:** After completing the final wizard step, redirect to `/admin` with a flash message like "You're in! Here's your launch checklist." (~30m)
**Files:** `lib/berrypod_web/live/setup/onboarding.ex`
#### 2. No shipping rate setup in checklist
Checkout requires shipping rates. There's no checklist item for configuring shipping, and no warning that checkout will fail without it. A user could "Go live" with no shipping rates.
**Fix:** Add "Set up shipping" checklist item that links to `/admin/settings` (shipping section). Gate "Go live" on at least one shipping profile existing. (~1h)
**Files:** `lib/berrypod_web/live/admin/dashboard.ex`, `lib/berrypod/setup.ex`
#### 3. No shop settings in checklist
Shop name, currency, country are critical but not in the checklist. User could go live with "My Shop" as the name.
**Fix:** Add "Configure your shop" as the first checklist item, linking to `/admin/settings`. Mark complete when shop name differs from default. (~45m)
**Files:** `lib/berrypod_web/live/admin/dashboard.ex`, `lib/berrypod/setup.ex`
### Medium priority
#### 4. Checklist dismissable and gone forever
Once dismissed, no way to get it back. Stored as `checklist_dismissed` setting.
**Fix:** Replace dismiss with a collapse/expand toggle. Or add a "Show launch checklist" link in the dashboard sidebar when dismissed but site isn't live yet. (~15m)
**Files:** `lib/berrypod_web/live/admin/dashboard.ex`, possibly `lib/berrypod_web/components/layouts/admin.html.heex`
#### 5. "Place a test order" has zero guidance
Links to the shop home page. No mention of Stripe test mode, test card numbers, or what a successful test looks like.
**Fix:** Add inline help text under the checklist item: "Use card number 4242 4242 4242 4242 with any future expiry and CVC. You'll see the order appear in Orders when it works." (~45m)
**Files:** `lib/berrypod_web/live/admin/dashboard.ex`
#### 6. Skip completed wizard steps on revisit
If a user closes their browser mid-setup and comes back, the wizard restarts from scratch (session-based). Should detect existing state and skip completed steps.
**Fix:** In `mount/3`, call `Setup.setup_status/0` and set `current_step` to the first incomplete step. (~1h)
**Files:** `lib/berrypod_web/live/setup/onboarding.ex`
#### 7. Better provider connection error messages
If the API key is wrong, the error is generic. No link to where to find the key, no troubleshooting.
**Fix:** Add provider-specific help text: "Find your Printify API key at Printify → Settings → Connections" (with link). Show the actual API error when it's user-facing (auth failure vs server error). (~30m)
**Files:** `lib/berrypod_web/live/setup/onboarding.ex`, `lib/berrypod_web/live/admin/providers.ex`
### Low priority
#### 8. Guide theme customisation
`theme_customised` is true if any setting differs from defaults. Changing one colour counts. No guidance on what to customise.
**Fix:** Add a brief tip under the checklist item: "Upload your logo, pick your colours, and choose a font that matches your brand." (~15m)
**Files:** `lib/berrypod_web/live/admin/dashboard.ex`
#### 9. Clarify "Sync your products" checklist step
Links to `/admin/providers` but user needs to click "Sync" on an already-connected provider. Confusing if they connected during the wizard.
**Fix:** If provider is connected but no products synced, link directly to the provider detail page with a prompt to sync. If products exist, mark as complete. (~15m)
**Files:** `lib/berrypod_web/live/admin/dashboard.ex`
#### 10. Move email setup into checklist as optional step
The orange "email not configured" banner appears on every page but isn't in the checklist. Email is needed for abandoned cart, order confirmations, contact form.
**Fix:** Add as a non-blocking checklist item (doesn't gate "Go live" but shows as recommended). (~30m)
**Files:** `lib/berrypod_web/live/admin/dashboard.ex`, `lib/berrypod/setup.ex`
## Task breakdown
| # | Task | Priority | Est | Status |
|---|------|----------|-----|--------|
| 1 | Redirect to dashboard after wizard completion with welcome flash | High | 30m | planned |
| 2 | Add shipping setup to checklist, gate "Go live" on shipping existing | High | 1h | planned |
| 3 | Add shop settings to checklist (name, currency) | High | 45m | planned |
| 4 | Make checklist collapsible instead of dismissable | Medium | 15m | planned |
| 5 | Add test order guidance (test card number, what to expect) | Medium | 45m | planned |
| 6 | Skip completed wizard steps on revisit | Medium | 1h | planned |
| 7 | Better provider connection error messages with help links | Medium | 30m | planned |
| 8 | Add theme customisation tips to checklist | Low | 15m | planned |
| 9 | Smarter "Sync products" checklist link | Low | 15m | planned |
| 10 | Add email setup as optional checklist item | Low | 30m | planned |
Total estimate: ~5.5h

View File

@ -96,7 +96,6 @@ defmodule Berrypod.Pages.BlockTypes do
%SettingsField{key: "title", label: "Title", type: :text, default: ""}, %SettingsField{key: "title", label: "Title", type: :text, default: ""},
%SettingsField{key: "description", label: "Description", type: :textarea, default: ""}, %SettingsField{key: "description", label: "Description", type: :textarea, default: ""},
%SettingsField{key: "image_id", label: "Image", type: :image, default: nil}, %SettingsField{key: "image_id", label: "Image", type: :image, default: nil},
%SettingsField{key: "image_url", label: "Image URL (legacy)", type: :text, default: ""},
%SettingsField{key: "link_text", label: "Link text", type: :text, default: ""}, %SettingsField{key: "link_text", label: "Link text", type: :text, default: ""},
%SettingsField{key: "link_href", label: "Link URL", type: :text, default: ""} %SettingsField{key: "link_href", label: "Link URL", type: :text, default: ""}
] ]
@ -344,14 +343,7 @@ defmodule Berrypod.Pages.BlockTypes do
allowed_on: :all, allowed_on: :all,
settings_schema: [ settings_schema: [
%SettingsField{key: "content", label: "Content", type: :textarea, default: ""}, %SettingsField{key: "content", label: "Content", type: :textarea, default: ""},
%SettingsField{key: "image_id", label: "Image", type: :image, default: nil}, %SettingsField{key: "image_id", label: "Image", type: :image, default: nil}
%SettingsField{key: "image_src", label: "Image URL (legacy)", type: :text, default: ""},
%SettingsField{
key: "image_alt",
label: "Image alt text (legacy)",
type: :text,
default: ""
}
] ]
}, },

View File

@ -42,9 +42,9 @@ defmodule Berrypod.Pages.Defaults do
defp blocks("home") do defp blocks("home") do
[ [
block("hero", %{ block("hero", %{
"title" => "Original designs, printed on demand", "title" => "Your headline goes here",
"description" => "description" =>
"Welcome to the Berrypod demo store. This is where your hero text goes \u2013 something short and punchy about what makes your shop worth a browse.", "Write something short and punchy about your shop \u2013 what you sell and why it's worth a look.",
"cta_text" => "Shop the collection", "cta_text" => "Shop the collection",
"cta_href" => "/collections/all", "cta_href" => "/collections/all",
"variant" => "default" "variant" => "default"
@ -55,11 +55,10 @@ defmodule Berrypod.Pages.Defaults do
"product_count" => 8 "product_count" => 8
}), }),
block("image_text", %{ block("image_text", %{
"title" => "Made with passion, printed with care", "title" => "Your story in a nutshell",
"description" => "description" =>
"This is an example content section. Use it to share your story, highlight what makes your products special, or link to your about page.", "Use this section to share what makes your products special, your creative process, or a bit about you. Pick an image from your media library to go alongside it.",
"image_url" => "/mockups/mountain-sunrise-print-3-800.webp", "link_text" => "Read more about us \u2192",
"link_text" => "Learn more about the studio \u2192",
"link_href" => "/about" "link_href" => "/about"
}) })
] ]
@ -68,13 +67,14 @@ defmodule Berrypod.Pages.Defaults do
defp blocks("about") do defp blocks("about") do
[ [
block("hero", %{ block("hero", %{
"title" => "About the studio", "title" => "About us",
"description" => "Your story goes here \u2013 this is sample content for the demo shop", "description" => "Share a bit about who you are and what you do",
"variant" => "sunken" "variant" => "sunken"
}), }),
block("content_body", %{ block("content_body", %{
"image_src" => "/mockups/night-sky-blanket-3", "content" =>
"image_alt" => "Night sky blanket draped over a chair" "Tell your customers who you are and what inspired your shop. What's the story behind your designs?\n\n" <>
"This is placeholder text \u2013 edit it to make it your own. You can also add an image from your media library using the block settings."
}) })
] ]
end end
@ -116,8 +116,7 @@ defmodule Berrypod.Pages.Defaults do
[ [
block("hero", %{ block("hero", %{
"title" => "Get in touch", "title" => "Get in touch",
"description" => "description" => "Add a friendly message about how customers can reach you",
"Sample contact page for the demo store. Add your own message here \u2013 something friendly about how customers can reach you.",
"variant" => "page" "variant" => "page"
}), }),
block("contact_form", %{"email" => "hello@example.com"}), block("contact_form", %{"email" => "hello@example.com"}),
@ -125,9 +124,9 @@ defmodule Berrypod.Pages.Defaults do
block("info_card", %{ block("info_card", %{
"title" => "Handy to know", "title" => "Handy to know",
"items" => [ "items" => [
%{"label" => "Printing", "value" => "Example: 2-5 business days"}, %{"label" => "Printing", "value" => "Update with your printing times"},
%{"label" => "Delivery", "value" => "Example: 3-7 business days after printing"}, %{"label" => "Delivery", "value" => "Update with your delivery times"},
%{"label" => "Issues", "value" => "Example: Reprints for any defects"} %{"label" => "Issues", "value" => "Update with your returns policy"}
] ]
}), }),
block("newsletter_card"), block("newsletter_card"),

View File

@ -112,15 +112,12 @@ defmodule BerrypodWeb.PageRenderer do
> >
Reset Reset
</button> </button>
<button phx-click="editor_done" class="admin-btn admin-btn-sm admin-btn-ghost">
Done
</button>
<button <button
phx-click="editor_toggle_sidebar" phx-click="editor_done"
class="admin-btn admin-btn-sm admin-btn-ghost" class="admin-btn admin-btn-sm admin-btn-ghost"
aria-label="Close sidebar" data-confirm={@editor_dirty && "You have unsaved changes. Leave without saving?"}
> >
<.icon name="hero-x-mark" class="size-5" /> Done
</button> </button>
</div> </div>
</div> </div>
@ -280,7 +277,7 @@ defmodule BerrypodWeb.PageRenderer do
defp render_block(%{block: %{"type" => "image_text"}} = assigns) do defp render_block(%{block: %{"type" => "image_text"}} = assigns) do
settings = assigns.block["settings"] || %{} settings = assigns.block["settings"] || %{}
image_url = resolve_block_image_url(settings["image_id"], settings["image_url"]) image_url = resolve_block_image_url(settings["image_id"])
assigns = assigns =
assigns assigns
@ -634,11 +631,12 @@ defmodule BerrypodWeb.PageRenderer do
defp render_block(%{block: %{"type" => "content_body"}} = assigns) do defp render_block(%{block: %{"type" => "content_body"}} = assigns) do
settings = assigns.block["settings"] || %{} settings = assigns.block["settings"] || %{}
content = settings["content"] || "" content = settings["content"] || ""
{image_src, image_alt} = resolve_content_image(settings) {image_src, source_width, image_alt} = resolve_content_image(settings)
assigns = assigns =
assigns assigns
|> assign(:image_src, image_src) |> assign(:image_src, image_src)
|> assign(:image_source_width, source_width || 1200)
|> assign(:image_alt, image_alt) |> assign(:image_alt, image_alt)
|> assign(:content, content) |> assign(:content, content)
@ -648,7 +646,7 @@ defmodule BerrypodWeb.PageRenderer do
<div class="content-image"> <div class="content-image">
<.responsive_image <.responsive_image
src={@image_src} src={@image_src}
source_width={1200} source_width={@image_source_width}
alt={@image_alt} alt={@image_alt}
sizes="(max-width: 800px) 100vw, 800px" sizes="(max-width: 800px) 100vw, 800px"
class="content-hero-image" class="content-hero-image"
@ -1154,47 +1152,41 @@ defmodule BerrypodWeb.PageRenderer do
defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}" defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}"
defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}" defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}"
# Resolves an image_id to a URL, falling back to a legacy URL string # Resolves an image_id to a full URL for blocks that need a single URL (e.g. background-image).
defp resolve_block_image_url(image_id, fallback_url) do defp resolve_block_image_url(image_id) do
case resolve_image(image_id) do case resolve_image(image_id) do
{url, _alt} -> url nil ->
nil -> fallback_url || "" ""
image ->
if image.is_svg do
"/image_cache/#{image.id}.webp"
else
width =
image.source_width
|> Berrypod.Images.Optimizer.applicable_widths()
|> List.last()
"/image_cache/#{image.id}-#{width || 400}.webp"
end
end end
end end
# Resolves image_id for content_body blocks, returning {src, alt} # Resolves image_id for content_body blocks, returning {base_path, source_width, alt}.
# base_path has no width/extension suffix — responsive_image adds those.
defp resolve_content_image(settings) do defp resolve_content_image(settings) do
case resolve_image(settings["image_id"]) do case resolve_image(settings["image_id"]) do
{src, alt} -> {src, alt} nil ->
nil -> {settings["image_src"], settings["image_alt"] || ""} {nil, nil, ""}
image ->
{"/image_cache/#{image.id}", image.source_width, image.alt || image.filename}
end end
end end
defp resolve_image(nil), do: nil defp resolve_image(nil), do: nil
defp resolve_image(""), do: nil defp resolve_image(""), do: nil
defp resolve_image(image_id), do: Berrypod.Media.get_image(image_id)
defp resolve_image(image_id) do
case Berrypod.Media.get_image(image_id) do
nil ->
nil
image ->
url =
if image.is_svg do
"/image_cache/#{image.id}.webp"
else
# Pick the largest variant that was actually generated
width =
image.source_width
|> Berrypod.Images.Optimizer.applicable_widths()
|> List.last()
"/image_cache/#{image.id}-#{width || 400}.webp"
end
{url, image.alt || image.filename}
end
end
@youtube_re ~r/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/ @youtube_re ~r/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
@vimeo_re ~r/vimeo\.com\/(?:video\/)?(\d+)/ @vimeo_re ~r/vimeo\.com\/(?:video\/)?(\d+)/

View File

@ -11,6 +11,6 @@ defmodule BerrypodWeb.PageControllerTest do
test "GET / renders the shop home page", %{conn: conn} do test "GET / renders the shop home page", %{conn: conn} do
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Original designs, printed on demand" assert html_response(conn, 200) =~ "Your headline goes here"
end end
end end

View File

@ -14,14 +14,14 @@ defmodule BerrypodWeb.Shop.ContentTest do
test "renders about page", %{conn: conn} do test "renders about page", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/about") {:ok, _view, html} = live(conn, ~p"/about")
assert html =~ "About the studio" assert html =~ "About us"
assert html =~ "sample about page" assert html =~ "Tell your customers who you are"
end end
test "displays about image", %{conn: conn} do test "displays about content body", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/about") {:ok, _view, html} = live(conn, ~p"/about")
assert html =~ "night-sky-blanket" assert html =~ "placeholder text"
end end
end end

View File

@ -30,7 +30,7 @@ defmodule BerrypodWeb.Shop.HomeTest do
test "renders the home page", %{conn: conn} do test "renders the home page", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/") {:ok, _view, html} = live(conn, ~p"/")
assert html =~ "Original designs, printed on demand" assert html =~ "Your headline goes here"
end end
test "renders hero section with CTA", %{conn: conn} do test "renders hero section with CTA", %{conn: conn} do
@ -55,8 +55,8 @@ defmodule BerrypodWeb.Shop.HomeTest do
test "renders image and text section", %{conn: conn} do test "renders image and text section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/") {:ok, _view, html} = live(conn, ~p"/")
assert html =~ "Made with passion, printed with care" assert html =~ "Your story in a nutshell"
assert html =~ "Learn more about the studio" assert html =~ "Read more about us"
end end
test "renders header with shop name", %{conn: conn} do test "renders header with shop name", %{conn: conn} do

View File

@ -90,10 +90,8 @@ defmodule BerrypodWeb.PageEditorHookTest do
"button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']" "button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']"
) )
# Close the sidebar via the X button # Close the sidebar via the backdrop
view view |> element(".page-editor-backdrop") |> render_click()
|> element("button[phx-click='editor_toggle_sidebar'][aria-label='Close sidebar']")
|> render_click()
assert has_element?(view, "[data-sidebar-open='false']") assert has_element?(view, "[data-sidebar-open='false']")
# Pencil button appears in header to re-open # Pencil button appears in header to re-open

View File

@ -49,17 +49,17 @@ defmodule BerrypodWeb.PageRendererTest do
test "home page renders hero and featured products" do test "home page renders hero and featured products" do
html = render_page("home", %{products: []}) html = render_page("home", %{products: []})
assert html =~ "Original designs, printed on demand" assert html =~ "Your headline goes here"
assert html =~ "Shop the collection" assert html =~ "Shop the collection"
assert html =~ "Featured products" assert html =~ "Featured products"
assert html =~ "Made with passion, printed with care" assert html =~ "Your story in a nutshell"
end end
test "about page renders hero and content area" do test "about page renders hero and content area" do
html = html =
render_page("about", %{content_blocks: [%{type: :paragraph, text: "Test about text"}]}) render_page("about", %{content_blocks: [%{type: :paragraph, text: "Test about text"}]})
assert html =~ "About the studio" assert html =~ "About us"
assert html =~ "content-body" assert html =~ "content-body"
end end